import { useEffect, useState } from 'react' import { getTimeline, type Org, type TimelineEvent } from '../api/client' import { useStages, type Stages } from '../codes/stage' import StatusChip from '../ui/StatusChip' import StatusGlyph, { type GlyphKind } from '../ui/StatusGlyph' const dateFormatter = new Intl.DateTimeFormat('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', }) const timeFormatter = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, }) function dateParts(at: number): { year: string; month: string; day: string; weekday: string } { const parts = dateFormatter.formatToParts(new Date(at)) const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '' return { year: get('year'), month: get('month'), day: get('day'), weekday: get('weekday') } } function dateKey(at: number): string { const { year, month, day } = dateParts(at) return `${year}-${month}-${day}` } function dateHeader(at: number): string { const { year, month, day, weekday } = dateParts(at) return `${year}-${month}-${day} (${weekday})` } function formatTime(at: number): string { return timeFormatter.format(new Date(at)) } interface EventGroup { key: string header: string items: TimelineEvent[] } function groupByDate(events: TimelineEvent[]): EventGroup[] { const groups: EventGroup[] = [] for (const event of events) { const key = dateKey(event.at) const current = groups[groups.length - 1] if (current && current.key === key) { current.items.push(event) } else { groups.push({ key, header: dateHeader(event.at), items: [event] }) } } return groups } /** * 이벤트 종류별 색·글리프. 이모지를 쓰지 않는 이유는 두 가지다. 하나는 OS마다 그림이 달라 * 같은 화면이 사람마다 다르게 보인다는 것, 다른 하나는 둥근 이모지가 이 화면의 각진 상태 * 태그와 어울리지 않는다는 것이다. 대신 같은 글리프 세트를 쓴다. */ const EVENT_META: Record< TimelineEvent['type'], { dot: string; glyph: GlyphKind; badge: string; tone: string } > = { STAGE: { dot: 'bg-blue-600', glyph: 'progress', badge: '단계 변경', tone: 'blue' }, FILE: { dot: 'bg-amber-500', glyph: 'document', badge: '자료 등록', tone: 'amber' }, MEMO: { dot: 'bg-emerald-600', glyph: 'note', badge: '업무메모', tone: 'emerald' }, } /** STAGE 이벤트의 title(단계 번호 문자열)을 "5. 법률검토(권리확인) 진행" 형태로 바꾼다. */ function stageEventText(title: string, stages: Stages): string { const n = Number(title) if (!Number.isInteger(n)) { return `${title} 단계로 진행` } return `${n}. ${stages.label(n)} 단계로 진행` } function EventBody({ event }: { event: TimelineEvent }) { const stages = useStages() if (event.type === 'STAGE') { return

{stageEventText(event.title, stages)}

} if (event.type === 'FILE') { return (

{event.title} {event.detail && {event.detail}}

) } return (

{event.title} {event.detail && {event.detail}}

) } /** 기관관리 상세의 타임라인 탭. 단계 변경 이력·업무메모·채널 첨부파일 등록을 하나의 시간순 * 목록으로 합쳐 보여준다(조회는 GET /api/orgs/{id}/timeline 한 번뿐이고 폴링은 없다). */ export default function Timeline({ org }: { org: Org }) { const [events, setEvents] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [reloadKey, setReloadKey] = useState(0) useEffect(() => { let cancelled = false setLoading(true) setError(null) getTimeline(org.id) .then((data) => { if (!cancelled) { setEvents(data) } }) .catch((e: Error) => { if (!cancelled) { setError(e.message) } }) .finally(() => { if (!cancelled) { setLoading(false) } }) return () => { cancelled = true } }, [org.id, reloadKey]) const groups = groupByDate(events) return (
{loading &&

불러오는 중…

} {!loading && error &&

{error}

} {!loading && !error && events.length === 0 && (

아직 기록이 없습니다. 채널을 만들고 업무를 진행하면 여기에 쌓입니다.

)} {!loading && !error && events.length > 0 && (
{groups.map((group) => (

{group.header}

    {group.items.map((event, i) => { const meta = EVENT_META[event.type] return (
  1. {formatTime(event.at)}
  2. ) })}
))}
)}
) }