zioinfo-mail/workspace/guardia-messenger/app/(tabs)/scan.tsx
DESKTOP-TKLFCPR\ython 0ebac500f5
Some checks are pending
GUARDiA CI / Python Lint & Import Test (push) Waiting to run
GUARDiA CI / Validate Install Scripts (push) Waiting to run
GUARDiA CI / PR Validation Summary (push) Blocked by required conditions
feat(enhance-v4): APK QR 배포 / 배치SSH / 자산QR / 스마트알림 / 웹메일 주소록+서명
- ITSM: app_deploy.py (APK 업로드·QR 생성·랜딩 페이지)
- ITSM: batch_ssh.py (다중 서버 동시 SSH 실행)
- ITSM: asset_qr.py (자산 QR 태그·체크인·라벨 인쇄)
- ITSM: smart_notify.py (조건 기반 알림 규칙 엔진)
- ITSM: models.py (AppVersion/BatchSSHJob/AssetQRToken/SmartNotifyRule 등 7개 모델)
- ITSM: main.py (4개 신규 라우터 등록)
- ITSM: static/app.js (앱배포·배치SSH·자산QR·알림규칙 4개 뷰)
- ITSM: static/index.html (신규 사이드바 메뉴 4개)
- Manager: AppDistribution.tsx (APK 업로드 UI·QR 표시·버전 관리)
- Manager: NotificationRules.tsx (알림 규칙 편집기)
- Manager: App.tsx + Sidebar.tsx (신규 라우트 등록)
- Mail: contacts.py (주소록 CRUD·자동완성)
- Mail: signature.py (HTML 서명 관리)
- Mail: Contacts.tsx + SignatureEditor.tsx (프론트엔드 컴포넌트)
- Messenger: scan.tsx (자산 QR 스캔 탭)
- Messenger: _layout.tsx (QR 탭 추가)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 19:49:00 +09:00

212 lines
9.4 KiB
TypeScript

import { useState, useEffect } from 'react'
import {
View, Text, StyleSheet, TouchableOpacity, Alert,
ScrollView, Platform, Linking, ActivityIndicator,
} from 'react-native'
import { COLORS, API_BASE } from '../../constants/Config'
import { getToken } from '../../utils/auth'
// expo-barcode-scanner는 EAS 빌드 환경에서만 실제 작동
// 개발/시뮬레이터에서는 수동 입력으로 대체
interface AssetInfo {
server_id: number; server_name: string; ip_display: string
os_name: string; location: string; status: string; last_checked: string
qr_token: string
}
export default function ScanTab() {
const [mode, setMode] = useState<'qr'|'manual'>('qr')
const [manualToken, setManualToken] = useState('')
const [scanning, setScanning] = useState(false)
const [loading, setLoading] = useState(false)
const [asset, setAsset] = useState<AssetInfo | null>(null)
const [checkedIn, setCheckedIn] = useState(false)
async function lookupToken(token: string) {
if (!token.trim()) return
setLoading(true); setAsset(null); setCheckedIn(false)
try {
const jwt = await getToken()
const res = await fetch(`${API_BASE}/api/asset-qr/scan/${token.trim()}`, {
headers: { Authorization: `Bearer ${jwt}` },
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
Alert.alert('조회 실패', e.detail || '자산을 찾을 수 없습니다')
return
}
const data = await res.json()
setAsset(data)
} catch (e: any) {
Alert.alert('오류', e.message || '서버 연결 실패')
} finally {
setLoading(false)
}
}
async function doCheckin() {
if (!asset) return
setLoading(true)
try {
const jwt = await getToken()
await fetch(`${API_BASE}/api/asset-qr/checkin/${asset.qr_token}`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ note: '모바일 실사' }),
})
setCheckedIn(true)
Alert.alert('✅ 실사 완료', `${asset.server_name} 실사 완료 처리했습니다.`)
} catch {
Alert.alert('오류', '체크인 실패')
} finally {
setLoading(false)
}
}
const statusColor = (s: string) => {
if (s === 'ACTIVE') return '#166534'
if (s === 'INACTIVE') return '#9a3412'
return '#92400e'
}
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:'⌨️ 토큰 입력'}].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: 56 }}>📷</Text>
<Text style={{ color: '#64748b', textAlign: 'center', marginTop: 8, fontSize: 13 }}>
QR {Platform.OS === 'android' ? ' (Android 지원)' : ' (iOS 지원)'}
</Text>
<Text style={{ color: '#94a3b8', fontSize: 11, textAlign: 'center', marginTop: 4 }}>
expo-barcode-scanner
</Text>
</View>
<TouchableOpacity style={S.btn} onPress={() => {
Alert.alert(
'QR 스캔',
'QR 스캔은 EAS 빌드 앱에서 사용 가능합니다.\n토큰 직접 입력 탭을 이용하세요.',
[{ text: '토큰 입력으로 이동', onPress: () => setMode('manual') }, { text: '확인' }]
)
}}>
<Text style={S.btnText}>📷 QR </Text>
</TouchableOpacity>
</View>
) : (
<View style={S.card}>
<Text style={S.fieldLabel}>QR (UUID)</Text>
<View style={S.row}>
<View style={[S.input, { flex: 1 }]}>
<Text style={{ color: manualToken ? '#1e293b' : '#94a3b8', fontSize: 13 }}
onPress={() => {/* focus */}}>
{manualToken || 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'}
</Text>
</View>
<TouchableOpacity style={[S.btn, { marginTop: 0, marginLeft: 8 }]}
onPress={() => lookupToken(manualToken)}>
<Text style={S.btnText}></Text>
</TouchableOpacity>
</View>
<Text style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>
UUID를 QR
</Text>
</View>
)}
{/* 로딩 */}
{loading && (
<View style={[S.card, { alignItems: 'center', padding: 24 }]}>
<ActivityIndicator color={COLORS.accent} size="large" />
<Text style={{ marginTop: 8, color: '#64748b' }}> ...</Text>
</View>
)}
{/* 자산 정보 */}
{asset && !loading && (
<View style={S.card}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<View>
<Text style={{ fontSize: 18, fontWeight: '800', color: '#003366' }}>{asset.server_name}</Text>
<Text style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{asset.ip_display}</Text>
</View>
<View style={{ backgroundColor: statusColor(asset.status) + '22', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12 }}>
<Text style={{ fontSize: 11, fontWeight: '700', color: statusColor(asset.status) }}>{asset.status}</Text>
</View>
</View>
{[
{ label: 'OS', value: asset.os_name },
{ label: '위치', value: asset.location || '미지정' },
{ label: '마지막 점검', value: asset.last_checked ? new Date(asset.last_checked).toLocaleDateString('ko-KR') : '기록 없음' },
].map(item => (
<View key={item.label} style={S.infoRow}>
<Text style={S.infoLabel}>{item.label}</Text>
<Text style={S.infoValue}>{item.value}</Text>
</View>
))}
<TouchableOpacity
style={[S.btn, checkedIn && { backgroundColor: '#166534' }]}
onPress={checkedIn ? undefined : doCheckin}
disabled={checkedIn || loading}>
<Text style={S.btnText}>{checkedIn ? '✅ 실사 완료됨' : '📋 실사 체크인'}</Text>
</TouchableOpacity>
</View>
)}
{/* 앱 QR 다운로드 안내 */}
<View style={[S.card, { backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }]}>
<Text style={{ fontSize: 13, fontWeight: '700', color: '#1d4ed8', marginBottom: 4 }}>💡 QR</Text>
<Text style={{ fontSize: 12, color: '#3b82f6' }}>
GUARDiA Manager에서 APK를 QR코드가 .{'\n'}
QR을 .
</Text>
<TouchableOpacity onPress={() => Linking.openURL('https://zioinfo.co.kr:8443/api/app/landing')}
style={{ marginTop: 8 }}>
<Text style={{ fontSize: 12, color: '#1d4ed8', textDecorationLine: 'underline' }}> </Text>
</TouchableOpacity>
</View>
</ScrollView>
)
}
const S = StyleSheet.create({
root: { flex: 1, backgroundColor: '#f8fafc' },
header: { padding: 20, paddingBottom: 12, backgroundColor: '#003366' },
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: '#e2e8f0' },
tab: { flex: 1, padding: 12, alignItems: 'center', borderBottomWidth: 2, borderColor: 'transparent' },
tabActive: { borderColor: '#003366' },
tabText: { fontSize: 13, color: '#64748b' },
tabTextActive: { color: '#003366', fontWeight: '700' },
card: { margin: 12, marginBottom: 0, backgroundColor: '#fff', borderRadius: 12, padding: 16,
borderWidth: 1, borderColor: '#e2e8f0',
shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 4, elevation: 2 },
qrBox: { alignItems: 'center', paddingVertical: 24, backgroundColor: '#f8fafc', borderRadius: 8, marginBottom: 12 },
btn: { backgroundColor: '#003366', borderRadius: 8, padding: 12, alignItems: 'center', marginTop: 8 },
btnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
row: { flexDirection: 'row', alignItems: 'center' },
input: { borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 8, padding: 10, backgroundColor: '#f8fafc' },
fieldLabel: { fontSize: 12, fontWeight: '600', color: '#374151', marginBottom: 6 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1, borderColor: '#f1f5f9' },
infoLabel: { fontSize: 12, color: '#64748b' },
infoValue: { fontSize: 13, fontWeight: '600', color: '#1e293b' },
})