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 {
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<ProjectRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [keyword, setKeyword] = useState('')
const [stageFilter, setStageFilter] = useState('')
const [contactFilter, setContactFilter] = useState('')
/** 오른쪽 상세 패널에서 다루는 기관. */
const [selected, setSelected] = useState<ProjectRow | null>(null)
const [logs, setLogs] = useState<ContactLog[]>([])
const [reports, setReports] = useState<OrgReport[]>([])
const [panelBusy, setPanelBusy] = useState(false)
const [panelError, setPanelError] = useState<string | null>(null)
const [logDate, setLogDate] = useState(today())
const [logMethod, setLogMethod] = useState(METHODS[0])
const [logSummary, setLogSummary] = useState('')
const [editingLogId, setEditingLogId] = useState<number | null>(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 (
<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>
)
}
return (
<div className="space-y-4 p-6">
<section className="rounded-lg border border-gray-200 bg-white p-5">
<h1 className="flex items-center gap-1.5 text-lg font-semibold">
사업관리
<HelpButton topic="projects.overview" />
</h1>
<p className="text-xs text-gray-500">
3단계 법률검토 대상 기관 현황과 신청 확인 연락 이력, 최종 보고서를 관리합니다.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<input
aria-label="기관명/기관코드 검색"
placeholder="기관명/기관코드 검색"
value={keyword}
onChange={(e) => setKeyword(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={stageFilter}
onChange={(e) => setStageFilter(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">현재 단계 전체</option>
{Array.from({ length: 12 }, (_, i) => i + 1).map((n) => (
<option key={n} value={String(n)}>{`${n}. ${stageLabel(n)}`}</option>
))}
</select>
<select
aria-label="연락 이력"
value={contactFilter}
onChange={(e) => setContactFilter(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">연락 이력 전체</option>
<option value="some">이력 있음</option>
<option value="none">이력 없음</option>
</select>
</div>
<div className="mt-3 overflow-x-auto">
<table data-testid="project-table" className="w-full min-w-[1000px] 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">최근 연락</th>
<th className="px-2 py-2 text-right font-normal">보고서</th>
<th className="px-2 py-2 text-right font-normal">관리</th>
</tr>
</thead>
<tbody>
{rows === null ? (
<tr>
<td colSpan={10} className="px-2 py-6 text-center text-sm text-gray-500">
불러오는 중…
</td>
</tr>
) : visible.length === 0 ? (
<tr>
<td colSpan={10} className="px-2 py-6 text-center text-sm text-gray-500">
조건에 맞는 기관이 없습니다.
</td>
</tr>
) : (
visible.map((row) => (
<tr key={row.org.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-2 py-2 text-gray-500 tabular-nums">{row.org.orgNo}</td>
<td className="px-2 py-2">
<button
type="button"
onClick={() => onOpenOrg(row.org.id)}
className="font-medium text-blue-700 hover:underline"
>
{row.org.orgName}
</button>
</td>
<td className="px-2 py-2 text-gray-600">
{row.org.stage === null ? '단계 미지정' : `${row.org.stage}. ${stageLabel(row.org.stage)}`}
</td>
<td className="px-2 py-2 text-gray-600">{row.org.mjName ?? '-'}</td>
<td className="px-2 py-2 text-gray-600">{row.org.lawyerName ?? '-'}</td>
<td className="px-2 py-2 text-right tabular-nums">
{row.org.reviewDone.toLocaleString()} / {row.org.reviewTotal.toLocaleString()}
</td>
<td className="px-2 py-2 text-right tabular-nums">
{row.org.processDone.toLocaleString()} / {row.org.processTotal.toLocaleString()}
</td>
<td className="px-2 py-2 text-right text-gray-600 tabular-nums">
{row.lastContactedOn ?? '-'}
{row.contactCount > 0 && (
<span className="ml-1 text-xs text-gray-400">({row.contactCount})</span>
)}
</td>
<td className="px-2 py-2 text-right tabular-nums">{row.reportCount}</td>
<td className="px-2 py-2 text-right">
<button
type="button"
onClick={() => void openPanel(row)}
className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
연락·보고서
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
{selected && (
<div
role="dialog"
aria-label={`${selected.org.orgName} 연락·보고서`}
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-6"
>
<div className="w-full max-w-3xl rounded-lg bg-white shadow-xl">
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-5 py-4">
<div>
<h2 className="text-base font-semibold">
{selected.org.orgNo} {selected.org.orgName}
</h2>
<p className="mt-1 text-xs text-gray-500">신청 확인 연락 이력과 최종 보고서</p>
</div>
<button
type="button"
aria-label="닫기"
onClick={() => setSelected(null)}
className="shrink-0 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
>
닫기
</button>
</div>
<div className="space-y-6 px-5 py-4">
{panelError && (
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{panelError}</p>
)}
<section>
<h3 className="flex items-center gap-1.5 text-sm font-semibold">
신청 확인 연락 이력
<HelpButton topic="projects.contactLog" />
</h3>
<div className="mt-2 flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-xs text-gray-500">
연락일
<input
type="date"
aria-label="연락일"
value={logDate}
onChange={(e) => setLogDate(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-gray-500">
방법
<select
aria-label="연락 방법"
value={logMethod}
onChange={(e) => setLogMethod(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
{METHODS.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</label>
<label className="flex min-w-64 flex-1 flex-col gap-1 text-xs text-gray-500">
내용
<input
type="text"
aria-label="연락 내용"
value={logSummary}
onChange={(e) => setLogSummary(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
/>
</label>
<button
type="button"
disabled={panelBusy || logSummary.trim() === ''}
onClick={() => void submitLog()}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
>
{editingLogId ? '수정 저장' : '등록'}
</button>
{editingLogId && (
<button
type="button"
onClick={() => {
setEditingLogId(null)
setLogSummary('')
}}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600"
>
취소
</button>
)}
</div>
<p className="mt-1 text-xs text-gray-400">작성자는 로그인한 사용자로 자동 기록됩니다.</p>
<ul className="mt-3 space-y-2">
{logs.length === 0 ? (
<li className="text-sm text-gray-400">등록된 연락 이력이 없습니다.</li>
) : (
logs.map((log) => (
<li key={log.id} className="rounded-md border border-gray-200 p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-500">
{log.contactedOn} · {log.method} · {log.author}
</span>
<span className="flex gap-1">
<button
type="button"
onClick={() => {
setEditingLogId(log.id)
setLogDate(log.contactedOn)
setLogMethod(log.method)
setLogSummary(log.summary)
}}
className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
>
수정
</button>
<button
type="button"
onClick={() => void removeLog(log)}
className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
>
삭제
</button>
</span>
</div>
<p className="mt-1 whitespace-pre-wrap text-sm text-gray-800">{log.summary}</p>
</li>
))
)}
</ul>
</section>
<section>
<h3 className="flex items-center gap-1.5 text-sm font-semibold">
최종 보고서
<HelpButton topic="projects.report" />
</h3>
<label className="mt-2 inline-flex cursor-pointer items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
보고서 업로드
<input
type="file"
aria-label="보고서 파일"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
void handleReportUpload(file)
}
e.target.value = ''
}}
/>
</label>
<ul className="mt-3 space-y-2">
{reports.length === 0 ? (
<li className="text-sm text-gray-400">업로드된 보고서가 없습니다.</li>
) : (
reports.map((report) => (
<li
key={report.id}
className="flex items-center justify-between gap-2 rounded-md border border-gray-200 p-2.5"
>
<div className="min-w-0">
<a
href={reportDownloadUrl(report.orgId, report.id)}
className="block truncate text-sm text-blue-700 hover:underline"
>
{report.fileName}
</a>
<span className="text-xs text-gray-400">
{formatSize(report.byteSize)} · {report.uploadedBy} ·{' '}
{formatDateTime(report.uploadedAt)}
</span>
</div>
<button
type="button"
onClick={() => void removeReport(report)}
className="shrink-0 rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50"
>
삭제
</button>
</li>
))
)}
</ul>
</section>
</div>
</div>
</div>
)}
</div>
)
}