itsm_ysm/mobile/app/(tabs)/home.tsx
itms-merge-dev efc3e66635 feat(itms): 모바일 앱 신규 (Expo, 12화면, OAuth password grant)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:10:46 +09:00

161 lines
7.9 KiB
TypeScript

import { useCallback, useState } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native'
import { router, useFocusEffect } from 'expo-router'
import { Feather } from '@expo/vector-icons'
import { useTheme } from '../../constants/ThemeContext'
import { type ThemeTokens } from '../../constants/ItmsTheme'
import { StatusPill, SectionTitle } from '../../components/ui'
import { rowTitle, rowStatus, rowDate, reqIncidentNum } from '../../constants/rows'
import {
itmsProfile, itmsMyIncidents, itmsMyReports, itmsMyTodo, itmsResourceStat, listRows,
type ItmsProfile, type RowMap,
} from '../itmsApi'
export default function Home() {
const { t } = useTheme()
const s = makeStyles(t)
const [me, setMe] = useState<ItmsProfile | null>(null)
const [incidents, setIncidents] = useState<RowMap[]>([])
const [reports, setReports] = useState<RowMap[]>([])
const [todo, setTodo] = useState<RowMap[]>([])
const [statRows, setStatRows] = useState<RowMap[]>([])
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
// 각 호출 독립 — 하나 실패해도 나머지 표시(서버 미기동 시 빈 상태).
try { setMe(await itmsProfile()) } catch { /* graceful */ }
try { setIncidents(listRows(await itmsMyIncidents())) } catch { setIncidents([]) }
try { setReports(listRows(await itmsMyReports())) } catch { setReports([]) }
try { setTodo(listRows(await itmsMyTodo())) } catch { setTodo([]) }
try { setStatRows(listRows(await itmsResourceStat())) } catch { setStatRows([]) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const onRefresh = useCallback(async () => { setRefreshing(true); await load(); setRefreshing(false) }, [load])
const kpis = [
{ key: 'todo', label: '내 할 일', count: todo.length, color: t.brand, icon: 'check-square' as const },
{ key: 'inc', label: '내 인시던트', count: incidents.length, color: t.info, icon: 'alert-circle' as const },
{ key: 'rep', label: '내 보고', count: reports.length, color: t.warning, icon: 'file-text' as const },
]
const quick = [
{ key: 'new', icon: 'plus-circle' as const, label: '요청 등록', go: () => router.push('/request-new') },
{ key: 'req', icon: 'file-text' as const, label: '내 요청', go: () => router.push('/requests') },
{ key: 'inc', icon: 'alert-circle' as const, label: '인시던트', go: () => router.push('/incidents') },
{ key: 'asset', icon: 'server' as const, label: '자산 조회', go: () => router.push('/assets') },
]
return (
<ScrollView
style={s.wrap} contentContainerStyle={s.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={t.accent} />}
>
<View style={s.header}>
<View>
<Text style={s.greeting}>,</Text>
<Text style={s.userName}>{me?.userNm || 'ITMS'} </Text>
</View>
<TouchableOpacity onPress={() => router.push('/notices')} style={s.iconBtn}>
<Feather name="bell" size={20} color={t.text} />
</TouchableOpacity>
</View>
{/* KPI 3분할 */}
<View style={s.kpiRow}>
{kpis.map((k) => (
<View key={k.key} style={s.kpiTile}>
<View style={[s.kpiIcon, { backgroundColor: k.color + '1e' }]}>
<Feather name={k.icon} size={16} color={k.color} />
</View>
<Text style={[s.kpiCount, { color: k.color }]}>{k.count}</Text>
<Text style={s.kpiLbl} numberOfLines={1}>{k.label}</Text>
</View>
))}
</View>
{/* 자산 요약 통계(있을 때만) */}
{statRows.length > 0 && (
<View style={s.statCard}>
<SectionTitle> </SectionTitle>
{statRows.slice(0, 6).map((r, i) => (
<View key={i} style={s.statRow}>
<Text style={s.statLbl} numberOfLines={1}>{rowTitle(r)}</Text>
<Text style={s.statVal}>{r.cnt ?? r.totCnt ?? r.count ?? r.value ?? '-'}</Text>
</View>
))}
</View>
)}
{/* 퀵액션 */}
<View style={s.quickGrid}>
{quick.map((q) => (
<TouchableOpacity key={q.key} style={s.quickCell} onPress={q.go}>
<View style={s.quickIcon}><Feather name={q.icon} size={22} color={t.brand} /></View>
<Text style={s.quickLbl}>{q.label}</Text>
</TouchableOpacity>
))}
</View>
{/* 내 할 일 */}
<SectionTitle style={s.mt}> ({todo.length})</SectionTitle>
{todo.length === 0 ? (
<Text style={s.empty}> .</Text>
) : todo.slice(0, 5).map((r, i) => (
<View key={i} style={s.rowCard}>
<View style={{ flex: 1 }}>
<Text style={s.rowTitle} numberOfLines={1}>{rowTitle(r)}</Text>
<Text style={s.rowSub}>{rowDate(r)}</Text>
</View>
<StatusPill label={rowStatus(r)} />
</View>
))}
{/* 내 인시던트 */}
<SectionTitle style={s.mt}> ({incidents.length})</SectionTitle>
{incidents.length === 0 ? (
<Text style={s.empty}> .</Text>
) : incidents.slice(0, 5).map((r, i) => (
<TouchableOpacity
key={i} style={s.rowCard}
onPress={() => router.push({ pathname: '/request-detail', params: { num: reqIncidentNum(r), raw: JSON.stringify(r) } })}
>
<View style={{ flex: 1 }}>
<Text style={s.rowTitle} numberOfLines={1}>{rowTitle(r)}</Text>
<Text style={s.rowSub}>{rowDate(r)}</Text>
</View>
<StatusPill label={rowStatus(r)} />
</TouchableOpacity>
))}
<View style={{ height: 24 }} />
</ScrollView>
)
}
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
content: { padding: 16 },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, marginTop: 8 },
greeting: { color: t.muted, fontSize: 13 },
userName: { color: t.text, fontSize: 20, fontWeight: '900', marginTop: 2 },
iconBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: t.card, borderWidth: 1, borderColor: t.border, justifyContent: 'center', alignItems: 'center' },
kpiRow: { flexDirection: 'row', gap: 10, marginBottom: 14 },
kpiTile: { flex: 1, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, paddingVertical: 14, alignItems: 'center', gap: 4 },
kpiIcon: { width: 30, height: 30, borderRadius: 15, justifyContent: 'center', alignItems: 'center', marginBottom: 2 },
kpiCount: { fontSize: 22, fontWeight: '900' },
kpiLbl: { color: t.muted, fontSize: 11, fontWeight: '600' },
statCard: { backgroundColor: t.card, borderRadius: 14, borderWidth: 1, borderColor: t.border, padding: 16, marginBottom: 14 },
statRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 6 },
statLbl: { color: t.muted, fontSize: 13, flex: 1, marginRight: 12 },
statVal: { color: t.text, fontSize: 14, fontWeight: '800' },
quickGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
quickCell: { width: '47.6%', backgroundColor: t.card, borderRadius: 14, borderWidth: 1, borderColor: t.border, paddingVertical: 18, alignItems: 'center', gap: 8 },
quickIcon: { width: 44, height: 44, borderRadius: 22, backgroundColor: t.brand + '18', justifyContent: 'center', alignItems: 'center' },
quickLbl: { color: t.text, fontSize: 13, fontWeight: '700' },
mt: { marginTop: 22 },
empty: { color: t.muted, fontSize: 13, paddingVertical: 12 },
rowCard: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, padding: 13, marginBottom: 8 },
rowTitle: { color: t.text, fontSize: 14, fontWeight: '700' },
rowSub: { color: t.muted, fontSize: 12, marginTop: 2 },
})