feat: 진행단계를 코드표 기반으로 바꾸고 13단계를 반영 (요구사항 2·7·11)
대시보드는 단계를 안 바꾸고는 코드표에 연결할 수 없는 구조였다. 칸반 컬럼 수가 단계
배열 길이에서 나오고 완료 판정이 11로 박혀 있었는데 코드표는 13을 말하기 때문이다.
프론트만 바꾸면 화면과 서버 숫자가 어긋나므로 백엔드까지 한 번에 옮긴다.
[11] 칸반 컬럼이 단계 미지정 + 13개가 된다. 단계 상한도 코드표 개수를 따른다.
[2] 완료 = ⑬보고서 제출 완료. 보고서가 실제로 올라온 기관만 센다.
[7] 진행상태에 '신청'이 생긴다. 신청은 ①만, 완료는 ⑬만, 나머지가 진행중이다.
채널이 없어 단계가 아예 없는 기관은 '단계 미지정'으로 그대로 둔다.
원문자 배열(①~⑫)과 구간 색 맵을 지웠다. 원문자는 번호에서 계산하고 색은 코드표의
tone에서 온다 - 단계가 늘 때마다 같이 늘려야 했던 자리다.
stages.ts를 지웠다. 남아 있던 소비자(기관상세·타임라인·시스템관리)도 모두 옮겼다.
동작이 바뀐 테스트 3건은 새 기준으로 다시 썼다(11단계 완료 집계, 13단계 거부,
마지막 단계 12). 도움말 문구도 13단계 기준으로 고쳤다.
백엔드 263건, 프론트 208건 전부 통과.
Co-Authored-By: Claude Opus 5 (1M context)
@0f40c99a42cf1af5c0ae9e7e47f33f39401a4527
--- frontend/src/components/Dashboard.test.tsx
+++ frontend/src/components/Dashboard.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 |
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
| 1 |
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import Dashboard from './Dashboard' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { DashboardData, DashboardOrgRow } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -56,7 +57,7 @@ |
| 56 | 57 |
} |
| 57 | 58 |
|
| 58 | 59 |
function renderDashboard(onOpenOrg = vi.fn(), onOpenOrgList = vi.fn()) {
|
| 59 |
- render(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />)
|
|
| 60 |
+ render(withCodes(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />))
|
|
| 60 | 61 |
return { onOpenOrg, onOpenOrgList }
|
| 61 | 62 |
} |
| 62 | 63 |
|
--- frontend/src/components/Dashboard.tsx
+++ frontend/src/components/Dashboard.tsx
... | ... | @@ -5,7 +5,8 @@ |
| 5 | 5 |
type DashboardData, |
| 6 | 6 |
type DashboardOrgRow, |
| 7 | 7 |
} from '../api/client' |
| 8 |
-import { STAGE_GROUPS, STAGE_LABELS, stageGroupKey, stageLabel } from '../stages'
|
|
| 8 |
+import { useStages } from '../codes/stage'
|
|
| 9 |
+import { barClass } from '../codes/tone'
|
|
| 9 | 10 |
import HelpButton from '../help/HelpButton' |
| 10 | 11 |
|
| 11 | 12 |
/** |
... | ... | @@ -28,16 +29,8 @@ |
| 28 | 29 |
onOpenOrgList: () => void |
| 29 | 30 |
} |
| 30 | 31 |
|
| 31 |
-const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫'] |
|
| 32 |
- |
|
| 33 |
-/** 칸반 컬럼 상단 색 띠. 구간별로 같은 색을 쓴다. */ |
|
| 34 |
-const GROUP_COLOR: Record<string, string> = {
|
|
| 35 |
- intake: 'bg-slate-400', |
|
| 36 |
- assign: 'bg-indigo-400', |
|
| 37 |
- check: 'bg-emerald-400', |
|
| 38 |
- process: 'bg-amber-400', |
|
| 39 |
- closing: 'bg-violet-400', |
|
| 40 |
-} |
|
| 32 |
+// 원문자(①②)와 구간 색은 더 이상 여기 박아두지 않는다. 원문자는 단계 번호에서 계산하고 |
|
| 33 |
+// 색은 코드표의 tone에서 온다 - 단계가 하나 늘 때마다 배열을 같이 늘려야 했던 자리다. |
|
| 41 | 34 |
|
| 42 | 35 |
/** 표/칸반이 한 번에 보여주는 기관 수. [더보기]를 누를 때마다 이만큼 늘어난다. */ |
| 43 | 36 |
const PAGE_SIZE = 8 |
... | ... | @@ -123,8 +116,9 @@ |
| 123 | 116 |
|
| 124 | 117 |
/** 단계 구간에 따라 카드 본문이 달라진다(시안의 컬럼별 카드 모양). */ |
| 125 | 118 |
function CardBody({ row }: { row: DashboardOrgRow }) {
|
| 119 |
+ const stages = useStages() |
|
| 126 | 120 |
const stage = columnOf(row) |
| 127 |
- const group = stageGroupKey(stage) |
|
| 121 |
+ const group = stages.groupKey(stage) |
|
| 128 | 122 |
const lawyer = row.lawyerName ?? '변호사 미배정' |
| 129 | 123 |
|
| 130 | 124 |
if (stage === null) {
|
... | ... | @@ -170,13 +164,14 @@ |
| 170 | 164 |
<div className="space-y-1 text-xs text-gray-500"> |
| 171 | 165 |
<p>{lawyer}</p>
|
| 172 | 166 |
<span className="inline-block rounded-full bg-violet-50 px-2 py-0.5 text-[11px] text-violet-700"> |
| 173 |
- {CIRCLED[stage - 1]} {stageLabel(stage)}
|
|
| 167 |
+ {stages.symbol(stage)} {stages.label(stage)}
|
|
| 174 | 168 |
</span> |
| 175 | 169 |
</div> |
| 176 | 170 |
) |
| 177 | 171 |
} |
| 178 | 172 |
|
| 179 | 173 |
export default function Dashboard({ onOpenOrg, onOpenOrgList }: Props) {
|
| 174 |
+ const stages = useStages() |
|
| 180 | 175 |
const [data, setData] = useState<DashboardData | null>(null) |
| 181 | 176 |
const [error, setError] = useState<string | null>(null) |
| 182 | 177 |
const [busy, setBusy] = useState(false) |
... | ... | @@ -226,7 +221,7 @@ |
| 226 | 221 |
const keyword = boardKeyword.trim() |
| 227 | 222 |
return rows.filter((row) => {
|
| 228 | 223 |
const stage = columnOf(row) |
| 229 |
- if (groupFilter && stageGroupKey(stage) !== groupFilter) {
|
|
| 224 |
+ if (groupFilter && stages.groupKey(stage) !== groupFilter) {
|
|
| 230 | 225 |
return false |
| 231 | 226 |
} |
| 232 | 227 |
if (lawyerFilter && row.lawyerName !== lawyerFilter) {
|
... | ... | @@ -235,13 +230,16 @@ |
| 235 | 230 |
if (stageFilter && String(stage ?? '') !== stageFilter) {
|
| 236 | 231 |
return false |
| 237 | 232 |
} |
| 233 |
+ // 진행상태는 코드표(STAGE의 progress)에서 온다 - 신청은 ①만, 완료는 ⑬만, |
|
| 234 |
+ // 나머지가 진행중이다. 채널이 없어 단계가 아예 없는 기관은 '단계 미지정'으로 따로 본다. |
|
| 238 | 235 |
if (statusFilter === 'unassigned' && row.hasChannel) {
|
| 239 | 236 |
return false |
| 240 | 237 |
} |
| 241 |
- if (statusFilter === 'active' && (!row.hasChannel || (stage ?? 0) >= 11)) {
|
|
| 242 |
- return false |
|
| 243 |
- } |
|
| 244 |
- if (statusFilter === 'done' && (stage ?? 0) < 11) {
|
|
| 238 |
+ if ( |
|
| 239 |
+ statusFilter && |
|
| 240 |
+ statusFilter !== 'unassigned' && |
|
| 241 |
+ (!row.hasChannel || stages.progressOf(stage) !== statusFilter) |
|
| 242 |
+ ) {
|
|
| 245 | 243 |
return false |
| 246 | 244 |
} |
| 247 | 245 |
if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) {
|
... | ... | @@ -249,7 +247,7 @@ |
| 249 | 247 |
} |
| 250 | 248 |
return true |
| 251 | 249 |
}) |
| 252 |
- }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword]) |
|
| 250 |
+ }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword, stages]) |
|
| 253 | 251 |
|
| 254 | 252 |
/** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */ |
| 255 | 253 |
const byRecent = useCallback( |
... | ... | @@ -344,7 +342,8 @@ |
| 344 | 342 |
} |
| 345 | 343 |
|
| 346 | 344 |
const s = data.summary |
| 347 |
- const columns: (number | null)[] = [null, ...STAGE_LABELS.map((_, i) => i + 1)] |
|
| 345 |
+ // 첫 칸은 '단계 미지정'(채널 미생성 기관 자리)이고, 나머지는 코드표의 단계 수만큼이다. |
|
| 346 |
+ const columns: (number | null)[] = [null, ...stages.numbers] |
|
| 348 | 347 |
|
| 349 | 348 |
return ( |
| 350 | 349 |
<div className="space-y-4 p-6"> |
... | ... | @@ -397,8 +396,8 @@ |
| 397 | 396 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 398 | 397 |
> |
| 399 | 398 |
<option value="">업무 구간 전체</option> |
| 400 |
- {STAGE_GROUPS.map((g) => (
|
|
| 401 |
- <option key={g.key} value={g.key}>{g.label}</option>
|
|
| 399 |
+ {stages.groups.map((g) => (
|
|
| 400 |
+ <option key={g.code} value={g.code}>{g.label}</option>
|
|
| 402 | 401 |
))} |
| 403 | 402 |
</select> |
| 404 | 403 |
<select |
... | ... | @@ -419,8 +418,8 @@ |
| 419 | 418 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 420 | 419 |
> |
| 421 | 420 |
<option value="">현재 단계 전체</option> |
| 422 |
- {STAGE_LABELS.map((label, i) => (
|
|
| 423 |
- <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
|
|
| 421 |
+ {stages.numbers.map((n) => (
|
|
| 422 |
+ <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
|
|
| 424 | 423 |
))} |
| 425 | 424 |
</select> |
| 426 | 425 |
<select |
... | ... | @@ -431,6 +430,7 @@ |
| 431 | 430 |
> |
| 432 | 431 |
<option value="">진행 상태 전체</option> |
| 433 | 432 |
<option value="unassigned">단계 미지정</option> |
| 433 |
+ <option value="applied">신청</option> |
|
| 434 | 434 |
<option value="active">진행중</option> |
| 435 | 435 |
<option value="done">완료</option> |
| 436 | 436 |
</select> |
... | ... | @@ -454,7 +454,7 @@ |
| 454 | 454 |
const cards = boardRows.filter((row) => columnOf(row) === stage).sort(byRecent) |
| 455 | 455 |
const expanded = expandedColumns[key] |
| 456 | 456 |
const shown = expanded ? cards : cards.slice(0, COLUMN_SIZE) |
| 457 |
- const color = stage === null ? 'bg-gray-300' : GROUP_COLOR[stageGroupKey(stage) ?? ''] ?? 'bg-gray-300' |
|
| 457 |
+ const color = stage === null ? 'bg-gray-300' : barClass(stages.groupTone(stages.groupKey(stage))) |
|
| 458 | 458 |
|
| 459 | 459 |
return ( |
| 460 | 460 |
<div |
... | ... | @@ -475,7 +475,7 @@ |
| 475 | 475 |
<div className={`h-1 rounded-t-lg ${color}`} />
|
| 476 | 476 |
<div className="flex items-center justify-between px-3 py-2"> |
| 477 | 477 |
<span className="text-sm font-medium"> |
| 478 |
- {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
|
|
| 478 |
+ {stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
|
|
| 479 | 479 |
</span> |
| 480 | 480 |
<span className="text-sm text-gray-500 tabular-nums">{cards.length}</span>
|
| 481 | 481 |
</div> |
... | ... | @@ -542,8 +542,8 @@ |
| 542 | 542 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 543 | 543 |
> |
| 544 | 544 |
<option value="">현재 단계 전체</option> |
| 545 |
- {STAGE_LABELS.map((label, i) => (
|
|
| 546 |
- <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
|
|
| 545 |
+ {stages.numbers.map((n) => (
|
|
| 546 |
+ <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
|
|
| 547 | 547 |
))} |
| 548 | 548 |
</select> |
| 549 | 549 |
<select |
... | ... | @@ -622,7 +622,7 @@ |
| 622 | 622 |
<td className="px-2 py-2 font-medium">{row.orgName}</td>
|
| 623 | 623 |
<td className="px-2 py-2"> |
| 624 | 624 |
<span className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700"> |
| 625 |
- {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
|
|
| 625 |
+ {stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
|
|
| 626 | 626 |
</span> |
| 627 | 627 |
</td> |
| 628 | 628 |
<td className="px-2 py-2 text-gray-600">{row.mjName ?? '-'}</td>
|
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 |
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
| 1 |
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import OrgOverview from './OrgOverview' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { Contact, Org } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -86,7 +87,7 @@ |
| 86 | 87 |
|
| 87 | 88 |
describe('OrgOverview', () => {
|
| 88 | 89 |
it('연번_기관명 헤더와 신청기관 담당자 정보를 보여준다', async () => {
|
| 89 |
- render(<OrgOverview org={org()} />)
|
|
| 90 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 90 | 91 |
|
| 91 | 92 |
expect(screen.getByText('001_00_국제방송교류재단')).toBeTruthy()
|
| 92 | 93 |
expect(screen.getByText('데이터정보화팀')).toBeTruthy()
|
... | ... | @@ -97,7 +98,7 @@ |
| 97 | 98 |
}) |
| 98 | 99 |
|
| 99 | 100 |
it('문정원 담당자와 아이티앤 담당자와 담당 변호사 정보가 비어 있으면 카드에 -로 표시된다', async () => {
|
| 100 |
- render(<OrgOverview org={org()} />)
|
|
| 101 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 101 | 102 |
|
| 102 | 103 |
const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement
|
| 103 | 104 |
const itnCard = screen.getByText('아이티앤 담당자').closest('section') as HTMLElement
|
... | ... | @@ -111,6 +112,7 @@ |
| 111 | 112 |
|
| 112 | 113 |
it('담당자 4종이 값을 가지고 있으면 각 카드에 값이 표시된다', async () => {
|
| 113 | 114 |
render( |
| 115 |
+ withCodes( |
|
| 114 | 116 |
<OrgOverview |
| 115 | 117 |
org={org({
|
| 116 | 118 |
mj: contact({
|
... | ... | @@ -139,6 +141,7 @@ |
| 139 | 141 |
lawyerAssignedDate: '2026-07-21', |
| 140 | 142 |
})} |
| 141 | 143 |
/>, |
| 144 |
+ ), |
|
| 142 | 145 |
) |
| 143 | 146 |
|
| 144 | 147 |
const applicantCard = screen.getByText('신청기관 담당자').closest('section') as HTMLElement
|
... | ... | @@ -168,7 +171,7 @@ |
| 168 | 171 |
}) |
| 169 | 172 |
|
| 170 | 173 |
it('배정된 카드에는 변경, 빈 카드에는 생성 버튼이 보인다', async () => {
|
| 171 |
- render(<OrgOverview org={org()} />)
|
|
| 174 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 172 | 175 |
|
| 173 | 176 |
expect(screen.getByRole('button', { name: '신청기관 담당자 변경' })).toBeTruthy()
|
| 174 | 177 |
expect(screen.getByRole('button', { name: '문정원 담당자 생성' })).toBeTruthy()
|
... | ... | @@ -183,7 +186,7 @@ |
| 183 | 186 |
]) |
| 184 | 187 |
const onChanged = vi.fn() |
| 185 | 188 |
|
| 186 |
- render(<OrgOverview org={org()} onChanged={onChanged} />)
|
|
| 189 |
+ render(withCodes(<OrgOverview org={org()} onChanged={onChanged} />))
|
|
| 187 | 190 |
|
| 188 | 191 |
fireEvent.click(screen.getByRole('button', { name: '문정원 담당자 생성' }))
|
| 189 | 192 |
|
... | ... | @@ -208,7 +211,7 @@ |
| 208 | 211 |
]) |
| 209 | 212 |
const onChanged = vi.fn() |
| 210 | 213 |
|
| 211 |
- render(<OrgOverview org={org()} onChanged={onChanged} />)
|
|
| 214 |
+ render(withCodes(<OrgOverview org={org()} onChanged={onChanged} />))
|
|
| 212 | 215 |
|
| 213 | 216 |
fireEvent.click(screen.getByRole('button', { name: '아이티앤 담당자 생성' }))
|
| 214 | 217 |
|
... | ... | @@ -228,7 +231,7 @@ |
| 228 | 231 |
}) |
| 229 | 232 |
|
| 230 | 233 |
it('상단 동작 버튼에서 담당 변호사 변경은 사라졌다', async () => {
|
| 231 |
- render(<OrgOverview org={org()} />)
|
|
| 234 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 232 | 235 |
|
| 233 | 236 |
expect(screen.queryByRole('button', { name: '담당 변호사 변경' })).toBeNull()
|
| 234 | 237 |
expect(screen.getByRole('button', { name: '진행단계 변경' })).not.toBeDisabled()
|
... | ... | @@ -242,7 +245,7 @@ |
| 242 | 245 |
{ stage: 1, changedAt: new Date('2026-07-15T09:00:00').getTime() },
|
| 243 | 246 |
]) |
| 244 | 247 |
|
| 245 |
- render(<OrgOverview org={org({ stage: 3 })} />)
|
|
| 248 |
+ render(withCodes(<OrgOverview org={org({ stage: 3 })} />))
|
|
| 246 | 249 |
|
| 247 | 250 |
await waitFor(() => {
|
| 248 | 251 |
expect(screen.getByText('2026-07-24 10:00')).toBeTruthy()
|
... | ... | @@ -252,7 +255,7 @@ |
| 252 | 255 |
}) |
| 253 | 256 |
|
| 254 | 257 |
it('업무단계 12개가 순서대로 보인다', async () => {
|
| 255 |
- render(<OrgOverview org={org()} />)
|
|
| 258 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 256 | 259 |
|
| 257 | 260 |
expect(screen.getByText('신청')).toBeTruthy()
|
| 258 | 261 |
expect(screen.getByText('변호사 배당')).toBeTruthy()
|
... | ... | @@ -261,14 +264,14 @@ |
| 261 | 264 |
}) |
| 262 | 265 |
|
| 263 | 266 |
it('기본 탭인 채팅은 자리표시 대신 채널 선택 화면을 보여준다', async () => {
|
| 264 |
- render(<OrgOverview org={org()} />)
|
|
| 267 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 265 | 268 |
|
| 266 | 269 |
expect(screen.getByRole('button', { name: '문정원' })).toBeTruthy()
|
| 267 | 270 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalledWith(1, 'mj')) |
| 268 | 271 |
}) |
| 269 | 272 |
|
| 270 | 273 |
it('권리확인 탭을 누르면 권리확인 보드가 마운트되어 조회를 부른다', async () => {
|
| 271 |
- render(<OrgOverview org={org()} />)
|
|
| 274 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 272 | 275 |
|
| 273 | 276 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 274 | 277 |
|
... | ... | @@ -280,7 +283,7 @@ |
| 280 | 283 |
}) |
| 281 | 284 |
|
| 282 | 285 |
it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
|
| 283 |
- render(<OrgOverview org={org()} />)
|
|
| 286 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 284 | 287 |
|
| 285 | 288 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 286 | 289 |
|
... | ... | @@ -291,7 +294,7 @@ |
| 291 | 294 |
}) |
| 292 | 295 |
|
| 293 | 296 |
it('권리처리 탭을 누르면 권리처리 보드가 마운트되어 조회를 부른다', async () => {
|
| 294 |
- render(<OrgOverview org={org()} />)
|
|
| 297 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 295 | 298 |
|
| 296 | 299 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 297 | 300 |
|
... | ... | @@ -306,7 +309,7 @@ |
| 306 | 309 |
mocks.getReviewPage.mockResolvedValue({ items: [], total: 12, done: 5, page: 0, size: 1 })
|
| 307 | 310 |
mocks.getProcessPage.mockResolvedValue({ items: [], total: 8, done: 3, page: 0, size: 1 })
|
| 308 | 311 |
|
| 309 |
- render(<OrgOverview org={org()} />)
|
|
| 312 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 310 | 313 |
|
| 311 | 314 |
await waitFor(() => {
|
| 312 | 315 |
expect(screen.getByText('완료 5 / 전체 12')).toBeTruthy()
|
... | ... | @@ -315,7 +318,7 @@ |
| 315 | 318 |
}) |
| 316 | 319 |
|
| 317 | 320 |
it('타임라인 탭을 누르면 Timeline이 마운트되어 조회를 부른다', async () => {
|
| 318 |
- render(<OrgOverview org={org()} />)
|
|
| 321 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 319 | 322 |
|
| 320 | 323 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 321 | 324 |
|
... | ... | @@ -326,7 +329,7 @@ |
| 326 | 329 |
}) |
| 327 | 330 |
|
| 328 | 331 |
it('자료 탭을 누르면 자료 목록 화면으로 바뀐다', async () => {
|
| 329 |
- render(<OrgOverview org={org()} />)
|
|
| 332 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 330 | 333 |
|
| 331 | 334 |
fireEvent.click(screen.getByRole('button', { name: '자료' }))
|
| 332 | 335 |
|
... | ... | @@ -335,7 +338,7 @@ |
| 335 | 338 |
}) |
| 336 | 339 |
|
| 337 | 340 |
it('채널이 있으면 Mattermost 열기가 채널 링크로 활성화된다', async () => {
|
| 338 |
- render(<OrgOverview org={org()} />)
|
|
| 341 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 339 | 342 |
|
| 340 | 343 |
const link = screen.getByRole('link', { name: 'Mattermost 열기' })
|
| 341 | 344 |
expect(link).toHaveAttribute( |
... | ... | @@ -346,7 +349,7 @@ |
| 346 | 349 |
}) |
| 347 | 350 |
|
| 348 | 351 |
it('채널이 없으면 Mattermost 열기가 비활성이다', () => {
|
| 349 |
- render(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
|
|
| 352 |
+ render(withCodes(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />))
|
|
| 350 | 353 |
|
| 351 | 354 |
expect(screen.queryByRole('link', { name: 'Mattermost 열기' })).toBeNull()
|
| 352 | 355 |
expect(screen.getByRole('button', { name: 'Mattermost 열기' })).toBeDisabled()
|
... | ... | @@ -354,9 +357,11 @@ |
| 354 | 357 |
|
| 355 | 358 |
it('치안정책연구소도 합성코드 슬러그로 채널 링크를 만든다', async () => {
|
| 356 | 359 |
render( |
| 357 |
- <OrgOverview |
|
| 358 |
- org={org({ orgNo: '008_01', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
|
|
| 359 |
- />, |
|
| 360 |
+ withCodes( |
|
| 361 |
+ <OrgOverview |
|
| 362 |
+ org={org({ orgNo: '008_01', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
|
|
| 363 |
+ />, |
|
| 364 |
+ ), |
|
| 360 | 365 |
) |
| 361 | 366 |
|
| 362 | 367 |
expect(screen.getByRole('link', { name: 'Mattermost 열기' })).toHaveAttribute(
|
... | ... | @@ -367,7 +372,7 @@ |
| 367 | 372 |
}) |
| 368 | 373 |
|
| 369 | 374 |
it('미구현 동작 버튼들은 비활성이다', async () => {
|
| 370 |
- render(<OrgOverview org={org()} />)
|
|
| 375 |
+ render(withCodes(<OrgOverview org={org()} />))
|
|
| 371 | 376 |
|
| 372 | 377 |
expect(screen.getByRole('button', { name: '자료 업로드' })).toBeDisabled()
|
| 373 | 378 |
expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled()
|
... | ... | @@ -378,14 +383,14 @@ |
| 378 | 383 |
|
| 379 | 384 |
describe('업무단계 현황 스트립', () => {
|
| 380 | 385 |
it('채널이 없어 단계가 시작되지 않았으면 전부 회색이고 안내 문구가 보인다', async () => {
|
| 381 |
- render(<OrgOverview org={org({ stage: null, channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
|
|
| 386 |
+ render(withCodes(<OrgOverview org={org({ stage: null, channelIdMj: null, channelIdLaw: null, status: 'READY' })} />))
|
|
| 382 | 387 |
|
| 383 | 388 |
expect(screen.getByText('채널 생성 시 신청 단계가 시작됩니다')).toBeTruthy()
|
| 384 | 389 |
expect(screen.queryByText('진행중')).toBeNull()
|
| 385 | 390 |
}) |
| 386 | 391 |
|
| 387 | 392 |
it('현재 단계 이전은 완료(체크) 표시, 현재 단계는 진행중, 이후는 미래 상태로 보인다', async () => {
|
| 388 |
- render(<OrgOverview org={org({ stage: 3 })} />)
|
|
| 393 |
+ render(withCodes(<OrgOverview org={org({ stage: 3 })} />))
|
|
| 389 | 394 |
|
| 390 | 395 |
const doneButton = screen.getByText('목록접수').closest('button') as HTMLElement
|
| 391 | 396 |
const currentButton = screen.getByText('예비검토').closest('button') as HTMLElement
|
... | ... | @@ -405,7 +410,7 @@ |
| 405 | 410 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 406 | 411 |
const onChanged = vi.fn() |
| 407 | 412 |
|
| 408 |
- render(<OrgOverview org={org({ stage: 4 })} onChanged={onChanged} />)
|
|
| 413 |
+ render(withCodes(<OrgOverview org={org({ stage: 4 })} onChanged={onChanged} />))
|
|
| 409 | 414 |
|
| 410 | 415 |
const currentButton = screen.getByText('변호사 배당').closest('button') as HTMLElement
|
| 411 | 416 |
fireEvent.click(currentButton) |
... | ... | @@ -422,7 +427,7 @@ |
| 422 | 427 |
it('확인을 취소하면 진행단계 변경 API를 부르지 않는다', async () => {
|
| 423 | 428 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) |
| 424 | 429 |
|
| 425 |
- render(<OrgOverview org={org({ stage: 4 })} />)
|
|
| 430 |
+ render(withCodes(<OrgOverview org={org({ stage: 4 })} />))
|
|
| 426 | 431 |
|
| 427 | 432 |
fireEvent.click(screen.getByText('변호사 배당').closest('button') as HTMLElement)
|
| 428 | 433 |
|
... | ... | @@ -435,7 +440,7 @@ |
| 435 | 440 |
it('현재 단계가 아닌 다른 단계를 클릭해도 아무 일도 일어나지 않는다', async () => {
|
| 436 | 441 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 437 | 442 |
|
| 438 |
- render(<OrgOverview org={org({ stage: 4 })} />)
|
|
| 443 |
+ render(withCodes(<OrgOverview org={org({ stage: 4 })} />))
|
|
| 439 | 444 |
|
| 440 | 445 |
fireEvent.click(screen.getByText('예비검토').closest('button') as HTMLElement)
|
| 441 | 446 |
fireEvent.click(screen.getByText('RE:확인').closest('button') as HTMLElement)
|
... | ... | @@ -446,12 +451,14 @@ |
| 446 | 451 |
confirmSpy.mockRestore() |
| 447 | 452 |
}) |
| 448 | 453 |
|
| 449 |
- it('마지막 단계(12)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => {
|
|
| 454 |
+ // 요구사항 [11]로 13단계(보고서 제출 완료)가 생겨 마지막 단계가 13으로 내려갔다. |
|
| 455 |
+ // 12단계는 이제 마지막이 아니라서 다음 단계로 넘어갈 수 있다. |
|
| 456 |
+ it('마지막 단계(13)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => {
|
|
| 450 | 457 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 451 | 458 |
|
| 452 |
- render(<OrgOverview org={org({ stage: 12 })} />)
|
|
| 459 |
+ render(withCodes(<OrgOverview org={org({ stage: 13 })} />))
|
|
| 453 | 460 |
|
| 454 |
- const lastButton = screen.getByText('보고서 작성').closest('button') as HTMLElement
|
|
| 461 |
+ const lastButton = screen.getByText('보고서 제출 완료').closest('button') as HTMLElement
|
|
| 455 | 462 |
expect(lastButton).toBeDisabled() |
| 456 | 463 |
fireEvent.click(lastButton) |
| 457 | 464 |
|
... | ... | @@ -467,7 +474,7 @@ |
| 467 | 474 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 468 | 475 |
const onChanged = vi.fn() |
| 469 | 476 |
|
| 470 |
- render(<OrgOverview org={org({ stage: 2 })} onChanged={onChanged} />)
|
|
| 477 |
+ render(withCodes(<OrgOverview org={org({ stage: 2 })} onChanged={onChanged} />))
|
|
| 471 | 478 |
|
| 472 | 479 |
fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
|
| 473 | 480 |
|
... | ... | @@ -487,7 +494,7 @@ |
| 487 | 494 |
}) |
| 488 | 495 |
|
| 489 | 496 |
it('닫기를 누르면 모달이 사라진다', async () => {
|
| 490 |
- render(<OrgOverview org={org({ stage: 2 })} />)
|
|
| 497 |
+ render(withCodes(<OrgOverview org={org({ stage: 2 })} />))
|
|
| 491 | 498 |
|
| 492 | 499 |
fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
|
| 493 | 500 |
expect(screen.getByRole('heading', { name: '진행단계 변경' })).toBeTruthy()
|
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -11,7 +11,7 @@ |
| 11 | 11 |
type Org, |
| 12 | 12 |
type PostView, |
| 13 | 13 |
} from '../api/client' |
| 14 |
-import { STAGE_LABELS, stageLabel } from '../stages'
|
|
| 14 |
+import { useStages } from '../codes/stage'
|
|
| 15 | 15 |
import HelpButton from '../help/HelpButton' |
| 16 | 16 |
import ChannelFiles from './ChannelFiles' |
| 17 | 17 |
import ChannelPosts from './ChannelPosts' |
... | ... | @@ -135,6 +135,7 @@ |
| 135 | 135 |
org: Org |
| 136 | 136 |
onChanged?: () => void |
| 137 | 137 |
}) {
|
| 138 |
+ const stages = useStages() |
|
| 138 | 139 |
const [activeTab, setActiveTab] = useState(TABS[0]) |
| 139 | 140 |
const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'itn' | 'lawyer' | null>(null) |
| 140 | 141 |
const [assignBusy, setAssignBusy] = useState(false) |
... | ... | @@ -273,18 +274,18 @@ |
| 273 | 274 |
|
| 274 | 275 |
/** 업무단계 현황 스트립에서 "현재 단계"를 클릭했을 때만 호출된다 - 다음 단계로 1칸 전진. */ |
| 275 | 276 |
async function advanceStage() {
|
| 276 |
- if (org.stage === null || org.stage >= 12 || stageBusy) {
|
|
| 277 |
+ if (org.stage === null || org.stage >= stages.count || stageBusy) {
|
|
| 277 | 278 |
return |
| 278 | 279 |
} |
| 279 | 280 |
const next = org.stage + 1 |
| 280 |
- if (!window.confirm(`다음 단계 "${next}. ${stageLabel(next)}"(으)로 진행할까요?`)) {
|
|
| 281 |
+ if (!window.confirm(`다음 단계 "${next}. ${stages.label(next)}"(으)로 진행할까요?`)) {
|
|
| 281 | 282 |
return |
| 282 | 283 |
} |
| 283 | 284 |
await changeStage(next) |
| 284 | 285 |
} |
| 285 | 286 |
|
| 286 | 287 |
async function pickStage(next: number) {
|
| 287 |
- if (!window.confirm(`"${next}. ${stageLabel(next)}"(으)로 진행단계를 변경할까요?`)) {
|
|
| 288 |
+ if (!window.confirm(`"${next}. ${stages.label(next)}"(으)로 진행단계를 변경할까요?`)) {
|
|
| 288 | 289 |
return |
| 289 | 290 |
} |
| 290 | 291 |
setStagePickerOpen(false) |
... | ... | @@ -439,18 +440,18 @@ |
| 439 | 440 |
<HelpButton topic="org.stage" /> |
| 440 | 441 |
</h2> |
| 441 | 442 |
<ol className="mt-3 flex gap-2 overflow-x-auto pb-2"> |
| 442 |
- {STAGE_LABELS.map((step, i) => {
|
|
| 443 |
- const n = i + 1 |
|
| 443 |
+ {stages.numbers.map((n) => {
|
|
| 444 |
+ const step = stages.label(n) |
|
| 444 | 445 |
const isDone = org.stage !== null && n < org.stage |
| 445 | 446 |
const isCurrent = org.stage !== null && n === org.stage |
| 446 |
- const clickable = isCurrent && n < 12 |
|
| 447 |
+ const clickable = isCurrent && n < stages.count |
|
| 447 | 448 |
|
| 448 | 449 |
return ( |
| 449 |
- <li key={step} className="shrink-0">
|
|
| 450 |
+ <li key={n} className="shrink-0">
|
|
| 450 | 451 |
<button |
| 451 | 452 |
type="button" |
| 452 | 453 |
disabled={!clickable || stageBusy}
|
| 453 |
- title={isCurrent && n >= 12 ? '마지막 단계입니다' : undefined}
|
|
| 454 |
+ title={isCurrent && n >= stages.count ? '마지막 단계입니다' : undefined}
|
|
| 454 | 455 |
onClick={() => {
|
| 455 | 456 |
if (isCurrent) {
|
| 456 | 457 |
void advanceStage() |
... | ... | @@ -697,6 +698,7 @@ |
| 697 | 698 |
onPick: (stage: number) => void |
| 698 | 699 |
onClose: () => void |
| 699 | 700 |
}) {
|
| 701 |
+ const stages = useStages() |
|
| 700 | 702 |
return ( |
| 701 | 703 |
<div className="fixed inset-0 flex items-center justify-center bg-black/30"> |
| 702 | 704 |
<div className="w-96 rounded-lg bg-white p-5"> |
... | ... | @@ -713,11 +715,11 @@ |
| 713 | 715 |
</div> |
| 714 | 716 |
|
| 715 | 717 |
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto"> |
| 716 |
- {STAGE_LABELS.map((label, i) => {
|
|
| 717 |
- const n = i + 1 |
|
| 718 |
+ {stages.numbers.map((n) => {
|
|
| 719 |
+ const label = stages.label(n) |
|
| 718 | 720 |
const isCurrent = currentStage === n |
| 719 | 721 |
return ( |
| 720 |
- <li key={label}>
|
|
| 722 |
+ <li key={n}>
|
|
| 721 | 723 |
<button |
| 722 | 724 |
type="button" |
| 723 | 725 |
disabled={busy}
|
--- frontend/src/components/SystemPage.test.tsx
+++ frontend/src/components/SystemPage.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 |
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
| 1 |
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import SystemPage from './SystemPage' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { ResetTarget } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -46,7 +47,7 @@ |
| 46 | 47 |
target(), |
| 47 | 48 |
target({ orgId: 2, orgNo: '002', orgName: '세종학당재단', channels: [{ kind: '문정원', channelId: 'c2' }] }),
|
| 48 | 49 |
]) |
| 49 |
- render(<SystemPage />) |
|
| 50 |
+ render(withCodes(<SystemPage />)) |
|
| 50 | 51 |
|
| 51 | 52 |
expect(await screen.findByText('대상 2개 기관 · 채널 3개')).toBeTruthy()
|
| 52 | 53 |
const rows = within(screen.getByTestId('reset-targets')).getAllByRole('row')
|
... | ... | @@ -55,7 +56,7 @@ |
| 55 | 56 |
}) |
| 56 | 57 |
|
| 57 | 58 |
it('확인 문구를 입력하기 전에는 실행 버튼이 잠겨 있다', async () => {
|
| 58 |
- render(<SystemPage />) |
|
| 59 |
+ render(withCodes(<SystemPage />)) |
|
| 59 | 60 |
await screen.findByTestId('reset-targets')
|
| 60 | 61 |
|
| 61 | 62 |
expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true)
|
... | ... | @@ -68,7 +69,7 @@ |
| 68 | 69 |
}) |
| 69 | 70 |
|
| 70 | 71 |
it('실행 버튼을 눌러도 확인창에서 한 번 더 물어본다', async () => {
|
| 71 |
- render(<SystemPage />) |
|
| 72 |
+ render(withCodes(<SystemPage />)) |
|
| 72 | 73 |
await screen.findByTestId('reset-targets')
|
| 73 | 74 |
|
| 74 | 75 |
fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
|
... | ... | @@ -82,7 +83,7 @@ |
| 82 | 83 |
}) |
| 83 | 84 |
|
| 84 | 85 |
it('확인창에서 실행하면 문구와 함께 초기화를 요청하고 결과를 보여준다', async () => {
|
| 85 |
- render(<SystemPage />) |
|
| 86 |
+ render(withCodes(<SystemPage />)) |
|
| 86 | 87 |
await screen.findByTestId('reset-targets')
|
| 87 | 88 |
|
| 88 | 89 |
await typeConfirmAndRun() |
... | ... | @@ -99,7 +100,7 @@ |
| 99 | 100 |
clearedOrgs: 0, |
| 100 | 101 |
failures: ['001 국제방송교류재단 (법률검토): 권한 없음'], |
| 101 | 102 |
}) |
| 102 |
- render(<SystemPage />) |
|
| 103 |
+ render(withCodes(<SystemPage />)) |
|
| 103 | 104 |
await screen.findByTestId('reset-targets')
|
| 104 | 105 |
|
| 105 | 106 |
await typeConfirmAndRun() |
... | ... | @@ -110,7 +111,7 @@ |
| 110 | 111 |
|
| 111 | 112 |
it('대상이 없으면 실행할 수 없다', async () => {
|
| 112 | 113 |
mocks.getResetPreview.mockResolvedValue([]) |
| 113 |
- render(<SystemPage />) |
|
| 114 |
+ render(withCodes(<SystemPage />)) |
|
| 114 | 115 |
|
| 115 | 116 |
expect(await screen.findByText('초기화할 채널이 없습니다.')).toBeTruthy()
|
| 116 | 117 |
fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
|
... | ... | @@ -118,7 +119,7 @@ |
| 118 | 119 |
}) |
| 119 | 120 |
|
| 120 | 121 |
it('무엇이 남고 무엇이 사라지는지 화면에 적어 둔다', async () => {
|
| 121 |
- render(<SystemPage />) |
|
| 122 |
+ render(withCodes(<SystemPage />)) |
|
| 122 | 123 |
await screen.findByTestId('reset-targets')
|
| 123 | 124 |
|
| 124 | 125 |
expect(screen.getByText(/보관\(아카이브\)/)).toBeTruthy() |
--- frontend/src/components/SystemPage.tsx
+++ frontend/src/components/SystemPage.tsx
... | ... | @@ -6,7 +6,7 @@ |
| 6 | 6 |
type ResetReport, |
| 7 | 7 |
type ResetTarget, |
| 8 | 8 |
} from '../api/client' |
| 9 |
-import { stageLabel } from '../stages'
|
|
| 9 |
+import { useStages } from '../codes/stage'
|
|
| 10 | 10 |
import HelpButton from '../help/HelpButton' |
| 11 | 11 |
|
| 12 | 12 |
/** |
... | ... | @@ -28,6 +28,7 @@ |
| 28 | 28 |
} |
| 29 | 29 |
|
| 30 | 30 |
export default function SystemPage({ onChanged }: Props) {
|
| 31 |
+ const stages = useStages() |
|
| 31 | 32 |
const [targets, setTargets] = useState<ResetTarget[] | null>(null) |
| 32 | 33 |
const [error, setError] = useState<string | null>(null) |
| 33 | 34 |
const [confirm, setConfirm] = useState('')
|
... | ... | @@ -134,7 +135,7 @@ |
| 134 | 135 |
<td className="px-3 py-2 text-gray-500 tabular-nums">{t.orgNo}</td>
|
| 135 | 136 |
<td className="px-3 py-2 font-medium">{t.orgName}</td>
|
| 136 | 137 |
<td className="px-3 py-2 text-gray-600"> |
| 137 |
- {t.stage === null ? '-' : `${t.stage}. ${stageLabel(t.stage)}`}
|
|
| 138 |
+ {t.stage === null ? '-' : `${t.stage}. ${stages.label(t.stage)}`}
|
|
| 138 | 139 |
</td> |
| 139 | 140 |
<td className="px-3 py-2 text-gray-600"> |
| 140 | 141 |
{t.channels.map((c) => c.kind).join(' · ')}
|
--- frontend/src/components/Timeline.test.tsx
+++ frontend/src/components/Timeline.test.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 |
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
| 1 |
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import Timeline from './Timeline' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { Org, TimelineEvent } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -48,7 +49,7 @@ |
| 48 | 49 |
it('불러오는 동안 로딩 문구를 보여준다', () => {
|
| 49 | 50 |
mocks.getTimeline.mockReturnValue(new Promise(() => {}))
|
| 50 | 51 |
|
| 51 |
- render(<Timeline org={org()} />)
|
|
| 52 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 52 | 53 |
|
| 53 | 54 |
expect(screen.getByText('불러오는 중…')).toBeTruthy()
|
| 54 | 55 |
}) |
... | ... | @@ -56,7 +57,7 @@ |
| 56 | 57 |
it('기록이 없으면 빈 상태 문구를 보여준다', async () => {
|
| 57 | 58 |
mocks.getTimeline.mockResolvedValue([]) |
| 58 | 59 |
|
| 59 |
- render(<Timeline org={org()} />)
|
|
| 60 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 60 | 61 |
|
| 61 | 62 |
await waitFor(() => |
| 62 | 63 |
expect( |
... | ... | @@ -76,7 +77,7 @@ |
| 76 | 77 |
event({ type: 'STAGE', at: day1, title: '4', detail: null }),
|
| 77 | 78 |
]) |
| 78 | 79 |
|
| 79 |
- render(<Timeline org={org()} />)
|
|
| 80 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 80 | 81 |
|
| 81 | 82 |
await waitFor(() => expect(screen.getByText('보고서.pdf')).toBeTruthy())
|
| 82 | 83 |
|
... | ... | @@ -99,7 +100,7 @@ |
| 99 | 100 |
it('새로고침 버튼을 누르면 다시 조회한다', async () => {
|
| 100 | 101 |
mocks.getTimeline.mockResolvedValue([]) |
| 101 | 102 |
|
| 102 |
- render(<Timeline org={org()} />)
|
|
| 103 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 103 | 104 |
|
| 104 | 105 |
await waitFor(() => expect(mocks.getTimeline).toHaveBeenCalledTimes(1)) |
| 105 | 106 |
|
... | ... | @@ -111,7 +112,7 @@ |
| 111 | 112 |
it('조회가 실패하면 오류 메시지를 보여준다', async () => {
|
| 112 | 113 |
mocks.getTimeline.mockRejectedValue(new Error('타임라인을 불러오지 못했습니다.'))
|
| 113 | 114 |
|
| 114 |
- render(<Timeline org={org()} />)
|
|
| 115 |
+ render(withCodes(<Timeline org={org()} />))
|
|
| 115 | 116 |
|
| 116 | 117 |
await waitFor(() => expect(screen.getByText('타임라인을 불러오지 못했습니다.')).toBeTruthy())
|
| 117 | 118 |
}) |
--- frontend/src/components/Timeline.tsx
+++ frontend/src/components/Timeline.tsx
... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 |
import { useEffect, useState } from 'react'
|
| 2 | 2 |
import { getTimeline, type Org, type TimelineEvent } from '../api/client'
|
| 3 |
-import { stageLabel } from '../stages'
|
|
| 3 |
+import { useStages, type Stages } from '../codes/stage'
|
|
| 4 | 4 |
|
| 5 | 5 |
const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
|
| 6 | 6 |
year: 'numeric', |
... | ... | @@ -62,17 +62,18 @@ |
| 62 | 62 |
} |
| 63 | 63 |
|
| 64 | 64 |
/** STAGE 이벤트의 title(단계 번호 문자열)을 "5. 법률검토(권리확인) 진행" 형태로 바꾼다. */ |
| 65 |
-function stageEventText(title: string): string {
|
|
| 65 |
+function stageEventText(title: string, stages: Stages): string {
|
|
| 66 | 66 |
const n = Number(title) |
| 67 | 67 |
if (!Number.isInteger(n)) {
|
| 68 | 68 |
return `${title} 단계로 진행`
|
| 69 | 69 |
} |
| 70 |
- return `${n}. ${stageLabel(n)} 단계로 진행`
|
|
| 70 |
+ return `${n}. ${stages.label(n)} 단계로 진행`
|
|
| 71 | 71 |
} |
| 72 | 72 |
|
| 73 | 73 |
function EventBody({ event }: { event: TimelineEvent }) {
|
| 74 |
+ const stages = useStages() |
|
| 74 | 75 |
if (event.type === 'STAGE') {
|
| 75 |
- return <p className="text-sm font-medium text-gray-900">{stageEventText(event.title)}</p>
|
|
| 76 |
+ return <p className="text-sm font-medium text-gray-900">{stageEventText(event.title, stages)}</p>
|
|
| 76 | 77 |
} |
| 77 | 78 |
if (event.type === 'FILE') {
|
| 78 | 79 |
return ( |
--- frontend/src/help/helpContent.ts
+++ frontend/src/help/helpContent.ts
... | ... | @@ -41,7 +41,7 @@ |
| 41 | 41 |
'서류제출 — ②목록접수 단계 이상에 도달한 기관 수', |
| 42 | 42 |
'권리확인 / 권리처리 — 전 기관 건수 합계(완료 / 전체)와 진척률', |
| 43 | 43 |
'보고서 작성 — ⑫보고서 작성 단계에 도달한 기관 수', |
| 44 |
- '완료 — ⑪법률검토 최종완료 이상에 도달한 기관 수', |
|
| 44 |
+ '완료 — ⑬보고서 제출 완료에 도달한 기관 수(최종 보고서가 올라온 기관만 센다)', |
|
| 45 | 45 |
], |
| 46 | 46 |
cautions: [ |
| 47 | 47 |
'미해결 RE는 아직 데이터가 없어 항상 0으로 표시된다(자리만 잡아 둔 상태).', |
... | ... | @@ -56,7 +56,7 @@ |
| 56 | 56 |
{
|
| 57 | 57 |
src: '/help/dashboard-kanban.png', |
| 58 | 58 |
alt: '단계 미지정 컬럼과 ①신청~ 컬럼들이 있는 칸반', |
| 59 |
- caption: '컬럼은 단계 미지정 + ①~⑫ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다', |
|
| 59 |
+ caption: '컬럼은 단계 미지정 + ①~⑬ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다', |
|
| 60 | 60 |
}, |
| 61 | 61 |
{
|
| 62 | 62 |
src: '/help/dashboard-drag-warning.png', |
... | ... | @@ -160,7 +160,7 @@ |
| 160 | 160 |
|
| 161 | 161 |
'org.stage': {
|
| 162 | 162 |
title: '진행단계 변경', |
| 163 |
- summary: '12단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.', |
|
| 163 |
+ summary: '13단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.', |
|
| 164 | 164 |
images: [ |
| 165 | 165 |
{
|
| 166 | 166 |
src: '/help/org-stage.png', |
--- frontend/src/stages.ts
... | ... | @@ -1,42 +0,0 @@ |
| 1 | -// 진행단계 12개 라벨. 백엔드는 숫자(1..12)만 저장하고 라벨은 여기서만 관리한다 - | |
| 2 | -// OrgOverview(업무단계 현황 스트립·진행단계 변경 모달)와 Timeline(단계 변경 이벤트)이 | |
| 3 | -// 같은 배열을 가져다 쓴다. 인덱스 0이 1단계다. | |
| 4 | -export const STAGE_LABELS: string[] = [ | |
| 5 | - '신청', | |
| 6 | - '목록접수', | |
| 7 | - '예비검토', | |
| 8 | - '변호사 배당', | |
| 9 | - '법률검토(권리확인)', | |
| 10 | - 'RE:확인', | |
| 11 | - '법률검토(권리확인) 완료', | |
| 12 | - '법률검토(권리처리)', | |
| 13 | - 'RE:처리', | |
| 14 | - '법률검토(권리처리) 완료', | |
| 15 | - '법률검토 최종완료', | |
| 16 | - '보고서 작성', | |
| 17 | -] | |
| 18 | - | |
| 19 | -/** 1..12 단계 번호를 라벨로 바꾼다. 범위 밖이면 번호를 그대로 문자열로 돌려준다. */ | |
| 20 | -export function stageLabel(stage: number): string { | |
| 21 | - return STAGE_LABELS[stage - 1] ?? String(stage) | |
| 22 | -} | |
| 23 | - | |
| 24 | -/** | |
| 25 | - * 12단계를 업무 성격으로 묶은 구간. 대시보드의 "업무 구간" 필터와 칸반 카드 모양 | |
| 26 | - * (권리확인 구간은 확인 진척 막대 1줄, 권리처리 구간은 확인+처리 2줄)이 이 묶음을 쓴다. | |
| 27 | - */ | |
| 28 | -export const STAGE_GROUPS: { key: string; label: string; stages: number[] }[] = [ | |
| 29 | - { key: 'intake', label: '접수', stages: [1, 2, 3] }, | |
| 30 | - { key: 'assign', label: '배당', stages: [4] }, | |
| 31 | - { key: 'check', label: '권리확인', stages: [5, 6, 7] }, | |
| 32 | - { key: 'process', label: '권리처리', stages: [8, 9, 10] }, | |
| 33 | - { key: 'closing', label: '마감', stages: [11, 12] }, | |
| 34 | -] | |
| 35 | - | |
| 36 | -/** 단계 번호가 속한 구간 key. 범위 밖이거나 null이면 null. */ | |
| 37 | -export function stageGroupKey(stage: number | null): string | null { | |
| 38 | - if (stage === null) { | |
| 39 | - return null | |
| 40 | - } | |
| 41 | - return STAGE_GROUPS.find((group) => group.stages.includes(stage))?.key ?? null | |
| 42 | -} |
--- src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
package kr.itn.itnhub.dashboard; |
| 2 | 2 |
|
| 3 |
+import kr.itn.itnhub.code.CodeService; |
|
| 3 | 4 |
import org.springframework.stereotype.Service; |
| 4 | 5 |
|
| 5 | 6 |
import java.util.List; |
... | ... | @@ -8,21 +9,36 @@ |
| 8 | 9 |
* 대시보드 화면 하나가 쓰는 서비스. 집계 쿼리는 {@link DashboardMapper#findOrgRows()} 한 번이고,
|
| 9 | 10 |
* 상단 KPI는 그 결과를 Java에서 접어 만든다 - 같은 원본에서 계산해야 타일 숫자와 아래 표/칸반 |
| 10 | 11 |
* 숫자가 어긋나지 않는다(쿼리를 따로 두면 두 결과 사이에 시차가 생긴다). |
| 12 |
+ * |
|
| 13 |
+ * <p>어느 단계부터 각 KPI에 세는지는 코드표(STAGE의 kpiFrom 속성)에서 온다. 예전에는 여기 |
|
| 14 |
+ * 상수 세 개로 박혀 있어서, 기준이 바뀔 때마다 배포가 필요했다.</p> |
|
| 11 | 15 |
*/ |
| 12 | 16 |
@Service |
| 13 | 17 |
public class DashboardService {
|
| 14 | 18 |
|
| 15 |
- /** ②목록접수. 시안 KPI "서류제출"의 기준으로 쓴다. */ |
|
| 16 |
- private static final int STAGE_DOC_SUBMITTED = 2; |
|
| 17 |
- /** ⑪법률검토 최종완료. 시안 KPI "완료"의 기준. */ |
|
| 18 |
- private static final int STAGE_COMPLETED = 11; |
|
| 19 |
- /** ⑫보고서 작성. */ |
|
| 20 |
- private static final int STAGE_REPORT = 12; |
|
| 19 |
+ /** KPI 이름. 코드표의 kpiFrom 값과 맞물린다. */ |
|
| 20 |
+ static final String KPI_DOC_SUBMITTED = "docSubmitted"; |
|
| 21 |
+ static final String KPI_COMPLETED = "completed"; |
|
| 22 |
+ static final String KPI_REPORT = "reportWriting"; |
|
| 21 | 23 |
|
| 22 | 24 |
private final DashboardMapper mapper; |
| 25 |
+ private final CodeService codeService; |
|
| 23 | 26 |
|
| 24 |
- public DashboardService(DashboardMapper mapper) {
|
|
| 27 |
+ public DashboardService(DashboardMapper mapper, CodeService codeService) {
|
|
| 25 | 28 |
this.mapper = mapper; |
| 29 |
+ this.codeService = codeService; |
|
| 30 |
+ } |
|
| 31 |
+ |
|
| 32 |
+ /** |
|
| 33 |
+ * 이 KPI에 집계하기 시작하는 단계 번호. 코드표에 지정이 없으면 아무 기관도 세지 않도록 |
|
| 34 |
+ * {@link Integer#MAX_VALUE}를 준다 - 0을 주면 단계가 있는 모든 기관이 잘못 집계된다.
|
|
| 35 |
+ */ |
|
| 36 |
+ private int thresholdOf(String kpi) {
|
|
| 37 |
+ return codeService.group(kr.itn.itnhub.code.Codes.STAGE).stream() |
|
| 38 |
+ .filter(code -> kpi.equals(code.attr(kr.itn.itnhub.code.Codes.ATTR_KPI_FROM))) |
|
| 39 |
+ .mapToInt(code -> Integer.parseInt(code.code())) |
|
| 40 |
+ .min() |
|
| 41 |
+ .orElse(Integer.MAX_VALUE); |
|
| 26 | 42 |
} |
| 27 | 43 |
|
| 28 | 44 |
public DashboardResponse dashboard() {
|
... | ... | @@ -31,6 +47,10 @@ |
| 31 | 47 |
} |
| 32 | 48 |
|
| 33 | 49 |
DashboardSummary summarize(List<DashboardOrgRow> rows) {
|
| 50 |
+ int docSubmittedFrom = thresholdOf(KPI_DOC_SUBMITTED); |
|
| 51 |
+ int completedFrom = thresholdOf(KPI_COMPLETED); |
|
| 52 |
+ int reportFrom = thresholdOf(KPI_REPORT); |
|
| 53 |
+ |
|
| 34 | 54 |
int docSubmitted = 0; |
| 35 | 55 |
int reviewTotal = 0; |
| 36 | 56 |
int reviewDone = 0; |
... | ... | @@ -51,13 +71,13 @@ |
| 51 | 71 |
if (stage == null) {
|
| 52 | 72 |
continue; |
| 53 | 73 |
} |
| 54 |
- if (stage >= STAGE_DOC_SUBMITTED) {
|
|
| 74 |
+ if (stage >= docSubmittedFrom) {
|
|
| 55 | 75 |
docSubmitted++; |
| 56 | 76 |
} |
| 57 |
- if (stage >= STAGE_COMPLETED) {
|
|
| 77 |
+ if (stage >= completedFrom) {
|
|
| 58 | 78 |
completed++; |
| 59 | 79 |
} |
| 60 |
- if (stage >= STAGE_REPORT) {
|
|
| 80 |
+ if (stage >= reportFrom) {
|
|
| 61 | 81 |
reportWriting++; |
| 62 | 82 |
} |
| 63 | 83 |
} |
--- src/main/java/kr/itn/itnhub/stage/StageService.java
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 |
package kr.itn.itnhub.stage; |
| 2 | 2 |
|
| 3 |
+import kr.itn.itnhub.code.CodeService; |
|
| 4 |
+import kr.itn.itnhub.code.Codes; |
|
| 3 | 5 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 4 | 6 |
import kr.itn.itnhub.org.OrgResponse; |
| 5 | 7 |
import kr.itn.itnhub.org.Organization; |
... | ... | @@ -17,10 +19,13 @@ |
| 17 | 19 |
|
| 18 | 20 |
private final OrganizationMapper orgMapper; |
| 19 | 21 |
private final StageHistoryMapper historyMapper; |
| 22 |
+ private final CodeService codeService; |
|
| 20 | 23 |
|
| 21 |
- public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper) {
|
|
| 24 |
+ public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper, |
|
| 25 |
+ CodeService codeService) {
|
|
| 22 | 26 |
this.orgMapper = orgMapper; |
| 23 | 27 |
this.historyMapper = historyMapper; |
| 28 |
+ this.codeService = codeService; |
|
| 24 | 29 |
} |
| 25 | 30 |
|
| 26 | 31 |
@Transactional |
... | ... | @@ -30,9 +35,13 @@ |
| 30 | 35 |
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
| 31 | 36 |
} |
| 32 | 37 |
|
| 38 |
+ // 단계 개수는 코드표에서 온다. 예전에는 12가 박혀 있어서 단계를 늘릴 때 |
|
| 39 |
+ // 여기와 메시지까지 같이 고쳐야 했다. |
|
| 40 |
+ int lastStage = codeService.group(Codes.STAGE).size(); |
|
| 33 | 41 |
Integer stage = request.stage(); |
| 34 |
- if (stage == null || stage < 1 || stage > 12) {
|
|
| 35 |
- throw new InvalidStageException("진행단계는 1에서 12 사이의 값이어야 합니다.");
|
|
| 42 |
+ if (stage == null || stage < 1 || stage > lastStage) {
|
|
| 43 |
+ throw new InvalidStageException( |
|
| 44 |
+ "진행단계는 1에서 " + lastStage + " 사이의 값이어야 합니다."); |
|
| 36 | 45 |
} |
| 37 | 46 |
|
| 38 | 47 |
orgMapper.updateStage(orgId, stage); |
--- src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
+++ src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
... | ... | @@ -85,6 +85,11 @@ |
| 85 | 85 |
orgId, seq, status); |
| 86 | 86 |
} |
| 87 | 87 |
|
| 88 |
+ /** |
|
| 89 |
+ * KPI 기준 단계는 코드표(STAGE의 kpiFrom)에서 온다. 요구사항 [2]로 완료 기준이 |
|
| 90 |
+ * ⑪법률검토 최종완료에서 ⑬보고서 제출 완료로 올라갔다 - 보고서가 실제로 올라온 |
|
| 91 |
+ * 기관만 완료로 센다. 그래서 11단계인 003은 더 이상 완료가 아니다. |
|
| 92 |
+ */ |
|
| 88 | 93 |
@Test |
| 89 | 94 |
void KPI는_전체_기관과_단계별_도달_기관수를_센다() throws Exception {
|
| 90 | 95 |
mvc.perform(get("/api/dashboard"))
|
... | ... | @@ -93,13 +98,23 @@ |
| 93 | 98 |
.andExpect(jsonPath("$.summary.applied").value(3))
|
| 94 | 99 |
// 서류제출 = 2단계 이상 (002, 003) |
| 95 | 100 |
.andExpect(jsonPath("$.summary.docSubmitted").value(2))
|
| 96 |
- // 완료 = 11단계 이상 (003) |
|
| 97 |
- .andExpect(jsonPath("$.summary.completed").value(1))
|
|
| 101 |
+ // 완료 = 13단계 이상 (없음) |
|
| 102 |
+ .andExpect(jsonPath("$.summary.completed").value(0))
|
|
| 98 | 103 |
// 보고서 작성 = 12단계 이상 (없음) |
| 99 | 104 |
.andExpect(jsonPath("$.summary.reportWriting").value(0));
|
| 100 | 105 |
} |
| 101 | 106 |
|
| 102 | 107 |
@Test |
| 108 |
+ void 완료는_보고서_제출까지_끝난_기관만_센다() throws Exception {
|
|
| 109 |
+ setStage(stage11Id, 13); |
|
| 110 |
+ |
|
| 111 |
+ mvc.perform(get("/api/dashboard"))
|
|
| 112 |
+ .andExpect(status().isOk()) |
|
| 113 |
+ .andExpect(jsonPath("$.summary.completed").value(1))
|
|
| 114 |
+ .andExpect(jsonPath("$.summary.reportWriting").value(1));
|
|
| 115 |
+ } |
|
| 116 |
+ |
|
| 117 |
+ @Test |
|
| 103 | 118 |
void KPI의_권리확인_권리처리는_전_기관_건수_합계다() throws Exception {
|
| 104 | 119 |
mvc.perform(get("/api/dashboard"))
|
| 105 | 120 |
.andExpect(status().isOk()) |
--- src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
+++ src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
... | ... | @@ -82,19 +82,34 @@ |
| 82 | 82 |
assertThat(mapper.findById(orgId).getStage()).isEqualTo(2); |
| 83 | 83 |
} |
| 84 | 84 |
|
| 85 |
+ /** |
|
| 86 |
+ * 상한은 코드표에 등록된 단계 수를 따른다. 요구사항 [11]로 13단계(보고서 제출 완료)가 |
|
| 87 |
+ * 생겼으므로 13은 이제 정상이고, 14부터 범위를 벗어난다. |
|
| 88 |
+ */ |
|
| 85 | 89 |
@Test |
| 86 | 90 |
void 범위를_벗어난_값은_400이다() throws Exception {
|
| 87 | 91 |
mvc.perform(put("/api/orgs/{id}/stage", orgId)
|
| 88 | 92 |
.with(csrf()) |
| 89 | 93 |
.contentType(MediaType.APPLICATION_JSON) |
| 90 |
- .content("{\"stage\": 13}"))
|
|
| 94 |
+ .content("{\"stage\": 14}"))
|
|
| 91 | 95 |
.andExpect(status().isBadRequest()) |
| 92 |
- .andExpect(jsonPath("$.message").value(containsString("1에서 12")));
|
|
| 96 |
+ .andExpect(jsonPath("$.message").value(containsString("1에서 13")));
|
|
| 93 | 97 |
|
| 94 | 98 |
assertThat(mapper.findById(orgId).getStage()).isNull(); |
| 95 | 99 |
} |
| 96 | 100 |
|
| 97 | 101 |
@Test |
| 102 |
+ void 마지막_단계인_13단계는_저장된다() throws Exception {
|
|
| 103 |
+ mvc.perform(put("/api/orgs/{id}/stage", orgId)
|
|
| 104 |
+ .with(csrf()) |
|
| 105 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 106 |
+ .content("{\"stage\": 13}"))
|
|
| 107 |
+ .andExpect(status().isOk()); |
|
| 108 |
+ |
|
| 109 |
+ assertThat(mapper.findById(orgId).getStage()).isEqualTo(13); |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ @Test |
|
| 98 | 113 |
void 하한_미만인_0단계는_400이다() throws Exception {
|
| 99 | 114 |
mvc.perform(put("/api/orgs/{id}/stage", orgId)
|
| 100 | 115 |
.with(csrf()) |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?