sync: update from workspace (latest ITSM/CICD/DR changes)
This commit is contained in:
parent
b851a2f79b
commit
43a65b5015
@ -91,6 +91,13 @@ export default function TabLayout() {
|
|||||||
tabBarIcon: ({ focused }) => <TabIcon icon="🧠" label="AI" focused={focused} />,
|
tabBarIcon: ({ focused }) => <TabIcon icon="🧠" label="AI" focused={focused} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="scan"
|
||||||
|
options={{
|
||||||
|
title: 'QR 스캔',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon="📷" label="QR" focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
211
app/(tabs)/scan.tsx
Normal file
211
app/(tabs)/scan.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
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' },
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user