/** * 기능 #51 — QR 스캔 → 자산 조회 * * QR 내용(server_id / asset_id) → GET /api/cmdb/servers/{id} * 표시: 서버명, 모델, OS, 위치, 상태만. ip_addr/ssh_user/os_pw_enc 절대 표시 금지. * 결과 카드 + [SR 접수] [체크인] [사진 촬영] 액션 버튼. * * expo-camera 미설치 환경 대비 — 동적 require + 토큰 수동 입력 폴백. */ import { useState } from 'react' import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, TextInput, ActivityIndicator, } from 'react-native' import { router } from 'expo-router' import { COLORS, API_BASE, STATUS_COLOR } from '../../constants/Config' import { getToken } from '../../utils/auth' import { sanitizeAsset } from '../../utils/security' interface AssetInfo { server_id: number server_name: string model?: string os_name?: string location?: string status?: string last_checked?: string owner?: string } function loadCamera(): any | null { try { return require('expo-camera') } catch { return null } } export default function QrScanTab() { const [mode, setMode] = useState<'qr' | 'manual'>('qr') const [assetId, setAssetId] = useState('') const [loading, setLoading] = useState(false) const [asset, setAsset] = useState(null) const cameraMod = loadCamera() async function lookup(id: string) { const clean = id.trim() if (!clean) return setLoading(true); setAsset(null) try { const jwt = await getToken() const res = await fetch(`${API_BASE}/api/cmdb/servers/${encodeURIComponent(clean)}`, { headers: { Authorization: `Bearer ${jwt}` }, }) if (!res.ok) { const e = await res.json().catch(() => ({})) Alert.alert('조회 실패', e.detail || '자산을 찾을 수 없습니다') return } const raw = await res.json() // 방어적 sanitize — 응답에 민감정보가 있어도 화면 상태에 담지 않음 setAsset(sanitizeAsset(raw) as AssetInfo) } catch (e: any) { Alert.alert('오류', e?.message || '서버 연결 실패') } finally { setLoading(false) } } const statusColor = (s?: string) => STATUS_COLOR[s ?? ''] || COLORS.muted return ( QR 자산 조회 서버 라벨 QR을 스캔하여 CMDB 정보를 조회합니다 {[{ id: 'qr', label: 'QR 스캔' }, { id: 'manual', label: '자산 ID 입력' }].map((t) => ( setMode(t.id as any)} style={[S.tab, mode === t.id && S.tabActive]}> {t.label} ))} {mode === 'qr' ? ( 📷 {cameraMod ? 'QR 코드를 사각형 안에 맞추세요' : 'QR 스캔은 EAS 빌드 앱에서 동작합니다'} { if (!cameraMod) { Alert.alert('QR 스캔', 'EAS 빌드 앱에서 사용 가능합니다. 자산 ID 직접 입력을 이용하세요.', [{ text: '자산 ID 입력', onPress: () => setMode('manual') }, { text: '확인' }]) return } Alert.alert('스캔', 'expo-camera CameraView 활성화 — 스캔된 server_id로 조회됩니다') }}> QR 스캔 시작 ) : ( 자산 ID (server_id) lookup(assetId)} /> lookup(assetId)}> 조회 )} {loading && ( 조회 중... )} {asset && !loading && ( {asset.server_name} {!!asset.model && {asset.model}} {asset.status || 'UNKNOWN'} {[ { label: 'OS', value: asset.os_name || '미지정' }, { label: '위치', value: asset.location || '미지정' }, { label: '담당자', value: asset.owner || '미지정' }, { label: '마지막 점검', value: asset.last_checked ? new Date(asset.last_checked).toLocaleDateString('ko-KR') : '기록 없음' }, ].map((it) => ( {it.label} {it.value} ))} {/* IP/SSH 정보는 의도적으로 미표시 (보안 원칙) */} 🔒 IP·접속계정 정보는 보안상 표시되지 않습니다 router.push({ pathname: '/(tabs)/sr', params: { server_id: String(asset.server_id) } })}> SR 접수 router.push({ pathname: '/(tabs)/field_checkin', params: { server_id: String(asset.server_id), name: asset.server_name } })}> 체크인 router.push({ pathname: '/(tabs)/equipment_photo', params: { server_id: String(asset.server_id), name: asset.server_name } })}> 사진 촬영 )} ) } const S = StyleSheet.create({ root: { flex: 1, backgroundColor: COLORS.bg }, header: { padding: 20, paddingBottom: 12, backgroundColor: COLORS.primary }, title: { fontSize: 20, fontWeight: '800', color: '#fff' }, subtitle: { fontSize: 12, color: 'rgba(255,255,255,0.7)', marginTop: 4 }, tabs: { flexDirection: 'row', backgroundColor: '#fff', borderBottomWidth: 1, borderColor: COLORS.border }, tab: { flex: 1, padding: 12, alignItems: 'center', borderBottomWidth: 2, borderColor: 'transparent' }, tabActive: { borderColor: COLORS.primary }, tabText: { fontSize: 13, color: COLORS.muted }, tabTextActive: { color: COLORS.primary, fontWeight: '700' }, card: { margin: 12, marginBottom: 0, backgroundColor: '#fff', borderRadius: 12, padding: 16, borderWidth: 1, borderColor: COLORS.border }, qrBox: { alignItems: 'center', paddingVertical: 28, backgroundColor: COLORS.bg, borderRadius: 8, marginBottom: 12 }, qrHint: { color: COLORS.muted, textAlign: 'center', marginTop: 8, fontSize: 12 }, btn: { backgroundColor: COLORS.primary, borderRadius: 8, padding: 12, alignItems: 'center', marginTop: 8 }, btnText: { color: '#fff', fontWeight: '700', fontSize: 14 }, row: { flexDirection: 'row', alignItems: 'center' }, input: { borderWidth: 1, borderColor: COLORS.border, borderRadius: 8, padding: 10, backgroundColor: COLORS.bg, color: COLORS.text, fontSize: 14 }, fieldLabel: { fontSize: 12, fontWeight: '600', color: '#374151', marginBottom: 6 }, assetHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }, assetName: { fontSize: 18, fontWeight: '800', color: COLORS.primary }, assetMeta: { fontSize: 12, color: COLORS.muted, marginTop: 2 }, badge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12 }, badgeText: { fontSize: 11, fontWeight: '700' }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1, borderColor: '#f1f5f9' }, infoLabel: { fontSize: 12, color: COLORS.muted }, infoValue: { fontSize: 13, fontWeight: '600', color: COLORS.text }, secNote: { fontSize: 11, color: '#94a3b8', marginTop: 10, fontStyle: 'italic' }, actions: { flexDirection: 'row', gap: 8, marginTop: 14 }, actBtn: { flex: 1, borderRadius: 8, paddingVertical: 11, alignItems: 'center' }, actText: { color: '#fff', fontWeight: '700', fontSize: 12 }, })