From 9779d85ae32a3f7d972c59bd58e9a6dd4dc4a180 Mon Sep 17 00:00:00 2001 From: mini Date: Thu, 6 Aug 2026 11:59:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=85=EB=AC=B4=ED=95=A8=EC=9D=84=20?= =?UTF-8?q?Case=20API=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /workers + /tasks + /workflow-catalogs 조합해서 클라이언트에서 직접 만들던 업무함 데이터를 서버의 GET /api/v1/cases, /cases/{id}/projection으로 대체. progress·display_status 계산도 서버가 내려주는 값 그대로 사용. Worker API 실패는 이제 non-blocking으로 처리(Case가 worker_id/worker_display_name을 이미 들고 있어서 조인 실패 케이스가 없어짐). Closes #268 --- src/api/cases.ts | 86 ++++++ src/pages/WorkListPage/WorkInboxDetail.tsx | 115 ++++---- .../WorkListPage/WorkInboxTargetList.tsx | 26 +- src/pages/WorkListPage/WorkListPage.test.tsx | 225 ++++++---------- src/pages/WorkListPage/WorkListPage.tsx | 232 +++-------------- src/pages/WorkListPage/workInboxModel.test.ts | 246 +++++++----------- src/pages/WorkListPage/workInboxModel.ts | 234 ++++------------- .../WorkListPage/workInboxPresentation.ts | 44 ++-- 8 files changed, 438 insertions(+), 770 deletions(-) create mode 100644 src/api/cases.ts diff --git a/src/api/cases.ts b/src/api/cases.ts new file mode 100644 index 0000000..51a21cd --- /dev/null +++ b/src/api/cases.ts @@ -0,0 +1,86 @@ +import { apiFetch } from './client' +import type { TaskStatus, TaskType } from './tasks' + +// fowoco/server CaseController 기준 (#88 업무함 Case·Workflow Snapshot 조회). +export type CaseDisplayStatus = + | 'DOCUMENT_PENDING' + | 'REQUEST_SENT' + | 'REVIEW_REQUIRED' + | 'COMPLETED' + | 'CANCELLED' + +export type CaseLifecycleStatus = 'ACTIVE' | 'COMPLETED' | 'CANCELLED' +export type CasePriority = 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW' + +export interface CaseTaskResponse { + task_id: string + task_type: TaskType + title: string + status: TaskStatus + due_date: string | null +} + +export interface CaseProgressResponse { + completed_steps: number + total_steps: number + percentage: number +} + +export interface CaseSummaryResponse { + case_id: string + worker_id: string + worker_display_name: string + title: string + display_status: CaseDisplayStatus + has_unread_response: boolean + priority: CasePriority + progress: CaseProgressResponse + due_date: string | null + current_task: CaseTaskResponse | null + updated_at: string +} + +export interface CasePageResponse { + items: CaseSummaryResponse[] + page: number + size: number + total_elements: number + total_pages: number +} + +export interface CaseReadinessResponse { + completed_checklist_items: number + total_checklist_items: number + verified_documents: number + total_documents: number + pending_approvals: number + approved_approvals: number + worker_responses: number + evidence_items: number +} + +export interface CaseProjectionResponse extends CaseSummaryResponse { + lifecycle_status: CaseLifecycleStatus + readiness: CaseReadinessResponse + tasks: CaseTaskResponse[] + workflow_catalog_version: string + workflow_snapshot: Record +} + +export interface FetchCasesParams { + keyword?: string + page?: number + size?: number +} + +export function fetchCases(params: FetchCasesParams = {}): Promise { + const query = new URLSearchParams() + if (params.keyword) query.set('keyword', params.keyword) + query.set('page', String(params.page ?? 0)) + query.set('size', String(params.size ?? 100)) + return apiFetch(`/cases?${query.toString()}`) +} + +export function fetchCaseProjection(caseId: string): Promise { + return apiFetch(`/cases/${encodeURIComponent(caseId)}/projection`) +} diff --git a/src/pages/WorkListPage/WorkInboxDetail.tsx b/src/pages/WorkListPage/WorkInboxDetail.tsx index c70b6da..ef508fb 100644 --- a/src/pages/WorkListPage/WorkInboxDetail.tsx +++ b/src/pages/WorkListPage/WorkInboxDetail.tsx @@ -2,14 +2,15 @@ import { useEffect, useState } from 'react' import { Button } from '../../components/ui/Button/Button' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { useToastStore } from '../../store/toastStore' -import { getWorkInboxCaseProgress, type WorkInboxWorkerGroup } from './workInboxModel' +import type { WorkInboxWorkerGroup } from './workInboxModel' import { + getCaseDisplayStatusPresentation, getDecisionSummary, getDuePresentation, getReviewActionLabel, getTaskStatusPresentation, getWorkflowLabel, - isReviewTask, + isReviewCase, } from './workInboxPresentation' import styles from './WorkListPage.module.css' @@ -29,33 +30,33 @@ const NATIONALITY_LABEL: Record = { } function getWorkerMeta(group: WorkInboxWorkerGroup): string { - const nationality = - NATIONALITY_LABEL[group.worker.nationality_code] ?? group.worker.nationality_code - const workStatus = group.worker.work_status === 'ACTIVE' ? '재직' : '근무 상태 확인 필요' + const worker = group.worker + if (!worker) return '근무 정보 확인 필요' + const nationality = NATIONALITY_LABEL[worker.nationality_code] ?? worker.nationality_code + const workStatus = worker.work_status === 'ACTIVE' ? '재직' : '근무 상태 확인 필요' return `${nationality} · ${workStatus} · 비자·근무 정보 미등록` } export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) { const showToast = useToastStore((state) => state.showToast) - const [activeCaseKey, setActiveCaseKey] = useState(group.primaryCase.key) + const [activeCaseId, setActiveCaseId] = useState(group.primaryCase.case_id) // 근로자를 바꾸면 새 근로자의 우선 Case로 되돌린다. useEffect(() => { - setActiveCaseKey(group.primaryCase.key) - }, [group.worker.worker_id, group.primaryCase.key]) + setActiveCaseId(group.primaryCase.case_id) + }, [group.workerId, group.primaryCase.case_id]) - const activeCaseIndex = group.cases.findIndex((item) => item.key === activeCaseKey) + const activeCaseIndex = group.cases.findIndex((item) => item.case_id === activeCaseId) const activeCase = group.cases[activeCaseIndex] ?? group.primaryCase - const activeTask = activeCase.primaryTask - const progress = getWorkInboxCaseProgress(activeCase) - const due = getDuePresentation(activeTask.task.due_date) - const activeStatus = getTaskStatusPresentation(activeTask.task.status) - const reviewTasks = group.tasks.filter((item) => isReviewTask(item.task.status)) - const detailTitleId = `work-inbox-detail-${group.worker.worker_id}` + const activeTask = activeCase.current_task + const due = getDuePresentation(activeTask?.due_date ?? activeCase.due_date) + const activeStatus = getCaseDisplayStatusPresentation(activeCase.display_status) + const reviewCases = group.cases.filter((item) => isReviewCase(item.display_status)) + const detailTitleId = `work-inbox-detail-${group.workerId}` function handleOpenOtherCase() { const nextIndex = (Math.max(activeCaseIndex, 0) + 1) % group.cases.length - setActiveCaseKey(group.cases[nextIndex].key) + setActiveCaseId(group.cases[nextIndex].case_id) } function handleViewEvidence() { @@ -71,7 +72,7 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {

- {group.worker.display_name} + {group.workerDisplayName}

{getWorkerMeta(group)}

@@ -79,7 +80,7 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) { + {activeTask && ( + + )}
- {activeCase.caseId && Case {activeCase.caseId}} -

{activeTask.task.title}

+ Case {activeCase.case_id} +

{activeTask?.title ?? activeCase.title}

- {getWorkflowLabel(activeTask)} · {due.label} · {activeStatus.label} + {activeCase.title} · {due.label} · {activeStatus.label}

- {progress.completed}/{progress.total} + {activeCase.progress.completed_steps}/{activeCase.progress.total_steps}
{group.cases.length > 1 && ( @@ -138,32 +141,36 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {

검토할 업무

- {reviewTasks.length}건 + {reviewCases.length}건
- {reviewTasks.length === 0 ? ( + {reviewCases.length === 0 ? (

현재 검토할 업무가 없습니다.

) : (
- {reviewTasks.map((item, index) => { - const taskDue = getDuePresentation(item.task.due_date) - const taskStatus = getTaskStatusPresentation(item.task.status) + {reviewCases.map((item, index) => { + const task = item.current_task + const taskDue = getDuePresentation(task?.due_date ?? item.due_date) + const taskStatus = task ? getTaskStatusPresentation(task.status) : null return ( -
+
-

{item.task.title}

+

{task?.title ?? item.title}

- {getWorkflowLabel(item)} · {taskStatus.label} + {task ? getWorkflowLabel(task) : item.title} ·{' '} + {taskStatus?.label ?? getCaseDisplayStatusPresentation(item.display_status).label}

{taskDue.label} - + {task && ( + + )}
) })} @@ -184,9 +191,11 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
-

{getDecisionSummary(activeTask.task.status)}

+

+ {activeTask ? getDecisionSummary(activeTask.status) : activeStatus.label} +

- 진행 업무 건 {group.cases.length}개 · 확인할 업무 {reviewTasks.length}개 · 자동 + 진행 업무 건 {group.cases.length}개 · 확인할 업무 {reviewCases.length}개 · 자동 확정되지 않음

diff --git a/src/pages/WorkListPage/WorkInboxTargetList.tsx b/src/pages/WorkListPage/WorkInboxTargetList.tsx index ff0b0b7..29cef92 100644 --- a/src/pages/WorkListPage/WorkInboxTargetList.tsx +++ b/src/pages/WorkListPage/WorkInboxTargetList.tsx @@ -25,7 +25,7 @@ export function WorkInboxTargetList({ function moveSelection(event: KeyboardEvent, currentIndex: number) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - onSelect(groups[currentIndex].worker.worker_id) + onSelect(groups[currentIndex].workerId) return } @@ -39,8 +39,8 @@ export function WorkInboxTargetList({ event.preventDefault() const nextGroup = groups[nextIndex] if (!nextGroup) return - onSelect(nextGroup.worker.worker_id) - optionRefs.current.get(nextGroup.worker.worker_id)?.focus() + onSelect(nextGroup.workerId) + optionRefs.current.get(nextGroup.workerId)?.focus() } return ( @@ -55,27 +55,27 @@ export function WorkInboxTargetList({
{groups.map((group, index) => { - const task = group.primaryTask - const due = getDuePresentation(task.task.due_date) - const reviewStage = getReviewStageLink(task.task.status) - const selected = group.worker.worker_id === selectedWorkerId + const currentTask = group.primaryCase.current_task + const due = getDuePresentation(currentTask?.due_date ?? group.primaryCase.due_date) + const reviewStage = getReviewStageLink(currentTask?.status ?? 'DRAFT') + const selected = group.workerId === selectedWorkerId return (
{ - if (node) optionRefs.current.set(group.worker.worker_id, node) - else optionRefs.current.delete(group.worker.worker_id) + if (node) optionRefs.current.set(group.workerId, node) + else optionRefs.current.delete(group.workerId) }} role="option" aria-selected={selected} tabIndex={selected ? 0 : -1} className={`${styles.targetOption} ${selected ? styles.targetOptionSelected : ''}`} - onClick={() => onSelect(group.worker.worker_id)} + onClick={() => onSelect(group.workerId)} onKeyDown={(event) => moveSelection(event, index)} > - {group.worker.display_name} + {group.workerDisplayName} - {getWorkflowLabel(task)} · {due.label} + {currentTask ? getWorkflowLabel(currentTask) : group.primaryCase.title} · {due.label}
) diff --git a/src/pages/WorkListPage/WorkListPage.test.tsx b/src/pages/WorkListPage/WorkListPage.test.tsx index f14fdac..75ec0b3 100644 --- a/src/pages/WorkListPage/WorkListPage.test.tsx +++ b/src/pages/WorkListPage/WorkListPage.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes, useLocation, useParams } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TaskPageResponse, TaskSummaryResponse } from '../../api/tasks' +import type { CasePageResponse, CaseSummaryResponse, CaseTaskResponse } from '../../api/cases' import type { WorkerPageResponse, WorkerResponse } from '../../api/workers' import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' import { useToastStore } from '../../store/toastStore' @@ -39,25 +39,33 @@ function worker( } } -function task( - taskId: string, - workerId: string, - overrides: Partial = {}, -): TaskSummaryResponse { +function currentTask(overrides: Partial & { task_id: string }): CaseTaskResponse { return { - task_id: taskId, - worker_id: workerId, - case_id: null, task_type: 'STAY_PERIOD_EXTENSION', - workflow_id: 'wf-stay-extension', - workflow_catalog_version: '1', - title: `업무 ${taskId}`, - source: 'MANUAL', + title: `업무 ${overrides.task_id}`, status: 'DRAFT', due_date: null, - content_revision: 1, - version: 1, - created_at: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +function caseSummary( + caseId: string, + workerId: string, + workerDisplayName: string, + overrides: Partial = {}, +): CaseSummaryResponse { + return { + case_id: caseId, + worker_id: workerId, + worker_display_name: workerDisplayName, + title: `Case ${caseId}`, + display_status: 'REVIEW_REQUIRED', + has_unread_response: false, + priority: 'NORMAL', + progress: { completed_steps: 0, total_steps: 1, percentage: 0 }, + due_date: null, + current_task: null, updated_at: '2026-01-01T00:00:00Z', ...overrides, } @@ -68,26 +76,33 @@ const WORKERS = [ worker('W-2', '파티마 누르', { nationality_code: 'ID' }), ] -const TASKS = [ - task('T-1', 'W-1', { - case_id: 'CASE-1', - title: '체류연장 업무 초안', - status: 'READY_FOR_REVIEW', +const CASES = [ + caseSummary('CASE-1', 'W-1', '응우옌 안', { + title: '체류기간 연장', + display_status: 'REVIEW_REQUIRED', + priority: 'HIGH', + progress: { completed_steps: 1, total_steps: 2, percentage: 50 }, due_date: isoDateOffset(5), + current_task: currentTask({ + task_id: 'T-1', + title: '체류연장 업무 초안', + status: 'READY_FOR_REVIEW', + due_date: isoDateOffset(5), + }), }), - task('T-2', 'W-1', { - case_id: 'CASE-1', - title: '여권 만료일 확인', - status: 'COMPLETED', - due_date: isoDateOffset(12), - }), - task('T-3', 'W-2', { - case_id: 'CASE-2', - workflow_id: 'wf-contract', - task_type: 'RECONTRACT', + caseSummary('CASE-2', 'W-2', '파티마 누르', { title: '표준근로계약서 갱신', - status: 'DRAFT', + display_status: 'DOCUMENT_PENDING', + priority: 'NORMAL', + progress: { completed_steps: 0, total_steps: 1, percentage: 0 }, due_date: isoDateOffset(1), + current_task: currentTask({ + task_id: 'T-3', + task_type: 'RECONTRACT', + title: '표준근로계약서 갱신', + status: 'DRAFT', + due_date: isoDateOffset(1), + }), }), ] @@ -126,10 +141,7 @@ function workerPageResponse( } } -function taskPageResponse( - items: TaskSummaryResponse[], - totalElements = items.length, -): TaskPageResponse { +function casePageResponse(items: CaseSummaryResponse[], totalElements = items.length): CasePageResponse { return { items, page: 0, @@ -139,44 +151,9 @@ function taskPageResponse( } } -function catalogResponse() { - return { - bundle_id: 'b-1', - bundle_version: '1', - bundle_status: 'ACTIVE', - source_repository: 'fowoco/knowledge', - generated_at: '2026-07-01T00:00:00Z', - workflows: [ - { - workflow_id: 'wf-stay-extension', - name: '체류기간 연장', - intent: '', - sensitivity: 'normal', - supported_task_types: ['STAY_PERIOD_EXTENSION'], - required_slots: [], - checklist_items: [], - completion_evidence: [], - source_ids: [], - }, - { - workflow_id: 'wf-contract', - name: 'Contract Review', - intent: '', - sensitivity: 'normal', - supported_task_types: ['RECONTRACT'], - required_slots: [], - checklist_items: [], - completion_evidence: [], - source_ids: [], - }, - ], - } -} - interface MockApiOptions { workers?: WorkerPageResponse | Response - tasks?: TaskPageResponse | Response - catalog?: ReturnType | Response + cases?: CasePageResponse | Response } function mockApi(options: MockApiOptions = {}) { @@ -186,11 +163,7 @@ function mockApi(options: MockApiOptions = {}) { const value = options.workers ?? workerPageResponse(WORKERS) return Promise.resolve(value instanceof Response ? value : jsonResponse(value)) } - if (url.includes('/workflow-catalogs')) { - const value = options.catalog ?? catalogResponse() - return Promise.resolve(value instanceof Response ? value : jsonResponse(value)) - } - const value = options.tasks ?? taskPageResponse(TASKS) + const value = options.cases ?? casePageResponse(CASES) return Promise.resolve(value instanceof Response ? value : jsonResponse(value)) }) } @@ -237,7 +210,7 @@ afterEach(() => { }) describe('WorkListPage', () => { - it('joins workers and tasks into a selected master-detail view without N+1 requests', async () => { + it('joins case and worker data into a selected master-detail view', async () => { mockApi() renderPage() @@ -247,14 +220,13 @@ describe('WorkListPage', () => { ).toBeInTheDocument() expect(screen.getByText('1/2')).toBeInTheDocument() expect( - screen.getByRole('progressbar', { name: /체류연장 업무 초안 Case 진행률/ }), + screen.getByRole('progressbar', { name: /체류기간 연장 Case 진행률/ }), ).toHaveAttribute('value', '1') const calledUrls = vi.mocked(fetch).mock.calls.map(([url]) => String(url)) - expect(calledUrls).toHaveLength(3) + expect(calledUrls).toHaveLength(2) expect(calledUrls.some((url) => url.includes('/workers?'))).toBe(true) - expect(calledUrls.some((url) => url.includes('/tasks?'))).toBe(true) - expect(calledUrls.some((url) => url.includes('/workflow-catalogs'))).toBe(true) + expect(calledUrls.some((url) => url.includes('/cases?'))).toBe(true) expect(calledUrls.some((url) => /\/tasks\/T-/.test(url))).toBe(false) }) @@ -284,29 +256,25 @@ describe('WorkListPage', () => { ) }) - it('searches worker, task, workflow, and case fields in the loaded data', async () => { + it('searches worker and case fields in the loaded data without re-fetching', async () => { mockApi() const user = userEvent.setup() renderPage() const search = await screen.findByLabelText('근로자·업무 건·지금 할 일 검색') - await user.type(search, 'Contract Review') + await user.type(search, '표준근로계약서') await waitFor(() => { expect(screen.getByRole('option', { name: /파티마 누르/ })).toBeInTheDocument() expect(screen.queryByRole('option', { name: /응우옌 안/ })).not.toBeInTheDocument() }) - const taskUrls = vi + const caseUrls = vi .mocked(fetch) .mock.calls.map(([url]) => String(url)) - .filter((url) => url.includes('/tasks?')) - expect(taskUrls).toHaveLength(1) - expect(taskUrls[0]).not.toContain('keyword=') - - await user.clear(search) - await user.type(search, 'CASE-1') - expect(await screen.findByRole('option', { name: /응우옌 안/ })).toBeInTheDocument() + .filter((url) => url.includes('/cases?')) + expect(caseUrls).toHaveLength(1) + expect(caseUrls[0]).not.toContain('keyword=') }) it('sorts workers by due date when requested', async () => { @@ -333,36 +301,24 @@ describe('WorkListPage', () => { expect(screen.queryByText('업무 상세 CASE-1')).not.toBeInTheDocument() }) - it('keeps the worker list visible when the Task API fails and retries only tasks', async () => { - mockApi({ tasks: errorResponse('/api/v1/tasks') }) - const user = userEvent.setup() + it('shows a blocking error when the Case API fails', async () => { + mockApi({ cases: errorResponse('/api/v1/cases') }) renderPage() - expect(await screen.findByRole('option', { name: /응우옌 안/ })).toBeInTheDocument() - expect(screen.getByText('연결된 업무를 불러오지 못했습니다')).toBeInTheDocument() - - const beforeRetry = vi - .mocked(fetch) - .mock.calls.filter(([url]) => String(url).includes('/tasks?')).length - await user.click(screen.getByRole('button', { name: '다시 시도' })) - await waitFor(() => { - const afterRetry = vi - .mocked(fetch) - .mock.calls.filter(([url]) => String(url).includes('/tasks?')).length - expect(afterRetry).toBeGreaterThan(beforeRetry) - }) + expect(await screen.findByText('업무 정보를 불러오지 못했습니다')).toBeInTheDocument() + expect(screen.queryByRole('listbox', { name: '업무 대상 근로자' })).not.toBeInTheDocument() }) - it('shows a blocking error when the Worker API fails', async () => { + it('still shows the case list when the Worker API fails, with a fallback meta line', async () => { mockApi({ workers: errorResponse('/api/v1/workers') }) renderPage() - expect(await screen.findByText('근로자 정보를 불러오지 못했습니다')).toBeInTheDocument() - expect(screen.queryByRole('listbox', { name: '업무 대상 근로자' })).not.toBeInTheDocument() + expect(await screen.findByRole('heading', { name: '응우옌 안', level: 2 })).toBeInTheDocument() + expect(screen.getByText('근무 정보 확인 필요')).toBeInTheDocument() }) - it('shows the empty work state and create action when there are no tasks', async () => { - mockApi({ tasks: taskPageResponse([]) }) + it('shows the empty work state and create action when there are no cases', async () => { + mockApi({ cases: casePageResponse([]) }) const user = userEvent.setup() renderPage() @@ -371,24 +327,8 @@ describe('WorkListPage', () => { expect(await screen.findByText('업무 생성')).toBeInTheDocument() }) - it('falls back to task type labels if the workflow catalog fails', async () => { - mockApi({ catalog: errorResponse('/api/v1/workflow-catalogs') }) - renderPage() - - expect( - await screen.findByText('업무 분류 이름을 불러오지 못해 업무 유형으로 표시합니다.'), - ).toBeInTheDocument() - expect(screen.getAllByText(/체류기간 연장/).length).toBeGreaterThan(0) - }) - - it('discloses pagination caps and hides unresolved worker references', async () => { - mockApi({ - workers: workerPageResponse(WORKERS, 120), - tasks: taskPageResponse( - [...TASKS, task('T-orphan', 'W-missing', { title: '연결 오류 업무' })], - 140, - ), - }) + it('discloses pagination caps when only part of the case list has loaded', async () => { + mockApi({ cases: casePageResponse(CASES, 140) }) renderPage() expect( @@ -396,10 +336,6 @@ describe('WorkListPage', () => { '일부 데이터만 불러왔습니다. 검색·정렬·진행률은 현재 불러온 범위 기준입니다.', ), ).toBeInTheDocument() - expect( - screen.getByText('근로자 정보를 확인할 수 없는 업무 1건은 목록에서 제외했습니다.'), - ).toBeInTheDocument() - expect(screen.queryByText('연결 오류 업무')).not.toBeInTheDocument() }) it('shows a dedicated empty state when unified search has no matches', async () => { @@ -417,13 +353,18 @@ describe('WorkListPage', () => { it('switches between a worker\'s cases via "다른 Case 열기"', async () => { mockApi({ - tasks: taskPageResponse([ - ...TASKS, - task('T-4', 'W-1', { - case_id: 'CASE-3', - title: '근로자 안내문 초안', - status: 'DRAFT', + cases: casePageResponse([ + ...CASES, + caseSummary('CASE-3', 'W-1', '응우옌 안', { + title: '근로자 안내문 준비', + priority: 'LOW', due_date: isoDateOffset(20), + current_task: currentTask({ + task_id: 'T-4', + title: '근로자 안내문 초안', + status: 'DRAFT', + due_date: isoDateOffset(20), + }), }), ]), }) @@ -450,7 +391,7 @@ describe('WorkListPage', () => { const user = userEvent.setup() renderPage() - // T-1 (응우옌 안의 우선 업무 건)은 READY_FOR_REVIEW 상태 -> 04 최종 검토로 라벨링된다. + // CASE-1의 current_task는 READY_FOR_REVIEW 상태 -> 04 최종 검토로 라벨링된다. const option = await screen.findByRole('option', { name: /응우옌 안/ }) const stageLink = within(option).getByRole('link', { name: '최종 검토' }) expect(stageLink).toHaveAttribute('href', '/tasks/new/review?step=3') diff --git a/src/pages/WorkListPage/WorkListPage.tsx b/src/pages/WorkListPage/WorkListPage.tsx index 47b32fb..62dd41c 100644 --- a/src/pages/WorkListPage/WorkListPage.tsx +++ b/src/pages/WorkListPage/WorkListPage.tsx @@ -1,13 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' +import { fetchCases, type CasePageResponse } from '../../api/cases' import { getErrorMessage } from '../../api/errors' -import { fetchTasks, type TaskPageResponse } from '../../api/tasks' -import { fetchWorkers, type WorkerPageResponse, type WorkerResponse } from '../../api/workers' -import { fetchWorkflowCatalog } from '../../api/workflows' +import { fetchWorkers } from '../../api/workers' import { Dropdown } from '../../components/ui/Dropdown/Dropdown' -import { EmptyState, type EmptyStateKind } from '../../components/ui/EmptyState/EmptyState' +import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { SearchInput } from '../../components/ui/SearchInput/SearchInput' -import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { useApiQuery } from '../../hooks/useApiQuery' import { useDebouncedValue } from '../../hooks/useDebouncedValue' import { WorkInboxDetail } from './WorkInboxDetail' @@ -21,93 +19,10 @@ const SORT_OPTIONS: { value: WorkInboxSort; label: string }[] = [ { value: 'worker-name', label: '정렬 · 근로자명' }, ] -function isWorkerPageEmpty(page: WorkerPageResponse): boolean { +function isCasePageEmpty(page: CasePageResponse): boolean { return page.items.length === 0 } -function isTaskPageEmpty(page: TaskPageResponse): boolean { - return page.items.length === 0 -} - -interface TaskStateWorkspaceProps { - workers: readonly WorkerResponse[] - selectedWorkerId: string | null - query: string - kind: Extract - title: string - body: string - onSelect: (workerId: string) => void - onRetry?: () => void -} - -function TaskStateWorkspace({ - workers, - selectedWorkerId, - query, - kind, - title, - body, - onSelect, - onRetry, -}: TaskStateWorkspaceProps) { - const normalizedQuery = query.trim().toLocaleLowerCase('ko-KR') - const visibleWorkers = workers.filter( - (worker) => - !normalizedQuery || worker.display_name.toLocaleLowerCase('ko-KR').includes(normalizedQuery), - ) - const resolvedWorkerId = - visibleWorkers.find((worker) => worker.worker_id === selectedWorkerId)?.worker_id ?? - visibleWorkers[0]?.worker_id ?? - null - - return ( -
-
-
-

- 근로자 {workers.length}명 -

-
-
- {visibleWorkers.map((worker) => { - const selected = worker.worker_id === resolvedWorkerId - return ( - - ) - })} -
-
-
- -
-
- ) -} - export function WorkListPage() { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() @@ -116,41 +31,30 @@ export function WorkListPage() { const debouncedQuery = useDebouncedValue(query) const selectedWorkerId = searchParams.get('workerId') + const casesFetcher = useCallback(() => fetchCases({ size: 100 }), []) const workersFetcher = useCallback(() => fetchWorkers({ size: 100 }), []) - const tasksFetcher = useCallback(() => fetchTasks({ size: 100 }), []) - const catalogFetcher = useCallback(() => fetchWorkflowCatalog(), []) - const workersQuery = useApiQuery(workersFetcher, isWorkerPageEmpty) - const tasksQuery = useApiQuery(tasksFetcher, isTaskPageEmpty) - const catalogQuery = useApiQuery(catalogFetcher) - - const workflowNameById = useMemo(() => { - const map = new Map() - for (const workflow of catalogQuery.data?.workflows ?? []) { - map.set(workflow.workflow_id, workflow.name) - } - return map - }, [catalogQuery.data]) + const casesQuery = useApiQuery(casesFetcher, isCasePageEmpty) + const workersQuery = useApiQuery(workersFetcher) const inbox = useMemo( () => buildWorkInboxModel({ + cases: casesQuery.data?.items ?? [], workers: workersQuery.data?.items ?? [], - tasks: tasksQuery.data?.items ?? [], - workflowNameById, query: debouncedQuery, selectedWorkerId, sort, }), - [workersQuery.data, tasksQuery.data, workflowNameById, debouncedQuery, selectedWorkerId, sort], + [casesQuery.data, workersQuery.data, debouncedQuery, selectedWorkerId, sort], ) const selectedGroup = inbox.selectedGroup - const selectedGroupId = selectedGroup?.worker.worker_id ?? null + const selectedGroupId = selectedGroup?.workerId ?? null useEffect(() => { if ( - tasksQuery.status !== 'success' || + casesQuery.status !== 'success' || !selectedGroupId || selectedWorkerId === selectedGroupId ) { @@ -159,7 +63,7 @@ export function WorkListPage() { const nextParams = new URLSearchParams(searchParams) nextParams.set('workerId', selectedGroupId) setSearchParams(nextParams, { replace: true }) - }, [searchParams, selectedGroupId, selectedWorkerId, setSearchParams, tasksQuery.status]) + }, [searchParams, selectedGroupId, selectedWorkerId, setSearchParams, casesQuery.status]) function handleSelectWorker(workerId: string) { const nextParams = new URLSearchParams(searchParams) @@ -167,15 +71,8 @@ export function WorkListPage() { setSearchParams(nextParams) } - function handleRetryAll() { - workersQuery.refetch() - tasksQuery.refetch() - catalogQuery.refetch() - } - const hasPaginationCap = - (workersQuery.data?.total_elements ?? 0) > (workersQuery.data?.items.length ?? 0) || - (tasksQuery.data?.total_elements ?? 0) > (tasksQuery.data?.items.length ?? 0) + (casesQuery.data?.total_elements ?? 0) > (casesQuery.data?.items.length ?? 0) const capNotice = hasPaginationCap ? '일부 데이터만 불러왔습니다. 검색·정렬·진행률은 현재 불러온 범위 기준입니다.' : null @@ -205,7 +102,7 @@ export function WorkListPage() { />
- {workersQuery.status === 'loading' && ( + {casesQuery.status === 'loading' && (
)} - {workersQuery.status === 'error' && ( + {casesQuery.status === 'error' && (
)} - {workersQuery.status === 'empty' && ( + {casesQuery.status === 'empty' && (
navigate('/tasks/new')} />
)} - {workersQuery.status === 'success' && tasksQuery.status === 'loading' && ( - - )} - - {workersQuery.status === 'success' && tasksQuery.status === 'error' && ( - - )} - - {workersQuery.status === 'success' && tasksQuery.status === 'empty' && ( + {casesQuery.status === 'success' && inbox.groups.length === 0 && (
navigate('/tasks/new')} + title="검색 결과가 없습니다" + body="다른 근로자명, Case 또는 업무명으로 다시 검색해 보세요." />
)} - {workersQuery.status === 'success' && - tasksQuery.status === 'success' && - inbox.allGroups.length === 0 && ( -
- -
- )} - - {workersQuery.status === 'success' && - tasksQuery.status === 'success' && - inbox.allGroups.length > 0 && - inbox.groups.length === 0 && ( -
- -
- )} - - {workersQuery.status === 'success' && tasksQuery.status === 'success' && selectedGroup && ( + {casesQuery.status === 'success' && selectedGroup && ( <> -
- {catalogQuery.status === 'error' && ( -

- 업무 분류 이름을 불러오지 못해 업무 유형으로 표시합니다. -

- )} - {inbox.orphanTasks.length > 0 && ( -

- 근로자 정보를 확인할 수 없는 업무 {inbox.orphanTasks.length}건은 목록에서 - 제외했습니다. -

- )} -

- {selectedGroup.worker.display_name}의 업무 상세를 표시했습니다. + {selectedGroup.workerDisplayName}의 업무 상세를 표시했습니다.

)} diff --git a/src/pages/WorkListPage/workInboxModel.test.ts b/src/pages/WorkListPage/workInboxModel.test.ts index 4e4fa1d..f9a85b9 100644 --- a/src/pages/WorkListPage/workInboxModel.test.ts +++ b/src/pages/WorkListPage/workInboxModel.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { TaskSummaryResponse } from '../../api/tasks' +import type { CasePriority, CaseSummaryResponse, CaseTaskResponse } from '../../api/cases' import type { WorkerResponse } from '../../api/workers' -import { - buildWorkInboxModel, - compareWorkInboxTasks, - getWorkInboxCaseProgress, - normalizeWorkInboxSearch, - type WorkInboxTask, -} from './workInboxModel' +import { buildWorkInboxModel, compareWorkInboxCases, normalizeWorkInboxSearch } from './workInboxModel' function worker(workerId: string, displayName = `근로자 ${workerId}`): WorkerResponse { return { @@ -26,234 +20,168 @@ function worker(workerId: string, displayName = `근로자 ${workerId}`): Worker } } -function task( - taskId: string, - workerId: string, - overrides: Partial = {}, -): TaskSummaryResponse { +function currentTask(taskId: string, overrides: Partial = {}): CaseTaskResponse { return { task_id: taskId, - worker_id: workerId, - case_id: null, task_type: 'STAY_PERIOD_EXTENSION', - workflow_id: 'wf-stay-extension', - workflow_catalog_version: '1', title: `업무 ${taskId}`, - source: 'MANUAL', status: 'DRAFT', due_date: null, - content_revision: 1, - version: 1, - created_at: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +function caseSummary( + caseId: string, + workerId: string, + workerDisplayName = `근로자 ${workerId}`, + overrides: Partial = {}, +): CaseSummaryResponse { + return { + case_id: caseId, + worker_id: workerId, + worker_display_name: workerDisplayName, + title: `Case ${caseId}`, + display_status: 'DOCUMENT_PENDING', + has_unread_response: false, + priority: 'NORMAL', + progress: { completed_steps: 0, total_steps: 1, percentage: 0 }, + due_date: null, + current_task: currentTask(`T-${caseId}`), updated_at: '2026-01-01T00:00:00Z', ...overrides, } } -function inboxTask(value: TaskSummaryResponse): WorkInboxTask { - return { task: value, workflowName: null } +function priorityCase(caseId: string, priority: CasePriority, dueDate: string | null = null) { + return caseSummary(caseId, 'W-1', undefined, { priority, due_date: dueDate }) } describe('workInboxModel', () => { - it('creates groups only for workers that have tasks and keeps unresolved references separate', () => { + it('groups cases by worker id using the worker info embedded in each case', () => { const model = buildWorkInboxModel({ workers: [worker('W-1'), worker('W-2')], - tasks: [task('T-1', 'W-1'), task('T-orphan', 'W-missing', { workflow_id: 'wf-missing' })], - workflowNameById: new Map([['wf-stay-extension', '체류기간 연장']]), + cases: [caseSummary('CASE-1', 'W-1'), caseSummary('CASE-2', 'W-2')], }) - expect(model.groups.map((group) => group.worker.worker_id)).toEqual(['W-1']) - expect(model.groups.some((group) => group.worker.worker_id === 'W-2')).toBe(false) - expect(model.orphanTasks.map((item) => item.task.task_id)).toEqual(['T-orphan']) - expect(model.diagnostics.missingWorkerIds).toEqual(['W-missing']) - expect(model.diagnostics.missingWorkflowIds).toEqual(['wf-missing']) + expect(model.groups.map((group) => group.workerId).sort()).toEqual(['W-1', 'W-2']) }) - it('returns no worker groups when there are no tasks', () => { + it('returns no worker groups when there are no cases', () => { const model = buildWorkInboxModel({ workers: [worker('W-1')], - tasks: [], + cases: [], }) expect(model.allGroups).toEqual([]) expect(model.groups).toEqual([]) expect(model.selectedGroup).toBeNull() - expect(model.selectedWorker).toBeNull() }) - it('groups shared case ids and gives every task without a case its own fallback case', () => { + it('keeps worker as null when /workers does not include the case owner, without dropping the case', () => { + const model = buildWorkInboxModel({ + workers: [], + cases: [caseSummary('CASE-1', 'W-missing', '응우옌 안')], + }) + + expect(model.groups).toHaveLength(1) + expect(model.groups[0].worker).toBeNull() + expect(model.groups[0].workerDisplayName).toBe('응우옌 안') + }) + + it('groups every case for the same worker together and picks the highest-priority one first', () => { const model = buildWorkInboxModel({ workers: [worker('W-1')], - tasks: [ - task('T-1', 'W-1', { case_id: 'CASE-1' }), - task('T-2', 'W-1', { case_id: 'CASE-1' }), - task('T-3', 'W-1'), - task('T-4', 'W-1'), + cases: [ + caseSummary('CASE-1', 'W-1', undefined, { priority: 'NORMAL' }), + caseSummary('CASE-2', 'W-1', undefined, { priority: 'URGENT' }), + caseSummary('CASE-3', 'W-1', undefined, { priority: 'LOW' }), ], }) - const cases = model.groups[0].cases - expect(cases).toHaveLength(3) - expect(cases.find((item) => item.key === 'case:CASE-1')?.tasks).toHaveLength(2) - expect(cases.find((item) => item.key === 'task:T-3')).toMatchObject({ - caseId: null, - source: 'task-fallback', - }) - expect(cases.find((item) => item.key === 'task:T-4')).toMatchObject({ - caseId: null, - source: 'task-fallback', - }) - expect(getWorkInboxCaseProgress(cases.find((item) => item.key === 'case:CASE-1')!)).toEqual({ - completed: 0, - total: 2, - }) + expect(model.groups[0].cases).toHaveLength(3) + expect(model.groups[0].primaryCase.case_id).toBe('CASE-2') }) - it('ranks tasks by actionable status, due date, and task id', () => { + it('ranks cases by priority, due date, then case id', () => { const values = [ - task('T-4', 'W-1', { - status: 'WAITING_WORKER', - due_date: '2026-01-01', - }), - task('T-1', 'W-1', { - status: 'READY_FOR_REVIEW', - due_date: null, - }), - task('T-3', 'W-1', { - status: 'READY_FOR_REVIEW', - due_date: '2026-08-01', - }), - task('T-2', 'W-1', { - status: 'READY_FOR_REVIEW', - due_date: '2026-08-01', - }), + priorityCase('C-4', 'NORMAL', '2026-01-01'), + priorityCase('C-1', 'URGENT', null), + priorityCase('C-3', 'URGENT', '2026-08-01'), + priorityCase('C-2', 'URGENT', '2026-08-01'), ] - expect( - values - .map(inboxTask) - .sort(compareWorkInboxTasks) - .map((item) => item.task.task_id), - ).toEqual(['T-2', 'T-3', 'T-1', 'T-4']) + expect(values.sort(compareWorkInboxCases).map((item) => item.case_id)).toEqual([ + 'C-2', + 'C-3', + 'C-1', + 'C-4', + ]) }) - it('ranks worker groups by their highest-priority task independently of response order', () => { + it('ranks worker groups by their highest-priority case independently of response order', () => { const model = buildWorkInboxModel({ workers: [worker('W-3'), worker('W-1'), worker('W-2')], - tasks: [ - task('T-3', 'W-3', { status: 'WAITING_EXTERNAL', due_date: '2026-07-01' }), - task('T-1', 'W-1', { status: 'READY_FOR_REVIEW', due_date: '2026-08-10' }), - task('T-2', 'W-2', { status: 'READY_FOR_REVIEW', due_date: '2026-08-01' }), + cases: [ + caseSummary('C-3', 'W-3', undefined, { priority: 'LOW', due_date: '2026-07-01' }), + caseSummary('C-1', 'W-1', undefined, { priority: 'HIGH', due_date: '2026-08-10' }), + caseSummary('C-2', 'W-2', undefined, { priority: 'URGENT', due_date: '2026-08-01' }), ], }) - expect(model.groups.map((group) => group.worker.worker_id)).toEqual(['W-2', 'W-1', 'W-3']) + expect(model.groups.map((group) => group.workerId)).toEqual(['W-2', 'W-1', 'W-3']) }) it('normalizes Unicode, letter case, and whitespace when searching all supported fields', () => { const workers = [worker('W-1', '김 민지'), worker('W-2', 'Nguyen An')] - const tasks = [ - task('T-1', 'W-1', { + const cases = [ + caseSummary('C-1', 'W-1', '김 민지', { title: '체류 기간 연장', - workflow_id: 'wf-visa', + current_task: currentTask('T-1', { title: '체류 기간 연장' }), }), - task('T-2', 'W-2', { - case_id: 'CASE-CONTRACT-2', + caseSummary('C-2', 'W-2', 'Nguyen An', { title: '계약 검토', - workflow_id: 'wf-contract', + current_task: currentTask('T-2', { title: '계약 검토' }), }), ] - const workflowNameById = new Map([ - ['wf-visa', 'Visa Renewal'], - ['wf-contract', 'Contract Review'], - ]) - const combinedResult = buildWorkInboxModel({ - workers, - tasks, - workflowNameById, - query: ' VISA 김 ', - }) - const taskTitleResult = buildWorkInboxModel({ - workers, - tasks, - workflowNameById, - query: '기간 연장', - }) - const workerNameResult = buildWorkInboxModel({ - workers, - tasks, - workflowNameById, - query: 'nguyen', - }) - const caseIdResult = buildWorkInboxModel({ - workers, - tasks, - workflowNameById, - query: 'case-contract', - }) + const titleResult = buildWorkInboxModel({ workers, cases, query: '기간 연장' }) + const workerNameResult = buildWorkInboxModel({ workers, cases, query: 'nguyen' }) expect(normalizeWorkInboxSearch(' VISA 김 ')).toBe('visa 김') - expect(combinedResult.groups.map((group) => group.worker.worker_id)).toEqual(['W-1']) - expect(taskTitleResult.groups.map((group) => group.worker.worker_id)).toEqual(['W-1']) - expect(workerNameResult.groups.map((group) => group.worker.worker_id)).toEqual(['W-2']) - expect(caseIdResult.groups.map((group) => group.worker.worker_id)).toEqual(['W-2']) + expect(titleResult.groups.map((group) => group.workerId)).toEqual(['W-1']) + expect(workerNameResult.groups.map((group) => group.workerId)).toEqual(['W-2']) }) it('supports deterministic due-date and worker-name sorting', () => { const input = { workers: [worker('W-1', '파티마'), worker('W-2', '김민지')], - tasks: [ - task('T-1', 'W-1', { status: 'READY_FOR_REVIEW', due_date: '2026-08-20' }), - task('T-2', 'W-2', { status: 'DRAFT', due_date: '2026-08-01' }), + cases: [ + caseSummary('C-1', 'W-1', '파티마', { priority: 'HIGH' as CasePriority, due_date: '2026-08-20' }), + caseSummary('C-2', 'W-2', '김민지', { priority: 'LOW' as CasePriority, due_date: '2026-08-01' }), ], } const dueDate = buildWorkInboxModel({ ...input, sort: 'due-date' }) const workerName = buildWorkInboxModel({ ...input, sort: 'worker-name' }) - expect(dueDate.groups.map((group) => group.worker.worker_id)).toEqual(['W-2', 'W-1']) - expect(workerName.groups.map((group) => group.worker.worker_id)).toEqual(['W-2', 'W-1']) - }) - - it('calculates case progress from completed tasks over every task in the case', () => { - const model = buildWorkInboxModel({ - workers: [worker('W-1')], - tasks: [ - task('T-1', 'W-1', { case_id: 'CASE-1', status: 'COMPLETED' }), - task('T-2', 'W-1', { case_id: 'CASE-1', status: 'DRAFT' }), - task('T-3', 'W-1', { case_id: 'CASE-1', status: 'CANCELLED' }), - ], - }) - - expect(getWorkInboxCaseProgress(model.groups[0].primaryCase)).toEqual({ - completed: 1, - total: 3, - }) + expect(dueDate.groups.map((group) => group.workerId)).toEqual(['W-2', 'W-1']) + expect(workerName.groups.map((group) => group.workerId)).toEqual(['W-2', 'W-1']) }) it('selects the requested visible worker and falls back to the first match', () => { const input = { workers: [worker('W-1', '김민지'), worker('W-2', '응우옌 안')], - tasks: [ - task('T-1', 'W-1', { status: 'WAITING_WORKER' }), - task('T-2', 'W-2', { status: 'READY_FOR_REVIEW' }), + cases: [ + caseSummary('C-1', 'W-1', '김민지', { priority: 'LOW' as CasePriority }), + caseSummary('C-2', 'W-2', '응우옌 안', { priority: 'HIGH' as CasePriority }), ], } - const requested = buildWorkInboxModel({ - ...input, - selectedWorkerId: 'W-1', - }) - const filtered = buildWorkInboxModel({ - ...input, - selectedWorkerId: 'W-1', - query: '응우옌', - }) + const requested = buildWorkInboxModel({ ...input, selectedWorkerId: 'W-1' }) + const filtered = buildWorkInboxModel({ ...input, selectedWorkerId: 'W-1', query: '응우옌' }) - expect(requested.selectedGroup?.worker.worker_id).toBe('W-1') - expect(requested.selectedWorker?.worker_id).toBe('W-1') - expect(filtered.selectedGroup?.worker.worker_id).toBe('W-2') - expect(filtered.selectedWorker?.worker_id).toBe('W-2') + expect(requested.selectedGroup?.workerId).toBe('W-1') + expect(filtered.selectedGroup?.workerId).toBe('W-2') }) }) diff --git a/src/pages/WorkListPage/workInboxModel.ts b/src/pages/WorkListPage/workInboxModel.ts index 34d662c..8d09dca 100644 --- a/src/pages/WorkListPage/workInboxModel.ts +++ b/src/pages/WorkListPage/workInboxModel.ts @@ -1,102 +1,42 @@ -import type { TaskStatus, TaskSummaryResponse } from '../../api/tasks' +import type { CasePriority, CaseSummaryResponse } from '../../api/cases' import type { WorkerResponse } from '../../api/workers' /** - * 현재 목록 API만으로 업무함에서 표현할 수 없는 값이다. - * - * 이 모델은 누락된 값을 추측하거나 화면 샘플로 채우지 않는다. 백엔드 projection API가 - * 추가되면 이 목록과 아래 파생 모델을 함께 갱신해야 한다. + * 낮은 숫자가 먼저 처리할 우선순위다. 우선순위가 같으면 마감일, case_id 순으로 정렬해 + * API 응답 순서와 무관하게 항상 같은 결과를 만든다. */ -export const WORK_INBOX_API_GAPS = { - casePresentation: - 'TaskSummaryResponse에는 case_id만 있으며 Case 제목, 전체 단계 수, 체크리스트 진행률은 없다.', - recommendedAction: 'TaskSummaryResponse에는 추천 CTA, 판단 근거, 담당자 또는 승인자 정보가 없다.', - workerPresentation: 'WorkerResponse에는 비자 유형과 근무 라인 또는 부서 정보가 없다.', - unassignedTask: - 'TaskSummaryResponse.worker_id는 필수다. Worker 목록에 없는 참조는 대상 미지정이 아니라 조회 범위 밖 또는 무결성 문제다.', -} as const - -/** - * 낮은 숫자가 먼저 처리할 상태다. 상태 우선순위가 같을 때 마감일, task_id 순으로 - * 정렬해 API 응답 순서와 무관하게 항상 같은 결과를 만든다. - */ -export const WORK_INBOX_TASK_STATUS_PRIORITY = { - READY_FOR_REVIEW: 0, - NEEDS_INFO: 1, - APPROVED: 2, - DRAFT: 3, - WAITING_WORKER: 4, - WAITING_EXTERNAL: 5, - COMPLETED: 6, - CANCELLED: 7, -} satisfies Record - -export interface WorkInboxTask { - task: TaskSummaryResponse - /** - * Workflow catalog 조회가 실패했거나 정의가 누락되면 null이다. workflow_id를 이름처럼 - * 표시하지 않도록 호출자가 null 상태를 명시적으로 처리한다. - */ - workflowName: string | null -} - -export interface WorkInboxCaseGroup { - /** - * 실제 Case는 `case:`, case_id가 없는 Task fallback은 `task:`다. - */ - key: string - caseId: string | null - source: 'case' | 'task-fallback' - tasks: WorkInboxTask[] - primaryTask: WorkInboxTask -} - -export interface WorkInboxCaseProgress { - completed: number - total: number -} +export const WORK_INBOX_CASE_PRIORITY_ORDER = { + URGENT: 0, + HIGH: 1, + NORMAL: 2, + LOW: 3, +} satisfies Record export interface WorkInboxWorkerGroup { - worker: WorkerResponse - tasks: WorkInboxTask[] - cases: WorkInboxCaseGroup[] - primaryTask: WorkInboxTask - primaryCase: WorkInboxCaseGroup + workerId: string + workerDisplayName: string + /** /workers 응답에서 찾은 근로자 상세 — 조회 범위 밖이면 null이다. */ + worker: WorkerResponse | null + cases: CaseSummaryResponse[] + primaryCase: CaseSummaryResponse } -export interface WorkInboxDiagnostics { - /** - * Task에는 worker_id가 있지만 현재 Worker 응답에는 없는 참조다. 의도적으로 대상이 없는 - * 업무라고 해석하면 안 된다. - */ - missingWorkerIds: string[] - missingWorkflowIds: string[] -} +export type WorkInboxSort = 'priority' | 'due-date' | 'worker-name' export interface BuildWorkInboxModelInput { + cases: readonly CaseSummaryResponse[] workers: readonly WorkerResponse[] - tasks: readonly TaskSummaryResponse[] - workflowNameById?: ReadonlyMap query?: string selectedWorkerId?: string | null sort?: WorkInboxSort } -export type WorkInboxSort = 'priority' | 'due-date' | 'worker-name' - export interface WorkInboxModel { - /** 검색 전 전체 그룹. Task가 하나 이상 연결된 실제 근로자만 포함한다. */ + /** 검색 전 전체 그룹. */ allGroups: WorkInboxWorkerGroup[] /** 정규화된 검색이 적용된 그룹. */ groups: WorkInboxWorkerGroup[] selectedGroup: WorkInboxWorkerGroup | null - selectedWorker: WorkerResponse | null - /** - * Worker 목록에서 참조를 해결하지 못한 Task다. 실제 근로자 그룹이나 - * '대상 미지정 업무' 그룹으로 변환하지 않는다. - */ - orphanTasks: WorkInboxTask[] - diagnostics: WorkInboxDiagnostics } function compareStableText(left: string, right: string): number { @@ -111,74 +51,34 @@ function dueDateSortValue(dueDate: string | null): number { return Number.isNaN(timestamp) ? Number.POSITIVE_INFINITY : timestamp } -export function compareWorkInboxTasks(left: WorkInboxTask, right: WorkInboxTask): number { - const statusDifference = - WORK_INBOX_TASK_STATUS_PRIORITY[left.task.status] - - WORK_INBOX_TASK_STATUS_PRIORITY[right.task.status] - if (statusDifference !== 0) return statusDifference +export function compareWorkInboxCases(left: CaseSummaryResponse, right: CaseSummaryResponse): number { + const priorityDifference = + WORK_INBOX_CASE_PRIORITY_ORDER[left.priority] - WORK_INBOX_CASE_PRIORITY_ORDER[right.priority] + if (priorityDifference !== 0) return priorityDifference - const leftDueDate = dueDateSortValue(left.task.due_date) - const rightDueDate = dueDateSortValue(right.task.due_date) + const leftDueDate = dueDateSortValue(left.due_date) + const rightDueDate = dueDateSortValue(right.due_date) if (leftDueDate < rightDueDate) return -1 if (leftDueDate > rightDueDate) return 1 - return compareStableText(left.task.task_id, right.task.task_id) + return compareStableText(left.case_id, right.case_id) } export function normalizeWorkInboxSearch(value: string): string { return value.normalize('NFKC').toLocaleLowerCase('ko-KR').replace(/\s+/gu, ' ').trim() } -function toWorkInboxTask( - task: TaskSummaryResponse, - workflowNameById: ReadonlyMap | undefined, -): WorkInboxTask { - const workflowName = workflowNameById?.get(task.workflow_id)?.trim() - return { - task, - workflowName: workflowName || null, - } -} - -function buildCaseGroups(tasks: WorkInboxTask[]): WorkInboxCaseGroup[] { - const tasksByCaseKey = new Map() - - for (const task of tasks) { - const key = task.task.case_id ? `case:${task.task.case_id}` : `task:${task.task.task_id}` - const caseTasks = tasksByCaseKey.get(key) ?? [] - caseTasks.push(task) - tasksByCaseKey.set(key, caseTasks) - } - - return [...tasksByCaseKey.entries()] - .map(([key, caseTasks]) => { - const sortedTasks = [...caseTasks].sort(compareWorkInboxTasks) - const caseId = sortedTasks[0].task.case_id - return { - key, - caseId, - source: caseId ? ('case' as const) : ('task-fallback' as const), - tasks: sortedTasks, - primaryTask: sortedTasks[0], - } - }) - .sort((left, right) => { - const primaryDifference = compareWorkInboxTasks(left.primaryTask, right.primaryTask) - return primaryDifference || compareStableText(left.key, right.key) - }) -} - function compareWorkerGroups(left: WorkInboxWorkerGroup, right: WorkInboxWorkerGroup): number { - const primaryDifference = compareWorkInboxTasks(left.primaryTask, right.primaryTask) - return primaryDifference || compareStableText(left.worker.worker_id, right.worker.worker_id) + const primaryDifference = compareWorkInboxCases(left.primaryCase, right.primaryCase) + return primaryDifference || compareStableText(left.workerId, right.workerId) } function compareWorkerGroupsByDueDate( left: WorkInboxWorkerGroup, right: WorkInboxWorkerGroup, ): number { - const leftDueDate = dueDateSortValue(left.primaryTask.task.due_date) - const rightDueDate = dueDateSortValue(right.primaryTask.task.due_date) + const leftDueDate = dueDateSortValue(left.primaryCase.due_date) + const rightDueDate = dueDateSortValue(right.primaryCase.due_date) if (leftDueDate < rightDueDate) return -1 if (leftDueDate > rightDueDate) return 1 return compareWorkerGroups(left, right) @@ -191,35 +91,21 @@ export function sortWorkInboxGroups( return [...groups].sort((left, right) => { if (sort === 'due-date') return compareWorkerGroupsByDueDate(left, right) if (sort === 'worker-name') { - const nameDifference = left.worker.display_name.localeCompare( - right.worker.display_name, - 'ko-KR', - ) - return nameDifference || compareStableText(left.worker.worker_id, right.worker.worker_id) + const nameDifference = left.workerDisplayName.localeCompare(right.workerDisplayName, 'ko-KR') + return nameDifference || compareStableText(left.workerId, right.workerId) } return compareWorkerGroups(left, right) }) } -export function getWorkInboxCaseProgress(caseGroup: WorkInboxCaseGroup): WorkInboxCaseProgress { - return { - completed: caseGroup.tasks.filter((item) => item.task.status === 'COMPLETED').length, - total: caseGroup.tasks.length, - } -} - function matchesQuery(group: WorkInboxWorkerGroup, normalizedQuery: string): boolean { if (!normalizedQuery) return true const queryTokens = normalizedQuery.split(' ') const searchableText = normalizeWorkInboxSearch( [ - group.worker.display_name, - ...group.tasks.flatMap((item) => [ - item.task.title, - item.task.case_id ?? '', - item.workflowName ?? '', - ]), + group.workerDisplayName, + ...group.cases.flatMap((item) => [item.title, item.current_task?.title ?? '']), ].join(' '), ) @@ -227,44 +113,30 @@ function matchesQuery(group: WorkInboxWorkerGroup, normalizedQuery: string): boo } export function buildWorkInboxModel({ + cases, workers, - tasks, - workflowNameById, query = '', selectedWorkerId, sort = 'priority', }: BuildWorkInboxModelInput): WorkInboxModel { const workerById = new Map(workers.map((worker) => [worker.worker_id, worker])) - const tasksByWorkerId = new Map() - const orphanTasks: WorkInboxTask[] = [] - const missingWorkerIds = new Set() - const missingWorkflowIds = new Set() - - for (const task of tasks) { - const item = toWorkInboxTask(task, workflowNameById) - if (!item.workflowName) missingWorkflowIds.add(task.workflow_id) + const casesByWorkerId = new Map() - if (!workerById.has(task.worker_id)) { - orphanTasks.push(item) - missingWorkerIds.add(task.worker_id) - continue - } - - const workerTasks = tasksByWorkerId.get(task.worker_id) ?? [] - workerTasks.push(item) - tasksByWorkerId.set(task.worker_id, workerTasks) + for (const item of cases) { + const workerCases = casesByWorkerId.get(item.worker_id) ?? [] + workerCases.push(item) + casesByWorkerId.set(item.worker_id, workerCases) } const allGroups = sortWorkInboxGroups( - [...tasksByWorkerId.entries()].map(([workerId, workerTasks]) => { - const sortedTasks = [...workerTasks].sort(compareWorkInboxTasks) - const cases = buildCaseGroups(sortedTasks) + [...casesByWorkerId.entries()].map(([workerId, workerCases]) => { + const sortedCases = [...workerCases].sort(compareWorkInboxCases) return { - worker: workerById.get(workerId) as WorkerResponse, - tasks: sortedTasks, - cases, - primaryTask: sortedTasks[0], - primaryCase: cases[0], + workerId, + workerDisplayName: sortedCases[0].worker_display_name, + worker: workerById.get(workerId) ?? null, + cases: sortedCases, + primaryCase: sortedCases[0], } }), sort, @@ -274,17 +146,7 @@ export function buildWorkInboxModel({ const groups = allGroups.filter((group) => matchesQuery(group, normalizedQuery)) const requestedSelection = selectedWorkerId?.trim() const selectedGroup = - groups.find((group) => group.worker.worker_id === requestedSelection) ?? groups[0] ?? null + groups.find((group) => group.workerId === requestedSelection) ?? groups[0] ?? null - return { - allGroups, - groups, - selectedGroup, - selectedWorker: selectedGroup?.worker ?? null, - orphanTasks: orphanTasks.sort(compareWorkInboxTasks), - diagnostics: { - missingWorkerIds: [...missingWorkerIds].sort(compareStableText), - missingWorkflowIds: [...missingWorkflowIds].sort(compareStableText), - }, - } + return { allGroups, groups, selectedGroup } } diff --git a/src/pages/WorkListPage/workInboxPresentation.ts b/src/pages/WorkListPage/workInboxPresentation.ts index c0a9a8c..fb8ad9e 100644 --- a/src/pages/WorkListPage/workInboxPresentation.ts +++ b/src/pages/WorkListPage/workInboxPresentation.ts @@ -1,8 +1,8 @@ +import type { CaseDisplayStatus, CaseTaskResponse } from '../../api/cases' import type { TaskStatus } from '../../api/tasks' import type { StatusTone } from '../../components/ui/StatusLabel/StatusLabel' import { TASK_STATUS_LABEL, TASK_STATUS_TONE, TASK_TYPE_LABEL } from '../../utils/taskStatus' import { getOperationalDateViewModel } from '../../view-models/dateViewModel' -import type { WorkInboxTask } from './workInboxModel' const REVIEW_ACTION_LABEL: Record = { DRAFT: '초안 검토', @@ -26,6 +26,23 @@ const DECISION_SUMMARY: Record = { CANCELLED: '취소된 업무입니다. 상세 화면에서 사유를 확인해 주세요.', } +// fowoco/server CaseDisplayStatus(#88) -> 화면 표시 매핑. +const CASE_DISPLAY_STATUS_LABEL: Record = { + DOCUMENT_PENDING: '서류 대기', + REQUEST_SENT: '요청 전송', + REVIEW_REQUIRED: '검토 필요', + COMPLETED: '완료', + CANCELLED: '취소', +} + +const CASE_DISPLAY_STATUS_TONE: Record = { + DOCUMENT_PENDING: 'neutral', + REQUEST_SENT: 'warning', + REVIEW_REQUIRED: 'critical', + COMPLETED: 'success', + CANCELLED: 'neutral', +} + export interface DuePresentation { label: string tone: StatusTone @@ -36,21 +53,18 @@ export function getDuePresentation(dueDate: string | null): DuePresentation { return { label: due.relative ?? '기한 미정', tone: due.tone } } +export function getCaseDisplayStatusPresentation(status: CaseDisplayStatus): { + label: string + tone: StatusTone +} { + return { label: CASE_DISPLAY_STATUS_LABEL[status], tone: CASE_DISPLAY_STATUS_TONE[status] } +} + export function getTaskStatusPresentation(status: TaskStatus): { label: string tone: StatusTone } { - const workInboxLabel: Partial> = { - DRAFT: '서류 대기', - NEEDS_INFO: '처리 필요', - READY_FOR_REVIEW: '승인 대기', - WAITING_WORKER: '요청 전송', - } - - return { - label: workInboxLabel[status] ?? TASK_STATUS_LABEL[status], - tone: TASK_STATUS_TONE[status], - } + return { label: TASK_STATUS_LABEL[status], tone: TASK_STATUS_TONE[status] } } export function getReviewActionLabel(status: TaskStatus): string { @@ -61,11 +75,11 @@ export function getDecisionSummary(status: TaskStatus): string { return DECISION_SUMMARY[status] } -export function getWorkflowLabel(item: WorkInboxTask): string { - return item.workflowName ?? TASK_TYPE_LABEL[item.task.task_type] +export function getWorkflowLabel(task: CaseTaskResponse): string { + return TASK_TYPE_LABEL[task.task_type] } -export function isReviewTask(status: TaskStatus): boolean { +export function isReviewCase(status: CaseDisplayStatus): boolean { return status !== 'COMPLETED' && status !== 'CANCELLED' }