guardia-messenger/components/ApprovalStages.tsx

118 lines
4.9 KiB
TypeScript

import { useEffect, useState } from 'react'
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'
import { COLORS } from '../constants/Config'
import { getApprovalStages } from '../services/api'
/**
* 기능 #65 — 다단계 승인 진행 시각화
* - GET /api/approvals/{id}/stages
* - 단계별 타임라인: 단계명, 승인자, 상태, 시각
* - 완료(초록 체크) / 현재(파란 점, 하이라이트) / 대기(회색 원) / 반려(빨강)
*/
export interface ApprovalStage {
level: number
name?: string
approver: string
status: 'approved' | 'pending' | 'rejected' | 'current' | 'waiting'
acted_at?: string | null
}
interface Props {
approvalId: string | number
/** 이미 보유한 단계 데이터가 있으면 fetch 생략 */
stages?: ApprovalStage[]
}
const STATUS_META: Record<string, { color: string; mark: string; label: string }> = {
approved: { color: COLORS.success, mark: '✓', label: '승인' },
rejected: { color: COLORS.danger, mark: '✕', label: '반려' },
current: { color: COLORS.accent, mark: '●', label: '진행중' },
pending: { color: '#cbd5e1', mark: '○', label: '대기' },
waiting: { color: '#cbd5e1', mark: '○', label: '대기' },
}
export default function ApprovalStages({ approvalId, stages: preset }: Props) {
const [stages, setStages] = useState<ApprovalStage[]>(preset ?? [])
const [loading, setLoading] = useState(!preset)
useEffect(() => {
if (preset) { setStages(preset); return }
let alive = true
;(async () => {
setLoading(true)
try {
const r = await getApprovalStages(approvalId)
if (alive) setStages(r.data?.stages ?? r.data ?? [])
} catch { /* 무시 */ }
finally { if (alive) setLoading(false) }
})()
return () => { alive = false }
}, [approvalId, preset])
if (loading) return <ActivityIndicator color={COLORS.accent} style={{ paddingVertical: 16 }} />
if (!stages.length) return <Text style={s.empty}> .</Text>
return (
<View>
{stages.map((st, idx) => {
const meta = STATUS_META[st.status] ?? STATUS_META.pending
const isCurrent = st.status === 'current'
const isLast = idx === stages.length - 1
return (
<View key={`${st.level}-${idx}`} style={s.row}>
<View style={s.gutter}>
<View style={[s.node, { borderColor: meta.color },
(st.status === 'approved' || st.status === 'rejected' || isCurrent) &&
{ backgroundColor: meta.color }]}>
<Text style={[s.nodeMark,
(st.status === 'pending' || st.status === 'waiting') && { color: meta.color }]}>
{meta.mark}
</Text>
</View>
{!isLast && <View style={s.line} />}
</View>
<View style={[s.content, isCurrent && s.contentCurrent]}>
<View style={s.head}>
<Text style={s.stageName}>
{st.level} · {st.name ?? `승인 ${st.level}`}
</Text>
<View style={[s.badge, { backgroundColor: meta.color }]}>
<Text style={s.badgeText}>{meta.label}</Text>
</View>
</View>
<Text style={s.approver}>: {st.approver}</Text>
{!!st.acted_at && <Text style={s.time}>{formatTime(st.acted_at)}</Text>}
</View>
</View>
)
})}
</View>
)
}
function formatTime(ts: string): string {
try {
const d = new Date(ts)
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
} catch { return ts }
}
const s = StyleSheet.create({
empty: { textAlign: 'center', color: COLORS.muted, paddingVertical: 16, fontSize: 12 },
row: { flexDirection: 'row' },
gutter: { width: 30, alignItems: 'center' },
node: { width: 24, height: 24, borderRadius: 12, borderWidth: 2,
alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff' },
nodeMark: { fontSize: 12, fontWeight: '800', color: '#fff' },
line: { flex: 1, width: 2, backgroundColor: COLORS.border, marginVertical: 2 },
content: { flex: 1, paddingBottom: 16, paddingLeft: 8 },
contentCurrent: { backgroundColor: COLORS.light, borderRadius: 8, padding: 8, marginLeft: 4, marginBottom: 12 },
head: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
stageName: { fontSize: 13, fontWeight: '700', color: COLORS.text, flex: 1 },
badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 9 },
badgeText: { fontSize: 10, fontWeight: '700', color: '#fff' },
approver: { fontSize: 12, color: COLORS.blue, marginTop: 3 },
time: { fontSize: 11, color: COLORS.muted, marginTop: 2 },
})