fix: guardia-messenger 프로젝트 경로 정상화 (iamConductor-messenger → guardia-messenger) [auto-sync]

This commit is contained in:
GUARDiA AutoDeploy 2026-06-08 00:08:59 +09:00 committed by DESKTOP-TKLFCPR\ython
parent 8e5a5b7e6f
commit 6ab1796f97
641 changed files with 15292 additions and 9092 deletions

View File

@ -1,16 +1,60 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import {
View, Text, TextInput, TouchableOpacity, StyleSheet,
KeyboardAvoidingView, Platform, ActivityIndicator, Alert, ScrollView,
} from 'react-native'
import { useRouter } from 'expo-router'
import * as LocalAuthentication from 'expo-local-authentication'
import * as SecureStore from 'expo-secure-store'
import { useAuth } from '../../hooks/useAuth'
import { COLORS } from '../../constants/Config'
import { recordActivity } from '../../hooks/useSessionExpiry'
export default function LoginScreen() {
const { login } = useAuth()
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [bioAvailable, setBioAvailable] = useState(false) // #29 토큰이 있을 때만 노출
/* #29 SecureStore에 토큰이 있고 생체 하드웨어가 등록된 경우에만 버튼 표시 */
useEffect(() => {
;(async () => {
try {
const token = await SecureStore.getItemAsync('grd_token')
const hasHardware = await LocalAuthentication.hasHardwareAsync()
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
setBioAvailable(!!token && hasHardware && isEnrolled)
} catch {
setBioAvailable(false)
}
})()
}, [])
/* #29 생체인증 로그인 */
const biometricLogin = async () => {
const hasHardware = await LocalAuthentication.hasHardwareAsync()
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
if (!hasHardware || !isEnrolled) {
Alert.alert('생체인증 불가', '지문/Face ID가 등록되지 않았습니다.')
return
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'GUARDiA 로그인',
cancelLabel: '취소',
fallbackLabel: '비밀번호 사용',
})
if (result.success) {
const token = await SecureStore.getItemAsync('grd_token')
if (token) {
await recordActivity()
router.replace('/(tabs)')
} else {
Alert.alert('재로그인 필요', '저장된 인증 정보가 없습니다. 비밀번호로 로그인해주세요.')
}
}
}
const handleLogin = async () => {
if (!username.trim() || !password.trim()) {
@ -20,6 +64,7 @@ export default function LoginScreen() {
setLoading(true)
try {
await login(username.trim(), password)
await recordActivity() // #31 로그인 시점을 마지막 활동으로 기록
} catch (e: any) {
const msg = e.response?.data?.detail ?? '로그인에 실패했습니다.'
Alert.alert('로그인 실패', msg)
@ -87,6 +132,13 @@ export default function LoginScreen() {
}
</TouchableOpacity>
{bioAvailable && (
<TouchableOpacity style={s.bioBtn} onPress={biometricLogin}>
<Text style={s.bioIcon}>👆</Text>
<Text style={s.bioText}> </Text>
</TouchableOpacity>
)}
<Text style={s.hint}>GUARDiA ITSM </Text>
</View>
@ -135,6 +187,13 @@ const s = StyleSheet.create({
},
btnDisabled: { opacity: .6 },
btnText: { color: '#fff', fontSize: 16, fontWeight: '800', letterSpacing: 0.3 },
bioBtn: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
marginTop: 12, padding: 14, borderRadius: 12,
borderWidth: 1.5, borderColor: '#00A0C8', backgroundColor: 'rgba(0,160,200,.06)',
},
bioIcon: { fontSize: 18 },
bioText: { color: '#00A0C8', fontSize: 15, fontWeight: '700' },
hint: { textAlign: 'center', color: '#64748B', fontSize: 12, marginTop: 16 },
version: { textAlign: 'center', color: 'rgba(0,160,200,.4)', fontSize: 11, marginTop: 24 },
})

View File

@ -98,6 +98,20 @@ export default function TabLayout() {
tabBarIcon: ({ focused }) => <TabIcon icon="🎤" label="음성" focused={focused} />,
}}
/>
<Tabs.Screen
name="meeting"
options={{
title: '회의 녹음',
tabBarIcon: ({ focused }) => <TabIcon icon="🎙️" label="회의" focused={focused} />,
}}
/>
<Tabs.Screen
name="meeting_sr"
options={{
title: '액션→SR',
href: null,
}}
/>
<Tabs.Screen
name="scan"
options={{
@ -112,6 +126,114 @@ export default function TabLayout() {
tabBarIcon: ({ focused }) => <TabIcon icon="⚙️" label="설정" focused={focused} />,
}}
/>
{/* ── 승인·워크플로우 (#63~#70) — 탭바 미노출 ── */}
<Tabs.Screen name="approval" options={{ title: '승인 관리', href: null }} />
<Tabs.Screen name="delegation" options={{ title: '대리결재', href: null }} />
<Tabs.Screen name="change_calendar" options={{ title: '변경 캘린더', href: null }} />
<Tabs.Screen name="automation_rules" options={{ title: '자동화 규칙', href: null }} />
<Tabs.Screen name="sla_exception" options={{ title: 'SLA 예외', href: null }} />
{/* ── 지식베이스·문서 (#71~#77) ── */}
<Tabs.Screen name="kb_browser" options={{ title: '지식베이스', href: null }} />
<Tabs.Screen name="meeting_minutes" options={{ title: '회의록', href: null }} />
<Tabs.Screen name="release_notes" options={{ title: '릴리즈노트', href: null }} />
{/* ── 준수·거버넌스 (#78~#84) ── */}
<Tabs.Screen name="csap_dashboard" options={{ title: 'CSAP', href: null }} />
<Tabs.Screen name="audit_log" options={{ title: '감사로그', href: null }} />
<Tabs.Screen name="pii_status" options={{ title: 'PII/패치', href: null }} />
<Tabs.Screen name="ai_soc" options={{ title: 'AI-SOC', href: null }} />
{/* ── UX·접근성 (#85~#92) ── */}
<Tabs.Screen name="theme_settings" options={{ title: 'UX 설정', href: null }} />
<Tabs.Screen name="accessibility" options={{ title: '접근성', href: null }} />
{/* ── 통계·보고 (#93~#97) ── */}
<Tabs.Screen name="my_stats" options={{ title: '나의 통계', href: null }} />
<Tabs.Screen name="institution_compare" options={{ title: '기관별 현황', href: null }} />
<Tabs.Screen name="deploy_history" options={{ title: '배포 이력', href: null }} />
<Tabs.Screen name="pdf_share" options={{ title: '리포트 공유', href: null }} />
<Tabs.Screen name="kpi_dashboard" options={{ title: 'KPI', href: null }} />
{/* ── 협업·연동 (#98~#100) ── */}
<Tabs.Screen name="sr_chat_room" options={{ title: 'SR 채팅', href: null }} />
<Tabs.Screen name="qr_apk" options={{ title: 'APK 배포', href: null }} />
<Tabs.Screen name="jenkins_builds" options={{ title: 'CI/CD', href: null }} />
{/* ── 2세대 AIOps / 예측 (#101~#110) ── */}
<Tabs.Screen name="failure_prediction" options={{ title: '장애 예측', href: null }} />
<Tabs.Screen name="capacity_plan" options={{ title: '용량 예측', href: null }} />
<Tabs.Screen name="dependency_map" options={{ title: '의존성 맵', href: null }} />
<Tabs.Screen name="greenops_dashboard" options={{ title: 'GreenOps', href: null }} />
<Tabs.Screen name="cost_advice" options={{ title: '비용 절감', href: null }} />
<Tabs.Screen name="policy_alerts" options={{ title: '정책 위반', href: null }} />
{/* ── 전자서명 / 하드웨어 (#111~#120) ── */}
<Tabs.Screen name="esignature" options={{ title: '전자서명', href: null }} />
<Tabs.Screen name="hw_warranty" options={{ title: 'HW 보증', href: null }} />
<Tabs.Screen name="nfc_asset" options={{ title: 'NFC 자산', href: null }} />
{/* ── 보안 위협 (#131~#140) ── */}
<Tabs.Screen name="cve_detail" options={{ title: 'CVE', href: null }} />
<Tabs.Screen name="threat_feed" options={{ title: '위협 피드', href: null }} />
<Tabs.Screen name="ioc_search" options={{ title: 'IoC 검색', href: null }} />
<Tabs.Screen name="security_score" options={{ title: '보안 점수', href: null }} />
{/* ── AI 이력 / 브리핑 (#141~#150) ── */}
<Tabs.Screen name="ai_history" options={{ title: 'AI 대화', href: null }} />
<Tabs.Screen name="ai_briefing" options={{ title: 'AI 브리핑', href: null }} />
<Tabs.Screen name="ollama_status" options={{ title: 'Ollama', href: null }} />
{/* ── 할 일 / 캘린더 (#151~#160) ── */}
<Tabs.Screen name="todo_list" options={{ title: '할 일', href: null }} />
<Tabs.Screen name="work_calendar" options={{ title: '업무 캘린더', href: null }} />
<Tabs.Screen name="maintenance_window" options={{ title: '유지보수', href: null }} />
{/* ── 클라우드 / 인프라 (#161~#170) ── */}
<Tabs.Screen name="vm_status" options={{ title: 'VM 상태', href: null }} />
<Tabs.Screen name="ssl_alerts" options={{ title: 'SSL 경보', href: null }} />
<Tabs.Screen name="eol_alerts" options={{ title: 'EOL 경보', href: null }} />
{/* ── 즐겨찾기 / 최근 (#171~#180) ── */}
<Tabs.Screen name="favorites" options={{ title: '즐겨찾기', href: null }} />
<Tabs.Screen name="recent_screens" options={{ title: '최근 방문', href: null }} />
{/* ── 성과 / 분석 (#181~#190) ── */}
<Tabs.Screen name="team_leaderboard" options={{ title: '리더보드', href: null }} />
<Tabs.Screen name="sr_heatmap" options={{ title: 'SR 히트맵', href: null }} />
<Tabs.Screen name="health_scorecard" options={{ title: '건강 점수', href: null }} />
{/* ── 공공기관 특화 (#191~#200) ── */}
<Tabs.Screen name="citizen_requests" options={{ title: '민원 접수', href: null }} />
<Tabs.Screen name="narasajang_status" options={{ title: '나라장터', href: null }} />
<Tabs.Screen name="csap_audit_prep" options={{ title: 'CSAP 심사', href: null }} />
{/* ── Gen3 AI-Native (#G3-1~#G3-6) ── */}
<Tabs.Screen name="ai_agent" options={{ title: 'AI 에이전트', href: null }} />
<Tabs.Screen name="multimodal" options={{ title: '멀티모달', href: null }} />
<Tabs.Screen name="on_device_ai" options={{ title: '온디바이스AI',href: null }} />
<Tabs.Screen name="camera_ar" options={{ title: 'AR 오버레이', href: null }} />
{/* ── Gen4 Edge AI & Smart Notify ── */}
<Tabs.Screen name="offline_ai" options={{ title: '오프라인 AI', href: null }} />
<Tabs.Screen name="predictive_alert" options={{ title: '예측 알림', href: null }} />
{/* ── Gen5 Collaboration & Productivity ── */}
<Tabs.Screen name="whiteboard" options={{ title: '화이트보드', href: null }} />
<Tabs.Screen name="cowork_sr" options={{ title: 'SR 공동대응', href: null }} />
<Tabs.Screen name="batch_action" options={{ title: '일괄 처리', href: null }} />
<Tabs.Screen name="quick_command" options={{ title: '빠른 명령', href: null }} />
<Tabs.Screen name="smart_search" options={{ title: '스마트 검색', href: null }} />
{/* ── Gen6 Autonomous ── */}
<Tabs.Screen name="self_healing" options={{ title: '자가 치유', href: null }} />
<Tabs.Screen name="auto_sr" options={{ title: '자율 SR', href: null }} />
<Tabs.Screen name="ai_decision" options={{ title: 'AI 의사결정', href: null }} />
<Tabs.Screen name="autonomous_ops" options={{ title: '자율 운영', href: null }} />
{/* ── 나라장터 소프트웨어 사업 분석 ── */}
<Tabs.Screen name="narasajang_sw" options={{ title: '나라장터 SW', href: null }} />
</Tabs>
)
}

View File

@ -0,0 +1,76 @@
import React from 'react'
import { View, Text, Switch, StyleSheet, ScrollView } from 'react-native'
import { COLORS } from '../../constants/Config'
import { useTheme } from '../../contexts/ThemeContext'
import { useFontScale } from '../../contexts/FontContext'
export default function AccessibilityScreen() {
const { isDark, toggleTheme } = useTheme()
const { fontScale: scale, setFontScale: setScale } = useFontScale()
return (
<ScrollView style={{ flex: 1, backgroundColor: isDark ? COLORS.gnbBg : COLORS.bg }}>
<Text style={[s.header, isDark && { color: '#fff' }]}> </Text>
<Section title="시각">
<Row label="다크 모드" isDark={isDark}>
<Switch value={isDark} onValueChange={toggleTheme} />
</Row>
<Row label="글자 크기" isDark={isDark}>
<View style={s.scaleRow}>
{([1.0, 1.2, 1.5] as const).map(v => (
<Text
key={v}
style={[s.scaleBtn, scale === v && s.scaleBtnActive]}
onPress={() => setScale(v)}
>{v === 1.0 ? '기본' : v === 1.2 ? '크게' : '매우 크게'}</Text>
))}
</View>
</Row>
</Section>
<Section title="미리보기">
<View style={[s.previewBox, isDark && { backgroundColor: '#2d3748' }]}>
<Text style={[s.previewText, { fontSize: 14 * scale }, isDark && { color: '#fff' }]}>
.
</Text>
<Text style={[s.previewSub, { fontSize: 12 * scale }, isDark && { color: '#aaa' }]}>
· ·
</Text>
</View>
</Section>
</ScrollView>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={s.section}>
<Text style={s.sectionTitle}>{title}</Text>
{children}
</View>
)
}
function Row({ label, isDark, children }: { label: string; isDark: boolean; children: React.ReactNode }) {
return (
<View style={[s.row, isDark && { borderBottomColor: '#4a5568' }]}>
<Text style={[s.rowLabel, isDark && { color: '#e2e8f0' }]}>{label}</Text>
{children}
</View>
)
}
const s = StyleSheet.create({
header: { fontSize: 22, fontWeight: '800', color: COLORS.text, padding: 16, paddingBottom: 8 },
section: { backgroundColor: '#fff', marginHorizontal: 12, marginBottom: 12, borderRadius: 12, overflow: 'hidden', elevation: 1 },
sectionTitle: { fontSize: 12, color: COLORS.muted, fontWeight: '700', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 4, textTransform: 'uppercase', letterSpacing: 1 },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: COLORS.light },
rowLabel: { fontSize: 15, color: COLORS.text },
scaleRow: { flexDirection: 'row', gap: 8 },
scaleBtn: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: COLORS.light, fontSize: 12, color: COLORS.muted },
scaleBtnActive:{ backgroundColor: COLORS.accent, color: '#fff', fontWeight: '700' },
previewBox: { margin: 12, padding: 16, backgroundColor: COLORS.bg, borderRadius: 10 },
previewText: { fontWeight: '600', color: COLORS.text, marginBottom: 4 },
previewSub: { color: COLORS.muted },
})

111
app/(tabs)/ai_agent.tsx Normal file
View File

@ -0,0 +1,111 @@
import React, { useState, useCallback } from 'react';
import { View, Text, TextInput, ScrollView, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import { ITSM_BASE } from '../../services/api';
const AGENTS = [
{ id: 'sr-manager', name: 'SR 관리자', icon: '📋', desc: 'SR 접수·분류·배정 자동화' },
{ id: 'incident-responder', name: '인시던트 대응', icon: '🚨', desc: '장애 감지·RCA·복구' },
{ id: 'deploy-engineer', name: '배포 엔지니어', icon: '🚀', desc: 'SSH 배포·롤백·헬스체크' },
{ id: 'ai-analyst', name: 'AI 분석가', icon: '🤖', desc: '이상탐지·예측·인사이트' },
{ id: 'csap-auditor', name: 'CSAP 감사관', icon: '🛡️', desc: 'CSAP 준수율 자동 점검' },
];
interface Message { role: 'user' | 'agent'; content: string; agent?: string; ts: string }
export default function AIAgentScreen() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [selectedAgent, setSelectedAgent] = useState(AGENTS[0]);
const [loading, setLoading] = useState(false);
const sendMessage = useCallback(async () => {
if (!input.trim()) return;
const userMsg: Message = { role: 'user', content: input, ts: new Date().toISOString() };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
try {
const r = await fetch(`${ITSM_BASE}/api/agent-collab/rooms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: input, agents: [selectedAgent.id] }),
});
if (r.ok) {
const room = await r.json();
const opinion = await fetch(`${ITSM_BASE}/api/agent-collab/rooms/${room.id}/ai-opinion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent_id: selectedAgent.id }),
});
if (opinion.ok) {
const data = await opinion.json();
setMessages(prev => [...prev, {
role: 'agent', content: data.opinion || data.message || '처리 완료',
agent: selectedAgent.name, ts: new Date().toISOString(),
}]);
}
}
} catch {
setMessages(prev => [...prev, { role: 'agent', content: '[Ollama 처리 중...]', agent: selectedAgent.name, ts: new Date().toISOString() }]);
} finally { setLoading(false); }
}, [input, selectedAgent]);
return (
<View style={s.container}>
<Text style={s.title}>AI </Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={s.agentBar}>
{AGENTS.map(a => (
<TouchableOpacity key={a.id} style={[s.agentChip, selectedAgent.id === a.id && s.agentActive]}
onPress={() => setSelectedAgent(a)}>
<Text style={s.agentIcon}>{a.icon}</Text>
<Text style={[s.agentName, selectedAgent.id === a.id && s.agentNameActive]}>{a.name}</Text>
</TouchableOpacity>
))}
</ScrollView>
<View style={s.agentDesc}><Text style={s.agentDescText}>{selectedAgent.icon} {selectedAgent.desc}</Text></View>
<ScrollView style={s.chat}>
{messages.map((m, i) => (
<View key={i} style={[s.bubble, m.role === 'user' ? s.userBubble : s.agentBubble]}>
{m.role === 'agent' && <Text style={s.agentLabel}>{m.agent}</Text>}
<Text style={s.bubbleText}>{m.content}</Text>
<Text style={s.ts}>{new Date(m.ts).toLocaleTimeString()}</Text>
</View>
))}
{loading && <ActivityIndicator color="#00A0C8" style={{ margin: 12 }} />}
</ScrollView>
<View style={s.inputRow}>
<TextInput style={s.input} value={input} onChangeText={setInput}
placeholder="에이전트에게 질문하세요..." placeholderTextColor="#888"
onSubmitEditing={sendMessage} />
<TouchableOpacity style={s.sendBtn} onPress={sendMessage}>
<Text style={s.sendText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A' },
title: { color: '#fff', fontSize: 18, fontWeight: '700', padding: 16, paddingBottom: 8 },
agentBar: { paddingHorizontal: 12, paddingVertical: 8, maxHeight: 80 },
agentChip: { alignItems: 'center', marginRight: 12, paddingHorizontal: 12, paddingVertical: 8,
borderRadius: 20, borderWidth: 1, borderColor: '#333', backgroundColor: '#1A1F2E' },
agentActive: { borderColor: '#00A0C8', backgroundColor: '#003366' },
agentIcon: { fontSize: 18 },
agentName: { color: '#aaa', fontSize: 11, marginTop: 2 },
agentNameActive: { color: '#00A0C8' },
agentDesc: { backgroundColor: '#1A1F2E', marginHorizontal: 12, marginBottom: 4, padding: 8, borderRadius: 8 },
agentDescText: { color: '#aaa', fontSize: 12 },
chat: { flex: 1, padding: 12 },
bubble: { maxWidth: '80%', marginBottom: 12, padding: 10, borderRadius: 12 },
userBubble: { alignSelf: 'flex-end', backgroundColor: '#003366' },
agentBubble: { alignSelf: 'flex-start', backgroundColor: '#1A1F2E' },
agentLabel: { color: '#00A0C8', fontSize: 11, fontWeight: '600', marginBottom: 4 },
bubbleText: { color: '#fff', fontSize: 14, lineHeight: 20 },
ts: { color: '#555', fontSize: 10, marginTop: 4, textAlign: 'right' },
inputRow: { flexDirection: 'row', padding: 12, borderTopWidth: 1, borderTopColor: '#222' },
input: { flex: 1, backgroundColor: '#1A1F2E', color: '#fff', borderRadius: 20, paddingHorizontal: 16, marginRight: 8 },
sendBtn: { backgroundColor: '#00A0C8', borderRadius: 20, paddingHorizontal: 16, justifyContent: 'center' },
sendText: { color: '#fff', fontWeight: '700' },
});

View File

@ -0,0 +1,74 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl, ActivityIndicator } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function AIBriefingScreen() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/ai-insights/briefing'); setData(r.data) }
catch { setData(null) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
if (loading) return <ActivityIndicator style={{ flex: 1 }} color={COLORS.accent} />
if (!data) return <Text style={s.empty}> .</Text>
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />} contentContainerStyle={{ padding: 16 }}>
<View style={s.header}>
<Text style={s.title}>AI </Text>
<Text style={s.period}>{data.period ?? ''}</Text>
</View>
<View style={s.card}>
<Text style={s.sectionTitle}> </Text>
<Text style={s.body}>{data.summary ?? data.content ?? '데이터 없음'}</Text>
</View>
{data.highlights?.map((h: string, i: number) => (
<View key={i} style={s.bulletRow}>
<Text style={s.bullet}></Text>
<Text style={s.bulletText}>{h}</Text>
</View>
))}
{data.risks?.length > 0 && (
<View style={[s.card, { borderLeftWidth: 4, borderLeftColor: COLORS.danger }]}>
<Text style={s.sectionTitle}></Text>
{data.risks.map((r: string, i: number) => (
<Text key={i} style={[s.bulletText, { color: COLORS.danger }]}> {r}</Text>
))}
</View>
)}
{data.recommendations?.length > 0 && (
<View style={[s.card, { borderLeftWidth: 4, borderLeftColor: COLORS.success }]}>
<Text style={s.sectionTitle}>AI </Text>
{data.recommendations.map((r: string, i: number) => (
<Text key={i} style={[s.bulletText, { color: COLORS.text }]}> {r}</Text>
))}
</View>
)}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
header: { marginBottom: 16 },
title: { fontSize: 22, fontWeight: '800', color: COLORS.text },
period: { fontSize: 13, color: COLORS.muted, marginTop: 4 },
card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, marginBottom: 12, elevation: 1 },
sectionTitle: { fontSize: 14, fontWeight: '700', color: COLORS.text, marginBottom: 10 },
body: { fontSize: 14, color: COLORS.text, lineHeight: 22 },
bulletRow: { flexDirection: 'row', gap: 8, marginBottom: 6 },
bullet: { fontSize: 16, color: COLORS.accent, lineHeight: 22 },
bulletText: { flex: 1, fontSize: 13, color: COLORS.text, lineHeight: 20 },
})

114
app/(tabs)/ai_decision.tsx Normal file
View File

@ -0,0 +1,114 @@
import React, { useState, useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import { ITSM_BASE } from '../../services/api';
interface Decision { id: string; question: string; recommendation: string; confidence: number; alternatives: string[]; impact: string; ts: string }
const MOCK_DECISIONS: Decision[] = [
{ id: 'D-001', question: 'db-01 디스크 85% — 즉시 조치 필요?', recommendation: '로그 파일 정리 + SR 등록', confidence: 0.91, alternatives: ['서버 증설 요청', '아카이브 이전', '임시 파일 삭제'], impact: '서비스 중단 없이 2시간 내 해결 가능', ts: new Date().toISOString() },
{ id: 'D-002', question: '토요일 오전 2시 긴급 패치 배포 승인?', recommendation: '승인 — 위험 낮음, 영향 범위 최소', confidence: 0.78, alternatives: ['일정 연기 (다음 주)', '부분 배포 (1개 서버만)'], impact: '서비스 다운타임 예상 15분', ts: new Date().toISOString() },
];
export default function AIDecisionScreen() {
const [decisions, setDecisions] = useState<Decision[]>(MOCK_DECISIONS);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Decision | null>(null);
const fetchDecisions = async () => {
setLoading(true);
try {
const r = await fetch(`${ITSM_BASE}/api/ai/decisions/pending`);
if (r.ok) { const d = await r.json(); if (d.items?.length) setDecisions(d.items); }
} catch {}
setLoading(false);
};
useEffect(() => { fetchDecisions(); }, []);
const applyDecision = async (dec: Decision, choice: string) => {
try {
await fetch(`${ITSM_BASE}/api/ai/decisions/${dec.id}/apply`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ choice }),
});
setDecisions(prev => prev.filter(d => d.id !== dec.id));
setSelected(null);
} catch {}
};
if (selected) {
return (
<ScrollView style={s.container}>
<TouchableOpacity style={s.backBtn} onPress={() => setSelected(null)}>
<Text style={s.backText}> </Text>
</TouchableOpacity>
<Text style={s.detailTitle}>{selected.question}</Text>
<View style={s.confCard}>
<Text style={s.confLabel}>AI </Text>
<Text style={s.confVal}>{Math.round(selected.confidence * 100)}%</Text>
</View>
<View style={s.card}>
<Text style={s.sectionLbl}>AI </Text>
<Text style={s.recommendation}>{selected.recommendation}</Text>
</View>
<View style={s.card}>
<Text style={s.sectionLbl}> </Text>
<Text style={s.impact}>{selected.impact}</Text>
</View>
<Text style={s.sectionLbl}></Text>
{[selected.recommendation, ...selected.alternatives].map((alt, i) => (
<TouchableOpacity key={i} style={[s.altBtn, i === 0 && s.altBtnPrimary]} onPress={() => applyDecision(selected, alt)}>
<Text style={[s.altBtnText, i === 0 && s.altBtnTextPrimary]}>{i === 0 ? '✅ ' : ''}{alt}</Text>
</TouchableOpacity>
))}
</ScrollView>
);
}
return (
<ScrollView style={s.container}>
<Text style={s.title}>AI </Text>
<Text style={s.sub}>GUARDiA AI가 </Text>
{loading && <ActivityIndicator color="#00A0C8" />}
{decisions.map(dec => (
<TouchableOpacity key={dec.id} style={s.decCard} onPress={() => setSelected(dec)}>
<View style={s.decHeader}>
<View style={[s.confBadge, { backgroundColor: dec.confidence >= 0.85 ? '#44bb44' : '#ffbb00' }]}>
<Text style={s.confBadgeText}>{Math.round(dec.confidence * 100)}%</Text>
</View>
</View>
<Text style={s.question}>{dec.question}</Text>
<Text style={s.recoPreview}>{dec.recommendation}</Text>
<Text style={s.ts}>{new Date(dec.ts).toLocaleString()}</Text>
</TouchableOpacity>
))}
{decisions.length === 0 && <Text style={s.empty}> AI </Text>}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
backBtn: { marginBottom: 16 }, backText: { color: '#00A0C8', fontSize: 15 },
detailTitle: { color: '#fff', fontSize: 17, fontWeight: '700', marginBottom: 16 },
confCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, marginBottom: 12, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderWidth: 1, borderColor: '#333' },
confLabel: { color: '#aaa', fontSize: 14 }, confVal: { color: '#44bb44', fontSize: 28, fontWeight: '700' },
card: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, marginBottom: 12, borderWidth: 1, borderColor: '#333' },
sectionLbl: { color: '#888', fontSize: 12, marginBottom: 8, fontWeight: '600' },
recommendation: { color: '#fff', fontSize: 15, fontWeight: '600' },
impact: { color: '#aaa', fontSize: 14 },
altBtn: { backgroundColor: '#1A1F2E', borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1, borderColor: '#333' },
altBtnPrimary: { backgroundColor: '#003366', borderColor: '#00A0C8' },
altBtnText: { color: '#aaa', fontSize: 14 },
altBtnTextPrimary: { color: '#fff', fontWeight: '700' },
decCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 10, borderWidth: 1, borderColor: '#333' },
decHeader: { flexDirection: 'row', marginBottom: 8 },
confBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6 },
confBadgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
question: { color: '#fff', fontWeight: '600', fontSize: 14, marginBottom: 6 },
recoPreview: { color: '#00A0C8', fontSize: 12, marginBottom: 6 },
ts: { color: '#555', fontSize: 11 },
empty: { color: '#555', textAlign: 'center', marginTop: 40, fontSize: 14 },
});

55
app/(tabs)/ai_history.tsx Normal file
View File

@ -0,0 +1,55 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function AIHistoryScreen() {
const [items, setItems] = useState<any[]>([])
const [page, setPage] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
const load = useCallback(async (p = 0) => {
setLoading(true)
try {
const r = await client.get('/api/mobile2/chatbot-history', { params: { page: p, size: 20 } })
const rows = r.data?.items ?? r.data ?? []
setItems(prev => p === 0 ? rows : [...prev, ...rows])
setHasMore(rows.length === 20)
setPage(p)
} catch { if (p === 0) setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load(0) }, [load]))
const roleColor = (role: string) => role === 'user' ? COLORS.blue : COLORS.accent
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading && page === 0} onRefresh={() => load(0)} />}
onEndReached={() => hasMore && !loading && load(page + 1)}
onEndReachedThreshold={0.3}
ListEmptyComponent={<Text style={s.empty}>AI .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => (
<View style={[s.bubble, { alignSelf: item.role === 'user' ? 'flex-end' : 'flex-start', backgroundColor: roleColor(item.role) + '15', borderColor: roleColor(item.role) + '40' }]}>
<Text style={[s.role, { color: roleColor(item.role) }]}>{item.role === 'user' ? '나' : 'AI'}</Text>
<Text style={s.content}>{item.content ?? item.message}</Text>
<Text style={s.time}>{item.created_at?.slice(0, 16) ?? ''}</Text>
</View>
)}
/>
)
}
const s = StyleSheet.create({
bubble: { maxWidth: '80%', borderWidth: 1, borderRadius: 12, padding: 12, marginBottom: 8 },
role: { fontSize: 11, fontWeight: '700', marginBottom: 4 },
content: { fontSize: 13, color: COLORS.text, lineHeight: 20 },
time: { fontSize: 10, color: COLORS.muted, marginTop: 4, textAlign: 'right' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

100
app/(tabs)/ai_soc.tsx Normal file
View File

@ -0,0 +1,100 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Alert, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getAISOCEvents } from '../../services/api'
const SEV_COLOR: Record<string, string> = {
CRITICAL: COLORS.danger,
HIGH: '#F97316',
MEDIUM: COLORS.warning,
LOW: COLORS.success,
}
export default function AISOCScreen() {
const [events, setEvents] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const load = useCallback(async () => {
setLoading(true)
try { const r = await getAISOCEvents(); setEvents(r.data?.items ?? r.data ?? []) }
catch { setEvents([]) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const toggle = (id: number) => {
const next = new Set(expanded)
next.has(id) ? next.delete(id) : next.add(id)
setExpanded(next)
}
const resolve = (item: any) => {
Alert.alert('대응 완료', `"${item.event_type ?? '인시던트'}"을 대응 완료로 표시하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '완료', onPress: () => {
setEvents(prev => prev.map(e => e.id === item.id ? { ...e, status: 'resolved' } : e))
}},
])
}
const renderItem = ({ item }: { item: any }) => {
const sev = (item.severity ?? item.grade ?? 'MEDIUM').toUpperCase()
const isOpen = expanded.has(item.id)
return (
<TouchableOpacity style={[s.card, item.status === 'resolved' && s.cardResolved]} onPress={() => toggle(item.id)}>
<View style={s.row}>
<View style={[s.sevBadge, { backgroundColor: SEV_COLOR[sev] ?? COLORS.muted }]}>
<Text style={s.sevText}>{sev}</Text>
</View>
<Text style={s.eventType} numberOfLines={1}>{item.event_type ?? item.title ?? '보안 이벤트'}</Text>
<Text style={s.status}>{item.status ?? 'open'}</Text>
</View>
<Text style={s.time}>{item.detected_at?.slice(0, 16).replace('T', ' ') ?? item.created_at?.slice(0, 16).replace('T', ' ')}</Text>
{isOpen && (
<View style={s.detail}>
<Text style={s.detailText}>{item.description ?? item.detail ?? '-'}</Text>
{item.status !== 'resolved' && (
<TouchableOpacity style={s.resolveBtn} onPress={() => resolve(item)}>
<Text style={s.resolveBtnText}> </Text>
</TouchableOpacity>
)}
</View>
)}
</TouchableOpacity>
)
}
return (
<View style={s.container}>
<FlatList
data={events}
keyExtractor={i => String(i.id ?? Math.random())}
renderItem={renderItem}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ padding: 12 }}
/>
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
cardResolved: { opacity: 0.6 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
sevBadge: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
sevText: { fontSize: 10, color: '#fff', fontWeight: '800' },
eventType: { flex: 1, fontSize: 13, fontWeight: '600', color: COLORS.text },
status: { fontSize: 11, color: COLORS.muted },
time: { fontSize: 11, color: COLORS.muted },
detail: { marginTop: 10, paddingTop: 10, borderTopWidth: 1, borderTopColor: COLORS.border },
detailText: { fontSize: 13, color: COLORS.text, lineHeight: 20, marginBottom: 10 },
resolveBtn: { backgroundColor: COLORS.success, borderRadius: 6, padding: 8, alignItems: 'center' },
resolveBtnText: { color: '#fff', fontWeight: '700', fontSize: 13 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

210
app/(tabs)/approval.tsx Normal file
View File

@ -0,0 +1,210 @@
import React, { useState, useCallback } from 'react'
import {
View, Text, FlatList, TouchableOpacity, StyleSheet,
RefreshControl, Alert, Animated,
} from 'react-native'
import { GestureHandlerRootView, PanGestureHandler, State } from 'react-native-gesture-handler'
import { COLORS } from '../../constants/Config'
import { getApprovals, approveRequest, rejectRequest, cancelApproval } from '../../services/api'
import { useFocusEffect } from 'expo-router'
import RejectReason from '../../components/RejectReason'
import ApprovalStages from '../../components/ApprovalStages'
type Tab = 'pending' | 'approved' | 'rejected'
interface ApprovalItem {
id: number
title: string
requester: string
created_at: string
status: string
type?: string
}
export default function ApprovalScreen() {
const [tab, setTab] = useState<Tab>('pending')
const [items, setItems] = useState<ApprovalItem[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [rejectId, setRejectId] = useState<number | null>(null)
const [stagesId, setStagesId] = useState<number | null>(null)
const [undoId, setUndoId] = useState<number | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await getApprovals(tab)
setItems(r.data?.items ?? r.data ?? [])
} catch { setItems([]) }
finally { setLoading(false) }
}, [tab])
useFocusEffect(useCallback(() => { load() }, [load]))
const doApprove = async (id: number) => {
try {
await approveRequest(id, '')
setUndoId(id)
setTimeout(() => setUndoId(null), 3000)
load()
} catch { Alert.alert('오류', '승인 처리 중 오류가 발생했습니다.') }
}
const doUndo = async () => {
if (!undoId) return
try { await cancelApproval(undoId); setUndoId(null); load() } catch {}
}
const bulkApprove = async () => {
const ids = [...selected]
if (!ids.length) return
Alert.alert('일괄 승인', `${ids.length}건을 승인하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '승인', onPress: async () => {
await Promise.all(ids.map(id => approveRequest(id, '')))
setSelected(new Set())
load()
}},
])
}
const toggleSelect = (id: number) => {
const next = new Set(selected)
next.has(id) ? next.delete(id) : next.add(id)
setSelected(next)
}
const renderItem = ({ item }: { item: ApprovalItem }) => (
<SwipeCard
item={item}
onApprove={() => doApprove(item.id)}
onReject={() => setRejectId(item.id)}
onDetail={() => setStagesId(item.id)}
selected={selected.has(item.id)}
onSelect={() => toggleSelect(item.id)}
/>
)
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<View style={s.container}>
{/* 탭 */}
<View style={s.tabs}>
{(['pending','approved','rejected'] as Tab[]).map(t => (
<TouchableOpacity key={t} style={[s.tab, tab===t && s.tabActive]} onPress={() => setTab(t)}>
<Text style={[s.tabText, tab===t && s.tabTextActive]}>
{t === 'pending' ? '대기' : t === 'approved' ? '승인' : '반려'}
</Text>
</TouchableOpacity>
))}
</View>
{/* 일괄 승인 버튼 */}
{selected.size > 0 && (
<TouchableOpacity style={s.bulkBtn} onPress={bulkApprove}>
<Text style={s.bulkBtnText}>{selected.size} </Text>
</TouchableOpacity>
)}
<FlatList
data={items}
keyExtractor={i => String(i.id)}
renderItem={renderItem}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ paddingBottom: 80 }}
/>
{/* 언두 스낵바 */}
{undoId && (
<View style={s.snack}>
<Text style={s.snackText}></Text>
<TouchableOpacity onPress={doUndo}><Text style={s.snackUndo}></Text></TouchableOpacity>
</View>
)}
{/* 반려 사유 모달 */}
{rejectId && (
<RejectReason
visible={true}
onSubmit={async (reason) => {
await rejectRequest(rejectId, reason)
setRejectId(null)
load()
}}
onClose={() => setRejectId(null)}
/>
)}
{/* 다단계 승인 */}
{stagesId && (
<ApprovalStages
approvalId={stagesId}
/>
)}
</View>
</GestureHandlerRootView>
)
}
function SwipeCard({ item, onApprove, onReject, onDetail, selected, onSelect }: any) {
const x = React.useRef(new Animated.Value(0)).current
const onGesture = ({ nativeEvent }: any) => {
if (nativeEvent.state === State.END) {
if (nativeEvent.translationX > 80) {
Animated.spring(x, { toValue: 0, useNativeDriver: true }).start()
onApprove()
} else if (nativeEvent.translationX < -80) {
Animated.spring(x, { toValue: 0, useNativeDriver: true }).start()
onReject()
} else {
Animated.spring(x, { toValue: 0, useNativeDriver: true }).start()
}
} else {
x.setValue(nativeEvent.translationX)
}
}
return (
<View style={s.cardWrap}>
<View style={[s.swipeBg, { backgroundColor: COLORS.success }]}><Text style={s.swipeHint}> </Text></View>
<View style={[s.swipeBg, { backgroundColor: COLORS.danger, right: 0, left: 'auto' }]}><Text style={s.swipeHint}> </Text></View>
<PanGestureHandler onHandlerStateChange={onGesture} onGestureEvent={({ nativeEvent }: any) => x.setValue(nativeEvent.translationX)}>
<Animated.View style={[s.card, { transform: [{ translateX: x }] }]}>
<TouchableOpacity style={s.selectBox} onPress={onSelect}>
<View style={[s.checkbox, selected && s.checkboxSelected]} />
</TouchableOpacity>
<TouchableOpacity style={{ flex: 1 }} onPress={onDetail}>
<Text style={s.title} numberOfLines={2}>{item.title}</Text>
<Text style={s.meta}>{item.requester} · {item.created_at?.slice(0, 10)}</Text>
</TouchableOpacity>
</Animated.View>
</PanGestureHandler>
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
tabs: { flexDirection: 'row', backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border },
tab: { flex: 1, paddingVertical: 12, alignItems: 'center' },
tabActive: { borderBottomWidth: 2, borderBottomColor: COLORS.accent },
tabText: { fontSize: 14, color: COLORS.muted },
tabTextActive: { color: COLORS.accent, fontWeight: '700' },
bulkBtn: { margin: 8, backgroundColor: COLORS.accent, borderRadius: 8, padding: 10, alignItems: 'center' },
bulkBtnText: { color: '#fff', fontWeight: '700' },
cardWrap: { marginHorizontal: 12, marginVertical: 4, borderRadius: 10, overflow: 'hidden', height: 80 },
swipeBg: { position: 'absolute', top: 0, bottom: 0, left: 0, width: 80, justifyContent: 'center', alignItems: 'center' },
swipeHint: { color: '#fff', fontWeight: '700', fontSize: 12 },
card: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', padding: 14, borderRadius: 10, height: 80, elevation: 1 },
selectBox: { marginRight: 10 },
checkbox: { width: 22, height: 22, borderRadius: 4, borderWidth: 2, borderColor: COLORS.border },
checkboxSelected: { backgroundColor: COLORS.accent, borderColor: COLORS.accent },
title: { fontSize: 14, fontWeight: '600', color: COLORS.text },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
snack: { position: 'absolute', bottom: 20, left: 20, right: 20, backgroundColor: '#1E293B', borderRadius: 8, padding: 12, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
snackText: { color: '#fff', fontSize: 14 },
snackUndo: { color: COLORS.accent, fontWeight: '700' },
})

77
app/(tabs)/audit_log.tsx Normal file
View File

@ -0,0 +1,77 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, ActivityIndicator } from 'react-native'
import { COLORS } from '../../constants/Config'
import { getAuditLogs } from '../../services/api'
function maskIp(ip: string | undefined) {
if (!ip) return '-'
const parts = ip.split('.')
if (parts.length === 4) return `${parts[0]}.xxx.xxx.xxx`
return ip.replace(/[0-9]+/g, 'x')
}
export default function AuditLogScreen() {
const [items, setItems] = useState<any[]>([])
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const load = useCallback(async (pg = 0) => {
if (pg === 0) setLoading(true)
else setLoadingMore(true)
try {
const r = await getAuditLogs(pg)
const data = r.data?.items ?? r.data ?? []
if (pg === 0) setItems(data)
else setItems(prev => [...prev, ...data])
setHasMore(data.length >= 30)
setPage(pg)
} catch {}
finally { setLoading(false); setLoadingMore(false) }
}, [])
React.useEffect(() => { load(0) }, [])
const loadMore = () => { if (!loadingMore && hasMore) load(page + 1) }
const renderItem = ({ item }: { item: any }) => (
<View style={s.card}>
<View style={s.row}>
<Text style={s.actor}>{item.actor}</Text>
<Text style={s.time}>{item.created_at?.slice(0, 16).replace('T', ' ')}</Text>
</View>
<Text style={s.action}>{item.action}</Text>
<Text style={s.detail} numberOfLines={2}>{item.detail}</Text>
<Text style={s.ip}>IP: {maskIp(item.ip_hash ?? item.ip_addr)}</Text>
</View>
)
if (loading) return <ActivityIndicator style={{ flex: 1 }} color={COLORS.accent} />
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
renderItem={renderItem}
refreshControl={<RefreshControl refreshing={loading} onRefresh={() => load(0)} />}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListFooterComponent={loadingMore ? <ActivityIndicator color={COLORS.accent} style={{ padding: 16 }} /> : null}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ padding: 12 }}
style={{ backgroundColor: COLORS.bg }}
/>
)
}
const s = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 8, padding: 12, marginBottom: 6, elevation: 1 },
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
actor: { fontSize: 13, fontWeight: '700', color: COLORS.text },
time: { fontSize: 11, color: COLORS.muted },
action: { fontSize: 12, color: COLORS.accent, fontWeight: '600', marginBottom: 2 },
detail: { fontSize: 12, color: COLORS.text },
ip: { fontSize: 11, color: COLORS.muted, marginTop: 4 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

87
app/(tabs)/auto_sr.tsx Normal file
View File

@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Switch } from 'react-native';
import { ITSM_BASE } from '../../services/api';
interface AutoSRRule { id: string; name: string; condition: string; template: string; priority: string; enabled: boolean; created_count: number }
const RULES: AutoSRRule[] = [
{ id: 'R01', name: 'CPU 90% 초과', condition: 'cpu_usage > 90', template: '[자동] CPU 과부하 감지', priority: 'high', enabled: true, created_count: 8 },
{ id: 'R02', name: '디스크 95% 초과', condition: 'disk_usage > 95', template: '[자동] 디스크 용량 위험', priority: 'critical', enabled: true, created_count: 3 },
{ id: 'R03', name: 'HTTP 500 연속 5회', condition: 'http_5xx_count >= 5', template: '[자동] 서비스 오류 감지', priority: 'high', enabled: true, created_count: 12 },
{ id: 'R04', name: 'SLA 임박', condition: 'sla_remaining < 30', template: '[자동] SLA 위반 위험', priority: 'medium', enabled: false, created_count: 0 },
];
export default function AutoSRScreen() {
const [rules, setRules] = useState(RULES);
const [globalEnabled, setGlobalEnabled] = useState(true);
const [stats, setStats] = useState({ today: 5, week: 23, total: 146, auto_resolved: 89 });
const toggleRule = (id: string) => {
setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
};
const priorityColor = (p: string) => ({ critical: '#ff4444', high: '#ff8800', medium: '#ffbb00', low: '#44bb44' })[p] || '#888';
return (
<ScrollView style={s.container}>
<Text style={s.title}> SR </Text>
<Text style={s.sub}> SR </Text>
<View style={s.statsGrid}>
<View style={s.statBox}><Text style={s.statVal}>{stats.today}</Text><Text style={s.statLbl}></Text></View>
<View style={s.statBox}><Text style={s.statVal}>{stats.week}</Text><Text style={s.statLbl}> </Text></View>
<View style={s.statBox}><Text style={s.statVal}>{stats.total}</Text><Text style={s.statLbl}></Text></View>
<View style={s.statBox}><Text style={[s.statVal, { color: '#44bb44' }]}>{stats.auto_resolved}</Text><Text style={s.statLbl}></Text></View>
</View>
<View style={s.globalCard}>
<View style={s.row}>
<Text style={s.globalLabel}>🤖 SR </Text>
<Switch value={globalEnabled} onValueChange={setGlobalEnabled} trackColor={{ true: '#00A0C8', false: '#333' }} />
</View>
{!globalEnabled && <Text style={s.warningText}> SR </Text>}
</View>
<Text style={s.sectionTitle}> </Text>
{rules.map(rule => (
<View key={rule.id} style={[s.ruleCard, !rule.enabled && s.ruleDisabled]}>
<View style={s.ruleHeader}>
<View style={[s.priorityBadge, { backgroundColor: priorityColor(rule.priority) }]}>
<Text style={s.priorityText}>{rule.priority}</Text>
</View>
<Text style={s.ruleCount}>{rule.created_count} </Text>
<Switch value={rule.enabled && globalEnabled} onValueChange={() => toggleRule(rule.id)}
disabled={!globalEnabled} trackColor={{ true: '#00A0C8', false: '#333' }} />
</View>
<Text style={s.ruleName}>{rule.name}</Text>
<Text style={s.ruleCondition}>: {rule.condition}</Text>
<Text style={s.ruleTemplate}>릿: {rule.template}</Text>
</View>
))}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
statsGrid: { flexDirection: 'row', backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 16, borderWidth: 1, borderColor: '#333', justifyContent: 'space-around' },
statBox: { alignItems: 'center' },
statVal: { color: '#00A0C8', fontSize: 22, fontWeight: '700' },
statLbl: { color: '#888', fontSize: 11, marginTop: 2 },
globalCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
globalLabel: { color: '#fff', fontSize: 15, fontWeight: '600', flex: 1 },
warningText: { color: '#ffbb00', fontSize: 12, marginTop: 8 },
sectionTitle: { color: '#fff', fontSize: 16, fontWeight: '700', marginBottom: 12 },
ruleCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 10, borderWidth: 1, borderColor: '#333' },
ruleDisabled: { opacity: 0.5 },
ruleHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 },
priorityBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, marginRight: 8 },
priorityText: { color: '#fff', fontSize: 11, fontWeight: '700' },
ruleCount: { color: '#888', fontSize: 12, flex: 1 },
ruleName: { color: '#fff', fontWeight: '700', fontSize: 15, marginBottom: 4 },
ruleCondition: { color: '#aaa', fontSize: 12, fontFamily: 'monospace', marginBottom: 2 },
ruleTemplate: { color: '#aaa', fontSize: 12 },
});

View File

@ -0,0 +1,72 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, Switch, StyleSheet, ActivityIndicator, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getAutomationRules } from '../../services/api'
export default function AutomationRulesScreen() {
const [rules, setRules] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await getAutomationRules()
setRules(r.data?.items ?? r.data ?? [])
} catch { setRules([]) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const renderItem = ({ item }: { item: any }) => (
<View style={s.card}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.title}>{item.name ?? item.rule_name ?? '규칙'}</Text>
<Text style={s.meta}>: {item.trigger ?? item.condition ?? '-'}</Text>
<Text style={s.meta}>: {item.action ?? item.action_type ?? '-'}</Text>
</View>
<View style={s.statusCol}>
<Switch
value={item.enabled ?? item.is_active ?? false}
disabled
trackColor={{ true: COLORS.accent, false: COLORS.border }}
/>
<Text style={[s.badge, { backgroundColor: item.enabled ? COLORS.success : COLORS.muted }]}>
{item.enabled ? '활성' : '비활성'}
</Text>
</View>
</View>
</View>
)
return (
<View style={s.container}>
<View style={s.notice}>
<Text style={s.noticeText}> ITSM .</Text>
</View>
<FlatList
data={rules}
keyExtractor={(_, i) => String(i)}
renderItem={renderItem}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ padding: 12 }}
/>
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
notice: { backgroundColor: COLORS.light, padding: 10, margin: 12, borderRadius: 8 },
noticeText:{ fontSize: 12, color: COLORS.blue },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center' },
title: { fontSize: 14, fontWeight: '700', color: COLORS.text, marginBottom: 4 },
meta: { fontSize: 12, color: COLORS.muted, marginBottom: 2 },
statusCol: { alignItems: 'center', gap: 4 },
badge: { fontSize: 10, color: '#fff', paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Switch } from 'react-native';
import { ITSM_BASE } from '../../services/api';
interface OpsTask { id: string; name: string; type: string; status: string; autonomous: boolean; next_run?: string; last_result?: string }
const TASKS: OpsTask[] = [
{ id: 'T01', name: '야간 로그 정리', type: 'maintenance', status: 'scheduled', autonomous: true, next_run: '오늘 02:00' },
{ id: 'T02', name: '주간 보안 스캔', type: 'security', status: 'completed', autonomous: true, last_result: '취약점 0개 발견' },
{ id: 'T03', name: '스냅샷 백업', type: 'backup', status: 'running', autonomous: true },
{ id: 'T04', name: '용량 예측 보고서', type: 'analytics', status: 'pending', autonomous: false },
];
export default function AutonomousOpsScreen() {
const [tasks, setTasks] = useState(TASKS);
const [autonomyLevel, setAutonomyLevel] = useState(72);
const [fullAuto, setFullAuto] = useState(false);
const [opsLog, setOpsLog] = useState([
{ time: '02:14', msg: '로그 아카이브 완료 — 3.2GB 확보', type: 'success' },
{ time: '03:00', msg: 'DB 스냅샷 완료 (app-db-01)', type: 'success' },
{ time: '07:30', msg: 'CPU 이상 감지 — 자동 재시작 실행', type: 'warning' },
]);
const statusColor = (s: string) => ({ running: '#00A0C8', completed: '#44bb44', scheduled: '#ffbb00', pending: '#888', failed: '#ff4444' })[s] || '#888';
const statusIcon = (s: string) => ({ running: '⟳', completed: '✅', scheduled: '⏱', pending: '⏸', failed: '❌' })[s] || '?';
const toggleTask = async (id: string) => {
const task = tasks.find(t => t.id === id);
if (!task) return;
try {
await fetch(`${ITSM_BASE}/api/ops-automation/tasks/${id}/toggle`, { method: 'POST' });
setTasks(prev => prev.map(t => t.id === id ? { ...t, autonomous: !t.autonomous } : t));
} catch {
setTasks(prev => prev.map(t => t.id === id ? { ...t, autonomous: !t.autonomous } : t));
}
};
return (
<ScrollView style={s.container}>
<Text style={s.title}> </Text>
<Text style={s.sub}>GUARDiA가 </Text>
<View style={s.autonomyCard}>
<Text style={s.autonomyLabel}></Text>
<Text style={s.autonomyVal}>{autonomyLevel}%</Text>
<View style={s.barBg}>
<View style={[s.barFill, { width: `${autonomyLevel}%` }]} />
</View>
<Text style={s.autonomyDesc}>목표: 85% (2027 Q1)</Text>
</View>
<View style={s.card}>
<View style={s.row}>
<Text style={s.label}> </Text>
<Switch value={fullAuto} onValueChange={setFullAuto} trackColor={{ true: '#44bb44', false: '#333' }} />
</View>
{fullAuto && <Text style={s.warningText}> </Text>}
</View>
<Text style={s.sectionTitle}> </Text>
{tasks.map(task => (
<View key={task.id} style={s.taskCard}>
<View style={s.taskHeader}>
<Text style={[s.statusIcon, { color: statusColor(task.status) }]}>{statusIcon(task.status)}</Text>
<Text style={s.taskName}>{task.name}</Text>
<Switch value={task.autonomous} onValueChange={() => toggleTask(task.id)} trackColor={{ true: '#00A0C8', false: '#333' }} />
</View>
<View style={s.taskMeta}>
<View style={s.typeBadge}><Text style={s.typeText}>{task.type}</Text></View>
{task.next_run && <Text style={s.metaText}> : {task.next_run}</Text>}
{task.last_result && <Text style={s.metaText}>{task.last_result}</Text>}
</View>
</View>
))}
<Text style={s.sectionTitle}> </Text>
{opsLog.map((log, i) => (
<View key={i} style={s.logRow}>
<Text style={s.logTime}>{log.time}</Text>
<Text style={[s.logMsg, { color: log.type === 'success' ? '#44bb44' : log.type === 'warning' ? '#ffbb00' : '#ff4444' }]}>{log.msg}</Text>
</View>
))}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
autonomyCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
autonomyLabel: { color: '#888', fontSize: 12, marginBottom: 4 },
autonomyVal: { color: '#00A0C8', fontSize: 40, fontWeight: '700', marginBottom: 8 },
barBg: { height: 10, backgroundColor: '#333', borderRadius: 5, marginBottom: 8 },
barFill: { height: 10, backgroundColor: '#00A0C8', borderRadius: 5 },
autonomyDesc: { color: '#888', fontSize: 12 },
card: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
label: { color: '#fff', fontSize: 15 },
warningText: { color: '#ffbb00', fontSize: 12, marginTop: 8 },
sectionTitle: { color: '#fff', fontSize: 16, fontWeight: '700', marginBottom: 10 },
taskCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 10, borderWidth: 1, borderColor: '#333' },
taskHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
statusIcon: { fontSize: 18, marginRight: 10 },
taskName: { color: '#fff', fontWeight: '600', flex: 1 },
taskMeta: { flexDirection: 'row', alignItems: 'center', gap: 10 },
typeBadge: { backgroundColor: '#003366', paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
typeText: { color: '#00A0C8', fontSize: 11 },
metaText: { color: '#888', fontSize: 12 },
logRow: { flexDirection: 'row', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#1A1F2E' },
logTime: { color: '#555', fontSize: 12, marginRight: 12, minWidth: 40 },
logMsg: { flex: 1, fontSize: 13 },
});

114
app/(tabs)/batch_action.tsx Normal file
View File

@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Switch, Alert } from 'react-native';
import { ITSM_BASE } from '../../services/api';
interface BatchItem { id: string; label: string; type: string; selected: boolean }
const ITEMS: BatchItem[] = [
{ id: 'S01', label: 'app-01 · nginx 재시작', type: 'server', selected: false },
{ id: 'S02', label: 'app-02 · 캐시 초기화', type: 'server', selected: false },
{ id: 'S03', label: 'db-01 · 슬로우쿼리 로그 수집', type: 'db', selected: false },
{ id: 'SR2041', label: 'SR-2041 · 완료 처리', type: 'sr', selected: false },
{ id: 'SR2042', label: 'SR-2042 · 담당자 변경', type: 'sr', selected: false },
{ id: 'SR2043', label: 'SR-2043 · 우선순위 High', type: 'sr', selected: false },
];
const ACTIONS = ['상태 변경', '담당자 변경', '우선순위 변경', '일괄 완료', '그룹 알림'];
export default function BatchActionScreen() {
const [items, setItems] = useState(ITEMS);
const [action, setAction] = useState('');
const [running, setRunning] = useState(false);
const toggle = (id: string) => setItems(prev => prev.map(i => i.id === id ? { ...i, selected: !i.selected } : i));
const selectAll = () => setItems(prev => prev.map(i => ({ ...i, selected: true })));
const clearAll = () => setItems(prev => prev.map(i => ({ ...i, selected: false })));
const selected = items.filter(i => i.selected);
const run = async () => {
if (!action) { Alert.alert('선택', '실행할 액션을 선택해주세요'); return; }
if (selected.length === 0) { Alert.alert('선택', '항목을 선택해주세요'); return; }
setRunning(true);
try {
await fetch(`${ITSM_BASE}/api/tasks/bulk`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ids: selected.map(i => i.id) }),
});
Alert.alert('완료', `${selected.length}개 항목에 "${action}" 실행 완료`);
clearAll();
} catch { Alert.alert('완료', `${selected.length}개 항목 처리 완료 (오프라인)`); }
setRunning(false);
};
const typeColor = (t: string) => ({ server: '#ff8800', db: '#bb44bb', sr: '#00A0C8' })[t] || '#888';
const typeIcon = (t: string) => ({ server: '🖥', db: '🗄', sr: '📋' })[t] || '•';
return (
<ScrollView style={s.container}>
<Text style={s.title}> </Text>
<Text style={s.sub}> </Text>
<View style={s.selectBar}>
<Text style={s.selectedCount}>{selected.length} </Text>
<TouchableOpacity onPress={selectAll}><Text style={s.barBtn}></Text></TouchableOpacity>
<TouchableOpacity onPress={clearAll}><Text style={s.barBtn}></Text></TouchableOpacity>
</View>
{items.map(item => (
<TouchableOpacity key={item.id} style={[s.itemRow, item.selected && s.itemRowSelected]} onPress={() => toggle(item.id)}>
<View style={[s.checkbox, item.selected && s.checkboxSelected]}>
{item.selected && <Text style={s.checkmark}></Text>}
</View>
<Text style={s.typeIcon}>{typeIcon(item.type)}</Text>
<View style={s.itemContent}>
<Text style={s.itemLabel}>{item.label}</Text>
</View>
<View style={[s.typeBadge, { backgroundColor: typeColor(item.type) + '33' }]}>
<Text style={[s.typeText, { color: typeColor(item.type) }]}>{item.type}</Text>
</View>
</TouchableOpacity>
))}
<Text style={s.sectionTitle}> </Text>
<View style={s.actionsRow}>
{ACTIONS.map(a => (
<TouchableOpacity key={a} style={[s.actionBtn, action === a && s.actionBtnActive]} onPress={() => setAction(a)}>
<Text style={[s.actionBtnText, action === a && s.actionBtnTextActive]}>{a}</Text>
</TouchableOpacity>
))}
</View>
<TouchableOpacity style={[s.runBtn, (selected.length === 0 || !action) && s.runBtnDisabled]} onPress={run} disabled={running || selected.length === 0 || !action}>
<Text style={s.runBtnText}>{running ? '처리 중...' : `${selected.length}개 일괄 실행`}</Text>
</TouchableOpacity>
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
selectBar: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#1A1F2E', borderRadius: 10, padding: 12, marginBottom: 12, gap: 12, borderWidth: 1, borderColor: '#333' },
selectedCount: { color: '#fff', fontWeight: '600', flex: 1 },
barBtn: { color: '#00A0C8', fontSize: 13 },
itemRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#1A1F2E', borderRadius: 10, padding: 14, marginBottom: 8, borderWidth: 1, borderColor: '#333', gap: 10 },
itemRowSelected: { borderColor: '#00A0C8', backgroundColor: '#00A0C822' },
checkbox: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, borderColor: '#555', alignItems: 'center', justifyContent: 'center' },
checkboxSelected: { backgroundColor: '#00A0C8', borderColor: '#00A0C8' },
checkmark: { color: '#fff', fontWeight: '700', fontSize: 14 },
typeIcon: { fontSize: 18 },
itemContent: { flex: 1 },
itemLabel: { color: '#fff', fontSize: 13 },
typeBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
typeText: { fontSize: 11, fontWeight: '600' },
sectionTitle: { color: '#fff', fontSize: 15, fontWeight: '700', marginTop: 16, marginBottom: 10 },
actionsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 16 },
actionBtn: { paddingHorizontal: 14, paddingVertical: 8, backgroundColor: '#1A1F2E', borderRadius: 10, borderWidth: 1, borderColor: '#333' },
actionBtnActive: { backgroundColor: '#003366', borderColor: '#00A0C8' },
actionBtnText: { color: '#aaa', fontSize: 13 },
actionBtnTextActive: { color: '#fff', fontWeight: '700' },
runBtn: { backgroundColor: '#00A0C8', padding: 16, borderRadius: 12, alignItems: 'center', marginTop: 8 },
runBtnDisabled: { backgroundColor: '#333' },
runBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
});

116
app/(tabs)/camera_ar.tsx Normal file
View File

@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { ITSM_BASE } from '../../services/api';
interface AROverlay { label: string; value: string; color: string; x: number; y: number }
const SAMPLE_OVERLAYS: AROverlay[] = [
{ label: 'CPU', value: '42%', color: '#44bb44', x: 20, y: 80 },
{ label: 'RAM', value: '67%', color: '#ffbb00', x: 60, y: 40 },
{ label: 'DISK', value: '81%', color: '#ff8800', x: 70, y: 70 },
{ label: 'NET', value: '1.2GB/s', color: '#44bb44', x: 30, y: 50 },
{ label: 'TEMP', value: '52°C', color: '#44bb44', x: 50, y: 20 },
];
export default function CameraARScreen() {
const [scanning, setScanning] = useState(false);
const [overlays, setOverlays] = useState<AROverlay[]>([]);
const [detectedServer, setDetectedServer] = useState<string | null>(null);
const [serverInfo, setServerInfo] = useState<any>(null);
const startScan = async () => {
setScanning(true);
setTimeout(() => {
setOverlays(SAMPLE_OVERLAYS);
setDetectedServer('app-svr-01');
setScanning(false);
}, 1500);
};
const fetchServerDetail = async (name: string) => {
try {
const r = await fetch(`${ITSM_BASE}/api/cmdb/servers?search=${name}`);
if (r.ok) { const d = await r.json(); setServerInfo(d.servers?.[0]); }
} catch { setServerInfo({ hostname: name, os: 'CentOS 7', status: 'active', role: '애플리케이션 서버' }); }
};
const createSR = async () => {
const critical = overlays.filter(o => o.color === '#ff4444' || o.color === '#ff8800');
if (critical.length === 0) { Alert.alert('정상', '감지된 이상 없음'); return; }
try {
await fetch(`${ITSM_BASE}/api/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: `[AR스캔] ${detectedServer} 리소스 이상`, description: critical.map(c => `${c.label}: ${c.value}`).join('\n'), priority: 'high' }),
});
Alert.alert('SR 등록', 'AR 스캔 기반 SR이 등록되었습니다.');
} catch { Alert.alert('오류', 'SR 등록 실패'); }
};
return (
<ScrollView style={s.container}>
<Text style={s.title}> AR </Text>
<Text style={s.sub}>/ </Text>
<View style={s.cameraView}>
<View style={s.cameraFrame}>
{!scanning && overlays.length === 0 && (
<Text style={s.cameraPlaceholder}>📷 {'\n'}( )</Text>
)}
{scanning && <Text style={s.scanningText}>🔍 ...</Text>}
{overlays.map((o, i) => (
<View key={i} style={[s.overlay, { left: `${o.x}%`, top: `${o.y}%`, borderColor: o.color }]}>
<Text style={s.overlayLabel}>{o.label}</Text>
<Text style={[s.overlayValue, { color: o.color }]}>{o.value}</Text>
</View>
))}
{detectedServer && <View style={s.detectedBadge}><Text style={s.detectedText}> {detectedServer}</Text></View>}
</View>
<TouchableOpacity style={s.scanBtn} onPress={startScan} disabled={scanning}>
<Text style={s.scanBtnText}>{scanning ? '스캔 중...' : '📡 AR 스캔 시작'}</Text>
</TouchableOpacity>
</View>
{overlays.length > 0 && (
<View style={s.metricsCard}>
<Text style={s.metricsTitle}> </Text>
<View style={s.metricsGrid}>
{overlays.map((o, i) => (
<View key={i} style={[s.metricItem, { borderColor: o.color }]}>
<Text style={s.metricLabel}>{o.label}</Text>
<Text style={[s.metricValue, { color: o.color }]}>{o.value}</Text>
</View>
))}
</View>
<TouchableOpacity style={s.srBtn} onPress={createSR}>
<Text style={s.srBtnText}>📋 SR </Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
cameraView: { backgroundColor: '#1A1F2E', borderRadius: 12, overflow: 'hidden', marginBottom: 16, borderWidth: 1, borderColor: '#333' },
cameraFrame: { height: 260, position: 'relative', alignItems: 'center', justifyContent: 'center' },
cameraPlaceholder: { color: '#555', textAlign: 'center', fontSize: 14 },
scanningText: { color: '#00A0C8', fontSize: 16, fontWeight: '600' },
overlay: { position: 'absolute', backgroundColor: 'rgba(0,0,0,0.75)', borderWidth: 1, borderRadius: 6, padding: 6 },
overlayLabel: { color: '#fff', fontSize: 10, fontWeight: '600' },
overlayValue: { fontSize: 12, fontWeight: '700' },
detectedBadge: { position: 'absolute', bottom: 12, right: 12, backgroundColor: '#003366', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, borderWidth: 1, borderColor: '#00A0C8' },
detectedText: { color: '#fff', fontSize: 12, fontWeight: '600' },
scanBtn: { backgroundColor: '#00A0C8', padding: 14, alignItems: 'center' },
scanBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
metricsCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, borderWidth: 1, borderColor: '#333' },
metricsTitle: { color: '#fff', fontWeight: '700', fontSize: 16, marginBottom: 12 },
metricsGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginBottom: 14 },
metricItem: { backgroundColor: '#0A0E1A', borderWidth: 1, borderRadius: 10, padding: 10, minWidth: '28%', alignItems: 'center' },
metricLabel: { color: '#aaa', fontSize: 11, marginBottom: 4 },
metricValue: { fontSize: 16, fontWeight: '700' },
srBtn: { backgroundColor: '#003366', padding: 12, borderRadius: 10, alignItems: 'center', borderWidth: 1, borderColor: '#00A0C8' },
srBtnText: { color: '#fff', fontWeight: '700' },
});

View File

@ -0,0 +1,80 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl, TouchableOpacity } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
type Period = '30d' | '60d' | '90d'
export default function CapacityPlanScreen() {
const [data, setData] = useState<any>(null)
const [period, setPeriod] = useState<Period>('30d')
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get(`/api/capacity/predictions?days=${period.replace('d', '')}`); setData(r.data) }
catch { setData(null) }
finally { setLoading(false) }
}, [period])
useFocusEffect(useCallback(() => { load() }, [load]))
const predictions = data?.predictions ?? data?.items ?? []
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}>
<View style={s.tabs}>
{(['30d','60d','90d'] as Period[]).map(p => (
<TouchableOpacity key={p} style={[s.tab, period===p && s.tabActive]} onPress={() => setPeriod(p)}>
<Text style={[s.tabText, period===p && s.tabTextActive]}>{p}</Text>
</TouchableOpacity>
))}
</View>
{predictions.map((item: any, i: number) => {
const util = item.predicted_utilization ?? item.cpu_avg ?? 0
const color = util >= 90 ? COLORS.danger : util >= 70 ? COLORS.warning : COLORS.success
return (
<View key={i} style={s.card}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.serverName}>{item.server_name ?? item.name ?? 'N/A'}</Text>
<Text style={s.meta}>{item.inst_name ?? ''} · {item.resource_type ?? 'CPU'}</Text>
</View>
<Text style={[s.pct, { color }]}>{util}%</Text>
</View>
<View style={s.barBg}>
<View style={[s.barFill, { width: `${Math.min(100, util)}%`, backgroundColor: color }]} />
</View>
{item.recommendation && (
<Text style={s.rec}>AI : {item.recommendation}</Text>
)}
</View>
)
})}
{predictions.length === 0 && !loading && (
<Text style={s.empty}> .</Text>
)}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
tabs: { flexDirection: 'row', backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border },
tab: { flex: 1, paddingVertical: 12, alignItems: 'center' },
tabActive: { borderBottomWidth: 2, borderBottomColor: COLORS.accent },
tabText: { fontSize: 13, color: COLORS.muted },
tabTextActive:{ color: COLORS.accent, fontWeight: '700' },
card: { backgroundColor: '#fff', margin: 8, marginBottom: 0, borderRadius: 10, padding: 14, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
serverName: { fontSize: 14, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
pct: { fontSize: 22, fontWeight: '800' },
barBg: { height: 6, backgroundColor: COLORS.border, borderRadius: 3, marginBottom: 6 },
barFill: { height: 6, borderRadius: 3 },
rec: { fontSize: 12, color: COLORS.blue, fontStyle: 'italic' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react'
import {
View, Text, TouchableOpacity, StyleSheet, FlatList, ActivityIndicator,
} from 'react-native'
import { COLORS } from '../../constants/Config'
import { getChangeCalendar } from '../../services/api'
const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']
export default function ChangeCalendarScreen() {
const [now] = useState(new Date())
const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth() + 1)
const [changes, setChanges] = useState<any[]>([])
const [selectedDay, setSelectedDay] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
getChangeCalendar(`${year}-${String(month).padStart(2, '0')}`)
.then(r => setChanges(r.data?.items ?? r.data ?? []))
.catch(() => setChanges([]))
.finally(() => setLoading(false))
}, [year, month])
const prevMonth = () => { if (month === 1) { setYear(y => y-1); setMonth(12) } else setMonth(m => m-1) }
const nextMonth = () => { if (month === 12) { setYear(y => y+1); setMonth(1) } else setMonth(m => m+1) }
const daysInMonth = new Date(year, month, 0).getDate()
const firstDay = new Date(year, month - 1, 1).getDay()
const hasChange = (day: number) =>
changes.some(c => new Date(c.scheduled_at ?? c.created_at).getDate() === day)
const dayChanges = selectedDay
? changes.filter(c => new Date(c.scheduled_at ?? c.created_at).getDate() === selectedDay)
: []
const cells: (number | null)[] = [
...Array(firstDay).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
]
return (
<View style={s.container}>
{/* 헤더 */}
<View style={s.header}>
<TouchableOpacity onPress={prevMonth}><Text style={s.arrow}></Text></TouchableOpacity>
<Text style={s.month}>{year} {month}</Text>
<TouchableOpacity onPress={nextMonth}><Text style={s.arrow}></Text></TouchableOpacity>
</View>
{/* 요일 */}
<View style={s.weekRow}>
{WEEKDAYS.map(d => <Text key={d} style={[s.weekDay, d==='일'&&{color:COLORS.danger}, d==='토'&&{color:COLORS.accent}]}>{d}</Text>)}
</View>
{/* 날짜 그리드 */}
{loading ? <ActivityIndicator color={COLORS.accent} style={{ marginTop: 20 }} /> : (
<View style={s.grid}>
{cells.map((day, idx) => (
<TouchableOpacity
key={idx}
style={[s.cell, day === selectedDay && s.cellSelected]}
onPress={() => day && setSelectedDay(day === selectedDay ? null : day)}
disabled={!day}
>
{day && (
<>
<Text style={[s.dayText, day === selectedDay && s.dayTextSelected]}>{day}</Text>
{hasChange(day) && <View style={s.dot} />}
</>
)}
</TouchableOpacity>
))}
</View>
)}
{/* 선택 날짜 변경 목록 */}
{selectedDay && (
<View style={s.list}>
<Text style={s.listTitle}>{month}/{selectedDay} </Text>
{dayChanges.length === 0
? <Text style={s.empty}> .</Text>
: dayChanges.map((c, i) => (
<View key={i} style={s.changeCard}>
<Text style={s.changeTitle}>{c.title ?? c.subject}</Text>
<Text style={s.changeMeta}>{c.status} · {c.requester ?? c.requested_by}</Text>
</View>
))
}
</View>
)}
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 16, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border },
arrow: { fontSize: 24, color: COLORS.accent, paddingHorizontal: 8 },
month: { fontSize: 16, fontWeight: '700', color: COLORS.text },
weekRow: { flexDirection: 'row', backgroundColor: '#fff', paddingVertical: 6 },
weekDay: { flex: 1, textAlign: 'center', fontSize: 12, fontWeight: '600', color: COLORS.muted },
grid: { flexDirection: 'row', flexWrap: 'wrap', backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border },
cell: { width: '14.28%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },
cellSelected: { backgroundColor: COLORS.light },
dayText: { fontSize: 14, color: COLORS.text },
dayTextSelected: { color: COLORS.accent, fontWeight: '700' },
dot: { width: 4, height: 4, borderRadius: 2, backgroundColor: COLORS.accent, marginTop: 2 },
list: { flex: 1, padding: 14 },
listTitle: { fontSize: 15, fontWeight: '700', color: COLORS.text, marginBottom: 10 },
empty: { color: COLORS.muted, fontSize: 14 },
changeCard: { backgroundColor: '#fff', borderRadius: 8, padding: 10, marginBottom: 6, elevation: 1 },
changeTitle: { fontSize: 14, fontWeight: '600', color: COLORS.text },
changeMeta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
})

View File

@ -3,40 +3,109 @@ import {
View, Text, TextInput, TouchableOpacity, ScrollView,
StyleSheet, KeyboardAvoidingView, Platform, ActivityIndicator,
} from 'react-native'
import { COLORS } from '../../constants/Config'
import { router } from 'expo-router'
import { COLORS, API_BASE } from '../../constants/Config'
import { sendAIMessage } from '../../services/api'
import { useAuth } from '../../hooks/useAuth'
import { authFetch } from '../../utils/auth'
import LineIcon from '../../components/LineIcon'
import { VoiceInput } from '../../components/VoiceInput'
import { NextActions } from '../../components/NextActions'
import { generateJSON, DEFAULT_TEXT_MODEL } from '../../lib/ollama'
interface Msg { id: number; role: 'user' | 'ai'; text: string; time: string }
const QUICK = ['서버 상태 확인', 'SR 목록 보여줘', '최근 인시던트', '라이선스 현황']
// Ollama가 자연어를 분류하는 명령 스키마
interface Command {
intent: 'query_server' | 'create_sr' | 'query_sr' | 'open_screen' | 'chat'
server_id?: string
screen?: string
reply?: string
}
const QUICK = ['서버 상태 확인', 'SR 목록 보여줘', '최근 인시던트', '긴급 SR 등록']
export default function ChatScreen() {
const { user } = useAuth()
const [msgs, setMsgs] = useState<Msg[]>([
{ id: 0, role: 'ai', text: `안녕하세요 ${user?.display_name ?? ''}님! 👋\nGUARDiA AI 어시스턴트입니다.\n무엇을 도와드릴까요?`, time: now() },
const [msgs, setMsgs] = useState<Msg[]>([
{ id: 0, role: 'ai', text: `안녕하세요 ${user?.display_name ?? ''}님! 👋\nGUARDiA AI 어시스턴트입니다.\n자연어로 명령하시면 SR 등록·조회를 자동 실행합니다.`, time: now() },
])
const [input, setInput] = useState('')
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [lastContext, setLastContext] = useState('AI 챗봇 대기 중')
const scrollRef = useRef<ScrollView>(null)
function now() { return new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) }
function pushAI(text: string) {
setMsgs(m => [...m, { id: Date.now() + Math.random(), role: 'ai', text, time: now() }])
}
// 자연어 → Ollama 의도 분류 → 명령 실행
async function classifyAndRun(text: string): Promise<string> {
const prompt =
`당신은 ITSM 운영 어시스턴트입니다. 운영자 입력: "${text}". ` +
`의도를 JSON으로만 분류하세요: ` +
`{"intent":"query_server|create_sr|query_sr|open_screen|chat",` +
`"server_id":"서버ID(있으면)","screen":"sr|dr|network|notifications(있으면)","reply":"chat일 때 한국어 답변"}`
const cmd = await generateJSON<Command>(DEFAULT_TEXT_MODEL, prompt, { intent: 'chat', reply: '' })
switch (cmd.intent) {
case 'query_server': {
if (!cmd.server_id) return '조회할 서버를 알려주세요. (예: "서버 001 상태")'
try {
const res = await authFetch(`${API_BASE}/api/servers/${encodeURIComponent(cmd.server_id)}`)
if (res.ok) {
const d = await res.json()
// 보안: ip/ssh/pw 등 민감 필드는 표시하지 않음
const name = d.name ?? d.hostname ?? cmd.server_id
const status = d.status ?? d.state ?? '알 수 없음'
return `🖥️ 서버 ${name}\n상태: ${status}${d.cpu ? `\nCPU: ${d.cpu}%` : ''}${d.mem ? `\n메모리: ${d.mem}%` : ''}`
}
return `서버 ${cmd.server_id} 정보를 가져오지 못했습니다.`
} catch {
return '서버 조회 중 오류가 발생했습니다.'
}
}
case 'create_sr':
setTimeout(() => router.push('/sr'), 400)
return '📋 SR 등록 화면을 엽니다.'
case 'query_sr':
setTimeout(() => router.push('/sr'), 400)
return '📋 SR 목록 화면으로 이동합니다.'
case 'open_screen': {
const screen = cmd.screen ?? 'index'
setTimeout(() => router.push(`/${screen}` as any), 400)
return `화면(${screen})으로 이동합니다.`
}
default:
return cmd.reply || ''
}
}
const send = async (text = input) => {
if (!text.trim() || loading) return
const userMsg: Msg = { id: Date.now(), role: 'user', text: text.trim(), time: now() }
setMsgs(m => [...m, userMsg])
const trimmed = text.trim()
setMsgs(m => [...m, { id: Date.now(), role: 'user', text: trimmed, time: now() }])
setInput('')
setLoading(true)
setLastContext(`사용자 요청: ${trimmed}`)
try {
const r = await sendAIMessage(text.trim())
const reply = r.data?.reply ?? r.data?.message ?? r.data?.response ?? '응답을 받았습니다.'
setMsgs(m => [...m, { id: Date.now()+1, role: 'ai', text: reply, time: now() }])
// 1) Ollama 의도 분류 + 명령 실행
const local = await classifyAndRun(trimmed)
if (local) {
pushAI(local)
} else {
// 2) 폴백: ITSM 챗봇 API
const r = await sendAIMessage(trimmed)
const reply = r.data?.reply ?? r.data?.message ?? r.data?.response ?? '응답을 받았습니다.'
pushAI(reply)
}
} catch {
setMsgs(m => [...m, { id: Date.now()+1, role: 'ai',
text: '현재 AI 서버에 연결할 수 없습니다. Ollama 서버 상태를 확인해주세요.', time: now() }])
} finally { setLoading(false) }
pushAI('현재 AI 서버에 연결할 수 없습니다. Ollama 서버 상태를 확인해주세요.')
} finally {
setLoading(false)
}
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100)
}
@ -45,14 +114,13 @@ export default function ChatScreen() {
}, [msgs])
return (
<KeyboardAvoidingView style={{ flex:1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={s.container}>
{/* 메시지 목록 */}
<ScrollView ref={scrollRef} style={s.messages} contentContainerStyle={{ padding: 16 }}>
{msgs.map(m => (
<View key={m.id} style={[s.msgRow, m.role === 'user' && s.userRow]}>
{m.role === 'ai' && (
<View style={[s.avatar, { backgroundColor:'rgba(0,160,200,.12)', borderRadius:20 }]}>
<View style={[s.avatar, { backgroundColor: 'rgba(0,160,200,.12)', borderRadius: 20 }]}>
<LineIcon name="ai" size={20} color={COLORS.accent} />
</View>
)}
@ -64,7 +132,7 @@ export default function ChatScreen() {
))}
{loading && (
<View style={s.msgRow}>
<View style={[s.avatar, { backgroundColor:'rgba(0,160,200,.12)', borderRadius:20 }]}>
<View style={[s.avatar, { backgroundColor: 'rgba(0,160,200,.12)', borderRadius: 20 }]}>
<LineIcon name="ai" size={20} color={COLORS.accent} />
</View>
<View style={s.aiBubble}>
@ -72,9 +140,15 @@ export default function ChatScreen() {
</View>
</View>
)}
{/* #20 다음 명령 제안 */}
{!loading && (
<View style={{ marginTop: 8 }}>
<NextActions context={lastContext} onSelect={a => send(a)} />
</View>
)}
</ScrollView>
{/* 빠른 질문 */}
<ScrollView horizontal showsHorizontalScrollIndicator={false}
style={s.quickScroll} contentContainerStyle={{ paddingHorizontal: 12, gap: 8 }}>
{QUICK.map(q => (
@ -84,13 +158,14 @@ export default function ChatScreen() {
))}
</ScrollView>
{/* 입력창 */}
<View style={s.inputRow}>
{/* #25 음성 입력 → 입력창 자동 전달 (ko-KR) */}
<VoiceInput onTranscript={t => { if (t) setInput(t) }} size="small" />
<TextInput
style={s.textInput}
value={input}
onChangeText={setInput}
placeholder="GUARDiA AI에게 질문하세요..."
placeholder="GUARDiA AI에게 명령하세요..."
placeholderTextColor={COLORS.muted}
multiline
maxLength={500}
@ -108,29 +183,22 @@ export default function ChatScreen() {
}
const s = StyleSheet.create({
container: { flex:1, backgroundColor:COLORS.bg },
messages: { flex:1 },
msgRow: { flexDirection:'row', alignItems:'flex-end', marginBottom:12, gap:8 },
userRow: { flexDirection:'row-reverse' },
avatar: { width:38, height:38, alignItems:'center', justifyContent:'center', marginBottom:4 },
bubble: { maxWidth:'75%', borderRadius:16, padding:12 },
aiBubble: { backgroundColor:'#fff', borderBottomLeftRadius:4,
shadowColor:'#000', shadowOffset:{width:0,height:1}, shadowOpacity:.06, elevation:1 },
userBubble: { backgroundColor:COLORS.accent, borderBottomRightRadius:4 },
bubbleText: { fontSize:14, color:COLORS.text, lineHeight:20 },
userText: { color:'#fff' },
timeText: { fontSize:10, color:COLORS.muted, marginTop:4, textAlign:'right' },
quickScroll: { maxHeight:44, borderTopWidth:1, borderTopColor:COLORS.border, backgroundColor:'#fff' },
quickChip: { alignSelf:'center', backgroundColor:COLORS.light, paddingHorizontal:12,
paddingVertical:6, borderRadius:20 },
quickChipText: { fontSize:12, color:COLORS.accent, fontWeight:'500' },
inputRow: { flexDirection:'row', padding:12, backgroundColor:'#fff',
borderTopWidth:1, borderTopColor:COLORS.border, alignItems:'flex-end', gap:8 },
textInput: { flex:1, borderWidth:1.5, borderColor:COLORS.border, borderRadius:20,
paddingHorizontal:14, paddingVertical:10, fontSize:14, color:COLORS.text,
maxHeight:100, backgroundColor:'#fafafa' },
sendBtn: { width:42, height:42, borderRadius:21, backgroundColor:COLORS.accent,
justifyContent:'center', alignItems:'center' },
sendDisabled:{ opacity:.4 },
sendIcon: { color:'#fff', fontSize:16, marginLeft:2 },
container: { flex: 1, backgroundColor: COLORS.bg },
messages: { flex: 1 },
msgRow: { flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12, gap: 8 },
userRow: { flexDirection: 'row-reverse' },
avatar: { width: 38, height: 38, alignItems: 'center', justifyContent: 'center', marginBottom: 4 },
bubble: { maxWidth: '75%', borderRadius: 16, padding: 12 },
aiBubble: { backgroundColor: '#fff', borderBottomLeftRadius: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: .06, elevation: 1 },
userBubble: { backgroundColor: COLORS.accent, borderBottomRightRadius: 4 },
bubbleText: { fontSize: 14, color: COLORS.text, lineHeight: 20 },
userText: { color: '#fff' },
timeText: { fontSize: 10, color: COLORS.muted, marginTop: 4, textAlign: 'right' },
quickScroll: { maxHeight: 44, borderTopWidth: 1, borderTopColor: COLORS.border, backgroundColor: '#fff' },
quickChip: { alignSelf: 'center', backgroundColor: COLORS.light, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20 },
quickChipText: { fontSize: 12, color: COLORS.accent, fontWeight: '500' },
inputRow: { flexDirection: 'row', padding: 12, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: COLORS.border, alignItems: 'flex-end', gap: 8 },
textInput: { flex: 1, borderWidth: 1.5, borderColor: COLORS.border, borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 14, color: COLORS.text, maxHeight: 100, backgroundColor: '#fafafa' },
sendBtn: { width: 42, height: 42, borderRadius: 21, backgroundColor: COLORS.accent, justifyContent: 'center', alignItems: 'center' },
sendDisabled: { opacity: .4 },
})

View File

@ -0,0 +1,71 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function CitizenRequestsScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/citizen/requests'); setItems(r.data?.requests ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const assign = async (item: any) => {
try {
await client.post('/api/tasks', { title: `[민원] ${item.title ?? item.subject}`, description: item.description ?? item.content, priority: 'HIGH', sr_type: 'REQUEST', source: 'citizen_portal' })
Alert.alert('완료', 'SR로 전환됐습니다.')
load()
} catch { Alert.alert('오류', '전환에 실패했습니다.') }
}
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={<Text style={s.header}> </Text>}
renderItem={({ item }) => (
<View style={s.card}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={1}>{item.title ?? item.subject}</Text>
<Text style={s.meta}>{item.citizen_name ?? '익명'} · {item.created_at?.slice(0, 16) ?? ''}</Text>
</View>
<View style={[s.statusBadge, { backgroundColor: item.status === 'pending' ? COLORS.warning + '20' : COLORS.success + '20' }]}>
<Text style={[s.statusText, { color: item.status === 'pending' ? COLORS.warning : COLORS.success }]}>{item.status === 'pending' ? '대기' : '처리중'}</Text>
</View>
</View>
<Text style={s.desc} numberOfLines={2}>{item.description ?? item.content ?? ''}</Text>
{item.status === 'pending' && (
<TouchableOpacity style={s.srBtn} onPress={() => assign(item)}>
<Text style={s.srText}>SR </Text>
</TouchableOpacity>
)}
</View>
)}
/>
)
}
const s = StyleSheet.create({
header: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 12 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 },
title: { fontSize: 13, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 11, color: COLORS.muted, marginTop: 2 },
statusBadge: { borderRadius: 4, paddingHorizontal: 8, paddingVertical: 3 },
statusText: { fontSize: 11, fontWeight: '700' },
desc: { fontSize: 12, color: COLORS.muted, marginBottom: 10 },
srBtn: { backgroundColor: COLORS.blue + '15', borderRadius: 6, padding: 8, alignItems: 'center' },
srText: { color: COLORS.blue, fontSize: 12, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,87 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
const SAVING_COLOR: Record<string, string> = { HIGH: COLORS.success, MEDIUM: COLORS.warning, LOW: COLORS.muted }
export default function CostAdviceScreen() {
const [items, setItems] = useState<any[]>([])
const [savings, setSavings] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [r, s] = await Promise.all([
client.get('/api/cost-optimizer/recommendations'),
client.get('/api/mobile2/savings-dashboard'),
])
setItems(r.data?.recommendations ?? r.data?.items ?? [])
setSavings(s.data)
} catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const apply = (item: any) => {
Alert.alert('조치 확인', `${item.title ?? item.resource}에 대한 비용 절감 조치를 SR로 등록하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: 'SR 등록', onPress: async () => {
try {
await client.post('/api/tasks', { title: `비용 절감 조치: ${item.title ?? item.resource}`, description: item.action ?? item.description, priority: 'MEDIUM', sr_type: 'CHANGE' })
Alert.alert('완료', 'SR이 등록됐습니다.')
} catch { Alert.alert('오류', 'SR 등록에 실패했습니다.') }
}},
])
}
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
ListHeaderComponent={savings && (
<View style={s.savings}>
<Text style={s.savingsLabel}> </Text>
<Text style={s.savingsAmount}>{(savings.total_saved_krw ?? 0).toLocaleString()}</Text>
</View>
)}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => {
const impact = item.impact ?? item.priority ?? 'MEDIUM'
return (
<View style={s.card}>
<View style={s.header}>
<Text style={[s.badge, { backgroundColor: SAVING_COLOR[impact] ?? COLORS.muted }]}>{impact}</Text>
<Text style={s.saving}> {item.estimated_saving_krw ? `${item.estimated_saving_krw.toLocaleString()}` : '-'}</Text>
</View>
<Text style={s.title}>{item.title ?? item.resource}</Text>
<Text style={s.desc} numberOfLines={2}>{item.action ?? item.description ?? ''}</Text>
<TouchableOpacity style={s.applyBtn} onPress={() => apply(item)}>
<Text style={s.applyText}>SR로 </Text>
</TouchableOpacity>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
savings: { backgroundColor: COLORS.success, borderRadius: 12, padding: 16, marginBottom: 8, alignItems: 'center' },
savingsLabel: { color: '#fff', fontSize: 12, opacity: 0.9 },
savingsAmount: { color: '#fff', fontSize: 28, fontWeight: '800', marginTop: 4 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
badge: { color: '#fff', fontSize: 10, fontWeight: '700', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 4 },
saving: { fontSize: 12, color: COLORS.success, fontWeight: '700' },
title: { fontSize: 14, fontWeight: '700', color: COLORS.text, marginBottom: 4 },
desc: { fontSize: 12, color: COLORS.muted, marginBottom: 8 },
applyBtn: { backgroundColor: COLORS.light, borderRadius: 6, padding: 8, alignItems: 'center' },
applyText: { color: COLORS.blue, fontSize: 12, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

101
app/(tabs)/cowork_sr.tsx Normal file
View File

@ -0,0 +1,101 @@
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, ScrollView, TextInput, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform } from 'react-native';
import { ITSM_BASE } from '../../services/api';
interface CoworkMsg { id: string; author: string; role: string; text: string; ts: string; type: 'chat' | 'action' | 'status' }
const SAMPLE_MSGS: CoworkMsg[] = [
{ id: '1', author: '김철수', role: 'engineer', text: 'db-01 서버 CPU 90% 넘어갔어요. 확인 부탁드립니다.', ts: '10:02', type: 'chat' },
{ id: '2', author: 'AI', role: 'ai', text: '원인 분석 완료: db-01 슬로우 쿼리 20개 감지. 쿼리 최적화 또는 서버 재시작 권장.', ts: '10:02', type: 'action' },
{ id: '3', author: '이영희', role: 'pm', text: 'SR 우선순위 Critical로 변경했습니다.', ts: '10:03', type: 'status' },
];
export default function CoworkSRScreen() {
const [srId] = useState('SR-2042');
const [messages, setMessages] = useState<CoworkMsg[]>(SAMPLE_MSGS);
const [input, setInput] = useState('');
const [participants] = useState([{ name: '김철수', role: 'engineer', online: true }, { name: '이영희', role: 'pm', online: true }, { name: 'AI', role: 'ai', online: true }]);
const scrollRef = useRef<ScrollView>(null);
const send = async () => {
if (!input.trim()) return;
const msg: CoworkMsg = { id: Date.now().toString(), author: '나', role: 'engineer', text: input, ts: new Date().toLocaleTimeString('ko', { hour: '2-digit', minute: '2-digit' }), type: 'chat' };
setMessages(prev => [...prev, msg]);
setInput('');
try {
await fetch(`${ITSM_BASE}/api/sr-chat/${srId}/messages`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: input }),
});
} catch {}
setTimeout(() => scrollRef.current?.scrollToEnd(), 100);
};
const roleColor = (role: string) => ({ engineer: '#00A0C8', pm: '#ffbb00', ai: '#44bb44', sm: '#bb44bb' })[role] || '#888';
return (
<KeyboardAvoidingView style={s.container} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={s.header}>
<Text style={s.srTitle}>{srId} </Text>
<View style={s.participants}>
{participants.map((p, i) => (
<View key={i} style={[s.avatar, { backgroundColor: roleColor(p.role) + '33', borderColor: roleColor(p.role) }]}>
<Text style={[s.avatarText, { color: roleColor(p.role) }]}>{p.name[0]}</Text>
{p.online && <View style={s.onlineDot} />}
</View>
))}
</View>
</View>
<ScrollView ref={scrollRef} style={s.messages} onContentSizeChange={() => scrollRef.current?.scrollToEnd()}>
{messages.map(msg => (
<View key={msg.id} style={[s.msgRow, msg.author === '나' && s.msgRowRight]}>
{msg.author !== '나' && (
<View style={[s.msgAvatar, { backgroundColor: roleColor(msg.role) + '33' }]}>
<Text style={[s.msgAvatarText, { color: roleColor(msg.role) }]}>{msg.author[0]}</Text>
</View>
)}
<View style={[s.msgBubble, msg.type === 'action' && s.msgBubbleAction, msg.type === 'status' && s.msgBubbleStatus, msg.author === '나' && s.msgBubbleSelf]}>
{msg.author !== '나' && <Text style={[s.msgAuthor, { color: roleColor(msg.role) }]}>{msg.author}</Text>}
<Text style={s.msgText}>{msg.text}</Text>
<Text style={s.msgTs}>{msg.ts}</Text>
</View>
</View>
))}
</ScrollView>
<View style={s.inputRow}>
<TextInput style={s.input} value={input} onChangeText={setInput} placeholder="메시지 입력..." placeholderTextColor="#555" returnKeyType="send" onSubmitEditing={send} />
<TouchableOpacity style={s.sendBtn} onPress={send}>
<Text style={s.sendText}></Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A' },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 14, borderBottomWidth: 1, borderBottomColor: '#333' },
srTitle: { color: '#fff', fontWeight: '700', fontSize: 16 },
participants: { flexDirection: 'row', gap: 6 },
avatar: { width: 30, height: 30, borderRadius: 15, borderWidth: 1, alignItems: 'center', justifyContent: 'center', position: 'relative' },
avatarText: { fontSize: 13, fontWeight: '700' },
onlineDot: { position: 'absolute', bottom: 0, right: 0, width: 8, height: 8, borderRadius: 4, backgroundColor: '#44bb44', borderWidth: 1, borderColor: '#0A0E1A' },
messages: { flex: 1, padding: 12 },
msgRow: { flexDirection: 'row', marginBottom: 12, alignItems: 'flex-end' },
msgRowRight: { justifyContent: 'flex-end' },
msgAvatar: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center', marginRight: 8 },
msgAvatarText: { fontSize: 12, fontWeight: '700' },
msgBubble: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 10, maxWidth: '75%', borderWidth: 1, borderColor: '#333' },
msgBubbleAction: { borderColor: '#44bb44', backgroundColor: '#44bb4422' },
msgBubbleStatus: { borderColor: '#ffbb00', backgroundColor: '#ffbb0022' },
msgBubbleSelf: { backgroundColor: '#003366', borderColor: '#00A0C8' },
msgAuthor: { fontSize: 11, fontWeight: '700', marginBottom: 4 },
msgText: { color: '#fff', fontSize: 14 },
msgTs: { color: '#555', fontSize: 10, marginTop: 4, textAlign: 'right' },
inputRow: { flexDirection: 'row', padding: 12, borderTopWidth: 1, borderTopColor: '#333' },
input: { flex: 1, backgroundColor: '#1A1F2E', borderRadius: 10, color: '#fff', paddingHorizontal: 14, paddingVertical: 10, borderWidth: 1, borderColor: '#333', marginRight: 10 },
sendBtn: { backgroundColor: '#00A0C8', paddingHorizontal: 16, borderRadius: 10, justifyContent: 'center' },
sendText: { color: '#fff', fontWeight: '700' },
});

View File

@ -0,0 +1,79 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function CSAPAuditPrepScreen() {
const [items, setItems] = useState<any[]>([])
const [summary, setSummary] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [r, s] = await Promise.all([
client.get('/api/compliance/csap/items'),
client.get('/api/compliance/csap/dashboard'),
])
setItems(r.data?.items ?? r.data ?? [])
setSummary(s.data)
} catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const autoSR = async (item: any) => {
try {
await client.post('/api/tasks', { title: `CSAP 조치: ${item.control_id} ${item.title}`, description: item.remediation ?? item.description, priority: 'HIGH', sr_type: 'COMPLIANCE' })
Alert.alert('완료', 'SR이 등록됐습니다.')
} catch { Alert.alert('오류', 'SR 등록에 실패했습니다.') }
}
const failItems = items.filter(i => i.status === 'fail' || i.status === 'FAIL')
const passCount = items.length - failItems.length
const pct = items.length > 0 ? Math.round((passCount / items.length) * 100) : 0
return (
<FlatList
data={failItems}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}>{loading ? '' : '미준수 항목이 없습니다. CSAP 심사 준비 완료!'}</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={
<View style={s.header}>
<View style={s.pctRow}>
<Text style={s.pctNum}>{pct}%</Text>
<Text style={s.pctLabel}>CSAP </Text>
</View>
<Text style={s.sub}> {failItems.length} / {items.length}</Text>
</View>
}
renderItem={({ item }) => (
<View style={[s.card, { borderLeftWidth: 4, borderLeftColor: COLORS.danger }]}>
<Text style={s.ctrlId}>{item.control_id} {item.title ?? item.name}</Text>
<Text style={s.desc} numberOfLines={2}>{item.description ?? ''}</Text>
<TouchableOpacity style={s.btn} onPress={() => autoSR(item)}>
<Text style={s.btnText}> SR </Text>
</TouchableOpacity>
</View>
)}
/>
)
}
const s = StyleSheet.create({
header: { backgroundColor: '#fff', borderRadius: 12, padding: 16, marginBottom: 12, alignItems: 'center', elevation: 2 },
pctRow: { flexDirection: 'row', alignItems: 'baseline', gap: 8 },
pctNum: { fontSize: 52, fontWeight: '900', color: COLORS.accent },
pctLabel:{ fontSize: 14, color: COLORS.muted },
sub: { fontSize: 12, color: COLORS.muted, marginTop: 4 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
ctrlId: { fontSize: 13, fontWeight: '700', color: COLORS.text, marginBottom: 4 },
desc: { fontSize: 12, color: COLORS.muted, marginBottom: 10 },
btn: { backgroundColor: COLORS.danger + '15', borderRadius: 6, padding: 8, alignItems: 'center' },
btnText: { color: COLORS.danger, fontSize: 12, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.success, marginTop: 40, fontSize: 14, fontWeight: '700' },
})

View File

@ -0,0 +1,106 @@
import React, { useState, useCallback } from 'react'
import {
View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, RefreshControl,
} from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getCSAPDashboard, getCSAPItems, createSR } from '../../services/api'
function DonutGauge({ score }: { score: number }) {
const color = score >= 85 ? COLORS.success : score >= 70 ? COLORS.warning : COLORS.danger
return (
<View style={g.wrap}>
<View style={[g.ring, { borderColor: COLORS.border }]}>
<View style={[g.progress, { borderColor: color, transform: [{ rotate: `${score * 3.6}deg` }] }]} />
<View style={g.inner}>
<Text style={[g.score, { color }]}>{score}%</Text>
<Text style={g.label}>CSAP</Text>
</View>
</View>
</View>
)
}
const g = StyleSheet.create({
wrap: { alignItems: 'center', marginVertical: 20 },
ring: { width: 120, height: 120, borderRadius: 60, borderWidth: 12, justifyContent: 'center', alignItems: 'center' },
progress: { position: 'absolute', width: 120, height: 120, borderRadius: 60, borderWidth: 12, borderTopColor: 'transparent', borderRightColor: 'transparent' },
inner: { alignItems: 'center' },
score: { fontSize: 26, fontWeight: '800' },
label: { fontSize: 11, color: COLORS.muted, fontWeight: '600' },
})
export default function CSAPDashboardScreen() {
const [dashboard, setDashboard] = useState<any>(null)
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [d, i] = await Promise.all([getCSAPDashboard(), getCSAPItems()])
setDashboard(d.data)
setItems((i.data?.items ?? i.data ?? []).filter((x: any) => x.status === 'non_compliant' || x.result === 'FAIL'))
} catch {} finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const createActionSR = async (item: any) => {
Alert.alert('즉시 조치 SR 등록', `"${item.title ?? item.check_item}" 미준수 항목으로 SR을 등록하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '등록', onPress: async () => {
try {
await createSR({ title: `[CSAP] ${item.title ?? item.check_item}`, description: item.description ?? '미준수 항목 조치 필요', priority: 'HIGH', sr_type: 'OTHER' })
Alert.alert('완료', 'SR이 등록됐습니다.')
} catch { Alert.alert('오류', 'SR 등록에 실패했습니다.') }
}},
])
}
const score = dashboard?.overall_score ?? dashboard?.compliance_rate ?? 0
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}>
<DonutGauge score={Math.round(score)} />
{/* 영역별 바 */}
{(dashboard?.domains ?? []).map((d: any, i: number) => (
<View key={i} style={s.domainRow}>
<Text style={s.domainName}>{d.name}</Text>
<View style={s.barBg}>
<View style={[s.barFill, { width: `${d.rate ?? 0}%`, backgroundColor: d.rate >= 80 ? COLORS.success : COLORS.warning }]} />
</View>
<Text style={s.domainRate}>{d.rate ?? 0}%</Text>
</View>
))}
{/* 미준수 항목 */}
<Text style={s.sectionTitle}> ({items.length})</Text>
{items.map((item, i) => (
<View key={i} style={s.card}>
<Text style={s.itemTitle} numberOfLines={2}>{item.title ?? item.check_item}</Text>
<Text style={s.itemDesc} numberOfLines={2}>{item.description ?? item.detail ?? '-'}</Text>
<TouchableOpacity style={s.srBtn} onPress={() => createActionSR(item)}>
<Text style={s.srBtnText}> SR </Text>
</TouchableOpacity>
</View>
))}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
domainRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 6, gap: 8 },
domainName: { width: 80, fontSize: 12, color: COLORS.text },
barBg: { flex: 1, height: 8, backgroundColor: COLORS.border, borderRadius: 4 },
barFill: { height: 8, borderRadius: 4 },
domainRate: { width: 40, fontSize: 12, color: COLORS.muted, textAlign: 'right' },
sectionTitle: { fontSize: 15, fontWeight: '700', color: COLORS.text, padding: 16, paddingBottom: 8 },
card: { backgroundColor: '#fff', borderRadius: 10, margin: 12, marginTop: 0, padding: 14, elevation: 1 },
itemTitle: { fontSize: 14, fontWeight: '600', color: COLORS.text, marginBottom: 4 },
itemDesc: { fontSize: 12, color: COLORS.muted, marginBottom: 10 },
srBtn: { backgroundColor: COLORS.danger, borderRadius: 6, padding: 8, alignItems: 'center' },
srBtnText: { color: '#fff', fontWeight: '700', fontSize: 12 },
})

97
app/(tabs)/cve_detail.tsx Normal file
View File

@ -0,0 +1,97 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
const SEV_COLOR: Record<string, string> = { CRITICAL: '#dc2626', HIGH: '#f97316', MEDIUM: COLORS.warning, LOW: COLORS.muted, NONE: '#94a3b8' }
export default function CVEDetailScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/patches/cve'); setItems(r.data?.cves ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const applyPatch = async (item: any) => {
Alert.alert('패치 적용', `${item.cve_id}를 대상 서버에 적용하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '적용', style: 'destructive', onPress: async () => {
try {
await client.post(`/api/patches/${item.cve_id}/apply`)
Alert.alert('완료', '패치 적용 SR이 생성됐습니다.')
load()
} catch { Alert.alert('오류', '패치 적용에 실패했습니다.') }
}},
])
}
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}>CVE .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => {
const sev = item.severity ?? 'MEDIUM'
const color = SEV_COLOR[sev] ?? COLORS.muted
const cvss = item.cvss_score ?? item.score ?? 0
return (
<View style={s.card}>
<View style={s.header}>
<Text style={[s.cveId, { color }]}>{item.cve_id ?? item.id}</Text>
<View style={[s.sevBadge, { backgroundColor: color }]}>
<Text style={s.sevText}>{sev}</Text>
</View>
</View>
<View style={s.cvssRow}>
<Text style={s.cvssLabel}>CVSS</Text>
<View style={s.cvssBar}>
<View style={[s.cvssFill, { width: `${(cvss / 10) * 100}%`, backgroundColor: color }]} />
</View>
<Text style={[s.cvssScore, { color }]}>{Number(cvss).toFixed(1)}</Text>
</View>
<Text style={s.desc} numberOfLines={2}>{item.description ?? item.summary ?? ''}</Text>
<View style={s.meta}>
<Text style={s.metaText}> : {item.affected_count ?? item.servers?.length ?? 0}</Text>
<Text style={s.metaText}>: {item.published_at?.slice(0, 10) ?? '-'}</Text>
</View>
{item.status !== 'PATCHED' && (
<TouchableOpacity style={s.patchBtn} onPress={() => applyPatch(item)}>
<Text style={s.patchText}> </Text>
</TouchableOpacity>
)}
{item.status === 'PATCHED' && <Text style={[s.patched]}> </Text>}
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 12, padding: 14, marginBottom: 8, elevation: 1 },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
cveId: { fontSize: 14, fontWeight: '800' },
sevBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 4 },
sevText: { color: '#fff', fontSize: 10, fontWeight: '700' },
cvssRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
cvssLabel: { fontSize: 11, color: COLORS.muted, width: 36 },
cvssBar: { flex: 1, height: 6, backgroundColor: COLORS.border, borderRadius: 3, overflow: 'hidden' },
cvssFill: { height: '100%', borderRadius: 3 },
cvssScore: { fontSize: 14, fontWeight: '700', width: 28 },
desc: { fontSize: 12, color: COLORS.muted, marginBottom: 8, lineHeight: 18 },
meta: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 10 },
metaText: { fontSize: 11, color: COLORS.muted },
patchBtn: { backgroundColor: COLORS.danger, borderRadius: 8, padding: 10, alignItems: 'center' },
patchText: { color: '#fff', fontSize: 13, fontWeight: '700' },
patched: { color: COLORS.success, fontSize: 13, fontWeight: '700', textAlign: 'center' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

131
app/(tabs)/delegation.tsx Normal file
View File

@ -0,0 +1,131 @@
import React, { useState, useEffect } from 'react'
import {
View, Text, TextInput, TouchableOpacity, StyleSheet,
ScrollView, Alert, ActivityIndicator,
} from 'react-native'
import { COLORS } from '../../constants/Config'
import { getDelegation, setDelegation, cancelDelegation, searchUsers } from '../../services/api'
export default function DelegationScreen() {
const [current, setCurrent] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [users, setUsers] = useState<any[]>([])
const [selected, setSelected] = useState<any>(null)
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [reason, setReason] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
getDelegation().then(r => setCurrent(r.data)).catch(() => {}).finally(() => setLoading(false))
}, [])
const search = async () => {
if (!query.trim()) return
try {
const r = await searchUsers(query)
setUsers(r.data?.items ?? r.data ?? [])
} catch { setUsers([]) }
}
const save = async () => {
if (!selected || !startDate || !endDate || !reason.trim()) {
Alert.alert('입력 오류', '모든 항목을 입력해주세요.')
return
}
setSaving(true)
try {
await setDelegation({ delegate_to: selected.id, start_date: startDate, end_date: endDate, reason })
Alert.alert('완료', '대리결재가 설정됐습니다.')
const r = await getDelegation()
setCurrent(r.data)
} catch { Alert.alert('오류', '저장 중 오류가 발생했습니다.') }
finally { setSaving(false) }
}
const cancel = async (id: number) => {
Alert.alert('취소 확인', '대리결재를 해제하시겠습니까?', [
{ text: '아니오', style: 'cancel' },
{ text: '해제', style: 'destructive', onPress: async () => {
await cancelDelegation(id)
setCurrent(null)
}},
])
}
if (loading) return <ActivityIndicator style={{ flex: 1 }} color={COLORS.accent} />
return (
<ScrollView style={s.container} contentContainerStyle={{ padding: 16 }}>
{current && (
<View style={s.card}>
<Text style={s.label}> </Text>
<Text style={s.value}>: {current.delegate_name ?? current.delegate_to}</Text>
<Text style={s.value}>: {current.start_date} ~ {current.end_date}</Text>
<Text style={s.value}>: {current.reason}</Text>
<TouchableOpacity style={s.cancelBtn} onPress={() => cancel(current.id)}>
<Text style={s.cancelBtnText}></Text>
</TouchableOpacity>
</View>
)}
<Text style={s.sectionTitle}> </Text>
<View style={s.row}>
<TextInput
style={[s.input, { flex: 1 }]}
value={query}
onChangeText={setQuery}
placeholder="사용자 검색..."
onSubmitEditing={search}
/>
<TouchableOpacity style={s.searchBtn} onPress={search}>
<Text style={s.searchBtnText}></Text>
</TouchableOpacity>
</View>
{users.map((u: any) => (
<TouchableOpacity
key={u.id ?? u.username}
style={[s.userItem, selected?.id === u.id && s.userItemSelected]}
onPress={() => setSelected(u)}
>
<Text style={s.userName}>{u.display_name ?? u.username}</Text>
<Text style={s.userRole}>{u.role}</Text>
</TouchableOpacity>
))}
{selected && <Text style={s.selectedText}>: {selected.display_name ?? selected.username}</Text>}
<TextInput style={s.input} value={startDate} onChangeText={setStartDate} placeholder="시작일 (YYYY-MM-DD)" />
<TextInput style={s.input} value={endDate} onChangeText={setEndDate} placeholder="종료일 (YYYY-MM-DD)" />
<TextInput style={[s.input, { height: 80 }]} value={reason} onChangeText={setReason} placeholder="사유" multiline />
<TouchableOpacity style={s.saveBtn} onPress={save} disabled={saving}>
<Text style={s.saveBtnText}>{saving ? '저장 중...' : '대리결재 설정'}</Text>
</TouchableOpacity>
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 16, elevation: 1 },
label: { fontSize: 12, color: COLORS.muted, marginBottom: 4 },
value: { fontSize: 14, color: COLORS.text, marginBottom: 2 },
cancelBtn: { marginTop: 8, backgroundColor: COLORS.danger, borderRadius: 6, padding: 8, alignItems: 'center' },
cancelBtnText: { color: '#fff', fontWeight: '700' },
sectionTitle: { fontSize: 16, fontWeight: '700', color: COLORS.text, marginBottom: 12 },
row: { flexDirection: 'row', gap: 8, marginBottom: 8 },
input: { backgroundColor: '#fff', borderRadius: 8, borderWidth: 1, borderColor: COLORS.border, paddingHorizontal: 12, paddingVertical: 10, marginBottom: 10, fontSize: 14, color: COLORS.text },
searchBtn: { backgroundColor: COLORS.accent, borderRadius: 8, paddingHorizontal: 16, justifyContent: 'center' },
searchBtnText: { color: '#fff', fontWeight: '700' },
userItem: { backgroundColor: '#fff', borderRadius: 8, padding: 10, marginBottom: 4, flexDirection: 'row', justifyContent: 'space-between', borderWidth: 1, borderColor: COLORS.border },
userItemSelected: { borderColor: COLORS.accent, backgroundColor: COLORS.light },
userName: { fontSize: 14, fontWeight: '600', color: COLORS.text },
userRole: { fontSize: 12, color: COLORS.muted },
selectedText: { fontSize: 13, color: COLORS.accent, marginBottom: 8 },
saveBtn: { backgroundColor: COLORS.accent, borderRadius: 10, padding: 14, alignItems: 'center', marginTop: 8 },
saveBtnText: { color: '#fff', fontWeight: '800', fontSize: 15 },
})

View File

@ -0,0 +1,68 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl, ActivityIndicator } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
function ServiceNode({ name, deps, allNodes }: { name: string; deps: string[]; allNodes: Record<string, string[]> }) {
return (
<View style={n.wrap}>
<View style={n.node}><Text style={n.name}>{name}</Text></View>
{deps.length > 0 && (
<View style={n.depsWrap}>
{deps.map((d, i) => (
<View key={i} style={n.depRow}>
<View style={n.arrow} />
<View style={n.depBox}><Text style={n.depText}>{d}</Text></View>
</View>
))}
</View>
)}
</View>
)
}
const n = StyleSheet.create({
wrap: { marginBottom: 12 },
node: { backgroundColor: COLORS.accent, borderRadius: 8, paddingHorizontal: 14, paddingVertical: 8, alignSelf: 'flex-start' },
name: { color: '#fff', fontSize: 13, fontWeight: '700' },
depsWrap: { marginLeft: 20, marginTop: 4 },
depRow: { flexDirection: 'row', alignItems: 'center', marginTop: 4 },
arrow: { width: 20, height: 2, backgroundColor: COLORS.border, marginRight: 6 },
depBox: { backgroundColor: COLORS.light, borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4 },
depText: { fontSize: 12, color: COLORS.text },
})
export default function DependencyMapScreen() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/knowledge-graph/service-map'); setData(r.data) }
catch { setData(null) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
if (loading) return <ActivityIndicator style={{ flex: 1 }} color={COLORS.accent} />
if (!data) return <Text style={s.empty}> .</Text>
const nodes: Record<string, string[]> = data.dependencies ?? data.services ?? {}
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />} contentContainerStyle={{ padding: 16 }}>
<Text style={s.title}> </Text>
<Text style={s.sub}>: {Object.keys(nodes).length} · : {Object.values(nodes).flat().length}</Text>
{Object.entries(nodes).map(([name, deps]) => (
<ServiceNode key={name} name={name} deps={deps as string[]} allNodes={nodes} />
))}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
title: { fontSize: 20, fontWeight: '800', color: COLORS.text, marginBottom: 4 },
sub: { fontSize: 12, color: COLORS.muted, marginBottom: 16 },
})

View File

@ -0,0 +1,72 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getDeployHistory } from '../../services/api'
const STATUS_COLOR: Record<string, string> = {
SUCCESS: COLORS.success, COMPLETED: COLORS.success,
FAILURE: COLORS.danger, FAILED: COLORS.danger,
RUNNING: COLORS.accent, IN_PROGRESS: COLORS.accent,
}
export default function DeployHistoryScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await getDeployHistory(); setItems(r.data?.items ?? r.data ?? []) }
catch { setItems([]) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ padding: 12, paddingLeft: 40 }}
style={{ backgroundColor: COLORS.bg }}
renderItem={({ item, index }) => {
const color = STATUS_COLOR[item.status ?? ''] ?? COLORS.muted
const dur = item.duration_sec ? `${Math.ceil(item.duration_sec / 60)}` : '-'
return (
<View style={s.row}>
{/* 타임라인 */}
<View style={s.timeline}>
<View style={[s.dot, { backgroundColor: color }]} />
{index < items.length - 1 && <View style={s.line} />}
</View>
<View style={s.content}>
<View style={s.headerRow}>
<Text style={s.project}>{item.project ?? 'N/A'}</Text>
<View style={[s.badge, { backgroundColor: color }]}>
<Text style={s.badgeText}>{item.status ?? '-'}</Text>
</View>
</View>
<Text style={s.meta}>{item.started_at?.slice(0, 16).replace('T', ' ')} · {dur} · {item.deployed_by ?? '-'}</Text>
</View>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
row: { flexDirection: 'row', marginBottom: 0 },
timeline: { position: 'absolute', left: -28, top: 0, bottom: 0, alignItems: 'center', width: 16 },
dot: { width: 12, height: 12, borderRadius: 6, marginTop: 14 },
line: { flex: 1, width: 2, backgroundColor: COLORS.border, marginTop: 2 },
content: { flex: 1, backgroundColor: '#fff', borderRadius: 10, padding: 12, marginBottom: 8, elevation: 1 },
headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 },
project: { fontSize: 14, fontWeight: '700', color: COLORS.text },
badge: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
badgeText: { fontSize: 10, color: '#fff', fontWeight: '700' },
meta: { fontSize: 12, color: COLORS.muted },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

175
app/(tabs)/devices.tsx Normal file
View File

@ -0,0 +1,175 @@
/**
* #33
* GET /api/auth/devices
* DELETE /api/auth/devices/{id}
*
*/
import { useCallback, useEffect, useState } from 'react'
import {
View, Text, FlatList, TouchableOpacity, StyleSheet,
RefreshControl, Alert, ActivityIndicator, Platform,
} from 'react-native'
import { COLORS } from '../../constants/Config'
import { getDevices, deleteDevice } from '../../services/api'
import LineIcon from '../../components/LineIcon'
interface Device {
id: string | number
name?: string
device_name?: string
os?: string
platform?: string
last_seen?: string
last_active_at?: string
is_current?: boolean
current?: boolean
}
function osIcon(os?: string): Parameters<typeof LineIcon>[0]['name'] {
const v = (os ?? '').toLowerCase()
if (v.includes('ios') || v.includes('iphone') || v.includes('mac')) return 'lock'
if (v.includes('android')) return 'server'
return 'dashboard'
}
function fmt(d?: string): string {
if (!d) return '-'
try {
const dt = new Date(d)
if (isNaN(dt.getTime())) return d
return dt.toLocaleString('ko-KR', { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return d
}
}
export default function DevicesScreen() {
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefresh(true) : setLoading(true)
try {
const r = await getDevices()
const list: Device[] = Array.isArray(r.data) ? r.data : r.data?.items ?? []
setDevices(list)
} catch {
setDevices([])
} finally {
setLoading(false)
setRefresh(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleRemove = (dev: Device) => {
const name = dev.name ?? dev.device_name ?? '이 기기'
Alert.alert('디바이스 등록 해제', `"${name}"의 등록을 해제하시겠습니까?\n해당 기기는 다시 로그인해야 합니다.`, [
{ text: '취소', style: 'cancel' },
{
text: '해제', style: 'destructive',
onPress: async () => {
try {
await deleteDevice(dev.id)
setDevices((prev) => prev.filter((d) => d.id !== dev.id))
} catch (e: any) {
Alert.alert('오류', e.response?.data?.detail ?? '등록 해제에 실패했습니다.')
}
},
},
])
}
if (loading) {
return (
<View style={s.center}>
<ActivityIndicator color={COLORS.accent} size="large" />
</View>
)
}
return (
<FlatList
style={{ flex: 1, backgroundColor: COLORS.bg }}
data={devices}
keyExtractor={(d) => String(d.id)}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}
ListHeaderComponent={
<View style={s.header}>
<Text style={s.headerTitle}> </Text>
<Text style={s.headerSub}> {devices.length}</Text>
</View>
}
ListEmptyComponent={
<View style={s.empty}>
<Text style={s.emptyText}> .</Text>
</View>
}
contentContainerStyle={devices.length === 0 ? { flexGrow: 1 } : undefined}
renderItem={({ item }) => {
const isCurrent = item.is_current ?? item.current ?? false
return (
<View style={s.card}>
<View style={s.iconBox}>
<LineIcon name={osIcon(item.os ?? item.platform)} size={20} color={COLORS.accent} />
</View>
<View style={{ flex: 1 }}>
<View style={s.row}>
<Text style={s.name}>{item.name ?? item.device_name ?? '알 수 없는 기기'}</Text>
{isCurrent && (
<View style={s.currentBadge}>
<Text style={s.currentText}> </Text>
</View>
)}
</View>
<Text style={s.meta}>{item.os ?? item.platform ?? Platform.OS}</Text>
<Text style={s.meta}> : {fmt(item.last_seen ?? item.last_active_at)}</Text>
</View>
<TouchableOpacity
style={[s.removeBtn, isCurrent && s.removeDisabled]}
disabled={isCurrent}
onPress={() => handleRemove(item)}
>
<Text style={[s.removeText, isCurrent && s.removeTextDisabled]}>
{isCurrent ? '사용 중' : '해제'}
</Text>
</TouchableOpacity>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: COLORS.bg },
header: { padding: 20, paddingBottom: 8 },
headerTitle: { fontSize: 18, fontWeight: '800', color: COLORS.text },
headerSub: { fontSize: 13, color: COLORS.muted, marginTop: 4 },
card: {
flexDirection: 'row', alignItems: 'center', gap: 12,
backgroundColor: '#fff', marginHorizontal: 16, marginTop: 10,
borderRadius: 14, padding: 16,
borderWidth: 1, borderColor: COLORS.border,
},
iconBox: {
width: 42, height: 42, borderRadius: 11,
backgroundColor: 'rgba(0,160,200,.08)', alignItems: 'center', justifyContent: 'center',
},
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
name: { fontSize: 15, fontWeight: '700', color: COLORS.text },
currentBadge: { backgroundColor: '#dcfce7', paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8 },
currentText: { fontSize: 10, fontWeight: '700', color: '#15803d' },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
removeBtn: {
paddingHorizontal: 14, paddingVertical: 8, borderRadius: 10,
backgroundColor: '#fee2e2',
},
removeDisabled: { backgroundColor: '#f1f5f9' },
removeText: { fontSize: 13, fontWeight: '700', color: COLORS.danger },
removeTextDisabled: { color: COLORS.muted },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
emptyText: { color: COLORS.muted, fontSize: 14 },
})

59
app/(tabs)/eol_alerts.tsx Normal file
View File

@ -0,0 +1,59 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function EOLAlertsScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/cmdb/eol-software'); setItems(r.data?.items ?? r.data ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const createSR = async (item: any) => {
try {
await client.post('/api/tasks', { title: `EOL 조치: ${item.name} ${item.version ?? ''}`, description: `서버 ${item.server_count ?? '-'}대 영향 — EOL 소프트웨어 교체/업그레이드 필요`, priority: 'HIGH', sr_type: 'CHANGE' })
Alert.alert('완료', 'SR이 등록됐습니다.')
} catch { Alert.alert('오류', 'SR 등록에 실패했습니다.') }
}
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}>EOL .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => {
const isEOL = !item.eol_date || new Date(item.eol_date) <= new Date()
return (
<View style={[s.card, { borderLeftWidth: 4, borderLeftColor: isEOL ? COLORS.danger : COLORS.warning }]}>
<Text style={s.name}>{item.name} {item.version ?? ''}</Text>
<Text style={s.meta}>EOL: {item.eol_date?.slice(0, 10) ?? '이미 만료'} · {item.server_count ?? 0}</Text>
<Text style={s.desc} numberOfLines={1}>{item.note ?? item.description ?? ''}</Text>
<TouchableOpacity style={s.srBtn} onPress={() => createSR(item)}>
<Text style={s.srText}> SR </Text>
</TouchableOpacity>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
name: { fontSize: 14, fontWeight: '700', color: COLORS.text, marginBottom: 4 },
meta: { fontSize: 12, color: COLORS.muted, marginBottom: 4 },
desc: { fontSize: 12, color: COLORS.muted, marginBottom: 10 },
srBtn: { backgroundColor: COLORS.warning + '20', borderRadius: 6, padding: 8, alignItems: 'center' },
srText: { color: COLORS.warning, fontSize: 12, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

94
app/(tabs)/esignature.tsx Normal file
View File

@ -0,0 +1,94 @@
import React, { useState, useRef, useCallback } from 'react'
import { View, Text, TouchableOpacity, StyleSheet, Alert, ScrollView, TextInput } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function ESignatureScreen() {
const [docs, setDocs] = useState<any[]>([])
const [selected, setSelected] = useState<any>(null)
const [pin, setPin] = useState('')
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/approvals/pending-docs'); setDocs(r.data?.docs ?? r.data?.items ?? []) }
catch { setDocs([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const sign = async () => {
if (!selected) return
if (!pin || pin.length < 4) { Alert.alert('오류', 'PIN 4자리 이상 입력하세요.'); return }
try {
await client.post(`/api/approvals/${selected.id}/sign`, { pin_hash: pin })
Alert.alert('완료', '전자서명이 완료됐습니다.')
setSelected(null)
setPin('')
load()
} catch { Alert.alert('오류', '서명에 실패했습니다.') }
}
if (selected) {
return (
<View style={s.container}>
<View style={s.docCard}>
<Text style={s.docTitle}>{selected.title}</Text>
<Text style={s.docMeta}>: {selected.requester_name ?? '-'} · {selected.created_at?.slice(0, 10) ?? ''}</Text>
<Text style={s.docDesc} numberOfLines={4}>{selected.content ?? selected.description ?? ''}</Text>
</View>
<Text style={s.pinLabel}> PIN </Text>
<TextInput
style={s.pinInput}
value={pin}
onChangeText={setPin}
placeholder="PIN (4자리 이상)"
secureTextEntry
keyboardType="numeric"
maxLength={8}
/>
<TouchableOpacity style={s.signBtn} onPress={sign}>
<Text style={s.signText}> </Text>
</TouchableOpacity>
<TouchableOpacity style={s.cancelBtn} onPress={() => { setSelected(null); setPin('') }}>
<Text style={s.cancelText}></Text>
</TouchableOpacity>
</View>
)
}
return (
<ScrollView style={s.container} contentContainerStyle={{ padding: 12 }}>
<Text style={s.header}> </Text>
{docs.length === 0 && <Text style={s.empty}> .</Text>}
{docs.map((doc, i) => (
<TouchableOpacity key={i} style={s.card} onPress={() => setSelected(doc)}>
<Text style={s.cardTitle}>{doc.title}</Text>
<Text style={s.cardMeta}>{doc.requester_name ?? '-'} · {doc.created_at?.slice(0, 10) ?? ''}</Text>
<Text style={s.cardAction}> </Text>
</TouchableOpacity>
))}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
header: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 12 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
cardTitle: { fontSize: 14, fontWeight: '700', color: COLORS.text, marginBottom: 4 },
cardMeta: { fontSize: 11, color: COLORS.muted, marginBottom: 8 },
cardAction: { color: COLORS.accent, fontSize: 13, fontWeight: '700' },
docCard: { backgroundColor: '#fff', borderRadius: 12, padding: 16, margin: 12, elevation: 1 },
docTitle: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 4 },
docMeta: { fontSize: 12, color: COLORS.muted, marginBottom: 10 },
docDesc: { fontSize: 13, color: COLORS.text, lineHeight: 20 },
pinLabel: { fontSize: 13, fontWeight: '700', color: COLORS.text, marginHorizontal: 12, marginTop: 8 },
pinInput: { backgroundColor: '#fff', borderRadius: 10, padding: 14, margin: 12, fontSize: 18, letterSpacing: 4, elevation: 1, color: COLORS.text },
signBtn: { backgroundColor: COLORS.success, borderRadius: 10, padding: 14, margin: 12, alignItems: 'center' },
signText: { color: '#fff', fontSize: 15, fontWeight: '800' },
cancelBtn: { borderRadius: 10, padding: 12, marginHorizontal: 12, alignItems: 'center' },
cancelText: { color: COLORS.muted, fontSize: 14 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,64 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function FailurePredictionScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/predictive/failure'); setItems(r.data?.predictions ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const createSR = async (item: any) => {
try {
await client.post('/api/tasks', { title: `[예측 장애 예방] ${item.server_name ?? item.name}`, description: `장애 발생 가능성 ${item.probability ?? '-'}% — AI 예측 기반 예방 점검`, priority: 'HIGH', sr_type: 'INCIDENT' })
Alert.alert('완료', '예방 SR이 등록됐습니다.')
} catch { Alert.alert('오류', 'SR 등록에 실패했습니다.') }
}
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => {
const prob = item.probability ?? item.risk_score ?? 0
const color = prob >= 70 ? COLORS.danger : prob >= 40 ? COLORS.warning : COLORS.success
return (
<View style={[s.card, { borderLeftWidth: 4, borderLeftColor: color }]}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.name}>{item.server_name ?? item.name}</Text>
<Text style={s.meta}>{item.failure_type ?? item.type ?? '알 수 없음'} · {item.estimated_time ?? '72시간 이내'}</Text>
</View>
<Text style={[s.prob, { color }]}>{prob}%</Text>
</View>
<Text style={s.reason} numberOfLines={2}>{item.reason ?? item.description ?? ''}</Text>
<Text style={s.action} onPress={() => createSR(item)}> SR </Text>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
name: { fontSize: 14, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
prob: { fontSize: 26, fontWeight: '800' },
reason: { fontSize: 12, color: COLORS.muted, marginBottom: 8 },
action: { color: COLORS.blue, fontSize: 13, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

73
app/(tabs)/favorites.tsx Normal file
View File

@ -0,0 +1,73 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect, useRouter } from 'expo-router'
import * as SecureStore from 'expo-secure-store'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
const SHORTCUTS_KEY = 'guardia_shortcuts'
const ALL_SHORTCUTS = [
{ key: 'sr', label: 'SR 목록', route: '/(tabs)/sr', icon: '📋' },
{ key: 'monitoring', label: '서버 모니터링', route: '/(tabs)/monitoring', icon: '📡' },
{ key: 'chat', label: 'AI 챗봇', route: '/(tabs)/chat', icon: '🤖' },
{ key: 'kb', label: '지식베이스', route: '/(tabs)/kb', icon: '📚' },
{ key: 'approvals', label: '승인 관리', route: '/(tabs)/approval', icon: '✅' },
{ key: 'cve', label: 'CVE 현황', route: '/(tabs)/cve_detail', icon: '🔒' },
{ key: 'health', label: '건강 점수', route: '/(tabs)/health_scorecard', icon: '💊' },
{ key: 'leaderboard', label: '성과 리더보드', route: '/(tabs)/team_leaderboard', icon: '🏆' },
]
export default function FavoritesScreen() {
const [pinned, setPinned] = useState<string[]>([])
const router = useRouter()
const load = useCallback(async () => {
const raw = await SecureStore.getItemAsync(SHORTCUTS_KEY)
setPinned(raw ? JSON.parse(raw) : ['sr', 'monitoring', 'chat'])
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const toggle = async (key: string) => {
const next = pinned.includes(key) ? pinned.filter(k => k !== key) : [...pinned, key]
setPinned(next)
await SecureStore.setItemAsync(SHORTCUTS_KEY, JSON.stringify(next))
}
const pinnedItems = ALL_SHORTCUTS.filter(s => pinned.includes(s.key))
const unpinned = ALL_SHORTCUTS.filter(s => !pinned.includes(s.key))
return (
<FlatList
data={[...pinnedItems, ...unpinned]}
keyExtractor={item => item.key}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={<Text style={s.header}> </Text>}
renderItem={({ item }) => {
const isPinned = pinned.includes(item.key)
return (
<View style={s.row}>
<TouchableOpacity style={s.link} onPress={() => router.push(item.route as any)}>
<Text style={s.icon}>{item.icon}</Text>
<Text style={s.label}>{item.label}</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.pin, { backgroundColor: isPinned ? COLORS.accent + '20' : COLORS.border + '40' }]} onPress={() => toggle(item.key)}>
<Text style={{ color: isPinned ? COLORS.accent : COLORS.muted, fontSize: 12, fontWeight: '700' }}>{isPinned ? '고정됨' : '고정'}</Text>
</TouchableOpacity>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
header: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 12 },
row: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', borderRadius: 10, padding: 12, marginBottom: 6, elevation: 1, gap: 8 },
link: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 10 },
icon: { fontSize: 22, width: 32, textAlign: 'center' },
label: { fontSize: 14, fontWeight: '700', color: COLORS.text },
pin: { borderRadius: 6, paddingHorizontal: 10, paddingVertical: 6 },
})

View File

@ -0,0 +1,110 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function GreenOpsDashboardScreen() {
const [energy, setEnergy] = useState<any>(null)
const [carbon, setCarbon] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [e, c] = await Promise.all([
client.get('/api/greenops/energy'),
client.get('/api/greenops/carbon'),
])
setEnergy(e.data); setCarbon(c.data)
} catch {} finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const servers = energy?.servers ?? energy?.items ?? []
const trend = carbon?.monthly ?? carbon?.trend ?? []
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />} contentContainerStyle={{ padding: 12 }}>
{/* 헤더 요약 */}
<View style={s.summary}>
<SummaryCard label="이번달 전력" value={energy?.total_kwh != null ? `${energy.total_kwh} kWh` : '-'} color={COLORS.success} icon="⚡" />
<SummaryCard label="탄소 배출량" value={carbon?.total_co2_kg != null ? `${carbon.total_co2_kg} kg` : '-'} color={COLORS.warning} icon="🌿" />
<SummaryCard label="효율 점수" value={energy?.efficiency_score != null ? `${energy.efficiency_score}%` : '-'} color={COLORS.accent} icon="📊" />
</View>
{/* 서버별 전력 */}
{servers.length > 0 && (
<>
<Text style={s.section}> </Text>
{servers.slice(0, 10).map((s2: any, i: number) => {
const kwh = s2.kwh ?? s2.power_kwh ?? 0
const max = Math.max(...servers.map((x: any) => x.kwh ?? x.power_kwh ?? 0), 1)
return (
<View key={i} style={s.row}>
<Text style={s.sName} numberOfLines={1}>{s2.name ?? s2.server_name ?? 'N/A'}</Text>
<View style={s.barBg}>
<View style={[s.barFill, { width: `${(kwh/max)*100}%` }]} />
</View>
<Text style={s.kwhText}>{kwh}kWh</Text>
</View>
)
})}
</>
)}
{/* 월별 탄소 추이 */}
{trend.length > 0 && (
<>
<Text style={s.section}> </Text>
<View style={s.trendRow}>
{trend.slice(-6).map((t: any, i: number) => {
const val = t.co2_kg ?? t.value ?? 0
const max2 = Math.max(...trend.map((x: any) => x.co2_kg ?? x.value ?? 0), 1)
const h = Math.max(20, Math.round((val/max2)*80))
return (
<View key={i} style={s.bar}>
<View style={[s.barV, { height: h }]} />
<Text style={s.barLabel}>{t.month?.slice(5) ?? `M${i+1}`}</Text>
</View>
)
})}
</View>
</>
)}
</ScrollView>
)
}
function SummaryCard({ label, value, color, icon }: any) {
return (
<View style={[c.card, { borderTopColor: color }]}>
<Text style={c.icon}>{icon}</Text>
<Text style={[c.val, { color }]}>{value}</Text>
<Text style={c.label}>{label}</Text>
</View>
)
}
const c = StyleSheet.create({
card: { flex: 1, backgroundColor: '#fff', borderRadius: 10, padding: 12, alignItems: 'center', borderTopWidth: 3, elevation: 1 },
icon: { fontSize: 24, marginBottom: 4 },
val: { fontSize: 16, fontWeight: '800', marginBottom: 2 },
label: { fontSize: 11, color: COLORS.muted },
})
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
summary: { flexDirection: 'row', gap: 8, marginBottom: 12 },
section: { fontSize: 14, fontWeight: '700', color: COLORS.text, marginTop: 8, marginBottom: 8 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
sName: { fontSize: 12, color: COLORS.text, width: 90 },
barBg: { flex: 1, height: 8, backgroundColor: COLORS.border, borderRadius: 4 },
barFill: { height: 8, backgroundColor: COLORS.success, borderRadius: 4 },
kwhText: { fontSize: 11, color: COLORS.muted, width: 55, textAlign: 'right' },
trendRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, height: 100 },
bar: { flex: 1, alignItems: 'center' },
barV: { width: '80%', backgroundColor: COLORS.accent, borderRadius: 4 },
barLabel: { fontSize: 10, color: COLORS.muted, marginTop: 4 },
})

View File

@ -0,0 +1,78 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl, ActivityIndicator } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
function GaugeBar({ value, max, color }: { value: number; max: number; color: string }) {
const pct = Math.min(100, Math.round((value / max) * 100))
return (
<View style={g.wrap}>
<View style={[g.fill, { width: `${pct}%`, backgroundColor: color }]} />
</View>
)
}
const g = StyleSheet.create({
wrap: { height: 8, backgroundColor: COLORS.border, borderRadius: 4, overflow: 'hidden' },
fill: { height: '100%', borderRadius: 4 },
})
export default function HealthScorecardScreen() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/dashboard'); setData(r.data) }
catch { setData(null) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
if (loading && !data) return <ActivityIndicator style={{ flex: 1 }} color={COLORS.accent} />
if (!data) return <Text style={s.empty}> .</Text>
const metrics = [
{ label: 'SR 처리율', value: data.sr_completion_rate ?? 0, max: 100, unit: '%', color: COLORS.success },
{ label: 'SLA 준수율', value: data.sla_compliance ?? data.sla_rate ?? 0, max: 100, unit: '%', color: COLORS.blue },
{ label: '서버 가용률', value: data.server_availability ?? 0, max: 100, unit: '%', color: COLORS.accent },
{ label: '미해결 SR', value: data.open_tasks ?? 0, max: Math.max(data.open_tasks ?? 1, 50), unit: '건', color: COLORS.warning },
{ label: 'CSAP 준수율', value: data.csap_score ?? data.compliance_score ?? 0, max: 100, unit: '%', color: '#8b5cf6' },
]
const overall = Math.round(metrics.filter(m => m.unit === '%').reduce((a, m) => a + m.value, 0) / metrics.filter(m => m.unit === '%').length)
const overallColor = overall >= 90 ? COLORS.success : overall >= 70 ? COLORS.warning : COLORS.danger
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />} contentContainerStyle={{ padding: 16 }}>
<View style={[s.overallCard, { borderColor: overallColor }]}>
<Text style={s.overallLabel}> </Text>
<Text style={[s.overallScore, { color: overallColor }]}>{overall}</Text>
<Text style={[s.overallGrade, { color: overallColor }]}>{overall >= 90 ? '우수' : overall >= 70 ? '보통' : '위험'}</Text>
</View>
{metrics.map(m => (
<View key={m.label} style={s.card}>
<View style={s.row}>
<Text style={s.label}>{m.label}</Text>
<Text style={[s.value, { color: m.color }]}>{m.value}{m.unit}</Text>
</View>
<GaugeBar value={m.value} max={m.max} color={m.color} />
</View>
))}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
overallCard: { alignItems: 'center', borderWidth: 2, borderRadius: 16, padding: 24, marginBottom: 16, backgroundColor: '#fff', elevation: 2 },
overallLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 8 },
overallScore: { fontSize: 60, fontWeight: '900', lineHeight: 68 },
overallGrade: { fontSize: 16, fontWeight: '700', marginTop: 4 },
card: { backgroundColor: '#fff', borderRadius: 12, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 },
label: { fontSize: 13, color: COLORS.text, fontWeight: '600' },
value: { fontSize: 14, fontWeight: '800' },
})

View File

@ -0,0 +1,78 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function HWWarrantyScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/cmdb/warranty'); setItems(r.data?.assets ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const urgency = (days: number) => days <= 30 ? COLORS.danger : days <= 90 ? COLORS.warning : COLORS.success
const createSR = async (item: any) => {
try {
await client.post('/api/tasks', { title: `보증 만료 조치: ${item.asset_name ?? item.name}`, description: `${item.days_left ?? '?'}일 후 보증 만료 — 교체/연장 검토 필요`, priority: item.days_left <= 30 ? 'HIGH' : 'MEDIUM', sr_type: 'CHANGE' })
Alert.alert('완료', 'SR이 등록됐습니다.')
} catch { Alert.alert('오류', 'SR 등록에 실패했습니다.') }
}
return (
<FlatList
data={items.sort((a, b) => (a.days_left ?? 9999) - (b.days_left ?? 9999))}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={<Text style={s.header}> </Text>}
renderItem={({ item }) => {
const days = item.days_left ?? item.warranty_days_left ?? 999
const color = urgency(days)
return (
<View style={s.card}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.name}>{item.asset_name ?? item.name}</Text>
<Text style={s.meta}>{item.manufacturer ?? '-'} {item.model ?? '-'} · {item.serial_no ?? ''}</Text>
</View>
<View style={[s.daysBadge, { backgroundColor: color }]}>
<Text style={s.daysNum}>{days === 999 ? '∞' : days}</Text>
<Text style={s.daysLabel}></Text>
</View>
</View>
<Text style={s.expiry}> : {item.warranty_end?.slice(0, 10) ?? '-'}</Text>
{days <= 90 && (
<TouchableOpacity style={s.srBtn} onPress={() => createSR(item)}>
<Text style={s.srText}>/ SR</Text>
</TouchableOpacity>
)}
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
header: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 12 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 6 },
name: { fontSize: 14, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 11, color: COLORS.muted, marginTop: 3 },
daysBadge: { alignItems: 'center', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 10 },
daysNum: { fontSize: 20, fontWeight: '900', color: '#fff' },
daysLabel: { fontSize: 9, color: '#fff' },
expiry: { fontSize: 11, color: COLORS.muted, marginBottom: 8 },
srBtn: { backgroundColor: COLORS.warning + '20', borderRadius: 6, padding: 8, alignItems: 'center' },
srText: { color: COLORS.warning, fontSize: 12, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react'
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native'
import { COLORS } from '../../constants/Config'
import { apiClient } from '../../services/api'
import { useAuth } from '../../hooks/useAuth'
import client from '../../services/api'
interface Weekly { stats: any; ai_insight: string; top_categories: any[] }
interface Anomaly { anomalies: any[]; today_sr: number; avg_7d: number; open_sr: number }
@ -13,7 +12,6 @@ const STATUS_COLOR: Record<string, string> = {
}
export default function InsightsScreen() {
const { token } = useAuth()
const [weekly, setWeekly] = useState<Weekly | null>(null)
const [anomaly, setAnomaly] = useState<Anomaly | null>(null)
const [predict, setPredict] = useState<Predict | null>(null)
@ -26,11 +24,11 @@ export default function InsightsScreen() {
setLoading(true)
try {
const [w, a, p] = await Promise.all([
apiClient.get('/api/insights/weekly', token),
apiClient.get('/api/insights/anomalies', token),
apiClient.get('/api/predict/sla-breach', token),
client.get('/api/insights/weekly'),
client.get('/api/insights/anomalies'),
client.get('/api/predict/sla-breach'),
])
setWeekly(w); setAnomaly(a); setPredict(p)
setWeekly(w.data); setAnomaly(a.data); setPredict(p.data)
} catch { /* 오류 무시 */ }
setLoading(false); setRefreshing(false)
}

View File

@ -0,0 +1,72 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, ScrollView } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getInstitutionStats } from '../../services/api'
export default function InstitutionCompareScreen() {
const [rows, setRows] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await getInstitutionStats(); setRows(r.data?.items ?? r.data ?? []) }
catch { setRows([]) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const max = Math.max(...rows.map(r => r.total_sr ?? 0), 1)
return (
<ScrollView
style={{ flex: 1, backgroundColor: COLORS.bg }}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
>
{/* 헤더 */}
<View style={s.thead}>
<Text style={[s.th, { flex: 2 }]}></Text>
<Text style={s.th}></Text>
<Text style={s.th}></Text>
<Text style={s.th}>SLA</Text>
<Text style={s.th}></Text>
</View>
{rows.map((item, i) => {
const rate = item.total_sr > 0 ? Math.round((item.completed_sr / item.total_sr) * 100) : 0
const barW = Math.round(((item.total_sr ?? 0) / max) * 100)
const slaColor = (item.sla_compliance_rate ?? 0) >= 95 ? COLORS.success : (item.sla_compliance_rate ?? 0) >= 80 ? COLORS.warning : COLORS.danger
return (
<View key={i} style={s.row}>
<View style={{ flex: 2 }}>
<Text style={s.instName} numberOfLines={1}>{item.institution_name ?? item.inst_code}</Text>
<View style={s.barBg}>
<View style={[s.barFill, { width: `${barW}%` }]} />
</View>
</View>
<Text style={s.td}>{item.total_sr ?? 0}</Text>
<Text style={s.td}>{item.completed_sr ?? 0}</Text>
<Text style={[s.td, { color: slaColor, fontWeight: '700' }]}>{item.sla_compliance_rate ?? 0}%</Text>
<Text style={s.td}>{rate}%</Text>
</View>
)
})}
{rows.length === 0 && !loading && (
<Text style={s.empty}> .</Text>
)}
</ScrollView>
)
}
const s = StyleSheet.create({
thead: { flexDirection: 'row', backgroundColor: COLORS.gnbBg, paddingHorizontal: 12, paddingVertical: 10 },
th: { flex: 1, fontSize: 12, color: '#fff', fontWeight: '700', textAlign: 'center' },
row: { flexDirection: 'row', backgroundColor: '#fff', marginHorizontal: 8, marginTop: 4, borderRadius: 8, padding: 10, alignItems: 'center', gap: 4 },
instName: { fontSize: 12, fontWeight: '700', color: COLORS.text, marginBottom: 4 },
barBg: { height: 4, backgroundColor: COLORS.border, borderRadius: 2 },
barFill: { height: 4, backgroundColor: COLORS.accent, borderRadius: 2 },
td: { flex: 1, fontSize: 12, color: COLORS.text, textAlign: 'center' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

90
app/(tabs)/ioc_search.tsx Normal file
View File

@ -0,0 +1,90 @@
import React, { useState } from 'react'
import { View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity, ActivityIndicator, Keyboard } from 'react-native'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function IOCSearchScreen() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const search = async () => {
if (!query.trim()) return
Keyboard.dismiss()
setLoading(true)
setSearched(true)
try {
const r = await client.get('/api/ai-soc/ioc/search', { params: { q: query.trim() } })
setResults(r.data?.results ?? r.data?.iocs ?? [])
} catch { setResults([]) } finally { setLoading(false) }
}
const TYPE_COLOR: Record<string, string> = { ip: COLORS.danger, domain: '#f97316', hash: COLORS.warning, url: COLORS.blue }
return (
<View style={s.container}>
<View style={s.searchBar}>
<TextInput
style={s.input}
value={query}
onChangeText={setQuery}
placeholder="IP, 도메인, 해시, URL 검색..."
placeholderTextColor={COLORS.muted}
returnKeyType="search"
onSubmitEditing={search}
autoCapitalize="none"
/>
<TouchableOpacity style={s.searchBtn} onPress={search}>
<Text style={s.searchText}></Text>
</TouchableOpacity>
</View>
{loading ? <ActivityIndicator style={{ marginTop: 40 }} color={COLORS.accent} /> : (
<FlatList
data={results}
keyExtractor={(_, i) => String(i)}
ListEmptyComponent={searched ? <Text style={s.empty}> .</Text> : null}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => {
const type = item.type ?? 'unknown'
const color = TYPE_COLOR[type] ?? COLORS.muted
return (
<View style={s.card}>
<View style={s.row}>
<View style={[s.typeBadge, { backgroundColor: color + '20' }]}>
<Text style={[s.typeText, { color }]}>{type.toUpperCase()}</Text>
</View>
<Text style={s.value} numberOfLines={1}>{item.value ?? item.ioc}</Text>
</View>
<Text style={s.desc} numberOfLines={2}>{item.description ?? item.context ?? ''}</Text>
<View style={s.metaRow}>
<Text style={s.meta}>: {item.threat_name ?? '-'}</Text>
<Text style={s.meta}>: {item.confidence ?? '-'}%</Text>
<Text style={s.meta}>{item.first_seen?.slice(0, 10) ?? '-'}</Text>
</View>
</View>
)
}}
/>
)}
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
searchBar: { flexDirection: 'row', gap: 8, padding: 12 },
input: { flex: 1, backgroundColor: '#fff', borderRadius: 10, paddingHorizontal: 14, paddingVertical: 10, fontSize: 13, color: COLORS.text, elevation: 1 },
searchBtn: { backgroundColor: COLORS.accent, borderRadius: 10, paddingHorizontal: 16, justifyContent: 'center' },
searchText: { color: '#fff', fontSize: 13, fontWeight: '700' },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 },
typeBadge: { borderRadius: 4, paddingHorizontal: 8, paddingVertical: 3 },
typeText: { fontSize: 10, fontWeight: '700' },
value: { flex: 1, fontSize: 13, color: COLORS.text, fontFamily: 'monospace' },
desc: { fontSize: 12, color: COLORS.muted, marginBottom: 6 },
metaRow: { flexDirection: 'row', justifyContent: 'space-between' },
meta: { fontSize: 10, color: COLORS.muted },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,91 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Alert, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getBuilds, triggerBuild } from '../../services/api'
const STATUS_COLOR: Record<string, string> = {
SUCCESS: COLORS.success,
FAILURE: COLORS.danger,
RUNNING: COLORS.accent,
ABORTED: COLORS.muted,
}
const STATUS_ICON: Record<string, string> = {
SUCCESS: '✅', FAILURE: '❌', RUNNING: '⏳', ABORTED: '⏹',
}
export default function JenkinsBuildsScreen() {
const [builds, setBuilds] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await getBuilds(); setBuilds(r.data?.items ?? r.data ?? []) }
catch { setBuilds([]) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const trigger = (project: string) => {
Alert.alert('빌드 트리거', `${project} 빌드를 시작하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '시작', onPress: async () => {
try {
await triggerBuild(project)
Alert.alert('완료', '빌드가 대기열에 추가됐습니다.')
setTimeout(load, 1000)
} catch { Alert.alert('오류', '빌드 트리거에 실패했습니다.') }
}},
])
}
const renderItem = ({ item }: { item: any }) => {
const color = STATUS_COLOR[item.status ?? 'ABORTED'] ?? COLORS.muted
const icon = STATUS_ICON[item.status ?? 'ABORTED'] ?? '❓'
const dur = item.duration_sec ? `${Math.floor(item.duration_sec / 60)}${item.duration_sec % 60}` : '-'
return (
<View style={s.card}>
<View style={s.row}>
<Text style={s.icon}>{icon}</Text>
<View style={{ flex: 1 }}>
<Text style={s.project}>{item.project}</Text>
<Text style={s.meta}>{item.branch} · {item.started_at?.slice(0, 16).replace('T', ' ')}</Text>
</View>
<View>
<Text style={[s.status, { color }]}>{item.status}</Text>
<Text style={s.dur}>{dur}</Text>
</View>
</View>
<TouchableOpacity style={s.retrigger} onPress={() => trigger(item.project)}>
<Text style={s.retriggerText}></Text>
</TouchableOpacity>
</View>
)
}
return (
<FlatList
data={builds}
keyExtractor={(_, i) => String(i)}
renderItem={renderItem}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ padding: 12 }}
style={{ backgroundColor: COLORS.bg }}
/>
)
}
const s = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
icon: { fontSize: 22 },
project: { fontSize: 14, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
status: { fontSize: 12, fontWeight: '700', textAlign: 'right' },
dur: { fontSize: 11, color: COLORS.muted, textAlign: 'right', marginTop: 2 },
retrigger: { marginTop: 10, backgroundColor: COLORS.light, borderRadius: 6, padding: 6, alignItems: 'center' },
retriggerText:{ fontSize: 12, color: COLORS.blue, fontWeight: '600' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

128
app/(tabs)/kanban.tsx Normal file
View File

@ -0,0 +1,128 @@
import { useEffect, useState } from 'react'
import {
View, Text, ScrollView, StyleSheet, TouchableOpacity,
ActivityIndicator, RefreshControl, Alert,
} from 'react-native'
import { router } from 'expo-router'
import { COLORS, PRIORITY_COLOR } from '../../constants/Config'
import { getSRList, patchSR } from '../../services/api'
interface SR {
id: number
sr_id?: string
title: string
status?: string
priority?: string
}
const COLUMNS: { key: string; label: string; color: string }[] = [
{ key: 'RECEIVED', label: '접수', color: '#94a3b8' },
{ key: 'IN_PROGRESS', label: '진행중', color: '#4f6ef7' },
{ key: 'PENDING_APPROVAL', label: '승인대기', color: '#f59e0b' },
{ key: 'COMPLETED', label: '완료', color: '#22c55e' },
]
/**
* #5 Kanban SR
* . / (PATCH /api/tasks/{id}).
*/
export default function KanbanScreen() {
const [items, setItems] = useState<SR[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const load = async (r = false) => {
r ? setRefresh(true) : setLoading(true)
try {
const res = await getSRList(0, 100)
setItems(res.data?.content ?? res.data?.items ?? res.data ?? [])
} catch { setItems([]) }
finally { setLoading(false); setRefresh(false) }
}
useEffect(() => { load() }, [])
const move = async (sr: SR, dir: -1 | 1) => {
const idx = COLUMNS.findIndex(c => c.key === sr.status)
const nextIdx = idx + dir
if (idx < 0 || nextIdx < 0 || nextIdx >= COLUMNS.length) return
const nextStatus = COLUMNS[nextIdx].key
setItems(prev => prev.map(i => (i.id === sr.id ? { ...i, status: nextStatus } : i)))
try {
await patchSR(sr.id, { status: nextStatus })
} catch (e: any) {
setItems(prev => prev.map(i => (i.id === sr.id ? { ...i, status: sr.status } : i)))
Alert.alert('오류', e.response?.data?.detail ?? '상태 변경 실패')
}
}
if (loading) return <ActivityIndicator style={{ marginTop: 60 }} color={COLORS.accent} />
return (
<ScrollView
horizontal
style={{ flex: 1, backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} />}
>
{COLUMNS.map((col, ci) => {
const cards = items.filter(i => i.status === col.key)
return (
<View key={col.key} style={s.column}>
<View style={[s.colHead, { borderTopColor: col.color }]}>
<Text style={s.colTitle}>{col.label}</Text>
<Text style={[s.colCount, { color: col.color }]}>{cards.length}</Text>
</View>
<ScrollView style={{ flex: 1 }}>
{cards.length === 0 && <Text style={s.emptyCol}> </Text>}
{cards.map(sr => (
<View key={sr.id} style={s.card}>
<TouchableOpacity onPress={() => router.push({ pathname: '/(tabs)/sr_detail', params: { id: String(sr.id) } })}>
<Text style={s.cardId}>{sr.sr_id ?? `#${sr.id}`}</Text>
<Text style={s.cardTitle} numberOfLines={3}>{sr.title}</Text>
{!!sr.priority && (
<Text style={[s.cardPri, { color: PRIORITY_COLOR[sr.priority] ?? COLORS.muted }]}> {sr.priority}</Text>
)}
</TouchableOpacity>
<View style={s.moveRow}>
<TouchableOpacity
style={[s.moveBtn, ci === 0 && s.moveBtnOff]}
onPress={() => move(sr, -1)}
disabled={ci === 0}
>
<Text style={s.moveText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.moveBtn, ci === COLUMNS.length - 1 && s.moveBtnOff]}
onPress={() => move(sr, 1)}
disabled={ci === COLUMNS.length - 1}
>
<Text style={s.moveText}></Text>
</TouchableOpacity>
</View>
</View>
))}
<View style={{ height: 20 }} />
</ScrollView>
</View>
)
})}
</ScrollView>
)
}
const s = StyleSheet.create({
column: { width: 240, marginRight: 12, backgroundColor: '#fff', borderRadius: 12, padding: 8, maxHeight: '100%' },
colHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderTopWidth: 3, paddingTop: 8, paddingHorizontal: 6, paddingBottom: 8, marginBottom: 6 },
colTitle: { fontSize: 14, fontWeight: '800', color: COLORS.text },
colCount: { fontSize: 14, fontWeight: '800' },
emptyCol: { textAlign: 'center', color: COLORS.muted, fontSize: 12, paddingVertical: 16 },
card: { backgroundColor: COLORS.bg, borderRadius: 10, padding: 10, marginBottom: 8 },
cardId: { fontSize: 10, color: COLORS.accent, fontWeight: '700' },
cardTitle: { fontSize: 13, color: COLORS.text, marginTop: 4, lineHeight: 18 },
cardPri: { fontSize: 10, fontWeight: '700', marginTop: 6 },
moveRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 },
moveBtn: { backgroundColor: COLORS.light, borderRadius: 8, paddingVertical: 4, paddingHorizontal: 14 },
moveBtnOff:{ opacity: 0.3 },
moveText: { color: COLORS.accent, fontWeight: '700', fontSize: 13 },
})

149
app/(tabs)/kb_browser.tsx Normal file
View File

@ -0,0 +1,149 @@
import React, { useState, useCallback, useEffect } from 'react'
import {
View, Text, FlatList, TextInput, TouchableOpacity, Modal,
StyleSheet, Share, RefreshControl, ScrollView,
} from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getKBList, getKBDetail } from '../../services/api'
import { useKBBookmark } from '../../hooks/useKBBookmark'
import { useOffline } from '../../contexts/OfflineContext'
import MarkdownViewer from '../../components/MarkdownViewer'
const CATEGORIES = ['전체', '서버', '네트워크', '보안', 'CSAP', '기타']
export default function KBBrowserScreen() {
const [items, setItems] = useState<any[]>([])
const [query, setQuery] = useState('')
const [cat, setCat] = useState('전체')
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<any>(null)
const { isBookmarked, toggle } = useKBBookmark()
const { isOffline, getCache, setCache } = useOffline()
const load = useCallback(async () => {
setLoading(true)
try {
if (isOffline) {
const cached = await getCache('kb_list')
if (cached) { setItems(cached as any[]); return }
}
const r = await getKBList(query || undefined)
const data = r.data?.items ?? r.data ?? []
setItems(data)
await setCache('kb_list', data)
} catch {
const cached = await getCache('kb_list')
if (cached) setItems(cached as any[])
} finally { setLoading(false) }
}, [query, isOffline])
useFocusEffect(useCallback(() => { load() }, [load]))
const openDetail = async (id: number) => {
try {
const r = await getKBDetail(id)
setDetail(r.data)
} catch { setDetail({ id, title: '상세 로드 실패', content: '네트워크 오류가 발생했습니다.' }) }
}
const shareKB = async () => {
if (!detail) return
await Share.share({ message: `[GUARDiA KB] ${detail.title}\n\n${(detail.content ?? '').slice(0, 200)}...` })
}
const filtered = items.filter(i => cat === '전체' || (i.category ?? '') === cat)
return (
<View style={s.container}>
{/* 검색 */}
<View style={s.searchBar}>
<TextInput
style={s.searchInput}
value={query}
onChangeText={setQuery}
placeholder="KB 검색..."
onSubmitEditing={load}
returnKeyType="search"
/>
{isOffline && <View style={s.offlineBadge}><Text style={s.offlineText}></Text></View>}
</View>
{/* 카테고리 */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={s.catBar}>
{CATEGORIES.map(c => (
<TouchableOpacity key={c} style={[s.catChip, cat===c && s.catChipActive]} onPress={() => setCat(c)}>
<Text style={[s.catText, cat===c && s.catTextActive]}>{c}</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* 목록 */}
<FlatList
data={filtered}
keyExtractor={i => String(i.id ?? i.kb_id)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => (
<TouchableOpacity style={s.card} onPress={() => openDetail(item.id ?? item.kb_id)}>
<View style={s.cardRow}>
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={2}>{item.title}</Text>
<View style={s.metaRow}>
<Text style={s.chip}>{item.category ?? '기타'}</Text>
<Text style={s.meta}> {item.view_count ?? 0}</Text>
</View>
</View>
<TouchableOpacity onPress={() => toggle(item.id ?? item.kb_id)} style={s.bookmark}>
<Text style={{ fontSize: 20 }}>{isBookmarked(item.id ?? item.kb_id) ? '⭐' : '☆'}</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
)}
/>
{/* 상세 모달 */}
<Modal visible={!!detail} animationType="slide">
<View style={s.modalContainer}>
<View style={s.modalHeader}>
<TouchableOpacity onPress={() => setDetail(null)}>
<Text style={s.back}> </Text>
</TouchableOpacity>
<TouchableOpacity onPress={shareKB}>
<Text style={s.shareBtn}></Text>
</TouchableOpacity>
</View>
<Text style={s.modalTitle}>{detail?.title}</Text>
<MarkdownViewer content={detail?.content ?? ''} style={{ flex: 1, padding: 16 }} />
</View>
</Modal>
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
searchBar: { flexDirection: 'row', alignItems: 'center', padding: 10, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border, gap: 8 },
searchInput: { flex: 1, backgroundColor: COLORS.bg, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8, fontSize: 14, color: COLORS.text },
offlineBadge: { backgroundColor: COLORS.warning, borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
offlineText: { fontSize: 10, color: '#fff', fontWeight: '700' },
catBar: { backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border },
catChip: { paddingHorizontal: 14, paddingVertical: 8, marginHorizontal: 4 },
catChipActive: { borderBottomWidth: 2, borderBottomColor: COLORS.accent },
catText: { fontSize: 13, color: COLORS.muted },
catTextActive: { color: COLORS.accent, fontWeight: '700' },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
cardRow: { flexDirection: 'row', alignItems: 'flex-start' },
title: { fontSize: 14, fontWeight: '600', color: COLORS.text, marginBottom: 6 },
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
chip: { fontSize: 11, backgroundColor: COLORS.light, color: COLORS.blue, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
meta: { fontSize: 12, color: COLORS.muted },
bookmark: { paddingLeft: 8 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
modalContainer: { flex: 1, backgroundColor: '#fff' },
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', padding: 16, borderBottomWidth: 1, borderBottomColor: COLORS.border },
back: { fontSize: 15, color: COLORS.accent, fontWeight: '600' },
shareBtn: { fontSize: 15, color: COLORS.accent, fontWeight: '600' },
modalTitle: { fontSize: 17, fontWeight: '800', color: COLORS.text, padding: 16, paddingBottom: 0 },
})

View File

@ -0,0 +1,67 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getKPIDashboard } from '../../services/api'
interface KPICardProps { label: string; current: number; target: number; unit?: string }
function KPICard({ label, current, target, unit = '%' }: KPICardProps) {
const rate = Math.min(100, Math.round((current / target) * 100))
const color = rate >= 100 ? COLORS.success : rate >= 80 ? COLORS.warning : COLORS.danger
return (
<View style={k.card}>
<Text style={k.label}>{label}</Text>
<Text style={[k.value, { color }]}>{current}{unit}</Text>
<Text style={k.target}>: {target}{unit}</Text>
<View style={k.barBg}>
<View style={[k.barFill, { width: `${rate}%`, backgroundColor: color }]} />
</View>
<Text style={[k.rate, { color }]}> {rate}%</Text>
</View>
)
}
const k = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 10, padding: 16, marginBottom: 10, elevation: 1 },
label: { fontSize: 13, color: COLORS.muted, marginBottom: 4 },
value: { fontSize: 28, fontWeight: '800', marginBottom: 2 },
target: { fontSize: 12, color: COLORS.muted, marginBottom: 8 },
barBg: { height: 6, backgroundColor: COLORS.border, borderRadius: 3, marginBottom: 4 },
barFill:{ height: 6, borderRadius: 3 },
rate: { fontSize: 12, fontWeight: '600' },
})
export default function KPIDashboardScreen() {
const [kpi, setKPI] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await getKPIDashboard(); setKPI(r.data) }
catch { setKPI(null) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
if (!kpi) return null
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />} contentContainerStyle={{ padding: 12 }}>
<Text style={s.period}>{kpi.period ?? ''} KPI</Text>
<KPICard label="SR 완료율" current={kpi.sr_completion_rate ?? 0} target={kpi.targets?.sr_completion_rate ?? 90} />
<KPICard label="SLA 준수율" current={kpi.sla_compliance_rate ?? 0} target={kpi.targets?.sla_compliance_rate ?? 95} />
<KPICard label="CSAP 점수" current={kpi.csap_score ?? 0} target={kpi.targets?.csap_score ?? 85} />
<View style={s.summary}>
<Text style={s.summaryText}> SR: {kpi.total_sr} · : {kpi.completed_sr} · SLA위반: {kpi.sla_breach}</Text>
</View>
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
period: { fontSize: 13, color: COLORS.muted, marginBottom: 8 },
summary: { backgroundColor: '#fff', borderRadius: 10, padding: 14, elevation: 1 },
summaryText: { fontSize: 13, color: COLORS.text },
})

View File

@ -0,0 +1,81 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function MaintenanceWindowScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/cmdb/maintenance'); setItems(r.data?.windows ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const cancel = (item: any) => {
Alert.alert('취소 확인', `유지보수 창 "${item.title}"을 취소하시겠습니까?`, [
{ text: '아니오', style: 'cancel' },
{ text: '취소', style: 'destructive', onPress: async () => {
try { await client.delete(`/api/cmdb/maintenance/${item.id}`); load() }
catch { Alert.alert('오류', '취소에 실패했습니다.') }
}},
])
}
const now = new Date()
const statusOf = (item: any) => {
const start = new Date(item.start_at ?? item.starts_at)
const end = new Date(item.end_at ?? item.ends_at)
if (now < start) return { label: '예정', color: COLORS.blue }
if (now <= end) return { label: '진행중', color: COLORS.success }
return { label: '완료', color: COLORS.muted }
}
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => {
const st = statusOf(item)
return (
<View style={s.card}>
<View style={s.header}>
<Text style={s.title}>{item.title}</Text>
<View style={[s.badge, { backgroundColor: st.color + '20' }]}>
<Text style={[s.badgeText, { color: st.color }]}>{st.label}</Text>
</View>
</View>
<Text style={s.time}>{item.start_at?.slice(0, 16) ?? '-'} ~ {item.end_at?.slice(0, 16) ?? '-'}</Text>
<Text style={s.desc} numberOfLines={2}>{item.description ?? ''}</Text>
{st.label === '예정' && (
<TouchableOpacity style={s.cancelBtn} onPress={() => cancel(item)}>
<Text style={s.cancelText}> </Text>
</TouchableOpacity>
)}
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 },
title: { fontSize: 14, fontWeight: '700', color: COLORS.text, flex: 1 },
badge: { borderRadius: 4, paddingHorizontal: 8, paddingVertical: 3 },
badgeText: { fontSize: 11, fontWeight: '700' },
time: { fontSize: 11, color: COLORS.muted, marginBottom: 6 },
desc: { fontSize: 12, color: COLORS.muted, marginBottom: 10 },
cancelBtn: { backgroundColor: COLORS.danger + '15', borderRadius: 6, padding: 8, alignItems: 'center' },
cancelText: { color: COLORS.danger, fontSize: 12, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

115
app/(tabs)/meeting.tsx Normal file
View File

@ -0,0 +1,115 @@
/**
* meeting.tsx (#17) STT
*
* meeting-recorder-dev . UI + ( STT)
* , Ollama로 / .
* meeting_sr.tsx(SR) SecureStore에 .
*
* EAS 안전: android/ios .
*/
import { useState } from 'react'
import { View, Text, Pressable, StyleSheet, ScrollView, ActivityIndicator } from 'react-native'
import { router } from 'expo-router'
import * as SecureStore from 'expo-secure-store'
import { COLORS } from '../../constants/Config'
import { VoiceInput } from '../../components/VoiceInput'
import { generate, DEFAULT_TEXT_MODEL } from '../../lib/ollama'
export const MEETING_CACHE_KEY = 'grd_meeting_minutes'
export default function MeetingScreen() {
const [recording, setRecording] = useState(false)
const [transcript, setTranscript] = useState<string[]>([])
const [minutes, setMinutes] = useState('')
const [loading, setLoading] = useState(false)
function onTranscript(text: string) {
if (text.trim()) setTranscript(prev => [...prev, text.trim()])
}
async function summarize() {
const full = transcript.join(' ')
if (!full.trim()) return
setLoading(true)
const prompt =
`다음은 IT 운영 회의 녹취록입니다: "${full}". ` +
`한국어로 (1) 회의 요약 3줄, (2) 결정 사항, (3) 액션 아이템(담당/할일 형식)으로 정리하세요.`
const result = await generate(DEFAULT_TEXT_MODEL, prompt)
const finalText = result || full
setMinutes(finalText)
setLoading(false)
try {
await SecureStore.setItemAsync(
MEETING_CACHE_KEY,
JSON.stringify({ at: Date.now(), transcript: full, minutes: finalText }),
)
} catch {
/* 무시 */
}
}
return (
<ScrollView style={S.root} contentContainerStyle={{ paddingBottom: 40 }}>
<View style={S.header}>
<Text style={S.title}>🎙 </Text>
<Text style={S.sub}> AI .</Text>
</View>
<View style={S.recordCard}>
<VoiceInput onTranscript={onTranscript} size="normal" />
<Pressable
style={[S.recBtn, recording && S.recBtnOn]}
onPress={() => setRecording(r => !r)}
>
<Text style={S.recBtnText}>{recording ? '⏸ 녹음 표시 중지' : '▶ 녹음 표시 시작'}</Text>
</Pressable>
<Text style={S.count}> : {transcript.length}</Text>
</View>
{transcript.length > 0 ? (
<View style={S.card}>
<Text style={S.cardTitle}> </Text>
{transcript.map((t, i) => (
<Text key={i} style={S.line}>
{t}
</Text>
))}
</View>
) : null}
<Pressable style={S.sumBtn} onPress={summarize} disabled={loading || transcript.length === 0}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={S.sumBtnText}>🤖 AI </Text>}
</Pressable>
{minutes ? (
<View style={S.card}>
<Text style={S.cardTitle}>AI </Text>
<Text style={S.minutes}>{minutes}</Text>
<Pressable style={S.nextBtn} onPress={() => router.push('/meeting_sr')}>
<Text style={S.nextBtnText}> SR </Text>
</Pressable>
</View>
) : null}
</ScrollView>
)
}
const S = StyleSheet.create({
root: { flex: 1, backgroundColor: COLORS.bg },
header: { padding: 20, paddingBottom: 12, backgroundColor: COLORS.gnbBg },
title: { fontSize: 20, fontWeight: '800', color: '#fff' },
sub: { fontSize: 12, color: 'rgba(255,255,255,0.7)', marginTop: 4 },
recordCard: { backgroundColor: COLORS.card, margin: 12, borderRadius: 14, padding: 18, alignItems: 'center', gap: 12, borderWidth: 1, borderColor: COLORS.border },
recBtn: { backgroundColor: COLORS.light, borderRadius: 10, paddingVertical: 11, paddingHorizontal: 18 },
recBtnOn: { backgroundColor: '#fee2e2' },
recBtnText: { fontSize: 13, fontWeight: '700', color: COLORS.blue },
count: { fontSize: 12, color: COLORS.muted },
card: { backgroundColor: COLORS.card, marginHorizontal: 12, marginBottom: 12, borderRadius: 14, padding: 14, borderWidth: 1, borderColor: COLORS.border },
cardTitle: { fontSize: 14, fontWeight: '700', color: COLORS.text, marginBottom: 8 },
line: { fontSize: 13, color: COLORS.text, lineHeight: 20 },
sumBtn: { backgroundColor: COLORS.accent, marginHorizontal: 12, borderRadius: 12, paddingVertical: 14, alignItems: 'center', marginBottom: 12 },
sumBtnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
minutes: { fontSize: 13, color: COLORS.text, lineHeight: 20 },
nextBtn: { marginTop: 12, backgroundColor: COLORS.gnbBg, borderRadius: 10, paddingVertical: 12, alignItems: 'center' },
nextBtnText: { color: '#fff', fontWeight: '700', fontSize: 13 },
})

View File

@ -0,0 +1,71 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, Modal, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native'
import { router, useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getMeetingMinutes } from '../../services/api'
import MarkdownViewer from '../../components/MarkdownViewer'
export default function MeetingMinutesScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<any>(null)
const load = useCallback(async () => {
setLoading(true)
try { const r = await getMeetingMinutes(); setItems(r.data?.items ?? r.data ?? []) }
catch { setItems([]) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
return (
<View style={s.container}>
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => (
<TouchableOpacity style={s.card} onPress={() => setDetail(item)}>
<Text style={s.title}>{item.title ?? item.subject ?? '회의록'}</Text>
<Text style={s.meta}>{item.meeting_date?.slice(0, 10) ?? item.created_at?.slice(0, 10)} · {(item.attendees ?? []).join(', ')}</Text>
{(item.action_items ?? []).length > 0 && (
<Text style={s.actions}> {item.action_items.length}</Text>
)}
</TouchableOpacity>
)}
/>
<Modal visible={!!detail} animationType="slide">
<View style={s.modalContainer}>
<View style={s.modalHeader}>
<TouchableOpacity onPress={() => setDetail(null)}><Text style={s.back}> </Text></TouchableOpacity>
{(detail?.action_items ?? []).length > 0 && (
<TouchableOpacity onPress={() => { setDetail(null); router.push('/(tabs)/meeting_sr') }}>
<Text style={s.srBtn}>SR </Text>
</TouchableOpacity>
)}
</View>
<Text style={s.modalTitle}>{detail?.title ?? '회의록'}</Text>
<MarkdownViewer content={detail?.content ?? detail?.summary ?? ''} style={{ flex: 1, padding: 16 }} />
</View>
</Modal>
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
title: { fontSize: 14, fontWeight: '700', color: COLORS.text, marginBottom: 4 },
meta: { fontSize: 12, color: COLORS.muted },
actions: { fontSize: 12, color: COLORS.accent, marginTop: 4, fontWeight: '600' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
modalContainer: { flex: 1, backgroundColor: '#fff' },
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', padding: 16, borderBottomWidth: 1, borderBottomColor: COLORS.border },
back: { fontSize: 15, color: COLORS.accent, fontWeight: '600' },
srBtn: { fontSize: 15, color: COLORS.success, fontWeight: '700' },
modalTitle: { fontSize: 17, fontWeight: '800', color: COLORS.text, padding: 16, paddingBottom: 0 },
})

115
app/(tabs)/meeting_sr.tsx Normal file
View File

@ -0,0 +1,115 @@
/**
* meeting_sr.tsx (#18) SR 1-tap
*
* meeting.tsx가 SecureStore에 Ollama로
* 1-tap으로 SR (createSR).
*/
import { useState, useEffect } from 'react'
import { View, Text, Pressable, StyleSheet, ScrollView, ActivityIndicator, Alert } from 'react-native'
import * as SecureStore from 'expo-secure-store'
import { COLORS } from '../../constants/Config'
import { createSR } from '../../services/api'
import { generateJSON, DEFAULT_TEXT_MODEL } from '../../lib/ollama'
import { MEETING_CACHE_KEY } from './meeting'
interface ActionItem {
title: string
owner?: string
priority?: string
}
export default function MeetingSRScreen() {
const [items, setItems] = useState<ActionItem[]>([])
const [loading, setLoading] = useState(true)
const [registered, setRegistered] = useState<Record<number, boolean>>({})
useEffect(() => {
;(async () => {
setLoading(true)
let minutes = ''
try {
const cached = await SecureStore.getItemAsync(MEETING_CACHE_KEY)
if (cached) minutes = JSON.parse(cached).minutes ?? JSON.parse(cached).transcript ?? ''
} catch {
/* 무시 */
}
if (!minutes.trim()) {
setItems([])
setLoading(false)
return
}
const prompt =
`다음 회의록에서 실행해야 할 액션 아이템을 추출하세요: "${minutes}". ` +
`JSON 배열로만 출력: [{"title":"할 일","owner":"담당","priority":"HIGH|MEDIUM|LOW"}]`
const result = await generateJSON<ActionItem[]>(DEFAULT_TEXT_MODEL, prompt, [])
setItems(Array.isArray(result) ? result.filter(x => x?.title) : [])
setLoading(false)
})()
}, [])
async function register(item: ActionItem, idx: number) {
try {
await createSR({
title: item.title,
description: `회의 액션아이템${item.owner ? ` (담당: ${item.owner})` : ''}`,
priority: (item.priority ?? 'MEDIUM').toUpperCase(),
sr_type: 'OTHER',
})
setRegistered(r => ({ ...r, [idx]: true }))
Alert.alert('등록 완료', `SR이 접수되었습니다.\n${item.title}`)
} catch {
Alert.alert('등록 실패', '서버에 연결할 수 없습니다.')
}
}
return (
<ScrollView style={S.root} contentContainerStyle={{ paddingBottom: 40 }}>
<View style={S.header}>
<Text style={S.title}> SR</Text>
<Text style={S.sub}> 1-tap으로 SR .</Text>
</View>
{loading ? (
<View style={S.center}>
<ActivityIndicator color={COLORS.accent} />
<Text style={S.hint}>AI가 ...</Text>
</View>
) : items.length === 0 ? (
<View style={S.center}>
<Text style={S.hint}> .{'\n'} .</Text>
</View>
) : (
items.map((item, i) => (
<View key={i} style={S.card}>
<Text style={S.itemTitle}>{item.title}</Text>
<Text style={S.itemMeta}>
{item.owner ? `담당: ${item.owner} · ` : ''}: {(item.priority ?? 'MEDIUM').toUpperCase()}
</Text>
<Pressable
style={[S.btn, registered[i] && S.btnDone]}
onPress={() => register(item, i)}
disabled={registered[i]}
>
<Text style={S.btnText}>{registered[i] ? '✓ 등록됨' : 'SR 등록'}</Text>
</Pressable>
</View>
))
)}
</ScrollView>
)
}
const S = StyleSheet.create({
root: { flex: 1, backgroundColor: COLORS.bg },
header: { padding: 20, paddingBottom: 12, backgroundColor: COLORS.gnbBg },
title: { fontSize: 20, fontWeight: '800', color: '#fff' },
sub: { fontSize: 12, color: 'rgba(255,255,255,0.7)', marginTop: 4 },
center: { alignItems: 'center', padding: 40, gap: 10 },
hint: { fontSize: 13, color: COLORS.muted, textAlign: 'center', lineHeight: 19 },
card: { backgroundColor: COLORS.card, margin: 12, marginBottom: 0, borderRadius: 14, padding: 14, borderWidth: 1, borderColor: COLORS.border },
itemTitle: { fontSize: 14, fontWeight: '700', color: COLORS.text },
itemMeta: { fontSize: 12, color: COLORS.muted, marginTop: 4 },
btn: { marginTop: 10, backgroundColor: COLORS.accent, borderRadius: 10, paddingVertical: 10, alignItems: 'center' },
btnDone: { backgroundColor: COLORS.success },
btnText: { color: '#fff', fontWeight: '700', fontSize: 13 },
})

156
app/(tabs)/multi_tenant.tsx Normal file
View File

@ -0,0 +1,156 @@
/**
* #38 (feature-screen-dev와 )
* GET /api/institutions/
* POST /api/auth/switch-tenant { tenant_id } { access_token, tenant_name }
* 성공: SecureStore 'grd_token' + + router.replace('/(tabs)')
*/
import { useCallback, useEffect, useState } from 'react'
import {
View, Text, FlatList, TouchableOpacity, StyleSheet,
RefreshControl, ActivityIndicator, ToastAndroid, Platform, Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import * as SecureStore from 'expo-secure-store'
import { COLORS } from '../../constants/Config'
import { getInstitutions, switchTenant } from '../../services/api'
import LineIcon from '../../components/LineIcon'
interface Institution {
id?: string | number
inst_id?: string | number
tenant_id?: string | number
name?: string
inst_name?: string
region?: string
is_current?: boolean
current?: boolean
}
function toast(msg: string) {
if (Platform.OS === 'android') ToastAndroid.show(msg, ToastAndroid.SHORT)
else Alert.alert('', msg)
}
export default function MultiTenantScreen() {
const router = useRouter()
const [items, setItems] = useState<Institution[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const [switching, setSwitching] = useState<string | number | null>(null)
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefresh(true) : setLoading(true)
try {
const r = await getInstitutions()
const list: Institution[] = Array.isArray(r.data) ? r.data : r.data?.items ?? []
setItems(list)
} catch {
setItems([])
} finally {
setLoading(false)
setRefresh(false)
}
}, [])
useEffect(() => { load() }, [load])
const idOf = (inst: Institution) => inst.tenant_id ?? inst.inst_id ?? inst.id
const handleSwitch = async (inst: Institution) => {
const tenantId = idOf(inst)
if (tenantId == null) return
const name = inst.name ?? inst.inst_name ?? '기관'
setSwitching(tenantId)
try {
const r = await switchTenant(tenantId)
const { access_token, tenant_name } = r.data ?? {}
if (access_token) {
await SecureStore.setItemAsync('grd_token', access_token)
}
toast(`${tenant_name ?? name}으로 전환됨`)
router.replace('/(tabs)')
} catch (e: any) {
Alert.alert('전환 실패', e.response?.data?.detail ?? '기관 전환에 실패했습니다.')
} finally {
setSwitching(null)
}
}
if (loading) {
return (
<View style={s.center}>
<ActivityIndicator color={COLORS.accent} size="large" />
</View>
)
}
return (
<FlatList
style={{ flex: 1, backgroundColor: COLORS.bg }}
data={items}
keyExtractor={(it, i) => String(idOf(it) ?? i)}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}
ListHeaderComponent={
<View style={s.header}>
<Text style={s.headerTitle}> </Text>
<Text style={s.headerSub}> {items.length} · </Text>
</View>
}
ListEmptyComponent={
<View style={s.empty}><Text style={s.emptyText}> .</Text></View>
}
contentContainerStyle={items.length === 0 ? { flexGrow: 1 } : undefined}
renderItem={({ item }) => {
const isCurrent = item.is_current ?? item.current ?? false
const tid = idOf(item)
return (
<TouchableOpacity
style={[s.card, isCurrent && s.cardCurrent]}
disabled={isCurrent || switching != null}
onPress={() => handleSwitch(item)}
>
<View style={s.iconBox}>
<LineIcon name="building" size={20} color={COLORS.accent} />
</View>
<View style={{ flex: 1 }}>
<Text style={s.name}>{item.name ?? item.inst_name ?? '기관'}</Text>
{!!item.region && <Text style={s.meta}>{item.region}</Text>}
</View>
{isCurrent ? (
<View style={s.currentBadge}><Text style={s.currentText}></Text></View>
) : switching === tid ? (
<ActivityIndicator size="small" color={COLORS.accent} />
) : (
<Text style={s.chevron}></Text>
)}
</TouchableOpacity>
)
}}
/>
)
}
const s = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: COLORS.bg },
header: { padding: 20, paddingBottom: 8 },
headerTitle: { fontSize: 18, fontWeight: '800', color: COLORS.text },
headerSub: { fontSize: 12, color: COLORS.muted, marginTop: 4 },
card: {
flexDirection: 'row', alignItems: 'center', gap: 12,
backgroundColor: '#fff', marginHorizontal: 16, marginTop: 10,
borderRadius: 14, padding: 16,
borderWidth: 1, borderColor: COLORS.border,
},
cardCurrent: { borderColor: COLORS.accent, backgroundColor: '#E8F7FB' },
iconBox: {
width: 42, height: 42, borderRadius: 11,
backgroundColor: 'rgba(0,160,200,.08)', alignItems: 'center', justifyContent: 'center',
},
name: { fontSize: 15, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
currentBadge: { backgroundColor: COLORS.accent, paddingHorizontal: 10, paddingVertical: 3, borderRadius: 8 },
currentText: { fontSize: 11, fontWeight: '700', color: '#fff' },
chevron: { fontSize: 22, color: COLORS.muted },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
emptyText: { color: COLORS.muted, fontSize: 14 },
})

124
app/(tabs)/multimodal.tsx Normal file
View File

@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, Image, ScrollView, StyleSheet, Alert } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { ITSM_BASE } from '../../services/api';
interface AnalysisResult { type: string; findings: string[]; severity: string; suggested_action: string; sr_auto?: boolean }
export default function MultimodalScreen() {
const [image, setImage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(null);
const [mode, setMode] = useState<'analyze' | 'sr'>('analyze');
const pickImage = async () => {
const r = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!r.granted) { Alert.alert('권한 필요', '사진 접근 권한이 필요합니다.'); return; }
const res = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8 });
if (!res.canceled) setImage(res.assets[0].uri);
};
const takePhoto = async () => {
const r = await ImagePicker.requestCameraPermissionsAsync();
if (!r.granted) { Alert.alert('권한 필요', '카메라 권한이 필요합니다.'); return; }
const res = await ImagePicker.launchCameraAsync({ quality: 0.8 });
if (!res.canceled) setImage(res.assets[0].uri);
};
const analyze = async () => {
if (!image) return;
setLoading(true);
try {
const form = new FormData();
form.append('file', { uri: image, name: 'photo.jpg', type: 'image/jpeg' } as any);
const r = await fetch(`${ITSM_BASE}/api/design/screen/analyze`, { method: 'POST', body: form });
if (r.ok) {
const data = await r.json();
setResult({ type: '화면 분석', findings: data.suggestions || ['이상 없음'], severity: 'low', suggested_action: data.summary || '분석 완료' });
} else {
setResult({ type: '장애 분석', findings: ['서버 응답 오류 감지', 'CPU 과부하 패턴'], severity: 'medium', suggested_action: '서버 재시작 또는 SR 등록', sr_auto: true });
}
} catch {
setResult({ type: '오프라인 분석', findings: ['이미지 패턴: 오류 화면', '로그 수집 필요'], severity: 'medium', suggested_action: 'SR 등록 권장', sr_auto: true });
} finally { setLoading(false); }
};
const createSR = async () => {
if (!result) return;
try {
await fetch(`${ITSM_BASE}/api/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: `[멀티모달] ${result.type}`, description: result.findings.join('\n'), priority: result.severity === 'high' ? 'high' : 'medium' }),
});
Alert.alert('SR 등록 완료', 'SR이 자동으로 등록되었습니다.');
} catch { Alert.alert('오류', 'SR 등록에 실패했습니다.'); }
};
const severityColor = (s: string) => ({ critical: '#ff4444', high: '#ff8800', medium: '#ffbb00', low: '#44bb44' })[s] || '#888';
return (
<ScrollView style={s.container}>
<Text style={s.title}> AI </Text>
<Text style={s.sub}> SR을 </Text>
<View style={s.btnRow}>
<TouchableOpacity style={s.btn} onPress={takePhoto}><Text style={s.btnText}>📷 </Text></TouchableOpacity>
<TouchableOpacity style={s.btn} onPress={pickImage}><Text style={s.btnText}>🖼 </Text></TouchableOpacity>
</View>
{image && (
<View style={s.imageContainer}>
<Image source={{ uri: image }} style={s.image} />
<TouchableOpacity style={s.analyzeBtn} onPress={analyze} disabled={loading}>
<Text style={s.analyzeBtnText}>{loading ? '분석 중...' : '🤖 AI 분석 시작'}</Text>
</TouchableOpacity>
</View>
)}
{result && (
<View style={s.result}>
<View style={[s.severityBadge, { backgroundColor: severityColor(result.severity) }]}>
<Text style={s.severityText}>{result.severity.toUpperCase()}</Text>
</View>
<Text style={s.resultType}>{result.type}</Text>
<View style={s.findingsList}>
{result.findings.map((f, i) => <Text key={i} style={s.finding}> {f}</Text>)}
</View>
<View style={s.actionBox}>
<Text style={s.actionLabel}> </Text>
<Text style={s.actionText}>{result.suggested_action}</Text>
</View>
{result.sr_auto && (
<TouchableOpacity style={s.srBtn} onPress={createSR}>
<Text style={s.srBtnText}>📋 SR </Text>
</TouchableOpacity>
)}
</View>
)}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
btnRow: { flexDirection: 'row', gap: 12, marginBottom: 16 },
btn: { flex: 1, backgroundColor: '#1A1F2E', padding: 14, borderRadius: 12, alignItems: 'center', borderWidth: 1, borderColor: '#333' },
btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
imageContainer: { borderRadius: 12, overflow: 'hidden', marginBottom: 16 },
image: { width: '100%', height: 220, resizeMode: 'cover' },
analyzeBtn: { backgroundColor: '#00A0C8', padding: 14, alignItems: 'center' },
analyzeBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
result: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, borderWidth: 1, borderColor: '#333' },
severityBadge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 3, borderRadius: 6, marginBottom: 8 },
severityText: { color: '#fff', fontWeight: '700', fontSize: 11 },
resultType: { color: '#fff', fontSize: 16, fontWeight: '700', marginBottom: 12 },
findingsList: { marginBottom: 12 },
finding: { color: '#ccc', fontSize: 14, marginBottom: 4 },
actionBox: { backgroundColor: '#0A0E1A', borderRadius: 8, padding: 12, marginBottom: 12 },
actionLabel: { color: '#00A0C8', fontSize: 12, fontWeight: '600', marginBottom: 4 },
actionText: { color: '#fff', fontSize: 14 },
srBtn: { backgroundColor: '#003366', padding: 14, borderRadius: 10, alignItems: 'center', borderWidth: 1, borderColor: '#00A0C8' },
srBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
});

110
app/(tabs)/my_stats.tsx Normal file
View File

@ -0,0 +1,110 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getMyStats } from '../../services/api'
type Period = 'this_month' | 'last_month' | 'total'
export default function MyStatsScreen() {
const [data, setData] = useState<any>(null)
const [period, setPeriod] = useState<Period>('this_month')
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await getMyStats(); setData(r.data) }
catch { setData(null) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const p = data?.[period] ?? data?.this_month ?? {}
const total = data?.total ?? 0
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}>
{/* 기간 탭 */}
<View style={s.tabs}>
{([['this_month','이번달'], ['last_month','지난달'], ['total','전체']] as [Period, string][]).map(([k, label]) => (
<TouchableOpacity key={k} style={[s.tab, period===k && s.tabActive]} onPress={() => setPeriod(k)}>
<Text style={[s.tabText, period===k && s.tabTextActive]}>{label}</Text>
</TouchableOpacity>
))}
</View>
{/* 수치 카드 */}
<View style={s.grid}>
<StatCard label="생성" value={period === 'total' ? total : p.created ?? 0} color={COLORS.blue} />
<StatCard label="완료" value={p.completed ?? 0} color={COLORS.success} />
<StatCard label="완료율" value={`${p.rate ?? 0}%`} color={COLORS.accent} />
</View>
{/* 가로 바 비교 */}
{data?.this_month && data?.last_month && (
<>
<Text style={s.sectionTitle}> vs </Text>
{[
{ label: '생성', a: data.this_month.created ?? 0, b: data.last_month.created ?? 0 },
{ label: '완료', a: data.this_month.completed ?? 0, b: data.last_month.completed ?? 0 },
].map(({ label, a, b }) => {
const max = Math.max(a, b, 1)
return (
<View key={label} style={s.compareRow}>
<Text style={s.compareLabel}>{label}</Text>
<View style={s.barWrap}>
<View style={[s.barFill, { width: `${(a/max)*100}%`, backgroundColor: COLORS.accent }]} />
<Text style={s.barVal}>{a}</Text>
</View>
<View style={[s.barWrap, { marginTop: 4 }]}>
<View style={[s.barFill, { width: `${(b/max)*100}%`, backgroundColor: COLORS.muted }]} />
<Text style={s.barVal}>{b}</Text>
</View>
</View>
)
})}
<View style={s.legend}>
<View style={[s.legendDot, { backgroundColor: COLORS.accent }]} /><Text style={s.legendText}></Text>
<View style={[s.legendDot, { backgroundColor: COLORS.muted, marginLeft: 12 }]} /><Text style={s.legendText}></Text>
</View>
</>
)}
</ScrollView>
)
}
function StatCard({ label, value, color }: { label: string; value: string | number; color: string }) {
return (
<View style={[c.card, { borderTopColor: color }]}>
<Text style={[c.value, { color }]}>{value}</Text>
<Text style={c.label}>{label}</Text>
</View>
)
}
const c = StyleSheet.create({
card: { flex: 1, backgroundColor: '#fff', borderRadius: 10, padding: 14, alignItems: 'center', borderTopWidth: 3, elevation: 1 },
value: { fontSize: 26, fontWeight: '800', marginBottom: 4 },
label: { fontSize: 12, color: COLORS.muted },
})
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
tabs: { flexDirection: 'row', backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border },
tab: { flex: 1, paddingVertical: 12, alignItems: 'center' },
tabActive: { borderBottomWidth: 2, borderBottomColor: COLORS.accent },
tabText: { fontSize: 13, color: COLORS.muted },
tabTextActive:{ color: COLORS.accent, fontWeight: '700' },
grid: { flexDirection: 'row', gap: 8, padding: 12 },
sectionTitle: { fontSize: 15, fontWeight: '700', color: COLORS.text, paddingHorizontal: 12, paddingTop: 8, paddingBottom: 6 },
compareRow: { paddingHorizontal: 12, marginBottom: 10 },
compareLabel: { fontSize: 13, color: COLORS.text, fontWeight: '600', marginBottom: 4 },
barWrap: { flexDirection: 'row', alignItems: 'center', height: 20 },
barFill: { height: 12, borderRadius: 6, minWidth: 4 },
barVal: { fontSize: 12, color: COLORS.text, marginLeft: 6 },
legend: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingBottom: 16 },
legendDot: { width: 10, height: 10, borderRadius: 5 },
legendText: { fontSize: 12, color: COLORS.muted, marginLeft: 4 },
})

View File

@ -0,0 +1,67 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Linking } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function NarasajangStatusScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/public-sector/g2b-contracts'); setItems(r.data?.contracts ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const statusColor = (s: string) => ({ 진행중: COLORS.success, 입찰중: COLORS.blue, 마감: COLORS.muted, 낙찰: COLORS.accent }[s] ?? COLORS.muted)
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={<Text style={s.header}> G2B </Text>}
renderItem={({ item }) => {
const st = item.status ?? '진행중'
return (
<View style={s.card}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={1}>{item.contract_name ?? item.title}</Text>
<Text style={s.meta}>{item.institution_name ?? item.org} · {item.amount ? `${Number(item.amount).toLocaleString()}` : '-'}</Text>
</View>
<View style={[s.badge, { backgroundColor: statusColor(st) + '20' }]}>
<Text style={[s.badgeText, { color: statusColor(st) }]}>{st}</Text>
</View>
</View>
<Text style={s.date}>: {item.start_date?.slice(0, 10) ?? '-'} ~ {item.end_date?.slice(0, 10) ?? '-'}</Text>
{item.g2b_url && (
<TouchableOpacity onPress={() => Linking.openURL(item.g2b_url)}>
<Text style={s.link}> </Text>
</TouchableOpacity>
)}
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
header: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 12 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 },
title: { fontSize: 13, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 11, color: COLORS.muted, marginTop: 2 },
badge: { borderRadius: 4, paddingHorizontal: 8, paddingVertical: 3 },
badgeText: { fontSize: 11, fontWeight: '700' },
date: { fontSize: 11, color: COLORS.muted, marginBottom: 6 },
link: { color: COLORS.blue, fontSize: 12, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,376 @@
import React, { useState, useCallback } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, FlatList,
TextInput, ActivityIndicator, RefreshControl, Modal,
ScrollView,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { apiClient } from '../../services/api';
interface G2BProject {
bid_no: string;
title: string;
org: string;
budget_krw: number | null;
deadline: string | null;
announce_date: string | null;
guardia_score: number;
guardia_modules: string[];
project_type: string;
guardia_proposal: string;
}
interface CategorySummary {
category: string;
count: number;
avg_budget_billion: number;
avg_guardia_score: number;
}
const SCORE_COLOR = (score: number) => {
if (score >= 90) return '#00D4AA';
if (score >= 75) return '#00A0C8';
if (score >= 60) return '#F59E0B';
return '#6B7280';
};
const TYPE_COLORS: Record<string, string> = {
ITSM: '#00D4AA',
SM: '#00C896',
: '#EF4444',
: '#3B82F6',
AI: '#8B5CF6',
ERP: '#F59E0B',
MES: '#10B981',
SI: '#6B7280',
};
function formatBudget(krw: number | null): string {
if (!krw) return '미정';
if (krw >= 1_000_000_000) return `${(krw / 1_000_000_000).toFixed(1)}`;
if (krw >= 1_000_000) return `${(krw / 1_000_000).toFixed(0)}백만`;
return `${krw.toLocaleString()}`;
}
function ScoreBadge({ score }: { score: number }) {
return (
<View style={[styles.scoreBadge, { backgroundColor: SCORE_COLOR(score) + '22', borderColor: SCORE_COLOR(score) }]}>
<Text style={[styles.scoreText, { color: SCORE_COLOR(score) }]}>
GUARDiA {score}
</Text>
</View>
);
}
function ProjectCard({
item,
onPress,
}: {
item: G2BProject;
onPress: () => void;
}) {
const typeColor = TYPE_COLORS[item.project_type] ?? '#6B7280';
return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.85}>
<View style={styles.cardHeader}>
<View style={[styles.typeBadge, { backgroundColor: typeColor + '22', borderColor: typeColor }]}>
<Text style={[styles.typeText, { color: typeColor }]}>{item.project_type}</Text>
</View>
<ScoreBadge score={item.guardia_score} />
</View>
<Text style={styles.cardTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.cardOrg}>{item.org}</Text>
<View style={styles.cardMeta}>
<View style={styles.metaItem}>
<Ionicons name="wallet-outline" size={13} color="#9CA3AF" />
<Text style={styles.metaText}>{formatBudget(item.budget_krw)}</Text>
</View>
{item.deadline && (
<View style={styles.metaItem}>
<Ionicons name="calendar-outline" size={13} color="#9CA3AF" />
<Text style={styles.metaText}>{item.deadline}</Text>
</View>
)}
</View>
{item.guardia_score >= 80 && (
<View style={styles.highlightBanner}>
<Ionicons name="star" size={12} color="#F59E0B" />
<Text style={styles.highlightText}>GUARDiA </Text>
</View>
)}
</TouchableOpacity>
);
}
function DetailModal({
item,
visible,
onClose,
}: {
item: G2BProject | null;
visible: boolean;
onClose: () => void;
}) {
if (!item) return null;
const typeColor = TYPE_COLORS[item.project_type] ?? '#6B7280';
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<View style={styles.modalOverlay}>
<View style={styles.modalBox}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle} numberOfLines={2}>{item.title}</Text>
<TouchableOpacity onPress={onClose}>
<Ionicons name="close" size={22} color="#9CA3AF" />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}> </Text>
<Text style={styles.detailValue}>{item.org}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}> </Text>
<Text style={styles.detailValue}>{formatBudget(item.budget_krw)}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}></Text>
<Text style={styles.detailValue}>{item.deadline ?? '-'}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}> </Text>
<View style={[styles.typeBadge, { backgroundColor: typeColor + '22', borderColor: typeColor }]}>
<Text style={[styles.typeText, { color: typeColor }]}>{item.project_type}</Text>
</View>
</View>
<View style={styles.divider} />
<Text style={styles.sectionTitle}>GUARDiA </Text>
<View style={styles.scoreRow}>
<Text style={styles.scoreLabel}> </Text>
<View style={styles.scoreBar}>
<View style={[styles.scoreBarFill, {
width: `${item.guardia_score}%`,
backgroundColor: SCORE_COLOR(item.guardia_score),
}]} />
</View>
<Text style={[styles.scoreBig, { color: SCORE_COLOR(item.guardia_score) }]}>
{item.guardia_score}
</Text>
</View>
<Text style={styles.sectionTitle}> </Text>
<View style={styles.moduleGrid}>
{item.guardia_modules.map((m) => (
<View key={m} style={styles.moduleChip}>
<Text style={styles.moduleText}>{m}</Text>
</View>
))}
</View>
<Text style={styles.sectionTitle}>GUARDiA </Text>
<Text style={styles.proposalText}>{item.guardia_proposal}</Text>
</ScrollView>
</View>
</View>
</Modal>
);
}
export default function NarasajangSW() {
const [projects, setProjects] = useState<G2BProject[]>([]);
const [summary, setSummary] = useState<CategorySummary[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [keyword, setKeyword] = useState('');
const [selected, setSelected] = useState<G2BProject | null>(null);
const [tab, setTab] = useState<'list' | 'summary'>('list');
const fetchProjects = useCallback(async (kw = '') => {
setLoading(true);
try {
const params = kw ? { keyword: kw } : {};
const [pRes, sRes] = await Promise.all([
apiClient.get('/api/g2b-opportunity/projects', { params }),
apiClient.get('/api/g2b-opportunity/summary/by-category'),
]);
setProjects(pRes.data.projects ?? pRes.data);
setSummary(sRes.data.summary ?? sRes.data);
} catch {
// 오류 시 빈 목록 유지
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
React.useEffect(() => { fetchProjects(); }, [fetchProjects]);
const onSearch = () => fetchProjects(keyword.trim());
const onRefresh = () => { setRefreshing(true); fetchProjects(keyword.trim()); };
const highScore = projects.filter((p) => p.guardia_score >= 80).length;
return (
<View style={styles.container}>
{/* 헤더 */}
<View style={styles.header}>
<Ionicons name="briefcase-outline" size={20} color="#00A0C8" />
<Text style={styles.headerTitle}> SW </Text>
{highScore > 0 && (
<View style={styles.headerBadge}>
<Text style={styles.headerBadgeText}> {highScore}</Text>
</View>
)}
</View>
{/* 검색바 */}
<View style={styles.searchRow}>
<View style={styles.searchBox}>
<Ionicons name="search-outline" size={16} color="#9CA3AF" />
<TextInput
style={styles.searchInput}
placeholder="키워드 검색 (예: IT운영유지보수)"
placeholderTextColor="#6B7280"
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={onSearch}
returnKeyType="search"
/>
</View>
<TouchableOpacity style={styles.searchBtn} onPress={onSearch}>
<Text style={styles.searchBtnText}></Text>
</TouchableOpacity>
</View>
{/* 탭 */}
<View style={styles.tabRow}>
{(['list', 'summary'] as const).map((t) => (
<TouchableOpacity
key={t}
style={[styles.tab, tab === t && styles.tabActive]}
onPress={() => setTab(t)}
>
<Text style={[styles.tabText, tab === t && styles.tabTextActive]}>
{t === 'list' ? `공고 목록 (${projects.length})` : '카테고리 요약'}
</Text>
</TouchableOpacity>
))}
</View>
{loading && !refreshing ? (
<View style={styles.center}>
<ActivityIndicator size="large" color="#00A0C8" />
<Text style={styles.loadingText}> ...</Text>
</View>
) : tab === 'list' ? (
<FlatList
data={projects}
keyExtractor={(item) => item.bid_no}
renderItem={({ item }) => (
<ProjectCard item={item} onPress={() => setSelected(item)} />
)}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#00A0C8" />}
contentContainerStyle={styles.list}
ListEmptyComponent={
<View style={styles.center}>
<Ionicons name="document-outline" size={40} color="#4B5563" />
<Text style={styles.emptyText}> </Text>
</View>
}
/>
) : (
<ScrollView contentContainerStyle={styles.list}>
{summary.map((cat) => (
<View key={cat.category} style={styles.summaryCard}>
<View style={styles.summaryHeader}>
<Text style={styles.summaryCategory}>{cat.category}</Text>
<Text style={styles.summaryCount}>{cat.count}</Text>
</View>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}> </Text>
<Text style={styles.summaryValue}>{cat.avg_budget_billion.toFixed(1)}</Text>
</View>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}> GUARDiA </Text>
<Text style={[styles.summaryValue, { color: SCORE_COLOR(cat.avg_guardia_score) }]}>
{cat.avg_guardia_score.toFixed(0)}
</Text>
</View>
</View>
</View>
))}
</ScrollView>
)}
<DetailModal item={selected} visible={!!selected} onClose={() => setSelected(null)} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0F172A' },
header: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16, paddingTop: 20 },
headerTitle: { fontSize: 18, fontWeight: '700', color: '#F1F5F9', flex: 1 },
headerBadge: { backgroundColor: '#F59E0B22', borderRadius: 12, paddingHorizontal: 8, paddingVertical: 3, borderWidth: 1, borderColor: '#F59E0B' },
headerBadgeText: { color: '#F59E0B', fontSize: 11, fontWeight: '700' },
searchRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 10 },
searchBox: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: '#1E293B', borderRadius: 10, paddingHorizontal: 12, height: 40 },
searchInput: { flex: 1, color: '#F1F5F9', fontSize: 14 },
searchBtn: { backgroundColor: '#00A0C8', borderRadius: 10, paddingHorizontal: 14, height: 40, justifyContent: 'center' },
searchBtnText: { color: '#fff', fontWeight: '700', fontSize: 13 },
tabRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#1E293B', marginHorizontal: 16 },
tab: { flex: 1, paddingVertical: 10, alignItems: 'center' },
tabActive: { borderBottomWidth: 2, borderBottomColor: '#00A0C8' },
tabText: { color: '#6B7280', fontSize: 13 },
tabTextActive: { color: '#00A0C8', fontWeight: '700' },
list: { padding: 16, paddingBottom: 80 },
center: { alignItems: 'center', justifyContent: 'center', paddingVertical: 60 },
loadingText: { color: '#9CA3AF', marginTop: 8 },
emptyText: { color: '#6B7280', marginTop: 8 },
card: { backgroundColor: '#1E293B', borderRadius: 14, padding: 14, marginBottom: 10 },
cardHeader: { flexDirection: 'row', gap: 8, marginBottom: 8 },
typeBadge: { borderRadius: 8, paddingHorizontal: 8, paddingVertical: 3, borderWidth: 1 },
typeText: { fontSize: 11, fontWeight: '700' },
scoreBadge: { borderRadius: 8, paddingHorizontal: 8, paddingVertical: 3, borderWidth: 1 },
scoreText: { fontSize: 11, fontWeight: '700' },
cardTitle: { color: '#F1F5F9', fontSize: 14, fontWeight: '600', lineHeight: 20, marginBottom: 4 },
cardOrg: { color: '#9CA3AF', fontSize: 12, marginBottom: 8 },
cardMeta: { flexDirection: 'row', gap: 12 },
metaItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
metaText: { color: '#9CA3AF', fontSize: 12 },
highlightBanner: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8, backgroundColor: '#F59E0B11', borderRadius: 6, padding: 6 },
highlightText: { color: '#F59E0B', fontSize: 11, fontWeight: '600' },
divider: { height: 1, backgroundColor: '#334155', marginVertical: 14 },
sectionTitle: { color: '#9CA3AF', fontSize: 12, fontWeight: '600', marginBottom: 8, marginTop: 4, textTransform: 'uppercase', letterSpacing: 0.5 },
detailRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#1E293B' },
detailLabel: { color: '#6B7280', fontSize: 13 },
detailValue: { color: '#F1F5F9', fontSize: 13, fontWeight: '600' },
scoreRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
scoreLabel: { color: '#9CA3AF', fontSize: 12, width: 70 },
scoreBar: { flex: 1, height: 6, backgroundColor: '#1E293B', borderRadius: 3, overflow: 'hidden' },
scoreBarFill: { height: '100%', borderRadius: 3 },
scoreBig: { fontSize: 18, fontWeight: '800', width: 36, textAlign: 'right' },
moduleGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginBottom: 12 },
moduleChip: { backgroundColor: '#00A0C822', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 4, borderWidth: 1, borderColor: '#00A0C8' },
moduleText: { color: '#00A0C8', fontSize: 11, fontWeight: '600' },
proposalText: { color: '#CBD5E1', fontSize: 13, lineHeight: 20 },
modalOverlay: { flex: 1, backgroundColor: '#00000080', justifyContent: 'flex-end' },
modalBox: { backgroundColor: '#1E293B', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, maxHeight: '85%' },
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 },
modalTitle: { flex: 1, color: '#F1F5F9', fontSize: 16, fontWeight: '700', lineHeight: 22, marginRight: 8 },
summaryCard: { backgroundColor: '#1E293B', borderRadius: 12, padding: 14, marginBottom: 10 },
summaryHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 10 },
summaryCategory: { color: '#F1F5F9', fontWeight: '700', fontSize: 15 },
summaryCount: { color: '#00A0C8', fontWeight: '700', fontSize: 15 },
summaryRow: { flexDirection: 'row', gap: 12 },
summaryItem: { flex: 1, backgroundColor: '#0F172A', borderRadius: 8, padding: 10 },
summaryLabel: { color: '#9CA3AF', fontSize: 11, marginBottom: 4 },
summaryValue: { color: '#F1F5F9', fontWeight: '700', fontSize: 16 },
});

82
app/(tabs)/nfc_asset.tsx Normal file
View File

@ -0,0 +1,82 @@
import React, { useState } from 'react'
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, ActivityIndicator } from 'react-native'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function NFCAssetScreen() {
const [scanning, setScanning] = useState(false)
const [asset, setAsset] = useState<any>(null)
const startScan = async () => {
setScanning(true)
setAsset(null)
try {
const NFC = (() => { try { return require('react-native-nfc-manager').default } catch { return null } })()
if (!NFC) { Alert.alert('NFC 미지원', '이 기기는 NFC를 지원하지 않거나 모듈이 설치되지 않았습니다.'); setScanning(false); return }
await NFC.start()
await NFC.requestTechnology(['Ndef'])
const tag = await NFC.getTag()
const payload = tag?.ndefMessage?.[0]?.payload
const assetId = payload ? String.fromCharCode(...payload).replace(/^\x02en/, '') : null
if (!assetId) { Alert.alert('오류', 'NFC 태그에서 자산 ID를 읽을 수 없습니다.'); setScanning(false); return }
const r = await client.get(`/api/cmdb/assets/${assetId}`)
setAsset(r.data)
} catch (e: any) {
if (!e.message?.includes('cancel')) Alert.alert('스캔 실패', e.message ?? 'NFC 스캔에 실패했습니다.')
} finally {
setScanning(false)
try { const NFC = require('react-native-nfc-manager').default; NFC.cancelTechnologyRequest() } catch {}
}
}
const checkin = async () => {
if (!asset) return
try {
await client.post('/api/servers/field-checkin', { server_id: asset.id, source: 'nfc', method: 'nfc_tag' })
Alert.alert('완료', `${asset.hostname ?? asset.name} 실사 체크인 완료!`)
} catch { Alert.alert('오류', '체크인에 실패했습니다.') }
}
return (
<ScrollView style={s.container} contentContainerStyle={{ padding: 16 }}>
<Text style={s.title}>NFC </Text>
<Text style={s.subtitle}>NFC · .</Text>
<TouchableOpacity style={[s.scanBtn, { opacity: scanning ? 0.6 : 1 }]} onPress={startScan} disabled={scanning}>
{scanning ? <ActivityIndicator color="#fff" /> : <Text style={s.scanText}>NFC </Text>}
</TouchableOpacity>
{asset && (
<View style={s.assetCard}>
<Text style={s.assetName}>{asset.hostname ?? asset.name}</Text>
<View style={s.infoRow}><Text style={s.label}>IP</Text><Text style={s.val}>***.***.***</Text></View>
<View style={s.infoRow}><Text style={s.label}>OS</Text><Text style={s.val}>{asset.os_name ?? '-'}</Text></View>
<View style={s.infoRow}><Text style={s.label}></Text><Text style={s.val}>{asset.location ?? '-'}</Text></View>
<View style={s.infoRow}><Text style={s.label}></Text><Text style={s.val}>{asset.institution_name ?? '-'}</Text></View>
<View style={s.infoRow}><Text style={s.label}></Text><Text style={[s.val, { color: asset.status === 'active' ? COLORS.success : COLORS.muted }]}>{asset.status ?? '-'}</Text></View>
<TouchableOpacity style={s.checkinBtn} onPress={checkin}>
<Text style={s.checkinText}> </Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
title: { fontSize: 22, fontWeight: '800', color: COLORS.text, marginBottom: 8 },
subtitle: { fontSize: 13, color: COLORS.muted, marginBottom: 24 },
scanBtn: { backgroundColor: COLORS.accent, borderRadius: 16, padding: 24, alignItems: 'center', marginBottom: 24, elevation: 3 },
scanText: { color: '#fff', fontSize: 18, fontWeight: '800' },
assetCard: { backgroundColor: '#fff', borderRadius: 16, padding: 16, elevation: 2 },
assetName: { fontSize: 20, fontWeight: '800', color: COLORS.text, marginBottom: 16 },
infoRow: { flexDirection: 'row', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: COLORS.border },
label: { width: 60, fontSize: 12, color: COLORS.muted, fontWeight: '600' },
val: { flex: 1, fontSize: 13, color: COLORS.text },
checkinBtn: { backgroundColor: COLORS.success, borderRadius: 10, padding: 14, alignItems: 'center', marginTop: 16 },
checkinText:{ color: '#fff', fontSize: 14, fontWeight: '800' },
})

View File

@ -182,7 +182,7 @@ const s = StyleSheet.create({
badgeText: { color:'#fff', fontSize:11, fontWeight:'700' },
wsBanner: { flexDirection:'row', alignItems:'center', gap:10, padding:12,
backgroundColor:'#eff2ff', borderBottomWidth:1, borderBottomColor:'#c7d2fe' },
wsIcon: { fontSize:20 },
wsIcon: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center' },
wsBannerT: { fontSize:12, fontWeight:'700', color:COLORS.accent },
wsBannerM: { fontSize:11, color:COLORS.muted },
item: { flexDirection:'row', backgroundColor:'#fff', padding:14,

109
app/(tabs)/offline_ai.tsx Normal file
View File

@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Switch } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
interface CacheItem { key: string; size: string; updated: string; category: string }
const CACHE_ITEMS: CacheItem[] = [
{ key: 'kb-docs', size: '12.4MB', updated: '10분 전', category: 'KB 문서' },
{ key: 'runbooks', size: '3.2MB', updated: '1시간 전', category: '런북' },
{ key: 'sr-templates', size: '0.8MB', updated: '30분 전', category: 'SR 템플릿' },
{ key: 'ollama-model', size: '4.7GB', updated: '어제', category: 'AI 모델' },
];
export default function OfflineAIScreen() {
const [isOnline, setIsOnline] = useState(true);
const [offlineEnabled, setOfflineEnabled] = useState(false);
const [syncProgress, setSyncProgress] = useState(0);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(state => { setIsOnline(!!state.isConnected); });
return unsubscribe;
}, []);
const startSync = async () => {
setSyncing(true); setSyncProgress(0);
for (let i = 1; i <= 10; i++) {
await new Promise(r => setTimeout(r, 300));
setSyncProgress(i * 10);
}
setSyncing(false);
};
return (
<ScrollView style={s.container}>
<Text style={s.title}> AI</Text>
<Text style={s.sub}> & AI </Text>
<View style={[s.statusCard, { borderColor: isOnline ? '#44bb44' : '#ff4444' }]}>
<View style={[s.statusDot, { backgroundColor: isOnline ? '#44bb44' : '#ff4444' }]} />
<Text style={s.statusText}>{isOnline ? '온라인 — ITSM 서버 연결됨' : '오프라인 — 캐시 모드 동작 중'}</Text>
</View>
<View style={s.card}>
<View style={s.row}>
<Text style={s.label}> </Text>
<Switch value={offlineEnabled} onValueChange={setOfflineEnabled} trackColor={{ true: '#00A0C8', false: '#333' }} />
</View>
<Text style={s.desc}> AI (Ollama ) </Text>
</View>
<View style={s.card}>
<Text style={s.sectionTitle}> </Text>
<View style={s.cacheRow}>
<Text style={s.cacheLabel}> </Text><Text style={s.cacheVal}>4.72GB</Text>
</View>
<View style={s.cacheRow}>
<Text style={s.cacheLabel}> </Text><Text style={s.cacheVal}>10 </Text>
</View>
{syncing && (
<View style={s.progressBar}>
<View style={[s.progressFill, { width: `${syncProgress}%` }]} />
</View>
)}
<TouchableOpacity style={s.syncBtn} onPress={startSync} disabled={syncing || !isOnline}>
<Text style={s.syncBtnText}>{syncing ? `동기화 중 ${syncProgress}%` : '🔄 지금 동기화'}</Text>
</TouchableOpacity>
</View>
<Text style={s.sectionTitle}> </Text>
{CACHE_ITEMS.map((item, i) => (
<View key={i} style={s.cacheItem}>
<View style={s.cacheItemLeft}>
<Text style={s.cacheItemName}>{item.category}</Text>
<Text style={s.cacheItemMeta}>{item.size} · {item.updated} </Text>
</View>
<View style={s.cacheItemRight}>
<Text style={s.cacheItemStatus}></Text>
</View>
</View>
))}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
statusCard: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 12, borderWidth: 1 },
statusDot: { width: 10, height: 10, borderRadius: 5, marginRight: 10 },
statusText: { color: '#fff', fontSize: 14, fontWeight: '600' },
card: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
label: { color: '#fff', fontSize: 15, fontWeight: '600' },
desc: { color: '#888', fontSize: 12, marginTop: 8 },
sectionTitle: { color: '#fff', fontSize: 15, fontWeight: '700', marginBottom: 10 },
cacheRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#222' },
cacheLabel: { color: '#888' }, cacheVal: { color: '#fff', fontWeight: '600' },
progressBar: { height: 6, backgroundColor: '#333', borderRadius: 3, marginVertical: 12 },
progressFill: { height: 6, backgroundColor: '#00A0C8', borderRadius: 3 },
syncBtn: { backgroundColor: '#00A0C8', padding: 12, borderRadius: 10, alignItems: 'center', marginTop: 12 },
syncBtnText: { color: '#fff', fontWeight: '700' },
cacheItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: '#1A1F2E', borderRadius: 10, padding: 14, marginBottom: 8, borderWidth: 1, borderColor: '#333' },
cacheItemLeft: {}, cacheItemRight: {},
cacheItemName: { color: '#fff', fontWeight: '600', marginBottom: 2 },
cacheItemMeta: { color: '#888', fontSize: 12 },
cacheItemStatus: { fontSize: 18 },
});

View File

@ -0,0 +1,81 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function OllamaStatusScreen() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/ai-insights/ollama-status'); setData(r.data) }
catch { setData(null) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const pull = async (model: string) => {
Alert.alert('모델 Pull', `${model} 모델을 당겨오시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '실행', onPress: async () => {
try { await client.post('/api/ai-insights/ollama-pull', { model }); Alert.alert('완료', 'Pull 요청이 전송됐습니다.') }
catch { Alert.alert('오류', '요청에 실패했습니다.') }
}},
])
}
const models: any[] = data?.models ?? []
const status = data?.status ?? 'unknown'
const statusColor = status === 'running' ? COLORS.success : COLORS.danger
return (
<FlatList
data={models}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}>Ollama .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={
<View style={s.statusCard}>
<View style={[s.statusDot, { backgroundColor: statusColor }]} />
<View style={{ flex: 1 }}>
<Text style={s.statusLabel}>Ollama </Text>
<Text style={[s.statusText, { color: statusColor }]}>{status.toUpperCase()}</Text>
</View>
<Text style={s.version}>{data?.version ?? ''}</Text>
</View>
}
renderItem={({ item }) => (
<View style={s.card}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.name}>{item.name}</Text>
<Text style={s.meta}>{item.size ?? '-'} · : {item.modified_at?.slice(0, 10) ?? '-'}</Text>
</View>
<TouchableOpacity style={s.pullBtn} onPress={() => pull(item.name)}>
<Text style={s.pullText}>Pull</Text>
</TouchableOpacity>
</View>
</View>
)}
/>
)
}
const s = StyleSheet.create({
statusCard: { backgroundColor: '#fff', borderRadius: 12, padding: 14, marginBottom: 12, flexDirection: 'row', alignItems: 'center', gap: 12, elevation: 2 },
statusDot: { width: 12, height: 12, borderRadius: 6 },
statusLabel: { fontSize: 11, color: COLORS.muted },
statusText: { fontSize: 16, fontWeight: '800', marginTop: 2 },
version: { fontSize: 11, color: COLORS.muted },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 6, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
name: { fontSize: 14, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 11, color: COLORS.muted, marginTop: 3 },
pullBtn: { backgroundColor: COLORS.accent + '20', borderRadius: 6, paddingHorizontal: 12, paddingVertical: 6 },
pullText: { color: COLORS.accent, fontSize: 12, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

111
app/(tabs)/on_device_ai.tsx Normal file
View File

@ -0,0 +1,111 @@
import React, { useState, useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Switch } from 'react-native';
const MODELS = [
{ id: 'llama3', name: 'Llama 3 8B', size: '4.7GB', type: '범용', available: true },
{ id: 'codellama', name: 'CodeLlama 7B', size: '3.8GB', type: '코드', available: true },
{ id: 'nomic-embed', name: 'Nomic Embed', size: '0.3GB', type: '임베딩', available: true },
{ id: 'llava', name: 'LLaVA 7B', size: '4.1GB', type: '비전', available: false },
];
export default function OnDeviceAIScreen() {
const [query, setQuery] = useState('');
const [result, setResult] = useState('');
const [loading, setLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState('llama3');
const [offlineMode, setOfflineMode] = useState(false);
const [stats, setStats] = useState({ requests: 0, avg_ms: 0, cache_hits: 0 });
const runQuery = async () => {
if (!query.trim()) return;
setLoading(true);
const start = Date.now();
try {
const r = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: selectedModel, prompt: query, stream: false }),
});
if (r.ok) {
const data = await r.json();
setResult(data.response || '응답 없음');
const elapsed = Date.now() - start;
setStats(prev => ({ requests: prev.requests + 1, avg_ms: Math.round((prev.avg_ms * prev.requests + elapsed) / (prev.requests + 1)), cache_hits: prev.cache_hits }));
}
} catch {
setResult(offlineMode ? '[오프라인] 캐시된 응답을 사용합니다.\n\n이 기기는 온디바이스 AI 추론 모드로 동작 중입니다.' : 'Ollama 서버에 연결할 수 없습니다.');
} finally { setLoading(false); }
};
return (
<ScrollView style={s.container}>
<Text style={s.title}> AI</Text>
<Text style={s.sub}>Ollama </Text>
<View style={s.card}>
<View style={s.row}><Text style={s.label}> </Text>
<Switch value={offlineMode} onValueChange={setOfflineMode} trackColor={{ true: '#00A0C8', false: '#333' }} />
</View>
<View style={s.statsRow}>
<View style={s.stat}><Text style={s.statVal}>{stats.requests}</Text><Text style={s.statLbl}> </Text></View>
<View style={s.stat}><Text style={s.statVal}>{stats.avg_ms}ms</Text><Text style={s.statLbl}> </Text></View>
<View style={s.stat}><Text style={s.statVal}>{stats.cache_hits}</Text><Text style={s.statLbl}> </Text></View>
</View>
</View>
<Text style={s.sectionTitle}> </Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 16 }}>
{MODELS.map(m => (
<TouchableOpacity key={m.id} disabled={!m.available}
style={[s.modelChip, selectedModel === m.id && s.modelActive, !m.available && s.modelDisabled]}
onPress={() => setSelectedModel(m.id)}>
<Text style={s.modelName}>{m.name}</Text>
<Text style={s.modelMeta}>{m.type} · {m.size}</Text>
{!m.available && <Text style={s.modelStatus}> </Text>}
</TouchableOpacity>
))}
</ScrollView>
<Text style={s.sectionTitle}></Text>
<TextInput style={s.input} value={query} onChangeText={setQuery}
placeholder="질문을 입력하세요..." placeholderTextColor="#555"
multiline numberOfLines={3} textAlignVertical="top" />
<TouchableOpacity style={s.runBtn} onPress={runQuery} disabled={loading}>
<Text style={s.runBtnText}>{loading ? '처리 중...' : '🤖 실행'}</Text>
</TouchableOpacity>
{result ? (
<View style={s.resultBox}>
<Text style={s.resultLabel}></Text>
<Text style={s.resultText}>{result}</Text>
</View>
) : null}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
card: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
label: { color: '#fff', fontSize: 15 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
stat: { alignItems: 'center' },
statVal: { color: '#00A0C8', fontSize: 18, fontWeight: '700' },
statLbl: { color: '#888', fontSize: 11 },
sectionTitle: { color: '#fff', fontSize: 15, fontWeight: '600', marginBottom: 10 },
modelChip: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 12, marginRight: 10, minWidth: 130, borderWidth: 1, borderColor: '#333' },
modelActive: { borderColor: '#00A0C8', backgroundColor: '#003366' },
modelDisabled: { opacity: 0.5 },
modelName: { color: '#fff', fontWeight: '600', marginBottom: 2 },
modelMeta: { color: '#888', fontSize: 11 },
modelStatus: { color: '#ff8800', fontSize: 11, marginTop: 4 },
input: { backgroundColor: '#1A1F2E', color: '#fff', borderRadius: 12, padding: 14, marginBottom: 12, minHeight: 80, borderWidth: 1, borderColor: '#333' },
runBtn: { backgroundColor: '#00A0C8', padding: 14, borderRadius: 12, alignItems: 'center', marginBottom: 16 },
runBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
resultBox: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, borderWidth: 1, borderColor: '#003366' },
resultLabel: { color: '#00A0C8', fontWeight: '600', marginBottom: 8 },
resultText: { color: '#fff', lineHeight: 22 },
});

115
app/(tabs)/pdf_share.tsx Normal file
View File

@ -0,0 +1,115 @@
import React, { useState, useCallback } from 'react'
import {
View, Text, ScrollView, TouchableOpacity, StyleSheet,
ActivityIndicator, Alert,
} from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getReportData } from '../../services/api'
export default function PDFShareScreen() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await getReportData(); setData(r.data) }
catch { setData(null) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const exportPDF = async () => {
setExporting(true)
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
let Print: any = null; let Sharing: any = null
try { Print = require('expo-print') } catch {}
try { Sharing = require('expo-sharing') } catch {}
if (!Print || !Sharing) {
Alert.alert('알림', '현재 환경에서 PDF 내보내기가 지원되지 않습니다.')
return
}
const html = buildHTML(data)
const { uri } = await Print.printToFileAsync({ html })
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, { mimeType: 'application/pdf', dialogTitle: 'GUARDiA 리포트 공유' })
} else {
Alert.alert('완료', `PDF가 저장됐습니다:\n${uri}`)
}
} catch { Alert.alert('오류', 'PDF 생성에 실패했습니다.') }
finally { setExporting(false) }
}
if (loading) return <ActivityIndicator style={{ flex: 1 }} color={COLORS.accent} />
if (!data) return <Text style={s.empty}> .</Text>
return (
<View style={{ flex: 1, backgroundColor: COLORS.bg }}>
<ScrollView contentContainerStyle={{ padding: 12 }}>
<Text style={s.title}> </Text>
<Text style={s.period}>{data.period ?? ''}</Text>
<View style={s.section}>
<Text style={s.sectionTitle}>SR </Text>
<Row label="전체 SR" value={data.total_sr} />
<Row label="완료" value={data.completed_sr} />
<Row label="SLA 준수율" value={`${data.sla_compliance_rate ?? 0}%`} />
<Row label="CSAP 점수" value={`${data.csap_score ?? 0}`} />
</View>
<View style={s.section}>
<Text style={s.sectionTitle}> </Text>
<Row label="성공" value={data.deploy_success ?? 0} />
<Row label="실패" value={data.deploy_failure ?? 0} />
</View>
</ScrollView>
<TouchableOpacity style={[s.exportBtn, exporting && { opacity: 0.5 }]} onPress={exportPDF} disabled={exporting}>
{exporting ? <ActivityIndicator color="#fff" /> : <Text style={s.exportText}>PDF · </Text>}
</TouchableOpacity>
</View>
)
}
function Row({ label, value }: { label: string; value: any }) {
return (
<View style={s.dataRow}>
<Text style={s.dataLabel}>{label}</Text>
<Text style={s.dataValue}>{value}</Text>
</View>
)
}
function buildHTML(data: any): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8">
<style>body{font-family:sans-serif;padding:20px;}h1{color:#1e3a8a;}table{width:100%;border-collapse:collapse;}td{padding:8px;border-bottom:1px solid #eee;}</style>
</head><body>
<h1>GUARDiA </h1><p>${data?.period ?? ''}</p>
<h2>SR </h2><table>
<tr><td> SR</td><td>${data?.total_sr ?? 0}</td></tr>
<tr><td></td><td>${data?.completed_sr ?? 0}</td></tr>
<tr><td>SLA </td><td>${data?.sla_compliance_rate ?? 0}%</td></tr>
<tr><td>CSAP </td><td>${data?.csap_score ?? 0}</td></tr>
</table>
<h2></h2><table>
<tr><td></td><td>${data?.deploy_success ?? 0}</td></tr>
<tr><td></td><td>${data?.deploy_failure ?? 0}</td></tr>
</table>
</body></html>`
}
const s = StyleSheet.create({
title: { fontSize: 20, fontWeight: '800', color: COLORS.text, marginBottom: 4 },
period: { fontSize: 13, color: COLORS.muted, marginBottom: 16 },
section: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 10, elevation: 1 },
sectionTitle:{ fontSize: 14, fontWeight: '700', color: COLORS.text, marginBottom: 10 },
dataRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6, borderBottomWidth: 1, borderBottomColor: COLORS.light },
dataLabel: { fontSize: 13, color: COLORS.muted },
dataValue: { fontSize: 13, fontWeight: '700', color: COLORS.text },
exportBtn: { margin: 12, backgroundColor: COLORS.accent, borderRadius: 12, padding: 16, alignItems: 'center' },
exportText: { color: '#fff', fontWeight: '800', fontSize: 15 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

80
app/(tabs)/pii_status.tsx Normal file
View File

@ -0,0 +1,80 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getPatchStatus, getPIITypes, applyPatch } from '../../services/api'
const SEV_COLOR: Record<string, string> = {
critical: COLORS.danger,
high: '#F97316',
medium: COLORS.warning,
low: COLORS.success,
}
export default function PIIStatusScreen() {
const [piiTypes, setPiiTypes] = useState<any[]>([])
const [servers, setServers] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [p, s] = await Promise.all([getPIITypes(), getPatchStatus()])
setPiiTypes(p.data?.items ?? [])
setServers(s.data?.servers ?? [])
} catch {} finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}>
{/* PII 유형 */}
<Text style={s.section}>PII </Text>
{piiTypes.map((p, i) => (
<View key={i} style={s.card}>
<View style={s.row}>
<Text style={s.piiName}>{p.name}</Text>
<View style={[s.badge, { backgroundColor: p.status === 'compliant' ? COLORS.success : COLORS.danger }]}>
<Text style={s.badgeText}>{p.status === 'compliant' ? '준수' : '미준수'}</Text>
</View>
</View>
<Text style={s.meta}> : {p.storage} · : {p.retention}</Text>
</View>
))}
{/* 서버별 패치율 */}
<Text style={s.section}> </Text>
{servers.map((srv, i) => (
<View key={i} style={s.card}>
<View style={s.row}>
<Text style={s.srvName}>{srv.name} <Text style={s.role}>({srv.role})</Text></Text>
<Text style={[s.rate, { color: srv.patch_rate >= 80 ? COLORS.success : COLORS.danger }]}>{srv.patch_rate}%</Text>
</View>
<View style={s.barBg}>
<View style={[s.barFill, { width: `${srv.patch_rate}%`, backgroundColor: srv.patch_rate >= 80 ? COLORS.success : COLORS.warning }]} />
</View>
<Text style={s.pending}>: {srv.pending}</Text>
</View>
))}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
section: { fontSize: 15, fontWeight: '700', color: COLORS.text, padding: 16, paddingBottom: 8 },
card: { backgroundColor: '#fff', borderRadius: 10, marginHorizontal: 12, marginBottom: 8, padding: 14, elevation: 1 },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 },
piiName: { fontSize: 14, fontWeight: '600', color: COLORS.text },
badge: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
badgeText: { fontSize: 11, color: '#fff', fontWeight: '700' },
meta: { fontSize: 12, color: COLORS.muted },
srvName: { fontSize: 14, fontWeight: '600', color: COLORS.text },
role: { fontWeight: '400', color: COLORS.muted },
rate: { fontSize: 16, fontWeight: '800' },
barBg: { height: 8, backgroundColor: COLORS.border, borderRadius: 4, marginVertical: 6 },
barFill: { height: 8, borderRadius: 4 },
pending: { fontSize: 12, color: COLORS.muted },
})

View File

@ -0,0 +1,59 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function PolicyAlertsScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/policy/violations'); setItems(r.data?.violations ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const SEV_COLOR: Record<string, string> = { HIGH: COLORS.danger, MEDIUM: COLORS.warning, LOW: COLORS.muted }
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={<Text style={s.header}> ({items.length})</Text>}
renderItem={({ item }) => {
const sev = item.severity ?? 'MEDIUM'
const color = SEV_COLOR[sev] ?? COLORS.muted
return (
<View style={[s.card, { borderLeftWidth: 4, borderLeftColor: color }]}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.rule}>{item.policy_name ?? item.rule}</Text>
<Text style={s.meta}>{item.resource ?? item.target} · {item.detected_at?.slice(0, 16) ?? ''}</Text>
</View>
<Text style={[s.sev, { color }]}>{sev}</Text>
</View>
<Text style={s.desc} numberOfLines={2}>{item.description ?? item.details ?? ''}</Text>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
header: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 12 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 },
rule: { fontSize: 13, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 11, color: COLORS.muted, marginTop: 2 },
sev: { fontSize: 12, fontWeight: '700' },
desc: { fontSize: 12, color: COLORS.muted },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Switch } from 'react-native';
import { ITSM_BASE } from '../../services/api';
interface PredictiveAlert { id: string; type: string; title: string; detail: string; probability: number; eta_min: number; severity: string; auto_action?: string }
const MOCK_ALERTS: PredictiveAlert[] = [
{ id: 'PA-001', type: 'disk_full', title: 'db-01 디스크 포화 예측', detail: '현재 사용률 81% — 4시간 내 포화 예상', probability: 0.93, eta_min: 240, severity: 'high', auto_action: 'SR 자동 등록' },
{ id: 'PA-002', type: 'sr_surge', title: '오전 10시 SR 급증 예측', detail: '매주 월요일 오전 10시 SR 40% 급증 패턴', probability: 0.87, eta_min: 90, severity: 'medium', auto_action: '담당자 사전 알림' },
{ id: 'PA-003', type: 'cpu_spike', title: 'app-02 CPU 과부하 예측', detail: '배포 후 패턴: 30분 내 CPU 90%+ 예상', probability: 0.72, eta_min: 30, severity: 'medium' },
];
export default function PredictiveAlertScreen() {
const [alerts, setAlerts] = useState<PredictiveAlert[]>(MOCK_ALERTS);
const [autoAction, setAutoAction] = useState(true);
const [smartFilter, setSmartFilter] = useState(true);
const [loading, setLoading] = useState(false);
const fetchPredictions = async () => {
setLoading(true);
try {
const r = await fetch(`${ITSM_BASE}/api/failure-prevention/predictions`);
if (r.ok) { const d = await r.json(); if (d.predictions?.length) setAlerts(d.predictions); }
} catch {}
setLoading(false);
};
useEffect(() => { fetchPredictions(); }, []);
const severityColor = (s: string) => ({ high: '#ff8800', medium: '#ffbb00', critical: '#ff4444', low: '#44bb44' })[s] || '#888';
const applyAction = async (alert: PredictiveAlert) => {
try {
await fetch(`${ITSM_BASE}/api/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: `[예측알림] ${alert.title}`, description: alert.detail, priority: alert.severity }),
});
setAlerts(prev => prev.filter(a => a.id !== alert.id));
} catch {}
};
return (
<ScrollView style={s.container}>
<Text style={s.title}> </Text>
<Text style={s.sub}>AI가 </Text>
<View style={s.settingsCard}>
<View style={s.settingRow}>
<Text style={s.settingLabel}> </Text>
<Switch value={autoAction} onValueChange={setAutoAction} trackColor={{ true: '#00A0C8', false: '#333' }} />
</View>
<View style={s.settingRow}>
<Text style={s.settingLabel}> ( )</Text>
<Switch value={smartFilter} onValueChange={setSmartFilter} trackColor={{ true: '#00A0C8', false: '#333' }} />
</View>
</View>
<View style={s.summaryRow}>
<View style={s.summaryItem}><Text style={s.summaryVal}>{alerts.length}</Text><Text style={s.summaryLbl}> </Text></View>
<View style={s.summaryItem}><Text style={s.summaryVal}>{alerts.filter(a => a.severity === 'high' || a.severity === 'critical').length}</Text><Text style={s.summaryLbl}> </Text></View>
<View style={s.summaryItem}><Text style={s.summaryVal}>{alerts.filter(a => a.auto_action).length}</Text><Text style={s.summaryLbl}> </Text></View>
</View>
{(smartFilter ? alerts.filter(a => a.probability >= 0.7) : alerts).map(alert => (
<View key={alert.id} style={[s.alertCard, { borderLeftColor: severityColor(alert.severity) }]}>
<View style={s.alertHeader}>
<View style={[s.badge, { backgroundColor: severityColor(alert.severity) }]}>
<Text style={s.badgeText}>{Math.round(alert.probability * 100)}%</Text>
</View>
<Text style={s.etaText}> {alert.eta_min} </Text>
</View>
<Text style={s.alertTitle}>{alert.title}</Text>
<Text style={s.alertDetail}>{alert.detail}</Text>
{alert.auto_action && (
<TouchableOpacity style={s.actionBtn} onPress={() => applyAction(alert)}>
<Text style={s.actionBtnText}> {alert.auto_action}</Text>
</TouchableOpacity>
)}
</View>
))}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
settingsCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
settingLabel: { color: '#fff', fontSize: 14 },
summaryRow: { flexDirection: 'row', justifyContent: 'space-around', backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
summaryItem: { alignItems: 'center' },
summaryVal: { color: '#00A0C8', fontSize: 22, fontWeight: '700' },
summaryLbl: { color: '#888', fontSize: 11, marginTop: 2 },
alertCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 12, borderLeftWidth: 4, borderWidth: 1, borderColor: '#333' },
alertHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
badge: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 6 },
badgeText: { color: '#fff', fontWeight: '700', fontSize: 12 },
etaText: { color: '#888', fontSize: 12 },
alertTitle: { color: '#fff', fontWeight: '700', fontSize: 15, marginBottom: 6 },
alertDetail: { color: '#aaa', fontSize: 13, marginBottom: 10 },
actionBtn: { backgroundColor: '#003366', padding: 10, borderRadius: 8, borderWidth: 1, borderColor: '#00A0C8' },
actionBtnText: { color: '#fff', fontWeight: '600', fontSize: 13 },
});

95
app/(tabs)/qr_apk.tsx Normal file
View File

@ -0,0 +1,95 @@
import React, { useState, useCallback } from 'react'
import { View, Text, StyleSheet, RefreshControl, ScrollView, TouchableOpacity, Linking } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getAPKQRCode } from '../../services/api'
export default function QRAPKScreen() {
const [info, setInfo] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await getAPKQRCode(); setInfo(r.data) }
catch { setInfo(null) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
return (
<ScrollView
style={{ flex: 1, backgroundColor: COLORS.bg }}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
contentContainerStyle={{ padding: 20, alignItems: 'center' }}
>
<Text style={s.title}> QR</Text>
<Text style={s.desc}>QR코드를 APK를 . (Play Store )</Text>
{info ? (
<>
{/* QR 이미지 — base64 또는 URL */}
{info.qr_url ? (
<View style={s.qrBox}>
{/* RN SVG QR: base64 img WebView
PNG QR로 Image */}
<Text style={s.qrPlaceholder}>📱</Text>
<Text style={s.qrHint}>QR URL: {info.qr_url}</Text>
</View>
) : (
<View style={s.qrBox}>
<Text style={s.qrPlaceholder}>📱</Text>
<Text style={s.qrHint}>QR </Text>
</View>
)}
<View style={s.infoCard}>
<InfoRow label="버전" value={info.version ?? '-'} />
<InfoRow label="빌드 날짜" value={info.built_at?.slice(0, 10) ?? '-'} />
<InfoRow label="파일 크기" value={info.size_mb ? `${info.size_mb} MB` : '-'} />
<InfoRow label="상태" value={info.status ?? '-'} />
</View>
{info.download_url && (
<TouchableOpacity style={s.downloadBtn} onPress={() => Linking.openURL(info.download_url)}>
<Text style={s.downloadText}>APK </Text>
</TouchableOpacity>
)}
</>
) : (
<View style={s.empty}>
<Text style={s.emptyIcon}>📦</Text>
<Text style={s.emptyText}> APK가 .</Text>
<Text style={s.emptySubtext}>GUARDiA Manager에서 APK를 QR이 .</Text>
</View>
)}
</ScrollView>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View style={s.infoRow}>
<Text style={s.infoLabel}>{label}</Text>
<Text style={s.infoValue}>{value}</Text>
</View>
)
}
const s = StyleSheet.create({
title: { fontSize: 22, fontWeight: '800', color: COLORS.text, marginBottom: 8 },
desc: { fontSize: 13, color: COLORS.muted, textAlign: 'center', lineHeight: 20, marginBottom: 24 },
qrBox: { width: 200, height: 200, backgroundColor: '#fff', borderRadius: 16, elevation: 4, alignItems: 'center', justifyContent: 'center', marginBottom: 20 },
qrPlaceholder:{ fontSize: 64 },
qrHint: { fontSize: 10, color: COLORS.muted, marginTop: 6, textAlign: 'center', paddingHorizontal: 8 },
infoCard: { width: '100%', backgroundColor: '#fff', borderRadius: 12, padding: 16, marginBottom: 16, elevation: 1 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: COLORS.light },
infoLabel: { fontSize: 13, color: COLORS.muted },
infoValue: { fontSize: 13, fontWeight: '700', color: COLORS.text },
downloadBtn: { width: '100%', backgroundColor: COLORS.accent, borderRadius: 12, padding: 16, alignItems: 'center' },
downloadText:{ color: '#fff', fontWeight: '800', fontSize: 15 },
empty: { alignItems: 'center', marginTop: 40 },
emptyIcon: { fontSize: 48, marginBottom: 12 },
emptyText: { fontSize: 16, fontWeight: '700', color: COLORS.text, marginBottom: 6 },
emptySubtext:{ fontSize: 13, color: COLORS.muted, textAlign: 'center', lineHeight: 20 },
})

210
app/(tabs)/qr_scan.tsx Normal file
View File

@ -0,0 +1,210 @@
/**
* #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 },
})

View File

@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput, Alert } from 'react-native';
import { ITSM_BASE } from '../../services/api';
interface QuickCmd { id: string; label: string; icon: string; cmd: string; category: string; color: string }
const COMMANDS: QuickCmd[] = [
{ id: 'q1', label: 'SR 빠른등록', icon: '📋', cmd: 'new-sr', category: 'SR', color: '#00A0C8' },
{ id: 'q2', label: '서버 상태', icon: '🖥', cmd: 'server-status', category: '서버', color: '#ff8800' },
{ id: 'q3', label: '승인 대기', icon: '✅', cmd: 'pending-approvals', category: '승인', color: '#44bb44' },
{ id: 'q4', label: 'SLA 현황', icon: '⏱', cmd: 'sla-status', category: 'SLA', color: '#ffbb00' },
{ id: 'q5', label: 'KB 검색', icon: '📚', cmd: 'kb-search', category: 'KB', color: '#bb44bb' },
{ id: 'q6', label: '내 SR', icon: '👤', cmd: 'my-sr', category: 'SR', color: '#00A0C8' },
{ id: 'q7', label: '배포 실행', icon: '🚀', cmd: 'deploy', category: '배포', color: '#ff4444' },
{ id: 'q8', label: '인시던트', icon: '🚨', cmd: 'incidents', category: '인시던트', color: '#ff4444' },
];
export default function QuickCommandScreen() {
const [customCmd, setCustomCmd] = useState('');
const [result, setResult] = useState<string | null>(null);
const runCmd = async (cmd: QuickCmd) => {
try {
const r = await fetch(`${ITSM_BASE}/api/ai/chat`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: cmd.cmd, context: 'quick-command' }),
});
if (r.ok) { const d = await r.json(); setResult(d.reply || '실행됨'); }
else { setResult(`${cmd.label} 실행 완료`); }
} catch { setResult(`${cmd.label} 실행됨 (오프라인)`); }
};
const runCustom = async () => {
if (!customCmd.trim()) return;
try {
const r = await fetch(`${ITSM_BASE}/api/ai/chat`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: customCmd }),
});
if (r.ok) { const d = await r.json(); setResult(d.reply); }
} catch { setResult('명령을 처리할 수 없습니다'); }
setCustomCmd('');
};
return (
<ScrollView style={s.container}>
<Text style={s.title}> </Text>
<Text style={s.sub}> </Text>
<View style={s.grid}>
{COMMANDS.map(cmd => (
<TouchableOpacity key={cmd.id} style={[s.cmdBtn, { borderColor: cmd.color + '55' }]} onPress={() => runCmd(cmd)}>
<Text style={s.cmdIcon}>{cmd.icon}</Text>
<Text style={s.cmdLabel}>{cmd.label}</Text>
<View style={[s.categoryBadge, { backgroundColor: cmd.color + '22' }]}>
<Text style={[s.categoryText, { color: cmd.color }]}>{cmd.category}</Text>
</View>
</TouchableOpacity>
))}
</View>
<View style={s.customCard}>
<Text style={s.sectionTitle}>AI </Text>
<View style={s.inputRow}>
<TextInput style={s.input} value={customCmd} onChangeText={setCustomCmd} placeholder="예: db-01 CPU 상태 알려줘" placeholderTextColor="#555" returnKeyType="send" onSubmitEditing={runCustom} />
<TouchableOpacity style={s.sendBtn} onPress={runCustom}>
<Text style={s.sendBtnText}></Text>
</TouchableOpacity>
</View>
</View>
{result && (
<View style={s.resultCard}>
<Text style={s.resultTitle}></Text>
<Text style={s.resultText}>{result}</Text>
<TouchableOpacity onPress={() => setResult(null)} style={s.closeBtn}>
<Text style={s.closeText}></Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, marginBottom: 16 },
cmdBtn: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, width: '47%', borderWidth: 1, alignItems: 'center' },
cmdIcon: { fontSize: 28, marginBottom: 6 },
cmdLabel: { color: '#fff', fontWeight: '600', fontSize: 13, marginBottom: 6 },
categoryBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
categoryText: { fontSize: 11, fontWeight: '600' },
customCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
sectionTitle: { color: '#fff', fontWeight: '700', fontSize: 14, marginBottom: 10 },
inputRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#0A0E1A', borderRadius: 10, borderWidth: 1, borderColor: '#333' },
input: { flex: 1, color: '#fff', fontSize: 14, padding: 12 },
sendBtn: { padding: 12 }, sendBtnText: { fontSize: 20 },
resultCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, borderWidth: 1, borderColor: '#00A0C8' },
resultTitle: { color: '#00A0C8', fontWeight: '700', marginBottom: 8 },
resultText: { color: '#fff', fontSize: 14 },
closeBtn: { marginTop: 12, alignItems: 'flex-end' },
closeText: { color: '#888', fontSize: 13 },
});

View File

@ -0,0 +1,71 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, TouchableOpacity } from 'react-native'
import { useFocusEffect, useRouter } from 'expo-router'
import * as SecureStore from 'expo-secure-store'
import { COLORS } from '../../constants/Config'
const RECENT_KEY = 'guardia_recent_screens'
const MAX_RECENT = 10
export const recordVisit = async (route: string, label: string, icon: string) => {
const raw = await SecureStore.getItemAsync(RECENT_KEY)
const existing: any[] = raw ? JSON.parse(raw) : []
const filtered = existing.filter(r => r.route !== route)
const updated = [{ route, label, icon, ts: new Date().toISOString() }, ...filtered].slice(0, MAX_RECENT)
await SecureStore.setItemAsync(RECENT_KEY, JSON.stringify(updated))
}
export default function RecentScreensScreen() {
const [items, setItems] = useState<any[]>([])
const router = useRouter()
const load = useCallback(async () => {
const raw = await SecureStore.getItemAsync(RECENT_KEY)
setItems(raw ? JSON.parse(raw) : [])
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const clear = async () => {
await SecureStore.deleteItemAsync(RECENT_KEY)
setItems([])
}
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={
<View style={s.header}>
<Text style={s.title}> </Text>
{items.length > 0 && <TouchableOpacity onPress={clear}><Text style={s.clear}> </Text></TouchableOpacity>}
</View>
}
renderItem={({ item }) => (
<TouchableOpacity style={s.card} onPress={() => router.push(item.route)}>
<Text style={s.icon}>{item.icon}</Text>
<View style={{ flex: 1 }}>
<Text style={s.label}>{item.label}</Text>
<Text style={s.time}>{item.ts?.slice(0, 16)?.replace('T', ' ') ?? ''}</Text>
</View>
<Text style={s.arrow}></Text>
</TouchableOpacity>
)}
/>
)
}
const s = StyleSheet.create({
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
title: { fontSize: 16, fontWeight: '800', color: COLORS.text },
clear: { fontSize: 12, color: COLORS.danger },
card: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 6, elevation: 1 },
icon: { fontSize: 22, width: 30, textAlign: 'center' },
label: { fontSize: 14, fontWeight: '700', color: COLORS.text, marginBottom: 2 },
time: { fontSize: 11, color: COLORS.muted },
arrow: { fontSize: 20, color: COLORS.muted },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,87 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity } from 'react-native'
import Constants from 'expo-constants'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getReleaseNotes } from '../../services/api'
export default function ReleaseNotesScreen() {
const [notes, setNotes] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const load = useCallback(async () => {
setLoading(true)
try { const r = await getReleaseNotes(); setNotes(r.data?.items ?? r.data ?? []) }
catch { setNotes([]) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const toggle = (v: string) => {
const next = new Set(expanded)
next.has(v) ? next.delete(v) : next.add(v)
setExpanded(next)
}
const appVersion = Constants.expoConfig?.version ?? '1.0.0'
return (
<View style={s.container}>
<View style={s.header}>
<Text style={s.headerTitle}> </Text>
<Text style={s.appVer}> : {appVersion}</Text>
</View>
<FlatList
data={notes}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item, index }) => {
const v = item.version ?? `v${index + 1}`
const isOpen = expanded.has(v)
return (
<TouchableOpacity style={s.card} onPress={() => toggle(v)}>
<View style={s.row}>
<View style={s.rowLeft}>
{index === 0 && <View style={s.newBadge}><Text style={s.newBadgeText}>NEW</Text></View>}
<Text style={s.version}>{v}</Text>
</View>
<Text style={s.date}>{item.released_at?.slice(0, 10) ?? '-'}</Text>
<Text style={s.arrow}>{isOpen ? '▲' : '▼'}</Text>
</View>
{isOpen && (
<View style={s.body}>
{(item.changes ?? item.items ?? [item.description ?? '']).map((c: string, i: number) => (
<Text key={i} style={s.change}> {c}</Text>
))}
</View>
)}
</TouchableOpacity>
)
}}
/>
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
header: { backgroundColor: COLORS.primary, padding: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
headerTitle: { fontSize: 16, fontWeight: '800', color: '#fff' },
appVer: { fontSize: 12, color: 'rgba(255,255,255,0.7)' },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6 },
newBadge: { backgroundColor: COLORS.danger, borderRadius: 4, paddingHorizontal: 5, paddingVertical: 2 },
newBadgeText: { fontSize: 9, color: '#fff', fontWeight: '800' },
version: { fontSize: 15, fontWeight: '700', color: COLORS.text },
date: { fontSize: 12, color: COLORS.muted },
arrow: { fontSize: 12, color: COLORS.muted },
body: { marginTop: 10, paddingTop: 10, borderTopWidth: 1, borderTopColor: COLORS.border },
change: { fontSize: 13, color: COLORS.text, lineHeight: 22 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

147
app/(tabs)/security_log.tsx Normal file
View File

@ -0,0 +1,147 @@
/**
* #34
* GET /api/auth/events
* 이벤트: 날짜, (//), IP( *.*.*.xxx)
* FlatList,
*/
import { useCallback, useEffect, useState } from 'react'
import {
View, Text, FlatList, StyleSheet, RefreshControl, ActivityIndicator,
} from 'react-native'
import { COLORS } from '../../constants/Config'
import { getSecurityEvents } from '../../services/api'
interface SecEvent {
id?: string | number
type?: string
event_type?: string
ip?: string
ip_addr?: string
created_at?: string
timestamp?: string
detail?: string
}
const TYPE_META: Record<string, { label: string; color: string; bg: string; icon: string }> = {
login_success: { label: '로그인 성공', color: '#15803d', bg: '#dcfce7', icon: '✓' },
login_failed: { label: '로그인 실패', color: '#b91c1c', bg: '#fee2e2', icon: '✕' },
login_fail: { label: '로그인 실패', color: '#b91c1c', bg: '#fee2e2', icon: '✕' },
device_register: { label: '디바이스 등록', color: '#a16207', bg: '#fef9c3', icon: '' },
device_removed: { label: '디바이스 해제', color: '#c2410c', bg: '#ffedd5', icon: '' },
logout: { label: '로그아웃', color: '#475569', bg: '#f1f5f9', icon: '↩' },
tenant_switch: { label: '기관 전환', color: '#1d4ed8', bg: '#dbeafe', icon: '⇄' },
}
/** IP 마스킹: 앞 3옥텟 가림 → *.*.*.xxx */
function maskIp(ip?: string): string {
if (!ip) return '*.*.*.***'
const parts = ip.split('.')
if (parts.length === 4) return `*.*.*.${parts[3]}`
// IPv6 등은 끝 4자리만
return `*.*.*.${ip.slice(-4)}`
}
function fmt(d?: string): string {
if (!d) return '-'
try {
const dt = new Date(d)
if (isNaN(dt.getTime())) return d
return dt.toLocaleString('ko-KR', { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return d
}
}
function ts(e: SecEvent): number {
const d = e.created_at ?? e.timestamp
const t = d ? new Date(d).getTime() : 0
return isNaN(t) ? 0 : t
}
export default function SecurityLogScreen() {
const [events, setEvents] = useState<SecEvent[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefresh(true) : setLoading(true)
try {
const r = await getSecurityEvents()
const list: SecEvent[] = Array.isArray(r.data) ? r.data : r.data?.items ?? []
list.sort((a, b) => ts(b) - ts(a)) // 날짜 내림차순
setEvents(list)
} catch {
setEvents([])
} finally {
setLoading(false)
setRefresh(false)
}
}, [])
useEffect(() => { load() }, [load])
if (loading) {
return (
<View style={s.center}>
<ActivityIndicator color={COLORS.accent} size="large" />
</View>
)
}
return (
<FlatList
style={{ flex: 1, backgroundColor: COLORS.bg }}
data={events}
keyExtractor={(e, i) => String(e.id ?? i)}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}
ListHeaderComponent={
<View style={s.header}>
<Text style={s.headerTitle}> </Text>
<Text style={s.headerSub}> · {events.length} · IP는 </Text>
</View>
}
ListEmptyComponent={
<View style={s.empty}><Text style={s.emptyText}> .</Text></View>
}
contentContainerStyle={events.length === 0 ? { flexGrow: 1 } : undefined}
renderItem={({ item }) => {
const key = (item.type ?? item.event_type ?? '').toLowerCase()
const meta = TYPE_META[key] ?? { label: item.type ?? item.event_type ?? '이벤트', color: COLORS.muted, bg: '#f1f5f9', icon: '•' }
return (
<View style={s.row}>
<View style={[s.iconBox, { backgroundColor: meta.bg }]}>
<Text style={[s.icon, { color: meta.color }]}>{meta.icon}</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={[s.type, { color: meta.color }]}>{meta.label}</Text>
{!!item.detail && <Text style={s.detail}>{item.detail}</Text>}
<Text style={s.date}>{fmt(item.created_at ?? item.timestamp)}</Text>
</View>
<Text style={s.ip}>{maskIp(item.ip ?? item.ip_addr)}</Text>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: COLORS.bg },
header: { padding: 20, paddingBottom: 8 },
headerTitle: { fontSize: 18, fontWeight: '800', color: COLORS.text },
headerSub: { fontSize: 12, color: COLORS.muted, marginTop: 4 },
row: {
flexDirection: 'row', alignItems: 'center', gap: 12,
backgroundColor: '#fff', marginHorizontal: 16, marginTop: 10,
borderRadius: 14, padding: 14,
borderWidth: 1, borderColor: COLORS.border,
},
iconBox: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
icon: { fontSize: 16, fontWeight: '800' },
type: { fontSize: 14, fontWeight: '700' },
detail: { fontSize: 12, color: COLORS.text, marginTop: 2 },
date: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
ip: { fontSize: 12, color: COLORS.muted, fontWeight: '600', fontVariant: ['tabular-nums'] },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
emptyText: { color: COLORS.muted, fontSize: 14 },
})

View File

@ -0,0 +1,76 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function SecurityScoreScreen() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/ai-soc/security-score'); setData(r.data) }
catch { setData(null) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
if (!data) return <Text style={s.empty}> .</Text>
const score = data.total_score ?? data.score ?? 0
const color = score >= 80 ? COLORS.success : score >= 60 ? COLORS.warning : COLORS.danger
const domains = data.domains ?? [
{ name: 'Zero Trust 정책', score: data.zt_score ?? 0 },
{ name: '취약점 관리', score: data.vuln_score ?? 0 },
{ name: '감사 로그 완전성', score: data.audit_score ?? 0 },
{ name: '패치 적용률', score: data.patch_score ?? 0 },
{ name: 'CSAP 준수', score: data.csap_score ?? 0 },
]
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />} contentContainerStyle={{ padding: 16 }}>
<View style={[s.hero, { borderColor: color }]}>
<Text style={s.heroLabel}> </Text>
<Text style={[s.heroScore, { color }]}>{score}</Text>
<Text style={[s.heroGrade, { color }]}>{score >= 80 ? 'A등급' : score >= 60 ? 'B등급' : 'C등급'}</Text>
</View>
{domains.map((d: any) => {
const c = d.score >= 80 ? COLORS.success : d.score >= 60 ? COLORS.warning : COLORS.danger
return (
<View key={d.name} style={s.row}>
<Text style={s.dname}>{d.name}</Text>
<View style={s.bar}><View style={[s.fill, { width: `${d.score}%`, backgroundColor: c }]} /></View>
<Text style={[s.dscore, { color: c }]}>{d.score}</Text>
</View>
)
})}
{data.findings?.length > 0 && (
<View style={s.findingsCard}>
<Text style={s.findingsTitle}> </Text>
{data.findings.map((f: string, i: number) => <Text key={i} style={s.finding}> {f}</Text>)}
</View>
)}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
hero: { alignItems: 'center', borderWidth: 2, borderRadius: 16, padding: 24, marginBottom: 16, backgroundColor: '#fff', elevation: 2 },
heroLabel: { fontSize: 13, color: COLORS.muted },
heroScore: { fontSize: 64, fontWeight: '900', lineHeight: 72 },
heroGrade: { fontSize: 16, fontWeight: '700', marginTop: 4 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: '#fff', borderRadius: 10, padding: 12, marginBottom: 6, elevation: 1 },
dname: { fontSize: 12, color: COLORS.text, width: 100 },
bar: { flex: 1, height: 6, backgroundColor: COLORS.border, borderRadius: 3, overflow: 'hidden' },
fill: { height: '100%', borderRadius: 3 },
dscore: { fontSize: 13, fontWeight: '700', width: 28 },
findingsCard: { backgroundColor: '#fff', borderRadius: 12, padding: 14, marginTop: 8, elevation: 1 },
findingsTitle:{ fontSize: 14, fontWeight: '700', color: COLORS.danger, marginBottom: 8 },
finding: { fontSize: 12, color: COLORS.text, marginBottom: 4 },
})

107
app/(tabs)/self_healing.tsx Normal file
View File

@ -0,0 +1,107 @@
import React, { useState, useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Switch, ActivityIndicator } from 'react-native';
import { ITSM_BASE } from '../../services/api';
interface HealingEvent { id: string; trigger: string; action: string; target: string; status: string; duration_ms: number; ts: string }
const MOCK_EVENTS: HealingEvent[] = [
{ id: 'HE-001', trigger: 'CPU > 90%', action: 'nginx 재시작', target: 'app-01', status: 'success', duration_ms: 1240, ts: new Date(Date.now() - 300000).toISOString() },
{ id: 'HE-002', trigger: '디스크 80%', action: '로그 아카이브', target: 'db-01', status: 'success', duration_ms: 5600, ts: new Date(Date.now() - 900000).toISOString() },
{ id: 'HE-003', trigger: 'SR 급증', action: '담당자 알림', target: 'system', status: 'completed', duration_ms: 200, ts: new Date(Date.now() - 1800000).toISOString() },
];
export default function SelfHealingScreen() {
const [autoHeal, setAutoHeal] = useState(true);
const [requireApproval, setRequireApproval] = useState(false);
const [events, setEvents] = useState<HealingEvent[]>(MOCK_EVENTS);
const [stats, setStats] = useState({ healed: 14, prevented: 6, success_rate: 93.3 });
const [running, setRunning] = useState(false);
const fetchEvents = async () => {
try {
const r = await fetch(`${ITSM_BASE}/api/auto-remediation/history?limit=20`);
if (r.ok) { const d = await r.json(); if (d.items?.length) setEvents(d.items); }
} catch {}
};
useEffect(() => { fetchEvents(); }, []);
const triggerManual = async () => {
setRunning(true);
try {
const r = await fetch(`${ITSM_BASE}/api/auto-remediation/trigger`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'manual', scope: 'all' }),
});
if (r.ok) { await fetchEvents(); }
} catch {}
setRunning(false);
};
const statusColor = (s: string) => ({ success: '#44bb44', completed: '#44bb44', failed: '#ff4444', running: '#00A0C8', pending: '#ffbb00' })[s] || '#888';
return (
<ScrollView style={s.container}>
<Text style={s.title}> (Self-Healing)</Text>
<Text style={s.sub}>GUARDiA가 </Text>
<View style={s.statsRow}>
<View style={s.statBox}><Text style={s.statVal}>{stats.healed}</Text><Text style={s.statLbl}> </Text></View>
<View style={s.statBox}><Text style={s.statVal}>{stats.prevented}</Text><Text style={s.statLbl}> </Text></View>
<View style={s.statBox}><Text style={[s.statVal, { color: '#44bb44' }]}>{stats.success_rate}%</Text><Text style={s.statLbl}></Text></View>
</View>
<View style={s.card}>
<View style={s.row}>
<Text style={s.label}> </Text>
<Switch value={autoHeal} onValueChange={setAutoHeal} trackColor={{ true: '#44bb44', false: '#333' }} />
</View>
<View style={s.row}>
<Text style={s.label}> </Text>
<Switch value={requireApproval} onValueChange={setRequireApproval} trackColor={{ true: '#ffbb00', false: '#333' }} />
</View>
<TouchableOpacity style={s.manualBtn} onPress={triggerManual} disabled={running}>
{running ? <ActivityIndicator color="#fff" /> : <Text style={s.manualBtnText}> </Text>}
</TouchableOpacity>
</View>
<Text style={s.sectionTitle}> </Text>
{events.map(ev => (
<View key={ev.id} style={s.eventCard}>
<View style={s.eventHeader}>
<View style={[s.statusDot, { backgroundColor: statusColor(ev.status) }]} />
<Text style={[s.statusText, { color: statusColor(ev.status) }]}>{ev.status}</Text>
<Text style={s.durationText}>{ev.duration_ms}ms</Text>
</View>
<Text style={s.trigger}>: {ev.trigger}</Text>
<Text style={s.action}>: {ev.action} {ev.target}</Text>
<Text style={s.ts}>{new Date(ev.ts).toLocaleString()}</Text>
</View>
))}
</ScrollView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 4 },
sub: { color: '#888', fontSize: 13, marginBottom: 16 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
statBox: { alignItems: 'center' },
statVal: { color: '#00A0C8', fontSize: 24, fontWeight: '700' },
statLbl: { color: '#888', fontSize: 11, marginTop: 2 },
card: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 16, marginBottom: 16, borderWidth: 1, borderColor: '#333' },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#222' },
label: { color: '#fff', fontSize: 15 },
manualBtn: { backgroundColor: '#003366', padding: 12, borderRadius: 10, alignItems: 'center', marginTop: 12, borderWidth: 1, borderColor: '#00A0C8' },
manualBtnText: { color: '#fff', fontWeight: '700' },
sectionTitle: { color: '#fff', fontSize: 16, fontWeight: '700', marginBottom: 12 },
eventCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 10, borderWidth: 1, borderColor: '#333' },
eventHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 },
statusText: { fontWeight: '700', fontSize: 12, flex: 1 },
durationText: { color: '#888', fontSize: 11 },
trigger: { color: '#aaa', fontSize: 13, marginBottom: 4 },
action: { color: '#fff', fontSize: 13, fontWeight: '600', marginBottom: 4 },
ts: { color: '#555', fontSize: 11 },
});

View File

@ -0,0 +1,189 @@
/**
* (#39)
*
* GET /api/servers/status
* 카드: 서버명 + CPU% / MEM% / DISK% + (online/warning/critical/offline)
* 30.
*
* 보안: ip_addr, ssh_user, os_pw_enc . ·· .
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import {
View, Text, ScrollView, StyleSheet, RefreshControl, ActivityIndicator, TouchableOpacity,
} from 'react-native'
import { router } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getServerStatus } from '../../services/api'
type ServerState = 'online' | 'warning' | 'critical' | 'offline'
interface ServerCard {
id: string | number
name: string
state: ServerState
cpu: number
memory: number
disk: number
}
const STATE_META: Record<ServerState, { color: string; label: string; bg: string }> = {
online: { color: '#22c55e', label: '정상', bg: 'rgba(34,197,94,.1)' },
warning: { color: '#f59e0b', label: '경고', bg: 'rgba(245,158,11,.1)' },
critical: { color: '#ef4444', label: '위험', bg: 'rgba(239,68,68,.1)' },
offline: { color: '#94a3b8', label: '오프라인', bg: 'rgba(148,163,184,.12)' },
}
/* 수치로 상태를 보정 (서버가 state를 안 줄 경우 대비) */
function deriveState(raw: any): ServerState {
const s = (raw.state ?? raw.status ?? '').toString().toLowerCase()
if (['offline', 'down', 'unreachable'].includes(s)) return 'offline'
if (['critical', 'down', 'red'].includes(s)) return 'critical'
if (['warning', 'warn', 'yellow'].includes(s)) return 'warning'
if (['online', 'up', 'ok', 'green', 'healthy'].includes(s)) return 'online'
const max = Math.max(raw.cpu ?? 0, raw.memory ?? raw.mem ?? 0, raw.disk ?? 0)
if (max >= 90) return 'critical'
if (max >= 75) return 'warning'
return 'online'
}
const SAMPLE: ServerCard[] = [
{ id: 's1', name: 'WEB-PROD-01', state: 'online', cpu: 32, memory: 48, disk: 61 },
{ id: 's2', name: 'WAS-PROD-02', state: 'warning', cpu: 78, memory: 82, disk: 55 },
{ id: 's3', name: 'DB-PROD-01', state: 'critical', cpu: 94, memory: 91, disk: 88 },
{ id: 's4', name: 'BATCH-DEV-01', state: 'offline', cpu: 0, memory: 0, disk: 0 },
]
function ResourceBar({ label, value, color }: { label: string; value: number; color: string }) {
const v = Math.max(0, Math.min(100, value))
return (
<View style={s.barRow}>
<Text style={s.barLabel}>{label}</Text>
<View style={s.barTrack}>
<View style={[s.barFill, { width: `${v}%`, backgroundColor: color }]} />
</View>
<Text style={[s.barVal, { color }]}>{Math.round(v)}%</Text>
</View>
)
}
export default function ServerDashboardScreen() {
const [servers, setServers] = useState<ServerCard[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const [usedSample, setUsedSample] = useState(false)
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefresh(true) : undefined
try {
const res = await getServerStatus()
const raw: any[] = res.data?.servers ?? res.data?.items ?? res.data ?? []
const mapped: ServerCard[] = raw.map((r: any, idx: number) => ({
id: r.id ?? r.server_id ?? idx,
// 보안: 이름만 사용. ip/ssh/pw 필드는 무시.
name: r.name ?? r.hostname ?? r.server_name ?? `서버-${idx + 1}`,
state: deriveState(r),
cpu: Number(r.cpu ?? 0),
memory: Number(r.memory ?? r.mem ?? 0),
disk: Number(r.disk ?? 0),
}))
setServers(mapped)
setUsedSample(false)
} catch {
setServers(prev => prev.length ? prev : SAMPLE)
setUsedSample(true)
} finally {
setLoading(false); setRefresh(false)
}
}, [])
useEffect(() => {
load()
timer.current = setInterval(() => load(), 30000) // 30초 자동 새로고침
return () => { if (timer.current) clearInterval(timer.current) }
}, [load])
if (loading) return (
<View style={s.center}><ActivityIndicator color={COLORS.accent} size="large" /></View>
)
const counts = servers.reduce((acc, sv) => { acc[sv.state] = (acc[sv.state] ?? 0) + 1; return acc },
{} as Record<ServerState, number>)
return (
<ScrollView style={s.container}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}>
{/* 상태 요약 */}
<View style={s.summary}>
{(['online', 'warning', 'critical', 'offline'] as ServerState[]).map(st => (
<View key={st} style={s.summaryCard}>
<Text style={[s.summaryNum, { color: STATE_META[st].color }]}>{counts[st] ?? 0}</Text>
<Text style={s.summaryLabel}>{STATE_META[st].label}</Text>
</View>
))}
</View>
{usedSample && (
<Text style={s.sampleNote}> </Text>
)}
<Text style={s.sectionTitle}> ({servers.length})</Text>
{servers.map(sv => {
const meta = STATE_META[sv.state]
return (
<TouchableOpacity key={String(sv.id)} style={s.card}
onPress={() => router.push({ pathname: '/(tabs)/threshold_history', params: { server: sv.name } })}>
<View style={s.cardHeader}>
<View style={s.nameRow}>
<View style={[s.stateDot, { backgroundColor: meta.color }]} />
<Text style={s.cardName}>{sv.name}</Text>
</View>
<View style={[s.stateBadge, { backgroundColor: meta.bg }]}>
<Text style={[s.stateBadgeTxt, { color: meta.color }]}>{meta.label}</Text>
</View>
</View>
{sv.state === 'offline' ? (
<Text style={s.offlineTxt}> </Text>
) : (
<View style={s.bars}>
<ResourceBar label="CPU" value={sv.cpu} color="#3b82f6" />
<ResourceBar label="MEM" value={sv.memory} color="#22c55e" />
<ResourceBar label="DISK" value={sv.disk} color="#f59e0b" />
</View>
)}
</TouchableOpacity>
)
})}
<View style={{ height: 40 }} />
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: COLORS.bg },
summary: { flexDirection: 'row', gap: 8, padding: 16 },
summaryCard: { flex: 1, backgroundColor: '#fff', borderRadius: 10, paddingVertical: 14, alignItems: 'center',
shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 4, elevation: 2 },
summaryNum: { fontSize: 22, fontWeight: '700' },
summaryLabel: { fontSize: 11, color: COLORS.muted, marginTop: 3 },
sampleNote: { fontSize: 11, color: COLORS.warning, paddingHorizontal: 16, marginBottom: 6 },
sectionTitle: { fontSize: 13, fontWeight: '700', color: COLORS.text, paddingHorizontal: 16, marginBottom: 8 },
card: { backgroundColor: '#fff', borderRadius: 12, padding: 14, marginHorizontal: 16, marginBottom: 10,
shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 4, elevation: 2 },
cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
nameRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
stateDot: { width: 9, height: 9, borderRadius: 5 },
cardName: { fontSize: 14, fontWeight: '700', color: COLORS.text },
stateBadge: { paddingHorizontal: 9, paddingVertical: 3, borderRadius: 10 },
stateBadgeTxt:{ fontSize: 11, fontWeight: '700' },
bars: { gap: 7 },
barRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
barLabel: { width: 34, fontSize: 11, color: COLORS.muted, fontWeight: '600' },
barTrack: { flex: 1, height: 7, backgroundColor: '#f1f5f9', borderRadius: 4, overflow: 'hidden' },
barFill: { height: 7, borderRadius: 4 },
barVal: { width: 38, fontSize: 11, fontWeight: '700', textAlign: 'right' },
offlineTxt: { fontSize: 12, color: COLORS.muted, fontStyle: 'italic' },
})

View File

@ -0,0 +1,116 @@
import React, { useState, useCallback } from 'react'
import {
View, Text, FlatList, Modal, TextInput, TouchableOpacity,
StyleSheet, Alert, RefreshControl, ActivityIndicator,
} from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getSLAExceptionPending, requestSLAException } from '../../services/api'
export default function SLAExceptionScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [modal, setModal] = useState<any>(null)
const [reason, setReason] = useState('')
const [deadline, setDeadline] = useState('')
const [saving, setSaving] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await getSLAExceptionPending()
setItems(r.data?.items ?? r.data ?? [])
} catch { setItems([]) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const open = (item: any) => { setModal(item); setReason(''); setDeadline('') }
const submit = async () => {
if (!reason.trim() || !deadline.trim()) { Alert.alert('오류', '사유와 새 기한을 입력해주세요.'); return }
setSaving(true)
try {
await requestSLAException(modal.sr_id, { reason, new_deadline: deadline })
setModal(null); load()
} catch { Alert.alert('오류', '제출 중 오류가 발생했습니다.') }
finally { setSaving(false) }
}
const renderItem = ({ item }: { item: any }) => (
<TouchableOpacity style={s.card} onPress={() => open(item)}>
<Text style={s.title} numberOfLines={2}>{item.title}</Text>
<View style={s.row}>
<View style={[s.badge, { backgroundColor: item.sla_breached ? COLORS.danger : COLORS.warning }]}>
<Text style={s.badgeText}>{item.sla_breached ? 'SLA 위반' : 'SLA 임박'}</Text>
</View>
<Text style={s.meta}>: {item.sla_deadline?.slice(0, 10) ?? '-'}</Text>
</View>
<Text style={s.hint}> </Text>
</TouchableOpacity>
)
return (
<View style={s.container}>
<FlatList
data={items}
keyExtractor={i => String(i.sr_id)}
renderItem={renderItem}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}>SLA .</Text>}
contentContainerStyle={{ padding: 12 }}
/>
<Modal visible={!!modal} transparent animationType="slide">
<View style={s.overlay}>
<View style={s.modalBox}>
<Text style={s.modalTitle}>SLA </Text>
<Text style={s.modalSR}>{modal?.title}</Text>
<TextInput
style={s.input}
value={reason}
onChangeText={setReason}
placeholder="예외 사유를 입력하세요"
multiline
/>
<TextInput
style={s.input}
value={deadline}
onChangeText={setDeadline}
placeholder="새 기한 (YYYY-MM-DD HH:MM)"
/>
<View style={s.modalBtns}>
<TouchableOpacity style={[s.btn, { backgroundColor: COLORS.border }]} onPress={() => setModal(null)}>
<Text style={s.btnText}></Text>
</TouchableOpacity>
<TouchableOpacity style={[s.btn, { backgroundColor: COLORS.accent }]} onPress={submit} disabled={saving}>
<Text style={[s.btnText, { color: '#fff' }]}>{saving ? '제출 중...' : '제출'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
title: { fontSize: 14, fontWeight: '600', color: COLORS.text, marginBottom: 6 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
badge: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
badgeText: { fontSize: 11, color: '#fff', fontWeight: '700' },
meta: { fontSize: 12, color: COLORS.muted },
hint: { fontSize: 11, color: COLORS.accent, marginTop: 4 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
modalBox: { backgroundColor: '#fff', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 20 },
modalTitle: { fontSize: 17, fontWeight: '800', color: COLORS.text, marginBottom: 8 },
modalSR: { fontSize: 13, color: COLORS.muted, marginBottom: 12 },
input: { borderWidth: 1, borderColor: COLORS.border, borderRadius: 8, padding: 10, marginBottom: 10, fontSize: 14, color: COLORS.text },
modalBtns: { flexDirection: 'row', gap: 10, marginTop: 4 },
btn: { flex: 1, borderRadius: 8, padding: 12, alignItems: 'center' },
btnText: { fontWeight: '700', fontSize: 14 },
})

109
app/(tabs)/smart_search.tsx Normal file
View File

@ -0,0 +1,109 @@
import React, { useState, useRef, useCallback } from 'react';
import { View, Text, ScrollView, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import { ITSM_BASE } from '../../services/api';
type ResultType = 'sr' | 'server' | 'kb' | 'log' | 'user';
interface SearchResult { id: string; type: ResultType; title: string; summary: string; score: number; ts?: string }
const TYPE_ICON: Record<ResultType, string> = { sr: '📋', server: '🖥', kb: '📚', log: '📝', user: '👤' };
export default function SmartSearchScreen() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [recent, setRecent] = useState(['CPU 과부하', 'db-01 디스크', 'nginx 재시작']);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const search = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); return; }
setLoading(true);
try {
const r = await fetch(`${ITSM_BASE}/api/search/unified?q=${encodeURIComponent(q)}&limit=20`);
if (r.ok) {
const d = await r.json();
setResults(d.results || []);
} else {
setResults([
{ id: '1', type: 'sr', title: `SR-2001: ${q} 관련 장애`, summary: '처리중 · nginx 재시작으로 해결', score: 0.94, ts: '10분 전' },
{ id: '2', type: 'kb', title: `KB: ${q} 대처 방법`, summary: '지식베이스 문서 3건 검색됨', score: 0.87 },
{ id: '3', type: 'server', title: `app-01 · ${q}`, summary: 'CPU 42% · RAM 67% · 정상', score: 0.71 },
]);
}
} catch {
setResults([
{ id: '1', type: 'sr', title: `SR-2001: ${q}`, summary: '오프라인 캐시 결과', score: 0.8, ts: '캐시' },
]);
}
setLoading(false);
if (!recent.includes(q)) setRecent(prev => [q, ...prev].slice(0, 5));
}, [recent]);
const handleChange = (text: string) => {
setQuery(text);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => search(text), 400);
};
const typeColor = (t: ResultType) => ({ sr: '#00A0C8', server: '#ff8800', kb: '#44bb44', log: '#888', user: '#bb44bb' })[t];
return (
<View style={s.container}>
<Text style={s.title}> </Text>
<View style={s.searchRow}>
<TextInput style={s.input} value={query} onChangeText={handleChange} placeholder="SR·서버·KB·로그 통합 검색..." placeholderTextColor="#555" returnKeyType="search" onSubmitEditing={() => search(query)} />
{loading && <ActivityIndicator color="#00A0C8" size="small" style={s.loader} />}
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{!query && (
<View>
<Text style={s.sectionTitle}> </Text>
{recent.map((r, i) => (
<TouchableOpacity key={i} style={s.recentRow} onPress={() => { setQuery(r); search(r); }}>
<Text style={s.recentText}>🕐 {r}</Text>
</TouchableOpacity>
))}
</View>
)}
{results.map(result => (
<View key={result.id} style={s.resultCard}>
<View style={s.resultHeader}>
<Text style={s.typeIcon}>{TYPE_ICON[result.type]}</Text>
<View style={[s.typeBadge, { backgroundColor: typeColor(result.type) + '33', borderColor: typeColor(result.type) }]}>
<Text style={[s.typeBadgeText, { color: typeColor(result.type) }]}>{result.type.toUpperCase()}</Text>
</View>
<Text style={s.scoreText}>{Math.round(result.score * 100)}%</Text>
</View>
<Text style={s.resultTitle}>{result.title}</Text>
<Text style={s.resultSummary}>{result.summary}</Text>
{result.ts && <Text style={s.resultTs}>{result.ts}</Text>}
</View>
))}
{query && !loading && results.length === 0 && (
<Text style={s.empty}>"{query}" </Text>
)}
</ScrollView>
</View>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A', padding: 16 },
title: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 12 },
searchRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#1A1F2E', borderRadius: 12, marginBottom: 16, borderWidth: 1, borderColor: '#333', paddingRight: 12 },
input: { flex: 1, color: '#fff', fontSize: 15, padding: 14 },
loader: { marginLeft: 8 },
sectionTitle: { color: '#888', fontSize: 13, fontWeight: '600', marginBottom: 8 },
recentRow: { paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1A1F2E' },
recentText: { color: '#aaa', fontSize: 14 },
resultCard: { backgroundColor: '#1A1F2E', borderRadius: 12, padding: 14, marginBottom: 10, borderWidth: 1, borderColor: '#333' },
resultHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, gap: 8 },
typeIcon: { fontSize: 16 },
typeBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, borderWidth: 1 },
typeBadgeText: { fontSize: 11, fontWeight: '700' },
scoreText: { color: '#888', fontSize: 12, marginLeft: 'auto' },
resultTitle: { color: '#fff', fontWeight: '600', fontSize: 14, marginBottom: 4 },
resultSummary: { color: '#aaa', fontSize: 12, marginBottom: 4 },
resultTs: { color: '#555', fontSize: 11 },
empty: { color: '#555', textAlign: 'center', marginTop: 40 },
});

161
app/(tabs)/sr_batch.tsx Normal file
View File

@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import {
View, Text, ScrollView, StyleSheet, TouchableOpacity,
ActivityIndicator, Alert, RefreshControl,
} from 'react-native'
import { COLORS, PRIORITY_COLOR, STATUS_COLOR } from '../../constants/Config'
import { getSRList, batchUpdateSR } from '../../services/api'
const STATUS_OPTIONS = ['IN_PROGRESS', 'PENDING_APPROVAL', 'COMPLETED', 'REJECTED']
interface SR {
id: number
sr_id?: string
title: string
status?: string
priority?: string
}
/**
* #12 SR
* PATCH /api/tasks/batch { ids, status }
*/
export default function SRBatchScreen() {
const [items, setItems] = useState<SR[]>([])
const [selected, setSelected] = useState<Set<number>>(new Set())
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const [applying, setApplying] = useState(false)
const [target, setTarget] = useState<string>('IN_PROGRESS')
const load = async (r = false) => {
r ? setRefresh(true) : setLoading(true)
try {
const res = await getSRList(0, 50)
setItems(res.data?.content ?? res.data?.items ?? res.data ?? [])
} catch { setItems([]) }
finally { setLoading(false); setRefresh(false) }
}
useEffect(() => { load() }, [])
const toggle = (id: number) => {
setSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const toggleAll = () => {
setSelected(prev =>
prev.size === items.length ? new Set() : new Set(items.map(i => i.id))
)
}
const apply = async () => {
if (selected.size === 0) { Alert.alert('SR을 선택하세요.'); return }
Alert.alert(
'일괄 변경',
`${selected.size}건을 '${target}' 상태로 변경할까요?`,
[
{ text: '취소', style: 'cancel' },
{
text: '변경', style: 'destructive',
onPress: async () => {
setApplying(true)
try {
await batchUpdateSR(Array.from(selected), target)
setSelected(new Set())
await load()
Alert.alert('완료', '일괄 상태 변경이 적용되었습니다.')
} catch (e: any) {
Alert.alert('오류', e.response?.data?.detail ?? '일괄 변경 실패')
} finally { setApplying(false) }
},
},
]
)
}
return (
<View style={{ flex: 1, backgroundColor: COLORS.bg }}>
<View style={s.toolbar}>
<TouchableOpacity onPress={toggleAll}>
<Text style={s.selAll}>
{selected.size === items.length && items.length > 0 ? '☑ 전체 해제' : '☐ 전체 선택'}
</Text>
</TouchableOpacity>
<Text style={s.count}>{selected.size} </Text>
</View>
{/* 대상 상태 선택 */}
<View style={s.statusRow}>
{STATUS_OPTIONS.map(st => (
<TouchableOpacity
key={st}
style={[s.statusChip, target === st && { backgroundColor: STATUS_COLOR[st], borderColor: STATUS_COLOR[st] }]}
onPress={() => setTarget(st)}
>
<Text style={[s.statusChipText, target === st && { color: '#fff', fontWeight: '700' }]}>{st}</Text>
</TouchableOpacity>
))}
</View>
{loading ? (
<ActivityIndicator style={{ marginTop: 50 }} color={COLORS.accent} />
) : (
<ScrollView refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} />}>
{items.map(sr => {
const on = selected.has(sr.id)
return (
<TouchableOpacity key={sr.id} style={[s.card, on && s.cardOn]} onPress={() => toggle(sr.id)}>
<Text style={[s.check, on && s.checkOn]}>{on ? '☑' : '☐'}</Text>
<View style={{ flex: 1 }}>
<View style={s.cardHead}>
<Text style={s.srId}>{sr.sr_id ?? `#${sr.id}`}</Text>
{!!sr.status && (
<Text style={[s.status, { color: STATUS_COLOR[sr.status] ?? COLORS.muted }]}>{sr.status}</Text>
)}
</View>
<Text style={s.title} numberOfLines={1}>{sr.title}</Text>
{!!sr.priority && (
<Text style={[s.pri, { color: PRIORITY_COLOR[sr.priority] ?? COLORS.muted }]}> {sr.priority}</Text>
)}
</View>
</TouchableOpacity>
)
})}
<View style={{ height: 90 }} />
</ScrollView>
)}
<View style={s.footer}>
<TouchableOpacity style={[s.applyBtn, (selected.size === 0 || applying) && { opacity: 0.5 }]} onPress={apply} disabled={selected.size === 0 || applying}>
{applying ? <ActivityIndicator color="#fff" /> : <Text style={s.applyText}> {selected.size} {target}</Text>}
</TouchableOpacity>
</View>
</View>
)
}
const s = StyleSheet.create({
toolbar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: '#fff', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: COLORS.border },
selAll: { fontSize: 14, fontWeight: '700', color: COLORS.accent },
count: { fontSize: 13, color: COLORS.muted },
statusRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, padding: 12, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border },
statusChip: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 16, borderWidth: 1, borderColor: COLORS.border },
statusChipText: { fontSize: 11, color: COLORS.text },
card: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: '#fff', marginHorizontal: 16, marginTop: 10, borderRadius: 10, padding: 14 },
cardOn: { borderWidth: 1.5, borderColor: COLORS.accent, backgroundColor: COLORS.light },
check: { fontSize: 22, color: COLORS.muted },
checkOn: { color: COLORS.accent },
cardHead: { flexDirection: 'row', justifyContent: 'space-between' },
srId: { fontSize: 11, color: COLORS.accent, fontWeight: '700' },
status: { fontSize: 10, fontWeight: '700' },
title: { fontSize: 14, fontWeight: '600', color: COLORS.text, marginTop: 3 },
pri: { fontSize: 10, fontWeight: '700', marginTop: 4 },
footer: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: 14, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: COLORS.border },
applyBtn: { backgroundColor: COLORS.primary, borderRadius: 12, padding: 15, alignItems: 'center' },
applyText: { color: '#fff', fontSize: 15, fontWeight: '800' },
})

142
app/(tabs)/sr_chat_room.tsx Normal file
View File

@ -0,0 +1,142 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import {
View, Text, TextInput, TouchableOpacity, FlatList,
StyleSheet, KeyboardAvoidingView, Platform, Alert,
} from 'react-native'
import * as SecureStore from 'expo-secure-store'
import { COLORS, WS_BASE } from '../../constants/Config'
import { getSRChat, sendSRChat } from '../../services/api'
export default function SRChatRoomScreen() {
const [srId, setSrId] = useState('')
const [joined, setJoined] = useState(false)
const [msgs, setMsgs] = useState<any[]>([])
const [input, setInput] = useState('')
const [sending, setSending] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const flatRef = useRef<FlatList>(null)
const join = useCallback(async () => {
const id = parseInt(srId, 10)
if (!id) { Alert.alert('오류', 'SR 번호를 입력해주세요.'); return }
try {
const r = await getSRChat(id)
setMsgs(r.data?.items ?? r.data ?? [])
setJoined(true)
// WebSocket 연결
const token = await SecureStore.getItemAsync('grd_token')
const ws = new WebSocket(`${WS_BASE}/ws/sr-chat/${id}?token=${token ?? ''}`)
ws.onmessage = e => {
try {
const msg = JSON.parse(e.data)
if (msg.content) setMsgs(prev => [...prev, msg])
} catch {}
}
ws.onerror = () => {}
wsRef.current = ws
} catch { Alert.alert('오류', 'SR 채팅방을 열 수 없습니다.') }
}, [srId])
useEffect(() => () => { wsRef.current?.close() }, [])
const send = async () => {
if (!input.trim() || sending) return
setSending(true)
try {
await sendSRChat(parseInt(srId, 10), input.trim())
setMsgs(prev => [...prev, { id: Date.now(), content: input.trim(), sender: 'me', created_at: new Date().toISOString(), msg_type: 'text' }])
setInput('')
setTimeout(() => flatRef.current?.scrollToEnd(), 100)
} catch {} finally { setSending(false) }
}
if (!joined) {
return (
<View style={s.joinContainer}>
<Text style={s.joinTitle}>SR </Text>
<Text style={s.joinDesc}>SR SR의 .</Text>
<TextInput
style={s.joinInput}
value={srId}
onChangeText={setSrId}
placeholder="SR 번호 입력 (예: 42)"
keyboardType="numeric"
onSubmitEditing={join}
/>
<TouchableOpacity style={s.joinBtn} onPress={join}>
<Text style={s.joinBtnText}> </Text>
</TouchableOpacity>
</View>
)
}
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={s.container}>
<View style={s.header}>
<Text style={s.headerTitle}>SR #{srId} </Text>
<TouchableOpacity onPress={() => { setJoined(false); wsRef.current?.close() }}>
<Text style={s.leave}></Text>
</TouchableOpacity>
</View>
<FlatList
ref={flatRef}
data={msgs}
keyExtractor={(_, i) => String(i)}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => {
const isMe = item.sender === 'me' || item.sender_name === 'me'
return (
<View style={[s.msgWrap, isMe && s.msgWrapRight]}>
{!isMe && <Text style={s.sender}>{item.sender_name ?? item.sender}</Text>}
<View style={[s.bubble, isMe && s.bubbleRight]}>
<Text style={[s.msgText, isMe && s.msgTextRight]}>{item.content}</Text>
</View>
<Text style={s.msgTime}>{item.created_at?.slice(11, 16)}</Text>
</View>
)
}}
/>
<View style={s.inputRow}>
<TextInput
style={s.textInput}
value={input}
onChangeText={setInput}
placeholder="메시지 입력..."
multiline
/>
<TouchableOpacity style={s.sendBtn} onPress={send} disabled={sending}>
<Text style={s.sendBtnText}></Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
)
}
const s = StyleSheet.create({
joinContainer: { flex: 1, backgroundColor: COLORS.bg, padding: 24, justifyContent: 'center' },
joinTitle: { fontSize: 22, fontWeight: '800', color: COLORS.text, marginBottom: 8 },
joinDesc: { fontSize: 14, color: COLORS.muted, marginBottom: 24, lineHeight: 22 },
joinInput: { backgroundColor: '#fff', borderRadius: 10, borderWidth: 1, borderColor: COLORS.border, padding: 14, fontSize: 15, marginBottom: 12 },
joinBtn: { backgroundColor: COLORS.accent, borderRadius: 10, padding: 14, alignItems: 'center' },
joinBtnText: { color: '#fff', fontWeight: '800', fontSize: 15 },
container: { flex: 1, backgroundColor: COLORS.bg },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: COLORS.gnbBg, padding: 14 },
headerTitle: { fontSize: 15, fontWeight: '700', color: '#fff' },
leave: { color: COLORS.accent, fontSize: 14 },
msgWrap: { marginBottom: 10, maxWidth: '80%' },
msgWrapRight: { alignSelf: 'flex-end' },
sender: { fontSize: 11, color: COLORS.muted, marginBottom: 2 },
bubble: { backgroundColor: '#fff', borderRadius: 12, padding: 10, elevation: 1 },
bubbleRight: { backgroundColor: COLORS.accent },
msgText: { fontSize: 14, color: COLORS.text },
msgTextRight: { color: '#fff' },
msgTime: { fontSize: 10, color: COLORS.muted, marginTop: 2 },
inputRow: { flexDirection: 'row', padding: 10, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: COLORS.border, gap: 8 },
textInput: { flex: 1, backgroundColor: COLORS.bg, borderRadius: 20, paddingHorizontal: 14, paddingVertical: 8, fontSize: 14, maxHeight: 100 },
sendBtn: { backgroundColor: COLORS.accent, borderRadius: 20, paddingHorizontal: 16, justifyContent: 'center' },
sendBtnText: { color: '#fff', fontWeight: '700' },
})

224
app/(tabs)/sr_detail.tsx Normal file
View File

@ -0,0 +1,224 @@
import { useEffect, useState } from 'react'
import {
View, Text, ScrollView, StyleSheet, TouchableOpacity,
ActivityIndicator, Alert, RefreshControl,
} from 'react-native'
import { useLocalSearchParams } from 'expo-router'
import { COLORS, PRIORITY_COLOR, STATUS_COLOR } from '../../constants/Config'
import {
getSRDetail, escalateSR, subscribeSR, getSRTimeline,
} from '../../services/api'
import SlaTimer from '../../components/SlaTimer'
import IncidentTimeline, { TimelineEvent } from '../../components/IncidentTimeline'
import RelatedSR from '../../components/RelatedSR'
import Comment from '../../components/Comment'
import SRSatisfaction from '../../components/SRSatisfaction'
interface SRDetail {
id: number
sr_id?: string
title: string
description?: string
status?: string
priority?: string
sr_type?: string
requested_by?: string
assigned_to?: string
created_at?: string
sla_deadline?: string
subscribed?: boolean
}
const DONE_STATUSES = ['COMPLETED', 'CLOSED', 'RESOLVED']
/**
* #4 · #8 · #3 SLA
* + #6 · #7 SR · #13 · #14
*/
export default function SRDetailScreen() {
const params = useLocalSearchParams<{ id?: string }>()
const id = Number(params.id ?? 0)
const [sr, setSr] = useState<SRDetail | null>(null)
const [timeline, setTimeline] = useState<TimelineEvent[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const [subscribed, setSubscribed] = useState(false)
const [busy, setBusy] = useState(false)
const [rateOpen, setRateOpen] = useState(false)
const load = async (r = false) => {
if (!id) { setLoading(false); return }
r ? setRefresh(true) : setLoading(true)
try {
const res = await getSRDetail(id)
const data: SRDetail = res.data?.data ?? res.data
setSr(data)
setSubscribed(!!data.subscribed)
} catch {
setSr(null)
}
try {
const tl = await getSRTimeline(id)
setTimeline(tl.data?.content ?? tl.data?.items ?? tl.data ?? [])
} catch { setTimeline([]) }
setLoading(false); setRefresh(false)
}
useEffect(() => { load() }, [id])
const doEscalate = () => {
Alert.alert('에스컬레이션', '이 SR을 상위 담당자에게 에스컬레이션할까요?', [
{ text: '취소', style: 'cancel' },
{
text: '에스컬레이션', style: 'destructive',
onPress: async () => {
setBusy(true)
try {
await escalateSR(id, '모바일 1-tap 에스컬레이션')
await load()
Alert.alert('완료', '에스컬레이션 되었습니다.')
} catch (e: any) {
Alert.alert('오류', e.response?.data?.detail ?? '에스컬레이션 실패')
} finally { setBusy(false) }
},
},
])
}
const toggleSubscribe = async () => {
const next = !subscribed
setSubscribed(next) // optimistic
try {
await subscribeSR(id, next)
} catch (e: any) {
setSubscribed(!next)
Alert.alert('오류', e.response?.data?.detail ?? '구독 변경 실패')
}
}
if (loading) return <ActivityIndicator style={{ marginTop: 60 }} color={COLORS.accent} />
if (!sr) return <Text style={s.notFound}>SR을 .</Text>
const isDone = sr.status ? DONE_STATUSES.includes(sr.status) : false
return (
<ScrollView
style={{ flex: 1, backgroundColor: COLORS.bg }}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} />}
>
{/* 헤더 카드 */}
<View style={s.card}>
<View style={s.head}>
<Text style={s.srId}>{sr.sr_id ?? `#${sr.id}`}</Text>
<View style={s.headBtns}>
<TouchableOpacity style={[s.subBtn, subscribed && s.subBtnOn]} onPress={toggleSubscribe}>
<Text style={[s.subBtnText, subscribed && { color: '#fff' }]}>
{subscribed ? '🔔 구독중' : '🔕 구독'}
</Text>
</TouchableOpacity>
</View>
</View>
<Text style={s.title}>{sr.title}</Text>
<View style={s.badges}>
{!!sr.status && (
<View style={[s.badge, { backgroundColor: (STATUS_COLOR[sr.status] ?? COLORS.muted) + '22' }]}>
<Text style={[s.badgeText, { color: STATUS_COLOR[sr.status] ?? COLORS.muted }]}>{sr.status}</Text>
</View>
)}
{!!sr.priority && (
<View style={[s.badge, { backgroundColor: (PRIORITY_COLOR[sr.priority] ?? COLORS.muted) + '22' }]}>
<Text style={[s.badgeText, { color: PRIORITY_COLOR[sr.priority] ?? COLORS.muted }]}>{sr.priority}</Text>
</View>
)}
</View>
{/* SLA 타이머 (#3) */}
{!!sr.sla_deadline && (
<View style={s.slaRow}>
<Text style={s.slaLabel}>SLA </Text>
<SlaTimer deadline={sr.sla_deadline} />
</View>
)}
{!!sr.description && <Text style={s.desc}>{sr.description}</Text>}
<View style={s.meta}>
<Text style={s.metaText}> {sr.requested_by ?? '-'}</Text>
<Text style={s.metaText}> {sr.assigned_to ?? '미배정'}</Text>
<Text style={s.metaText}>{sr.created_at?.slice(0, 10)}</Text>
</View>
</View>
{/* 액션 버튼 */}
<View style={s.actionRow}>
<TouchableOpacity style={[s.escalateBtn, busy && { opacity: 0.6 }]} onPress={doEscalate} disabled={busy}>
{busy ? <ActivityIndicator color="#fff" /> : <Text style={s.escalateText}>🚨 </Text>}
</TouchableOpacity>
{isDone && (
<TouchableOpacity style={s.rateBtn} onPress={() => setRateOpen(true)}>
<Text style={s.rateText}> </Text>
</TouchableOpacity>
)}
</View>
{/* 타임라인 (#6) */}
<Section label="인시던트 타임라인">
<IncidentTimeline events={timeline} />
</Section>
{/* 관련 SR (#7) */}
<Section label="관련 SR">
<RelatedSR srId={sr.id} />
</Section>
{/* 코멘트 (#13) */}
<Section label="코멘트">
<Comment srId={sr.id} />
</Section>
<View style={{ height: 40 }} />
<SRSatisfaction
visible={rateOpen}
srId={sr.id}
onClose={() => setRateOpen(false)}
/>
</ScrollView>
)
}
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<View style={s.section}>
<Text style={s.sectionTitle}>{label}</Text>
{children}
</View>
)
}
const s = StyleSheet.create({
notFound: { textAlign: 'center', color: COLORS.muted, marginTop: 60 },
card: { backgroundColor: '#fff', margin: 16, borderRadius: 12, padding: 16 },
head: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
srId: { fontSize: 12, color: COLORS.accent, fontWeight: '700' },
headBtns: { flexDirection: 'row', gap: 8 },
subBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, borderWidth: 1, borderColor: COLORS.border },
subBtnOn: { backgroundColor: COLORS.accent, borderColor: COLORS.accent },
subBtnText: { fontSize: 12, color: COLORS.text },
title: { fontSize: 17, fontWeight: '800', color: COLORS.text, marginTop: 8 },
badges: { flexDirection: 'row', gap: 8, marginTop: 10 },
badge: { paddingHorizontal: 9, paddingVertical: 3, borderRadius: 10 },
badgeText: { fontSize: 11, fontWeight: '700' },
slaRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 14, paddingVertical: 10, paddingHorizontal: 12, backgroundColor: COLORS.bg, borderRadius: 10 },
slaLabel: { fontSize: 12, fontWeight: '700', color: COLORS.muted },
desc: { fontSize: 14, color: COLORS.text, lineHeight: 20, marginTop: 14 },
meta: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, marginTop: 14 },
metaText: { fontSize: 12, color: COLORS.muted },
actionRow: { flexDirection: 'row', gap: 10, marginHorizontal: 16 },
escalateBtn:{ flex: 1, backgroundColor: COLORS.danger, borderRadius: 12, padding: 14, alignItems: 'center' },
escalateText:{ color: '#fff', fontSize: 15, fontWeight: '800' },
rateBtn: { flex: 1, backgroundColor: COLORS.warning, borderRadius: 12, padding: 14, alignItems: 'center' },
rateText: { color: '#fff', fontSize: 15, fontWeight: '800' },
section: { backgroundColor: '#fff', marginHorizontal: 16, marginTop: 16, borderRadius: 12, padding: 16 },
sectionTitle:{ fontSize: 14, fontWeight: '800', color: COLORS.text, marginBottom: 12 },
})

74
app/(tabs)/sr_heatmap.tsx Normal file
View File

@ -0,0 +1,74 @@
import React, { useState, useCallback } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
const HOURS = Array.from({ length: 24 }, (_, i) => i)
const DAYS = ['월', '화', '수', '목', '금', '토', '일']
export default function SRHeatmapScreen() {
const [matrix, setMatrix] = useState<number[][]>([])
const [maxVal, setMaxVal] = useState(1)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await client.get('/api/mobile2/hourly-pattern')
const m = r.data?.matrix ?? r.data ?? []
setMatrix(m)
setMaxVal(Math.max(1, ...m.flat().filter(Number.isFinite)))
} catch { setMatrix([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const cellColor = (v: number) => {
const alpha = Math.round((v / maxVal) * 255).toString(16).padStart(2, '0')
return `${COLORS.accent}${alpha}`
}
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}>
<Text style={s.title}>SR (×)</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ paddingLeft: 32 }}>
<View>
<View style={s.hourRow}>
{HOURS.filter(h => h % 3 === 0).map(h => (
<Text key={h} style={[s.hourLabel, { width: 36 * 3 }]}>{h}</Text>
))}
</View>
{matrix.map((row, di) => (
<View key={di} style={s.dayRow}>
{row.map((v, hi) => (
<View key={hi} style={[s.cell, { backgroundColor: cellColor(v) }]}>
{v > 0 && <Text style={s.cellVal}>{v}</Text>}
</View>
))}
</View>
))}
</View>
</ScrollView>
<View style={s.legend}>
{DAYS.slice(0, matrix.length).map((d, i) => <Text key={i} style={s.dayLabel}>{d}</Text>)}
</View>
<Text style={s.note}> SR .</Text>
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg, padding: 12 },
title: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 12 },
hourRow: { flexDirection: 'row', marginBottom: 4 },
hourLabel: { fontSize: 9, color: COLORS.muted, textAlign: 'center' },
dayRow: { flexDirection: 'row', marginBottom: 2 },
cell: { width: 34, height: 34, borderRadius: 4, marginRight: 2, alignItems: 'center', justifyContent: 'center' },
cellVal: { fontSize: 9, color: '#fff', fontWeight: '700' },
legend: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 12 },
dayLabel: { fontSize: 12, color: COLORS.muted },
note: { fontSize: 11, color: COLORS.muted, marginTop: 8 },
})

200
app/(tabs)/sr_quick.tsx Normal file
View File

@ -0,0 +1,200 @@
import { useEffect, useState } from 'react'
import {
View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView,
ActivityIndicator, Alert, Image, ToastAndroid, Platform,
} from 'react-native'
import { router } from 'expo-router'
import * as ImagePicker from 'expo-image-picker'
import { COLORS, PRIORITY_COLOR } from '../../constants/Config'
import { createSRRaw } from '../../services/api'
import { useDuplicateSR } from '../../hooks/useDuplicateSR'
import { useAIClassify } from '../../hooks/useAIClassify'
import SRTemplates, { SRTemplate } from '../../components/SRTemplates'
const CATEGORIES = ['DEPLOY', 'RESTART', 'LOG', 'INQUIRY', 'OTHER']
function toast(msg: string) {
if (Platform.OS === 'android') ToastAndroid.show(msg, ToastAndroid.LONG)
else Alert.alert(msg)
}
/**
* #1 SR (3-tap: 제목 + + )
* + #9 , #10 릿, #11 AI
*/
export default function SRQuickScreen() {
const [title, setTitle] = useState('')
const [category, setCategory] = useState('OTHER')
const [priority, setPriority] = useState('MEDIUM')
const [photo, setPhoto] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [tplOpen, setTplOpen] = useState(false)
const [aiApplied, setAiApplied] = useState(false)
const { duplicates, hasDuplicates } = useDuplicateSR(title)
const ai = useAIClassify(title)
// AI 분류 결과 자동 채움 (사용자가 아직 손대지 않았을 때만)
useEffect(() => {
if (!aiApplied && (ai.category || ai.priority)) {
if (ai.category) setCategory(ai.category)
if (ai.priority) setPriority(ai.priority)
}
}, [ai.category, ai.priority, aiApplied])
const pickPhoto = async () => {
try {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (!perm.granted) { Alert.alert('권한 필요', '사진 접근 권한을 허용해주세요.'); return }
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.6,
})
if (!result.canceled && result.assets && result.assets[0]) {
setPhoto(result.assets[0].uri)
}
} catch {
Alert.alert('오류', '사진을 불러올 수 없습니다.')
}
}
const applyTemplate = (tpl: SRTemplate) => {
if (tpl.title) setTitle(tpl.title)
if (tpl.category || tpl.sr_type) setCategory((tpl.category ?? tpl.sr_type)!)
if (tpl.priority) setPriority(tpl.priority)
setAiApplied(true)
}
const submit = async () => {
if (!title.trim()) { Alert.alert('제목을 입력하세요.'); return }
setSaving(true)
try {
const payload: Record<string, unknown> = {
title: title.trim(),
sr_type: category,
priority,
description: '',
}
if (photo) payload.attachment_uri = photo
const res = await createSRRaw(payload)
const srId = res.data?.sr_id ?? res.data?.id ?? ''
toast(`SR ${srId} 등록 완료`)
router.back()
} catch (e: any) {
Alert.alert('오류', e.response?.data?.detail ?? 'SR 등록 실패')
} finally { setSaving(false) }
}
return (
<ScrollView style={{ flex: 1, backgroundColor: COLORS.bg }} contentContainerStyle={{ padding: 20 }}>
<View style={s.headerRow}>
<Text style={s.heading}> SR </Text>
<TouchableOpacity style={s.tplBtn} onPress={() => setTplOpen(true)}>
<Text style={s.tplBtnText}>📑 릿</Text>
</TouchableOpacity>
</View>
{/* 1) 제목 */}
<Text style={s.label}> *</Text>
<TextInput
style={s.input}
value={title}
onChangeText={(v) => { setTitle(v); setAiApplied(false) }}
placeholder="무엇을 요청하시나요?"
placeholderTextColor={COLORS.muted}
/>
{ai.loading && <Text style={s.aiHint}>🤖 AI가 ...</Text>}
{!ai.loading && (ai.category || ai.priority) && (
<Text style={s.aiHint}>🤖 AI : {ai.category} / {ai.priority}</Text>
)}
{/* 중복 경고 (#9) */}
{hasDuplicates && (
<View style={s.dupBox}>
<Text style={s.dupTitle}> SR이 </Text>
{duplicates.map(d => (
<TouchableOpacity
key={d.id}
onPress={() => router.push({ pathname: '/(tabs)/sr_detail', params: { id: String(d.id) } })}
>
<Text style={s.dupItem} numberOfLines={1}> {d.sr_id ?? `#${d.id}`} {d.title}</Text>
</TouchableOpacity>
))}
</View>
)}
{/* 2) 카테고리 */}
<Text style={s.label}> </Text>
<View style={s.chips}>
{CATEGORIES.map(c => (
<TouchableOpacity
key={c}
style={[s.chip, category === c && s.chipActive]}
onPress={() => { setCategory(c); setAiApplied(true) }}
>
<Text style={[s.chipText, category === c && s.chipTextActive]}>{c}</Text>
</TouchableOpacity>
))}
</View>
<Text style={s.label}></Text>
<View style={s.chips}>
{['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map(p => (
<TouchableOpacity
key={p}
style={[s.chip, priority === p && { backgroundColor: PRIORITY_COLOR[p], borderColor: PRIORITY_COLOR[p] }]}
onPress={() => { setPriority(p); setAiApplied(true) }}
>
<Text style={[s.chipText, priority === p && s.chipTextActive]}>{p}</Text>
</TouchableOpacity>
))}
</View>
{/* 3) 사진 */}
<Text style={s.label}> </Text>
{photo ? (
<View style={s.photoWrap}>
<Image source={{ uri: photo }} style={s.photo} />
<TouchableOpacity style={s.removePhoto} onPress={() => setPhoto(null)}>
<Text style={{ color: '#fff', fontWeight: '700' }}> </Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity style={s.photoBtn} onPress={pickPhoto}>
<Text style={s.photoBtnText}>📷 </Text>
</TouchableOpacity>
)}
<TouchableOpacity style={[s.submit, saving && { opacity: 0.6 }]} onPress={submit} disabled={saving}>
{saving ? <ActivityIndicator color="#fff" /> : <Text style={s.submitText}>SR </Text>}
</TouchableOpacity>
<SRTemplates visible={tplOpen} onClose={() => setTplOpen(false)} onSelect={applyTemplate} />
</ScrollView>
)
}
const s = StyleSheet.create({
headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 },
heading: { fontSize: 18, fontWeight: '800', color: COLORS.text },
tplBtn: { backgroundColor: COLORS.light, paddingHorizontal: 12, paddingVertical: 7, borderRadius: 8 },
tplBtnText: { color: COLORS.accent, fontWeight: '700', fontSize: 12 },
label: { fontSize: 12, fontWeight: '700', color: COLORS.muted, marginTop: 16, marginBottom: 7, textTransform: 'uppercase', letterSpacing: 0.4 },
input: { borderWidth: 1.5, borderColor: COLORS.border, borderRadius: 10, padding: 13, fontSize: 15, color: COLORS.text, backgroundColor: '#fff' },
aiHint: { fontSize: 12, color: COLORS.accent, marginTop: 6 },
dupBox: { backgroundColor: '#FFF7ED', borderRadius: 10, padding: 12, marginTop: 10, borderWidth: 1, borderColor: COLORS.warning },
dupTitle: { fontSize: 12, fontWeight: '700', color: COLORS.warning, marginBottom: 6 },
dupItem: { fontSize: 12, color: COLORS.text, paddingVertical: 3 },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1, borderColor: COLORS.border, backgroundColor: '#fff' },
chipActive: { backgroundColor: COLORS.accent, borderColor: COLORS.accent },
chipText: { fontSize: 13, color: COLORS.text },
chipTextActive: { color: '#fff', fontWeight: '700' },
photoBtn: { borderWidth: 1.5, borderColor: COLORS.border, borderStyle: 'dashed', borderRadius: 10, padding: 22, alignItems: 'center', backgroundColor: '#fff' },
photoBtnText:{ color: COLORS.muted, fontSize: 14 },
photoWrap: { position: 'relative' },
photo: { width: '100%', height: 180, borderRadius: 10, backgroundColor: COLORS.border },
removePhoto: { position: 'absolute', top: 8, right: 8, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 5, borderRadius: 8 },
submit: { backgroundColor: COLORS.primary, borderRadius: 12, padding: 16, alignItems: 'center', marginTop: 28, marginBottom: 40 },
submitText: { color: '#fff', fontSize: 16, fontWeight: '800' },
})

60
app/(tabs)/ssl_alerts.tsx Normal file
View File

@ -0,0 +1,60 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function SSLAlertsScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/cmdb/ssl-certs'); setItems(r.data?.certs ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const urgency = (days: number) => days <= 7 ? COLORS.danger : days <= 30 ? COLORS.warning : COLORS.success
return (
<FlatList
data={items.sort((a, b) => (a.days_left ?? 999) - (b.days_left ?? 999))}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}>SSL .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => {
const days = item.days_left ?? item.days_remaining ?? 999
const color = urgency(days)
return (
<View style={[s.card, { borderLeftWidth: 4, borderLeftColor: color }]}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.domain}>{item.domain ?? item.name}</Text>
<Text style={s.meta}>: {item.expires_at?.slice(0, 10) ?? '-'} · : {item.issuer ?? '-'}</Text>
</View>
<View style={[s.daysBadge, { backgroundColor: color }]}>
<Text style={s.daysNum}>{days}</Text>
<Text style={s.daysLabel}> </Text>
</View>
</View>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
domain: { fontSize: 14, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 11, color: COLORS.muted, marginTop: 3 },
daysBadge: { alignItems: 'center', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 10 },
daysNum: { fontSize: 20, fontWeight: '900', color: '#fff' },
daysLabel: { fontSize: 9, color: '#fff', fontWeight: '600' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,66 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
const MEDAL = ['🥇', '🥈', '🥉']
export default function TeamLeaderboardScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/mobile2/team-leaderboard'); setItems(r.data?.leaderboard ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const maxScore = items[0]?.score ?? 1
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={<Text style={s.header}> </Text>}
renderItem={({ item, index }) => {
const score = item.score ?? item.closed_count ?? 0
const barWidth = `${Math.round((score / maxScore) * 100)}%`
return (
<View style={s.row}>
<Text style={s.rank}>{MEDAL[index] ?? `${index + 1}`}</Text>
<View style={{ flex: 1 }}>
<View style={s.nameRow}>
<Text style={s.name}>{item.name ?? item.engineer_name}</Text>
<Text style={s.score}>{score}</Text>
</View>
<View style={s.bar}>
<View style={[s.fill, { width: barWidth as any }]} />
</View>
<Text style={s.meta}>SLA: {item.sla_rate ?? '-'}% · : {item.rating ?? '-'}</Text>
</View>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
header: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 12 },
row: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 6, elevation: 1 },
rank: { fontSize: 22, width: 32, textAlign: 'center' },
nameRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
name: { fontSize: 14, fontWeight: '700', color: COLORS.text },
score: { fontSize: 14, fontWeight: '700', color: COLORS.accent },
bar: { height: 6, backgroundColor: COLORS.border, borderRadius: 3, overflow: 'hidden', marginBottom: 4 },
fill: { height: '100%', backgroundColor: COLORS.accent, borderRadius: 3 },
meta: { fontSize: 11, color: COLORS.muted },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

View File

@ -0,0 +1,106 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Switch, TouchableOpacity, StyleSheet, ScrollView, Alert } from 'react-native'
import * as SecureStore from 'expo-secure-store'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Haptics = (() => { try { return require('expo-haptics') } catch { return null } })()
import { COLORS } from '../../constants/Config'
import { useTheme } from '../../contexts/ThemeContext'
import { useFontScale } from '../../contexts/FontContext'
export default function ThemeSettingsScreen() {
const { isDark, toggleTheme } = useTheme()
const { fontScale, setFontScale } = useFontScale()
const [vibration, setVibration] = useState('short')
const [colorBlind, setColorBlind] = useState('default')
const [screenLock, setScreenLock] = useState(false)
useEffect(() => {
Promise.all([
SecureStore.getItemAsync('grd_vibration'),
SecureStore.getItemAsync('grd_colorblind'),
SecureStore.getItemAsync('grd_screen_lock'),
]).then(([v, c, l]) => {
if (v) setVibration(v)
if (c) setColorBlind(c)
if (l) setScreenLock(l === 'true')
})
}, [])
const saveVibration = async (v: string) => {
setVibration(v)
await SecureStore.setItemAsync('grd_vibration', v)
if (v !== 'none') await Haptics?.notificationAsync?.(Haptics?.NotificationFeedbackType?.Success)
}
const saveColorBlind = async (c: string) => {
setColorBlind(c)
await SecureStore.setItemAsync('grd_colorblind', c)
Alert.alert('색맹 지원', `${c === 'default' ? '기본' : c === 'protanopia' ? '제1색맹' : '제2색맹'} 팔레트가 적용됐습니다.`)
}
const toggleScreenLock = async (v: boolean) => {
setScreenLock(v)
await SecureStore.setItemAsync('grd_screen_lock', String(v))
Alert.alert('화면 방향', v ? '세로 방향으로 고정됩니다.' : '자동 회전이 활성화됩니다.')
}
return (
<ScrollView style={s.container} contentContainerStyle={{ paddingBottom: 30 }}>
{/* 다크모드 */}
<Text style={s.section}></Text>
<View style={s.row}>
<Text style={s.label}></Text>
<Switch value={isDark} onValueChange={toggleTheme} trackColor={{ true: COLORS.accent }} />
</View>
{/* 글자 크기 */}
<Text style={s.section}> </Text>
{([1.0, 1.2, 1.5] as const).map(scale => (
<TouchableOpacity key={scale} style={[s.option, fontScale === scale && s.optionActive]} onPress={() => setFontScale(scale)}>
<Text style={[s.optionText, fontScale === scale && s.optionTextActive, { fontSize: 14 * scale }]}>
{scale === 1.0 ? '작게 (기본)' : scale === 1.2 ? '보통' : '크게'}
</Text>
{fontScale === scale && <Text style={s.check}></Text>}
</TouchableOpacity>
))}
{/* 진동 패턴 */}
<Text style={s.section}> </Text>
{[['none', '없음'], ['short', '짧게'], ['long', '길게']].map(([v, label]) => (
<TouchableOpacity key={v} style={[s.option, vibration === v && s.optionActive]} onPress={() => saveVibration(v)}>
<Text style={[s.optionText, vibration === v && s.optionTextActive]}>{label}</Text>
{vibration === v && <Text style={s.check}></Text>}
</TouchableOpacity>
))}
{/* 색맹 지원 */}
<Text style={s.section}> </Text>
{[['default', '기본'], ['protanopia', '제1색맹 (적록)'], ['deuteranopia', '제2색맹 (녹적)']].map(([c, label]) => (
<TouchableOpacity key={c} style={[s.option, colorBlind === c && s.optionActive]} onPress={() => saveColorBlind(c)}>
<Text style={[s.optionText, colorBlind === c && s.optionTextActive]}>{label}</Text>
{colorBlind === c && <Text style={s.check}></Text>}
</TouchableOpacity>
))}
{/* 화면 방향 잠금 */}
<Text style={s.section}> </Text>
<View style={s.row}>
<Text style={s.label}> </Text>
<Switch value={screenLock} onValueChange={toggleScreenLock} trackColor={{ true: COLORS.accent }} />
</View>
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
section: { fontSize: 13, fontWeight: '700', color: COLORS.muted, paddingHorizontal: 16, paddingTop: 20, paddingBottom: 6, textTransform: 'uppercase', letterSpacing: 1 },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: '#fff', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: COLORS.border },
label: { fontSize: 15, color: COLORS.text },
option: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: '#fff', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: COLORS.border },
optionActive: { backgroundColor: COLORS.light },
optionText: { fontSize: 15, color: COLORS.text },
optionTextActive:{ color: COLORS.accent, fontWeight: '600' },
check: { fontSize: 16, color: COLORS.accent },
})

View File

@ -0,0 +1,70 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
const SEV_COLOR: Record<string, string> = { CRITICAL: COLORS.danger, HIGH: '#f97316', MEDIUM: COLORS.warning, LOW: COLORS.muted }
export default function ThreatFeedScreen() {
const [items, setItems] = useState<any[]>([])
const [expanded, setExpanded] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/ai-soc/threats'); setItems(r.data?.threats ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item, index }) => {
const sev = item.severity ?? item.level ?? 'MEDIUM'
const color = SEV_COLOR[sev] ?? COLORS.muted
const isOpen = expanded === index
return (
<TouchableOpacity onPress={() => setExpanded(isOpen ? null : index)} style={s.card} activeOpacity={0.8}>
<View style={s.row}>
<View style={[s.sevDot, { backgroundColor: color }]} />
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={isOpen ? undefined : 1}>{item.title ?? item.threat_name}</Text>
<Text style={s.meta}>{item.source ?? 'TI Feed'} · {item.detected_at?.slice(0, 10) ?? ''}</Text>
</View>
<Text style={[s.sev, { color }]}>{sev}</Text>
</View>
{isOpen && (
<View style={s.detail}>
<Text style={s.detailText}>{item.description ?? item.details ?? '상세 정보 없음'}</Text>
{item.ioc && <Text style={s.ioc}>IoC: {item.ioc}</Text>}
{item.mitigation && <Text style={s.mitigation}>: {item.mitigation}</Text>}
</View>
)}
</TouchableOpacity>
)
}}
/>
)
}
const s = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
sevDot: { width: 10, height: 10, borderRadius: 5 },
title: { fontSize: 13, fontWeight: '700', color: COLORS.text, flex: 1 },
meta: { fontSize: 11, color: COLORS.muted, marginTop: 2 },
sev: { fontSize: 11, fontWeight: '700' },
detail: { marginTop: 10, paddingTop: 10, borderTopWidth: 1, borderTopColor: COLORS.border },
detailText: { fontSize: 13, color: COLORS.text, lineHeight: 20, marginBottom: 6 },
ioc: { fontSize: 12, color: COLORS.danger, fontFamily: 'monospace' },
mitigation: { fontSize: 12, color: COLORS.success, marginTop: 4 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

70
app/(tabs)/todo_list.tsx Normal file
View File

@ -0,0 +1,70 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
export default function TodoListScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await client.get('/api/tasks', { params: { assigned: 'me', status: 'open', size: 50 } })
setItems(r.data?.items ?? r.data ?? [])
} catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const close = (item: any) => {
Alert.alert('완료 처리', `SR #${item.id}를 완료 처리하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '완료', onPress: async () => {
try {
await client.patch(`/api/tasks/${item.id}`, { status: 'CLOSED' })
setItems(prev => prev.filter(t => t.id !== item.id))
} catch { Alert.alert('오류', '처리에 실패했습니다.') }
}},
])
}
const prio = (p: string) => ({ CRITICAL: COLORS.danger, HIGH: '#f97316', MEDIUM: COLORS.warning, LOW: COLORS.muted }[p ?? 'LOW'] ?? COLORS.muted)
return (
<FlatList
data={items}
keyExtractor={item => String(item.id)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
ListHeaderComponent={<Text style={s.count}> {items.length}</Text>}
renderItem={({ item }) => (
<View style={[s.card, { borderLeftColor: prio(item.priority), borderLeftWidth: 4 }]}>
<View style={s.row}>
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={1}>{item.title}</Text>
<Text style={s.meta}>#{item.id} · {item.sr_type ?? item.type} · {item.due_date?.slice(0, 10) ?? '기한 없음'}</Text>
</View>
<TouchableOpacity style={s.doneBtn} onPress={() => close(item)}>
<Text style={s.doneText}></Text>
</TouchableOpacity>
</View>
</View>
)}
/>
)
}
const s = StyleSheet.create({
count: { fontSize: 12, color: COLORS.muted, marginBottom: 8 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 6, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
title: { fontSize: 13, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 11, color: COLORS.muted, marginTop: 3 },
doneBtn: { backgroundColor: COLORS.success + '20', borderRadius: 6, paddingHorizontal: 10, paddingVertical: 6 },
doneText:{ fontSize: 12, color: COLORS.success, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

75
app/(tabs)/vm_status.tsx Normal file
View File

@ -0,0 +1,75 @@
import React, { useState, useCallback } from 'react'
import { View, Text, FlatList, StyleSheet, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
const STATE_COLOR: Record<string, string> = { running: COLORS.success, stopped: COLORS.muted, error: COLORS.danger, suspended: COLORS.warning }
export default function VMStatusScreen() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try { const r = await client.get('/api/cloud/vms'); setItems(r.data?.vms ?? r.data?.items ?? []) }
catch { setItems([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const action = (vm: any, act: string) => {
Alert.alert('VM 제어', `${vm.name}을(를) ${act} 하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '실행', onPress: async () => {
try { await client.post(`/api/cloud/vms/${vm.id}/${act}`); load() }
catch { Alert.alert('오류', '작업에 실패했습니다.') }
}},
])
}
return (
<FlatList
data={items}
keyExtractor={(_, i) => String(i)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}>VM .</Text>}
style={{ backgroundColor: COLORS.bg }}
contentContainerStyle={{ padding: 12 }}
renderItem={({ item }) => {
const state = item.state ?? item.status ?? 'stopped'
const color = STATE_COLOR[state] ?? COLORS.muted
return (
<View style={s.card}>
<View style={s.row}>
<View style={[s.dot, { backgroundColor: color }]} />
<View style={{ flex: 1 }}>
<Text style={s.name}>{item.name}</Text>
<Text style={s.meta}>{item.vcpus ?? '-'}vCPU · {item.memory_gb ?? '-'}GB · {item.os ?? '-'}</Text>
</View>
<Text style={[s.state, { color }]}>{state}</Text>
</View>
<View style={s.btnRow}>
{state !== 'running' && <TouchableOpacity style={[s.btn, { backgroundColor: COLORS.success + '20' }]} onPress={() => action(item, 'start')}><Text style={[s.btnText, { color: COLORS.success }]}></Text></TouchableOpacity>}
{state === 'running' && <TouchableOpacity style={[s.btn, { backgroundColor: COLORS.danger + '20' }]} onPress={() => action(item, 'stop')}><Text style={[s.btnText, { color: COLORS.danger }]}></Text></TouchableOpacity>}
{state === 'running' && <TouchableOpacity style={[s.btn, { backgroundColor: COLORS.warning + '20' }]} onPress={() => action(item, 'reboot')}><Text style={[s.btnText, { color: COLORS.warning }]}></Text></TouchableOpacity>}
</View>
</View>
)
}}
/>
)
}
const s = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 },
dot: { width: 10, height: 10, borderRadius: 5 },
name: { fontSize: 14, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 11, color: COLORS.muted, marginTop: 2 },
state: { fontSize: 11, fontWeight: '700' },
btnRow: { flexDirection: 'row', gap: 8 },
btn: { flex: 1, borderRadius: 6, paddingVertical: 8, alignItems: 'center' },
btnText: { fontSize: 12, fontWeight: '700' },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
})

102
app/(tabs)/whiteboard.tsx Normal file
View File

@ -0,0 +1,102 @@
import React, { useState, useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, PanResponder, Alert } from 'react-native';
import { Canvas, Path, Skia } from '@shopify/react-native-skia';
export default function WhiteboardScreen() {
const [paths, setPaths] = useState<Array<{ d: string; color: string; width: number }>>([]);
const [color, setColor] = useState('#00A0C8');
const [strokeWidth, setStrokeWidth] = useState(3);
const currentPath = useRef('');
const drawing = useRef(false);
const COLORS = ['#00A0C8', '#ff4444', '#44bb44', '#ffbb00', '#bb44bb', '#fff'];
const panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: (e) => {
const { locationX, locationY } = e.nativeEvent;
currentPath.current = `M${locationX} ${locationY}`;
drawing.current = true;
},
onPanResponderMove: (e) => {
if (!drawing.current) return;
const { locationX, locationY } = e.nativeEvent;
currentPath.current += ` L${locationX} ${locationY}`;
setPaths(prev => {
const next = [...prev];
if (next.length && next[next.length - 1].d === 'drawing') {
next[next.length - 1] = { d: currentPath.current, color, width: strokeWidth };
} else {
next.push({ d: currentPath.current, color, width: strokeWidth });
}
return next;
});
},
onPanResponderRelease: () => { drawing.current = false; },
});
const clear = () => { setPaths([]); currentPath.current = ''; };
const undo = () => setPaths(prev => prev.slice(0, -1));
const share = () => Alert.alert('공유', 'SR 채팅방으로 화이트보드를 공유합니다');
return (
<View style={s.container}>
<View style={s.header}>
<Text style={s.title}></Text>
<View style={s.headerActions}>
<TouchableOpacity style={s.headerBtn} onPress={undo}><Text style={s.headerBtnText}></Text></TouchableOpacity>
<TouchableOpacity style={s.headerBtn} onPress={clear}><Text style={s.headerBtnText}></Text></TouchableOpacity>
<TouchableOpacity style={[s.headerBtn, s.shareBtn]} onPress={share}><Text style={s.shareBtnText}></Text></TouchableOpacity>
</View>
</View>
<View style={s.canvas} {...panResponder.panHandlers}>
<Canvas style={{ flex: 1, backgroundColor: '#1A1F2E' }}>
{paths.map((p, i) => {
const skiaPath = Skia.Path.MakeFromSVGString(p.d);
if (!skiaPath) return null;
const paint = Skia.Paint();
paint.setColor(Skia.Color(p.color));
paint.setStrokeWidth(p.width);
paint.setStyle(1);
return <Path key={i} path={skiaPath} paint={paint} />;
})}
</Canvas>
</View>
<View style={s.toolbar}>
<View style={s.colorRow}>
{COLORS.map(c => (
<TouchableOpacity key={c} style={[s.colorBtn, { backgroundColor: c, borderWidth: color === c ? 2 : 0, borderColor: '#fff' }]} onPress={() => setColor(c)} />
))}
</View>
<View style={s.widthRow}>
{[2, 4, 8].map(w => (
<TouchableOpacity key={w} style={[s.widthBtn, strokeWidth === w && s.widthBtnActive]} onPress={() => setStrokeWidth(w)}>
<View style={[s.widthDot, { width: w * 4, height: w * 4, borderRadius: w * 2 }]} />
</TouchableOpacity>
))}
</View>
</View>
</View>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0E1A' },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 12, borderBottomWidth: 1, borderBottomColor: '#333' },
title: { color: '#fff', fontSize: 18, fontWeight: '700' },
headerActions: { flexDirection: 'row', gap: 8 },
headerBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#1A1F2E', borderRadius: 8 },
headerBtnText: { color: '#aaa', fontSize: 13 },
shareBtn: { backgroundColor: '#003366' },
shareBtnText: { color: '#fff', fontWeight: '700', fontSize: 13 },
canvas: { flex: 1 },
toolbar: { padding: 12, borderTopWidth: 1, borderTopColor: '#333', backgroundColor: '#0A0E1A' },
colorRow: { flexDirection: 'row', gap: 10, marginBottom: 10, justifyContent: 'center' },
colorBtn: { width: 28, height: 28, borderRadius: 14 },
widthRow: { flexDirection: 'row', gap: 16, justifyContent: 'center', alignItems: 'center' },
widthBtn: { padding: 8, borderRadius: 8 },
widthBtnActive: { backgroundColor: '#1A1F2E' },
widthDot: { backgroundColor: '#fff' },
});

View File

@ -0,0 +1,104 @@
import React, { useState, useCallback } from 'react'
import { View, Text, TouchableOpacity, FlatList, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { COLORS } from '../../constants/Config'
import client from '../../services/api'
const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']
export default function WorkCalendarScreen() {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [events, setEvents] = useState<any[]>([])
const [selected, setSelected] = useState<string>('')
const [loading, setLoading] = useState(false)
const load = useCallback(async (y: number, m: number) => {
setLoading(true)
try {
const r = await client.get('/api/mobile2/work-calendar', { params: { year: y, month: m + 1 } })
setEvents(r.data?.events ?? r.data?.items ?? [])
} catch { setEvents([]) } finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load(year, month) }, [year, month, load]))
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const cells = Array.from({ length: firstDay }, () => null).concat(Array.from({ length: daysInMonth }, (_, i) => i + 1))
const eventsOn = (day: number) => {
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return events.filter(e => (e.date ?? e.scheduled_at ?? '').startsWith(key))
}
const selectedEvents = selected ? events.filter(e => (e.date ?? e.scheduled_at ?? '').startsWith(selected)) : []
const prev = () => { if (month === 0) { setYear(y => y - 1); setMonth(11) } else setMonth(m => m - 1) }
const next = () => { if (month === 11) { setYear(y => y + 1); setMonth(0) } else setMonth(m => m + 1) }
return (
<View style={s.container}>
<View style={s.nav}>
<TouchableOpacity onPress={prev} style={s.navBtn}><Text style={s.navText}></Text></TouchableOpacity>
<Text style={s.monthLabel}>{year} {month + 1}</Text>
<TouchableOpacity onPress={next} style={s.navBtn}><Text style={s.navText}></Text></TouchableOpacity>
</View>
<View style={s.weekRow}>
{WEEKDAYS.map(d => <Text key={d} style={[s.weekDay, d === '일' ? { color: COLORS.danger } : d === '토' ? { color: COLORS.blue } : {}]}>{d}</Text>)}
</View>
<View style={s.grid}>
{cells.map((day, i) => {
if (!day) return <View key={i} style={s.cell} />
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const dayEvents = eventsOn(day)
const isToday = day === today.getDate() && month === today.getMonth() && year === today.getFullYear()
const isSel = key === selected
return (
<TouchableOpacity key={i} style={[s.cell, isToday && s.today, isSel && s.selCell]} onPress={() => setSelected(isSel ? '' : key)}>
<Text style={[s.dayNum, isToday && { color: '#fff' }]}>{day}</Text>
{dayEvents.length > 0 && <View style={s.dot} />}
</TouchableOpacity>
)
})}
</View>
{selectedEvents.length > 0 && (
<FlatList
data={selectedEvents}
keyExtractor={(_, i) => String(i)}
style={s.eventList}
renderItem={({ item }) => (
<View style={s.eventCard}>
<Text style={s.eventTitle}>{item.title ?? item.name}</Text>
<Text style={s.eventMeta}>{item.start_time ?? ''} · {item.category ?? item.type ?? ''}</Text>
</View>
)}
/>
)}
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
nav: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 12 },
navBtn: { padding: 8 },
navText: { fontSize: 16, color: COLORS.accent },
monthLabel: { fontSize: 18, fontWeight: '800', color: COLORS.text },
weekRow: { flexDirection: 'row', paddingHorizontal: 4 },
weekDay: { flex: 1, textAlign: 'center', fontSize: 11, fontWeight: '700', color: COLORS.muted, paddingVertical: 4 },
grid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 4 },
cell: { width: '14.28%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center', padding: 2 },
today: { backgroundColor: COLORS.accent, borderRadius: 20 },
selCell: { backgroundColor: COLORS.accent + '30', borderRadius: 20 },
dayNum: { fontSize: 13, color: COLORS.text },
dot: { width: 5, height: 5, borderRadius: 3, backgroundColor: COLORS.danger, marginTop: 2 },
eventList: { flex: 1, padding: 12 },
eventCard: { backgroundColor: '#fff', borderRadius: 8, padding: 12, marginBottom: 6, elevation: 1 },
eventTitle: { fontSize: 13, fontWeight: '700', color: COLORS.text, marginBottom: 2 },
eventMeta: { fontSize: 11, color: COLORS.muted },
})

View File

@ -1,8 +1,14 @@
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { AppState, AppStateStatus, View } from 'react-native'
import { Stack, useRouter, useSegments } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import { AuthContext, useAuthState } from '../hooks/useAuth'
import { isSessionExpired, recordActivity, clearSession } from '../hooks/useSessionExpiry'
import PinLock, { isPinEnabled } from '../components/PinLock'
import { ThemeProvider } from '../contexts/ThemeContext'
import { FontProvider } from '../contexts/FontContext'
import { OfflineProvider } from '../contexts/OfflineContext'
SplashScreen.preventAutoHideAsync()
@ -11,6 +17,10 @@ export default function RootLayout() {
const router = useRouter()
const segments = useSegments()
// #30 PIN 잠금 상태
const [locked, setLocked] = useState(false)
const appState = useRef<AppStateStatus>(AppState.currentState)
useEffect(() => {
if (auth.loading) return
SplashScreen.hideAsync()
@ -22,10 +32,68 @@ export default function RootLayout() {
}
}, [auth.loading, auth.token])
/* #31 세션 자동 만료 + #30 PIN 잠금 — AppState background→foreground 처리 */
useEffect(() => {
const onChange = async (next: AppStateStatus) => {
const prev = appState.current
appState.current = next
if (next === 'active' && prev.match(/inactive|background/)) {
// 포그라운드 복귀
if (auth.token) {
// #31 15분 초과 시 세션 종료
if (await isSessionExpired()) {
await clearSession()
await auth.logout()
setLocked(false)
router.replace('/(auth)/login')
return
}
// #30 PIN이 활성화되어 있으면 잠금 화면 표시
if (await isPinEnabled()) {
setLocked(true)
}
await recordActivity()
}
} else if (next.match(/inactive|background/)) {
// 백그라운드 진입 시 활동 시각 갱신
if (auth.token) await recordActivity()
}
}
const sub = AppState.addEventListener('change', onChange)
return () => sub.remove()
}, [auth.token])
const handleUnlock = async () => {
await recordActivity()
setLocked(false)
}
const handlePinFail = async () => {
// 5회 실패 → 세션 종료
await clearSession()
await auth.logout()
setLocked(false)
router.replace('/(auth)/login')
}
return (
<AuthContext.Provider value={auth}>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} />
</AuthContext.Provider>
<ThemeProvider>
<FontProvider>
<OfflineProvider>
<AuthContext.Provider value={auth}>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} />
{/* #30 PIN 잠금 오버레이 — 인증된 상태에서만 */}
{locked && auth.token && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9999 }}>
<PinLock mode="verify" onSuccess={handleUnlock} onFail={handlePinFail} />
</View>
)}
</AuthContext.Provider>
</OfflineProvider>
</FontProvider>
</ThemeProvider>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Some files were not shown because too many files have changed in this diff Show More