203 lines
9.5 KiB
TypeScript
203 lines
9.5 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'
|
|
import LineIcon from '../../components/LineIcon'
|
|
|
|
type IconName = Parameters<typeof LineIcon>[0]['name']
|
|
|
|
const TYPE_ICON_NAME: Record<string, IconName> = {
|
|
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<string, string> = {
|
|
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<string, string> = {}
|
|
|
|
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}>
|
|
<View style={s.wsIcon}>
|
|
<LineIcon name={TYPE_ICON_NAME[wsEvents[0]?.event_type ?? ''] ?? 'bell'} size={20} color={COLORS.accent} />
|
|
</View>
|
|
<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}>
|
|
<View style={{ width: 60, height: 60, borderRadius: 30, backgroundColor: 'rgba(0,160,200,.1)',
|
|
alignItems: 'center', justifyContent: 'center' }}>
|
|
<LineIcon name="bell" size={32} color={COLORS.accent} />
|
|
</View>
|
|
<Text style={s.emptyT}>알림이 없습니다.</Text>
|
|
<Text style={s.emptyH}>SR 등록 또는 배포 실행 시{'\n'}알림이 표시됩니다.</Text>
|
|
</View>
|
|
)}
|
|
{items.map(n => {
|
|
const iconName = TYPE_ICON_NAME[n.type] ?? TYPE_ICON_NAME.DEFAULT
|
|
const iconColor = TYPE_COLOR[n.type] ?? '#64748b'
|
|
return (
|
|
<TouchableOpacity key={String(n.id)} style={[s.item, !n.is_read && s.unread]} onPress={() => markRead(n.id)}>
|
|
<View style={[s.iconBox, { backgroundColor: iconColor + '18' }]}>
|
|
<LineIcon name={iconName} size={20} color={iconColor} />
|
|
{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' },
|
|
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 },
|
|
})
|