import { useCallback, useEffect, useMemo, useState } from 'react' import { addContactLog, deleteContactLog, deleteReport, getContactLogs, getProjects, getReports, reportDownloadUrl, updateContactLog, uploadReport, type ContactLog, type OrgReport, type ProjectRow, } from '../api/client' import { stageLabel } from '../stages' import HelpButton from '../help/HelpButton' /** * 사업관리 화면. 3단계 법률검토로 진행할 기관 현황을 한 표로 모아 보고, 기관을 고르면 * "신청 확인 연락" 이력과 최종 보고서를 다룬다(요청자 요구: 별도 메뉴 + 연락 이력관리 + * 기관별 최종 보고서 업로드). * * 기관 현황 숫자는 대시보드와 같은 집계를 그대로 받아 쓴다 - 두 화면 숫자가 어긋나지 않게. */ interface Props { /** 기관명을 클릭했을 때 기관관리 화면으로 넘긴다. */ onOpenOrg: (orgId: number) => void } const METHODS = ['전화', '메일', '방문', '기타'] function formatDateTime(ms: number): string { const d = new Date(ms) const pad = (n: number) => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` } function formatSize(bytes: number): string { if (bytes < 1024) { return `${bytes} B` } if (bytes < 1024 * 1024) { return `${Math.round(bytes / 1024)} KB` } return `${(bytes / 1024 / 1024).toFixed(1)} MB` } /** 오늘 날짜를 yyyy-MM-dd로. 연락 이력 입력의 기본값이다. */ function today(): string { const d = new Date() const pad = (n: number) => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` } export default function ProjectsPage({ onOpenOrg }: Props) { const [rows, setRows] = useState(null) const [error, setError] = useState(null) const [keyword, setKeyword] = useState('') const [stageFilter, setStageFilter] = useState('') const [contactFilter, setContactFilter] = useState('') /** 오른쪽 상세 패널에서 다루는 기관. */ const [selected, setSelected] = useState(null) const [logs, setLogs] = useState([]) const [reports, setReports] = useState([]) const [panelBusy, setPanelBusy] = useState(false) const [panelError, setPanelError] = useState(null) const [logDate, setLogDate] = useState(today()) const [logMethod, setLogMethod] = useState(METHODS[0]) const [logSummary, setLogSummary] = useState('') const [editingLogId, setEditingLogId] = useState(null) const reload = useCallback(async () => { try { setRows(await getProjects()) setError(null) } catch (e) { setError((e as Error).message) } }, []) useEffect(() => { void reload() }, [reload]) const visible = useMemo(() => { const kw = keyword.trim() return (rows ?? []).filter((row) => { if (kw && !row.org.orgName.includes(kw) && !row.org.orgNo.includes(kw)) { return false } if (stageFilter && String(row.org.stage ?? '') !== stageFilter) { return false } if (contactFilter === 'none' && row.contactCount > 0) { return false } if (contactFilter === 'some' && row.contactCount === 0) { return false } return true }) }, [rows, keyword, stageFilter, contactFilter]) async function openPanel(row: ProjectRow) { setSelected(row) setPanelError(null) setEditingLogId(null) setLogSummary('') setLogDate(today()) setPanelBusy(true) try { const [logList, reportList] = await Promise.all([ getContactLogs(row.org.id), getReports(row.org.id), ]) setLogs(logList) setReports(reportList) } catch (e) { setPanelError((e as Error).message) } finally { setPanelBusy(false) } } async function submitLog() { if (!selected || logSummary.trim() === '' || panelBusy) { return } setPanelBusy(true) setPanelError(null) try { const body = { contactedOn: logDate, method: logMethod, summary: logSummary.trim() } const next = editingLogId ? await updateContactLog(selected.org.id, editingLogId, body) : await addContactLog(selected.org.id, body) setLogs(next) setLogSummary('') setEditingLogId(null) await reload() } catch (e) { setPanelError((e as Error).message) } finally { setPanelBusy(false) } } async function removeLog(log: ContactLog) { if (!selected || !window.confirm('이 연락 이력을 삭제할까요?')) { return } setPanelBusy(true) try { await deleteContactLog(selected.org.id, log.id) setLogs((prev) => prev.filter((l) => l.id !== log.id)) await reload() } catch (e) { setPanelError((e as Error).message) } finally { setPanelBusy(false) } } async function handleReportUpload(file: File) { if (!selected) { return } setPanelBusy(true) setPanelError(null) try { setReports(await uploadReport(selected.org.id, file)) await reload() } catch (e) { setPanelError((e as Error).message) } finally { setPanelBusy(false) } } async function removeReport(report: OrgReport) { if (!selected || !window.confirm(`"${report.fileName}"을 삭제할까요?`)) { return } setPanelBusy(true) try { await deleteReport(selected.org.id, report.id) setReports((prev) => prev.filter((r) => r.id !== report.id)) await reload() } catch (e) { setPanelError((e as Error).message) } finally { setPanelBusy(false) } } if (error && !rows) { return (

{error}

) } return (

사업관리

3단계 법률검토 대상 기관 현황과 신청 확인 연락 이력, 최종 보고서를 관리합니다.

setKeyword(e.target.value)} className="min-w-48 flex-1 rounded-md border border-gray-300 px-2 py-1.5 text-sm" />
{rows === null ? ( ) : visible.length === 0 ? ( ) : ( visible.map((row) => ( )) )}
연번 기관명 현재 단계 문정원 담당 담당 변호사 권리확인 권리처리 최근 연락 보고서 관리
불러오는 중…
조건에 맞는 기관이 없습니다.
{row.org.orgNo} {row.org.stage === null ? '단계 미지정' : `${row.org.stage}. ${stageLabel(row.org.stage)}`} {row.org.mjName ?? '-'} {row.org.lawyerName ?? '-'} {row.org.reviewDone.toLocaleString()} / {row.org.reviewTotal.toLocaleString()} {row.org.processDone.toLocaleString()} / {row.org.processTotal.toLocaleString()} {row.lastContactedOn ?? '-'} {row.contactCount > 0 && ( ({row.contactCount}) )} {row.reportCount}
{selected && (

{selected.org.orgNo} {selected.org.orgName}

신청 확인 연락 이력과 최종 보고서

{panelError && (

{panelError}

)}

신청 확인 연락 이력

{editingLogId && ( )}

작성자는 로그인한 사용자로 자동 기록됩니다.

    {logs.length === 0 ? (
  • 등록된 연락 이력이 없습니다.
  • ) : ( logs.map((log) => (
  • {log.contactedOn} · {log.method} · {log.author}

    {log.summary}

  • )) )}

최종 보고서

    {reports.length === 0 ? (
  • 업로드된 보고서가 없습니다.
  • ) : ( reports.map((report) => (
  • {report.fileName} {formatSize(report.byteSize)} · {report.uploadedBy} ·{' '} {formatDateTime(report.uploadedAt)}
  • )) )}
)}
) }