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, useRef, useState } from 'react'
import {
createContact,
deleteContact,
getContacts,
importMembers,
updateContactInfo,
type Contact,
type ContactCategory,
type ContactInput,
type MemberImportReport,
} from '../api/client'
import { useCodes } from '../codes/useCodes'
import { badgeClass } from '../codes/tone'
import HelpButton from '../help/HelpButton'
// 구분 5종(표시명·뱃지색·필터·드롭다운)은 예전에 이 파일 안에서만 네 번 따로 적혀 있었다.
// 지금은 전부 코드표(CONTACT_CATEGORY)에서 온다.
function emptyForm(contact: Contact | null): ContactInput {
return {
category: contact?.category ?? 'APPLICANT',
name: contact?.name ?? '',
affiliation: contact?.affiliation ?? '',
deptName: contact?.deptName ?? '',
title: contact?.title ?? '',
phone: contact?.phone ?? '',
email: contact?.email ?? '',
}
}
function ContactFormModal({
editing,
onClose,
onSaved,
}: {
editing: Contact | null
onClose: () => void
onSaved: () => void
}) {
const [form, setForm] = useState<ContactInput>(emptyForm(editing))
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const categories = useCodes('CONTACT_CATEGORY')
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
const canSave = form.category.trim() !== '' && form.name.trim() !== '' && !busy
function setField(key: keyof ContactInput, value: string) {
setForm((prev) => ({ ...prev, [key]: value }))
}
async function handleSave() {
if (!canSave) {
return
}
setBusy(true)
setError(null)
try {
if (editing) {
await updateContactInfo(editing.id, form)
} else {
await createContact(form)
}
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : '저장에 실패했습니다.')
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/30">
<div className="w-[26rem] rounded-lg bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">{editing ? '담당자 수정' : '담당자 추가'}</h2>
<button
type="button"
onClick={onClose}
aria-label="닫기"
disabled={busy}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 disabled:opacity-50"
>
✕
</button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div className="flex flex-col gap-1">
<label htmlFor="contact-category" className="text-xs text-gray-500">
구분
</label>
<select
id="contact-category"
value={form.category}
onChange={(e) => setField('category', e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5"
>
{categories.map((category) => (
<option key={category.code} value={category.code}>
{category.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="contact-name" className="text-xs text-gray-500">
성명
</label>
<input
id="contact-name"
value={form.name}
onChange={(e) => setField('name', e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="contact-affiliation" className="text-xs text-gray-500">
소속
</label>
<input
id="contact-affiliation"
value={form.affiliation}
onChange={(e) => setField('affiliation', e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="contact-dept" className="text-xs text-gray-500">
부서
</label>
<input
id="contact-dept"
value={form.deptName}
onChange={(e) => setField('deptName', e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="contact-title" className="text-xs text-gray-500">
직급/직함
</label>
<input
id="contact-title"
value={form.title}
onChange={(e) => setField('title', e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="contact-phone" className="text-xs text-gray-500">
연락처
</label>
<input
id="contact-phone"
value={form.phone}
onChange={(e) => setField('phone', e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5"
/>
</div>
<div className="col-span-2 flex flex-col gap-1">
<label htmlFor="contact-email" className="text-xs text-gray-500">
이메일
</label>
<input
id="contact-email"
value={form.email}
onChange={(e) => setField('email', e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5"
/>
</div>
</div>
{error && <p className="mt-3 text-xs text-red-600">{error}</p>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
disabled={busy}
onClick={onClose}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
>
취소
</button>
<button
type="button"
disabled={!canSave}
onClick={() => void handleSave()}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
>
저장
</button>
</div>
</div>
</div>
)
}
/** 담당자관리 화면. 담당자 정보가 입력되는 유일한 곳이다 - 채널관리는 여기 등록된
* 담당자를 ContactPickerModal로 골라 배정만 한다. */
export default function ContactsPage() {
const [contacts, setContacts] = useState<Contact[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<ContactCategory | 'ALL'>('ALL')
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<Contact | null>(null)
const [error, setError] = useState<string | null>(null)
const importInputRef = useRef<HTMLInputElement>(null)
const [importBusy, setImportBusy] = useState(false)
const [importReport, setImportReport] = useState<MemberImportReport | null>(null)
const [importError, setImportError] = useState<string | null>(null)
const categories = useCodes('CONTACT_CATEGORY')
/** 코드표에 없는 구분값이 저장돼 있어도 뱃지가 사라지지 않도록 undefined를 허용한다. */
function categoryOf(category: ContactCategory) {
return categories.find((candidate) => candidate.code === category)
}
// '전체'는 코드가 아니라 화면에서만 쓰는 필터 항목이라 코드표에 넣지 않고 여기서 앞에 붙인다.
const filters: { key: ContactCategory | 'ALL'; label: string }[] = [
{ key: 'ALL', label: '전체' },
...categories.map((category) => ({
key: category.code as ContactCategory,
label: category.label,
})),
]
async function load() {
setLoading(true)
try {
setContacts(await getContacts())
} catch (e) {
setError(e instanceof Error ? e.message : '담당자 목록을 불러오지 못했습니다.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const visible = filter === 'ALL' ? contacts : contacts.filter((c) => c.category === filter)
function openAdd() {
setEditing(null)
setModalOpen(true)
}
function openEdit(contact: Contact) {
setEditing(contact)
setModalOpen(true)
}
function closeModal() {
setModalOpen(false)
setEditing(null)
}
async function handleSaved() {
closeModal()
await load()
}
async function handleDelete(contact: Contact) {
if (!window.confirm('삭제하면 기관에 지정된 배정도 함께 해제됩니다. 삭제할까요?')) {
return
}
try {
await deleteContact(contact.id)
await load()
} catch (e) {
setError(e instanceof Error ? e.message : '삭제에 실패했습니다.')
}
}
async function handleImportFile(file: File) {
setImportBusy(true)
setImportError(null)
try {
const report = await importMembers(file)
setImportReport(report)
await load()
} catch (e) {
setImportError(e instanceof Error ? e.message : '회원명단 업로드에 실패했습니다.')
} finally {
setImportBusy(false)
}
}
return (
<div className="p-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{filters.map((f) => (
<button
key={f.key}
type="button"
onClick={() => setFilter(f.key)}
aria-current={filter === f.key ? 'true' : undefined}
className={`rounded-full px-3 py-1.5 text-sm ${
filter === f.key
? 'bg-gray-900 text-white'
: 'border border-gray-300 text-gray-600 hover:bg-gray-50'
}`}
>
{f.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
ref={importInputRef}
type="file"
accept=".xlsx,.xlsm"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void handleImportFile(file)
e.target.value = ''
}}
/>
<button
type="button"
disabled={importBusy}
onClick={() => importInputRef.current?.click()}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{importBusy ? '업로드 중…' : '회원명단 업로드'}
</button>
<button
type="button"
onClick={openAdd}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
담당자 추가
</button>
<span className="flex items-center">
<HelpButton topic="contacts.manage" />
</span>
</div>
</div>
{importReport && (
<p className="mt-4 text-sm text-gray-600">
{`등록 ${importReport.created} · 갱신 ${importReport.updated} · 기관지정 ${importReport.assigned} · 건너뜀 ${importReport.skipped}`}
</p>
)}
{importError && <p className="mt-4 text-sm text-red-600">{importError}</p>}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
<div className="mt-5 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>
<th className="whitespace-nowrap px-3 py-2 font-medium">성명</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">소속</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">부서</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">직급/직함</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">연락처</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">이메일</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">관리</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="py-8 text-center text-sm text-gray-400">
불러오는 중…
</td>
</tr>
) : visible.length === 0 ? (
<tr>
<td colSpan={8} className="py-8 text-center text-sm text-gray-300">
등록된 담당자가 없습니다.
</td>
</tr>
) : (
visible.map((contact) => (
<tr key={contact.id} className="border-b border-gray-100 last:border-b-0">
<td className="px-3 py-2">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(
categoryOf(contact.category)?.attrs.tone as string | undefined,
)}`}
>
{categoryOf(contact.category)?.label ?? contact.category}
</span>
</td>
<td className="px-3 py-2 font-medium text-gray-900">{contact.name}</td>
<td className="px-3 py-2 text-gray-600">{contact.affiliation ?? '-'}</td>
<td className="px-3 py-2 text-gray-600">{contact.deptName ?? '-'}</td>
<td className="px-3 py-2 text-gray-600">{contact.title ?? '-'}</td>
<td className="px-3 py-2 text-gray-600">{contact.phone ?? '-'}</td>
<td className="px-3 py-2 text-gray-600">{contact.email ?? '-'}</td>
<td className="px-3 py-2">
<div className="flex gap-2">
<button
type="button"
onClick={() => openEdit(contact)}
className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
수정
</button>
<button
type="button"
onClick={() => void handleDelete(contact)}
className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
삭제
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{modalOpen && (
<ContactFormModal editing={editing} onClose={closeModal} onSaved={() => void handleSaved()} />
)}
</div>
)
}