import { useEffect, useState } from 'react' import { getProcessPage, getReviewPage, getStageHistory, updateAssignments, updateStage, type Contact, type ContactCategory, type Org, } from '../api/client' import { STAGE_LABELS, stageLabel } from '../stages' import ChannelFiles from './ChannelFiles' import ChannelPosts from './ChannelPosts' import ContactPickerModal from './ContactPickerModal' import ProcessBoard from './ProcessBoard' import ReviewBoard from './ReviewBoard' import StatusBadge from './StatusBadge' import Timeline from './Timeline' import WorkMemos from './WorkMemos' // 기관관리 상세 화면. 3단계 대시보드 시안의 "기관관리" 레이아웃을 따른다. // 웹 DB에 있는 것(연번·기관명·담당자 3종·채널·진행단계)만 실데이터고, // 통계/일부 탭 내용은 아직 백엔드가 없어 시안과 같은 골격에 "추후 연동" 자리만 잡아 둔다. const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모'] // 담당 변호사 변경은 카드의 [생성/변경] 버튼으로 대체되어 목록에서 뺐다. // 진행단계 변경은 업무단계 현황 스트립 + 이 버튼의 모달로 실제 동작한다. const DISABLED_ACTIONS = ['자료 업로드', '최초 게시글 보기', '보고서 관리'] // 카드에서 담당자를 지정/변경할 때 쓰는 역할 정의 (채널관리의 배정과 같은 API를 쓴다) const CARD_ROLES: Record< 'applicant' | 'mj' | 'itn' | 'lawyer', { category: ContactCategory; pickerTitle: string } > = { applicant: { category: 'APPLICANT', pickerTitle: '신청기관 담당자 선택' }, mj: { category: 'MJ', pickerTitle: '문정원 담당자 선택' }, itn: { category: 'ITN', pickerTitle: '아이티앤 담당자 선택' }, lawyer: { category: 'LAWYER', pickerTitle: '담당 변호사 선택' }, } // Mattermost 웹 주소. 채널 화면은 팀명 + 채널 내부명으로 열린다. // 내부명 규칙은 백엔드 ChannelNaming.java가 정본이며 여기서는 열람 링크용으로만 복제한다. // 기관 코드({연번1}_{연번2})가 하위기관까지 구분하므로 기관명에 따른 별도 특례는 없다. const MATTERMOST_BASE = 'https://hub.iten.co.kr/itn-hub/channels/' function mattermostChannelUrl(org: Org): string { const slug = org.orgNo.trim().toLowerCase().replace(/_/g, '-').replace(/ /g, '-') return MATTERMOST_BASE + `org-${slug}-mj` } const stageDateFormatter = new Intl.DateTimeFormat('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', }) const stageTimeFormatter = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }) /** 2026. 07. 24. + 16:05 → "2026-07-24 16:05" (카드 폭에서 날짜/시간 두 줄로 자연 줄바꿈) */ function formatStageDate(ms: number): string { const date = stageDateFormatter.format(new Date(ms)).replace(/\. ?/g, '-').replace(/-$/, '') return `${date} ${stageTimeFormatter.format(new Date(ms))}` } function Field({ label, value }: { label: string; value: string | null }) { return (
{label} {value ?? '-'}
) } function ContactCard({ title, fields, assigned, busy, onEdit, }: { title: string fields: { label: string; value: string | null }[] assigned: boolean busy: boolean onEdit: () => void }) { return (

{title}

{fields.map((f) => ( ))}
) } export default function OrgOverview({ org, onChanged, }: { org: Org onChanged?: () => void }) { const [activeTab, setActiveTab] = useState(TABS[0]) const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'itn' | 'lawyer' | null>(null) const [assignBusy, setAssignBusy] = useState(false) const [assignError, setAssignError] = useState(null) const [stageBusy, setStageBusy] = useState(false) const [stageError, setStageError] = useState(null) const [stagePickerOpen, setStagePickerOpen] = useState(false) // 단계별로 '그 단계에 들어간 날짜'(가장 최근 이력)를 찍기 위한 조회 결과 const [stageDates, setStageDates] = useState>({}) // 통계 카드용 권리확인/권리처리 완료·전체 건수. 탭을 열지 않아도 헤더에 바로 보여야 하므로 // 각 탭이 마운트되기 전에 가볍게(size:1) 별도로 조회한다 - 실패해도 카드는 '-'로 남을 뿐 // 화면 전체를 깨뜨리지 않는다. const [reviewStats, setReviewStats] = useState<{ done: number; total: number } | null>(null) const [processStats, setProcessStats] = useState<{ done: number; total: number } | null>(null) useEffect(() => { let cancelled = false setReviewStats(null) getReviewPage(org.id, { page: 0, size: 1 }) .then((data) => { if (!cancelled) { setReviewStats({ done: data.done, total: data.total }) } }) .catch(() => { /* 통계는 장식이다 - 조회 실패로 화면을 깨지 않는다 */ }) return () => { cancelled = true } }, [org.id]) useEffect(() => { let cancelled = false setProcessStats(null) getProcessPage(org.id, { page: 0, size: 1 }) .then((data) => { if (!cancelled) { setProcessStats({ done: data.done, total: data.total }) } }) .catch(() => { /* 통계는 장식이다 - 조회 실패로 화면을 깨지 않는다 */ }) return () => { cancelled = true } }, [org.id]) useEffect(() => { let cancelled = false getStageHistory(org.id) .then((rows) => { if (cancelled) { return } // 최신순으로 오므로, 단계별 첫 등장( = 가장 최근 도달 시각)만 채택한다 const dates: Record = {} for (const row of rows) { if (!(row.stage in dates)) { dates[row.stage] = row.changedAt } } setStageDates(dates) }) .catch(() => { /* 날짜는 장식이다 - 조회 실패로 화면을 깨지 않는다 */ }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [org.id, org.stage]) async function assign(role: 'applicant' | 'mj' | 'itn' | 'lawyer', contact: Contact) { setPickerRole(null) setAssignError(null) setAssignBusy(true) try { await updateAssignments(org.id, { applicantContactId: role === 'applicant' ? contact.id : (org.applicant?.id ?? null), mjContactId: role === 'mj' ? contact.id : (org.mj?.id ?? null), itnContactId: role === 'itn' ? contact.id : (org.itn?.id ?? null), lawyerContactId: role === 'lawyer' ? contact.id : (org.lawyer?.id ?? null), lawyerAssignedDate: org.lawyerAssignedDate ?? null, }) onChanged?.() } catch (e) { setAssignError(e instanceof Error ? e.message : '담당자 지정에 실패했습니다.') } finally { setAssignBusy(false) } } /** 실제로 진행단계를 바꾸는 공통 경로. 스트립의 "다음 단계로" 클릭과 [진행단계 변경] * 모달의 단계 선택이 모두 여기를 거친다. */ async function changeStage(next: number) { setStageError(null) setStageBusy(true) try { await updateStage(org.id, next) onChanged?.() } catch (e) { setStageError(e instanceof Error ? e.message : '진행단계 변경에 실패했습니다.') } finally { setStageBusy(false) } } /** 업무단계 현황 스트립에서 "현재 단계"를 클릭했을 때만 호출된다 - 다음 단계로 1칸 전진. */ async function advanceStage() { if (org.stage === null || org.stage >= 12 || stageBusy) { return } const next = org.stage + 1 if (!window.confirm(`다음 단계 "${next}. ${stageLabel(next)}"(으)로 진행할까요?`)) { return } await changeStage(next) } async function pickStage(next: number) { if (!window.confirm(`"${next}. ${stageLabel(next)}"(으)로 진행단계를 변경할까요?`)) { return } setStagePickerOpen(false) await changeStage(next) } return (
{/* 헤더 */}

{org.orgNo}_{org.orgName}

{DISABLED_ACTIONS.map((label) => ( ))} {org.channelIdMj ? ( Mattermost 열기 ) : ( )}
{/* 담당자 카드 4개 (신청기관/문정원/아이티앤/변호사). 담당자 정보 입력은 담당자관리에서만 하고, 여기서는 [생성/변경]으로 이미 등록된 담당자를 골라 배정만 한다(채널관리와 같은 API). */}
setPickerRole('applicant')} fields={[ { label: '부서', value: org.applicant?.deptName ?? null }, { label: '담당자', value: org.applicant ? org.applicant.title ? `${org.applicant.name} ${org.applicant.title}` : org.applicant.name : null, }, { label: '연락처', value: org.applicant?.phone ?? null }, { label: '이메일', value: org.applicant?.email ?? null }, ]} /> setPickerRole('mj')} fields={[ { label: '부서', value: org.mj?.deptName ?? null }, { label: '담당자', value: org.mj?.name ?? null }, { label: '연락처', value: org.mj?.phone ?? null }, { label: '이메일', value: org.mj?.email ?? null }, ]} /> setPickerRole('itn')} fields={[ { label: '부서', value: org.itn?.deptName ?? null }, { label: '담당자', value: org.itn?.name ?? null }, { label: '연락처', value: org.itn?.phone ?? null }, { label: '이메일', value: org.itn?.email ?? null }, ]} /> setPickerRole('lawyer')} fields={[ { label: '성명', value: org.lawyer?.name ?? null }, { label: '연락처', value: org.lawyer?.phone ?? null }, { label: '이메일', value: org.lawyer?.email ?? null }, { label: '배정일', value: org.lawyerAssignedDate }, ]} />
{assignError &&

{assignError}

} {pickerRole && ( void assign(pickerRole, contact)} onClose={() => setPickerRole(null)} /> )} {/* 업무단계 현황: 완료된 단계는 초록 체크, 현재 단계는 파란 강조 + 클릭 시 다음 단계로 전진(확인 후), 그 뒤 단계는 회색 그대로. 채널 생성 전(stage === null)에는 전부 회색이다. */}

업무단계 현황

    {STAGE_LABELS.map((step, i) => { const n = i + 1 const isDone = org.stage !== null && n < org.stage const isCurrent = org.stage !== null && n === org.stage const clickable = isCurrent && n < 12 return (
  1. ) })}
{org.stage === null && (

채널 생성 시 신청 단계가 시작됩니다

)} {stageError &&

{stageError}

}
{/* 통계 4칸. 권리확인/권리처리는 실제 완료·전체 건수를 보여주고, 나머지는 아직 백엔드가 없어 자리만 잡아 둔다. */}

사전검토

검토 대상 -

권리확인

{reviewStats ? `완료 ${reviewStats.done} / 전체 ${reviewStats.total}` : ( <> 완료 / 전체 - )}

권리처리

{processStats ? `완료 ${processStats.done} / 전체 ${processStats.total}` : ( <> 완료 / 전체 - )}

RE

확인·처리 합계 -

{/* 탭 */}
{activeTab === '채팅' ? (
) : activeTab === '자료' ? (
) : activeTab === '업무메모' ? (
) : activeTab === '권리확인' ? (
) : activeTab === '권리처리' ? (
) : activeTab === '타임라인' ? (
) : (
{activeTab} 상세 화면은 추후 확정
)}
{stagePickerOpen && ( void pickStage(n)} onClose={() => setStagePickerOpen(false)} /> )}
) } /** [진행단계 변경] 버튼이 여는 모달. 12개 단계 중 하나를 골라 바로 그 단계로 변경한다 * (현재 단계는 굵게 표시). ContactPickerModal과 같은 오버레이/닫기 규칙을 따른다. */ function StagePickerModal({ currentStage, busy, onPick, onClose, }: { currentStage: number | null busy: boolean onPick: (stage: number) => void onClose: () => void }) { return (

진행단계 변경

    {STAGE_LABELS.map((label, i) => { const n = i + 1 const isCurrent = currentStage === n return (
  • ) })}
) }