import { useCallback, useEffect, useMemo, useState } from 'react' import { getDashboard, updateStage, type DashboardData, type DashboardOrgRow, } from '../api/client' import { STAGE_GROUPS, STAGE_LABELS, stageGroupKey, stageLabel } from '../stages' import HelpButton from '../help/HelpButton' /** * 시안(3단계 대쉬보드 화면 설계안)의 대시보드 화면. 세 블록으로 구성된다. * 1) KPI 타일 7개 * 2) 단계별 현황 칸반 - 카드를 끌어 단계를 옮긴다 * 3) 최근 진행 기관 표 - 최근 변동순 상위 N개 * * 칸반 맨 앞의 "단계 미지정" 컬럼은 채널을 아직 만들지 않은 기관만 담는다(요청자 확정). * 기관관리 상세 화면에는 이 개념이 없다 - 거기서는 채널 생성 전이면 1~12 스트립이 전부 * 회색으로 보일 뿐이다(OrgOverview). 미지정 카드는 끌어도 단계가 바뀌지 않고, 채널을 * 먼저 만들라는 경고창이 뜬다. 단계만 올려 두면 게시글·자료·권리검토가 전부 채널에 붙어 * 있어서 빈 껍데기가 되기 때문이다. */ interface Props { /** 카드/표에서 기관을 클릭했을 때. 기관관리 화면으로 이동해 해당 기관을 연다. */ onOpenOrg: (orgId: number) => void /** [전체 기관 보기]. 기관관리 화면으로만 이동한다. */ onOpenOrgList: () => void } const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫'] /** 칸반 컬럼 상단 색 띠. 구간별로 같은 색을 쓴다. */ const GROUP_COLOR: Record = { intake: 'bg-slate-400', assign: 'bg-indigo-400', check: 'bg-emerald-400', process: 'bg-amber-400', closing: 'bg-violet-400', } /** 표/칸반이 한 번에 보여주는 기관 수. [더보기]를 누를 때마다 이만큼 늘어난다. */ 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 (
{label} {value} {sub && {sub}}
) } function ProgressLine({ label, done, total }: { label: string; done: number; total: number }) { const value = percent(done, total) return (
{label} {done.toLocaleString()} / {total.toLocaleString()} {value}%
) } /** 단계 구간에 따라 카드 본문이 달라진다(시안의 컬럼별 카드 모양). */ function CardBody({ row }: { row: DashboardOrgRow }) { const stage = columnOf(row) const group = stageGroupKey(stage) const lawyer = row.lawyerName ?? '변호사 미배정' if (stage === null) { return

채널 미생성 · 담당자 배정 후 채널을 만드세요

} if (stage === 1) { return

신청일 {formatDate(row.stageChangedAt)}

} if (stage === 2 || stage === 3) { return (

목록 {row.reviewTotal.toLocaleString()}건

접수일 {formatDate(row.stageChangedAt)}

) } if (group === 'assign') { return (

{lawyer}

배정일 {row.lawyerAssignedDate ?? formatDate(row.stageChangedAt)}

) } if (group === 'check') { return (

{lawyer}

) } if (group === 'process') { return (

{lawyer}

) } return (

{lawyer}

{CIRCLED[stage - 1]} {stageLabel(stage)}
) } export default function Dashboard({ onOpenOrg, onOpenOrgList }: Props) { const [data, setData] = useState(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [warning, setWarning] = useState(null) // 칸반 필터 const [groupFilter, setGroupFilter] = useState('') const [lawyerFilter, setLawyerFilter] = useState('') const [stageFilter, setStageFilter] = useState('') const [statusFilter, setStatusFilter] = useState('') const [boardKeyword, setBoardKeyword] = useState('') const [expandedColumns, setExpandedColumns] = useState>({}) // 최근 진행 기관 표 필터 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 (groupFilter && stageGroupKey(stage) !== groupFilter) { return false } if (lawyerFilter && row.lawyerName !== lawyerFilter) { return false } if (stageFilter && String(stage ?? '') !== stageFilter) { return false } if (statusFilter === 'unassigned' && row.hasChannel) { return false } if (statusFilter === 'active' && (!row.hasChannel || (stage ?? 0) >= 11)) { return false } if (statusFilter === 'done' && (stage ?? 0) < 11) { return false } if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) { return false } return true }) }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword]) /** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */ 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 } if (tableData === 'received' && row.reviewTotal === 0) { return false } if (tableData === 'pending' && row.reviewTotal > 0) { 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 (

{error}

) } if (!data) { return

불러오는 중…

} const s = data.summary const columns: (number | null)[] = [null, ...STAGE_LABELS.map((_, i) => i + 1)] return (
{error &&

{error}

}

대시보드

전체기관의 진행 상태 및 검토 현황

● 시스템 정상

단계별 현황

setBoardKeyword(e.target.value)} className="min-w-40 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm" />
기관 카드를 드래그하여 단계 이동이 가능합니다. 단계 이동 시 현재 단계가 자동 변경됩니다. 정렬 기준 최근 진행순
{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' : GROUP_COLOR[stageGroupKey(stage) ?? ''] ?? 'bg-gray-300' return (
{ 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" >
{stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`} {cards.length}
{shown.map((row) => (
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' : ''}`} >

{row.orgName}

{(() => { const days = daysSince(row.stageChangedAt) return days === null ? null : ( D+{days} ) })()}
))} {cards.length > shown.length && ( )}
) })}

최근 진행 기관

최근 변경 기준 {Math.min(visible, tableRows.length)}개 기관

setTableKeyword(e.target.value)} className="min-w-48 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm" />
{tableRows.slice(0, visible).map((row) => { const stage = columnOf(row) return ( onOpenOrg(row.id)} className="cursor-pointer border-b border-gray-100 hover:bg-gray-50" > ) })} {tableRows.length === 0 && ( )}
연번 기관명 현재 단계 문정원 담당 담당 변호사 권리확인 권리처리 RE 최근 변경
{row.orgNo} {row.orgName} {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`} {row.mjName ?? '-'} {row.lawyerName ?? '-'} {row.reviewDone.toLocaleString()} / {row.reviewTotal.toLocaleString()} {row.processDone.toLocaleString()} / {row.processTotal.toLocaleString()} {row.reCount} {formatDate(row.lastChangedAt)}
조건에 맞는 기관이 없습니다.
{tableRows.length > visible ? ( ) : ( )}
{warning && (

채널을 먼저 생성해 주세요

{warning}

)}
) }