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, useMemo, useState } from 'react'
import { getDashboard, type DashboardData, type DashboardOrgRow, type DistributionItem } from '../api/client'
type Mode = 'review' | 'process'
interface Props {
mode: Mode
onOpenOrg: (orgId: number) => void
}
const format = (value: number) => value.toLocaleString('ko-KR')
const percent = (done: number, total: number) => total ? Math.round((done / total) * 1000) / 10 : 0
function ProgressRing({ done, total, color }: { done: number; total: number; color: string }) {
const rate = percent(done, total)
return (
<div
className="relative flex h-24 w-24 shrink-0 items-center justify-center rounded-full"
style={{ background: `conic-gradient(${color} ${rate}%, #e5e7eb 0)` }}
role="img"
aria-label={`${rate}% 완료`}
>
<div className="absolute inset-3 rounded-full bg-white" />
<div className="relative text-center">
<strong className="block text-xl font-bold">{rate}%</strong>
<span className="text-[10px] text-gray-500">{format(done)} / {format(total)}</span>
</div>
</div>
)
}
const CHART_COLORS = ['#2f7ed8', '#19a974', '#efa500', '#0b8f35', '#8b5cf6', '#64748b']
function BarDistribution({ title, items }: { title: string; items: DistributionItem[] }) {
const max = Math.max(...items.map((item) => item.count), 1)
return <article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<h2 className="mb-3 text-xs font-semibold text-gray-600">{title}</h2>
<div className="space-y-2.5">{items.length ? items.map((item) => <div key={item.label} className="grid grid-cols-[minmax(80px,130px)_1fr_auto] items-center gap-2 text-[11px]">
<span className="truncate" title={item.label}>{item.label}</span><div className="h-1.5 rounded-full bg-gray-200"><div className="h-full rounded-full bg-blue-600" style={{ width: `${item.count / max * 100}%` }} /></div><b className="tabular-nums">{format(item.count)}</b>
</div>) : <p className="py-6 text-center text-xs text-gray-400">집계 데이터가 없습니다.</p>}</div>
</article>
}
function DonutDistribution({ title, items }: { title: string; items: DistributionItem[] }) {
const total = items.reduce((sum, item) => sum + item.count, 0)
let cursor = 0
const stops = items.map((item, index) => { const start = cursor; cursor += total ? item.count / total * 100 : 0; return `${CHART_COLORS[index % CHART_COLORS.length]} ${start}% ${cursor}%` }).join(', ')
return <article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<h2 className="mb-3 text-xs font-semibold text-gray-600">{title}</h2>
<div className="flex min-h-28 items-center justify-center gap-5">
<div role="img" aria-label={`${title} 전체 ${format(total)}건`} className="relative flex h-20 w-20 shrink-0 items-center justify-center rounded-full" style={{ background: total ? `conic-gradient(${stops})` : '#e5e7eb' }}><div className="absolute inset-2.5 rounded-full bg-white"/><div className="relative text-center"><span className="block text-[9px] text-gray-500">합계</span><b className="text-sm tabular-nums">{format(total)}</b></div></div>
<div className="space-y-1.5">{items.map((item,index)=><div key={item.label} className="flex items-center gap-2 text-[10px]"><i className="h-2 w-2 rounded-full" style={{backgroundColor:CHART_COLORS[index%CHART_COLORS.length]}}/><span>{item.label}</span><b className="tabular-nums">{format(item.count)}</b><span className="text-gray-400">{total ? Math.round(item.count/total*100) : 0}%</span></div>)}</div>
</div>
</article>
}
export default function RightsDashboard({ mode, onOpenOrg }: Props) {
const [data, setData] = useState<DashboardData | null>(null)
const [error, setError] = useState<string | null>(null)
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState('all')
useEffect(() => {
let active = true
setData(null)
setError(null)
void getDashboard()
.then((result) => active && setData(result))
.catch((cause: Error) => active && setError(cause.message))
return () => { active = false }
}, [mode])
const rows = useMemo(() => {
if (!data) return []
return data.orgs.filter((row) => {
const total = mode === 'review' ? row.reviewTotal : row.processTotal
const done = mode === 'review' ? row.reviewDone : row.processDone
const matchesKeyword = row.orgName.includes(keyword.trim()) || row.orgNo.includes(keyword.trim())
const matchesStatus = status === 'all'
|| (status === 'complete' && total > 0 && done === total)
|| (status === 'progress' && done > 0 && done < total)
|| (status === 'waiting' && total > 0 && done === 0)
return matchesKeyword && matchesStatus
})
}, [data, keyword, mode, status])
if (error) return <div role="alert" className="m-6 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-700">{error}</div>
if (!data) return <p className="p-8 text-sm text-gray-500">권리관리 현황을 불러오는 중…</p>
const isReview = mode === 'review'
const title = isReview ? '권리확인' : '권리처리'
const total = isReview ? data.summary.reviewTotal : data.summary.processTotal
const done = isReview ? data.summary.reviewDone : data.summary.processDone
const remaining = Math.max(total - done, 0)
const rate = percent(done, total)
const accent = isReview ? '#ea8a00' : '#078d2a'
const rights = data.rights
return (
<div className="p-5 lg:p-7">
<div className="mb-4">
<h1 className="text-xl font-bold text-gray-900">{title}</h1>
<p className="mt-1 text-xs text-gray-500">{isReview ? '검토 대상 게시물의 권리 확인 및 처리 방향 결정' : '권리확인 완료 건의 처리 진행 상황 관리'}</p>
</div>
<section className="grid overflow-hidden rounded-lg text-white shadow-sm sm:grid-cols-2 lg:grid-cols-4" style={{ backgroundColor: accent }}>
{[[`${rate}%`, `${title} 진척률`], [format(done), `${title} 완료`], [format(total), '전체 대상'], [format(remaining), '잔여 건수']].map(([value, label]) => (
<div key={label} className="border-b border-white/20 px-5 py-4 sm:border-r lg:border-b-0">
<strong className="block text-2xl font-bold tabular-nums">{value}</strong>
<span className="text-[11px] font-medium text-white/85">{label}</span>
</div>
))}
</section>
<section className={`mt-3 grid gap-3 ${isReview ? 'lg:grid-cols-[1.15fr_repeat(4,1fr)]' : 'lg:grid-cols-[1.1fr_repeat(3,1fr)]'}`}>
<article className="flex items-center gap-5 rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
<ProgressRing done={done} total={total} color={accent} />
<div><p className="text-xs font-medium text-gray-500">전체 진행률</p><p className="mt-1 text-lg font-bold">{rate}%</p><p className="mt-2 text-xs text-gray-500">잔여 {format(remaining)}건</p></div>
</article>
<article className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"><p className="text-xs font-medium text-gray-500">{title} 완료</p><p className="mt-2 text-2xl font-bold tabular-nums">{format(done)}</p><p className="mt-2 text-xs text-gray-500">전체 대상의 {rate}%</p><div className="mt-3 h-1 rounded-full bg-gray-200"><div className="h-full rounded-full" style={{width:`${rate}%`,backgroundColor:accent}} /></div></article>
{isReview && <article className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"><p className="text-xs font-medium text-gray-500">자유이용 확정</p><p className="mt-2 text-2xl font-bold tabular-nums">{format(rights?.reviewFreeUseCount ?? 0)}</p><p className="mt-2 text-xs text-gray-500">신유형 개방 판정 건수</p><div className="mt-3 h-1 rounded-full bg-gray-200"><div className="h-full rounded-full bg-green-600" style={{width:`${done ? Math.min((rights?.reviewFreeUseCount ?? 0)/done*100,100):0}%`}} /></div></article>}
{isReview && <article className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"><p className="text-xs font-medium text-gray-500">처리필요 건수</p><p className="mt-2 text-2xl font-bold tabular-nums">{format(rights?.reviewNeedsProcessingCount ?? 0)}</p><p className="mt-2 text-xs text-gray-500">권리처리필요 Y 판정 건수</p></article>}
{isReview && <article className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"><p className="text-xs font-medium text-gray-500">권리처리 추진 미희망</p><p className="mt-2 text-2xl font-bold tabular-nums">{format(rights?.reviewNoProcessingCount ?? 0)}</p><p className="mt-2 text-xs text-gray-500">기존 공공누리 유형 유지 건수</p></article>}
{!isReview && <article className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"><p className="text-xs font-medium text-gray-500">처리대상 건수(실제)</p><p className="mt-2 text-2xl font-bold tabular-nums">{format(total)}</p><p className="mt-2 text-xs text-gray-500">권리처리 자료 등록 건수</p></article>}
{!isReview && <article className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"><p className="text-xs font-medium text-gray-500">유형변경 건수</p><p className="mt-2 text-2xl font-bold tabular-nums text-pink-600">{format(rights?.changedKoglCount ?? 0)}</p><p className="mt-2 text-xs text-gray-500">기존 유형과 최종 판정 유형이 다른 건</p><div className="mt-3 h-1 rounded-full bg-gray-200"><div className="h-full rounded-full bg-pink-500" style={{width:`${total ? Math.min((rights?.changedKoglCount ?? 0)/total*100,100) : 0}%`}} /></div></article>}
</section>
{rights && (isReview ? (
<section className="mt-3 grid gap-3 lg:grid-cols-3">
<BarDistribution title="권리확인 결과 유형별 현황" items={rights.reviewResults} />
<DonutDistribution title="검토 대상 분류 현황" items={rights.reviewSourceKoglTypes} />
<DonutDistribution title="판정 공공누리유형 현황" items={rights.reviewJudgedKoglTypes} />
</section>
) : (
<section className="mt-3 grid gap-3 lg:grid-cols-2">
<BarDistribution title="권리처리 유형별 현황" items={rights.processTypes} />
<DonutDistribution title="처리 상태 현황" items={rights.processStatuses} />
</section>
))}
<section className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
<div className="flex flex-col gap-3 border-b border-gray-200 p-4 sm:flex-row sm:items-end sm:justify-between">
<div><h2 className="text-sm font-semibold">기관별 {title} 현황</h2><p className="mt-1 text-xs text-gray-500">기관을 선택하면 상세 {title} 화면으로 이동합니다.</p></div>
<div className="flex gap-2">
<label className="sr-only" htmlFor={`${mode}-search`}>기관명 검색</label>
<input id={`${mode}-search`} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="기관명 검색…" className="h-9 w-44 rounded-md border border-gray-300 px-3 text-xs focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100" />
<label className="sr-only" htmlFor={`${mode}-status`}>진행 상태</label>
<select id={`${mode}-status`} value={status} onChange={(e) => setStatus(e.target.value)} className="h-9 rounded-md border border-gray-300 bg-white px-2 text-xs focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"><option value="all">상태 전체</option><option value="complete">완료</option><option value="progress">진행 중</option><option value="waiting">대기</option></select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[680px] text-xs">
<thead className="bg-gray-50 text-gray-500"><tr><th className="px-4 py-2.5 text-left font-medium">기관명</th><th className="px-4 py-2.5 text-right font-medium">대상</th><th className="px-4 py-2.5 text-right font-medium">완료</th><th className="px-4 py-2.5 text-right font-medium">잔여</th><th className="w-64 px-4 py-2.5 text-left font-medium">진척률</th></tr></thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row: DashboardOrgRow) => { const rowTotal=isReview?row.reviewTotal:row.processTotal; const rowDone=isReview?row.reviewDone:row.processDone; const rowRate=percent(rowDone,rowTotal); return (
<tr key={row.id} className="cursor-pointer hover:bg-gray-50" tabIndex={0} onClick={() => onOpenOrg(row.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenOrg(row.id) } }}>
<td className="px-4 py-3 font-medium text-gray-900"><span className="mr-2 text-gray-400">{row.orgNo}</span>{row.orgName}</td><td className="px-4 py-3 text-right tabular-nums">{format(rowTotal)}</td><td className="px-4 py-3 text-right font-medium tabular-nums" style={{ color: accent }}>{format(rowDone)}</td><td className="px-4 py-3 text-right tabular-nums">{format(Math.max(rowTotal-rowDone,0))}</td><td className="px-4 py-3"><div className="flex items-center gap-2"><div className="h-1.5 flex-1 rounded-full bg-gray-200"><div className="h-full rounded-full" style={{ width: `${rowRate}%`, backgroundColor: accent }} /></div><b className="w-10 text-right tabular-nums">{rowRate}%</b></div></td>
</tr>) })}
{!rows.length && <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500">조건에 맞는 기관이 없습니다.</td></tr>}
</tbody>
</table>
</div>
</section>
</div>
)
}