211 lines
9.6 KiB
TypeScript
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 },
|
|
})
|