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,
updateContact,
type Contact,
type Org,
type ProvisionResult,
} from '../api/client'
import ConfirmDialog from './ConfirmDialog'
import StatusBadge from './StatusBadge'
// 엑셀처럼 한 사람이 한 행이다. 셀이 null이면 그 역할에 해당 항목이 없다는 뜻(- 표시).
// aria-label은 "행 제목 + 항목"으로 유일하게 만들어 라벨 중복 없이 접근 가능하게 한다.
const CONTACT_COLUMNS = ['부서명', '담당자명', '직급/직함', '연락처', '이메일', '배정일']
interface ContactCell {
key: keyof Contact
label: string
type?: string
}
const CONTACT_ROWS: { title: string; cells: (ContactCell | null)[] }[] = [
{
title: '신청기관 담당자',
cells: [
{ key: 'deptName', label: '신청기관 담당자 부서명' },
{ key: 'managerName', label: '신청기관 담당자 담당자명' },
{ key: 'managerTitle', label: '신청기관 담당자 직급/직함' },
{ key: 'managerPhone', label: '신청기관 담당자 연락처' },
{ key: 'managerEmail', label: '신청기관 담당자 이메일' },
null,
],
},
{
title: '문정원 담당자',
cells: [
{ key: 'mjDeptName', label: '문정원 담당자 부서명' },
{ key: 'mjManagerName', label: '문정원 담당자 담당자명' },
null,
{ key: 'mjManagerPhone', label: '문정원 담당자 연락처' },
{ key: 'mjManagerEmail', label: '문정원 담당자 이메일' },
null,
],
},
{
title: '담당 변호사',
cells: [
null,
{ key: 'lawyerName', label: '담당 변호사 성명' },
null,
{ key: 'lawyerPhone', label: '담당 변호사 연락처' },
{ key: 'lawyerEmail', label: '담당 변호사 이메일' },
{ key: 'lawyerAssignedDate', label: '담당 변호사 배정일', type: 'date' },
],
},
]
function emptyContact(org: Org): Contact {
return {
deptName: org.deptName ?? '',
managerName: org.managerName ?? '',
managerTitle: org.managerTitle ?? '',
managerPhone: org.managerPhone ?? '',
managerEmail: org.managerEmail ?? '',
mjDeptName: org.mjDeptName ?? '',
mjManagerName: org.mjManagerName ?? '',
mjManagerPhone: org.mjManagerPhone ?? '',
mjManagerEmail: org.mjManagerEmail ?? '',
lawyerName: org.lawyerName ?? '',
lawyerPhone: org.lawyerPhone ?? '',
lawyerEmail: org.lawyerEmail ?? '',
lawyerAssignedDate: org.lawyerAssignedDate ?? '',
}
}
const REQUIRED_FIELDS: (keyof Contact)[] = [
'deptName',
'managerName',
'managerPhone',
'managerEmail',
]
function ContactEditTable({
contact,
onChange,
}: {
contact: Contact
onChange: (key: keyof Contact, value: string) => void
}) {
return (
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table className="w-full min-w-[860px] text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
<th className="whitespace-nowrap px-3 py-2 font-medium">구분</th>
{CONTACT_COLUMNS.map((col) => (
<th key={col} className="whitespace-nowrap px-3 py-2 font-medium">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{CONTACT_ROWS.map((row) => (
<tr key={row.title} className="border-b border-gray-100 last:border-b-0">
<th
scope="row"
className="whitespace-nowrap px-3 py-2 text-left text-sm font-medium text-gray-700"
>
{row.title}
</th>
{row.cells.map((cell, i) => (
<td key={cell?.key ?? `empty-${i}`} className="px-2 py-1.5">
{cell ? (
<input
aria-label={cell.label}
type={cell.type ?? 'text'}
className="w-full min-w-[7rem] rounded-md border border-gray-300 px-2.5 py-1.5 text-sm"
value={contact[cell.key]}
onChange={(e) => onChange(cell.key, e.target.value)}
/>
) : (
<span className="block text-center text-gray-300">-</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export default function OrgDetail({ org, onChanged }: { org: Org; onChanged: () => void }) {
const [contact, setContact] = useState<Contact>(emptyContact(org))
const [confirming, setConfirming] = useState(false)
const [result, setResult] = useState<ProvisionResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
// org.id로만 키를 잡는다(org 객체 전체가 아니라) - App.reload()가 채널 생성 뒤에
// 새 배열/새 객체를 만들어 넘기더라도, 같은 기관을 계속 보고 있는 한 방금 받은
// result/error를 이 effect가 지워버리면 안 되기 때문이다(같은 이유로 입력 중이던
// contact 값도 다른 기관으로 전환할 때만 초기화한다).
useEffect(() => {
setContact(emptyContact(org))
setResult(null)
setError(null)
// 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'
const requiredFilled = REQUIRED_FIELDS.every((key) => contact[key].trim() !== '')
function setField(key: keyof Contact, value: string) {
setContact((prev) => ({ ...prev, [key]: value }))
}
async function save() {
setError(null)
setBusy(true)
try {
await updateContact(org.id, contact)
onChanged()
} catch (e) {
setError((e as Error).message)
} finally {
setBusy(false)
}
}
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">
<ContactEditTable contact={contact} onChange={setField} />
<p className="mt-2 text-xs text-gray-400">
신청기관 담당자의 부서명·담당자명·연락처·이메일은 채널 생성을 위해 필수입니다.
</p>
</section>
<div className="mt-5 flex gap-2">
<button
type="button"
disabled={busy || !requiredFilled}
onClick={() => void save()}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
>
정보 저장
</button>
<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)}
/>
)}
</div>
)
}
function outcomeLabel(outcome: ProvisionResult['mj']): string {
switch (outcome) {
case 'CACHED':
return '이미 있음 (변경 없음)'
case 'RECOVERED':
return '기존 채널 연결됨'
case 'CREATED':
return '새로 생성됨'
case 'FAILED':
return '실패'
}
}