118 lines
4.9 KiB
TypeScript
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 },
|
|
})
|