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 { useCallback, useEffect, useMemo, useState } from 'react'
import {
getDashboard,
updateStage,
type DashboardData,
type DashboardOrgRow,
} from '../api/client'
import { useStages } from '../codes/stage'
import { barClass } from '../codes/tone'
import StatusChip from '../ui/StatusChip'
import HelpButton from '../help/HelpButton'
/**
* 시안(3단계 대쉬보드 화면 설계안)의 대시보드 화면. 세 블록으로 구성된다.
* 1) KPI 타일 7개
* 2) 단계별 현황 칸반 - 카드를 끌어 단계를 옮긴다
* 3) 최근 진행 기관 표 - 최근 변동순 상위 N개
*
* 칸반 맨 앞의 "단계 미지정" 컬럼은 <b>채널을 아직 만들지 않은 기관만</b> 담는다(요청자 확정).
* 기관관리 상세 화면에는 이 개념이 없다 - 거기서는 채널 생성 전이면 1~12 스트립이 전부
* 회색으로 보일 뿐이다(OrgOverview). 미지정 카드는 끌어도 단계가 바뀌지 않고, 채널을
* 먼저 만들라는 경고창이 뜬다. 단계만 올려 두면 게시글·자료·권리검토가 전부 채널에 붙어
* 있어서 빈 껍데기가 되기 때문이다.
*/
interface Props {
/** 카드/표에서 기관을 클릭했을 때. 기관관리 화면으로 이동해 해당 기관을 연다. */
onOpenOrg: (orgId: number) => void
/** [전체 기관 보기]. 기관관리 화면으로만 이동한다. */
onOpenOrgList: () => void
}
// 원문자(①②)와 구간 색은 더 이상 여기 박아두지 않는다. 원문자는 단계 번호에서 계산하고
// 색은 코드표의 tone에서 온다 - 단계가 하나 늘 때마다 배열을 같이 늘려야 했던 자리다.
/** 표/칸반이 한 번에 보여주는 기관 수. [더보기]를 누를 때마다 이만큼 늘어난다. */
const PAGE_SIZE = 8
/** 칸반 컬럼 하나가 한 번에 보여주는 카드 수. */
const COLUMN_SIZE = 2
const DAY_MS = 24 * 60 * 60 * 1000
function formatDate(ms: number | null): string {
if (!ms) {
return '-'
}
const d = new Date(ms)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** 현재 단계로 넘어온 뒤 며칠 지났는지. 이력이 없으면 null이라 배지를 아예 안 그린다. */
function daysSince(ms: number | null): number | null {
if (!ms) {
return null
}
return Math.max(0, Math.floor((Date.now() - ms) / DAY_MS))
}
function percent(done: number, total: number): number {
return total === 0 ? 0 : Math.round((done / total) * 100)
}
/**
* 카드가 들어갈 칸반 컬럼. null이면 "단계 미지정"이다.
*
* 채널이 있는데 단계가 비어 있는 기관은 V11 백필과 채널 생성 로직 덕에 원칙적으로 없지만,
* 혹시 생기더라도 보드에서 사라지지 않도록 1단계로 본다.
*/
function columnOf(row: DashboardOrgRow): number | null {
if (!row.hasChannel) {
return null
}
return row.stage ?? 1
}
function KpiTile({
label,
value,
sub,
accent,
}: {
label: string
value: string
sub?: string
accent?: boolean
}) {
return (
<div
className={`flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg border bg-white px-3 py-4 ${
accent ? 'border-gray-300 shadow-sm' : 'border-gray-200'
}`}
>
<span className="text-xs text-gray-500">{label}</span>
<span className="text-2xl font-semibold tabular-nums">{value}</span>
{sub && <span className="text-xs text-gray-400 tabular-nums">{sub}</span>}
</div>
)
}
function ProgressLine({ label, done, total }: { label: string; done: number; total: number }) {
const value = percent(done, total)
return (
<div className="space-y-1">
<div className="flex items-baseline justify-between text-xs">
<span className="text-gray-500">
{label} <span className="font-medium text-gray-800 tabular-nums">{done.toLocaleString()} / {total.toLocaleString()}</span>
</span>
<span className="font-medium text-gray-600 tabular-nums">{value}%</span>
</div>
<div className="h-1 w-full rounded-full bg-gray-100">
<div className="h-1 rounded-full bg-blue-500" style={{ width: `${value}%` }} />
</div>
</div>
)
}
/** 단계 구간에 따라 카드 본문이 달라진다(시안의 컬럼별 카드 모양). */
function CardBody({ row }: { row: DashboardOrgRow }) {
const stages = useStages()
const stage = columnOf(row)
const group = stages.groupKey(stage)
const lawyer = row.lawyerName ?? '변호사 미배정'
if (stage === null) {
return <p className="text-xs text-gray-500">채널 미생성 · 담당자 배정 후 채널을 만드세요</p>
}
if (stage === 1) {
return <p className="text-xs text-gray-500">신청일 {formatDate(row.stageChangedAt)}</p>
}
if (stage === 2 || stage === 3) {
return (
<div className="space-y-0.5 text-xs text-gray-500">
<p>목록 {row.reviewTotal.toLocaleString()}건</p>
<p>접수일 {formatDate(row.stageChangedAt)}</p>
</div>
)
}
if (group === 'assign') {
return (
<div className="space-y-0.5 text-xs text-gray-500">
<p>{lawyer}</p>
<p>배정일 {row.lawyerAssignedDate ?? formatDate(row.stageChangedAt)}</p>
</div>
)
}
if (group === 'check') {
return (
<div className="space-y-1.5">
<p className="text-xs text-gray-500">{lawyer}</p>
<ProgressLine label="확인" done={row.reviewDone} total={row.reviewTotal} />
</div>
)
}
if (group === 'process') {
return (
<div className="space-y-1.5">
<p className="text-xs text-gray-500">{lawyer}</p>
<ProgressLine label="확인" done={row.reviewDone} total={row.reviewTotal} />
<ProgressLine label="처리" done={row.processDone} total={row.processTotal} />
</div>
)
}
return (
<div className="space-y-1 text-xs text-gray-500">
<p>{lawyer}</p>
<StatusChip label={stages.label(stage)} tone="violet" prefix={stages.symbol(stage)} />
</div>
)
}
export default function Dashboard({ onOpenOrg, onOpenOrgList }: Props) {
const stages = useStages()
const [data, setData] = useState<DashboardData | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [warning, setWarning] = useState<string | null>(null)
// 칸반 필터 (업무 구간은 요구사항 [6]으로 제거했다)
const [lawyerFilter, setLawyerFilter] = useState('')
const [stageFilter, setStageFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [boardKeyword, setBoardKeyword] = useState('')
const [expandedColumns, setExpandedColumns] = useState<Record<string, boolean>>({})
// 최근 진행 기관 표 필터
const [tableKeyword, setTableKeyword] = useState('')
const [tableStage, setTableStage] = useState('')
const [tableMj, setTableMj] = useState('')
const [tableLawyer, setTableLawyer] = useState('')
const [tableData, setTableData] = useState('')
const [visible, setVisible] = useState(PAGE_SIZE)
const reload = useCallback(async () => {
try {
setData(await getDashboard())
setError(null)
} catch (e) {
setError((e as Error).message)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const rows = useMemo(() => data?.orgs ?? [], [data])
const lawyers = useMemo(
() => [...new Set(rows.map((r) => r.lawyerName).filter((n): n is string => !!n))].sort(),
[rows],
)
const mjs = useMemo(
() => [...new Set(rows.map((r) => r.mjName).filter((n): n is string => !!n))].sort(),
[rows],
)
const boardRows = useMemo(() => {
const keyword = boardKeyword.trim()
return rows.filter((row) => {
const stage = columnOf(row)
if (lawyerFilter && row.lawyerName !== lawyerFilter) {
return false
}
if (stageFilter && String(stage ?? '') !== stageFilter) {
return false
}
// 진행상태는 코드표(STAGE의 progress)에서 온다 - 신청은 ①만, 완료는 ⑬만,
// 나머지가 진행중이다. 채널이 없어 단계가 아예 없는 기관은 '단계 미지정'으로 따로 본다.
if (statusFilter === 'unassigned' && row.hasChannel) {
return false
}
if (
statusFilter &&
statusFilter !== 'unassigned' &&
(!row.hasChannel || stages.progressOf(stage) !== statusFilter)
) {
return false
}
if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) {
return false
}
return true
})
}, [rows, lawyerFilter, stageFilter, statusFilter, boardKeyword, stages])
/** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */
const byRecent = useCallback(
(a: DashboardOrgRow, b: DashboardOrgRow) => (b.lastChangedAt ?? 0) - (a.lastChangedAt ?? 0),
[],
)
const tableRows = useMemo(() => {
const keyword = tableKeyword.trim()
return rows
.filter((row) => {
if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) {
return false
}
if (tableStage && String(columnOf(row) ?? '') !== tableStage) {
return false
}
if (tableMj && row.mjName !== tableMj) {
return false
}
if (tableLawyer && row.lawyerName !== tableLawyer) {
return false
}
// 요구사항 [8]: 권리확인·권리처리를 구분하지 않는다. 어느 쪽이든 한 건이라도
// 올라와 있으면 접수로 본다.
const received = row.reviewTotal + row.processTotal > 0
if (tableData === 'received' && !received) {
return false
}
if (tableData === 'pending' && received) {
return false
}
return true
})
.sort(byRecent)
}, [rows, tableKeyword, tableStage, tableMj, tableLawyer, tableData, byRecent])
async function moveStage(row: DashboardOrgRow, stage: number) {
if (!row.hasChannel) {
setWarning(`${row.orgNo} ${row.orgName}\n\n채널을 먼저 생성해야 단계를 옮길 수 있습니다. 채널관리에서 채널을 생성하면 자동으로 ①신청 단계로 들어갑니다.`)
return
}
if (columnOf(row) === stage) {
return
}
setBusy(true)
try {
await updateStage(row.id, stage)
await reload()
} catch (e) {
setError((e as Error).message)
} finally {
setBusy(false)
}
}
function handleDragStart(e: React.DragEvent, row: DashboardOrgRow) {
if (!row.hasChannel) {
// 드래그 자체를 취소하고 경고창을 띄운다. draggable을 false로 두면 이벤트가
// 아예 안 와서 "왜 안 움직이는지" 알려줄 기회가 없다.
e.preventDefault()
setWarning(`${row.orgNo} ${row.orgName}\n\n채널이 아직 없어 단계를 옮길 수 없습니다. 채널관리에서 채널을 먼저 생성해 주세요. 채널을 만들면 자동으로 ①신청 단계로 들어갑니다.`)
return
}
e.dataTransfer.setData('text/plain', String(row.id))
e.dataTransfer.effectAllowed = 'move'
}
function handleDrop(e: React.DragEvent, stage: number) {
e.preventDefault()
const id = Number(e.dataTransfer.getData('text/plain'))
const row = rows.find((r) => r.id === id)
if (row) {
void moveStage(row, stage)
}
}
if (error && !data) {
return (
<div className="flex flex-col items-center gap-3 p-8 text-sm">
<p className="text-red-600">{error}</p>
<button
type="button"
onClick={() => void reload()}
className="rounded-md border border-gray-300 px-3 py-1.5"
>
다시 시도
</button>
</div>
)
}
if (!data) {
return <p className="p-8 text-sm text-gray-500">불러오는 중…</p>
}
const s = data.summary
// 첫 칸은 '단계 미지정'(채널 미생성 기관 자리)이고, 나머지는 코드표의 단계 수만큼이다.
const columns: (number | null)[] = [null, ...stages.numbers]
return (
<div className="space-y-4 p-6">
{error && <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
<section className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<h1 className="flex items-center gap-1.5 text-lg font-semibold">
대시보드
<HelpButton topic="dashboard.kpi" />
</h1>
<p className="text-xs text-gray-500">전체기관의 진행 상태 및 검토 현황</p>
</div>
<span className="text-xs text-gray-500">● 시스템 정상</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<KpiTile label="신청" value={String(s.applied)} />
<KpiTile label="서류제출" value={String(s.docSubmitted)} />
<KpiTile
label="권리확인"
value={`${s.reviewDone.toLocaleString()} / ${s.reviewTotal.toLocaleString()}`}
sub={`${percent(s.reviewDone, s.reviewTotal)}%`}
accent
/>
<KpiTile
label="권리처리"
value={`${s.processDone.toLocaleString()} / ${s.processTotal.toLocaleString()}`}
sub={`${percent(s.processDone, s.processTotal)}%`}
accent
/>
<KpiTile label="미해결 RE" value={String(s.unresolvedRe)} />
<KpiTile label="보고서 작성" value={String(s.reportWriting)} />
<KpiTile label="완료" value={String(s.completed)} />
</div>
</section>
<section className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="flex items-center gap-1.5 text-sm font-semibold">
단계별 현황
<HelpButton topic="dashboard.kanban" />
</h2>
{/*
요구사항 [6]: 업무 구간 필터는 뺐다. 현재 단계와 기능이 겹친다는 지적이 있었고,
순서도 현재 단계를 앞으로 옮겼다. 구간 구분 자체는 칸반 컬럼 색에 계속 쓰이므로
코드표에는 그대로 남겨 둔다.
*/}
<div className="mt-3 flex flex-wrap gap-2">
<select
aria-label="현재 단계"
value={stageFilter}
onChange={(e) => setStageFilter(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">현재 단계 전체</option>
{stages.numbers.map((n) => (
<option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
))}
</select>
<select
aria-label="담당 변호사"
value={lawyerFilter}
onChange={(e) => setLawyerFilter(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">담당 변호사 전체</option>
{lawyers.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<select
aria-label="진행 상태"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">진행 상태 전체</option>
<option value="unassigned">단계 미지정</option>
<option value="applied">신청</option>
<option value="active">진행중</option>
<option value="done">완료</option>
</select>
<input
aria-label="기관명 검색"
placeholder="기관명 검색"
value={boardKeyword}
onChange={(e) => setBoardKeyword(e.target.value)}
className="min-w-40 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
<div className="mt-3 flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 text-xs text-gray-500">
<span>기관 카드를 드래그하여 단계 이동이 가능합니다. 단계 이동 시 현재 단계가 자동 변경됩니다.</span>
<span>정렬 기준 최근 진행순</span>
</div>
<div className="mt-3 flex gap-3 overflow-x-auto pb-2">
{columns.map((stage) => {
const key = stage === null ? 'unassigned' : String(stage)
const cards = boardRows.filter((row) => columnOf(row) === stage).sort(byRecent)
const expanded = expandedColumns[key]
const shown = expanded ? cards : cards.slice(0, COLUMN_SIZE)
const color = stage === null ? 'bg-gray-300' : barClass(stages.groupTone(stages.groupKey(stage)))
return (
<div
key={key}
data-testid={`column-${key}`}
onDragOver={(e) => {
if (stage !== null) {
e.preventDefault()
}
}}
onDrop={(e) => {
if (stage !== null) {
handleDrop(e, stage)
}
}}
className="flex w-56 shrink-0 flex-col rounded-lg border border-gray-200 bg-gray-50/60"
>
<div className={`h-1 rounded-t-lg ${color}`} />
<div className="flex items-center justify-between px-3 py-2">
<span className="text-sm font-medium">
{stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
</span>
<span className="text-sm text-gray-500 tabular-nums">{cards.length}</span>
</div>
<div className="space-y-2 px-2 pb-2">
{shown.map((row) => (
<div
key={row.id}
draggable
onDragStart={(e) => handleDragStart(e, row)}
onClick={() => onOpenOrg(row.id)}
className={`space-y-1.5 rounded-md border border-gray-200 bg-white p-2.5 ${
row.hasChannel ? 'cursor-grab' : 'cursor-pointer border-dashed'
} ${busy ? 'opacity-60' : ''}`}
>
<p className="truncate text-sm font-medium">{row.orgName}</p>
<CardBody row={row} />
{(() => {
const days = daysSince(row.stageChangedAt)
return days === null ? null : (
<span className="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-500">
D+{days}
</span>
)
})()}
</div>
))}
{cards.length > shown.length && (
<button
type="button"
onClick={() => setExpandedColumns((prev) => ({ ...prev, [key]: true }))}
className="w-full rounded-md py-1.5 text-xs text-gray-500 hover:bg-gray-100"
>
+ {cards.length - shown.length}건 더보기
</button>
)}
</div>
</div>
)
})}
</div>
</section>
<section className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="flex items-center gap-1.5 text-sm font-semibold">
최근 진행 기관
<HelpButton topic="dashboard.recent" />
</h2>
<p className="text-xs text-gray-500">최근 변경 기준 {Math.min(visible, tableRows.length)}개 기관</p>
<div className="mt-3 flex flex-wrap gap-2">
<input
aria-label="기관명/기관코드 검색"
placeholder="기관명/기관코드 검색"
value={tableKeyword}
onChange={(e) => setTableKeyword(e.target.value)}
className="min-w-48 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
/>
<select
aria-label="표 현재 단계"
value={tableStage}
onChange={(e) => setTableStage(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">현재 단계 전체</option>
{stages.numbers.map((n) => (
<option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
))}
</select>
<select
aria-label="문정원 담당"
value={tableMj}
onChange={(e) => setTableMj(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">문정원 담당 전체</option>
{mjs.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<select
aria-label="표 담당변호사"
value={tableLawyer}
onChange={(e) => setTableLawyer(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">담당변호사 전체</option>
{lawyers.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<select
aria-label="자료접수 상태"
value={tableData}
onChange={(e) => setTableData(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">자료접수 상태 전체</option>
<option value="received">접수</option>
<option value="pending">미접수</option>
</select>
<button
type="button"
onClick={() => {
setTableKeyword('')
setTableStage('')
setTableMj('')
setTableLawyer('')
setTableData('')
setVisible(PAGE_SIZE)
}}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
>
초기화
</button>
</div>
<div className="mt-3 overflow-x-auto">
<table data-testid="recent-table" className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs text-gray-500">
<th className="px-2 py-2 text-left font-normal">연번</th>
<th className="px-2 py-2 text-left font-normal">기관명</th>
<th className="px-2 py-2 text-left font-normal">현재 단계</th>
<th className="px-2 py-2 text-left font-normal">문정원 담당</th>
<th className="px-2 py-2 text-left font-normal">담당 변호사</th>
<th className="px-2 py-2 text-right font-normal">권리확인</th>
<th className="px-2 py-2 text-right font-normal">권리처리</th>
<th className="px-2 py-2 text-right font-normal">RE</th>
<th className="px-2 py-2 text-right font-normal">최근 변경</th>
</tr>
</thead>
<tbody>
{tableRows.slice(0, visible).map((row) => {
const stage = columnOf(row)
return (
<tr
key={row.id}
onClick={() => onOpenOrg(row.id)}
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
>
<td className="px-2 py-2 text-gray-500 tabular-nums">{row.orgNo}</td>
<td className="px-2 py-2 font-medium">{row.orgName}</td>
<td className="px-2 py-2">
{stage === null ? (
<StatusChip label="단계 미지정" tone="gray" glyph="idle" />
) : (
<StatusChip
label={stages.label(stage)}
tone="violet"
prefix={stages.symbol(stage)}
/>
)}
</td>
<td className="px-2 py-2 text-gray-600">{row.mjName ?? '-'}</td>
<td className="px-2 py-2 text-gray-600">{row.lawyerName ?? '-'}</td>
<td className="px-2 py-2 text-right tabular-nums">
{row.reviewDone.toLocaleString()} / {row.reviewTotal.toLocaleString()}
</td>
<td className="px-2 py-2 text-right tabular-nums">
{row.processDone.toLocaleString()} / {row.processTotal.toLocaleString()}
</td>
<td className="px-2 py-2 text-right tabular-nums">{row.reCount}</td>
<td className="px-2 py-2 text-right text-gray-500 tabular-nums">
{formatDate(row.lastChangedAt)}
</td>
</tr>
)
})}
{tableRows.length === 0 && (
<tr>
<td colSpan={9} className="px-2 py-6 text-center text-sm text-gray-500">
조건에 맞는 기관이 없습니다.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="mt-3 flex items-center justify-between">
{tableRows.length > visible ? (
<button
type="button"
onClick={() => setVisible((v) => v + PAGE_SIZE)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
>
더보기 ({tableRows.length - visible}개 남음)
</button>
) : (
<span />
)}
<button
type="button"
onClick={onOpenOrgList}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
>
사업관리에서 전체 보기 →
</button>
</div>
</section>
{warning && (
<div className="fixed inset-0 flex items-center justify-center bg-black/30" role="alertdialog">
<div className="w-96 rounded-lg bg-white p-5">
<h2 className="text-base font-semibold">채널을 먼저 생성해 주세요</h2>
<p className="mt-3 whitespace-pre-line rounded-md bg-gray-50 p-3 text-sm text-gray-700">
{warning}
</p>
<div className="mt-5 flex justify-end">
<button
type="button"
onClick={() => setWarning(null)}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white"
>
확인
</button>
</div>
</div>
</div>
)}
</div>
)
}