guardia-messenger/app/(tabs)/qr_scan.tsx

211 lines
9.6 KiB
TypeScript

/**
* 기능 #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<AssetInfo | null>(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 (
<ScrollView style={S.root} contentContainerStyle={{ paddingBottom: 40 }}>
<View style={S.header}>
<Text style={S.title}>QR </Text>
<Text style={S.subtitle}> QR을 CMDB </Text>
</View>
<View style={S.tabs}>
{[{ id: 'qr', label: 'QR 스캔' }, { id: 'manual', label: '자산 ID 입력' }].map((t) => (
<TouchableOpacity key={t.id} onPress={() => setMode(t.id as any)}
style={[S.tab, mode === t.id && S.tabActive]}>
<Text style={[S.tabText, mode === t.id && S.tabTextActive]}>{t.label}</Text>
</TouchableOpacity>
))}
</View>
{mode === 'qr' ? (
<View style={S.card}>
<View style={S.qrBox}>
<Text style={{ fontSize: 52 }}>📷</Text>
<Text style={S.qrHint}>
{cameraMod
? 'QR 코드를 사각형 안에 맞추세요'
: 'QR 스캔은 EAS 빌드 앱에서 동작합니다'}
</Text>
</View>
<TouchableOpacity style={S.btn} onPress={() => {
if (!cameraMod) {
Alert.alert('QR 스캔', 'EAS 빌드 앱에서 사용 가능합니다. 자산 ID 직접 입력을 이용하세요.',
[{ text: '자산 ID 입력', onPress: () => setMode('manual') }, { text: '확인' }])
return
}
Alert.alert('스캔', 'expo-camera CameraView 활성화 — 스캔된 server_id로 조회됩니다')
}}>
<Text style={S.btnText}>QR </Text>
</TouchableOpacity>
</View>
) : (
<View style={S.card}>
<Text style={S.fieldLabel}> ID (server_id)</Text>
<View style={S.row}>
<TextInput
style={[S.input, { flex: 1 }]}
value={assetId}
onChangeText={setAssetId}
placeholder="예: 1024"
placeholderTextColor="#94a3b8"
keyboardType="number-pad"
onSubmitEditing={() => lookup(assetId)}
/>
<TouchableOpacity style={[S.btn, { marginTop: 0, marginLeft: 8, paddingHorizontal: 18 }]}
onPress={() => lookup(assetId)}>
<Text style={S.btnText}></Text>
</TouchableOpacity>
</View>
</View>
)}
{loading && (
<View style={[S.card, { alignItems: 'center', padding: 24 }]}>
<ActivityIndicator color={COLORS.accent} size="large" />
<Text style={{ marginTop: 8, color: COLORS.muted }}> ...</Text>
</View>
)}
{asset && !loading && (
<View style={S.card}>
<View style={S.assetHead}>
<View style={{ flex: 1 }}>
<Text style={S.assetName}>{asset.server_name}</Text>
{!!asset.model && <Text style={S.assetMeta}>{asset.model}</Text>}
</View>
<View style={[S.badge, { backgroundColor: statusColor(asset.status) + '22' }]}>
<Text style={[S.badgeText, { color: statusColor(asset.status) }]}>{asset.status || 'UNKNOWN'}</Text>
</View>
</View>
{[
{ 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) => (
<View key={it.label} style={S.infoRow}>
<Text style={S.infoLabel}>{it.label}</Text>
<Text style={S.infoValue}>{it.value}</Text>
</View>
))}
{/* IP/SSH 정보는 의도적으로 미표시 (보안 원칙) */}
<Text style={S.secNote}>🔒 IP· </Text>
<View style={S.actions}>
<TouchableOpacity style={[S.actBtn, { backgroundColor: COLORS.primary }]}
onPress={() => router.push({ pathname: '/(tabs)/sr', params: { server_id: String(asset.server_id) } })}>
<Text style={S.actText}>SR </Text>
</TouchableOpacity>
<TouchableOpacity style={[S.actBtn, { backgroundColor: COLORS.blue }]}
onPress={() => router.push({ pathname: '/(tabs)/field_checkin', params: { server_id: String(asset.server_id), name: asset.server_name } })}>
<Text style={S.actText}></Text>
</TouchableOpacity>
<TouchableOpacity style={[S.actBtn, { backgroundColor: COLORS.accent }]}
onPress={() => router.push({ pathname: '/(tabs)/equipment_photo', params: { server_id: String(asset.server_id), name: asset.server_name } })}>
<Text style={S.actText}> </Text>
</TouchableOpacity>
</View>
</View>
)}
</ScrollView>
)
}
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 },
})