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 { useEffect, useState } from 'react'
import {
provisionChannels,
updateAssignments,
type AssignmentInput,
type Contact,
type ContactCategory,
type Org,
type ProvisionResult,
} from '../api/client'
import ConfirmDialog from './ConfirmDialog'
import ContactPickerModal from './ContactPickerModal'
import StatusBadge from './StatusBadge'
// 채널관리 상세. 담당자 정보는 여기서 입력하지 않는다(담당자관리에서만 입력) - 이 화면은
// 이미 등록된 담당자를 ContactPickerModal로 골라 신청기관/문정원/변호사 역할에 배정만 한다.
interface RoleConfig {
key: 'applicant' | 'mj' | 'lawyer'
category: ContactCategory
label: string
pickerTitle: string
field: keyof AssignmentInput
}
const ROLES: RoleConfig[] = [
{
key: 'applicant',
category: 'APPLICANT',
label: '신청기관 담당자',
pickerTitle: '신청기관 담당자 선택',
field: 'applicantContactId',
},
{
key: 'mj',
category: 'MJ',
label: '문정원 담당자',
pickerTitle: '문정원 담당자 선택',
field: 'mjContactId',
},
{
key: 'lawyer',
category: 'LAWYER',
label: '담당 변호사',
pickerTitle: '담당 변호사 선택',
field: 'lawyerContactId',
},
]
function contactOf(org: Org, role: RoleConfig): Contact | null {
switch (role.key) {
case 'applicant':
return org.applicant
case 'mj':
return org.mj
case 'lawyer':
return org.lawyer
}
}
/** 배정된 담당자의 한 줄 요약: 이름 · 부서(또는 소속) · 연락처. 값이 없는 항목은 건너뛴다. */
function contactSummary(contact: Contact): string {
const parts = [contact.name, contact.deptName ?? contact.affiliation ?? null, contact.phone]
return parts.filter((p): p is string => Boolean(p)).join(' · ')
}
export default function OrgDetail({ org, onChanged }: { org: Org; onChanged: () => void }) {
const [confirming, setConfirming] = useState(false)
const [result, setResult] = useState<ProvisionResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [pickerRole, setPickerRole] = useState<RoleConfig | null>(null)
const [lawyerDate, setLawyerDate] = useState(org.lawyerAssignedDate ?? '')
// org.id로만 키를 잡는다(org 객체 전체가 아니라) - App.reload()가 채널 생성/배정 뒤에
// 새 배열/새 객체를 만들어 넘기더라도, 같은 기관을 계속 보고 있는 한 방금 받은
// result/error를 이 effect가 지워버리면 안 되기 때문이다.
useEffect(() => {
setResult(null)
setError(null)
setLawyerDate(org.lawyerAssignedDate ?? '')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [org.id])
const displayMj = `${org.orgNo}_${org.orgName} (문정원)`
const displayLaw = `${org.orgNo}_${org.orgName} (법률검토)`
const canProvision = org.status !== 'INFO_PENDING'
function currentAssignment(): AssignmentInput {
return {
applicantContactId: org.applicant?.id ?? null,
mjContactId: org.mj?.id ?? null,
lawyerContactId: org.lawyer?.id ?? null,
lawyerAssignedDate: org.lawyerAssignedDate ?? null,
}
}
/** 매 호출이 세 배정값 + 배정일을 통째로 다시 보낸다(부분 갱신이 아니다). */
async function persistAssignments(overrides: Partial<AssignmentInput>) {
setError(null)
setBusy(true)
try {
await updateAssignments(org.id, { ...currentAssignment(), ...overrides })
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : '저장에 실패했습니다.')
} finally {
setBusy(false)
}
}
async function handleSelect(role: RoleConfig, contact: Contact) {
setPickerRole(null)
await persistAssignments({ [role.field]: contact.id })
}
async function handleClear(role: RoleConfig) {
await persistAssignments({ [role.field]: null })
}
async function handleLawyerDateChange(value: string) {
setLawyerDate(value)
await persistAssignments({ lawyerAssignedDate: value === '' ? null : value })
}
async function provision() {
setError(null)
setBusy(true)
try {
const provisionResult = await provisionChannels(org.id)
setResult(provisionResult)
onChanged()
} catch (e) {
setError((e as Error).message)
} finally {
setBusy(false)
setConfirming(false)
}
}
return (
<div className="p-8">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">
{org.orgNo} {org.orgName}
</h1>
<StatusBadge status={org.status} />
</div>
<section className="mt-6 space-y-3">
{ROLES.map((role) => {
const contact = contactOf(org, role)
return (
<div
key={role.key}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-4"
>
<div className="flex flex-col gap-1">
<span className="text-xs text-gray-500">{role.label}</span>
{contact ? (
<span className="text-sm font-medium text-gray-900">{contactSummary(contact)}</span>
) : (
<span className="text-sm text-gray-300">미지정</span>
)}
</div>
<div className="flex items-end gap-2">
{role.key === 'lawyer' && (
<div className="flex flex-col gap-1">
<label htmlFor="lawyer-assigned-date" className="text-xs text-gray-500">
배정일
</label>
<input
id="lawyer-assigned-date"
type="date"
disabled={busy}
value={lawyerDate}
onChange={(e) => void handleLawyerDateChange(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1 text-sm"
/>
</div>
)}
<button
type="button"
disabled={busy}
aria-label={`${role.label} 선택`}
onClick={() => setPickerRole(role)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
>
선택
</button>
{contact && (
<button
type="button"
disabled={busy}
aria-label={`${role.label} 해제`}
onClick={() => void handleClear(role)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-500 disabled:opacity-50"
>
해제
</button>
)}
</div>
</div>
)
})}
<p className="text-xs text-gray-400">신청기관 담당자를 지정해야 채널을 만들 수 있습니다.</p>
</section>
<div className="mt-5 flex gap-2">
<button
type="button"
disabled={busy || !canProvision}
onClick={() => setConfirming(true)}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
>
채널 생성
</button>
</div>
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
{result && (
<div className="mt-4 max-w-xl rounded-lg border border-gray-200 p-4 text-sm">
<p>문정원 채널: {outcomeLabel(result.mj)}</p>
<p>법률검토 채널: {outcomeLabel(result.law)}</p>
{result.mj === 'FAILED' && result.law === 'FAILED' ? (
<p className="mt-2 text-amber-700">
문정원 채널과 법률검토 채널 모두 생성에 실패했습니다. 아직 만들어진 채널이 없으니 [채널 생성]을 다시 눌러도 안전합니다.
</p>
) : (
<>
{result.law === 'FAILED' && (
<p className="mt-2 text-amber-700">
법률검토 채널 생성에 실패했습니다. 문정원 채널은 그대로 남아 있으니
[채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다.
</p>
)}
{result.mj === 'FAILED' && (
<p className="mt-2 text-amber-700">
문정원 채널 생성에 실패했습니다. 법률검토 채널은 그대로 남아 있으니
[채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다.
</p>
)}
</>
)}
{result.message && (
<pre className="mt-2 whitespace-pre-wrap text-xs text-gray-500">{result.message}</pre>
)}
</div>
)}
{confirming && (
<ConfirmDialog
title="아래 채널 2개를 Mattermost에 생성합니다."
lines={[displayMj, displayLaw]}
confirmLabel="생성"
busy={busy}
onConfirm={() => void provision()}
onCancel={() => setConfirming(false)}
/>
)}
{pickerRole && (
<ContactPickerModal
category={pickerRole.category}
title={pickerRole.pickerTitle}
onSelect={(contact) => void handleSelect(pickerRole, contact)}
onClose={() => setPickerRole(null)}
/>
)}
</div>
)
}
function outcomeLabel(outcome: ProvisionResult['mj']): string {
switch (outcome) {
case 'CACHED':
return '이미 있음 (변경 없음)'
case 'RECOVERED':
return '기존 채널 연결됨'
case 'CREATED':
return '새로 생성됨'
case 'FAILED':
return '실패'
}
}