guardia-messenger/app/(tabs)/notifications.tsx
DESKTOP-TKLFCPRython f29f525c77 refactor: 101.79.17.164 → zioinfo.co.kr 전체 도메인 변환 + Manager UI 배포
- 37개 파일 IP → zioinfo.co.kr 치환 (소스/매뉴얼/설정/하네스)
- Manager DrConsole/NetworkConsole/CsapConsole 빌드 + /var/www/manager/ 배포
- 테스트: Manager HTTP 200, ITSM 신규 API 7개 전체 200

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:09:17 +09:00

176 lines
8.1 KiB
TypeScript

import { useEffect, useState, useRef } from 'react'
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, RefreshControl, Animated } from 'react-native'
import { COLORS } from '../../constants/Config'
import { getNotifications, markNotificationRead } from '../../services/api'
import { useWebSocket } from '../../hooks/useWebSocket'
const TYPE_ICON: Record<string, string> = {
SR_CREATED:'📋', SR_UPDATED:'🔄', SR_COMPLETED:'✅',
INCIDENT:'🚨', SLA_BREACH:'⏰', DEPLOY_SUCCESS:'🚀',
DEPLOY_FAIL:'❌', LICENSE:'🔑', SYSTEM:'⚙️', DEFAULT:'🔔',
sr_created:'📋', sr_updated:'🔄', sr_completed:'✅',
deploy_notify:'🚀', incident_created:'🚨', sla_breach:'⏰',
anomaly_alert:'⚠️', batch_notify:'⚙️',
}
interface NotifItem {
id: string | number; type: string; title: string
message: string; is_read: boolean; created_at: string
source: 'api' | 'ws'
}
const SAMPLE: NotifItem[] = [
{ id:'s1', type:'SR_CREATED', title:'[샘플] SR 신규 접수',
message:'서버 재시작 요청이 접수되었습니다.',
is_read:false, created_at:new Date().toISOString(), source:'api' },
{ id:'s2', type:'DEPLOY_SUCCESS', title:'[샘플] 배포 완료',
message:'zioinfo-web 배포가 완료되었습니다.',
is_read:true, created_at:new Date(Date.now()-3600000).toISOString(), source:'api' },
]
const fmtTitle = (t: string) => ({
sr_created:'SR 신규 접수', sr_updated:'SR 상태 변경', sr_completed:'SR 완료',
deploy_notify:'배포 알림', incident_created:'인시던트 발생', sla_breach:'SLA 위반 경고',
SR_CREATED:'SR 신규 접수', INCIDENT:'인시던트', SLA_BREACH:'SLA 위반',
DEPLOY_SUCCESS:'배포 성공', DEPLOY_FAIL:'배포 실패',
} as Record<string,string>)[t] ?? t
const fmtMsg = (evt: { event_type:string; data:Record<string,unknown> }) => {
const d = evt.data ?? {}
if (d.title) return String(d.title)
if (d.message) return String(d.message)
if (d.sr_id) return `SR ${d.sr_id}: ${d.status ?? ''}`
return `${evt.event_type} 이벤트 수신`
}
export default function NotificationsScreen() {
const [items, setItems] = useState<NotifItem[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const pulseAnim = useRef(new Animated.Value(1)).current
const { connected, lastEvent, events: wsEvents } = useWebSocket(['sr','deploy','sla','incident'])
useEffect(() => {
if (connected) {
Animated.loop(Animated.sequence([
Animated.timing(pulseAnim, { toValue:0.4, duration:900, useNativeDriver:true }),
Animated.timing(pulseAnim, { toValue:1.0, duration:900, useNativeDriver:true }),
])).start()
} else { pulseAnim.setValue(1) }
}, [connected])
useEffect(() => {
if (!lastEvent) return
const item: NotifItem = {
id:`ws-${Date.now()}`, type:lastEvent.event_type,
title:fmtTitle(lastEvent.event_type), message:fmtMsg(lastEvent),
is_read:false, created_at:(lastEvent.timestamp as string) || new Date().toISOString(),
source:'ws',
}
setItems(prev => [item, ...prev])
}, [lastEvent])
const load = async (r = false) => {
r ? setRefresh(true) : setLoading(true)
try {
const res = await getNotifications()
const api: NotifItem[] = (res.data.content ?? res.data.items ?? res.data ?? [])
.map((n: any) => ({ ...n, source:'api' as const }))
setItems(prev => [...prev.filter(i => i.source==='ws'), ...api])
} catch { if (!r) setItems(prev => prev.length===0 ? SAMPLE : prev) }
finally { setLoading(false); setRefresh(false) }
}
useEffect(() => { load() }, [])
const markRead = async (id: string | number) => {
setItems(prev => prev.map(n => n.id===id ? {...n, is_read:true} : n))
if (!String(id).startsWith('ws-')) try { await markNotificationRead(Number(id)) } catch {}
}
const unread = items.filter(n => !n.is_read).length
return (
<View style={{ flex:1, backgroundColor:COLORS.bg }}>
<View style={s.header}>
<View>
<Text style={s.title}></Text>
<View style={s.wsRow}>
<Animated.View style={[s.dot, { opacity:pulseAnim, backgroundColor:connected?COLORS.success:'#94a3b8' }]} />
<Text style={[s.wsLabel, { color:connected?COLORS.success:'#94a3b8' }]}>
{connected ? 'GUARDiA 실시간 연결' : '연결 중...'}
</Text>
</View>
</View>
{unread>0 && <View style={s.badge}><Text style={s.badgeText}>{unread}</Text></View>}
</View>
{wsEvents.length>0 && wsEvents[0] && (
<View style={s.wsBanner}>
<Text style={s.wsIcon}>{TYPE_ICON[wsEvents[0]?.event_type] ?? '⚡'}</Text>
<View style={{ flex:1 }}>
<Text style={s.wsBannerT}> : {fmtTitle(wsEvents[0]?.event_type ?? '')}</Text>
<Text style={s.wsBannerM} numberOfLines={1}>{fmtMsg(wsEvents[0])}</Text>
</View>
</View>
)}
<ScrollView refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} />}>
{!loading && items.length===0 && (
<View style={s.empty}>
<Text style={{ fontSize:48 }}>🔔</Text>
<Text style={s.emptyT}> .</Text>
<Text style={s.emptyH}>SR {'\n'} .</Text>
</View>
)}
{items.map(n => (
<TouchableOpacity key={String(n.id)} style={[s.item, !n.is_read && s.unread]} onPress={() => markRead(n.id)}>
<View>
<Text style={s.icon}>{TYPE_ICON[n.type] ?? TYPE_ICON.DEFAULT}</Text>
{n.source==='ws' && <View style={s.wsPin} />}
</View>
<View style={{ flex:1 }}>
<Text style={[s.iT, !n.is_read && { fontWeight:'700' }]}>{n.title}</Text>
<Text style={s.iM} numberOfLines={2}>{n.message}</Text>
<View style={{ flexDirection:'row', gap:8, marginTop:4 }}>
<Text style={s.iTime}>{new Date(n.created_at).toLocaleString('ko-KR')}</Text>
{n.source==='ws' && <Text style={[s.iTime,{color:COLORS.accent}]}> </Text>}
</View>
</View>
{!n.is_read && <View style={s.readDot} />}
</TouchableOpacity>
))}
<View style={{ height:24 }} />
</ScrollView>
</View>
)
}
const s = StyleSheet.create({
header: { flexDirection:'row', alignItems:'center', justifyContent:'space-between',
padding:16, backgroundColor:'#fff', borderBottomWidth:1, borderBottomColor:COLORS.border },
title: { fontSize:17, fontWeight:'700', color:COLORS.text },
wsRow: { flexDirection:'row', alignItems:'center', gap:5, marginTop:3 },
dot: { width:7, height:7, borderRadius:4 },
wsLabel: { fontSize:11, fontWeight:'500' },
badge: { backgroundColor:COLORS.danger, borderRadius:10, paddingHorizontal:7, paddingVertical:2 },
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 },
wsBannerT: { fontSize:12, fontWeight:'700', color:COLORS.accent },
wsBannerM: { fontSize:11, color:COLORS.muted },
item: { flexDirection:'row', backgroundColor:'#fff', padding:14,
borderBottomWidth:1, borderBottomColor:'#f1f5f9', gap:10, alignItems:'flex-start' },
unread: { backgroundColor:'#f8f9ff' },
icon: { fontSize:24, marginTop:2 },
wsPin: { position:'absolute', top:-2, right:-4, width:8, height:8, borderRadius:4, backgroundColor:COLORS.accent },
iT: { fontSize:14, color:COLORS.text },
iM: { fontSize:12, color:COLORS.muted, marginTop:3, lineHeight:17 },
iTime: { fontSize:11, color:COLORS.muted },
readDot: { width:8, height:8, borderRadius:4, backgroundColor:COLORS.accent, marginTop:6 },
empty: { alignItems:'center', marginTop:60, gap:10, paddingHorizontal:32 },
emptyT: { fontSize:15, fontWeight:'600', color:COLORS.muted },
emptyH: { fontSize:12, color:COLORS.muted, textAlign:'center', lineHeight:18 },
})