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' import LineIcon from '../../components/LineIcon' type IconName = Parameters[0]['name'] const TYPE_ICON_NAME: Record = { SR_CREATED: 'sr', SR_UPDATED: 'sync', SR_COMPLETED: 'check', INCIDENT: 'alert', SLA_BREACH: 'bell', DEPLOY_SUCCESS: 'zap', DEPLOY_FAIL:'alert', LICENSE: 'lock', SYSTEM: 'settings', DEFAULT: 'bell', sr_created: 'sr', sr_updated: 'sync', sr_completed: 'check', deploy_notify: 'zap', incident_created: 'alert', sla_breach: 'bell', anomaly_alert: 'alert', batch_notify: 'settings', } /* 하위 호환: 이모지 대신 타입 색상 */ const TYPE_COLOR: Record = { SR_CREATED: '#00A0C8', SR_UPDATED: '#f59e0b', SR_COMPLETED: '#22c55e', INCIDENT: '#ef4444', SLA_BREACH: '#f59e0b', DEPLOY_SUCCESS: '#22c55e', DEPLOY_FAIL: '#ef4444', DEFAULT: '#64748b', sr_created: '#00A0C8', sr_updated: '#f59e0b', sr_completed: '#22c55e', deploy_notify: '#22c55e', incident_created: '#ef4444', } /** 이전 코드와의 호환을 위한 빈 맵 (미사용) */ const TYPE_ICON: Record = {} 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)[t] ?? t const fmtMsg = (evt: { event_type:string; data:Record }) => { 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([]) 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 ( 알림 {connected ? 'GUARDiA 실시간 연결' : '연결 중...'} {unread>0 && {unread}} {wsEvents.length>0 && wsEvents[0] && ( 실시간: {fmtTitle(wsEvents[0]?.event_type ?? '')} {fmtMsg(wsEvents[0])} )} load(true)} />}> {!loading && items.length===0 && ( 알림이 없습니다. SR 등록 또는 배포 실행 시{'\n'}알림이 표시됩니다. )} {items.map(n => { const iconName = TYPE_ICON_NAME[n.type] ?? TYPE_ICON_NAME.DEFAULT const iconColor = TYPE_COLOR[n.type] ?? '#64748b' return ( markRead(n.id)}> {n.source==='ws' && } {n.title} {n.message} {new Date(n.created_at).toLocaleString('ko-KR')} {n.source==='ws' && 실시간} {!n.is_read && } ) })} ) } 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' }, iconBox: { width:40, height:40, borderRadius:10, alignItems:'center', justifyContent:'center', position:'relative', flexShrink:0 }, 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 }, })