fix: guardia-messenger 프로젝트 경로 정상화 (iamConductor-messenger → guardia-messenger) [auto-sync]
@ -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 },
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
76
app/(tabs)/accessibility.tsx
Normal 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
@ -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' },
|
||||
});
|
||||
74
app/(tabs)/ai_briefing.tsx
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 },
|
||||
});
|
||||
72
app/(tabs)/automation_rules.tsx
Normal 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 },
|
||||
})
|
||||
113
app/(tabs)/autonomous_ops.tsx
Normal 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
@ -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
@ -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' },
|
||||
});
|
||||
80
app/(tabs)/capacity_plan.tsx
Normal 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 },
|
||||
})
|
||||
117
app/(tabs)/change_calendar.tsx
Normal 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 },
|
||||
})
|
||||
@ -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 },
|
||||
})
|
||||
|
||||
71
app/(tabs)/citizen_requests.tsx
Normal 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 },
|
||||
})
|
||||
87
app/(tabs)/cost_advice.tsx
Normal 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
@ -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' },
|
||||
});
|
||||
79
app/(tabs)/csap_audit_prep.tsx
Normal 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' },
|
||||
})
|
||||
106
app/(tabs)/csap_dashboard.tsx
Normal 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
@ -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
@ -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 },
|
||||
})
|
||||
68
app/(tabs)/dependency_map.tsx
Normal 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 },
|
||||
})
|
||||
72
app/(tabs)/deploy_history.tsx
Normal 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
@ -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
@ -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
@ -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 },
|
||||
})
|
||||
64
app/(tabs)/failure_prediction.tsx
Normal 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
@ -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 },
|
||||
})
|
||||
110
app/(tabs)/greenops_dashboard.tsx
Normal 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 },
|
||||
})
|
||||
78
app/(tabs)/health_scorecard.tsx
Normal 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' },
|
||||
})
|
||||
78
app/(tabs)/hw_warranty.tsx
Normal 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 },
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
|
||||
72
app/(tabs)/institution_compare.tsx
Normal 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
@ -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 },
|
||||
})
|
||||
91
app/(tabs)/jenkins_builds.tsx
Normal 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
@ -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
@ -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 },
|
||||
})
|
||||
67
app/(tabs)/kpi_dashboard.tsx
Normal 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 },
|
||||
})
|
||||
81
app/(tabs)/maintenance_window.tsx
Normal 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
@ -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 },
|
||||
})
|
||||
71
app/(tabs)/meeting_minutes.tsx
Normal 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
@ -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
@ -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
@ -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
@ -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 },
|
||||
})
|
||||
67
app/(tabs)/narasajang_status.tsx
Normal 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 },
|
||||
})
|
||||
376
app/(tabs)/narasajang_sw.tsx
Normal 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
@ -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' },
|
||||
})
|
||||
@ -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
@ -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 },
|
||||
});
|
||||
81
app/(tabs)/ollama_status.tsx
Normal 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
@ -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
@ -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
@ -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 },
|
||||
})
|
||||
59
app/(tabs)/policy_alerts.tsx
Normal 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 },
|
||||
})
|
||||
105
app/(tabs)/predictive_alert.tsx
Normal 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
@ -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
@ -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 },
|
||||
})
|
||||
105
app/(tabs)/quick_command.tsx
Normal 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 },
|
||||
});
|
||||
71
app/(tabs)/recent_screens.tsx
Normal 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 },
|
||||
})
|
||||
87
app/(tabs)/release_notes.tsx
Normal 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
@ -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 },
|
||||
})
|
||||
76
app/(tabs)/security_score.tsx
Normal 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
@ -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 },
|
||||
});
|
||||
189
app/(tabs)/server_dashboard.tsx
Normal 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' },
|
||||
})
|
||||
116
app/(tabs)/sla_exception.tsx
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 },
|
||||
})
|
||||
66
app/(tabs)/team_leaderboard.tsx
Normal 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 },
|
||||
})
|
||||
106
app/(tabs)/theme_settings.tsx
Normal 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 },
|
||||
})
|
||||
70
app/(tabs)/threat_feed.tsx
Normal 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
@ -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
@ -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
@ -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' },
|
||||
});
|
||||
104
app/(tabs)/work_calendar.tsx
Normal 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 },
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
assets/icons/guardia/brand-1/original_16.png
Normal file
|
After Width: | Height: | Size: 910 B |
BIN
assets/icons/guardia/brand-1/original_24.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/icons/guardia/brand-1/original_32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
assets/icons/guardia/brand-1/original_48.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
assets/icons/guardia/brand-1/original_64.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
assets/icons/guardia/brand-2/original_16.png
Normal file
|
After Width: | Height: | Size: 440 B |
BIN
assets/icons/guardia/brand-2/original_24.png
Normal file
|
After Width: | Height: | Size: 725 B |
BIN
assets/icons/guardia/brand-2/original_32.png
Normal file
|
After Width: | Height: | Size: 993 B |
BIN
assets/icons/guardia/brand-2/original_48.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/icons/guardia/brand-2/original_64.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/icons/guardia/brand-3/original_16.png
Normal file
|
After Width: | Height: | Size: 474 B |
BIN
assets/icons/guardia/brand-3/original_24.png
Normal file
|
After Width: | Height: | Size: 878 B |
BIN
assets/icons/guardia/brand-3/original_32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/guardia/brand-3/original_48.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/icons/guardia/brand-3/original_64.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |