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 {
getNotice,
getProcessPage,
getReviewPage,
getStageHistory,
updateAssignments,
updateStage,
type Contact,
type ContactCategory,
type Org,
type PostView,
} from '../api/client'
import { STAGE_LABELS, stageLabel } from '../stages'
import HelpButton from '../help/HelpButton'
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', '타임라인', '업무메모']
/** 탭마다 그 탭 기능의 설명서를 띄운다. RE는 화면이 아직 없어 도움말도 없다. */
const TAB_HELP: Record<string, string> = {
'채팅': 'org.chat',
'자료': 'org.files',
'권리확인': 'org.review',
'권리처리': 'org.process',
'타임라인': 'org.timeline',
'업무메모': 'org.memo',
}
// 담당 변호사 변경은 카드의 [생성/변경] 버튼으로 대체되어 목록에서 뺐다.
// 진행단계 변경은 업무단계 현황 스트립 + 이 버튼의 모달로 실제 동작한다.
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 (
<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' | 'itn' | 'lawyer' | null>(null)
const [assignBusy, setAssignBusy] = useState(false)
const [assignError, setAssignError] = useState<string | null>(null)
const [stageBusy, setStageBusy] = useState(false)
const [stageError, setStageError] = useState<string | null>(null)
const [stagePickerOpen, setStagePickerOpen] = useState(false)
/** [최초 게시글 보기]가 띄우는 핀 게시물. 채널별로 따로 담는다. */
const [firstPosts, setFirstPosts] = useState<{ mj: PostView | null; law: PostView | null } | null>(null)
const [firstPostsBusy, setFirstPostsBusy] = useState(false)
const [firstPostsError, setFirstPostsError] = useState<string | null>(null)
// 단계별로 '그 단계에 들어간 날짜'(가장 최근 이력)를 찍기 위한 조회 결과
const [stageDates, setStageDates] = useState<Record<number, number>>({})
// 통계 카드용 권리확인/권리처리 완료·전체 건수. 탭을 열지 않아도 헤더에 바로 보여야 하므로
// 각 탭이 마운트되기 전에 가볍게(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<number, number> = {}
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)
}
}
/**
* 채널에 고정(핀)된 게시물을 두 채널에서 가져온다. 채널 생성 때 올린 최초 안내글이
* 곧 그 채널의 공지라서, 요청자가 말한 "최초 자료 등록 + 핀 고정한 게시물"이 이것이다.
*/
async function openFirstPosts() {
setFirstPostsBusy(true)
setFirstPostsError(null)
setFirstPosts({ mj: null, law: null })
try {
const [mj, law] = await Promise.all([
org.channelIdMj ? getNotice(org.id, 'mj') : Promise.resolve(null),
org.channelIdLaw ? getNotice(org.id, 'law') : Promise.resolve(null),
])
setFirstPosts({ mj, law })
} catch (e) {
setFirstPostsError(e instanceof Error ? e.message : '게시글을 불러오지 못했습니다.')
} finally {
setFirstPostsBusy(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 (
<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">
<button
type="button"
disabled={stageBusy}
onClick={() => setStagePickerOpen(true)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
진행단계 변경
</button>
<span className="flex items-center">
<HelpButton topic="org.stage" />
</span>
<button
type="button"
disabled={!org.channelIdMj && !org.channelIdLaw}
title={!org.channelIdMj && !org.channelIdLaw ? '채널을 먼저 생성하세요' : undefined}
onClick={() => void openFirstPosts()}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-300"
>
최초 게시글 보기
</button>
<span className="flex items-center">
<HelpButton topic="org.firstPost" />
</span>
{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>
{/* 담당자 카드 4개 (신청기관/문정원/아이티앤/변호사). 담당자 정보 입력은 담당자관리에서만 하고,
여기서는 [생성/변경]으로 이미 등록된 담당자를 골라 배정만 한다(채널관리와 같은 API). */}
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<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.itn !== null}
busy={assignBusy}
onEdit={() => 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 },
]}
/>
<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)}
/>
)}
{/* 업무단계 현황: 완료된 단계는 초록 체크, 현재 단계는 파란 강조 + 클릭 시 다음 단계로
전진(확인 후), 그 뒤 단계는 회색 그대로. 채널 생성 전(stage === null)에는 전부 회색이다. */}
<section className="mt-5">
<h2 className="flex items-center gap-1.5 text-sm font-semibold">
업무단계 현황
<HelpButton topic="org.stage" />
</h2>
<ol className="mt-3 flex gap-2 overflow-x-auto pb-2">
{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 (
<li key={step} className="shrink-0">
<button
type="button"
disabled={!clickable || stageBusy}
title={isCurrent && n >= 12 ? '마지막 단계입니다' : undefined}
onClick={() => {
if (isCurrent) {
void advanceStage()
}
}}
className={`flex w-24 flex-col items-center gap-1 rounded-lg border px-2 py-3 text-center ${
isDone
? 'border-emerald-200 bg-emerald-50'
: isCurrent
? 'border-blue-400 bg-blue-50'
: 'border-gray-200'
} ${clickable ? 'cursor-pointer hover:bg-blue-100' : 'cursor-default disabled:opacity-100'}`}
>
<span
className={`flex h-6 w-6 items-center justify-center rounded-full border text-xs ${
isDone
? 'border-emerald-500 bg-emerald-500 text-white'
: isCurrent
? 'border-blue-600 bg-blue-600 text-white'
: 'border-gray-300 text-gray-500'
}`}
>
{isDone ? '✓' : n}
</span>
<span
className={`text-[11px] leading-tight ${
isDone ? 'text-emerald-700' : isCurrent ? 'font-semibold text-blue-700' : 'text-gray-600'
}`}
>
{step}
</span>
{isCurrent ? (
<>
<span className="text-[11px] font-semibold text-blue-600">진행중</span>
<span className="text-[10px] text-blue-500">
{stageDates[n] ? formatStageDate(stageDates[n]) : ''}
</span>
</>
) : isDone && stageDates[n] ? (
<span className="text-[10px] text-emerald-600">
{formatStageDate(stageDates[n])}
</span>
) : (
<span className="text-[11px] text-gray-300">-</span>
)}
</button>
</li>
)
})}
</ol>
{org.stage === null && (
<p className="mt-1 text-xs text-gray-400">채널 생성 시 신청 단계가 시작됩니다</p>
)}
{stageError && <p className="mt-1 text-xs text-red-600">{stageError}</p>}
</section>
{/* 통계 4칸. 권리확인/권리처리는 실제 완료·전체 건수를 보여주고, 나머지는 아직 백엔드가
없어 자리만 잡아 둔다. */}
<div className="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-lg border border-gray-200 p-4">
<p className="text-xs text-gray-400">사전검토</p>
<p className="mt-1 text-sm font-semibold">
검토 대상 <span className="ml-1 text-lg text-gray-300">-</span>
</p>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<p className="text-xs text-gray-400">권리확인</p>
<p className="mt-1 text-sm font-semibold">
{reviewStats ? `완료 ${reviewStats.done} / 전체 ${reviewStats.total}` : (
<>
완료 / 전체 <span className="ml-1 text-lg text-gray-300">-</span>
</>
)}
</p>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<p className="text-xs text-gray-400">권리처리</p>
<p className="mt-1 text-sm font-semibold">
{processStats ? `완료 ${processStats.done} / 전체 ${processStats.total}` : (
<>
완료 / 전체 <span className="ml-1 text-lg text-gray-300">-</span>
</>
)}
</p>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<p className="text-xs text-gray-400">RE</p>
<p className="mt-1 text-sm font-semibold">
확인·처리 합계 <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>
))}
{TAB_HELP[activeTab] && (
<span className="flex items-center pl-2">
<HelpButton topic={TAB_HELP[activeTab]} />
</span>
)}
</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>
) : activeTab === '권리확인' ? (
<div className="mt-4 rounded-lg border border-gray-200">
<ReviewBoard key={org.id} org={org} />
</div>
) : activeTab === '권리처리' ? (
<div className="mt-4 rounded-lg border border-gray-200">
<ProcessBoard key={org.id} org={org} />
</div>
) : activeTab === '타임라인' ? (
<div className="mt-4 rounded-lg border border-gray-200">
<Timeline 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>
{stagePickerOpen && (
<StagePickerModal
currentStage={org.stage}
busy={stageBusy}
onPick={(n) => void pickStage(n)}
onClose={() => setStagePickerOpen(false)}
/>
)}
{firstPosts && (
<div
role="dialog"
aria-label="최초 게시글"
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-6"
>
<div className="w-full max-w-2xl rounded-lg bg-white shadow-xl">
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-5 py-4">
<div>
<h2 className="text-base font-semibold">최초 게시글</h2>
<p className="mt-1 text-xs text-gray-500">
각 채널에 고정(핀)된 게시물입니다. 채널을 만들 때 올린 최초 안내글이 여기 보입니다.
</p>
</div>
<button
type="button"
aria-label="닫기"
onClick={() => setFirstPosts(null)}
className="shrink-0 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
>
닫기
</button>
</div>
<div className="space-y-4 px-5 py-4">
{firstPostsBusy && <p className="text-sm text-gray-500">불러오는 중…</p>}
{firstPostsError && <p className="text-sm text-red-600">{firstPostsError}</p>}
{!firstPostsBusy &&
!firstPostsError &&
([
['문정원', firstPosts.mj],
['법률검토', firstPosts.law],
] as const).map(([label, post]) => (
<div key={label}>
<h3 className="text-sm font-semibold">{label} 채널</h3>
{post ? (
<div className="mt-1.5 rounded-md border border-gray-200 bg-amber-50/40 p-3">
<p className="text-xs text-gray-500">
{post.user} · {formatStageDate(post.createAt)}
</p>
<p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{post.message}</p>
{post.files.length > 0 && (
<p className="mt-2 text-xs text-gray-500">
첨부 {post.files.length}개: {post.files.map((f) => f.name).join(', ')}
</p>
)}
</div>
) : (
<p className="mt-1.5 text-sm text-gray-400">고정된 게시물이 없습니다.</p>
)}
</div>
))}
</div>
<div className="flex justify-end border-t border-gray-200 px-5 py-3">
<button
type="button"
onClick={() => {
setFirstPosts(null)
setActiveTab('채팅')
}}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
채팅 탭에서 보기
</button>
</div>
</div>
</div>
)}
</div>
)
}
/** [진행단계 변경] 버튼이 여는 모달. 12개 단계 중 하나를 골라 바로 그 단계로 변경한다
* (현재 단계는 굵게 표시). ContactPickerModal과 같은 오버레이/닫기 규칙을 따른다. */
function StagePickerModal({
currentStage,
busy,
onPick,
onClose,
}: {
currentStage: number | null
busy: boolean
onPick: (stage: number) => void
onClose: () => void
}) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-lg bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">진행단계 변경</h2>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="rounded-md p-1 text-gray-400 hover:bg-gray-100"
>
✕
</button>
</div>
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
{STAGE_LABELS.map((label, i) => {
const n = i + 1
const isCurrent = currentStage === n
return (
<li key={label}>
<button
type="button"
disabled={busy}
onClick={() => onPick(n)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50 disabled:opacity-50 ${
isCurrent ? 'font-semibold text-blue-700' : 'text-gray-700'
}`}
>
<span className="w-5 shrink-0 text-xs text-gray-400">{n}</span>
<span>{label}</span>
{isCurrent && <span className="ml-auto text-xs text-blue-500">현재</span>}
</button>
</li>
)
})}
</ul>
</div>
</div>
)
}