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 { useState } from 'react'
import {
updateAssignments,
type Contact,
type ContactCategory,
type Org,
} from '../api/client'
import ChannelFiles from './ChannelFiles'
import ChannelPosts from './ChannelPosts'
import ContactPickerModal from './ContactPickerModal'
import StatusBadge from './StatusBadge'
import WorkMemos from './WorkMemos'
// 기관관리 상세 화면. 3단계 대시보드 시안의 "기관관리" 레이아웃을 따른다.
// 웹 DB에 있는 것(연번·기관명·담당자 3종·채널)만 실데이터고,
// 업무단계/통계/일부 탭 내용은 아직 백엔드가 없어 시안과 같은 골격에
// "추후 연동" 자리만 잡아 둔다.
const STEPS = [
'신청',
'목록접수',
'예비검토',
'변호사 배당',
'법률검토(권리확인)',
'RE:확인',
'법률검토(권리확인) 완료',
'법률검토(권리처리)',
'RE:처리',
'법률검토(권리처리) 완료',
'법률검토 최종완료',
'보고서 작성',
]
const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모']
// 담당 변호사 변경은 카드의 [생성/변경] 버튼으로 대체되어 목록에서 뺐다
const DISABLED_ACTIONS = ['진행단계 변경', '자료 업로드', '최초 게시글 보기', '보고서 관리']
// 카드에서 담당자를 지정/변경할 때 쓰는 역할 정의 (채널관리의 배정과 같은 API를 쓴다)
const CARD_ROLES: Record<
'applicant' | 'mj' | 'lawyer',
{ category: ContactCategory; pickerTitle: string }
> = {
applicant: { category: 'APPLICANT', pickerTitle: '신청기관 담당자 선택' },
mj: { category: 'MJ', 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`
}
function Field({ label, value }: { label: string; value: string | null }) {
return (
<div className="flex gap-3 text-sm">
<span className="w-14 shrink-0 text-gray-400">{label}</span>
<span className={value ? 'font-medium text-gray-900' : 'text-gray-300'}>
{value ?? '-'}
</span>
</div>
)
}
function ContactCard({
title,
fields,
assigned,
busy,
onEdit,
}: {
title: string
fields: { label: string; value: string | null }[]
assigned: boolean
busy: boolean
onEdit: () => void
}) {
return (
<section className="rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">{title}</h2>
<button
type="button"
disabled={busy}
aria-label={`${title} ${assigned ? '변경' : '생성'}`}
onClick={onEdit}
className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
{assigned ? '변경' : '생성'}
</button>
</div>
<div className="mt-3 space-y-1.5">
{fields.map((f) => (
<Field key={f.label} label={f.label} value={f.value} />
))}
</div>
</section>
)
}
export default function OrgOverview({
org,
onChanged,
}: {
org: Org
onChanged?: () => void
}) {
const [activeTab, setActiveTab] = useState(TABS[0])
const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'lawyer' | null>(null)
const [assignBusy, setAssignBusy] = useState(false)
const [assignError, setAssignError] = useState<string | null>(null)
async function assign(role: 'applicant' | 'mj' | '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),
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)
}
}
return (
<div className="p-6">
<div className="rounded-xl border border-gray-200 bg-white p-6">
{/* 헤더 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="text-lg font-bold">
{org.orgNo}_{org.orgName}
</h1>
<StatusBadge status={org.status} />
</div>
<div className="flex flex-wrap gap-2">
{DISABLED_ACTIONS.map((label) => (
<button
key={label}
type="button"
disabled
title="준비 중"
className="cursor-not-allowed rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-300"
>
{label}
</button>
))}
{org.channelIdMj ? (
<a
href={mattermostChannelUrl(org)}
target="_blank"
rel="noreferrer"
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
Mattermost 열기
</a>
) : (
<button
type="button"
disabled
title="채널이 아직 없습니다"
className="cursor-not-allowed rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-300"
>
Mattermost 열기
</button>
)}
</div>
</div>
{/* 담당자 카드 3개 (신청기관/문정원/변호사). 담당자 정보 입력은 담당자관리에서만 하고,
여기서는 [생성/변경]으로 이미 등록된 담당자를 골라 배정만 한다(채널관리와 같은 API). */}
<div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3">
<ContactCard
title="신청기관 담당자"
assigned={org.applicant !== null}
busy={assignBusy}
onEdit={() => 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 },
]}
/>
<ContactCard
title="문정원 담당자"
assigned={org.mj !== null}
busy={assignBusy}
onEdit={() => 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 },
]}
/>
<ContactCard
title="담당 변호사"
assigned={org.lawyer !== null}
busy={assignBusy}
onEdit={() => 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 },
]}
/>
</div>
{assignError && <p className="mt-2 text-sm text-red-600">{assignError}</p>}
{pickerRole && (
<ContactPickerModal
category={CARD_ROLES[pickerRole].category}
title={CARD_ROLES[pickerRole].pickerTitle}
onSelect={(contact) => void assign(pickerRole, contact)}
onClose={() => setPickerRole(null)}
/>
)}
{/* 업무단계 현황 */}
<section className="mt-5">
<h2 className="text-sm font-semibold">업무단계 현황</h2>
<ol className="mt-3 flex gap-2 overflow-x-auto pb-2">
{STEPS.map((step, i) => (
<li
key={step}
className="flex w-24 shrink-0 flex-col items-center gap-1 rounded-lg border border-gray-200 px-2 py-3 text-center"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full border border-gray-300 text-xs text-gray-500">
{i + 1}
</span>
<span className="text-[11px] leading-tight text-gray-600">{step}</span>
<span className="text-[11px] text-gray-300">-</span>
</li>
))}
</ol>
</section>
{/* 통계 4칸 */}
<div className="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
{[
['사전검토', '검토 대상'],
['권리확인', '완료 / 전체'],
['권리처리', '완료 / 전체'],
['RE', '확인·처리 합계'],
].map(([title, caption]) => (
<div key={title} className="rounded-lg border border-gray-200 p-4">
<p className="text-xs text-gray-400">{title}</p>
<p className="mt-1 text-sm font-semibold">
{caption} <span className="ml-1 text-lg text-gray-300">-</span>
</p>
</div>
))}
</div>
{/* 탭 */}
<div className="mt-6 border-b border-gray-200">
<nav className="flex gap-1" aria-label="기관 상세 탭">
{TABS.map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
aria-current={activeTab === tab ? 'true' : undefined}
className={`border-b-2 px-4 py-2 text-sm ${
activeTab === tab
? 'border-blue-600 font-semibold text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab}
</button>
))}
</nav>
</div>
{activeTab === '채팅' ? (
<div className="mt-4 rounded-lg border border-gray-200">
<ChannelPosts key={org.id} org={org} />
</div>
) : activeTab === '자료' ? (
<div className="mt-4 rounded-lg border border-gray-200">
<ChannelFiles key={org.id} org={org} />
</div>
) : activeTab === '업무메모' ? (
<div className="mt-4 rounded-lg border border-gray-200">
<WorkMemos key={org.id} org={org} />
</div>
) : (
<div className="mt-4 rounded-lg border border-dashed border-gray-200 p-10 text-center text-sm text-gray-400">
{activeTab} 상세 화면은 추후 확정
</div>
)}
</div>
</div>
)
}