File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import { useEffect, useState } from 'react'
import { getTimeline, type Org, type TimelineEvent } from '../api/client'
import { useStages, type Stages } from '../codes/stage'
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
}
const EVENT_META: Record<TimelineEvent['type'], { dot: string; icon: string; badge: string; badgeClass: string }> = {
STAGE: { dot: 'bg-blue-600', icon: '🚩', badge: '단계 변경', badgeClass: 'bg-blue-50 text-blue-700' },
FILE: { dot: 'bg-amber-500', icon: '📎', badge: '자료 등록', badgeClass: 'bg-amber-50 text-amber-700' },
MEMO: { dot: 'bg-emerald-600', icon: '✏️', badge: '업무메모', badgeClass: 'bg-emerald-50 text-emerald-700' },
}
/** 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 <p className="text-sm font-medium text-gray-900">{stageEventText(event.title, stages)}</p>
}
if (event.type === 'FILE') {
return (
<p className="text-sm text-gray-900">
<span className="font-semibold">{event.title}</span>
{event.detail && <span className="ml-2 text-xs text-gray-400">{event.detail}</span>}
</p>
)
}
return (
<p className="text-sm text-gray-900">
<span className="font-semibold">{event.title}</span>
{event.detail && <span className="ml-2 text-xs text-gray-500">{event.detail}</span>}
</p>
)
}
/** 기관관리 상세의 타임라인 탭. 단계 변경 이력·업무메모·채널 첨부파일 등록을 하나의 시간순
* 목록으로 합쳐 보여준다(조회는 GET /api/orgs/{id}/timeline 한 번뿐이고 폴링은 없다). */
export default function Timeline({ org }: { org: Org }) {
const [events, setEvents] = useState<TimelineEvent[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="p-4">
<div className="flex justify-end">
<button
type="button"
onClick={() => setReloadKey((k) => k + 1)}
className="rounded-md border border-gray-300 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50"
>
새로고침
</button>
</div>
{loading && <p className="py-6 text-center text-sm text-gray-400">불러오는 중…</p>}
{!loading && error && <p className="py-4 text-center text-sm text-red-600">{error}</p>}
{!loading && !error && events.length === 0 && (
<p className="py-10 text-center text-sm text-gray-300">
아직 기록이 없습니다. 채널을 만들고 업무를 진행하면 여기에 쌓입니다.
</p>
)}
{!loading && !error && events.length > 0 && (
<div className="mt-3 space-y-6">
{groups.map((group) => (
<section key={group.key}>
<h3 className="text-xs font-semibold text-gray-500">{group.header}</h3>
<ol className="mt-2 space-y-4 border-l-2 border-gray-200 pl-5">
{group.items.map((event, i) => {
const meta = EVENT_META[event.type]
return (
<li key={`${group.key}-${i}`} className="relative">
<span
aria-hidden="true"
className={`absolute -left-[1.65rem] flex h-5 w-5 items-center justify-center rounded-full text-[10px] leading-none text-white ${meta.dot}`}
>
{meta.icon}
</span>
<div className="flex items-baseline gap-2">
<span className="text-xs tabular-nums text-gray-400">{formatTime(event.at)}</span>
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${meta.badgeClass}`}>
{meta.badge}
</span>
</div>
<div className="mt-1">
<EventBody event={event} />
</div>
</li>
)
})}
</ol>
</section>
))}
</div>
)}
</div>
)
}