From fe9d808643a6b66655f531e41387cfa3cbf31b13 Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 14:55:08 +0900
Subject: [PATCH 01/10] =?UTF-8?q?refactor(task):=20=EC=84=9C=EB=B2=84=20?=
=?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EC=83=81=ED=83=9C=20=EA=B8=B0=EC=A4=80=20?=
=?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=9D=90=EB=A6=84=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/approvals.test.ts | 63 ++++
src/api/approvals.ts | 112 ++++++
.../CaseDetailPage/CaseDetailPage.module.css | 4 +
.../CaseDetailPage/CaseDetailPage.test.tsx | 135 +++----
src/pages/CaseDetailPage/CaseDetailPage.tsx | 350 +++++++++---------
src/pages/CaseDetailPage/caseDetailData.ts | 73 ----
.../overlays/ApprovalDecisionModal.tsx | 57 +--
.../overlays/ApprovalRequestModal.tsx | 36 +-
.../CaseDetailPage/overlays/overlays.test.tsx | 16 +-
9 files changed, 471 insertions(+), 375 deletions(-)
create mode 100644 src/api/approvals.test.ts
create mode 100644 src/api/approvals.ts
diff --git a/src/api/approvals.test.ts b/src/api/approvals.test.ts
new file mode 100644
index 0000000..3f6a801
--- /dev/null
+++ b/src/api/approvals.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TaskDetailResponse } from './tasks'
+import {
+ approveTask,
+ buildTaskApprovalSnapshot,
+ completeTask,
+ recordTaskEvidence,
+ rejectTask,
+ requestTaskApproval,
+} from './approvals'
+
+function jsonResponse(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+function task(): TaskDetailResponse {
+ return {
+ task_id: 'T-1', worker_id: 'W-1', case_id: null, task_type: 'STAY_PERIOD_EXTENSION',
+ workflow_id: 'wf-stay', workflow_catalog_version: '3', title: '체류기간 연장', description: '안내',
+ business_data: { office: '수원' }, source: 'MANUAL', status: 'DRAFT', due_date: '2026-08-10',
+ content_revision: 2, version: 7, missing_required_slots: [], checklist_items: [], created_by: 'U-1',
+ updated_by: 'U-1', created_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-01T00:00:00Z',
+ }
+}
+
+beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
+afterEach(() => vi.unstubAllGlobals())
+
+describe('approval APIs', () => {
+ it('builds a server-compatible snapshot from the current Task version', () => {
+ expect(buildTaskApprovalSnapshot(task())).toEqual({
+ expected_version: 7,
+ ai_snapshot: null,
+ hr_snapshot: {
+ worker_id: 'W-1', task_type: 'STAY_PERIOD_EXTENSION', workflow_id: 'wf-stay',
+ title: '체류기간 연장', description: '안내', due_date: '2026-08-10', business_data: { office: '수원' },
+ },
+ changed_fields: ['task_content'],
+ source_versions: { workflow_catalog_version: '3', content_revision: 2 },
+ })
+ })
+
+ it('uses the approval, decision, evidence and completion endpoints', async () => {
+ vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ task_id: 'T-1' }, 201)))
+
+ await requestTaskApproval('T-1', buildTaskApprovalSnapshot(task()))
+ await approveTask('T-1', { expected_version: 8 })
+ await rejectTask('T-1', { expected_version: 8, reason: '마감일 확인 필요' })
+ await recordTaskEvidence('T-1', { evidence_type: 'RECEIPT', note: '접수번호 1234' })
+ await completeTask('T-1', 9)
+
+ const calls = vi.mocked(fetch).mock.calls
+ expect(String(calls[0][0])).toContain('/tasks/T-1/approval-requests')
+ expect(String(calls[1][0])).toContain('/tasks/T-1/approve')
+ expect(String(calls[2][0])).toContain('/tasks/T-1/reject')
+ expect(String(calls[3][0])).toContain('/tasks/T-1/evidence')
+ expect(String(calls[4][0])).toContain('/tasks/T-1/complete')
+ expect(JSON.parse(calls[4][1]?.body as string)).toEqual({ expected_version: 9 })
+ })
+})
diff --git a/src/api/approvals.ts b/src/api/approvals.ts
new file mode 100644
index 0000000..1df9ca2
--- /dev/null
+++ b/src/api/approvals.ts
@@ -0,0 +1,112 @@
+import { apiFetch } from './client'
+import type { TaskDetailResponse, TaskStatus } from './tasks'
+
+export type ApprovalStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'INVALIDATED'
+
+export interface ApprovalResponse {
+ approval_request_id: string
+ task_id: string
+ approval_status: ApprovalStatus
+ task_status: TaskStatus
+ content_revision: number
+ task_version: number
+ requested_at: string
+ decided_at: string | null
+}
+
+export interface RequestTaskApprovalBody {
+ expected_version: number
+ ai_snapshot: Record | null
+ hr_snapshot: Record
+ changed_fields: string[]
+ source_versions: Record
+}
+
+export interface DecideTaskApprovalBody {
+ expected_version: number
+ reason?: string
+}
+
+export type EvidenceType = 'DOCUMENT' | 'RECEIPT' | 'OFFICIAL_RESULT' | 'HR_CONFIRMATION'
+
+export interface RecordTaskEvidenceBody {
+ evidence_type: EvidenceType
+ file_reference?: string
+ note?: string
+ recorded_at?: string
+}
+
+export interface TaskActionResponse {
+ resource_id: string
+ task_id: string
+ task_status: TaskStatus
+ task_version: number
+}
+
+export function buildTaskApprovalSnapshot(task: TaskDetailResponse): RequestTaskApprovalBody {
+ return {
+ expected_version: task.version,
+ ai_snapshot: null,
+ hr_snapshot: {
+ worker_id: task.worker_id,
+ task_type: task.task_type,
+ workflow_id: task.workflow_id,
+ title: task.title,
+ description: task.description,
+ due_date: task.due_date,
+ business_data: task.business_data,
+ },
+ changed_fields: ['task_content'],
+ source_versions: {
+ workflow_catalog_version: task.workflow_catalog_version,
+ content_revision: task.content_revision,
+ },
+ }
+}
+
+export function requestTaskApproval(
+ taskId: string,
+ body: RequestTaskApprovalBody,
+): Promise {
+ return apiFetch(`/tasks/${encodeURIComponent(taskId)}/approval-requests`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
+
+export function approveTask(
+ taskId: string,
+ body: DecideTaskApprovalBody,
+): Promise {
+ return apiFetch(`/tasks/${encodeURIComponent(taskId)}/approve`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
+
+export function rejectTask(
+ taskId: string,
+ body: Required>,
+): Promise {
+ return apiFetch(`/tasks/${encodeURIComponent(taskId)}/reject`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
+
+export function recordTaskEvidence(
+ taskId: string,
+ body: RecordTaskEvidenceBody,
+): Promise {
+ return apiFetch(`/tasks/${encodeURIComponent(taskId)}/evidence`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
+
+export function completeTask(taskId: string, expectedVersion: number): Promise {
+ return apiFetch(`/tasks/${encodeURIComponent(taskId)}/complete`, {
+ method: 'POST',
+ body: JSON.stringify({ expected_version: expectedVersion }),
+ })
+}
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.module.css b/src/pages/CaseDetailPage/CaseDetailPage.module.css
index 21a04aa..fcf0f58 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.module.css
+++ b/src/pages/CaseDetailPage/CaseDetailPage.module.css
@@ -169,6 +169,10 @@
color: var(--text-secondary);
}
+.currentStateRows {
+ margin-bottom: var(--fowoco-spacing-20);
+}
+
.stepList {
margin-top: 28px;
display: flex;
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx
index c69b6de..b9e8e04 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx
+++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx
@@ -8,7 +8,7 @@ import type { TaskDetailResponse } from '../../api/tasks'
import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport'
import { useToastStore } from '../../store/toastStore'
import { CaseDetailPage } from './CaseDetailPage'
-import { CASE_COMMUNICATION, CASE_STEPS, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData'
+import { CASE_COMMUNICATION, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData'
function jsonResponse(body: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, ...init })
@@ -90,6 +90,21 @@ function mockTaskAndActivities(
return Promise.resolve(jsonResponse({ draft_id: 'draft-1', version: 1, review_status: 'PENDING' }))
}
if (url.includes('/documents?')) return Promise.resolve(jsonResponse(documentsResponse(documents)))
+ if (url.includes('/approval-requests')) {
+ return Promise.resolve(jsonResponse({ task_id: 'T-1', task_status: 'READY_FOR_REVIEW', task_version: 2 }, { status: 201 }))
+ }
+ if (url.endsWith('/approve')) {
+ return Promise.resolve(jsonResponse({ task_id: 'T-1', task_status: 'APPROVED', task_version: 2 }))
+ }
+ if (url.endsWith('/reject')) {
+ return Promise.resolve(jsonResponse({ task_id: 'T-1', task_status: 'DRAFT', task_version: 2 }))
+ }
+ if (url.endsWith('/evidence')) {
+ return Promise.resolve(jsonResponse({ resource_id: 'E-1', task_id: 'T-1', task_status: 'APPROVED', task_version: 1 }, { status: 201 }))
+ }
+ if (url.endsWith('/complete')) {
+ return Promise.resolve(jsonResponse({ resource_id: 'T-1', task_id: 'T-1', task_status: 'COMPLETED', task_version: 2 }))
+ }
return Promise.resolve(jsonResponse(task(taskOverrides)))
})
}
@@ -146,15 +161,15 @@ describe('CaseDetailPage', () => {
expect(await screen.findByRole('button', { name: '다시 시도' })).toBeInTheDocument()
})
- it('renders the real task title/status and every demo agent step', async () => {
+ it('renders the real Task state without the static five-step demo', async () => {
mockTaskAndActivities()
renderPage()
expect(await screen.findByText('응웬반A 체류연장 준비')).toBeInTheDocument()
- expect(screen.getByText('검토 필요')).toBeInTheDocument()
- for (const step of CASE_STEPS) {
- expect(screen.getByText(step.title)).toBeInTheDocument()
- }
+ expect(screen.getAllByText('검토 필요').length).toBeGreaterThan(0)
+ expect(screen.getByText('현재 업무 상태')).toBeInTheDocument()
+ expect(screen.getAllByText('1 / 2').length).toBeGreaterThan(0)
+ expect(screen.queryByText('보안 링크 전달')).not.toBeInTheDocument()
})
it('switches to the checklist tab and toggles a real checklist item', async () => {
@@ -263,23 +278,17 @@ describe('CaseDetailPage', () => {
mockTaskAndActivities()
renderPage()
- expect(await screen.findByText('완료 처리 불가 · 승인과 증빙 필요')).toBeInTheDocument()
+ expect(await screen.findByText(/완료 처리 불가 · 승인 · 필수 체크리스트/)).toBeInTheDocument()
})
- it('shows a toast when a draft is saved', async () => {
+ it('requests approval through the API and refetches the Task', async () => {
const user = userEvent.setup()
- mockTaskAndActivities()
- renderPage()
- await screen.findByText('응웬반A 체류연장 준비')
-
- await user.click(screen.getByRole('button', { name: '초안 저장' }))
-
- expect(screen.getByText('초안을 저장했습니다.')).toBeInTheDocument()
- })
-
- it('opens the approval request modal and shows a toast on submit', async () => {
- const user = userEvent.setup()
- mockTaskAndActivities()
+ mockTaskAndActivities({
+ status: 'DRAFT',
+ checklist_items: [
+ { checklist_item_id: 'chk-1', item_code: 'passport', label: '여권 사본 확인', required: true, completed: true, completed_by: null, completed_at: null, version: 1 },
+ ],
+ })
renderPage()
await screen.findByText('응웬반A 체류연장 준비')
@@ -290,30 +299,33 @@ describe('CaseDetailPage', () => {
expect(screen.getByText('승인을 요청했습니다.')).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ const call = vi.mocked(fetch).mock.calls.find(([url]) => String(url).includes('/approval-requests'))
+ expect(call?.[1]?.method).toBe('POST')
})
- it('walks through the approve decision flow', async () => {
+ it('approves through the API instead of setting a local success state', async () => {
const user = userEvent.setup()
mockTaskAndActivities()
renderPage()
await screen.findByText('응웬반A 체류연장 준비')
- await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' }))
+ await user.click(screen.getByRole('button', { name: '승인 검토' }))
expect(screen.getByRole('dialog', { name: '승인 요청을 검토하세요' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '승인' }))
expect(screen.getByText('승인했습니다.')).toBeInTheDocument()
- expect(screen.getAllByText('승인 완료').length).toBeGreaterThan(0)
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/approve'))).toBe(true)
+ expect(screen.queryByText('승인 완료')).not.toBeInTheDocument()
})
- it('walks through the reject decision flow', async () => {
+ it('rejects through the API without fabricating a local rejected status', async () => {
const user = userEvent.setup()
mockTaskAndActivities()
renderPage()
await screen.findByText('응웬반A 체류연장 준비')
- await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' }))
+ await user.click(screen.getByRole('button', { name: '승인 검토' }))
await user.click(screen.getByRole('button', { name: '반려' }))
expect(screen.getByRole('dialog', { name: '반려 사유를 입력하세요' })).toBeInTheDocument()
@@ -321,36 +333,8 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByRole('button', { name: '반려 확정' }))
expect(screen.getByText('반려했습니다.')).toBeInTheDocument()
- expect(screen.getAllByText('반려됨').length).toBeGreaterThan(0)
- })
-
- it('shows the other-approver-handled overlay after a decision is already made', async () => {
- const user = userEvent.setup()
- mockTaskAndActivities()
- renderPage()
- await screen.findByText('응웬반A 체류연장 준비')
-
- await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' }))
- await user.click(screen.getByRole('button', { name: '승인' }))
-
- await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' }))
-
- expect(screen.getByRole('dialog', { name: '다른 승인자가 처리했습니다' })).toBeInTheDocument()
- })
-
- it('opens the snapshot diff overlay and re-requests approval', async () => {
- const user = userEvent.setup()
- mockTaskAndActivities()
- renderPage()
- await screen.findByText('응웬반A 체류연장 준비')
-
- await user.click(screen.getByRole('button', { name: '데모: 재승인 필요 보기' }))
- expect(screen.getByRole('dialog', { name: '승인본 V1 · 수정본 V2 변경 내용' })).toBeInTheDocument()
-
- await user.click(screen.getByRole('button', { name: '재승인 요청' }))
-
- expect(screen.getByText('재승인을 요청했습니다.')).toBeInTheDocument()
- expect(screen.getAllByText('승인 대기').length).toBeGreaterThan(0)
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/reject'))).toBe(true)
+ expect(screen.queryByText('반려됨')).not.toBeInTheDocument()
})
it('opens and closes the more menu', async () => {
@@ -445,50 +429,37 @@ describe('CaseDetailPage', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
- it('blocks completion until approved, then completes via the external completion overlay', async () => {
+ it('records evidence and completes through the API when the server Task is approved', async () => {
const user = userEvent.setup()
- mockTaskAndActivities()
+ mockTaskAndActivities({
+ status: 'APPROVED',
+ checklist_items: [
+ { checklist_item_id: 'chk-1', item_code: 'passport', label: '여권 사본 확인', required: true, completed: true, completed_by: null, completed_at: null, version: 1 },
+ ],
+ })
renderPage()
await screen.findByText('응웬반A 체류연장 준비')
- expect(screen.queryByRole('button', { name: '완료 처리 시작 →' })).not.toBeInTheDocument()
-
- await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' }))
- await user.click(screen.getByRole('button', { name: '승인' }))
-
- await user.click(screen.getByRole('button', { name: '완료 처리 시작 →' }))
+ await user.click(await screen.findByRole('button', { name: '완료 처리 시작 →' }))
expect(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '접수번호' }))
await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032')
await user.click(screen.getByLabelText('실제 제출은 담당자가 직접 수행했습니다.'))
- await user.click(screen.getByRole('button', { name: '완료 처리' }))
-
- expect(screen.getByText('완료 처리했습니다.')).toBeInTheDocument()
- expect(screen.getByText('완료 처리되었습니다.')).toBeInTheDocument()
- })
-
- it('opens the internal completion demo overlay independent of approval state', async () => {
- const user = userEvent.setup()
- mockTaskAndActivities()
- renderPage()
- await screen.findByText('응웬반A 체류연장 준비')
+ await user.click(within(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).getByRole('button', { name: '완료 처리' }))
- await user.click(screen.getByRole('button', { name: '데모: 내부업무 완료 보기' }))
- expect(screen.getByRole('dialog', { name: '일반 내부업무 완료' })).toBeInTheDocument()
-
- await user.click(screen.getByRole('button', { name: '파일 없이 완료' }))
-
- expect(screen.getByText('(데모) 내부업무를 완료 처리했습니다.')).toBeInTheDocument()
+ expect(screen.getByText('업무를 완료했습니다.')).toBeInTheDocument()
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/evidence'))).toBe(true)
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/complete'))).toBe(true)
})
it('reissues the security link and shows the new-link overlay', async () => {
const user = userEvent.setup()
- mockTaskAndActivities()
+ mockTaskAndActivities({ status: 'APPROVED' })
renderPage()
await screen.findByText('응웬반A 체류연장 준비')
- await user.click(screen.getByRole('button', { name: '보안 링크 재발급 →' }))
+ await user.click(screen.getByRole('button', { name: '근로자 보안 링크 발급·재발급 →' }))
const reissueDialog = screen.getByRole('dialog', { name: '보안 링크 재발급' })
expect(within(reissueDialog).getByText('응웬반A 체류연장 준비')).toBeInTheDocument()
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx
index 08872a4..0b48486 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.tsx
+++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx
@@ -1,5 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
+import {
+ approveTask,
+ buildTaskApprovalSnapshot,
+ completeTask,
+ recordTaskEvidence,
+ rejectTask,
+ requestTaskApproval,
+ type EvidenceType,
+} from '../../api/approvals'
import { fetchTaskActivities } from '../../api/audit'
import { fetchDocumentReadiness, fetchDocuments, upsertDocumentRequestDraft } from '../../api/documents'
import { ApiError, getErrorMessage } from '../../api/errors'
@@ -21,52 +30,40 @@ import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../ut
import { daysUntil } from '../../utils/urgency'
import styles from './CaseDetailPage.module.css'
import {
- ACTION_DOCK,
AGENT_SUMMARY,
CASE_COMMUNICATION,
- CASE_STEPS,
CASE_TABS,
- COMPLETION_GATES,
CONTEXT_ACCESS,
CONTEXT_DRAWER,
- type StepStatus,
} from './caseDetailData'
import { ApprovalDecisionModal } from './overlays/ApprovalDecisionModal'
import { ApprovalRequestModal } from './overlays/ApprovalRequestModal'
-import { ApprovalSnapshotDiffModal } from './overlays/ApprovalSnapshotDiffModal'
import { ExternalCompletionModal } from './overlays/ExternalCompletionModal'
-import { InternalCompletionModal } from './overlays/InternalCompletionModal'
import { LinkReissueModal, type ReissueSubmission } from './overlays/LinkReissueModal'
import { LinkReissuedModal } from './overlays/LinkReissuedModal'
-import { OtherApproverHandledModal } from './overlays/OtherApproverHandledModal'
import { RejectionReasonModal } from './overlays/RejectionReasonModal'
-type ApprovalOverlay = 'none' | 'request' | 'decision' | 'rejection' | 'other-handled' | 'snapshot-diff'
-type ApprovalState = 'pending' | 'approved' | 'rejected'
-type CompletionOverlay = 'none' | 'external' | 'internal-demo'
-type CompletionState = 'blocked' | 'completed'
+type ApprovalOverlay = 'none' | 'request' | 'decision' | 'rejection'
+type CompletionOverlay = 'none' | 'external'
type LinkOverlay = 'none' | 'reissue' | 'reissued'
-const APPROVAL_BADGE: Record = {
- pending: { label: '승인 대기', tone: 'warning' },
- approved: { label: '승인 완료', tone: 'success' },
- rejected: { label: '반려됨', tone: 'critical' },
-}
-
const CASE_TAB_ITEMS = CASE_TABS.map((label) => ({ id: label, label }))
-const STEP_CIRCLE_CLASS: Record = {
- done: styles.stepCircleDone,
- pending: styles.stepCirclePending,
- locked: styles.stepCircleLocked,
- waiting: styles.stepCircleWaiting,
+function getApprovalBadge(status: import('../../api/tasks').TaskStatus): {
+ label: string
+ tone: StatusTone
+} | null {
+ if (status === 'READY_FOR_REVIEW') return { label: '승인 대기', tone: 'warning' }
+ if (status === 'APPROVED' || status === 'WAITING_WORKER' || status === 'WAITING_EXTERNAL') {
+ return { label: '승인 완료', tone: 'success' }
+ }
+ return null
}
-const STEP_STATUS_CLASS: Record = {
- done: styles.stepStatusDone,
- pending: styles.stepStatusPending,
- locked: styles.stepStatusLocked,
- waiting: styles.stepStatusWaiting,
+const EVIDENCE_TYPE_BY_LABEL: Record = {
+ 접수번호: 'RECEIPT',
+ 파일: 'DOCUMENT',
+ '화면 캡처': 'OFFICIAL_RESULT',
}
export function CaseDetailPage() {
@@ -75,9 +72,8 @@ export function CaseDetailPage() {
const [moreMenuOpen, setMoreMenuOpen] = useState(false)
const [contextDrawerOpen, setContextDrawerOpen] = useState(false)
const [approvalOverlay, setApprovalOverlay] = useState('none')
- const [approvalState, setApprovalState] = useState('pending')
const [completionOverlay, setCompletionOverlay] = useState('none')
- const [completionState, setCompletionState] = useState('blocked')
+ const [actionPending, setActionPending] = useState(false)
const [togglingItemId, setTogglingItemId] = useState(null)
const [linkOverlay, setLinkOverlay] = useState('none')
const [lastReissue, setLastReissue] = useState(null)
@@ -127,71 +123,84 @@ export function CaseDetailPage() {
setApprovalOverlay('request')
}
- function handleSubmitApprovalRequest() {
- // TODO(backend): POST /api/work-items/:id/approval-request -> 승인 대기 상태로 전환
- setApprovalOverlay('none')
- showToast('승인을 요청했습니다.')
+ async function handleSubmitApprovalRequest() {
+ if (!task || actionPending) return
+ setActionPending(true)
+ try {
+ await requestTaskApproval(task.task_id, buildTaskApprovalSnapshot(task))
+ setApprovalOverlay('none')
+ refetchTask()
+ showToast('승인을 요청했습니다.')
+ } catch (error) {
+ showToast(error instanceof ApiError ? getErrorMessage(error) : '승인을 요청하지 못했습니다.')
+ } finally {
+ setActionPending(false)
+ }
}
function handleOpenReview() {
- // 데모 진입점: 실제로는 승인자 계정으로 로그인해야 볼 수 있는 화면이다.
- setApprovalOverlay(approvalState === 'pending' ? 'decision' : 'other-handled')
+ if (task?.status !== 'READY_FOR_REVIEW') return
+ setApprovalOverlay('decision')
}
- function handleApprove() {
- // TODO(backend): POST /api/work-items/:id/approval-decisions { decision: 'approved' }
- setApprovalState('approved')
- setApprovalOverlay('none')
- showToast('승인했습니다.')
+ async function handleApprove() {
+ if (!task || actionPending) return
+ setActionPending(true)
+ try {
+ await approveTask(task.task_id, { expected_version: task.version })
+ setApprovalOverlay('none')
+ refetchTask()
+ showToast('승인했습니다.')
+ } catch (error) {
+ showToast(error instanceof ApiError ? getErrorMessage(error) : '승인하지 못했습니다.')
+ } finally {
+ setActionPending(false)
+ }
}
function handleStartReject() {
setApprovalOverlay('rejection')
}
- function handleConfirmReject(reason: string) {
- // TODO(backend): POST /api/work-items/:id/approval-decisions { decision: 'rejected', reason }
- void reason
- setApprovalState('rejected')
- setApprovalOverlay('none')
- showToast('반려했습니다.')
- }
-
- function handleOpenSnapshotDiff() {
- setApprovalOverlay('snapshot-diff')
- }
-
- function handleRequestReapproval() {
- // TODO(backend): POST /api/work-items/:id/approval-request -> 재승인 요청, 승인 대기 상태로 전환
- setApprovalState('pending')
- setApprovalOverlay('none')
- showToast('재승인을 요청했습니다.')
+ async function handleConfirmReject(reason: string) {
+ if (!task || actionPending) return
+ setActionPending(true)
+ try {
+ await rejectTask(task.task_id, { expected_version: task.version, reason })
+ setApprovalOverlay('none')
+ refetchTask()
+ showToast('반려했습니다.')
+ } catch (error) {
+ showToast(error instanceof ApiError ? getErrorMessage(error) : '반려하지 못했습니다.')
+ } finally {
+ setActionPending(false)
+ }
}
function handleOpenExternalCompletion() {
- if (approvalState !== 'approved' || completionState === 'completed') return
+ if (!task || !['APPROVED', 'WAITING_WORKER', 'WAITING_EXTERNAL'].includes(task.status)) return
setCompletionOverlay('external')
}
- function handleCompleteExternal(evidenceType: string, evidenceValue: string, memo: string) {
- // TODO(backend): POST /api/work-items/:id/complete { evidenceType, evidenceValue, memo }
- void evidenceType
- void evidenceValue
- void memo
- setCompletionState('completed')
- setCompletionOverlay('none')
- showToast('완료 처리했습니다.')
- }
-
- function handleOpenInternalCompletionDemo() {
- setCompletionOverlay('internal-demo')
- }
-
- function handleCompleteInternalDemo(memo: string) {
- // 이 데모 케이스는 외부기관 유형이라 실제 완료 상태에는 반영하지 않는다.
- void memo
- setCompletionOverlay('none')
- showToast('(데모) 내부업무를 완료 처리했습니다.')
+ async function handleCompleteExternal(evidenceType: string, evidenceValue: string, memo: string) {
+ if (!task || actionPending) return
+ const normalizedEvidenceType = EVIDENCE_TYPE_BY_LABEL[evidenceType]
+ if (!normalizedEvidenceType) return
+ setActionPending(true)
+ try {
+ const evidence = await recordTaskEvidence(task.task_id, {
+ evidence_type: normalizedEvidenceType,
+ note: [evidenceValue.trim(), memo.trim()].filter(Boolean).join(' · '),
+ })
+ await completeTask(task.task_id, evidence.task_version)
+ setCompletionOverlay('none')
+ refetchTask()
+ showToast('업무를 완료했습니다.')
+ } catch (error) {
+ showToast(error instanceof ApiError ? getErrorMessage(error) : '업무를 완료하지 못했습니다.')
+ } finally {
+ setActionPending(false)
+ }
}
function handleMoreActions() {
@@ -238,11 +247,6 @@ export function CaseDetailPage() {
setContextDrawerOpen(true)
}
- function handleSaveDraft() {
- // TODO(backend): PATCH /api/work-items/:id/draft -> 현재 입력 상태 저장
- showToast('초안을 저장했습니다.')
- }
-
async function handleSaveDocumentRequestDraft() {
if (!task || !readiness) return
try {
@@ -296,6 +300,25 @@ export function CaseDetailPage() {
const dueDays = daysUntil(task.due_date)
const dueLabel = dueDays === null ? '마감일 없음' : dueDays <= 0 ? '오늘 마감' : `D-${dueDays}`
+ const approvalBadge = getApprovalBadge(task.status)
+ const requiredChecklist = task.checklist_items.filter((item) => item.required)
+ const completedRequiredChecklist = requiredChecklist.filter((item) => item.completed).length
+ const checklistReady = completedRequiredChecklist === requiredChecklist.length
+ const informationReady = task.missing_required_slots.length === 0
+ const documentsReady = readiness ? !readiness.completion_blocked : false
+ const approvalReady = task.status === 'APPROVED' || task.status === 'WAITING_WORKER' || task.status === 'WAITING_EXTERNAL'
+ const canRequestApproval =
+ (task.status === 'DRAFT' || task.status === 'NEEDS_INFO') &&
+ checklistReady &&
+ informationReady &&
+ documentsReady
+ const canComplete = approvalReady && checklistReady && informationReady && documentsReady
+ const completionBlockers = [
+ !approvalReady && '승인',
+ !checklistReady && '필수 체크리스트',
+ !informationReady && '필수 정보',
+ !documentsReady && '서류 준비',
+ ].filter(Boolean) as string[]
return (
@@ -338,9 +361,7 @@ export function CaseDetailPage() {
{task.title}
{TASK_STATUS_LABEL[task.status]}
-
- {APPROVAL_BADGE[approvalState].label}
-
+ {approvalBadge && {approvalBadge.label} }
{TASK_SOURCE_LABEL[task.source]}
@@ -383,65 +404,48 @@ export function CaseDetailPage() {
-
처리 단계
-
필수 단계 3 / 5 완료
+
현재 업무 상태
+
+ {TASK_STATUS_LABEL[task.status]}
+
-
-
- {CASE_STEPS.map((step, index) => (
-
-
-
- {step.status === 'done' ? '✓' : step.no}
-
- {index < CASE_STEPS.length - 1 && (
-
- )}
-
-
-
-
{step.title}
-
{step.actor}
- {step.title === '보안 링크 전달' && (
-
- 보안 링크 재발급 →
-
- )}
-
-
- {step.statusLabel}
-
-
-
- ))}
+
+ 서버에 저장된 현재 Task와 체크리스트만 표시합니다. 고정된 예시 단계는 사용하지 않습니다.
+
+
+
+
+
+
+ {approvalReady && (
+
+ 근로자 보안 링크 발급·재발급 →
+
+ )}
완료 조건
-
{COMPLETION_GATES.description}
+
현재 서버 상태와 필수 조건을 기준으로 확인합니다.
-
-
- {approvalState === 'approved' && completionState === 'blocked' ? (
+ {canComplete ? (
완료 처리 시작 →
- ) : completionState === 'completed' ? (
+ ) : task.status === 'COMPLETED' ? (
완료 처리되었습니다.
) : (
-
{COMPLETION_GATES.blocked}
+
+ 완료 처리 불가 · {completionBlockers.join(' · ') || '현재 상태 확인 필요'}
+
)}
-
-
- 데모: 내부업무 완료 보기
-
@@ -599,28 +591,48 @@ export function CaseDetailPage() {
)}
- {ACTION_DOCK.nextStep}
-
- 데모: 승인자로 검토
-
-
- 데모: 재승인 필요 보기
-
-
- {ACTION_DOCK.draftSaveLabel}
-
- {ACTION_DOCK.approveLabel}
+
+ {task.status === 'READY_FOR_REVIEW'
+ ? '다음 행동 · 승인 검토'
+ : task.status === 'COMPLETED'
+ ? '이 업무는 완료되었습니다.'
+ : task.status === 'CANCELLED'
+ ? '이 업무는 취소되었습니다.'
+ : approvalReady
+ ? '다음 행동 · 실행 결과와 증빙 확인'
+ : '다음 행동 · 필수 조건 확인 후 승인 요청'}
+
+ {task.status === 'READY_FOR_REVIEW' && (
+ 승인 검토
+ )}
+ {(task.status === 'DRAFT' || task.status === 'NEEDS_INFO') && (
+
+ 승인 요청
+
+ )}
+ {canComplete && (
+ 완료 처리
+ )}
-
{ACTION_DOCK.footnote}
+
+ 승인·반려·완료 결과는 서버 응답 후 Task를 다시 조회해 반영합니다. 화면에서 성공 상태를 임의로 만들지 않습니다.
+
setApprovalOverlay('none')}
onSubmit={handleSubmitApprovalRequest}
/>
setApprovalOverlay('none')}
onApprove={handleApprove}
onReject={handleStartReject}
@@ -630,25 +642,11 @@ export function CaseDetailPage() {
onBack={() => setApprovalOverlay('decision')}
onConfirm={handleConfirmReject}
/>
- setApprovalOverlay('none')}
- />
- setApprovalOverlay('none')}
- onRequestReapproval={handleRequestReapproval}
- />
setCompletionOverlay('none')}
onComplete={handleCompleteExternal}
/>
- setCompletionOverlay('none')}
- onComplete={handleCompleteInternalDemo}
- />
void
onApprove: () => void
onReject: () => void
}
-export function ApprovalDecisionModal({ open, onClose, onApprove, onReject }: ApprovalDecisionModalProps) {
- function handleEditThenApprove() {
- // TODO(backend): 승인본 내용을 수정한 뒤 승인하는 흐름. PATCH API 계약이 정해지면 구현한다.
- }
-
+export function ApprovalDecisionModal({
+ open,
+ taskTitle,
+ dueDate,
+ workflowId,
+ submitting = false,
+ onClose,
+ onApprove,
+ onReject,
+}: ApprovalDecisionModalProps) {
return (
-
- 요청자 {APPROVAL_SNAPSHOT.requester} · {APPROVAL_SNAPSHOT.requestedAt} · 승인 대기
-
+ 서버에 저장된 현재 Task 버전을 검토합니다.
-
승인본 V1 · 핵심 내용 Snapshot
- {APPROVAL_SNAPSHOT.rows.map((row) => (
-
- {row.label}
- {row.value}
-
- ))}
+
현재 승인 대상
+
+ 업무
+ {taskTitle}
+
+
+ 마감일
+ {dueDate ?? '미지정'}
+
+
+ Workflow
+ {workflowId}
+
- {APPROVAL_SNAPSHOT.diffNote} ▾
-
-
{APPROVAL_SNAPSHOT.decisionPolicy}
+
승인·반려 결과는 서버 활동이력에 기록됩니다.
-
+
반려
-
- 수정 후 승인
-
-
- 승인
+
+ {submitting ? '처리 중…' : '승인'}
diff --git a/src/pages/CaseDetailPage/overlays/ApprovalRequestModal.tsx b/src/pages/CaseDetailPage/overlays/ApprovalRequestModal.tsx
index 3757a24..abaed4b 100644
--- a/src/pages/CaseDetailPage/overlays/ApprovalRequestModal.tsx
+++ b/src/pages/CaseDetailPage/overlays/ApprovalRequestModal.tsx
@@ -1,50 +1,54 @@
import { Modal } from '../../../components/ui/Modal/Modal'
-import { APPROVAL_REQUEST_FORM } from '../caseDetailData'
import styles from './overlays.module.css'
export interface ApprovalRequestModalProps {
open: boolean
+ taskTitle: string
+ dueDate: string | null
+ submitting?: boolean
onClose: () => void
onSubmit: () => void
}
-export function ApprovalRequestModal({ open, onClose, onSubmit }: ApprovalRequestModalProps) {
+export function ApprovalRequestModal({
+ open,
+ taskTitle,
+ dueDate,
+ submitting = false,
+ onClose,
+ onSubmit,
+}: ApprovalRequestModalProps) {
return (
- 안내문과 핵심 내용을 지정 승인자 또는 승인자 그룹에 요청합니다.
+ 현재 Task의 제목·마감일·업무 데이터와 버전을 승인본으로 고정합니다.
승인 대상
-
{APPROVAL_REQUEST_FORM.target}
+
{taskTitle}
-
승인자
-
{APPROVAL_REQUEST_FORM.approverGroup}
+
업무 마감일
+
{dueDate ?? '미지정'}
-
{APPROVAL_REQUEST_FORM.anyOneRuleTitle}
-
{APPROVAL_REQUEST_FORM.anyOneRuleBody}
-
-
-
-
요청 메모
-
{APPROVAL_REQUEST_FORM.memo}
+
현재 버전만 승인됩니다.
+
승인 후 핵심 내용이 바뀌면 기존 승인은 무효화되고 다시 검토해야 합니다.
취소
-
- 승인 요청 보내기
+
+ {submitting ? '요청 중…' : '승인 요청 보내기'}
- {APPROVAL_REQUEST_FORM.footnote}
+ 외부 발송이 아니라 FOWOCO 내부 승인 요청입니다.
)
}
diff --git a/src/pages/CaseDetailPage/overlays/overlays.test.tsx b/src/pages/CaseDetailPage/overlays/overlays.test.tsx
index 6d7e7f4..963a802 100644
--- a/src/pages/CaseDetailPage/overlays/overlays.test.tsx
+++ b/src/pages/CaseDetailPage/overlays/overlays.test.tsx
@@ -13,7 +13,7 @@ describe('ApprovalRequestModal', () => {
it('calls onSubmit when the request button is clicked', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
- render( )
+ render( )
await user.click(screen.getByRole('button', { name: '승인 요청 보내기' }))
@@ -23,7 +23,7 @@ describe('ApprovalRequestModal', () => {
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
- render( )
+ render( )
await user.click(screen.getByRole('button', { name: '취소' }))
@@ -36,7 +36,17 @@ describe('ApprovalDecisionModal', () => {
const user = userEvent.setup()
const onApprove = vi.fn()
const onReject = vi.fn()
- render( )
+ render(
+ ,
+ )
await user.click(screen.getByRole('button', { name: '반려' }))
expect(onReject).toHaveBeenCalledOnce()
From 8df216df1d6cc9508ca3e694669b3d1d3e1dd841 Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 15:01:12 +0900
Subject: [PATCH 02/10] =?UTF-8?q?refactor(document):=20=EB=82=A0=EC=A7=9C?=
=?UTF-8?q?=EC=99=80=20=EC=84=9C=EB=A5=98=20=EC=83=81=ED=83=9C=20ViewModel?=
=?UTF-8?q?=20=EC=A0=95=EA=B7=9C=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../CaseDetailPage/CaseDetailPage.test.tsx | 2 +-
src/pages/CaseDetailPage/CaseDetailPage.tsx | 30 +++---
.../DocumentDetailPage.test.tsx | 11 +--
.../DocumentDetailPage/DocumentDetailPage.tsx | 41 +++------
.../DocumentListPage.test.tsx | 13 ++-
.../DocumentListPage/DocumentListPage.tsx | 63 ++++++-------
.../WorkListPage/workInboxPresentation.ts | 11 +--
.../WorkerDetailPage.test.tsx | 4 +-
.../WorkerDetailPage/WorkerDetailPage.tsx | 41 +++++----
.../WorkerListPage/WorkerListPage.test.tsx | 2 +-
src/pages/WorkerListPage/WorkerListPage.tsx | 10 +-
src/utils/documentLabels.ts | 19 +---
src/view-models/dateViewModel.test.ts | 27 ++++++
src/view-models/dateViewModel.ts | 83 +++++++++++++++++
src/view-models/documentViewModel.test.ts | 45 +++++++++
src/view-models/documentViewModel.ts | 92 +++++++++++++++++++
16 files changed, 351 insertions(+), 143 deletions(-)
create mode 100644 src/view-models/dateViewModel.test.ts
create mode 100644 src/view-models/dateViewModel.ts
create mode 100644 src/view-models/documentViewModel.test.ts
create mode 100644 src/view-models/documentViewModel.ts
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx
index b9e8e04..469371c 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx
+++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx
@@ -229,7 +229,7 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByRole('tab', { name: CASE_TABS[2] }))
expect(await screen.findByText('여권 사본')).toBeInTheDocument()
- expect(screen.getByText('확인 완료')).toBeInTheDocument()
+ expect(screen.getByText('완료')).toBeInTheDocument()
})
it('shows the document-readiness gate and saves a document request draft when documents are missing', async () => {
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx
index 0b48486..cb87110 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.tsx
+++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx
@@ -25,9 +25,9 @@ import { useApiQuery } from '../../hooks/useApiQuery'
import { useToastStore } from '../../store/toastStore'
import { ACTOR_TYPE_TO_AGENT_SOURCE, AUDIT_ACTION_LABEL } from '../../utils/auditLabels'
import { formatEventTime } from '../../utils/datetime'
-import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL, SUBMISSION_STATUS_TONE } from '../../utils/documentLabels'
import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../utils/taskStatus'
-import { daysUntil } from '../../utils/urgency'
+import { getDocumentViewModel } from '../../view-models/documentViewModel'
+import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import styles from './CaseDetailPage.module.css'
import {
AGENT_SUMMARY,
@@ -298,8 +298,7 @@ export function CaseDetailPage() {
)
}
- const dueDays = daysUntil(task.due_date)
- const dueLabel = dueDays === null ? '마감일 없음' : dueDays <= 0 ? '오늘 마감' : `D-${dueDays}`
+ const taskDue = getOperationalDateViewModel('TASK_DUE', task.due_date)
const approvalBadge = getApprovalBadge(task.status)
const requiredChecklist = task.checklist_items.filter((item) => item.required)
const completedRequiredChecklist = requiredChecklist.filter((item) => item.completed).length
@@ -365,7 +364,7 @@ export function CaseDetailPage() {
{TASK_SOURCE_LABEL[task.source]}
- {dueLabel} · {task.workflow_id}
+ {taskDue.display} · {task.workflow_id}
-
+
- {documents.map((document) => (
-
- {DOCUMENT_TYPE_LABEL[document.document_type]}
-
- {SUBMISSION_STATUS_LABEL[document.submission_status]}
-
- {document.expiry_date ?? '없음'}
-
- ))}
+ {documents.map((document) => {
+ const view = getDocumentViewModel(document)
+ return (
+
+ {view.typeLabel}
+ {view.statusLabel}
+ {view.expiry.display}
+
+ )
+ })}
)}
{readiness && (readiness.missing.length > 0 || readiness.expired.length > 0) && (
diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx
index 567cd2c..f192cb3 100644
--- a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx
+++ b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx
@@ -84,17 +84,14 @@ describe('DocumentDetailPage', () => {
expect(await screen.findByText('서류를 찾을 수 없습니다')).toBeInTheDocument()
})
- it('approves and rejects the document locally, toggling status', async () => {
- const user = userEvent.setup()
+ it('does not fabricate approval or rejection without a versioned API', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS)))
renderPage('D-1')
await screen.findByRole('heading', { name: '외국인등록증' })
- await user.click(screen.getByRole('button', { name: '확인 완료 처리' }))
- expect(screen.getByText('확인 완료')).toBeInTheDocument()
-
- await user.click(screen.getByRole('button', { name: '반려' }))
- expect(screen.getByText('미제출')).toBeInTheDocument()
+ expect(screen.getByText('서류 없음')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '반려' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '상세 확인' })).toBeDisabled()
})
it('shows a loading state', () => {
diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx
index 6b62ce9..d599a44 100644
--- a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx
+++ b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx
@@ -1,27 +1,23 @@
-import { useCallback, useState } from 'react'
+import { useCallback } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
-import { fetchDocuments, type SubmissionStatus } from '../../api/documents'
+import { fetchDocuments } from '../../api/documents'
import { getErrorMessage } from '../../api/errors'
import { Button } from '../../components/ui/Button/Button'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel'
import { useApiQuery } from '../../hooks/useApiQuery'
-import { useToastStore } from '../../store/toastStore'
-import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL, SUBMISSION_STATUS_TONE } from '../../utils/documentLabels'
+import { getDocumentViewModel } from '../../view-models/documentViewModel'
import styles from './DocumentDetailPage.module.css'
export function DocumentDetailPage() {
const { documentId } = useParams()
const navigate = useNavigate()
- const showToast = useToastStore((state) => state.showToast)
// GET /api/v1/documents/{id} 단건 조회가 없어서(#57 조사 결과), 목록을 통째로 받아
// worker_document_id로 찾는다.
const { status: fetchStatus, data, error, refetch } = useApiQuery(useCallback(() => fetchDocuments({ size: 100 }), []))
const document = data?.items.find((item) => item.worker_document_id === documentId) ?? null
- const [localStatus, setLocalStatus] = useState(null)
-
if (fetchStatus === 'loading') {
return (
@@ -57,20 +53,7 @@ export function DocumentDetailPage() {
)
}
- const status = localStatus ?? document.submission_status
-
- // TODO(backend): PATCH /api/v1/workers/{workerId}/documents/{id}에는 expected_version이
- // 필요한데, 목록 응답(DocumentItemResponse)에 version 필드가 없어 안전하게 호출할 수
- // 없다 (#57 조사 결과 — 서버에 문의 필요). 그때까지 확인/반려는 화면에서만 반영한다.
- function handleApprove() {
- setLocalStatus('VERIFIED')
- showToast('서류를 확인 완료 처리했습니다.')
- }
-
- function handleReject() {
- setLocalStatus('MISSING')
- showToast('서류를 반려했습니다. 근로자에게 재제출을 요청하세요.')
- }
+ const view = getDocumentViewModel(document)
return (
@@ -81,19 +64,19 @@ export function DocumentDetailPage() {
-
{DOCUMENT_TYPE_LABEL[document.document_type]}
- {SUBMISSION_STATUS_LABEL[status]}
+ {view.typeLabel}
+ {view.statusLabel}
- {document.display_name ?? '알 수 없음'} · 만료일 {document.expiry_date ?? '없음'}
+ {view.workerName} · {view.expiry.display}
첨부 미리보기
{/* TODO(backend): file_id로 실제 파일을 내려받는 API가 아직 없음 */}
-
{DOCUMENT_TYPE_LABEL[document.document_type]}
-
미리보기는 백엔드 연동 후 제공됩니다.
+
{view.typeLabel}
+
{view.fileLabel} · 미리보기 API 연결 전입니다.
@@ -111,11 +94,11 @@ export function DocumentDetailPage() {
-
+
반려
-
- 확인 완료 처리
+
+ {view.reviewable ? '확인 완료 API 대기' : view.actionLabel}
diff --git a/src/pages/DocumentListPage/DocumentListPage.test.tsx b/src/pages/DocumentListPage/DocumentListPage.test.tsx
index d3b2874..d2b2c38 100644
--- a/src/pages/DocumentListPage/DocumentListPage.test.tsx
+++ b/src/pages/DocumentListPage/DocumentListPage.test.tsx
@@ -35,6 +35,7 @@ const DOCUMENTS: DocumentItemResponse[] = [
document_type: 'CONTRACT',
submission_status: 'SUBMITTED',
expiry_date: '2027-07-18',
+ file_id: 'F-2',
}),
document({
worker_document_id: 'D-3',
@@ -42,6 +43,7 @@ const DOCUMENTS: DocumentItemResponse[] = [
document_type: 'PERMIT',
submission_status: 'VERIFIED',
expiry_date: '2027-07-10',
+ file_id: 'F-3',
}),
document({
worker_document_id: 'D-4',
@@ -49,6 +51,7 @@ const DOCUMENTS: DocumentItemResponse[] = [
document_type: 'PASSPORT_COPY',
submission_status: 'VERIFIED',
expiry_date: isoDateOffset(12),
+ file_id: 'F-4',
}),
]
@@ -98,8 +101,8 @@ describe('DocumentListPage', () => {
expect(screen.getByRole('tab', { name: '검토 필요 1' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '만료 예정 1' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '누락 문서 1' })).toBeInTheDocument()
- expect(screen.getByRole('tab', { name: '요청 중 1' })).toBeInTheDocument()
- expect(screen.getByRole('tab', { name: '최근 업로드 1' })).toBeInTheDocument()
+ expect(screen.getByRole('tab', { name: '완료 2' })).toBeInTheDocument()
+ expect(screen.queryByRole('tab', { name: /요청 중/ })).not.toBeInTheDocument()
})
it('shows the metric strip computed from document status and expiry', async () => {
@@ -154,7 +157,7 @@ describe('DocumentListPage', () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS)))
renderPage()
- await user.click(await screen.findByRole('button', { name: '확인하기 →' }))
+ await user.click(await screen.findByRole('button', { name: '검토하기 →' }))
expect(await screen.findByText('서류 상세')).toBeInTheDocument()
})
@@ -164,8 +167,8 @@ describe('DocumentListPage', () => {
renderPage()
await screen.findByText('수라즈C')
- expect(screen.getByRole('button', { name: '요청 초안' })).toBeInTheDocument() // MISSING
- expect(screen.getByRole('button', { name: '확인하기 →' })).toBeInTheDocument() // SUBMITTED
+ expect(screen.getByRole('button', { name: '상세 확인' })).toBeInTheDocument() // MISSING
+ expect(screen.getByRole('button', { name: '검토하기 →' })).toBeInTheDocument() // SUBMITTED
expect(screen.getAllByRole('button', { name: '보기' })).toHaveLength(2) // VERIFIED
})
diff --git a/src/pages/DocumentListPage/DocumentListPage.tsx b/src/pages/DocumentListPage/DocumentListPage.tsx
index 0df3806..8354b9e 100644
--- a/src/pages/DocumentListPage/DocumentListPage.tsx
+++ b/src/pages/DocumentListPage/DocumentListPage.tsx
@@ -10,23 +10,17 @@ import { Tabs } from '../../components/ui/Tabs/Tabs'
import { useApiQuery } from '../../hooks/useApiQuery'
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
import { daysUntil } from '../../utils/urgency'
-import {
- DOCUMENT_TYPE_LABEL,
- getDocumentReviewAction,
- SUBMISSION_STATUS_LABEL,
- SUBMISSION_STATUS_TONE,
-} from '../../utils/documentLabels'
+import { DOCUMENT_TYPE_LABEL } from '../../utils/documentLabels'
+import { getDocumentViewModel } from '../../view-models/documentViewModel'
import styles from './DocumentListPage.module.css'
import { FileUploadModal } from './FileUploadModal'
-type TabId = 'all' | 'needs-review' | 'expiring-soon' | 'missing' | 'requested' | 'recently-uploaded'
+type TabId = 'all' | 'needs-review' | 'expiring-soon' | 'missing' | 'completed'
const EXPIRING_SOON_WITHIN_DAYS = 30
-// fowoco/server의 SubmissionStatus는 MISSING/SUBMITTED/VERIFIED 3종뿐이라(#196 조사 결과)
-// Figma DOC-001의 6개 탭과 1:1로 대응하지 않는다. "만료 예정"은 expiry_date 기준으로
-// 클라이언트에서 계산하고, "요청 중"·"최근 업로드"는 재요청·업로드 시각 필드가 서버에 없어
-// 각각 MISSING·SUBMITTED로 근사한다.
+// 요청 전송 여부와 최근 업로드 시각은 현재 Document API에 없다. MISSING을 "요청 중"으로
+// 추측하지 않고 서버가 보장하는 제출 상태와 expiry_date만 사용한다.
function matchesTab(document: DocumentItemResponse, tab: TabId): boolean {
if (tab === 'all') return true
if (tab === 'needs-review') return document.submission_status === 'SUBMITTED'
@@ -35,8 +29,7 @@ function matchesTab(document: DocumentItemResponse, tab: TabId): boolean {
return days !== null && days >= 0 && days <= EXPIRING_SOON_WITHIN_DAYS
}
if (tab === 'missing') return document.submission_status === 'MISSING'
- if (tab === 'requested') return document.submission_status === 'MISSING'
- return document.submission_status === 'SUBMITTED'
+ return document.submission_status === 'VERIFIED'
}
const DOCUMENT_TABS: { id: TabId; label: string }[] = [
@@ -44,8 +37,7 @@ const DOCUMENT_TABS: { id: TabId; label: string }[] = [
{ id: 'needs-review', label: '검토 필요' },
{ id: 'expiring-soon', label: '만료 예정' },
{ id: 'missing', label: '누락 문서' },
- { id: 'requested', label: '요청 중' },
- { id: 'recently-uploaded', label: '최근 업로드' },
+ { id: 'completed', label: '완료' },
]
export function DocumentListPage() {
@@ -104,7 +96,7 @@ export function DocumentListPage() {
근로자별 서류 제출 현황
- 미제출·확인 대기 서류를 우선 보여주며, 확인이 끝나면 상태가 자동으로 갱신됩니다.
+ 서류 없음·승인 대기·완료 상태와 문서 만료일을 서버 응답 기준으로 확인합니다.
@@ -183,25 +175,26 @@ export function DocumentListPage() {
) : (
- {visibleDocuments.map((document) => (
-
-
-
{document.display_name ?? '알 수 없음'}
-
{DOCUMENT_TYPE_LABEL[document.document_type]}
-
-
- {SUBMISSION_STATUS_LABEL[document.submission_status]}
-
- {document.expiry_date ?? '없음'}
- handleReviewDocument(document.worker_document_id)}
- >
- {getDocumentReviewAction(document)}
-
-
- ))}
+ {visibleDocuments.map((document) => {
+ const view = getDocumentViewModel(document)
+ return (
+
+
+
{view.workerName}
+
{view.typeLabel}
+
+ {view.statusLabel}
+ {view.expiry.display}
+ handleReviewDocument(view.id)}
+ >
+ {view.actionLabel}
+
+
+ )
+ })}
)}
diff --git a/src/pages/WorkListPage/workInboxPresentation.ts b/src/pages/WorkListPage/workInboxPresentation.ts
index a9d5494..bb4b1d6 100644
--- a/src/pages/WorkListPage/workInboxPresentation.ts
+++ b/src/pages/WorkListPage/workInboxPresentation.ts
@@ -1,7 +1,7 @@
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 { daysUntil } from '../../utils/urgency'
+import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import type { WorkInboxTask } from './workInboxModel'
const REVIEW_ACTION_LABEL: Record
= {
@@ -32,13 +32,8 @@ export interface DuePresentation {
}
export function getDuePresentation(dueDate: string | null): DuePresentation {
- const dueDays = daysUntil(dueDate)
- if (dueDays === null) return { label: '기한 미정', tone: 'neutral' }
- if (dueDays < 0) return { label: `D+${Math.abs(dueDays)}`, tone: 'critical' }
- if (dueDays === 0) return { label: '오늘', tone: 'critical' }
- if (dueDays <= 7) return { label: `D-${dueDays}`, tone: 'critical' }
- if (dueDays <= 30) return { label: `D-${dueDays}`, tone: 'warning' }
- return { label: `D-${dueDays}`, tone: 'neutral' }
+ const due = getOperationalDateViewModel('TASK_DUE', dueDate)
+ return { label: due.relative ?? '기한 미정', tone: due.tone }
}
export function getTaskStatusPresentation(status: TaskStatus): {
diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
index 12d1329..37d5965 100644
--- a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
+++ b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
@@ -47,7 +47,7 @@ function document(overrides: Partial = {}): DocumentItemRe
document_type: 'CONTRACT',
submission_status: 'SUBMITTED',
expiry_date: '2027-07-18',
- file_id: null,
+ file_id: 'F-1',
...overrides,
}
}
@@ -123,7 +123,7 @@ describe('WorkerDetailPage', () => {
renderPage('W-018')
expect(await screen.findByText('근로계약서')).toBeInTheDocument()
- expect(screen.getByText('확인 대기')).toBeInTheDocument()
+ expect(screen.getByText('승인 대기')).toBeInTheDocument()
})
it('shows an empty state when the worker has no documents', async () => {
diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx
index 5402d0a..b949e6c 100644
--- a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx
+++ b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx
@@ -7,8 +7,8 @@ import { DetailRow } from '../../components/ui/DetailRow/DetailRow'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel'
import { useApiQuery } from '../../hooks/useApiQuery'
-import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL, SUBMISSION_STATUS_TONE } from '../../utils/documentLabels'
-import { daysUntil, getUrgencyTier, URGENCY_TONE } from '../../utils/urgency'
+import { getDocumentViewModel } from '../../view-models/documentViewModel'
+import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import { RegisterDocumentModal } from './overlays/RegisterDocumentModal'
import styles from './WorkerDetailPage.module.css'
@@ -56,9 +56,9 @@ export function WorkerDetailPage() {
)
}
- const deadlineDays = daysUntil(worker.stay_expiry_date)
- const deadlineLabel = deadlineDays === null ? '정상' : `D-${deadlineDays} 체류만료`
- const deadlineTier = getUrgencyTier(deadlineDays)
+ const stayExpiry = getOperationalDateViewModel('STAY_EXPIRY', worker.stay_expiry_date)
+ const contractStart = getOperationalDateViewModel('CONTRACT_START', worker.contract_start_date)
+ const contractEnd = getOperationalDateViewModel('CONTRACT_END', worker.contract_end_date)
return (
@@ -70,8 +70,8 @@ export function WorkerDetailPage() {
{worker.display_name}
- {deadlineTier !== 'comfortable' && (
- {deadlineLabel}
+ {!stayExpiry.missing && stayExpiry.tone !== 'neutral' && (
+ {stayExpiry.relative} 체류만료
)}
@@ -86,10 +86,12 @@ export function WorkerDetailPage() {
+
+
@@ -107,15 +109,16 @@ export function WorkerDetailPage() {
) : (
- {workerDocuments.map((document) => (
-
- {DOCUMENT_TYPE_LABEL[document.document_type]}
-
- {SUBMISSION_STATUS_LABEL[document.submission_status]}
-
- {document.expiry_date ?? '없음'}
-
- ))}
+ {workerDocuments.map((document) => {
+ const view = getDocumentViewModel(document)
+ return (
+
+ {view.typeLabel}
+ {view.statusLabel}
+ {view.expiry.display}
+
+ )
+ })}
)}
diff --git a/src/pages/WorkerListPage/WorkerListPage.test.tsx b/src/pages/WorkerListPage/WorkerListPage.test.tsx
index 524c8bb..1ea95a3 100644
--- a/src/pages/WorkerListPage/WorkerListPage.test.tsx
+++ b/src/pages/WorkerListPage/WorkerListPage.test.tsx
@@ -220,7 +220,7 @@ describe('WorkerListPage', () => {
.find((el) => el.className.includes(styles.workerDeadline))
expect(urgentRow).toHaveClass(styles.workerDeadlineUrgent)
const comfortableRow = screen
- .getAllByText('정상')
+ .getAllByText('체류 만료일 미등록')
.find((el) => el.className.includes(styles.workerDeadline))
expect(comfortableRow).toHaveClass(styles.workerDeadlineComfortable)
})
diff --git a/src/pages/WorkerListPage/WorkerListPage.tsx b/src/pages/WorkerListPage/WorkerListPage.tsx
index a4d3446..168dad0 100644
--- a/src/pages/WorkerListPage/WorkerListPage.tsx
+++ b/src/pages/WorkerListPage/WorkerListPage.tsx
@@ -16,6 +16,7 @@ import { AUDIT_ACTION_LABEL } from '../../utils/auditLabels'
import { formatEventTime } from '../../utils/datetime'
import { TASK_STATUS_LABEL, TASK_STATUS_NEXT_ACTION } from '../../utils/taskStatus'
import { daysUntil, getUrgencyTier, URGENCY_TONE } from '../../utils/urgency'
+import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import styles from './WorkerListPage.module.css'
const DEADLINE_TIER_CLASS = {
@@ -70,14 +71,11 @@ const PRIORITY_COUNT = 5
// WorkerResponse에는 별도 visa_type 필드가 없다.
const VISA_TYPE = 'E-9'
-function deadlineLabel(deadlineDays: number | null): string {
- if (deadlineDays === null) return '정상'
- return `D-${deadlineDays} 체류만료`
-}
-
function toRow(worker: WorkerResponse) {
const deadlineDays = daysUntil(worker.stay_expiry_date)
- return { worker, deadlineDays, label: deadlineLabel(deadlineDays) }
+ const expiry = getOperationalDateViewModel('STAY_EXPIRY', worker.stay_expiry_date)
+ const label = expiry.missing ? expiry.display : `${expiry.relative} 체류만료`
+ return { worker, deadlineDays, label }
}
export function WorkerListPage() {
diff --git a/src/utils/documentLabels.ts b/src/utils/documentLabels.ts
index 59762a3..950da40 100644
--- a/src/utils/documentLabels.ts
+++ b/src/utils/documentLabels.ts
@@ -1,6 +1,5 @@
import type { StatusTone } from '../components/ui/StatusLabel/StatusLabel'
-import type { DocumentItemResponse, DocumentType, SubmissionStatus } from '../api/documents'
-import { daysUntil } from './urgency'
+import type { DocumentType, SubmissionStatus } from '../api/documents'
export const DOCUMENT_TYPE_LABEL: Record = {
PASSPORT_COPY: '여권 사본',
@@ -10,9 +9,9 @@ export const DOCUMENT_TYPE_LABEL: Record = {
}
export const SUBMISSION_STATUS_LABEL: Record = {
- MISSING: '미제출',
- SUBMITTED: '확인 대기',
- VERIFIED: '확인 완료',
+ MISSING: '서류 없음',
+ SUBMITTED: '승인 대기',
+ VERIFIED: '완료',
}
export const SUBMISSION_STATUS_TONE: Record = {
@@ -20,13 +19,3 @@ export const SUBMISSION_STATUS_TONE: Record = {
SUBMITTED: 'warning',
VERIFIED: 'success',
}
-
-// Figma DOC-001(node 1499:1256) 기준 상태별 다음 행동 문구. "증빙 연결"(완료 증빙 유형 전용)은
-// 서버 DocumentType에 대응 값이 없어 별도 처리가 필요해 여기 포함하지 않는다 (#219).
-export function getDocumentReviewAction(document: DocumentItemResponse): string {
- if (document.submission_status === 'MISSING') return '요청 초안'
- const expiryDays = daysUntil(document.expiry_date)
- if (expiryDays !== null && expiryDays < 0) return '교체 요청'
- if (document.submission_status === 'VERIFIED') return '보기'
- return '확인하기 →'
-}
diff --git a/src/view-models/dateViewModel.test.ts b/src/view-models/dateViewModel.test.ts
new file mode 100644
index 0000000..8e91af6
--- /dev/null
+++ b/src/view-models/dateViewModel.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it, vi } from 'vitest'
+import { getOperationalDateViewModel } from './dateViewModel'
+
+describe('getOperationalDateViewModel', () => {
+ it('keeps the date meaning visible when a value is missing', () => {
+ expect(getOperationalDateViewModel('STAY_EXPIRY', null)).toMatchObject({
+ label: '체류 만료일',
+ display: '체류 만료일 미등록',
+ missing: true,
+ })
+ expect(getOperationalDateViewModel('TASK_DUE', null).display).toBe('업무 마감일 미등록')
+ })
+
+ it('formats a document expiry date with a relative deadline', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date(2026, 7, 4, 9))
+
+ expect(getOperationalDateViewModel('DOCUMENT_EXPIRY', '2026-08-10')).toMatchObject({
+ value: '2026.08.10',
+ relative: 'D-6',
+ display: '2026.08.10 · D-6',
+ tone: 'critical',
+ })
+
+ vi.useRealTimers()
+ })
+})
diff --git a/src/view-models/dateViewModel.ts b/src/view-models/dateViewModel.ts
new file mode 100644
index 0000000..57c4697
--- /dev/null
+++ b/src/view-models/dateViewModel.ts
@@ -0,0 +1,83 @@
+import type { StatusTone } from '../components/ui/StatusLabel/StatusLabel'
+import { daysUntil } from '../utils/urgency'
+
+export type OperationalDateKind =
+ | 'TASK_DUE'
+ | 'STAY_EXPIRY'
+ | 'CONTRACT_START'
+ | 'CONTRACT_END'
+ | 'DOCUMENT_EXPIRY'
+
+export interface OperationalDateViewModel {
+ kind: OperationalDateKind
+ label: string
+ value: string
+ relative: string | null
+ display: string
+ tone: StatusTone
+ missing: boolean
+ expired: boolean
+}
+
+const DATE_LABEL: Record = {
+ TASK_DUE: '업무 마감일',
+ STAY_EXPIRY: '체류 만료일',
+ CONTRACT_START: '근로계약 시작일',
+ CONTRACT_END: '근로계약 종료일',
+ DOCUMENT_EXPIRY: '문서 만료일',
+}
+
+function formatDate(date: string): string {
+ const [year, month, day] = date.split('-')
+ if (!year || !month || !day) return date
+ return `${year}.${month}.${day}`
+}
+
+function getRelativeDate(days: number): string {
+ if (days < 0) return `D+${Math.abs(days)}`
+ if (days === 0) return '오늘'
+ return `D-${days}`
+}
+
+export function getOperationalDateViewModel(
+ kind: OperationalDateKind,
+ date: string | null,
+): OperationalDateViewModel {
+ const label = DATE_LABEL[kind]
+ if (!date) {
+ return {
+ kind,
+ label,
+ value: '미등록',
+ relative: null,
+ display: `${label} 미등록`,
+ tone: 'neutral',
+ missing: true,
+ expired: false,
+ }
+ }
+
+ const days = daysUntil(date)
+ const relative = days === null ? null : getRelativeDate(days)
+ const isStartDate = kind === 'CONTRACT_START'
+ const expired = !isStartDate && days !== null && days < 0
+ const tone: StatusTone = isStartDate
+ ? 'neutral'
+ : days !== null && days <= 7
+ ? 'critical'
+ : days !== null && days <= 30
+ ? 'warning'
+ : 'neutral'
+ const value = formatDate(date)
+
+ return {
+ kind,
+ label,
+ value,
+ relative,
+ display: relative && !isStartDate ? `${value} · ${relative}` : value,
+ tone,
+ missing: false,
+ expired,
+ }
+}
diff --git a/src/view-models/documentViewModel.test.ts b/src/view-models/documentViewModel.test.ts
new file mode 100644
index 0000000..0340081
--- /dev/null
+++ b/src/view-models/documentViewModel.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+import type { DocumentItemResponse } from '../api/documents'
+import { getDocumentViewModel } from './documentViewModel'
+
+function document(overrides: Partial = {}): DocumentItemResponse {
+ return {
+ worker_document_id: 'D-1', worker_id: 'W-1', display_name: '응웬반A',
+ document_type: 'PASSPORT_COPY', submission_status: 'MISSING', expiry_date: null, file_id: null,
+ ...overrides,
+ }
+}
+
+describe('getDocumentViewModel', () => {
+ it('does not infer that a missing document was requested', () => {
+ expect(getDocumentViewModel(document())).toMatchObject({
+ workflowState: 'NOT_SUBMITTED',
+ statusLabel: '서류 없음',
+ actionLabel: '상세 확인',
+ fileAvailable: false,
+ })
+ })
+
+ it('requires a real file before a submitted document can be reviewed', () => {
+ expect(getDocumentViewModel(document({ submission_status: 'SUBMITTED' }))).toMatchObject({
+ statusLabel: '파일 연결 확인', reviewable: false,
+ })
+ expect(getDocumentViewModel(document({ submission_status: 'SUBMITTED', file_id: 'F-1' }))).toMatchObject({
+ statusLabel: '승인 대기', actionLabel: '검토하기 →', reviewable: true,
+ })
+ })
+
+ it('surfaces expiry separately from submission completion', () => {
+ const expired = new Date()
+ expired.setDate(expired.getDate() - 1)
+ const expiryDate = [
+ expired.getFullYear(),
+ String(expired.getMonth() + 1).padStart(2, '0'),
+ String(expired.getDate()).padStart(2, '0'),
+ ].join('-')
+
+ expect(getDocumentViewModel(document({
+ submission_status: 'VERIFIED', expiry_date: expiryDate, file_id: 'F-1',
+ }))).toMatchObject({ workflowState: 'EXPIRED', statusLabel: '만료', actionLabel: '교체 요청' })
+ })
+})
diff --git a/src/view-models/documentViewModel.ts b/src/view-models/documentViewModel.ts
new file mode 100644
index 0000000..f0c6ec7
--- /dev/null
+++ b/src/view-models/documentViewModel.ts
@@ -0,0 +1,92 @@
+import type { DocumentItemResponse } from '../api/documents'
+import type { StatusTone } from '../components/ui/StatusLabel/StatusLabel'
+import { DOCUMENT_TYPE_LABEL } from '../utils/documentLabels'
+import { getOperationalDateViewModel, type OperationalDateViewModel } from './dateViewModel'
+
+export type DocumentWorkflowState = 'NOT_SUBMITTED' | 'REVIEW_REQUIRED' | 'COMPLETED' | 'EXPIRED'
+
+export interface DocumentViewModel {
+ id: string
+ workerId: string
+ workerName: string
+ typeLabel: string
+ workflowState: DocumentWorkflowState
+ statusLabel: string
+ statusTone: StatusTone
+ expiry: OperationalDateViewModel
+ fileAvailable: boolean
+ fileLabel: string
+ actionLabel: string
+ reviewable: boolean
+}
+
+export function getDocumentViewModel(document: DocumentItemResponse): DocumentViewModel {
+ const expiry = getOperationalDateViewModel('DOCUMENT_EXPIRY', document.expiry_date)
+ const fileAvailable = Boolean(document.file_id)
+
+ if (document.submission_status === 'MISSING') {
+ return {
+ id: document.worker_document_id,
+ workerId: document.worker_id,
+ workerName: document.display_name ?? '이름 미등록',
+ typeLabel: DOCUMENT_TYPE_LABEL[document.document_type],
+ workflowState: 'NOT_SUBMITTED',
+ statusLabel: '서류 없음',
+ statusTone: 'critical',
+ expiry,
+ fileAvailable: false,
+ fileLabel: '파일 없음',
+ actionLabel: '상세 확인',
+ reviewable: false,
+ }
+ }
+
+ if (expiry.expired) {
+ return {
+ id: document.worker_document_id,
+ workerId: document.worker_id,
+ workerName: document.display_name ?? '이름 미등록',
+ typeLabel: DOCUMENT_TYPE_LABEL[document.document_type],
+ workflowState: 'EXPIRED',
+ statusLabel: '만료',
+ statusTone: 'critical',
+ expiry,
+ fileAvailable,
+ fileLabel: fileAvailable ? '파일 연결됨' : '파일 없음',
+ actionLabel: '교체 요청',
+ reviewable: false,
+ }
+ }
+
+ if (document.submission_status === 'SUBMITTED') {
+ return {
+ id: document.worker_document_id,
+ workerId: document.worker_id,
+ workerName: document.display_name ?? '이름 미등록',
+ typeLabel: DOCUMENT_TYPE_LABEL[document.document_type],
+ workflowState: 'REVIEW_REQUIRED',
+ statusLabel: fileAvailable ? '승인 대기' : '파일 연결 확인',
+ statusTone: fileAvailable ? 'warning' : 'critical',
+ expiry,
+ fileAvailable,
+ fileLabel: fileAvailable ? '파일 연결됨' : '파일 없음',
+ actionLabel: fileAvailable ? '검토하기 →' : '연결 확인',
+ reviewable: fileAvailable,
+ }
+ }
+
+ return {
+ id: document.worker_document_id,
+ workerId: document.worker_id,
+ workerName: document.display_name ?? '이름 미등록',
+ typeLabel: DOCUMENT_TYPE_LABEL[document.document_type],
+ workflowState: 'COMPLETED',
+ statusLabel: '완료',
+ statusTone: 'success',
+ expiry,
+ fileAvailable,
+ fileLabel: fileAvailable ? '파일 연결됨' : '파일 없음',
+ actionLabel: fileAvailable ? '보기' : '상세 확인',
+ reviewable: false,
+ }
+}
From dc84c331c1af0a2544f3f4ec3d59d263f7af0835 Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 15:09:05 +0900
Subject: [PATCH 03/10] =?UTF-8?q?chore(task):=20=EB=AF=B8=EC=82=AC?=
=?UTF-8?q?=EC=9A=A9=20=EC=8A=B9=EC=9D=B8=20=EB=8D=B0=EB=AA=A8=20=EC=98=A4?=
=?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/pages/CaseDetailPage/caseDetailData.ts | 23 --------
.../overlays/ApprovalSnapshotDiffModal.tsx | 52 -----------------
.../overlays/InternalCompletionModal.tsx | 57 -------------------
.../overlays/OtherApproverHandledModal.tsx | 32 -----------
.../CaseDetailPage/overlays/overlays.test.tsx | 41 -------------
5 files changed, 205 deletions(-)
delete mode 100644 src/pages/CaseDetailPage/overlays/ApprovalSnapshotDiffModal.tsx
delete mode 100644 src/pages/CaseDetailPage/overlays/InternalCompletionModal.tsx
delete mode 100644 src/pages/CaseDetailPage/overlays/OtherApproverHandledModal.tsx
diff --git a/src/pages/CaseDetailPage/caseDetailData.ts b/src/pages/CaseDetailPage/caseDetailData.ts
index 2cc2c86..55fad38 100644
--- a/src/pages/CaseDetailPage/caseDetailData.ts
+++ b/src/pages/CaseDetailPage/caseDetailData.ts
@@ -43,26 +43,3 @@ export const CASE_COMMUNICATION: CaseCommunicationEntry[] = [
{ id: 'comm-2', time: '어제 17:40', actor: '김경민', message: '근로자에게 서류 제출 안내 문자를 발송했습니다.' },
{ id: 'comm-3', time: '어제 09:05', actor: '응웬반A', message: '서류를 준비 중이라고 답장했습니다.' },
]
-
-// 승인 플로우 오버레이 5종 데모 데이터 (Figma "05_States & Overlays" 기준)
-export const OTHER_APPROVER_HANDLED = {
- policyNote: 'ANY_ONE · 먼저 처리된 결과가 최종입니다.',
- rows: [
- { label: '승인 요청일', value: '2026.07.20 10:14' },
- { label: '지정 승인자', value: '김수진 · 박지훈' },
- { label: '처리자', value: '김수진 HR_MANAGER' },
- { label: '처리일', value: '2026.07.20 10:22' },
- { label: '처리 결과', value: '승인됨' },
- { label: '사유', value: '필수서류와 마감일 확인' },
- ],
-}
-
-export const APPROVAL_SNAPSHOT_DIFF = {
- warningNote: '승인된 핵심 내용이 변경되어 재승인이 필요합니다.',
- rows: [
- { field: '마감일', before: '2026.07.24', after: '2026.07.25', result: '재승인' as const },
- { field: '요청 서류', before: '여권 사본', after: '여권·등록증 사본', result: '재승인' as const },
- { field: '안내문 본문', before: 'V1 승인 문구', after: '마감일 안내 추가', result: '재승인' as const },
- { field: '내부 메모', before: '초안 확인', after: '전화 확인 완료', result: '승인 유지' as const },
- ],
-}
diff --git a/src/pages/CaseDetailPage/overlays/ApprovalSnapshotDiffModal.tsx b/src/pages/CaseDetailPage/overlays/ApprovalSnapshotDiffModal.tsx
deleted file mode 100644
index 739689e..0000000
--- a/src/pages/CaseDetailPage/overlays/ApprovalSnapshotDiffModal.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Modal } from '../../../components/ui/Modal/Modal'
-import { APPROVAL_SNAPSHOT_DIFF } from '../caseDetailData'
-import styles from './overlays.module.css'
-
-export interface ApprovalSnapshotDiffModalProps {
- open: boolean
- onClose: () => void
- onRequestReapproval: () => void
-}
-
-export function ApprovalSnapshotDiffModal({
- open,
- onClose,
- onRequestReapproval,
-}: ApprovalSnapshotDiffModalProps) {
- return (
-
- {APPROVAL_SNAPSHOT_DIFF.warningNote}
-
-
-
- 변경 필드
- 승인본 V1
- 수정본 V2
- 결과
-
- {APPROVAL_SNAPSHOT_DIFF.rows.map((row, index) => (
-
- {row.field}
- {row.before}
- {row.after}
-
- {row.result}
-
-
- ))}
-
-
-
-
- 닫기
-
-
- 재승인 요청
-
-
-
- )
-}
diff --git a/src/pages/CaseDetailPage/overlays/InternalCompletionModal.tsx b/src/pages/CaseDetailPage/overlays/InternalCompletionModal.tsx
deleted file mode 100644
index 4fbaaa2..0000000
--- a/src/pages/CaseDetailPage/overlays/InternalCompletionModal.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { useState } from 'react'
-import { Modal } from '../../../components/ui/Modal/Modal'
-import styles from './overlays.module.css'
-
-export interface InternalCompletionModalProps {
- open: boolean
- onClose: () => void
- onComplete: (memo: string) => void
-}
-
-export function InternalCompletionModal({ open, onClose, onComplete }: InternalCompletionModalProps) {
- const [memo, setMemo] = useState('')
-
- function handleComplete() {
- onComplete(memo)
- setMemo('')
- }
-
- return (
-
-
- 필수 체크리스트가 완료되어 파일 첨부 없이 완료할 수 있습니다.
-
-
- ✓ 필수 체크리스트 4 / 4 완료
-
-
- 증빙 요구
- 증빙 불필요
-
-
- 완료 처리자
- 김민지 · 자동 기록
-
-
- 완료 일시
- 완료 시점 자동 기록
-
-
-
- )
-}
diff --git a/src/pages/CaseDetailPage/overlays/OtherApproverHandledModal.tsx b/src/pages/CaseDetailPage/overlays/OtherApproverHandledModal.tsx
deleted file mode 100644
index e8a5fd4..0000000
--- a/src/pages/CaseDetailPage/overlays/OtherApproverHandledModal.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Modal } from '../../../components/ui/Modal/Modal'
-import { OTHER_APPROVER_HANDLED } from '../caseDetailData'
-import styles from './overlays.module.css'
-
-export interface OtherApproverHandledModalProps {
- open: boolean
- onClose: () => void
-}
-
-export function OtherApproverHandledModal({ open, onClose }: OtherApproverHandledModalProps) {
- return (
-
- {OTHER_APPROVER_HANDLED.policyNote}
-
-
- {OTHER_APPROVER_HANDLED.rows.map((row) => (
-
- {row.label}
- {row.value}
-
- ))}
-
-
-
- 승인·반려 불가
-
- 확인
-
-
-
- )
-}
diff --git a/src/pages/CaseDetailPage/overlays/overlays.test.tsx b/src/pages/CaseDetailPage/overlays/overlays.test.tsx
index 963a802..fbbb3b4 100644
--- a/src/pages/CaseDetailPage/overlays/overlays.test.tsx
+++ b/src/pages/CaseDetailPage/overlays/overlays.test.tsx
@@ -4,9 +4,6 @@ import { describe, expect, it, vi } from 'vitest'
import { ApprovalRequestModal } from './ApprovalRequestModal'
import { ApprovalDecisionModal } from './ApprovalDecisionModal'
import { RejectionReasonModal } from './RejectionReasonModal'
-import { OtherApproverHandledModal } from './OtherApproverHandledModal'
-import { ApprovalSnapshotDiffModal } from './ApprovalSnapshotDiffModal'
-import { InternalCompletionModal } from './InternalCompletionModal'
import { ExternalCompletionModal } from './ExternalCompletionModal'
describe('ApprovalRequestModal', () => {
@@ -83,44 +80,6 @@ describe('RejectionReasonModal', () => {
})
})
-describe('OtherApproverHandledModal', () => {
- it('calls onClose when confirmed', async () => {
- const user = userEvent.setup()
- const onClose = vi.fn()
- render( )
-
- await user.click(screen.getByRole('button', { name: '확인' }))
-
- expect(onClose).toHaveBeenCalledOnce()
- })
-})
-
-describe('ApprovalSnapshotDiffModal', () => {
- it('renders diff rows and calls onRequestReapproval', async () => {
- const user = userEvent.setup()
- const onRequestReapproval = vi.fn()
- render( )
-
- expect(screen.getByText('마감일')).toBeInTheDocument()
- await user.click(screen.getByRole('button', { name: '재승인 요청' }))
-
- expect(onRequestReapproval).toHaveBeenCalledOnce()
- })
-})
-
-describe('InternalCompletionModal', () => {
- it('calls onComplete with the memo text', async () => {
- const user = userEvent.setup()
- const onComplete = vi.fn()
- render( )
-
- await user.type(screen.getByPlaceholderText('완료 메모 · 선택사항'), '전화 확인 완료')
- await user.click(screen.getByRole('button', { name: '파일 없이 완료' }))
-
- expect(onComplete).toHaveBeenCalledWith('전화 확인 완료')
- })
-})
-
describe('ExternalCompletionModal', () => {
it('keeps the complete button disabled until evidence type, value, and confirmation are all set', async () => {
const user = userEvent.setup()
From 0433626dcd43cbcd7f8d2f0a2641f3e43bcc0134 Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 14:30:18 +0900
Subject: [PATCH 04/10] =?UTF-8?q?fix(worker-link):=20=EC=8B=A4=EC=A0=9C=20?=
=?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=84=A0=ED=83=9D=EA=B3=BC=20=EB=AF=B8?=
=?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=83=81=ED=83=9C=20=EB=AA=85=EC=8B=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../LinkRequestPage/LinkRequestPage.test.tsx | 5 +-
src/pages/LinkRequestPage/LinkRequestPage.tsx | 10 +--
src/pages/LinkRequestPage/linkRequestData.ts | 16 ++---
.../LinkUploadPage/LinkUploadPage.module.css | 23 ++++++
.../LinkUploadPage/LinkUploadPage.test.tsx | 25 +++++--
src/pages/LinkUploadPage/LinkUploadPage.tsx | 71 +++++++++++++++----
src/pages/LinkUploadPage/linkUploadData.ts | 7 --
7 files changed, 113 insertions(+), 44 deletions(-)
diff --git a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx
index 7151ae2..210ee9c 100644
--- a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx
+++ b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx
@@ -12,7 +12,8 @@ describe('LinkRequestPage', () => {
,
)
- expect(screen.getByText('마감 · 7월 24일 수요일')).toBeInTheDocument()
+ expect(screen.getByText('데모 화면입니다.')).toBeInTheDocument()
+ expect(screen.getByText('마감 · 링크 API 연결 후 표시')).toBeInTheDocument()
})
it('navigates to the upload page on confirmation', async () => {
@@ -26,7 +27,7 @@ describe('LinkRequestPage', () => {
,
)
- await user.click(screen.getByRole('button', { name: '안내를 확인했습니다' }))
+ await user.click(screen.getByRole('button', { name: '파일 선택 화면 보기' }))
expect(screen.getByText('upload screen')).toBeInTheDocument()
})
diff --git a/src/pages/LinkRequestPage/LinkRequestPage.tsx b/src/pages/LinkRequestPage/LinkRequestPage.tsx
index 3a4826e..5901746 100644
--- a/src/pages/LinkRequestPage/LinkRequestPage.tsx
+++ b/src/pages/LinkRequestPage/LinkRequestPage.tsx
@@ -6,10 +6,6 @@ import { LINK_REQUEST } from './linkRequestData'
export function LinkRequestPage() {
const navigate = useNavigate()
- function handleAskQuestion() {
- // TODO(backend): POST /api/links/:token/questions -> HR 담당자에게 문의 전달
- }
-
return (
보안 링크}>
@@ -35,15 +31,15 @@ export function LinkRequestPage() {
-
- 질문이 있습니다
+
+ 질문 API 연결 필요
navigate('/worker-portal/upload')}
>
- 안내를 확인했습니다
+ 파일 선택 화면 보기
diff --git a/src/pages/LinkRequestPage/linkRequestData.ts b/src/pages/LinkRequestPage/linkRequestData.ts
index 766d212..2988dcd 100644
--- a/src/pages/LinkRequestPage/linkRequestData.ts
+++ b/src/pages/LinkRequestPage/linkRequestData.ts
@@ -2,16 +2,16 @@
export const LINK_REQUEST = {
expiryNotice: {
- title: '이 링크는 72시간 동안 유효합니다.',
- body: '만료 전 필요한 작업을 완료해 주세요.',
+ title: '데모 화면입니다.',
+ body: '실제 보안 링크 유효시간은 토큰 조회 API 연결 후 표시됩니다.',
},
- requester: '한빛정밀 인사팀 요청',
- headline: ['여권 사본을', '제출해 주세요'],
- deadline: '마감 · 7월 24일 수요일',
- body: '체류연장 준비를 위해 여권의 사진이 있는 면을 제출해 주세요. 촬영한 이미지가 흐리지 않은지 확인해 주세요.',
+ requester: '보안 링크 요청 정보',
+ headline: ['서류 제출 안내를', '확인해 주세요'],
+ deadline: '마감 · 링크 API 연결 후 표시',
+ body: '실제 요청 서류와 안내 문구는 보안 링크 토큰을 조회한 뒤 표시됩니다.',
privacy: {
title: '이 화면에는 이 업무에 필요한 정보만 표시됩니다.',
- body: '제출 파일은 회사 HR 담당자가 확인합니다.',
+ body: '파일은 실제 업로드 API가 연결된 뒤에만 제출됩니다.',
},
- footnote: '안내를 읽은 것과 서류 제출 완료는 별도로 기록됩니다.',
+ footnote: '현재는 화면 확인만 가능하며 읽음·제출 상태를 기록하지 않습니다.',
}
diff --git a/src/pages/LinkUploadPage/LinkUploadPage.module.css b/src/pages/LinkUploadPage/LinkUploadPage.module.css
index bb73a3b..22cc301 100644
--- a/src/pages/LinkUploadPage/LinkUploadPage.module.css
+++ b/src/pages/LinkUploadPage/LinkUploadPage.module.css
@@ -43,6 +43,24 @@
color: var(--text-secondary);
}
+.fileInput {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.fileError {
+ margin: 12px 0 0;
+ color: var(--status-critical);
+ font-size: 13px;
+}
+
.selectedFile {
display: flex;
align-items: center;
@@ -108,6 +126,11 @@
color: var(--brand-primary);
}
+.helpLink:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
.submit {
width: 100%;
height: 52px;
diff --git a/src/pages/LinkUploadPage/LinkUploadPage.test.tsx b/src/pages/LinkUploadPage/LinkUploadPage.test.tsx
index 9155533..edb3d85 100644
--- a/src/pages/LinkUploadPage/LinkUploadPage.test.tsx
+++ b/src/pages/LinkUploadPage/LinkUploadPage.test.tsx
@@ -13,26 +13,37 @@ function renderPage() {
}
describe('LinkUploadPage', () => {
- it('disables submit until a file is selected', async () => {
+ it('shows the real selected file without pretending it was uploaded', async () => {
const user = userEvent.setup()
renderPage()
+ const file = new File(['passport'], 'passport_photo.jpg', { type: 'image/jpeg' })
- const submit = screen.getByRole('button', { name: '제출하기' })
- expect(submit).toBeDisabled()
+ await user.upload(screen.getByLabelText('제출할 파일 선택'), file)
- await user.click(screen.getByRole('button', { name: '파일 또는 사진 선택' }))
-
- expect(submit).toBeEnabled()
expect(screen.getByText('passport_photo.jpg')).toBeInTheDocument()
+ expect(screen.getByText(/제출 전/)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '제출 API 연결 필요' })).toBeDisabled()
})
it('removes the selected file', async () => {
const user = userEvent.setup()
renderPage()
+ const file = new File(['passport'], 'passport_photo.jpg', { type: 'image/jpeg' })
- await user.click(screen.getByRole('button', { name: '파일 또는 사진 선택' }))
+ await user.upload(screen.getByLabelText('제출할 파일 선택'), file)
await user.click(screen.getByRole('button', { name: '삭제' }))
expect(screen.queryByText('passport_photo.jpg')).not.toBeInTheDocument()
})
+
+ it('rejects unsupported file types', async () => {
+ const user = userEvent.setup({ applyAccept: false })
+ renderPage()
+ const file = new File(['script'], 'worker.exe', { type: 'application/octet-stream' })
+
+ await user.upload(screen.getByLabelText('제출할 파일 선택'), file)
+
+ expect(screen.getByRole('alert')).toHaveTextContent('JPG, PNG, PDF 파일만 선택할 수 있습니다.')
+ expect(screen.queryByText('worker.exe')).not.toBeInTheDocument()
+ })
})
diff --git a/src/pages/LinkUploadPage/LinkUploadPage.tsx b/src/pages/LinkUploadPage/LinkUploadPage.tsx
index ecb3994..f00a702 100644
--- a/src/pages/LinkUploadPage/LinkUploadPage.tsx
+++ b/src/pages/LinkUploadPage/LinkUploadPage.tsx
@@ -1,15 +1,46 @@
-import { useState } from 'react'
+import { useRef, useState, type ChangeEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { MobileShell } from '../../components/mobile/MobileShell'
import styles from './LinkUploadPage.module.css'
-import { DEMO_FILE, HELP_LINKS } from './linkUploadData'
+import { HELP_LINKS } from './linkUploadData'
+
+const MAX_FILE_SIZE = 10 * 1024 * 1024
+const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'application/pdf']
+
+function formatFileSize(bytes: number) {
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
+}
export function LinkUploadPage() {
const navigate = useNavigate()
- const [fileSelected, setFileSelected] = useState(false)
+ const fileInputRef = useRef(null)
+ const [file, setFile] = useState(null)
+ const [fileError, setFileError] = useState(null)
- function handleSubmit() {
- // TODO(backend): POST /api/links/:token/submit (multipart) -> 제출 완료 처리
+ function handleFileChange(event: ChangeEvent) {
+ const selected = event.target.files?.[0] ?? null
+ setFileError(null)
+ if (!selected) return
+ if (!ACCEPTED_FILE_TYPES.includes(selected.type)) {
+ setFile(null)
+ setFileError('JPG, PNG, PDF 파일만 선택할 수 있습니다.')
+ event.target.value = ''
+ return
+ }
+ if (selected.size > MAX_FILE_SIZE) {
+ setFile(null)
+ setFileError('파일 크기는 10MB 이하여야 합니다.')
+ event.target.value = ''
+ return
+ }
+ setFile(selected)
+ }
+
+ function handleRemoveFile() {
+ setFile(null)
+ setFileError(null)
+ if (fileInputRef.current) fileInputRef.current.value = ''
}
return (
@@ -25,22 +56,32 @@ export function LinkUploadPage() {
type="button"
className={styles.dropzone}
aria-label="파일 또는 사진 선택"
- onClick={() => setFileSelected(true)}
+ onClick={() => fileInputRef.current?.click()}
>
+
파일 또는 사진 선택
JPG, PNG, PDF · 최대 10MB
+
+
+ {fileError && {fileError}
}
- {fileSelected && (
+ {file && (
-
{DEMO_FILE.name}
+
{file.name}
- {DEMO_FILE.size} · {DEMO_FILE.status}
+ {formatFileSize(file.size)} · 제출 전
-
setFileSelected(false)}>
+
삭제
@@ -53,6 +94,8 @@ export function LinkUploadPage() {
key={label}
type="button"
className={`${styles.helpLink} ${index === 0 ? styles.helpLinkPrimary : ''}`}
+ disabled
+ title="문의 API 연결 필요"
>
{label}
→
@@ -60,11 +103,13 @@ export function LinkUploadPage() {
))}
-
- 제출하기
+
+ 제출 API 연결 필요
- 제출 후 HR 담당자가 확인하면 기존 업무에 기록됩니다.
+
+ 파일 선택과 형식 검증만 가능합니다. 보안 링크 토큰·업로드·제출 API 연결 후 실제 기록됩니다.
+
)
}
diff --git a/src/pages/LinkUploadPage/linkUploadData.ts b/src/pages/LinkUploadPage/linkUploadData.ts
index e35ec03..0ee963d 100644
--- a/src/pages/LinkUploadPage/linkUploadData.ts
+++ b/src/pages/LinkUploadPage/linkUploadData.ts
@@ -1,8 +1 @@
export const HELP_LINKS = ['질문이 있습니다', '내용을 이해하지 못했습니다', '지금 처리하기 어렵습니다']
-
-// TODO(backend): POST /api/links/:token/files (presigned upload) -> 실제 업로드 후 DEMO_FILE 대체
-export const DEMO_FILE = {
- name: 'passport_photo.jpg',
- size: '2.4MB',
- status: '업로드 준비됨',
-}
From 17b2f4e36df506ff05827cbd543e73e9cb3adbdd Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 15:11:28 +0900
Subject: [PATCH 05/10] =?UTF-8?q?feat(worker-link):=20=ED=86=A0=ED=81=B0?=
=?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EC=A0=9C?=
=?UTF-8?q?=EC=B6=9C=20=ED=9D=90=EB=A6=84=20=EC=97=B0=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/workerLinks.test.ts | 51 +++++++
src/api/workerLinks.ts | 103 +++++++++++++
.../CaseDetailPage/CaseDetailPage.test.tsx | 8 +-
src/pages/CaseDetailPage/CaseDetailPage.tsx | 29 +++-
.../overlays/LinkReissueModal.tsx | 5 +-
.../overlays/LinkReissuedModal.tsx | 23 ++-
src/pages/LinkExpiredPage/LinkExpiredPage.tsx | 21 ++-
.../LinkRequestPage.module.css | 20 +++
.../LinkRequestPage/LinkRequestPage.test.tsx | 50 ++++--
src/pages/LinkRequestPage/LinkRequestPage.tsx | 118 ++++++++++++--
src/pages/LinkRequestPage/linkRequestData.ts | 17 ---
.../LinkUploadPage/LinkUploadPage.module.css | 43 ++++++
.../LinkUploadPage/LinkUploadPage.test.tsx | 58 ++++++-
src/pages/LinkUploadPage/LinkUploadPage.tsx | 144 ++++++++++++++++--
src/routes.tsx | 4 +-
.../workerRequestStateViewModel.test.ts | 13 ++
.../workerRequestStateViewModel.ts | 33 ++++
17 files changed, 654 insertions(+), 86 deletions(-)
create mode 100644 src/api/workerLinks.test.ts
create mode 100644 src/api/workerLinks.ts
delete mode 100644 src/pages/LinkRequestPage/linkRequestData.ts
create mode 100644 src/view-models/workerRequestStateViewModel.test.ts
create mode 100644 src/view-models/workerRequestStateViewModel.ts
diff --git a/src/api/workerLinks.test.ts b/src/api/workerLinks.test.ts
new file mode 100644
index 0000000..de7590b
--- /dev/null
+++ b/src/api/workerLinks.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ fetchWorkerLink,
+ issueWorkerLink,
+ resolveWorkerPortalUrl,
+ submitWorkerResponse,
+ uploadWorkerLinkDocument,
+} from './workerLinks'
+
+function jsonResponse(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
+}
+
+beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
+afterEach(() => vi.unstubAllGlobals())
+
+describe('worker link APIs', () => {
+ it('uses the authenticated issue endpoint with an idempotency key', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ worker_url: 'raw-token', expires_at: '2026-08-07T00:00:00Z' }, 201))
+ await issueWorkerLink('T-1', { expires_in_hours: 72, rotate_existing: true }, 'issue-1')
+
+ const [url, init] = vi.mocked(fetch).mock.calls[0]
+ expect(String(url)).toContain('/tasks/T-1/worker-link')
+ expect(new Headers(init?.headers).get('Idempotency-Key')).toBe('issue-1')
+ })
+
+ it('views, uploads and submits through the public token endpoints', async () => {
+ vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ upload_id: 'U-1' }, 201)))
+
+ await fetchWorkerLink('token value')
+ await uploadWorkerLinkDocument('token value', new File(['passport'], 'passport.jpg', { type: 'image/jpeg' }), 'upload-1')
+ await submitWorkerResponse('token value', {
+ response_type: 'DOCUMENT_SUBMITTED', upload_ids: ['U-1'], idempotency_key: 'response-1',
+ })
+
+ const calls = vi.mocked(fetch).mock.calls
+ expect(String(calls[0][0])).toContain('/public/worker-links/token%20value')
+ expect(String(calls[1][0])).toContain('/documents')
+ expect(calls[1][1]?.body).toBeInstanceOf(FormData)
+ expect(String(calls[2][0])).toContain('/responses')
+ })
+
+ it('turns the current backend raw-token response into a frontend route', () => {
+ expect(resolveWorkerPortalUrl('raw/token', 'https://fowoco.kr')).toBe(
+ 'https://fowoco.kr/worker-portal/raw%2Ftoken',
+ )
+ expect(resolveWorkerPortalUrl('https://worker.fowoco.kr/s/token', 'https://fowoco.kr')).toBe(
+ 'https://worker.fowoco.kr/s/token',
+ )
+ })
+})
diff --git a/src/api/workerLinks.ts b/src/api/workerLinks.ts
new file mode 100644
index 0000000..b4e5fde
--- /dev/null
+++ b/src/api/workerLinks.ts
@@ -0,0 +1,103 @@
+import { apiFetch } from './client'
+
+export type WorkerResponseType =
+ | 'ACKNOWLEDGED'
+ | 'QUESTION'
+ | 'NOT_UNDERSTOOD'
+ | 'DOCUMENT_SUBMITTED'
+ | 'DIFFICULT'
+
+export interface WorkerLinkIssueBody {
+ expires_in_hours?: number
+ rotate_existing: boolean
+}
+
+export interface WorkerLinkIssueResponse {
+ worker_url: string
+ expires_at: string
+}
+
+export interface WorkerLinkViewResponse {
+ guidance: string
+ due_date: string | null
+ allowed_responses: WorkerResponseType[]
+}
+
+export interface WorkerLinkDocumentUploadResponse {
+ upload_id: string
+ file_name: string
+ size: number
+ expires_at: string
+}
+
+export interface WorkerResponseSubmitBody {
+ response_type: WorkerResponseType
+ message?: string
+ upload_ids?: string[]
+ idempotency_key: string
+}
+
+export interface WorkerResponseSubmitResponse {
+ response_id: string
+ received_at: string
+}
+
+export function issueWorkerLink(
+ taskId: string,
+ body: WorkerLinkIssueBody,
+ idempotencyKey: string,
+): Promise {
+ return apiFetch(`/tasks/${encodeURIComponent(taskId)}/worker-link`, {
+ method: 'POST',
+ headers: { 'Idempotency-Key': idempotencyKey },
+ body: JSON.stringify(body),
+ })
+}
+
+export function fetchWorkerLink(token: string): Promise {
+ return apiFetch(`/public/worker-links/${encodeURIComponent(token)}`, {
+ skipAuthRetry: true,
+ })
+}
+
+export function uploadWorkerLinkDocument(
+ token: string,
+ file: File,
+ clientRequestId: string,
+ documentType?: string,
+): Promise {
+ const body = new FormData()
+ body.set('file', file)
+ body.set('clientRequestId', clientRequestId)
+ if (documentType) body.set('documentType', documentType)
+
+ return apiFetch(
+ `/public/worker-links/${encodeURIComponent(token)}/documents`,
+ {
+ method: 'POST',
+ headers: { 'Idempotency-Key': clientRequestId },
+ body,
+ skipAuthRetry: true,
+ },
+ )
+}
+
+export function submitWorkerResponse(
+ token: string,
+ body: WorkerResponseSubmitBody,
+): Promise {
+ return apiFetch(
+ `/public/worker-links/${encodeURIComponent(token)}/responses`,
+ {
+ method: 'POST',
+ body: JSON.stringify(body),
+ skipAuthRetry: true,
+ },
+ )
+}
+
+export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string): string {
+ if (/^https?:\/\//i.test(workerUrlOrToken)) return workerUrlOrToken
+ if (workerUrlOrToken.startsWith('/')) return new URL(workerUrlOrToken, origin).toString()
+ return `${origin}/worker-portal/${encodeURIComponent(workerUrlOrToken)}`
+}
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx
index 469371c..02de4ea 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx
+++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx
@@ -105,6 +105,9 @@ function mockTaskAndActivities(
if (url.endsWith('/complete')) {
return Promise.resolve(jsonResponse({ resource_id: 'T-1', task_id: 'T-1', task_status: 'COMPLETED', task_version: 2 }))
}
+ if (url.endsWith('/worker-link')) {
+ return Promise.resolve(jsonResponse({ worker_url: 'worker-token-1', expires_at: '2026-08-07T00:00:00Z' }, { status: 201 }))
+ }
return Promise.resolve(jsonResponse(task(taskOverrides)))
})
}
@@ -453,7 +456,7 @@ describe('CaseDetailPage', () => {
expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/complete'))).toBe(true)
})
- it('reissues the security link and shows the new-link overlay', async () => {
+ it('issues the security link through the API and shows the real URL', async () => {
const user = userEvent.setup()
mockTaskAndActivities({ status: 'APPROVED' })
renderPage()
@@ -466,6 +469,7 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByRole('button', { name: '새 링크 생성' }))
expect(screen.getByRole('dialog', { name: '새 링크가 준비되었습니다' })).toBeInTheDocument()
- expect(screen.getByText('fowoco.kr/s/7K9P-****-Q2M4')).toBeInTheDocument()
+ expect(screen.getByText('http://localhost:3000/worker-portal/worker-token-1')).toBeInTheDocument()
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-link'))).toBe(true)
})
})
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx
index cb87110..085af44 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.tsx
+++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx
@@ -13,6 +13,7 @@ import { fetchTaskActivities } from '../../api/audit'
import { fetchDocumentReadiness, fetchDocuments, upsertDocumentRequestDraft } from '../../api/documents'
import { ApiError, getErrorMessage } from '../../api/errors'
import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks'
+import { issueWorkerLink, resolveWorkerPortalUrl } from '../../api/workerLinks'
import { AgentSourceLabel } from '../../components/ui/AgentSourceLabel/AgentSourceLabel'
import { AgentSummary } from '../../components/ui/AgentSummary/AgentSummary'
import { Button } from '../../components/ui/Button/Button'
@@ -77,6 +78,8 @@ export function CaseDetailPage() {
const [togglingItemId, setTogglingItemId] = useState(null)
const [linkOverlay, setLinkOverlay] = useState('none')
const [lastReissue, setLastReissue] = useState(null)
+ const [issuedWorkerUrl, setIssuedWorkerUrl] = useState(null)
+ const [issuedExpiresAt, setIssuedExpiresAt] = useState(null)
const moreMenuRef = useRef(null)
const showToast = useToastStore((state) => state.showToast)
@@ -265,10 +268,26 @@ export function CaseDetailPage() {
setLinkOverlay('reissue')
}
- function handleSubmitLinkReissue(submission: ReissueSubmission) {
- // TODO(backend): POST /api/v1/tasks/:taskId/worker-link { rotateExisting: true } (feat/7-worker-link, 미병합)
- setLastReissue(submission)
- setLinkOverlay('reissued')
+ async function handleSubmitLinkReissue(submission: ReissueSubmission) {
+ if (!task || actionPending) return
+ const expiryHours = submission.expiry === '24시간' ? 24 : submission.expiry === '7일' ? 168 : 72
+ setActionPending(true)
+ try {
+ const issued = await issueWorkerLink(
+ task.task_id,
+ { expires_in_hours: expiryHours, rotate_existing: true },
+ crypto.randomUUID(),
+ )
+ setLastReissue(submission)
+ setIssuedWorkerUrl(resolveWorkerPortalUrl(issued.worker_url, window.location.origin))
+ setIssuedExpiresAt(issued.expires_at)
+ setLinkOverlay('reissued')
+ showToast('보안 링크를 발급했습니다. 아직 자동 전송되지는 않았습니다.')
+ } catch (error) {
+ showToast(error instanceof ApiError ? getErrorMessage(error) : '보안 링크를 발급하지 못했습니다.')
+ } finally {
+ setActionPending(false)
+ }
}
if (taskStatus === 'loading') {
@@ -656,6 +675,8 @@ export function CaseDetailPage() {
setLinkOverlay('none')}
/>
diff --git a/src/pages/CaseDetailPage/overlays/LinkReissueModal.tsx b/src/pages/CaseDetailPage/overlays/LinkReissueModal.tsx
index 6a13fbb..d0dae2d 100644
--- a/src/pages/CaseDetailPage/overlays/LinkReissueModal.tsx
+++ b/src/pages/CaseDetailPage/overlays/LinkReissueModal.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { Modal } from '../../../components/ui/Modal/Modal'
import styles from './overlays.module.css'
-const EXPIRIES = ['24시간', '72시간', '7일', '업무 마감일까지']
+const EXPIRIES = ['24시간', '72시간', '7일']
export interface ReissueSubmission {
reason: string
@@ -17,8 +17,7 @@ export interface LinkReissueModalProps {
}
// Figma 08_Prototype Flow · Flow E(보안 링크 만료·재발급) 기준.
-// 서버 워커 링크 재발급 API는 아직 main에 병합되지 않아 (feat/7-worker-link 브랜치,
-// POST /api/v1/tasks/{taskId}/worker-link { rotateExisting: true }) 데모로만 동작한다.
+// POST /api/v1/tasks/{taskId}/worker-link의 rotate_existing 계약을 사용한다.
export function LinkReissueModal({ open, taskTitle, onClose, onSubmit }: LinkReissueModalProps) {
const [reason, setReason] = useState('기존 링크 만료')
const [expiry, setExpiry] = useState(EXPIRIES[1])
diff --git a/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx b/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx
index 7fdeb1e..044be9c 100644
--- a/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx
+++ b/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx
@@ -1,22 +1,31 @@
import { Modal } from '../../../components/ui/Modal/Modal'
import { useToastStore } from '../../../store/toastStore'
+import { getWorkerRequestStateViewModel } from '../../../view-models/workerRequestStateViewModel'
import type { ReissueSubmission } from './LinkReissueModal'
import styles from './overlays.module.css'
-const DEMO_NEW_LINK = 'fowoco.kr/s/7K9P-****-Q2M4'
-
export interface LinkReissuedModalProps {
open: boolean
submission: ReissueSubmission | null
+ workerUrl: string | null
+ expiresAt: string | null
onClose: () => void
}
-export function LinkReissuedModal({ open, submission, onClose }: LinkReissuedModalProps) {
+export function LinkReissuedModal({
+ open,
+ submission,
+ workerUrl,
+ expiresAt,
+ onClose,
+}: LinkReissuedModalProps) {
const showToast = useToastStore((state) => state.showToast)
+ const requestState = getWorkerRequestStateViewModel({})
async function handleCopyLink() {
+ if (!workerUrl) return
try {
- await navigator.clipboard.writeText(DEMO_NEW_LINK)
+ await navigator.clipboard.writeText(workerUrl)
showToast('링크를 복사했습니다.')
} catch {
showToast('복사에 실패했습니다. 직접 선택해 복사해 주세요.')
@@ -30,11 +39,11 @@ export function LinkReissuedModal({ open, submission, onClose }: LinkReissuedMod
- ✓ 기존 링크 폐기 완료 · 새 링크만 유효 · {submission?.expiry}
+ ✓ {requestState.label} · {submission?.expiry} · {expiresAt ? new Date(expiresAt).toLocaleString('ko-KR') : '만료시각 확인 필요'}까지
-
{DEMO_NEW_LINK}
+
{workerUrl ?? '링크 확인 필요'}
복사
@@ -44,7 +53,7 @@ export function LinkReissuedModal({ open, submission, onClose }: LinkReissuedMod
{submission?.reason}
-
SMS·메신저가 자동으로 발송된 것이 아닙니다.
+
{requestState.description} SMS·메신저로 직접 전달해 주세요.
diff --git a/src/pages/LinkExpiredPage/LinkExpiredPage.tsx b/src/pages/LinkExpiredPage/LinkExpiredPage.tsx
index 3ce7d07..e9ff92c 100644
--- a/src/pages/LinkExpiredPage/LinkExpiredPage.tsx
+++ b/src/pages/LinkExpiredPage/LinkExpiredPage.tsx
@@ -1,9 +1,19 @@
+import { useState } from 'react'
+import { useParams } from 'react-router-dom'
import { MobileShell } from '../../components/mobile/MobileShell'
import styles from './LinkExpiredPage.module.css'
export function LinkExpiredPage() {
- function handleCopyRequest() {
- // TODO(backend): POST /api/links/:token/reissue-request -> 재발급 요청 문구 생성 후 클립보드 복사
+ const { token } = useParams()
+ const [copied, setCopied] = useState(false)
+
+ async function handleCopyRequest() {
+ try {
+ await navigator.clipboard.writeText('기존 FOWOCO 제출 링크를 사용할 수 없습니다. 새 링크를 보내 주세요.')
+ setCopied(true)
+ } catch {
+ setCopied(false)
+ }
}
return (
@@ -18,9 +28,8 @@ export function LinkExpiredPage() {
링크 상태 · 만료
- 만료시각 2026.07.20 14:30
-
- 기존 링크로는 제출할 수 없습니다.
+ 이 링크는 만료됐거나 새 링크 발급으로 폐기되었습니다.
+ {token ? <> 기존 링크로는 제출할 수 없습니다.> : null}
@@ -31,7 +40,7 @@ export function LinkExpiredPage() {
- 재발급 요청 문구 복사
+ {copied ? '요청 문구 복사됨' : '재발급 요청 문구 복사'}
diff --git a/src/pages/LinkRequestPage/LinkRequestPage.module.css b/src/pages/LinkRequestPage/LinkRequestPage.module.css
index 418685c..69c5dfd 100644
--- a/src/pages/LinkRequestPage/LinkRequestPage.module.css
+++ b/src/pages/LinkRequestPage/LinkRequestPage.module.css
@@ -101,6 +101,26 @@
cursor: pointer;
}
+.primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.responseNotice {
+ margin: 20px 0 0;
+ padding: 12px 16px;
+ border-radius: var(--fowoco-radius-8);
+ background: var(--fowoco-green-50);
+ color: var(--fowoco-green-600);
+ font-size: 13px;
+}
+
+.responseError {
+ margin: 20px 0 0;
+ color: var(--status-critical);
+ font-size: 13px;
+}
+
.footnote {
margin: 12px 0 0;
font-size: 12px;
diff --git a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx
index 210ee9c..6ded607 100644
--- a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx
+++ b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx
@@ -1,34 +1,62 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LinkRequestPage } from './LinkRequestPage'
+function jsonResponse(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
+}
+
+const VIEW = {
+ guidance: '여권 사진면 사본을 제출해 주세요.',
+ due_date: '2026-08-10',
+ allowed_responses: ['QUESTION', 'DOCUMENT_SUBMITTED'],
+}
+
+beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
+afterEach(() => vi.unstubAllGlobals())
+
describe('LinkRequestPage', () => {
- it('renders the request headline and deadline', () => {
+ it('renders guidance loaded from the public token API', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(VIEW))
render(
-
-
+
+
+ } />
+
,
)
- expect(screen.getByText('데모 화면입니다.')).toBeInTheDocument()
- expect(screen.getByText('마감 · 링크 API 연결 후 표시')).toBeInTheDocument()
+ expect(await screen.findByText('여권 사진면 사본을 제출해 주세요.')).toBeInTheDocument()
+ expect(screen.getByText(/2026.08.10/)).toBeInTheDocument()
})
- it('navigates to the upload page on confirmation', async () => {
+ it('keeps the token when navigating to the upload page', async () => {
const user = userEvent.setup()
+ vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(VIEW))
render(
-
+
- } />
- upload screen
} />
+
} />
+
upload screen} />
,
)
- await user.click(screen.getByRole('button', { name: '파일 선택 화면 보기' }))
+ await user.click(await screen.findByRole('button', { name: '서류 제출하기' }))
expect(screen.getByText('upload screen')).toBeInTheDocument()
})
+
+ it('shows an explicit state when opened without a token', () => {
+ render(
+
+ } />
+ ,
+ )
+
+ expect(screen.getByText('제출 링크가 필요합니다')).toBeInTheDocument()
+ expect(fetch).not.toHaveBeenCalled()
+ })
})
diff --git a/src/pages/LinkRequestPage/LinkRequestPage.tsx b/src/pages/LinkRequestPage/LinkRequestPage.tsx
index 5901746..3ddc900 100644
--- a/src/pages/LinkRequestPage/LinkRequestPage.tsx
+++ b/src/pages/LinkRequestPage/LinkRequestPage.tsx
@@ -1,49 +1,135 @@
-import { useNavigate } from 'react-router-dom'
+import { useCallback, useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { ApiError, getErrorMessage } from '../../api/errors'
+import { fetchWorkerLink, submitWorkerResponse } from '../../api/workerLinks'
import { MobileShell } from '../../components/mobile/MobileShell'
+import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
+import { useApiQuery } from '../../hooks/useApiQuery'
+import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import styles from './LinkRequestPage.module.css'
-import { LINK_REQUEST } from './linkRequestData'
export function LinkRequestPage() {
+ const { token } = useParams()
+
+ if (!token) {
+ return (
+ 보안 링크}>
+
+
+ )
+ }
+
+ return
+}
+
+function WorkerLinkRequest({ token }: { token: string }) {
const navigate = useNavigate()
+ const [submittingQuestion, setSubmittingQuestion] = useState(false)
+ const [questionSent, setQuestionSent] = useState(false)
+ const [responseError, setResponseError] = useState(null)
+ const fetcher = useCallback(() => fetchWorkerLink(token), [token])
+ const { status, data, error, refetch } = useApiQuery(fetcher)
+
+ useEffect(() => {
+ if (status === 'error' && error?.status === 410) {
+ navigate(`/worker-portal/${encodeURIComponent(token)}/expired`, { replace: true })
+ }
+ }, [error, navigate, status, token])
+
+ async function handleQuestion() {
+ if (submittingQuestion) return
+ setSubmittingQuestion(true)
+ setResponseError(null)
+ try {
+ await submitWorkerResponse(token, {
+ response_type: 'QUESTION',
+ idempotency_key: crypto.randomUUID(),
+ })
+ setQuestionSent(true)
+ } catch (caught) {
+ setResponseError(caught instanceof ApiError ? getErrorMessage(caught) : '응답을 보내지 못했습니다.')
+ } finally {
+ setSubmittingQuestion(false)
+ }
+ }
+
+ if (status === 'loading') {
+ return (
+ 보안 링크}>
+
+
+ )
+ }
+
+ if (status === 'error' || !data) {
+ return (
+ 보안 링크}>
+
+
+ )
+ }
+
+ const due = getOperationalDateViewModel('TASK_DUE', data.due_date)
+ const canUpload = data.allowed_responses.includes('DOCUMENT_SUBMITTED')
+ const canAskQuestion = data.allowed_responses.includes('QUESTION')
return (
보안 링크}>
-
{LINK_REQUEST.expiryNotice.title}
-
{LINK_REQUEST.expiryNotice.body}
+
회사에서 발급한 제출 링크입니다.
+
이 화면을 닫아도 같은 링크로 다시 열 수 있습니다.
- {LINK_REQUEST.requester}
+ 서류 제출 요청
- {LINK_REQUEST.headline[0]}
+ 요청 내용을
- {LINK_REQUEST.headline[1]}
+ 확인해 주세요
- {LINK_REQUEST.deadline}
+ {due.display}
- {LINK_REQUEST.body}
+ {data.guidance}
-
{LINK_REQUEST.privacy.title}
-
{LINK_REQUEST.privacy.body}
+
이 업무에 필요한 정보만 표시됩니다.
+
선택한 파일은 이 보안 링크의 업무에만 연결됩니다.
+ {questionSent && 담당자에게 질문 의사를 전했습니다.
}
+ {responseError && {responseError}
}
+
-
- 질문 API 연결 필요
+
+ {questionSent ? '질문 의사 전송됨' : '질문이 있습니다'}
navigate('/worker-portal/upload')}
+ disabled={!canUpload}
+ onClick={() => navigate(`/worker-portal/${encodeURIComponent(token)}/upload`)}
>
- 파일 선택 화면 보기
+ 서류 제출하기
- {LINK_REQUEST.footnote}
+ 제출 결과는 담당자에게 전달되며 같은 요청의 중복 제출은 차단됩니다.
)
}
diff --git a/src/pages/LinkRequestPage/linkRequestData.ts b/src/pages/LinkRequestPage/linkRequestData.ts
deleted file mode 100644
index 2988dcd..0000000
--- a/src/pages/LinkRequestPage/linkRequestData.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-// TODO(backend): GET /api/links/:token -> 아래 상수 대체 (만료 시 LinkExpiredPage로 라우팅)
-
-export const LINK_REQUEST = {
- expiryNotice: {
- title: '데모 화면입니다.',
- body: '실제 보안 링크 유효시간은 토큰 조회 API 연결 후 표시됩니다.',
- },
- requester: '보안 링크 요청 정보',
- headline: ['서류 제출 안내를', '확인해 주세요'],
- deadline: '마감 · 링크 API 연결 후 표시',
- body: '실제 요청 서류와 안내 문구는 보안 링크 토큰을 조회한 뒤 표시됩니다.',
- privacy: {
- title: '이 화면에는 이 업무에 필요한 정보만 표시됩니다.',
- body: '파일은 실제 업로드 API가 연결된 뒤에만 제출됩니다.',
- },
- footnote: '현재는 화면 확인만 가능하며 읽음·제출 상태를 기록하지 않습니다.',
-}
diff --git a/src/pages/LinkUploadPage/LinkUploadPage.module.css b/src/pages/LinkUploadPage/LinkUploadPage.module.css
index 22cc301..168dce9 100644
--- a/src/pages/LinkUploadPage/LinkUploadPage.module.css
+++ b/src/pages/LinkUploadPage/LinkUploadPage.module.css
@@ -61,6 +61,49 @@
font-size: 13px;
}
+.responseNotice {
+ margin: 12px 0 0;
+ padding: 12px 16px;
+ border-radius: var(--fowoco-radius-8);
+ background: var(--fowoco-green-50);
+ color: var(--fowoco-green-600);
+ font-size: 13px;
+}
+
+.successCard {
+ margin-top: 48px;
+ padding: 32px 24px;
+ border-radius: var(--fowoco-radius-8);
+ background: var(--surface-subtle);
+ text-align: center;
+}
+
+.successIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: var(--fowoco-green-50);
+ color: var(--fowoco-green-600);
+ font-size: 24px;
+}
+
+.successTitle {
+ margin: 20px 0 0;
+ color: var(--text-primary);
+ font-size: 24px;
+}
+
+.successBody,
+.successMeta {
+ margin: 12px 0 0;
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.6;
+}
+
.selectedFile {
display: flex;
align-items: center;
diff --git a/src/pages/LinkUploadPage/LinkUploadPage.test.tsx b/src/pages/LinkUploadPage/LinkUploadPage.test.tsx
index edb3d85..c4c160c 100644
--- a/src/pages/LinkUploadPage/LinkUploadPage.test.tsx
+++ b/src/pages/LinkUploadPage/LinkUploadPage.test.tsx
@@ -1,28 +1,52 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { MemoryRouter } from 'react-router-dom'
-import { describe, expect, it } from 'vitest'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LinkUploadPage } from './LinkUploadPage'
+function jsonResponse(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
+}
+
function renderPage() {
render(
-
-
+
+
+ } />
+
,
)
}
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn((input) => {
+ const url = String(input)
+ if (url.endsWith('/documents')) {
+ return Promise.resolve(jsonResponse({ upload_id: 'U-1', file_name: 'passport.jpg', size: 8, expires_at: '2026-08-07T00:00:00Z' }, 201))
+ }
+ if (url.endsWith('/responses')) {
+ return Promise.resolve(jsonResponse({ response_id: 'R-1', received_at: '2026-08-04T00:00:00Z' }, 201))
+ }
+ return Promise.resolve(jsonResponse({
+ guidance: '여권 사본을 제출해 주세요.', due_date: '2026-08-10',
+ allowed_responses: ['QUESTION', 'NOT_UNDERSTOOD', 'DIFFICULT', 'DOCUMENT_SUBMITTED'],
+ }))
+ }))
+})
+
+afterEach(() => vi.unstubAllGlobals())
+
describe('LinkUploadPage', () => {
it('shows the real selected file without pretending it was uploaded', async () => {
const user = userEvent.setup()
renderPage()
const file = new File(['passport'], 'passport_photo.jpg', { type: 'image/jpeg' })
- await user.upload(screen.getByLabelText('제출할 파일 선택'), file)
+ await user.upload(await screen.findByLabelText('제출할 파일 선택'), file)
expect(screen.getByText('passport_photo.jpg')).toBeInTheDocument()
expect(screen.getByText(/제출 전/)).toBeInTheDocument()
- expect(screen.getByRole('button', { name: '제출 API 연결 필요' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '서류 제출' })).toBeEnabled()
})
it('removes the selected file', async () => {
@@ -30,7 +54,7 @@ describe('LinkUploadPage', () => {
renderPage()
const file = new File(['passport'], 'passport_photo.jpg', { type: 'image/jpeg' })
- await user.upload(screen.getByLabelText('제출할 파일 선택'), file)
+ await user.upload(await screen.findByLabelText('제출할 파일 선택'), file)
await user.click(screen.getByRole('button', { name: '삭제' }))
expect(screen.queryByText('passport_photo.jpg')).not.toBeInTheDocument()
@@ -41,9 +65,27 @@ describe('LinkUploadPage', () => {
renderPage()
const file = new File(['script'], 'worker.exe', { type: 'application/octet-stream' })
- await user.upload(screen.getByLabelText('제출할 파일 선택'), file)
+ await user.upload(await screen.findByLabelText('제출할 파일 선택'), file)
expect(screen.getByRole('alert')).toHaveTextContent('JPG, PNG, PDF 파일만 선택할 수 있습니다.')
expect(screen.queryByText('worker.exe')).not.toBeInTheDocument()
})
+
+ it('uploads the selected file and submits the upload id as a worker response', async () => {
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: '사진 또는 파일을 추가해 주세요' })
+ const file = new File(['passport'], 'passport_photo.jpg', { type: 'image/jpeg' })
+
+ await user.upload(await screen.findByLabelText('제출할 파일 선택'), file)
+ await user.click(screen.getByRole('button', { name: '서류 제출' }))
+
+ expect(await screen.findByText('서류를 제출했습니다')).toBeInTheDocument()
+ const calls = vi.mocked(fetch).mock.calls
+ expect(calls.some(([url]) => String(url).endsWith('/documents'))).toBe(true)
+ const responseCall = calls.find(([url]) => String(url).endsWith('/responses'))
+ expect(JSON.parse(responseCall?.[1]?.body as string)).toMatchObject({
+ response_type: 'DOCUMENT_SUBMITTED', upload_ids: ['U-1'],
+ })
+ })
})
diff --git a/src/pages/LinkUploadPage/LinkUploadPage.tsx b/src/pages/LinkUploadPage/LinkUploadPage.tsx
index f00a702..15a5836 100644
--- a/src/pages/LinkUploadPage/LinkUploadPage.tsx
+++ b/src/pages/LinkUploadPage/LinkUploadPage.tsx
@@ -1,6 +1,16 @@
-import { useRef, useState, type ChangeEvent } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { ApiError, getErrorMessage } from '../../api/errors'
+import {
+ fetchWorkerLink,
+ submitWorkerResponse,
+ uploadWorkerLinkDocument,
+ type WorkerResponseSubmitResponse,
+ type WorkerResponseType,
+} from '../../api/workerLinks'
import { MobileShell } from '../../components/mobile/MobileShell'
+import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
+import { useApiQuery } from '../../hooks/useApiQuery'
import styles from './LinkUploadPage.module.css'
import { HELP_LINKS } from './linkUploadData'
@@ -13,10 +23,25 @@ function formatFileSize(bytes: number) {
}
export function LinkUploadPage() {
+ const { token } = useParams()
const navigate = useNavigate()
const fileInputRef = useRef(null)
const [file, setFile] = useState(null)
const [fileError, setFileError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+ const [submission, setSubmission] = useState(null)
+ const [responseMessage, setResponseMessage] = useState(null)
+ const fetcher = useCallback(
+ () => (token ? fetchWorkerLink(token) : Promise.reject(new Error('missing token'))),
+ [token],
+ )
+ const { status, data, error, refetch } = useApiQuery(fetcher)
+
+ useEffect(() => {
+ if (token && status === 'error' && error?.status === 410) {
+ navigate(`/worker-portal/${encodeURIComponent(token)}/expired`, { replace: true })
+ }
+ }, [error, navigate, status, token])
function handleFileChange(event: ChangeEvent) {
const selected = event.target.files?.[0] ?? null
@@ -43,8 +68,99 @@ export function LinkUploadPage() {
if (fileInputRef.current) fileInputRef.current.value = ''
}
+ async function handleSubmit() {
+ if (!token || !file || submitting) return
+ setSubmitting(true)
+ setFileError(null)
+ const uploadRequestId = crypto.randomUUID()
+ try {
+ const upload = await uploadWorkerLinkDocument(token, file, uploadRequestId)
+ const result = await submitWorkerResponse(token, {
+ response_type: 'DOCUMENT_SUBMITTED',
+ upload_ids: [upload.upload_id],
+ idempotency_key: crypto.randomUUID(),
+ })
+ setSubmission(result)
+ } catch (caught) {
+ if (caught instanceof ApiError && caught.status === 410) {
+ navigate(`/worker-portal/${encodeURIComponent(token)}/expired`, { replace: true })
+ return
+ }
+ setFileError(caught instanceof ApiError ? getErrorMessage(caught) : '파일을 제출하지 못했습니다.')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ async function handleHelpResponse(responseType: WorkerResponseType, successMessage: string) {
+ if (!token || submitting) return
+ setSubmitting(true)
+ setFileError(null)
+ try {
+ await submitWorkerResponse(token, {
+ response_type: responseType,
+ idempotency_key: crypto.randomUUID(),
+ })
+ setResponseMessage(successMessage)
+ } catch (caught) {
+ setFileError(caught instanceof ApiError ? getErrorMessage(caught) : '응답을 보내지 못했습니다.')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ if (!token) {
+ return (
+ navigate(-1)}>
+
+
+ )
+ }
+
+ if (status === 'loading') {
+ return (
+ navigate(-1)}>
+
+
+ )
+ }
+
+ if (status === 'error' || !data) {
+ return (
+ navigate(-1)}>
+
+
+ )
+ }
+
+ if (submission) {
+ return (
+ 완료}>
+
+
✓
+
서류를 제출했습니다
+
담당자가 파일을 확인하면 다음 상태로 진행됩니다.
+
접수 ID · {submission.response_id}
+
+
+ )
+ }
+
+ const canSubmitDocument = data.allowed_responses.includes('DOCUMENT_SUBMITTED')
+ const helpResponses: Array<{ label: string; type: WorkerResponseType; message: string }> = [
+ { label: HELP_LINKS[0], type: 'QUESTION', message: '담당자에게 질문 의사를 전했습니다.' },
+ { label: HELP_LINKS[1], type: 'NOT_UNDERSTOOD', message: '담당자에게 추가 설명을 요청했습니다.' },
+ { label: HELP_LINKS[2], type: 'DIFFICULT', message: '담당자에게 처리 어려움 상태를 전했습니다.' },
+ ]
+
return (
- navigate(-1)} right={1 / 1 }>
+ navigate(-1)} right={보안 링크 }>
사진 또는 파일을
@@ -72,6 +188,7 @@ export function LinkUploadPage() {
/>
{fileError && {fileError}
}
+ {responseMessage && {responseMessage}
}
{file && (
@@ -89,26 +206,31 @@ export function LinkUploadPage() {
제출이 어렵다면
- {HELP_LINKS.map((label, index) => (
+ {helpResponses.map((response, index) => (
handleHelpResponse(response.type, response.message)}
>
- {label}
+ {response.label}
→
))}
-
- 제출 API 연결 필요
+
+ {submitting ? '제출 중…' : '서류 제출'}
- 파일 선택과 형식 검증만 가능합니다. 보안 링크 토큰·업로드·제출 API 연결 후 실제 기록됩니다.
+ 업로드가 끝난 뒤 제출 응답까지 접수되어야 담당자 화면에 반영됩니다.
)
diff --git a/src/routes.tsx b/src/routes.tsx
index 63126e7..bfe3436 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -62,7 +62,9 @@ export const router = createBrowserRouter([
],
},
{ path: '/worker-portal', element: },
- { path: '/worker-portal/upload', element: },
{ path: '/worker-portal/expired', element: },
+ { path: '/worker-portal/:token/upload', element: },
+ { path: '/worker-portal/:token/expired', element: },
+ { path: '/worker-portal/:token', element: },
{ path: '*', element: },
])
diff --git a/src/view-models/workerRequestStateViewModel.test.ts b/src/view-models/workerRequestStateViewModel.test.ts
new file mode 100644
index 0000000..b05b74a
--- /dev/null
+++ b/src/view-models/workerRequestStateViewModel.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest'
+import { getWorkerRequestStateViewModel } from './workerRequestStateViewModel'
+
+describe('getWorkerRequestStateViewModel', () => {
+ it('maps the four operational states without inferring transmission from registration', () => {
+ expect(getWorkerRequestStateViewModel({}).label).toBe('서류대기')
+ expect(getWorkerRequestStateViewModel({ requestSentAt: '2026-08-04T01:00:00Z' }).label).toBe('요청전송')
+ expect(getWorkerRequestStateViewModel({
+ requestSentAt: '2026-08-04T01:00:00Z', responseReceivedAt: '2026-08-04T02:00:00Z',
+ }).label).toBe('승인대기')
+ expect(getWorkerRequestStateViewModel({ completedAt: '2026-08-04T03:00:00Z' }).label).toBe('완료')
+ })
+})
diff --git a/src/view-models/workerRequestStateViewModel.ts b/src/view-models/workerRequestStateViewModel.ts
new file mode 100644
index 0000000..c426107
--- /dev/null
+++ b/src/view-models/workerRequestStateViewModel.ts
@@ -0,0 +1,33 @@
+export type WorkerRequestState = 'DOCUMENT_WAITING' | 'REQUEST_SENT' | 'APPROVAL_WAITING' | 'COMPLETED'
+
+export interface WorkerRequestStateSource {
+ requestSentAt?: string | null
+ responseReceivedAt?: string | null
+ responseReadAt?: string | null
+ completedAt?: string | null
+}
+
+export interface WorkerRequestStateViewModel {
+ state: WorkerRequestState
+ label: '서류대기' | '요청전송' | '승인대기' | '완료'
+ description: string
+}
+
+export function getWorkerRequestStateViewModel(
+ source: WorkerRequestStateSource,
+): WorkerRequestStateViewModel {
+ if (source.completedAt) {
+ return { state: 'COMPLETED', label: '완료', description: '서류 확인과 후속 처리가 완료되었습니다.' }
+ }
+ if (source.responseReceivedAt && !source.responseReadAt) {
+ return { state: 'APPROVAL_WAITING', label: '승인대기', description: '근로자 응답이 도착해 담당자 확인이 필요합니다.' }
+ }
+ if (source.requestSentAt) {
+ return { state: 'REQUEST_SENT', label: '요청전송', description: '요청을 전송했으며 근로자 응답을 기다립니다.' }
+ }
+ return {
+ state: 'DOCUMENT_WAITING',
+ label: '서류대기',
+ description: '요청 정보는 등록됐지만 모바일 링크 전송은 확인되지 않았습니다.',
+ }
+}
From d447fc9f72a8b436159d40ae1b5e2241eeedf8e5 Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 15:56:24 +0900
Subject: [PATCH 06/10] feat(ui): align task and worker flows with Figma
---
src/components/layout/AppLayout.module.css | 62 +++-
src/components/layout/AppLayout.tsx | 21 +-
.../HeaderActions/HeaderActions.module.css | 39 +-
src/components/layout/nav-icons/documents.svg | 5 +
src/components/layout/nav-icons/settings.svg | 4 +
src/components/layout/nav-icons/today.svg | 3 +
src/components/layout/nav-icons/work.svg | 4 +
src/components/layout/nav-icons/workers.svg | 5 +
src/components/layout/navItems.ts | 17 +-
src/components/mobile/MobileShell.module.css | 5 +-
src/components/mobile/MobileShell.tsx | 12 +-
src/components/ui/SearchInput/SearchInput.tsx | 11 +-
.../CaseDetailPage/CaseDetailPage.module.css | 129 ++++---
.../CaseDetailPage/CaseDetailPage.test.tsx | 195 ++++++++--
src/pages/CaseDetailPage/CaseDetailPage.tsx | 334 +++++++++++++-----
.../LinkExpiredPage.module.css | 16 +-
.../LinkExpiredPage/LinkExpiredPage.test.tsx | 2 +-
src/pages/LinkExpiredPage/LinkExpiredPage.tsx | 16 +-
.../LinkRequestPage.module.css | 4 +
.../LinkRequestPage/LinkRequestPage.test.tsx | 23 +-
src/pages/LinkRequestPage/LinkRequestPage.tsx | 64 +++-
.../LinkUploadPage/LinkUploadPage.module.css | 16 +-
src/pages/LinkUploadPage/LinkUploadPage.tsx | 56 ++-
src/pages/WorkListPage/WorkInboxDetail.tsx | 77 ++--
.../WorkListPage/WorkInboxTargetList.tsx | 1 -
.../WorkListPage/WorkListPage.module.css | 199 +++++++----
src/pages/WorkListPage/WorkListPage.tsx | 4 +-
27 files changed, 960 insertions(+), 364 deletions(-)
create mode 100644 src/components/layout/nav-icons/documents.svg
create mode 100644 src/components/layout/nav-icons/settings.svg
create mode 100644 src/components/layout/nav-icons/today.svg
create mode 100644 src/components/layout/nav-icons/work.svg
create mode 100644 src/components/layout/nav-icons/workers.svg
diff --git a/src/components/layout/AppLayout.module.css b/src/components/layout/AppLayout.module.css
index 6fe7c35..52d2021 100644
--- a/src/components/layout/AppLayout.module.css
+++ b/src/components/layout/AppLayout.module.css
@@ -18,7 +18,7 @@
flex: 0 0 208px;
display: flex;
flex-direction: column;
- padding: 28px 20px;
+ padding: 28px 20px 22px;
overflow-y: auto;
background: var(--brand-dark);
color: var(--fowoco-white);
@@ -44,34 +44,50 @@
}
.brand {
- margin: 0 0 4px 4px;
+ height: 32px;
+ margin: 0 0 0 4px;
font-size: 20px;
font-weight: 700;
+ line-height: 28px;
}
.kicker {
- margin: 0 0 24px 4px;
+ height: 19px;
+ margin: 20px 0 19px 4px;
font-size: 11px;
font-weight: 500;
+ line-height: 19px;
color: var(--fowoco-teal-100);
}
.nav {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 8px;
}
.navLink {
- padding: 10px 16px;
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ height: 44px;
+ padding: 0 16px;
border-radius: var(--fowoco-radius-6);
font-size: 14px;
color: var(--fowoco-teal-100);
text-decoration: none;
- transition: background-color var(--fowoco-motion-fast) var(--fowoco-easing-standard),
+ transition:
+ background-color var(--fowoco-motion-fast) var(--fowoco-easing-standard),
color var(--fowoco-motion-fast) var(--fowoco-easing-standard);
}
+.navIcon {
+ width: 18px;
+ height: 18px;
+ flex: 0 0 18px;
+}
+
.navLink:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--fowoco-white);
@@ -83,6 +99,17 @@
font-weight: 500;
}
+.navLinkActive::before {
+ position: absolute;
+ top: 10px;
+ bottom: 10px;
+ left: 0;
+ width: 3px;
+ border-radius: 2px;
+ background: var(--brand-primary);
+ content: '';
+}
+
.sidebarFooter {
margin-top: auto;
display: flex;
@@ -90,8 +117,7 @@
gap: 2px;
}
-.help,
-.logout {
+.help {
padding: 10px 16px;
background: none;
border: none;
@@ -101,11 +127,6 @@
cursor: pointer;
}
-.logout {
- color: var(--fowoco-white);
- opacity: 0.85;
-}
-
.main {
flex: 1;
display: flex;
@@ -124,7 +145,7 @@
.topBar {
display: flex;
align-items: center;
- justify-content: flex-end;
+ justify-content: space-between;
gap: 12px;
flex-shrink: 0;
min-height: 64px;
@@ -133,16 +154,27 @@
border-bottom: 1px solid var(--border-default);
}
+.topBarBack {
+ font-size: 13px;
+ color: var(--text-secondary);
+ text-decoration: none;
+}
+
+.topBarBack:hover {
+ color: var(--text-primary);
+}
+
.topBarActions {
display: flex;
align-items: center;
gap: 16px;
+ margin-left: auto;
}
.content {
flex: 1;
min-height: 0;
- padding: 32px;
+ padding: 32px 40px;
overflow-y: auto;
}
diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx
index 570cf06..3fd9957 100644
--- a/src/components/layout/AppLayout.tsx
+++ b/src/components/layout/AppLayout.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
-import { NavLink, useNavigate } from 'react-router-dom'
+import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
import { OnboardingTour } from '../onboarding/OnboardingTour'
import { hasCompletedOnboarding, markOnboardingCompleted } from '../onboarding/onboardingStorage'
import { useAuthStore } from '../../store/authStore'
@@ -12,10 +12,12 @@ import { RouteTransition } from './RouteTransition'
export function AppLayout() {
const navigate = useNavigate()
+ const location = useLocation()
const user = useAuthStore((state) => state.user)
const logout = useAuthStore((state) => state.logout)
const [helpOpen, setHelpOpen] = useState(false)
const [tourOpen, setTourOpen] = useState(false)
+ const isTaskDetail = /^\/tasks\/[^/]+$/.test(location.pathname)
useEffect(() => {
if (!hasCompletedOnboarding()) setTourOpen(true)
@@ -51,7 +53,8 @@ export function AppLayout() {
`${styles.navLink} ${isActive ? styles.navLinkActive : ''}`
}
>
- {item.label}
+
+ {item.label}
))}
@@ -60,14 +63,16 @@ export function AppLayout() {
setHelpOpen(true)}>
? 도움말
-
- 로그아웃
-
+ {isTaskDetail && (
+
+ ← 업무함
+
+ )}
@@ -78,7 +83,11 @@ export function AppLayout() {
- setHelpOpen(false)} onReplayTour={handleReplayTour} />
+ setHelpOpen(false)}
+ onReplayTour={handleReplayTour}
+ />
diff --git a/src/components/layout/HeaderActions/HeaderActions.module.css b/src/components/layout/HeaderActions/HeaderActions.module.css
index 0c02a1e..8088443 100644
--- a/src/components/layout/HeaderActions/HeaderActions.module.css
+++ b/src/components/layout/HeaderActions/HeaderActions.module.css
@@ -13,17 +13,17 @@
display: flex;
align-items: center;
justify-content: center;
- width: 36px;
- height: 36px;
- background: var(--surface-subtle);
- border: none;
- border-radius: 50%;
+ width: 40px;
+ height: 40px;
+ background: var(--surface-default);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-8);
cursor: pointer;
}
.bellIcon {
- width: 18px;
- height: 18px;
+ width: 20px;
+ height: 20px;
color: var(--brand-primary);
}
@@ -47,12 +47,13 @@
.profileButton {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 10px;
+ width: 188px;
height: 40px;
- padding: 0 10px 0 4px;
- background: var(--surface-subtle);
- border: none;
- border-radius: var(--fowoco-radius-999);
+ padding: 6px 12px;
+ background: var(--surface-default);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-8);
cursor: pointer;
}
@@ -62,15 +63,21 @@
justify-content: center;
width: 28px;
height: 28px;
- font-size: 12px;
- font-weight: 700;
- color: var(--fowoco-white);
- background: var(--brand-primary);
+ flex: 0 0 28px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--brand-primary);
+ background: var(--surface-subtle);
border-radius: 50%;
}
.profileLabel {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
font-size: 13px;
+ text-align: left;
+ text-overflow: ellipsis;
color: var(--text-primary);
white-space: nowrap;
}
diff --git a/src/components/layout/nav-icons/documents.svg b/src/components/layout/nav-icons/documents.svg
new file mode 100644
index 0000000..eed7848
--- /dev/null
+++ b/src/components/layout/nav-icons/documents.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/components/layout/nav-icons/settings.svg b/src/components/layout/nav-icons/settings.svg
new file mode 100644
index 0000000..55844a8
--- /dev/null
+++ b/src/components/layout/nav-icons/settings.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/components/layout/nav-icons/today.svg b/src/components/layout/nav-icons/today.svg
new file mode 100644
index 0000000..b538fee
--- /dev/null
+++ b/src/components/layout/nav-icons/today.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/components/layout/nav-icons/work.svg b/src/components/layout/nav-icons/work.svg
new file mode 100644
index 0000000..b0ded0b
--- /dev/null
+++ b/src/components/layout/nav-icons/work.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/components/layout/nav-icons/workers.svg b/src/components/layout/nav-icons/workers.svg
new file mode 100644
index 0000000..7901084
--- /dev/null
+++ b/src/components/layout/nav-icons/workers.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/components/layout/navItems.ts b/src/components/layout/navItems.ts
index 8ab2316..c48ece5 100644
--- a/src/components/layout/navItems.ts
+++ b/src/components/layout/navItems.ts
@@ -1,15 +1,22 @@
+import documentsIcon from './nav-icons/documents.svg'
+import settingsIcon from './nav-icons/settings.svg'
+import todayIcon from './nav-icons/today.svg'
+import workIcon from './nav-icons/work.svg'
+import workersIcon from './nav-icons/workers.svg'
+
export interface NavItem {
label: string
to: string
+ iconSrc: string
}
// Figma PWF v3 사이드바(Aside - SideNavBar, 05_Desktop Core Product 전 화면 공통)는 5개 메뉴만
// 정의한다. Agent/티켓 메뉴는 Figma에 없는 화면이라(#206) 링크만 제거하고 라우트는 유지한다.
// SettingsPage는 제거되어 "설정" 메뉴는 내 프로필 페이지로 연결된다.
export const NAV_ITEMS: NavItem[] = [
- { label: 'Today', to: '/dashboard' },
- { label: '업무함', to: '/tasks' },
- { label: '근로자', to: '/workers' },
- { label: '문서함', to: '/documents' },
- { label: '설정', to: '/profile' },
+ { label: 'Today', to: '/dashboard', iconSrc: todayIcon },
+ { label: '업무함', to: '/tasks', iconSrc: workIcon },
+ { label: '근로자', to: '/workers', iconSrc: workersIcon },
+ { label: '문서함', to: '/documents', iconSrc: documentsIcon },
+ { label: '설정', to: '/profile', iconSrc: settingsIcon },
]
diff --git a/src/components/mobile/MobileShell.module.css b/src/components/mobile/MobileShell.module.css
index 8628da9..5d17c23 100644
--- a/src/components/mobile/MobileShell.module.css
+++ b/src/components/mobile/MobileShell.module.css
@@ -33,18 +33,21 @@
.title {
flex: 1;
+ margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
}
.brand {
+ margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--brand-primary);
}
.right {
+ margin-left: auto;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
@@ -60,5 +63,5 @@
}
.content {
- padding: 24px 20px 40px;
+ padding: 24px 20px 22px;
}
diff --git a/src/components/mobile/MobileShell.tsx b/src/components/mobile/MobileShell.tsx
index 8e7e5a9..7fb4ff3 100644
--- a/src/components/mobile/MobileShell.tsx
+++ b/src/components/mobile/MobileShell.tsx
@@ -19,12 +19,12 @@ export function MobileShell({ children, onBack, title, right, expired }: MobileS
←
)}
- {title ? (
- {title}
- ) : (
- FOWOCO
- )}
- {expired ? 만료 : right}
+ {title ? {title}
: FOWOCO
}
+ {expired ? (
+ 만료
+ ) : right ? (
+ {right}
+ ) : null}
{children}
diff --git a/src/components/ui/SearchInput/SearchInput.tsx b/src/components/ui/SearchInput/SearchInput.tsx
index ede5153..e7162aa 100644
--- a/src/components/ui/SearchInput/SearchInput.tsx
+++ b/src/components/ui/SearchInput/SearchInput.tsx
@@ -6,16 +6,23 @@ export interface SearchInputProps {
onChange: (value: string) => void
placeholder: string
ariaLabel: string
+ className?: string
}
-export function SearchInput({ value, onChange, placeholder, ariaLabel }: SearchInputProps) {
+export function SearchInput({
+ value,
+ onChange,
+ placeholder,
+ ariaLabel,
+ className,
+}: SearchInputProps) {
function handleChange(event: ChangeEvent) {
onChange(event.target.value)
}
return (
= {}): TaskDetailResponse {
version: 1,
missing_required_slots: [],
checklist_items: [
- { checklist_item_id: 'chk-1', item_code: 'passport', label: '여권 사본 확인', required: true, completed: true, completed_by: null, completed_at: null, version: 1 },
- { checklist_item_id: 'chk-2', item_code: 'signature', label: '근로자 서명 확인', required: true, completed: false, completed_by: null, completed_at: null, version: 1 },
+ {
+ checklist_item_id: 'chk-1',
+ item_code: 'passport',
+ label: '여권 사본 확인',
+ required: true,
+ completed: true,
+ completed_by: null,
+ completed_at: null,
+ version: 1,
+ },
+ {
+ checklist_item_id: 'chk-2',
+ item_code: 'signature',
+ label: '근로자 서명 확인',
+ required: true,
+ completed: false,
+ completed_by: null,
+ completed_at: null,
+ version: 1,
+ },
],
created_by: 'u-1',
updated_by: 'u-1',
@@ -68,8 +102,17 @@ function activity(overrides: Partial = {}): AuditEventRespon
}
}
-function readinessResponse(overrides: Partial = {}): DocumentReadinessResponse {
- return { required: [], available: [], missing: [], expired: [], completion_blocked: false, ...overrides }
+function readinessResponse(
+ overrides: Partial = {},
+): DocumentReadinessResponse {
+ return {
+ required: [],
+ available: [],
+ missing: [],
+ expired: [],
+ completion_blocked: false,
+ ...overrides,
+ }
}
function documentsResponse(items: DocumentItemResponse[] = []): DocumentPageResponse {
@@ -85,28 +128,58 @@ function mockTaskAndActivities(
vi.mocked(fetch).mockImplementation((input) => {
const url = String(input)
if (url.includes('/activities')) return Promise.resolve(jsonResponse(activities))
- if (url.includes('/document-readiness')) return Promise.resolve(jsonResponse(readinessResponse(readinessOverrides)))
+ if (url.includes('/document-readiness'))
+ return Promise.resolve(jsonResponse(readinessResponse(readinessOverrides)))
if (url.includes('/document-request-draft')) {
- return Promise.resolve(jsonResponse({ draft_id: 'draft-1', version: 1, review_status: 'PENDING' }))
+ return Promise.resolve(
+ jsonResponse({ draft_id: 'draft-1', version: 1, review_status: 'PENDING' }),
+ )
}
- if (url.includes('/documents?')) return Promise.resolve(jsonResponse(documentsResponse(documents)))
+ if (url.includes('/documents?'))
+ return Promise.resolve(jsonResponse(documentsResponse(documents)))
if (url.includes('/approval-requests')) {
- return Promise.resolve(jsonResponse({ task_id: 'T-1', task_status: 'READY_FOR_REVIEW', task_version: 2 }, { status: 201 }))
+ return Promise.resolve(
+ jsonResponse(
+ { task_id: 'T-1', task_status: 'READY_FOR_REVIEW', task_version: 2 },
+ { status: 201 },
+ ),
+ )
}
if (url.endsWith('/approve')) {
- return Promise.resolve(jsonResponse({ task_id: 'T-1', task_status: 'APPROVED', task_version: 2 }))
+ return Promise.resolve(
+ jsonResponse({ task_id: 'T-1', task_status: 'APPROVED', task_version: 2 }),
+ )
}
if (url.endsWith('/reject')) {
- return Promise.resolve(jsonResponse({ task_id: 'T-1', task_status: 'DRAFT', task_version: 2 }))
+ return Promise.resolve(
+ jsonResponse({ task_id: 'T-1', task_status: 'DRAFT', task_version: 2 }),
+ )
}
if (url.endsWith('/evidence')) {
- return Promise.resolve(jsonResponse({ resource_id: 'E-1', task_id: 'T-1', task_status: 'APPROVED', task_version: 1 }, { status: 201 }))
+ return Promise.resolve(
+ jsonResponse(
+ { resource_id: 'E-1', task_id: 'T-1', task_status: 'APPROVED', task_version: 1 },
+ { status: 201 },
+ ),
+ )
}
if (url.endsWith('/complete')) {
- return Promise.resolve(jsonResponse({ resource_id: 'T-1', task_id: 'T-1', task_status: 'COMPLETED', task_version: 2 }))
+ return Promise.resolve(
+ jsonResponse({
+ resource_id: 'T-1',
+ task_id: 'T-1',
+ task_status: 'COMPLETED',
+ task_version: 2,
+ }),
+ )
}
if (url.endsWith('/worker-link')) {
- return Promise.resolve(jsonResponse({ worker_url: 'worker-token-1', expires_at: '2026-08-07T00:00:00Z' }, { status: 201 }))
+ return Promise.resolve(
+ jsonResponse(
+ { worker_url: 'worker-token-1', expires_at: '2026-08-07T00:00:00Z' },
+ { status: 201 },
+ ),
+ )
}
return Promise.resolve(jsonResponse(task(taskOverrides)))
})
@@ -116,7 +189,8 @@ function mockTaskError(status: number, code: string, message: string) {
vi.mocked(fetch).mockImplementation((input) => {
const url = String(input)
if (url.includes('/activities')) return Promise.resolve(jsonResponse([]))
- if (url.includes('/document-readiness')) return Promise.resolve(jsonResponse(readinessResponse()))
+ if (url.includes('/document-readiness'))
+ return Promise.resolve(jsonResponse(readinessResponse()))
if (url.includes('/documents?')) return Promise.resolve(jsonResponse(documentsResponse()))
return Promise.resolve(errorResponse(status, code, message))
})
@@ -170,7 +244,7 @@ describe('CaseDetailPage', () => {
expect(await screen.findByText('응웬반A 체류연장 준비')).toBeInTheDocument()
expect(screen.getAllByText('검토 필요').length).toBeGreaterThan(0)
- expect(screen.getByText('현재 업무 상태')).toBeInTheDocument()
+ expect(screen.getByText('업무 진행')).toBeInTheDocument()
expect(screen.getAllByText('1 / 2').length).toBeGreaterThan(0)
expect(screen.queryByText('보안 링크 전달')).not.toBeInTheDocument()
})
@@ -192,8 +266,26 @@ describe('CaseDetailPage', () => {
jsonResponse(
task({
checklist_items: [
- { checklist_item_id: 'chk-1', item_code: 'passport', label: '여권 사본 확인', required: true, completed: false, completed_by: null, completed_at: null, version: 2 },
- { checklist_item_id: 'chk-2', item_code: 'signature', label: '근로자 서명 확인', required: true, completed: false, completed_by: null, completed_at: null, version: 1 },
+ {
+ checklist_item_id: 'chk-1',
+ item_code: 'passport',
+ label: '여권 사본 확인',
+ required: true,
+ completed: false,
+ completed_by: null,
+ completed_at: null,
+ version: 2,
+ },
+ {
+ checklist_item_id: 'chk-2',
+ item_code: 'signature',
+ label: '근로자 서명 확인',
+ required: true,
+ completed: false,
+ completed_by: null,
+ completed_at: null,
+ version: 1,
+ },
],
}),
),
@@ -201,7 +293,9 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByText('여권 사본 확인'))
const checklistPatchCall = await waitFor(() => {
- const call = vi.mocked(fetch).mock.calls.find(([url]) => String(url).includes('/checklist-items/chk-1'))
+ const call = vi
+ .mocked(fetch)
+ .mock.calls.find(([url]) => String(url).includes('/checklist-items/chk-1'))
expect(call).toBeDefined()
return call!
})
@@ -237,12 +331,7 @@ describe('CaseDetailPage', () => {
it('shows the document-readiness gate and saves a document request draft when documents are missing', async () => {
const user = userEvent.setup()
- mockTaskAndActivities(
- {},
- [],
- { missing: ['ARC'], expired: [], completion_blocked: true },
- [],
- )
+ mockTaskAndActivities({}, [], { missing: ['ARC'], expired: [], completion_blocked: true }, [])
renderPage()
await screen.findByText('응웬반A 체류연장 준비')
@@ -289,7 +378,16 @@ describe('CaseDetailPage', () => {
mockTaskAndActivities({
status: 'DRAFT',
checklist_items: [
- { checklist_item_id: 'chk-1', item_code: 'passport', label: '여권 사본 확인', required: true, completed: true, completed_by: null, completed_at: null, version: 1 },
+ {
+ checklist_item_id: 'chk-1',
+ item_code: 'passport',
+ label: '여권 사본 확인',
+ required: true,
+ completed: true,
+ completed_by: null,
+ completed_at: null,
+ version: 1,
+ },
],
})
renderPage()
@@ -302,7 +400,9 @@ describe('CaseDetailPage', () => {
expect(screen.getByText('승인을 요청했습니다.')).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
- const call = vi.mocked(fetch).mock.calls.find(([url]) => String(url).includes('/approval-requests'))
+ const call = vi
+ .mocked(fetch)
+ .mock.calls.find(([url]) => String(url).includes('/approval-requests'))
expect(call?.[1]?.method).toBe('POST')
})
@@ -373,7 +473,9 @@ describe('CaseDetailPage', () => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
await waitFor(() => {
- const cancelCall = vi.mocked(fetch).mock.calls.find(([url]) => String(url).includes('/tasks/T-1/cancel'))
+ const cancelCall = vi
+ .mocked(fetch)
+ .mock.calls.find(([url]) => String(url).includes('/tasks/T-1/cancel'))
expect(cancelCall).toBeDefined()
})
expect(await screen.findByText('업무를 취소했습니다.')).toBeInTheDocument()
@@ -437,7 +539,16 @@ describe('CaseDetailPage', () => {
mockTaskAndActivities({
status: 'APPROVED',
checklist_items: [
- { checklist_item_id: 'chk-1', item_code: 'passport', label: '여권 사본 확인', required: true, completed: true, completed_by: null, completed_at: null, version: 1 },
+ {
+ checklist_item_id: 'chk-1',
+ item_code: 'passport',
+ label: '여권 사본 확인',
+ required: true,
+ completed: true,
+ completed_by: null,
+ completed_at: null,
+ version: 1,
+ },
],
})
renderPage()
@@ -449,11 +560,19 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByRole('button', { name: '접수번호' }))
await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032')
await user.click(screen.getByLabelText('실제 제출은 담당자가 직접 수행했습니다.'))
- await user.click(within(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).getByRole('button', { name: '완료 처리' }))
+ await user.click(
+ within(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).getByRole('button', {
+ name: '완료 처리',
+ }),
+ )
expect(screen.getByText('업무를 완료했습니다.')).toBeInTheDocument()
- expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/evidence'))).toBe(true)
- expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/complete'))).toBe(true)
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/evidence'))).toBe(
+ true,
+ )
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/complete'))).toBe(
+ true,
+ )
})
it('issues the security link through the API and shows the real URL', async () => {
@@ -469,7 +588,11 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByRole('button', { name: '새 링크 생성' }))
expect(screen.getByRole('dialog', { name: '새 링크가 준비되었습니다' })).toBeInTheDocument()
- expect(screen.getByText('http://localhost:3000/worker-portal/worker-token-1')).toBeInTheDocument()
- expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-link'))).toBe(true)
+ expect(
+ screen.getByText('http://localhost:3000/worker-portal/worker-token-1'),
+ ).toBeInTheDocument()
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-link'))).toBe(
+ true,
+ )
})
})
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx
index 085af44..a469628 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.tsx
+++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
-import { Link, useParams } from 'react-router-dom'
+import { useParams } from 'react-router-dom'
import {
approveTask,
buildTaskApprovalSnapshot,
@@ -10,7 +10,11 @@ import {
type EvidenceType,
} from '../../api/approvals'
import { fetchTaskActivities } from '../../api/audit'
-import { fetchDocumentReadiness, fetchDocuments, upsertDocumentRequestDraft } from '../../api/documents'
+import {
+ fetchDocumentReadiness,
+ fetchDocuments,
+ upsertDocumentRequestDraft,
+} from '../../api/documents'
import { ApiError, getErrorMessage } from '../../api/errors'
import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks'
import { issueWorkerLink, resolveWorkerPortalUrl } from '../../api/workerLinks'
@@ -30,13 +34,7 @@ import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../ut
import { getDocumentViewModel } from '../../view-models/documentViewModel'
import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import styles from './CaseDetailPage.module.css'
-import {
- AGENT_SUMMARY,
- CASE_COMMUNICATION,
- CASE_TABS,
- CONTEXT_ACCESS,
- CONTEXT_DRAWER,
-} from './caseDetailData'
+import { CASE_COMMUNICATION, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData'
import { ApprovalDecisionModal } from './overlays/ApprovalDecisionModal'
import { ApprovalRequestModal } from './overlays/ApprovalRequestModal'
import { ExternalCompletionModal } from './overlays/ExternalCompletionModal'
@@ -84,7 +82,12 @@ export function CaseDetailPage() {
const showToast = useToastStore((state) => state.showToast)
const taskFetcher = useCallback(() => fetchTaskById(taskId ?? ''), [taskId])
- const { status: taskStatus, data: task, error: taskError, refetch: refetchTask } = useApiQuery(taskFetcher)
+ const {
+ status: taskStatus,
+ data: task,
+ error: taskError,
+ refetch: refetchTask,
+ } = useApiQuery(taskFetcher)
const activitiesFetcher = useCallback(() => fetchTaskActivities(taskId ?? ''), [taskId])
const { data: activities } = useApiQuery(activitiesFetcher)
@@ -210,7 +213,11 @@ export function CaseDetailPage() {
setMoreMenuOpen((open) => !open)
}
- async function handleToggleChecklistItem(itemId: string, completed: boolean, itemVersion: number) {
+ async function handleToggleChecklistItem(
+ itemId: string,
+ completed: boolean,
+ itemVersion: number,
+ ) {
if (!task) return
setTogglingItemId(itemId)
try {
@@ -221,7 +228,9 @@ export function CaseDetailPage() {
})
await refetchTask()
} catch (error) {
- showToast(error instanceof ApiError ? getErrorMessage(error) : '체크리스트를 수정하지 못했습니다.')
+ showToast(
+ error instanceof ApiError ? getErrorMessage(error) : '체크리스트를 수정하지 못했습니다.',
+ )
} finally {
setTogglingItemId(null)
}
@@ -284,7 +293,9 @@ export function CaseDetailPage() {
setLinkOverlay('reissued')
showToast('보안 링크를 발급했습니다. 아직 자동 전송되지는 않았습니다.')
} catch (error) {
- showToast(error instanceof ApiError ? getErrorMessage(error) : '보안 링크를 발급하지 못했습니다.')
+ showToast(
+ error instanceof ApiError ? getErrorMessage(error) : '보안 링크를 발급하지 못했습니다.',
+ )
} finally {
setActionPending(false)
}
@@ -309,7 +320,9 @@ export function CaseDetailPage() {
@@ -324,7 +337,10 @@ export function CaseDetailPage() {
const checklistReady = completedRequiredChecklist === requiredChecklist.length
const informationReady = task.missing_required_slots.length === 0
const documentsReady = readiness ? !readiness.completion_blocked : false
- const approvalReady = task.status === 'APPROVED' || task.status === 'WAITING_WORKER' || task.status === 'WAITING_EXTERNAL'
+ const approvalReady =
+ task.status === 'APPROVED' ||
+ task.status === 'WAITING_WORKER' ||
+ task.status === 'WAITING_EXTERNAL'
const canRequestApproval =
(task.status === 'DRAFT' || task.status === 'NEEDS_INFO') &&
checklistReady &&
@@ -337,13 +353,46 @@ export function CaseDetailPage() {
!informationReady && '필수 정보',
!documentsReady && '서류 준비',
].filter(Boolean) as string[]
+ const firstIncompleteChecklistIndex = task.checklist_items.findIndex((item) => !item.completed)
+ const agentHeadline =
+ checklistReady && informationReady && documentsReady
+ ? `${task.title} 검토 준비가 완료되었습니다.`
+ : `${task.title}에 필요한 항목을 확인했습니다.`
+ const agentBody = completionBlockers.length
+ ? `${completionBlockers.join(' · ')} 확인이 필요합니다.`
+ : (task.description ?? '필수 항목과 서류가 모두 준비되었습니다.')
+
+ function handleAgentAction() {
+ if (!task) return
+ if (task.status === 'READY_FOR_REVIEW') {
+ handleOpenReview()
+ return
+ }
+ if (!documentsReady) {
+ setActiveTab('문서')
+ return
+ }
+ setActiveTab('체크리스트')
+ }
return (
-
-
- ← 업무함
-
+
+
+
{task.title}
+
+ {taskDue.display} · {TASK_SOURCE_LABEL[task.source]} · {TASK_STATUS_LABEL[task.status]}
+
+
+
+
+ {TASK_STATUS_LABEL[task.status]}
+
+ {approvalBadge && (
+ {approvalBadge.label}
+ )}
+ {TASK_SOURCE_LABEL[task.source]}
+
-
+
취소
@@ -376,16 +430,6 @@ export function CaseDetailPage() {
-
-
{task.title}
- {TASK_STATUS_LABEL[task.status]}
- {approvalBadge && {approvalBadge.label} }
- {TASK_SOURCE_LABEL[task.source]}
-
-
- {taskDue.display} · {task.workflow_id}
-
-
-
{CONTEXT_ACCESS.label}
+
Agent가 참고한 정보
- {CONTEXT_ACCESS.rows.map((row) => (
-
- {row.label} {row.value}
-
-
- ))}
+ 출처 · {TASK_SOURCE_LABEL[task.source]}
+
+ 필수 항목 · {completedRequiredChecklist}/{requiredChecklist.length}
+
+ 필수 정보 ·{' '}
+ {informationReady ? '확인' : `${task.missing_required_slots.length}개 부족`}
+
+ 필요 서류 · {documentsReady ? '확인' : '보완 필요'}
펼쳐 보기 →
@@ -422,51 +475,85 @@ export function CaseDetailPage() {
-
현재 업무 상태
-
- {TASK_STATUS_LABEL[task.status]}
-
-
-
- 서버에 저장된 현재 Task와 체크리스트만 표시합니다. 고정된 예시 단계는 사용하지 않습니다.
-
-
-
-
-
-
+
업무 진행
+
현재 · {TASK_STATUS_LABEL[task.status]}
+ {task.checklist_items.length === 0 ? (
+
이 업무에 등록된 진행 항목이 없습니다.
+ ) : (
+
+ {task.checklist_items.map((item, index) => {
+ const isCurrent = !item.completed && index === firstIncompleteChecklistIndex
+ return (
+
+
+
+ {item.completed ? '✓' : index + 1}
+
+ {index < task.checklist_items.length - 1 && (
+
+ )}
+
+
+
+
{item.label}
+
+ {item.required ? '필수 항목' : '선택 항목'}
+
+
+
+ {item.completed ? '완료' : isCurrent ? '현재 · 확인 필요' : '대기'}
+
+
+
+ )
+ })}
+
+ )}
{approvalReady && (
-
+
근로자 보안 링크 발급·재발급 →
)}
-
-
완료 조건
-
현재 서버 상태와 필수 조건을 기준으로 확인합니다.
+
+
완료까지 필요한 조건
+
현재 진행을 막는 조건을 먼저 확인하세요.
+
{canComplete ? (
-
+
완료 처리 시작 →
) : task.status === 'COMPLETED' ? (
완료 처리되었습니다.
) : (
- 완료 처리 불가 · {completionBlockers.join(' · ') || '현재 상태 확인 필요'}
+ 완료 처리 불가 · {completionBlockers.join(' · ') || '현재 상태 확인 필요'} 확인이
+ 필요합니다.
)}
@@ -494,9 +597,18 @@ export function CaseDetailPage() {
)}
{activeTab === '체크리스트' && (
-
+
{task.checklist_items.length === 0 ? (
-
+
) : (
{task.checklist_items.map((item) => (
@@ -533,21 +645,38 @@ export function CaseDetailPage() {
)}
{activeTab === '문서' && (
-
+
{documentsStatus === 'loading' && (
-
+
)}
{documentsStatus === 'error' && (
)}
{documentsStatus === 'empty' && (
-
+
)}
{documentsStatus === 'success' && (
@@ -564,7 +693,11 @@ export function CaseDetailPage() {
)}
{readiness && (readiness.missing.length > 0 || readiness.expired.length > 0) && (
-
+
요청 초안 저장 →
)}
@@ -572,7 +705,12 @@ export function CaseDetailPage() {
)}
{activeTab === '소통' && (
-
+
{/* TODO(backend): GET /api/work-items/:id/communication -> CASE_COMMUNICATION 대체 */}
{CASE_COMMUNICATION.map((entry) => (
@@ -587,9 +725,18 @@ export function CaseDetailPage() {
)}
{activeTab === '활동이력' && (
-
+
{activityRows.length === 0 ? (
-
+
) : (
{activityRows.map((entry, index) => (
@@ -622,20 +769,29 @@ export function CaseDetailPage() {
: '다음 행동 · 필수 조건 확인 후 승인 요청'}
{task.status === 'READY_FOR_REVIEW' && (
- 승인 검토
+
+ 승인 검토
+
)}
{(task.status === 'DRAFT' || task.status === 'NEEDS_INFO') && (
-
+
승인 요청
)}
{canComplete && (
- 완료 처리
+
+ 완료 처리
+
)}
- 승인·반려·완료 결과는 서버 응답 후 Task를 다시 조회해 반영합니다. 화면에서 성공 상태를 임의로 만들지 않습니다.
+ {approvalReady
+ ? '실제 전달과 외부 제출은 담당자가 직접 수행하고 결과를 증빙으로 남깁니다.'
+ : '승인 전에는 근로자 링크 전달이나 외부 처리를 시작할 수 없습니다.'}
setLinkOverlay('none')}
/>
- setContextDrawerOpen(false)} title="관련 Context">
+ setContextDrawerOpen(false)}
+ title="관련 Context"
+ >
{/* TODO(backend): GET /api/work-items/:id/context -> CONTEXT_DRAWER 대체 */}
Agent가 확인한 내용
diff --git a/src/pages/LinkExpiredPage/LinkExpiredPage.module.css b/src/pages/LinkExpiredPage/LinkExpiredPage.module.css
index c7a2145..70369a3 100644
--- a/src/pages/LinkExpiredPage/LinkExpiredPage.module.css
+++ b/src/pages/LinkExpiredPage/LinkExpiredPage.module.css
@@ -1,7 +1,7 @@
.iconWrap {
display: flex;
justify-content: center;
- margin-top: 30px;
+ margin-top: 22px;
}
.icon {
@@ -18,7 +18,7 @@
}
.headline {
- margin: 30px 0 0;
+ margin: 40px 0 0;
font-size: 24px;
font-weight: 700;
text-align: center;
@@ -33,7 +33,8 @@
}
.reasonCard {
- margin-top: 30px;
+ min-height: 96px;
+ margin-top: 47px;
padding: 16px 20px;
background: var(--surface-subtle);
border-radius: var(--fowoco-radius-8);
@@ -54,14 +55,15 @@
}
.sectionLabel {
- margin: 30px 0 0;
+ margin: 43px 0 0;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.contactCard {
- margin-top: 12px;
+ min-height: 108px;
+ margin-top: 16px;
padding: 15px 19px;
background: var(--surface-default);
border: 1px solid var(--border-default);
@@ -84,7 +86,7 @@
.copyButton {
width: 100%;
height: 52px;
- margin-top: 30px;
+ margin-top: 36px;
background: var(--brand-primary);
border: none;
border-radius: var(--fowoco-radius-6);
@@ -95,7 +97,7 @@
}
.footnote {
- margin: 12px 0 0;
+ margin: 30px 0 0;
font-size: 12px;
text-align: center;
line-height: 20px;
diff --git a/src/pages/LinkExpiredPage/LinkExpiredPage.test.tsx b/src/pages/LinkExpiredPage/LinkExpiredPage.test.tsx
index 6bf20b0..8ec47f8 100644
--- a/src/pages/LinkExpiredPage/LinkExpiredPage.test.tsx
+++ b/src/pages/LinkExpiredPage/LinkExpiredPage.test.tsx
@@ -7,7 +7,7 @@ describe('LinkExpiredPage', () => {
render(
)
expect(screen.getByText('이 링크는 만료되었습니다.')).toBeInTheDocument()
- expect(screen.getByText('한빛정밀 인사팀 · 김경민')).toBeInTheDocument()
+ expect(screen.getByText('회사 인사팀 담당자')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '재발급 요청 문구 복사' })).toBeInTheDocument()
})
})
diff --git a/src/pages/LinkExpiredPage/LinkExpiredPage.tsx b/src/pages/LinkExpiredPage/LinkExpiredPage.tsx
index e9ff92c..f8e9011 100644
--- a/src/pages/LinkExpiredPage/LinkExpiredPage.tsx
+++ b/src/pages/LinkExpiredPage/LinkExpiredPage.tsx
@@ -9,7 +9,9 @@ export function LinkExpiredPage() {
async function handleCopyRequest() {
try {
- await navigator.clipboard.writeText('기존 FOWOCO 제출 링크를 사용할 수 없습니다. 새 링크를 보내 주세요.')
+ await navigator.clipboard.writeText(
+ '기존 FOWOCO 제출 링크를 사용할 수 없습니다. 새 링크를 보내 주세요.',
+ )
setCopied(true)
} catch {
setCopied(false)
@@ -29,13 +31,18 @@ export function LinkExpiredPage() {
링크 상태 · 만료
이 링크는 만료됐거나 새 링크 발급으로 폐기되었습니다.
- {token ? <> 기존 링크로는 제출할 수 없습니다.> : null}
+ {token ? (
+ <>
+
+ 기존 링크로는 제출할 수 없습니다.
+ >
+ ) : null}
담당자에게 요청하는 방법
-
한빛정밀 인사팀 · 김경민
+
회사 인사팀 담당자
기존에 안내를 받은 문자 또는 메신저로 요청하세요.
@@ -45,8 +52,7 @@ export function LinkExpiredPage() {
새 링크가 발급되면 이전 링크는 즉시 폐기됩니다.
-
- 이 버튼은 메시지를 자동 발송하지 않습니다.
+ 이 버튼은 메시지를 자동 발송하지 않습니다.
)
diff --git a/src/pages/LinkRequestPage/LinkRequestPage.module.css b/src/pages/LinkRequestPage/LinkRequestPage.module.css
index 69c5dfd..b30398b 100644
--- a/src/pages/LinkRequestPage/LinkRequestPage.module.css
+++ b/src/pages/LinkRequestPage/LinkRequestPage.module.css
@@ -1,4 +1,5 @@
.expiryNotice {
+ min-height: 64px;
padding: 10px 20px;
background: var(--fowoco-amber-50);
border-radius: var(--fowoco-radius-8);
@@ -53,6 +54,7 @@
}
.privacy {
+ min-height: 96px;
margin: 30px 0 0;
padding: 16px 20px;
background: var(--surface-subtle);
@@ -80,6 +82,7 @@
}
.secondary {
+ width: 100%;
height: 48px;
background: var(--surface-default);
border: 1px solid var(--border-default);
@@ -91,6 +94,7 @@
}
.primary {
+ width: 100%;
height: 52px;
background: var(--brand-primary);
border: none;
diff --git a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx
index 6ded607..8d9ed3f 100644
--- a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx
+++ b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx
@@ -5,13 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LinkRequestPage } from './LinkRequestPage'
function jsonResponse(body: unknown, status = 200) {
- return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
}
const VIEW = {
guidance: '여권 사진면 사본을 제출해 주세요.',
due_date: '2026-08-10',
- allowed_responses: ['QUESTION', 'DOCUMENT_SUBMITTED'],
+ allowed_responses: ['ACKNOWLEDGED', 'QUESTION', 'DOCUMENT_SUBMITTED'],
}
beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
@@ -19,7 +22,7 @@ afterEach(() => vi.unstubAllGlobals())
describe('LinkRequestPage', () => {
it('renders guidance loaded from the public token API', async () => {
- vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(VIEW))
+ vi.mocked(fetch).mockResolvedValue(jsonResponse(VIEW))
render(
@@ -34,7 +37,7 @@ describe('LinkRequestPage', () => {
it('keeps the token when navigating to the upload page', async () => {
const user = userEvent.setup()
- vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(VIEW))
+ vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse(VIEW)))
render(
@@ -44,15 +47,23 @@ describe('LinkRequestPage', () => {
,
)
- await user.click(await screen.findByRole('button', { name: '서류 제출하기' }))
+ await user.click(await screen.findByRole('button', { name: '안내를 확인했습니다' }))
expect(screen.getByText('upload screen')).toBeInTheDocument()
+ const responseCall = vi
+ .mocked(fetch)
+ .mock.calls.find(([url]) => String(url).endsWith('/responses'))
+ expect(JSON.parse(responseCall?.[1]?.body as string)).toMatchObject({
+ response_type: 'ACKNOWLEDGED',
+ })
})
it('shows an explicit state when opened without a token', () => {
render(
- } />
+
+ } />
+
,
)
diff --git a/src/pages/LinkRequestPage/LinkRequestPage.tsx b/src/pages/LinkRequestPage/LinkRequestPage.tsx
index 3ddc900..920ab41 100644
--- a/src/pages/LinkRequestPage/LinkRequestPage.tsx
+++ b/src/pages/LinkRequestPage/LinkRequestPage.tsx
@@ -29,6 +29,7 @@ export function LinkRequestPage() {
function WorkerLinkRequest({ token }: { token: string }) {
const navigate = useNavigate()
const [submittingQuestion, setSubmittingQuestion] = useState(false)
+ const [submittingAcknowledgement, setSubmittingAcknowledgement] = useState(false)
const [questionSent, setQuestionSent] = useState(false)
const [responseError, setResponseError] = useState(null)
const fetcher = useCallback(() => fetchWorkerLink(token), [token])
@@ -51,16 +52,47 @@ function WorkerLinkRequest({ token }: { token: string }) {
})
setQuestionSent(true)
} catch (caught) {
- setResponseError(caught instanceof ApiError ? getErrorMessage(caught) : '응답을 보내지 못했습니다.')
+ setResponseError(
+ caught instanceof ApiError ? getErrorMessage(caught) : '응답을 보내지 못했습니다.',
+ )
} finally {
setSubmittingQuestion(false)
}
}
+ async function handleAcknowledgement() {
+ if (submittingAcknowledgement) return
+ setSubmittingAcknowledgement(true)
+ setResponseError(null)
+ try {
+ if (data?.allowed_responses.includes('ACKNOWLEDGED')) {
+ await submitWorkerResponse(token, {
+ response_type: 'ACKNOWLEDGED',
+ idempotency_key: crypto.randomUUID(),
+ })
+ }
+ navigate(`/worker-portal/${encodeURIComponent(token)}/upload`)
+ } catch (caught) {
+ if (caught instanceof ApiError && caught.status === 410) {
+ navigate(`/worker-portal/${encodeURIComponent(token)}/expired`, { replace: true })
+ return
+ }
+ setResponseError(
+ caught instanceof ApiError ? getErrorMessage(caught) : '확인 응답을 보내지 못했습니다.',
+ )
+ } finally {
+ setSubmittingAcknowledgement(false)
+ }
+ }
+
if (status === 'loading') {
return (
보안 링크}>
-
+
)
}
@@ -71,7 +103,9 @@ function WorkerLinkRequest({ token }: { token: string }) {
@@ -80,7 +114,9 @@ function WorkerLinkRequest({ token }: { token: string }) {
}
const due = getOperationalDateViewModel('TASK_DUE', data.due_date)
- const canUpload = data.allowed_responses.includes('DOCUMENT_SUBMITTED')
+ const canContinue =
+ data.allowed_responses.includes('ACKNOWLEDGED') ||
+ data.allowed_responses.includes('DOCUMENT_SUBMITTED')
const canAskQuestion = data.allowed_responses.includes('QUESTION')
return (
@@ -90,7 +126,7 @@ function WorkerLinkRequest({ token }: { token: string }) {
이 화면을 닫아도 같은 링크로 다시 열 수 있습니다.
-
서류 제출 요청
+
회사 인사팀 요청
요청 내용을
@@ -108,13 +144,19 @@ function WorkerLinkRequest({ token }: { token: string }) {
{questionSent &&
담당자에게 질문 의사를 전했습니다.
}
- {responseError &&
{responseError}
}
+ {responseError && (
+
+ {responseError}
+
+ )}
{questionSent ? '질문 의사 전송됨' : '질문이 있습니다'}
@@ -122,14 +164,14 @@ function WorkerLinkRequest({ token }: { token: string }) {
navigate(`/worker-portal/${encodeURIComponent(token)}/upload`)}
+ disabled={!canContinue || submittingAcknowledgement}
+ onClick={handleAcknowledgement}
>
- 서류 제출하기
+ {submittingAcknowledgement ? '확인 중…' : '안내를 확인했습니다'}
-
제출 결과는 담당자에게 전달되며 같은 요청의 중복 제출은 차단됩니다.
+
다음 화면에서 요청받은 파일을 선택해 제출할 수 있습니다.
)
}
diff --git a/src/pages/LinkUploadPage/LinkUploadPage.module.css b/src/pages/LinkUploadPage/LinkUploadPage.module.css
index 168dce9..9c40bc9 100644
--- a/src/pages/LinkUploadPage/LinkUploadPage.module.css
+++ b/src/pages/LinkUploadPage/LinkUploadPage.module.css
@@ -1,5 +1,5 @@
.headline {
- margin: 30px 0 0;
+ margin: 6px 0 0;
font-size: 26px;
font-weight: 700;
line-height: 36px;
@@ -7,7 +7,7 @@
}
.subtext {
- margin: 16px 0 0;
+ margin: 12px 0 0;
font-size: 15px;
line-height: 25px;
color: var(--text-secondary);
@@ -15,7 +15,8 @@
.dropzone {
width: 100%;
- margin-top: 30px;
+ min-height: 148px;
+ margin-top: 20px;
padding: 20px;
background: var(--surface-default);
border: 2px solid var(--brand-primary);
@@ -108,7 +109,8 @@
display: flex;
align-items: center;
justify-content: space-between;
- margin-top: 24px;
+ min-height: 72px;
+ margin-top: 16px;
padding: 12px 16px;
background: var(--fowoco-green-50);
border-radius: var(--fowoco-radius-8);
@@ -137,7 +139,7 @@
}
.helpLabel {
- margin: 20px 0 0;
+ margin: 24px 0 0;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
@@ -146,7 +148,7 @@
.helpLinks {
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: 8px;
margin-top: 12px;
}
@@ -177,7 +179,7 @@
.submit {
width: 100%;
height: 52px;
- margin-top: 24px;
+ margin-top: 32px;
background: var(--brand-primary);
border: none;
border-radius: var(--fowoco-radius-6);
diff --git a/src/pages/LinkUploadPage/LinkUploadPage.tsx b/src/pages/LinkUploadPage/LinkUploadPage.tsx
index 15a5836..db17880 100644
--- a/src/pages/LinkUploadPage/LinkUploadPage.tsx
+++ b/src/pages/LinkUploadPage/LinkUploadPage.tsx
@@ -86,7 +86,9 @@ export function LinkUploadPage() {
navigate(`/worker-portal/${encodeURIComponent(token)}/expired`, { replace: true })
return
}
- setFileError(caught instanceof ApiError ? getErrorMessage(caught) : '파일을 제출하지 못했습니다.')
+ setFileError(
+ caught instanceof ApiError ? getErrorMessage(caught) : '파일을 제출하지 못했습니다.',
+ )
} finally {
setSubmitting(false)
}
@@ -103,7 +105,9 @@ export function LinkUploadPage() {
})
setResponseMessage(successMessage)
} catch (caught) {
- setFileError(caught instanceof ApiError ? getErrorMessage(caught) : '응답을 보내지 못했습니다.')
+ setFileError(
+ caught instanceof ApiError ? getErrorMessage(caught) : '응답을 보내지 못했습니다.',
+ )
} finally {
setSubmitting(false)
}
@@ -112,7 +116,11 @@ export function LinkUploadPage() {
if (!token) {
return (
navigate(-1)}>
-
+
)
}
@@ -120,7 +128,11 @@ export function LinkUploadPage() {
if (status === 'loading') {
return (
navigate(-1)}>
-
+
)
}
@@ -131,7 +143,9 @@ export function LinkUploadPage() {
@@ -155,18 +169,28 @@ export function LinkUploadPage() {
const canSubmitDocument = data.allowed_responses.includes('DOCUMENT_SUBMITTED')
const helpResponses: Array<{ label: string; type: WorkerResponseType; message: string }> = [
{ label: HELP_LINKS[0], type: 'QUESTION', message: '담당자에게 질문 의사를 전했습니다.' },
- { label: HELP_LINKS[1], type: 'NOT_UNDERSTOOD', message: '담당자에게 추가 설명을 요청했습니다.' },
- { label: HELP_LINKS[2], type: 'DIFFICULT', message: '담당자에게 처리 어려움 상태를 전했습니다.' },
+ {
+ label: HELP_LINKS[1],
+ type: 'NOT_UNDERSTOOD',
+ message: '담당자에게 추가 설명을 요청했습니다.',
+ },
+ {
+ label: HELP_LINKS[2],
+ type: 'DIFFICULT',
+ message: '담당자에게 처리 어려움 상태를 전했습니다.',
+ },
]
return (
-
navigate(-1)} right={보안 링크 }>
+ navigate(-1)} right={1 / 1 }>
사진 또는 파일을
추가해 주세요
- 여권 사진면 전체가 보이고 글자가 흐리지 않은지 확인해 주세요.
+
+ 여권 사진면 전체가 보이고 글자가 흐리지 않은지 확인해 주세요.
+
- {fileError && {fileError}
}
+ {fileError && (
+
+ {fileError}
+
+ )}
{responseMessage && {responseMessage}
}
{file && (
{file.name}
-
- {formatFileSize(file.size)} · 제출 전
-
+
{formatFileSize(file.size)} · 제출 전 확인
삭제
@@ -229,9 +255,7 @@ export function LinkUploadPage() {
{submitting ? '제출 중…' : '서류 제출'}
-
- 업로드가 끝난 뒤 제출 응답까지 접수되어야 담당자 화면에 반영됩니다.
-
+
제출한 파일은 회사 인사팀 담당자가 확인합니다.
)
}
diff --git a/src/pages/WorkListPage/WorkInboxDetail.tsx b/src/pages/WorkListPage/WorkInboxDetail.tsx
index adcb0ea..a5a4927 100644
--- a/src/pages/WorkListPage/WorkInboxDetail.tsx
+++ b/src/pages/WorkListPage/WorkInboxDetail.tsx
@@ -33,7 +33,8 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
const progress = getWorkInboxCaseProgress(activeCase)
const due = getDuePresentation(activeTask.task.due_date)
const activeStatus = getTaskStatusPresentation(activeTask.task.status)
- const headerStatus = due.tone === 'critical' ? { label: '긴급', tone: 'critical' as const } : activeStatus
+ const headerStatus =
+ due.tone === 'critical' ? { label: '긴급', tone: 'critical' as const } : activeStatus
const reviewTasks = group.tasks.filter((item) => isReviewTask(item.task.status))
const detailTitleId = `work-inbox-detail-${group.worker.worker_id}`
@@ -60,8 +61,12 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
{headerStatus.label}
+
+ Agent 제안 · {due.label}, {getWorkflowLabel(activeTask)} 확인 필요
+
+
-
+
우선 Case {Math.max(activeCaseIndex, 0) + 1}/{group.cases.length}
@@ -73,27 +78,29 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
Case 열기 →
- {activeCase.caseId &&
Case {activeCase.caseId}
}
-
{activeTask.task.title}
-
- {getWorkflowLabel(activeTask)} · {due.label} · {activeStatus.label}
-
-
-
- 진행 {progress.completed}/{progress.total}
-
-
+
+ {activeCase.caseId &&
Case {activeCase.caseId}
}
+
{activeTask.task.title}
+
+ {getWorkflowLabel(activeTask)} · {due.label} · {activeStatus.label}
+
+
+
+ 진행 {progress.completed}/{progress.total}
+
+
+
+ {group.cases.length > 1 && (
+
+ 다른 Case 열기 →
+
+ )}
- {group.cases.length > 1 && (
-
- 다른 Case 열기 →
-
- )}
@@ -135,22 +142,24 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
-
+
현재 결정
- {activeStatus.label}
+
+ 근거 보기 →
+
+
+
+
+
{getDecisionSummary(activeTask.task.status)}
+
{activeStatus.label}
+
+
+ 진행 업무 건 {group.cases.length}개 · 확인할 업무 {reviewTasks.length}개 · 자동 확정되지
+ 않음
+
-
{getDecisionSummary(activeTask.task.status)}
-
- 진행 Case {group.cases.length}개 · 확인할 업무 {reviewTasks.length}개
-
-
- 근거 보기 →
-
-
- 판단 근거와 문서 연결 정보는 현재 API에서 제공되지 않습니다.
-
)
diff --git a/src/pages/WorkListPage/WorkInboxTargetList.tsx b/src/pages/WorkListPage/WorkInboxTargetList.tsx
index 0dcbb88..5460abf 100644
--- a/src/pages/WorkListPage/WorkInboxTargetList.tsx
+++ b/src/pages/WorkListPage/WorkInboxTargetList.tsx
@@ -77,7 +77,6 @@ export function WorkInboxTargetList({
{group.worker.display_name}
{status.label}
-
{task.task.title}
{getWorkflowLabel(task)} · {due.label}
diff --git a/src/pages/WorkListPage/WorkListPage.module.css b/src/pages/WorkListPage/WorkListPage.module.css
index ce2d8f1..297dd57 100644
--- a/src/pages/WorkListPage/WorkListPage.module.css
+++ b/src/pages/WorkListPage/WorkListPage.module.css
@@ -1,17 +1,18 @@
.page {
width: 100%;
- margin: 0;
+ margin: -20px 0 0;
}
.headline {
margin: 0;
- font-size: 26px;
+ font-size: 24px;
font-weight: 700;
+ line-height: 32px;
color: var(--text-primary);
}
.description {
- margin: 6px 0 0;
+ margin: 5px 0 0;
font-size: 14px;
line-height: 1.5;
color: var(--text-secondary);
@@ -22,7 +23,19 @@
align-items: center;
justify-content: space-between;
gap: var(--fowoco-spacing-16);
- margin-top: var(--fowoco-spacing-20);
+ min-height: 58px;
+ margin-top: var(--fowoco-spacing-12);
+}
+
+.searchInput {
+ height: 40px;
+ max-width: none;
+ padding: 0 14px;
+ font-size: 13px;
+}
+
+.sortDropdown button {
+ height: 40px;
}
.stateWrap {
@@ -60,11 +73,11 @@
.workspace {
display: grid;
- grid-template-columns: minmax(230px, 29%) minmax(0, 1fr);
+ grid-template-columns: clamp(280px, 28.82%, 332px) minmax(0, 1fr);
min-height: 520px;
- height: calc(100svh - 244px);
- max-height: 720px;
- margin-top: var(--fowoco-spacing-24);
+ height: calc(100svh - 208px);
+ max-height: 712px;
+ margin-top: 0;
overflow: hidden;
background: var(--surface-default);
border: 1px solid var(--border-default);
@@ -80,8 +93,9 @@
min-height: 0;
display: flex;
flex-direction: column;
+ padding: var(--fowoco-spacing-16);
border-right: 1px solid var(--border-default);
- background: var(--surface-default);
+ background: var(--surface-page);
}
.listHeader {
@@ -90,14 +104,14 @@
align-items: baseline;
justify-content: space-between;
gap: var(--fowoco-spacing-8);
- min-height: 56px;
- padding: 17px 16px 13px;
- border-bottom: 1px solid var(--border-default);
+ min-height: 28px;
+ padding: 0;
}
.listTitle {
margin: 0;
- font-size: 15px;
+ font-size: 18px;
+ line-height: 24px;
font-weight: 700;
color: var(--text-primary);
}
@@ -110,9 +124,10 @@
.capNotice {
flex-shrink: 0;
- margin: 0;
- padding: 10px 16px;
- border-bottom: 1px solid var(--border-default);
+ margin: var(--fowoco-spacing-8) 0 0;
+ padding: 8px 10px;
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
background: var(--fowoco-amber-50);
font-size: 11px;
line-height: 1.5;
@@ -125,15 +140,16 @@
flex: 1;
flex-direction: column;
gap: var(--fowoco-spacing-8);
- padding: var(--fowoco-spacing-12);
+ margin-top: var(--fowoco-spacing-12);
+ padding: 0;
overflow-y: auto;
}
.targetOption {
position: relative;
width: 100%;
- min-height: 84px;
- padding: 12px 13px;
+ min-height: 80px;
+ padding: 12px 16px;
overflow: hidden;
text-align: left;
background: var(--surface-default);
@@ -157,10 +173,21 @@
}
.targetOptionSelected {
- padding-left: 16px;
+ padding-left: 29px;
border-color: var(--brand-primary);
background: var(--fowoco-teal-50);
- box-shadow: inset 3px 0 0 var(--brand-primary);
+ box-shadow: none;
+}
+
+.targetOptionSelected::before {
+ position: absolute;
+ top: 12px;
+ bottom: 12px;
+ left: 16px;
+ width: 3px;
+ border-radius: 2px;
+ background: var(--brand-primary);
+ content: '';
}
.targetOptionTop {
@@ -173,14 +200,13 @@
.targetName {
min-width: 0;
overflow: hidden;
- font-size: 14px;
- font-weight: 700;
+ font-size: 15px;
+ font-weight: 500;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
-.targetTaskTitle,
.targetMeta {
display: block;
overflow: hidden;
@@ -188,52 +214,77 @@
white-space: nowrap;
}
-.targetTaskTitle {
- margin-top: var(--fowoco-spacing-8);
- font-size: 12px;
- font-weight: 500;
- color: var(--text-primary);
-}
-
.targetMeta {
- margin-top: var(--fowoco-spacing-4);
- font-size: 11px;
+ margin-top: 2px;
+ font-size: 13px;
+ line-height: 20px;
color: var(--text-secondary);
}
.detailPanel {
min-width: 0;
min-height: 0;
- padding: var(--fowoco-spacing-24);
+ padding: var(--fowoco-spacing-16) var(--fowoco-spacing-24);
overflow-y: auto;
background: var(--surface-default);
}
.detailHeader {
display: flex;
- align-items: flex-start;
+ align-items: center;
justify-content: space-between;
gap: var(--fowoco-spacing-16);
+ min-height: 72px;
}
.detailName {
margin: 0;
- font-size: 22px;
+ font-size: 18px;
+ line-height: 26px;
font-weight: 700;
color: var(--text-primary);
}
.detailMeta {
- margin: 6px 0 0;
- font-size: 12px;
+ margin: 2px 0 0;
+ font-size: 13px;
+ line-height: 20px;
color: var(--text-secondary);
}
+.agentSuggestion {
+ min-height: 40px;
+ margin: 0;
+ padding: 9px 16px;
+ border-radius: var(--fowoco-radius-6);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 22px;
+ color: var(--brand-primary);
+}
+
.priorityCase {
- margin-top: var(--fowoco-spacing-24);
- padding: var(--fowoco-spacing-20);
+ margin-top: var(--fowoco-spacing-16);
+ padding: 0;
+ overflow: hidden;
border: 1px solid var(--border-default);
- border-radius: var(--fowoco-radius-8);
+ border-radius: var(--fowoco-radius-6);
+}
+
+.priorityCaseHeader,
+.decisionHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--fowoco-spacing-12);
+ min-height: 74px;
+ padding: 15px 20px;
+ background: var(--surface-subtle);
+ border-bottom: 1px solid var(--border-default);
+}
+
+.priorityCaseBody {
+ padding: 12px 16px;
}
.sectionHeadingRow {
@@ -246,13 +297,14 @@
.caseEyebrow,
.caseIdentifier {
margin: 0;
- font-size: 12px;
+ font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.caseIdentifier {
- margin-top: var(--fowoco-spacing-16);
+ margin: 0 0 var(--fowoco-spacing-4);
+ font-size: 12px;
font-weight: 500;
color: var(--brand-primary);
}
@@ -278,8 +330,9 @@
}
.caseTitle {
- margin: var(--fowoco-spacing-8) 0 0;
- font-size: 17px;
+ margin: 0;
+ font-size: 16px;
+ font-weight: 500;
line-height: 1.45;
color: var(--text-primary);
}
@@ -296,14 +349,14 @@
grid-template-columns: auto minmax(120px, 1fr);
align-items: center;
gap: var(--fowoco-spacing-12);
- margin-top: var(--fowoco-spacing-16);
- font-size: 11px;
+ margin-top: var(--fowoco-spacing-4);
+ font-size: 13px;
color: var(--text-secondary);
}
.progress {
width: 100%;
- height: 6px;
+ height: 8px;
overflow: hidden;
appearance: none;
border: none;
@@ -328,12 +381,12 @@
.detailSection,
.decisionSection {
- margin-top: var(--fowoco-spacing-24);
+ margin-top: var(--fowoco-spacing-16);
}
.sectionTitle {
margin: 0;
- font-size: 15px;
+ font-size: 17px;
font-weight: 700;
color: var(--text-primary);
}
@@ -364,8 +417,8 @@
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--fowoco-spacing-12);
- min-height: 72px;
- padding: 12px 14px;
+ min-height: 80px;
+ padding: 12px 16px;
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-6);
background: var(--surface-default);
@@ -378,8 +431,8 @@
.reviewTaskTitle {
margin: 0;
overflow: hidden;
- font-size: 13px;
- font-weight: 700;
+ font-size: 15px;
+ font-weight: 500;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
@@ -396,25 +449,38 @@
.reviewTaskButton {
flex-shrink: 0;
- height: 36px;
- padding: 6px 12px;
+ width: 120px;
+ height: 40px;
+ padding: 8px 16px;
font-size: 12px;
}
.decisionSection {
- padding-top: var(--fowoco-spacing-20);
- border-top: 1px solid var(--border-default);
+ overflow: hidden;
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+}
+
+.decisionBody {
+ padding: 8px 16px 12px;
+}
+
+.decisionStatusRow {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--fowoco-spacing-12);
}
.decisionSummary {
- margin: var(--fowoco-spacing-16) 0 0;
- font-size: 13px;
+ margin: 0;
+ font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
}
.decisionMeta {
- margin: var(--fowoco-spacing-8) 0 0;
+ margin: 2px 0 0;
font-size: 12px;
color: var(--text-secondary);
}
@@ -466,6 +532,17 @@
}
}
+@media (max-width: 1180px) {
+ .page {
+ margin-top: 0;
+ }
+
+ .workspace {
+ height: auto;
+ max-height: none;
+ }
+}
+
@media (max-width: 768px) {
.toolbar {
align-items: stretch;
diff --git a/src/pages/WorkListPage/WorkListPage.tsx b/src/pages/WorkListPage/WorkListPage.tsx
index 6c034ad..b23ee6c 100644
--- a/src/pages/WorkListPage/WorkListPage.tsx
+++ b/src/pages/WorkListPage/WorkListPage.tsx
@@ -194,13 +194,15 @@ export function WorkListPage() {
onChange={setQuery}
placeholder="근로자·Case·업무 검색"
ariaLabel="근로자·Case·업무 검색"
+ className={styles.searchInput}
/>
setSort(value as WorkInboxSort)}
ariaLabel="업무함 정렬"
- width="176px"
+ className={styles.sortDropdown}
+ width="133px"
/>
From ce9018b853a6c81eabbcf284075ca7895eb7e26b Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 17:59:26 +0900
Subject: [PATCH 07/10] =?UTF-8?q?feat(ui):=20PWF3=20=ED=99=88=EA=B3=BC=20?=
=?UTF-8?q?=EC=97=85=EB=AC=B4=ED=95=A8=20=ED=99=94=EB=A9=B4=20=EC=A0=95?=
=?UTF-8?q?=EB=A0=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/layout/AppLayout.module.css | 24 +
src/components/layout/AppLayout.test.tsx | 3 +-
src/components/layout/AppLayout.tsx | 8 +-
src/components/layout/navItems.ts | 6 +-
.../ui/SearchInput/SearchInput.module.css | 35 +-
src/components/ui/SearchInput/SearchInput.tsx | 18 +-
src/components/ui/SearchInput/search.svg | 3 +
.../ui/WorkItemRow/WorkItemRow.module.css | 87 ++-
src/components/ui/WorkItemRow/WorkItemRow.tsx | 36 +-
.../DashboardPage/DashboardPage.module.css | 573 ++++++++++++------
.../DashboardPage/DashboardPage.test.tsx | 57 +-
src/pages/DashboardPage/DashboardPage.tsx | 259 ++++----
.../DashboardPage/assets/agent-spark.svg | 4 +
.../DashboardPage/assets/command-submit.svg | 4 +
.../DashboardPage/assets/metric-approval.svg | 4 +
src/pages/DashboardPage/assets/metric-due.svg | 4 +
.../DashboardPage/assets/metric-info.svg | 4 +
.../DashboardPage/assets/metric-response.svg | 4 +
src/pages/DashboardPage/dashboardData.ts | 159 +++--
src/pages/WorkListPage/WorkInboxDetail.tsx | 225 ++++---
.../WorkListPage/WorkInboxTargetList.tsx | 1 -
.../WorkListPage/WorkListPage.module.css | 156 +++--
src/pages/WorkListPage/WorkListPage.test.tsx | 25 +-
src/pages/WorkListPage/WorkListPage.tsx | 7 +-
.../WorkListPage/workInboxPresentation.ts | 11 +-
25 files changed, 1154 insertions(+), 563 deletions(-)
create mode 100644 src/components/ui/SearchInput/search.svg
create mode 100644 src/pages/DashboardPage/assets/agent-spark.svg
create mode 100644 src/pages/DashboardPage/assets/command-submit.svg
create mode 100644 src/pages/DashboardPage/assets/metric-approval.svg
create mode 100644 src/pages/DashboardPage/assets/metric-due.svg
create mode 100644 src/pages/DashboardPage/assets/metric-info.svg
create mode 100644 src/pages/DashboardPage/assets/metric-response.svg
diff --git a/src/components/layout/AppLayout.module.css b/src/components/layout/AppLayout.module.css
index 52d2021..5d1a894 100644
--- a/src/components/layout/AppLayout.module.css
+++ b/src/components/layout/AppLayout.module.css
@@ -178,6 +178,22 @@
overflow-y: auto;
}
+.contentDashboard {
+ padding-top: 18px;
+}
+
+.contentWorkInbox {
+ padding-bottom: 0;
+ overflow: hidden;
+}
+
+@media (max-width: 1180px) {
+ .contentWorkInbox {
+ padding-bottom: 32px;
+ overflow-y: auto;
+ }
+}
+
@media (max-width: 768px) {
.topBar {
padding: 12px 16px;
@@ -189,4 +205,12 @@
padding: 16px;
overflow-y: visible;
}
+
+ .contentWorkInbox {
+ padding-bottom: 16px;
+ }
+
+ .contentDashboard {
+ padding-top: 16px;
+ }
}
diff --git a/src/components/layout/AppLayout.test.tsx b/src/components/layout/AppLayout.test.tsx
index 502fcea..d17708d 100644
--- a/src/components/layout/AppLayout.test.tsx
+++ b/src/components/layout/AppLayout.test.tsx
@@ -35,7 +35,7 @@ afterEach(() => {
})
describe('AppLayout', () => {
- it('renders the FOWOCO logo and every nav item', () => {
+ it('renders the current global navigation without a workers tab', () => {
localStorage.setItem('fowoco.onboarding.completed', 'true')
renderLayout()
@@ -43,6 +43,7 @@ describe('AppLayout', () => {
for (const item of NAV_ITEMS) {
expect(screen.getByRole('link', { name: item.label })).toBeInTheDocument()
}
+ expect(screen.queryByRole('link', { name: '근로자' })).not.toBeInTheDocument()
})
it('opens and closes the help modal', async () => {
diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx
index 3fd9957..9e9dabc 100644
--- a/src/components/layout/AppLayout.tsx
+++ b/src/components/layout/AppLayout.tsx
@@ -18,6 +18,8 @@ export function AppLayout() {
const [helpOpen, setHelpOpen] = useState(false)
const [tourOpen, setTourOpen] = useState(false)
const isTaskDetail = /^\/tasks\/[^/]+$/.test(location.pathname)
+ const isWorkInbox = location.pathname === '/tasks'
+ const isDashboard = location.pathname === '/dashboard'
useEffect(() => {
if (!hasCompletedOnboarding()) setTourOpen(true)
@@ -78,7 +80,11 @@ export function AppLayout() {
-
+
diff --git a/src/components/layout/navItems.ts b/src/components/layout/navItems.ts
index c48ece5..c6c56fa 100644
--- a/src/components/layout/navItems.ts
+++ b/src/components/layout/navItems.ts
@@ -2,7 +2,6 @@ import documentsIcon from './nav-icons/documents.svg'
import settingsIcon from './nav-icons/settings.svg'
import todayIcon from './nav-icons/today.svg'
import workIcon from './nav-icons/work.svg'
-import workersIcon from './nav-icons/workers.svg'
export interface NavItem {
label: string
@@ -10,13 +9,12 @@ export interface NavItem {
iconSrc: string
}
-// Figma PWF v3 사이드바(Aside - SideNavBar, 05_Desktop Core Product 전 화면 공통)는 5개 메뉴만
-// 정의한다. Agent/티켓 메뉴는 Figma에 없는 화면이라(#206) 링크만 제거하고 라우트는 유지한다.
+// 공용 사이드바에는 주요 운영 진입점만 노출한다. 근로자 기능은 업무·문서 흐름에서
+// 컨텍스트로 진입하므로 전역 탭에서는 제외하고, 딥링크와 관련 라우트는 유지한다.
// SettingsPage는 제거되어 "설정" 메뉴는 내 프로필 페이지로 연결된다.
export const NAV_ITEMS: NavItem[] = [
{ label: 'Today', to: '/dashboard', iconSrc: todayIcon },
{ label: '업무함', to: '/tasks', iconSrc: workIcon },
- { label: '근로자', to: '/workers', iconSrc: workersIcon },
{ label: '문서함', to: '/documents', iconSrc: documentsIcon },
{ label: '설정', to: '/profile', iconSrc: settingsIcon },
]
diff --git a/src/components/ui/SearchInput/SearchInput.module.css b/src/components/ui/SearchInput/SearchInput.module.css
index 434632f..7104eb1 100644
--- a/src/components/ui/SearchInput/SearchInput.module.css
+++ b/src/components/ui/SearchInput/SearchInput.module.css
@@ -1,15 +1,42 @@
-.search {
+.field {
+ display: flex;
+ align-items: center;
+ gap: 7px;
flex: 1 1 240px;
max-width: 520px;
height: 48px;
padding: 0 var(--fowoco-spacing-16);
- font-size: 14px;
- font-family: inherit;
+ overflow: hidden;
+ background: var(--surface-default);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-6);
}
-.search:focus-visible {
+.field:focus-within {
outline: 2px solid var(--brand-primary);
outline-offset: 2px;
}
+
+.icon {
+ flex: 0 0 16px;
+ width: 16px;
+ height: 16px;
+}
+
+.search {
+ min-width: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ outline: 0;
+ font-family: inherit;
+ font-size: 14px;
+ color: var(--text-primary);
+}
+
+.search::placeholder {
+ color: var(--text-secondary);
+ opacity: 1;
+}
diff --git a/src/components/ui/SearchInput/SearchInput.tsx b/src/components/ui/SearchInput/SearchInput.tsx
index e7162aa..d619e42 100644
--- a/src/components/ui/SearchInput/SearchInput.tsx
+++ b/src/components/ui/SearchInput/SearchInput.tsx
@@ -1,4 +1,5 @@
import type { ChangeEvent } from 'react'
+import searchIcon from './search.svg'
import styles from './SearchInput.module.css'
export interface SearchInputProps {
@@ -21,12 +22,15 @@ export function SearchInput({
}
return (
-
+
+
+
+
)
}
diff --git a/src/components/ui/SearchInput/search.svg b/src/components/ui/SearchInput/search.svg
new file mode 100644
index 0000000..5cdb1c6
--- /dev/null
+++ b/src/components/ui/SearchInput/search.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/components/ui/WorkItemRow/WorkItemRow.module.css b/src/components/ui/WorkItemRow/WorkItemRow.module.css
index 0a54620..38ba993 100644
--- a/src/components/ui/WorkItemRow/WorkItemRow.module.css
+++ b/src/components/ui/WorkItemRow/WorkItemRow.module.css
@@ -2,7 +2,9 @@
display: flex;
align-items: center;
gap: var(--fowoco-spacing-16);
- padding: var(--fowoco-spacing-16) var(--fowoco-spacing-20);
+ width: 100%;
+ min-height: 56px;
+ padding: 8px var(--fowoco-spacing-20);
background: var(--surface-default);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-8);
@@ -36,7 +38,7 @@
.rail {
flex-shrink: 0;
width: 4px;
- height: 44px;
+ height: 40px;
border-radius: var(--fowoco-radius-4);
background: var(--status-warning);
}
@@ -45,6 +47,10 @@
background: var(--status-critical);
}
+.railInfo {
+ background: #3976d3;
+}
+
.railNeutral {
background: var(--border-default);
}
@@ -53,8 +59,8 @@
flex: 1;
min-width: 0;
display: flex;
- flex-direction: column;
- gap: var(--fowoco-spacing-4);
+ align-items: center;
+ gap: 12px;
}
.title {
@@ -62,6 +68,48 @@
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
+ white-space: nowrap;
+}
+
+.inlineMeta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.status {
+ display: inline-flex;
+ align-items: center;
+ min-height: 26px;
+ padding: 4px 8px;
+ border-radius: var(--fowoco-radius-999);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 18px;
+ white-space: nowrap;
+}
+
+.statusWarning {
+ color: var(--status-warning);
+ background: var(--fowoco-amber-50);
+}
+
+.statusPrimary {
+ color: var(--brand-primary);
+ background: var(--fowoco-teal-50);
+}
+
+.statusNeutral {
+ color: var(--text-secondary);
+ background: var(--surface-subtle);
+}
+
+.detailItem {
+ font-size: 12px;
+ line-height: 18px;
+ color: var(--text-secondary);
+ white-space: nowrap;
}
.meta {
@@ -72,8 +120,35 @@
.next {
flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 120px;
+ height: 40px;
+ padding: 8px 16px;
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
font-family: var(--font-sans);
- font-size: 13px;
+ font-size: 14px;
font-weight: 500;
- color: var(--brand-primary);
+ color: var(--text-primary);
+ background: var(--surface-default);
+}
+
+@media (max-width: 720px) {
+ .content {
+ flex: 1 1 calc(100% - 20px);
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .inlineMeta {
+ flex-wrap: wrap;
+ }
+
+ .next {
+ flex-basis: auto;
+ width: 100%;
+ }
}
diff --git a/src/components/ui/WorkItemRow/WorkItemRow.tsx b/src/components/ui/WorkItemRow/WorkItemRow.tsx
index ba56a8e..1505b31 100644
--- a/src/components/ui/WorkItemRow/WorkItemRow.tsx
+++ b/src/components/ui/WorkItemRow/WorkItemRow.tsx
@@ -1,16 +1,28 @@
import styles from './WorkItemRow.module.css'
-export type WorkItemUrgency = 'warning' | 'critical' | 'neutral'
+export type WorkItemUrgency = 'warning' | 'critical' | 'info' | 'neutral'
const RAIL_CLASS: Record
= {
warning: '',
critical: styles.railCritical,
+ info: styles.railInfo,
neutral: styles.railNeutral,
}
+export type WorkItemStatusTone = 'warning' | 'primary' | 'neutral'
+
+const STATUS_CLASS: Record = {
+ warning: styles.statusWarning,
+ primary: styles.statusPrimary,
+ neutral: styles.statusNeutral,
+}
+
export interface WorkItemRowProps {
title: string
- meta: string
+ meta?: string
+ statusLabel?: string
+ statusTone?: WorkItemStatusTone
+ detailItems?: string[]
nextAction: string
urgency?: WorkItemUrgency
onClick?: () => void
@@ -19,6 +31,9 @@ export interface WorkItemRowProps {
export function WorkItemRow({
title,
meta,
+ statusLabel,
+ statusTone = 'neutral',
+ detailItems = [],
nextAction,
urgency = 'warning',
onClick,
@@ -28,7 +43,22 @@ export function WorkItemRow({
{title}
- {meta}
+ {statusLabel || detailItems.length > 0 ? (
+
+ {statusLabel && (
+
+ {statusLabel}
+
+ )}
+ {detailItems.map((item) => (
+
+ {item}
+
+ ))}
+
+ ) : (
+ {meta}
+ )}
{nextAction}
diff --git a/src/pages/DashboardPage/DashboardPage.module.css b/src/pages/DashboardPage/DashboardPage.module.css
index cd61077..cddaebd 100644
--- a/src/pages/DashboardPage/DashboardPage.module.css
+++ b/src/pages/DashboardPage/DashboardPage.module.css
@@ -1,339 +1,574 @@
-.stateWrap {
- margin-top: 24px;
+.page {
+ width: 100%;
+ min-width: 0;
+ font-family: var(--font-sans);
+}
+
+.pageHeader {
+ margin-left: 5px;
}
.headline {
margin: 0;
font-size: 28px;
font-weight: 700;
+ line-height: 36px;
color: var(--text-primary);
}
.description {
- margin: 8px 0 0;
+ margin: 0;
font-size: 14px;
+ line-height: 22px;
color: var(--text-secondary);
}
-.commandBox {
- margin-top: 24px;
- padding: 19px;
- background: var(--fowoco-teal-50);
- border: 1px solid var(--border-default);
+.agentRequest {
+ height: 144px;
+ margin-top: 19px;
+ padding: 13px 13px 12px;
+ overflow: hidden;
+ background: #f4faf9;
+ border: 1px solid #95cecb;
border-radius: var(--fowoco-radius-8);
}
-.commandBoxHeader {
+.agentRequestTitle {
display: flex;
align-items: center;
- justify-content: space-between;
- margin-bottom: 12px;
+ gap: 5px;
+ height: 22px;
}
-.commandBoxTitle {
- font-size: 13px;
- font-weight: 700;
- color: var(--brand-primary);
+.agentRequestTitle img,
+.agentPreparedTitle img {
+ flex: 0 0 auto;
+ width: 20px;
+ height: 20px;
}
-.commandBoxChevron {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
+.agentRequestTitle h2 {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 21px;
color: var(--brand-primary);
}
.commandInput {
display: flex;
align-items: center;
- width: 100%;
- padding: 15px 19px;
- background: var(--surface-default);
- border: 1px solid var(--border-default);
- border-radius: var(--fowoco-radius-8);
+ justify-content: space-between;
+ width: calc(100% - 37px);
+ height: 40px;
+ margin: 6px 18px 0;
+ padding: 3px 13px;
+ overflow: hidden;
+ background: rgba(207, 227, 227, 0.7);
+ border: 0;
+ border-radius: 21px;
font-family: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 22px;
+ color: #465b5d;
+ text-align: left;
cursor: pointer;
}
-.commandPlaceholder {
- font-size: 14px;
- color: var(--text-secondary);
+.commandInput span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.commandInput img {
+ flex: 0 0 auto;
+ width: 18px;
+ height: 18px;
}
.promptChips {
display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 12px;
+ align-items: center;
+ gap: 10px;
+ margin: 6px 25px 0;
}
.promptChip {
- padding: 7px 14px;
- background: var(--surface-default);
- border: none;
- border-radius: var(--fowoco-radius-999);
- font-size: 12px;
- font-weight: 500;
- color: var(--brand-primary);
+ height: 24px;
+ padding: 2px 5px;
+ background: var(--surface-subtle);
+ border: 0;
+ border-radius: var(--fowoco-radius-8);
+ font-family: inherit;
+ font-size: 11px;
+ font-weight: 400;
+ line-height: 20px;
+ color: var(--text-secondary);
+ white-space: nowrap;
cursor: pointer;
}
-.metricStrip {
+.dashboardGrid {
display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 16px;
+ grid-template-columns: minmax(0, 767px) minmax(0, 359px);
+ gap: 26px;
margin-top: 16px;
}
-@media (max-width: 720px) {
- .metricStrip {
- grid-template-columns: repeat(2, 1fr);
- }
+.primaryColumn {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+}
+
+.metricStrip {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 30px;
+ height: 72px;
}
.metricCard {
display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 16px 19px;
+ align-items: center;
+ justify-content: space-between;
+ min-width: 0;
+ height: 72px;
+ padding: 16px;
background: var(--surface-default);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-8);
+ font-family: inherit;
text-align: left;
cursor: pointer;
}
+.metricText {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ height: 40px;
+ white-space: nowrap;
+}
+
.metricLabel {
font-size: 12px;
+ font-weight: 500;
+ line-height: 18px;
color: var(--text-secondary);
}
-.metricValueRow {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
.metricValue {
- font-size: 20px;
+ font-size: 16px;
font-weight: 700;
+ line-height: 22px;
color: var(--text-primary);
}
.metricIcon {
- width: 20px;
- height: 20px;
- color: var(--brand-primary);
+ display: flex;
+ flex: 0 0 32px;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--fowoco-radius-8);
}
-.mainGrid {
- display: grid;
- grid-template-columns: 1fr 340px;
- gap: 16px;
- margin-top: 40px;
- align-items: start;
+.metricIcon img {
+ width: 18px;
+ height: 18px;
}
-@media (max-width: 960px) {
- .mainGrid {
- grid-template-columns: 1fr;
- }
+.metricIcon_warning {
+ background: var(--fowoco-amber-50);
+}
- .commandInput {
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
- }
+.metricIcon_info {
+ background: var(--surface-subtle);
}
-.mainColumn {
- display: flex;
- flex-direction: column;
- gap: 0;
+.metricIcon_critical {
+ background: var(--fowoco-red-50);
}
-.topApprovalCard {
- padding: 19px;
+.metricIcon_success {
+ background: var(--fowoco-green-50);
+}
+
+.priorityApproval {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 183px;
+ padding: 15px 24px;
background: var(--surface-default);
- border: 1px solid var(--border-default);
+ border: 1px solid var(--status-warning);
border-radius: var(--fowoco-radius-8);
+ box-shadow: 0 2px 8px rgba(23, 43, 46, 0.08);
}
-.topApprovalHeader {
+.priorityHeader {
display: flex;
- align-items: baseline;
+ align-items: center;
justify-content: space-between;
+ height: 26px;
+ padding: 0 10px;
}
-.topApprovalItem {
+.priorityHeader h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ line-height: 26px;
+ color: var(--text-primary);
+}
+
+.priorityHeader span {
+ font-size: 11px;
+ line-height: 18px;
+ color: #bbc3c4;
+ white-space: nowrap;
+}
+
+.priorityBody {
display: flex;
align-items: center;
- justify-content: space-between;
- gap: 16px;
- width: 100%;
- margin-top: 16px;
- padding: 15px 19px;
- background: var(--surface-subtle);
- border: none;
- border-radius: var(--fowoco-radius-8);
- text-align: left;
- cursor: pointer;
+ gap: 25px;
+ margin-top: 15px;
}
-.topApprovalContent {
+.priorityContent {
display: flex;
+ flex: 1;
+ min-width: 0;
flex-direction: column;
- gap: 4px;
+ gap: 5px;
}
-.topApprovalTitle {
- font-size: 14px;
- font-weight: 700;
- color: var(--text-primary);
+.priorityCopy {
+ display: flex;
+ min-height: 70px;
+ flex-direction: column;
+ justify-content: center;
+ padding: 8px 15px;
+ background: var(--surface-default);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+ white-space: nowrap;
}
-.topApprovalMeta {
+.priorityCopy strong {
+ margin-bottom: -3px;
+ overflow: hidden;
font-size: 12px;
+ font-weight: 500;
+ line-height: 22px;
+ color: var(--text-primary);
+ text-overflow: ellipsis;
+}
+
+.priorityCopy span {
+ overflow: hidden;
+ font-size: 8px;
+ font-weight: 400;
+ line-height: 20px;
color: var(--text-secondary);
+ text-overflow: ellipsis;
}
-.topApprovalNote {
+.priorityContent > button {
+ width: 100%;
+ height: 32px;
+ padding: 5px 16px;
+ background: var(--surface-default);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+ font-family: inherit;
font-size: 12px;
- color: var(--text-secondary);
+ font-weight: 500;
+ line-height: 20px;
+ color: var(--text-primary);
+ cursor: pointer;
}
-.topApprovalChevron {
- flex-shrink: 0;
- font-size: 20px;
+.priorityNext {
+ display: flex;
+ flex: 0 0 40px;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ padding: 0 0 3px;
+ background: var(--surface-default);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+ font-family: inherit;
+ font-size: 28px;
+ font-weight: 400;
+ line-height: 1;
color: var(--text-secondary);
+ cursor: pointer;
}
-.topApprovalAction {
- width: 100%;
- margin-top: 12px;
- height: 44px;
- background: var(--text-primary);
- border: none;
- border-radius: var(--fowoco-radius-8);
- font-size: 14px;
- font-weight: 500;
- color: var(--fowoco-white);
- cursor: pointer;
+.todayTasks {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-width: 0;
}
.sectionHeader {
display: flex;
- align-items: baseline;
+ align-items: center;
justify-content: space-between;
- margin-top: 32px;
+ width: calc(100% - 13px);
+ min-height: 26px;
+ margin: 0 auto;
}
-.sectionTitle {
+.sectionHeader h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
+ line-height: 26px;
color: var(--text-primary);
}
-.sectionNote {
+.sectionHeader p {
margin: 0;
font-size: 12px;
+ line-height: 20px;
color: var(--text-secondary);
}
.workItemList {
display: flex;
flex-direction: column;
- gap: 16px;
- margin-top: 16px;
- max-height: 480px;
- overflow-y: auto;
+ gap: 8px;
}
-.agentPanel {
- padding: 19px;
+.agentPrepared {
+ display: flex;
+ height: 539px;
+ min-width: 0;
+ flex-direction: column;
+ gap: 15px;
+ padding: 16px;
+ overflow: hidden;
background: var(--fowoco-teal-50);
- border: 1px solid var(--border-default);
+ border: 1px solid rgba(7, 132, 127, 0.7);
border-radius: var(--fowoco-radius-8);
}
-.agentPanelTitle {
+.agentPreparedTitle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 26px;
+}
+
+.agentPreparedTitle h2 {
margin: 0;
- font-size: 13px;
+ font-size: 16px;
font-weight: 700;
+ line-height: 24px;
color: var(--brand-primary);
+ white-space: nowrap;
}
-.agentPanelSummary {
- margin: 12px 0 0;
- font-size: 13px;
- font-weight: 700;
- color: var(--text-primary);
+.preparedIntro {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ line-height: 18px;
}
-.agentPanelNote {
- margin: 6px 0 0;
+.preparedIntro strong {
font-size: 12px;
- line-height: 18px;
- color: var(--text-secondary);
+ font-weight: 500;
+ color: var(--text-primary);
}
-.agentGroupLabel {
- margin: 20px 0 0;
- font-size: 12px;
- font-weight: 700;
+.preparedIntro p {
+ margin: 0;
+ font-size: 11px;
color: var(--text-secondary);
}
-.agentGroupLabelWarning {
- margin: 0 0 8px;
+.preparedSections {
+ display: flex;
+ min-height: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 18px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+.preparedSection {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.preparedSection h3 {
+ margin: 0;
font-size: 12px;
- font-weight: 700;
- color: var(--fowoco-amber-600);
+ font-weight: 500;
+ line-height: normal;
+ color: var(--text-primary);
}
-.agentReadyList {
+.preparedSection ul {
display: flex;
flex-direction: column;
gap: 8px;
- margin: 10px 0 0;
+ margin: 0;
padding: 0;
- max-height: 220px;
- overflow-y: auto;
list-style: none;
}
-.agentReadyItem {
- font-size: 12px;
+.preparedSection li {
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr);
+ column-gap: 8px;
+ align-items: center;
+ min-height: 20px;
+ font-size: 13px;
+ line-height: 20px;
color: var(--text-primary);
}
-.agentNeedsInfoBox {
- margin-top: 16px;
- padding: 15px;
- background: var(--fowoco-amber-50);
- border-radius: var(--fowoco-radius-8);
+.preparedSection li strong {
+ font-weight: 500;
}
-.agentPendingItem {
- margin-top: 12px;
+.doneMark,
+.reviewMark,
+.nextMark {
+ align-self: start;
+ width: 16px;
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 20px;
}
-.agentNeedsInfoBox .agentPendingItem:first-of-type {
- margin-top: 0;
+.doneMark {
+ color: var(--fowoco-green-600);
}
-.agentPendingLabel {
- margin: 0;
- font-size: 12px;
- font-weight: 700;
- color: var(--text-primary);
+.reviewMark {
+ color: var(--status-warning);
}
-.agentPendingNote {
- margin: 4px 0 0;
+.nextMark {
+ color: #3976d3;
+}
+
+.reviewSection {
+ padding: 12px;
+ background: var(--fowoco-amber-50);
+ border: 1px solid var(--status-warning);
+ border-radius: var(--fowoco-radius-6);
+}
+
+.reviewSection h3 {
+ color: var(--status-warning);
+}
+
+.describedItem p {
+ grid-column: 1 / -1;
+ margin: 0;
font-size: 11px;
+ font-weight: 400;
+ line-height: normal;
color: var(--text-secondary);
}
+
+.stateWrap {
+ margin-top: 16px;
+}
+
+@media (max-width: 1260px) {
+ .dashboardGrid {
+ grid-template-columns: minmax(0, 2fr) minmax(290px, 1fr);
+ }
+
+ .metricStrip {
+ gap: 12px;
+ }
+
+ .priorityBody {
+ gap: 12px;
+ }
+}
+
+@media (max-width: 1080px) {
+ .dashboardGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .agentPrepared {
+ height: auto;
+ max-height: 539px;
+ }
+}
+
+@media (max-width: 760px) {
+ .headline {
+ font-size: 22px;
+ line-height: 30px;
+ }
+
+ .description {
+ margin-top: 4px;
+ }
+
+ .agentRequest {
+ height: auto;
+ min-height: 144px;
+ }
+
+ .commandInput {
+ width: 100%;
+ margin-right: 0;
+ margin-left: 0;
+ }
+
+ .promptChips {
+ flex-wrap: wrap;
+ margin-right: 0;
+ margin-left: 0;
+ }
+
+ .metricStrip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ height: auto;
+ }
+
+ .priorityApproval {
+ height: auto;
+ }
+
+ .priorityHeader {
+ height: auto;
+ padding: 0;
+ }
+
+ .priorityHeader h2 {
+ font-size: 16px;
+ }
+
+ .priorityBody {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .priorityNext {
+ width: 100%;
+ }
+}
diff --git a/src/pages/DashboardPage/DashboardPage.test.tsx b/src/pages/DashboardPage/DashboardPage.test.tsx
index 3d386c8..d1b8638 100644
--- a/src/pages/DashboardPage/DashboardPage.test.tsx
+++ b/src/pages/DashboardPage/DashboardPage.test.tsx
@@ -3,50 +3,48 @@ import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
import { DashboardPage } from './DashboardPage'
+import styles from './DashboardPage.module.css'
import {
AGENT_PREPARED,
AI_REQUEST_PROMPT_CHIPS,
+ APPROVAL_QUEUE,
METRIC_STRIP,
TODAY_WORK_ITEMS,
- TOP_APPROVAL,
} from './dashboardData'
function renderPage(demoState = 'success') {
- render(
+ return render(
} />
업무 생성 페이지} />
- 업무함} />
- 업무 상세} />
,
)
}
describe('DashboardPage', () => {
- it('renders the pending-approval headline', () => {
+ it('renders the blocking approval headline from the HOME-001 structure', () => {
renderPage()
- const pendingApproval = METRIC_STRIP.find((metric) => metric.id === 'pending-approval')
expect(
- screen.getByText(`지금 확인이 필요한 승인 ${pendingApproval?.value}건이 있습니다.`),
+ screen.getByRole('heading', {
+ name: `지금 확인이 필요한 승인 ${APPROVAL_QUEUE.blockingCount}건이 있습니다.`,
+ }),
).toBeInTheDocument()
})
- it('renders the top approval card and navigates to its task on click', async () => {
- const user = userEvent.setup()
+ it('renders every work item row', () => {
renderPage()
-
- expect(screen.getByText(TOP_APPROVAL.title)).toBeInTheDocument()
- await user.click(screen.getByRole('button', { name: TOP_APPROVAL.actionLabel }))
-
- expect(await screen.findByText('업무 상세')).toBeInTheDocument()
+ for (const item of TODAY_WORK_ITEMS) {
+ expect(screen.getByText(item.title)).toBeInTheDocument()
+ }
})
- it('renders every work item row', () => {
+ it('renders the Figma status label and next action for every priority work item', () => {
renderPage()
for (const item of TODAY_WORK_ITEMS) {
- expect(screen.getByText(item.title)).toBeInTheDocument()
+ expect(screen.getByText(item.status)).toBeInTheDocument()
+ expect(screen.getAllByText(item.nextAction).length).toBeGreaterThan(0)
}
})
@@ -74,26 +72,21 @@ describe('DashboardPage', () => {
}
})
- it('renders the agent-prepared panel with ready, needs-info, and after-approval groups', () => {
+ it('renders every Agent prepared group', () => {
renderPage()
- for (const item of AGENT_PREPARED.ready) {
- expect(screen.getByText(item.label, { exact: false })).toBeInTheDocument()
- }
- for (const item of AGENT_PREPARED.needsInfo) {
- expect(screen.getByText(item.label, { exact: false })).toBeInTheDocument()
- }
- for (const item of AGENT_PREPARED.afterApproval) {
- expect(screen.getByText(item.label, { exact: false })).toBeInTheDocument()
+ const items = [
+ ...AGENT_PREPARED.prepared,
+ ...AGENT_PREPARED.review,
+ ...AGENT_PREPARED.afterApproval,
+ ]
+ for (const item of items) {
+ expect(screen.getByText(item.label)).toBeInTheDocument()
}
})
- it('navigates to work creation when the command box is clicked', async () => {
- const user = userEvent.setup()
- renderPage()
-
- await user.click(screen.getByText(/처리할 업무를 자연어로 입력해 주세요/))
-
- expect(await screen.findByText('업무 생성 페이지')).toBeInTheDocument()
+ it('uses the Figma desktop grid class for the success view', () => {
+ const { container } = renderPage()
+ expect(container.querySelector(`.${styles.dashboardGrid}`)).toBeInTheDocument()
})
it('navigates to work creation with the chosen prompt chip prefilled', async () => {
diff --git a/src/pages/DashboardPage/DashboardPage.tsx b/src/pages/DashboardPage/DashboardPage.tsx
index 9edf706..d33274e 100644
--- a/src/pages/DashboardPage/DashboardPage.tsx
+++ b/src/pages/DashboardPage/DashboardPage.tsx
@@ -1,51 +1,52 @@
import { useNavigate } from 'react-router-dom'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
-import { WorkItemRow } from '../../components/ui/WorkItemRow/WorkItemRow'
+import { WorkItemRow, type WorkItemStatusTone } from '../../components/ui/WorkItemRow/WorkItemRow'
import { useAsyncDemoData } from '../../hooks/useAsyncDemoData'
-import { CalendarIcon, CheckCircleIcon, WarningTriangleIcon } from './DashboardIcons'
+import agentSparkIcon from './assets/agent-spark.svg'
+import commandSubmitIcon from './assets/command-submit.svg'
import styles from './DashboardPage.module.css'
import {
AGENT_PREPARED,
AI_REQUEST_PROMPT_CHIPS,
- COMMAND_BAR,
+ APPROVAL_QUEUE,
METRIC_STRIP,
TODAY_WORK_ITEMS,
- TOP_APPROVAL,
- type MetricIconKey,
+ type DashboardWorkStatus,
} from './dashboardData'
-const METRIC_ICON: Record = {
- check: CheckCircleIcon,
- calendar: CalendarIcon,
- warning: WarningTriangleIcon,
- response: CheckCircleIcon,
+const STATUS_TONE: Record = {
+ 승인대기: 'warning',
+ 요청전송: 'primary',
+ 서류대기: 'neutral',
}
export function DashboardPage() {
const navigate = useNavigate()
const status = useAsyncDemoData(TODAY_WORK_ITEMS.length === 0)
- const pendingApprovalCount = METRIC_STRIP.find((metric) => metric.id === 'pending-approval')?.value ?? 0
return (
-
- {status === 'success' && (
- <>
-
지금 확인이 필요한 승인 {pendingApprovalCount}건이 있습니다.
-
- Agent가 필요한 자료와 다음 행동을 먼저 준비했습니다. 검토와 최종 결정은 담당자가 수행합니다.
-
- >
- )}
+
+
-
-
-
✦ {COMMAND_BAR.title}
-
- ⌃
-
+
+
+
+
Agent 업무 요청
- navigate('/tasks/new')}>
- {COMMAND_BAR.placeholder}
+ navigate('/tasks/new')}
+ >
+ 처리할 업무를 자연어로 입력해 주세요. 예: 응웬반A의 체류기간 연장 준비
+
{AI_REQUEST_PROMPT_CHIPS.map((chip) => (
@@ -59,22 +60,7 @@ export function DashboardPage() {
))}
-
-
-
- {METRIC_STRIP.map((metric) => {
- const Icon = METRIC_ICON[metric.icon]
- return (
- navigate('/tasks')}>
- {metric.label}
-
- {metric.value}건 ›
-
-
-
- )
- })}
-
+
{status === 'loading' && (
@@ -112,90 +98,125 @@ export function DashboardPage() {
)}
{status === 'success' && (
-
-
-
-
-
먼저 검토할 승인 업무
-
{TOP_APPROVAL.requestedLabel}
+
+
+
+ {METRIC_STRIP.map((metric) => (
+
navigate(`/tasks?view=${metric.id}`)}
+ >
+
+ {metric.label}
+ {metric.value}건 ›
+
+
+
+
+
+ ))}
+
+
+
+
+
먼저 검토할 승인 업무
+ 요청 · {APPROVAL_QUEUE.oldestValue}
- navigate(`/tasks/${TODAY_WORK_ITEMS[0].id}`)}
- >
-
- {TOP_APPROVAL.title}
- {TOP_APPROVAL.meta}
- {TOP_APPROVAL.note}
-
-
+
+
+
+ {APPROVAL_QUEUE.title}
+ {APPROVAL_QUEUE.meta}
+ {APPROVAL_QUEUE.note}
+
+
navigate('/tasks')}>
+ 승인 검토
+
+
+
navigate('/tasks')}
+ >
›
-
-
-
navigate(`/tasks/${TODAY_WORK_ITEMS[0].id}`)}
- >
- {TOP_APPROVAL.actionLabel}
-
-
+
+
+
-
-
오늘의 우선 업무
-
지금 할 일 · {TODAY_WORK_ITEMS.length}건
-
+
+
+
오늘의 우선 업무
+
지금 할 일 · {TODAY_WORK_ITEMS.length}건
+
+
+ {TODAY_WORK_ITEMS.map((item) => (
+ navigate(`/tasks/${item.id}`)}
+ />
+ ))}
+
+
+
-
- {TODAY_WORK_ITEMS.map((item) => (
-
navigate(`/tasks/${item.id}`)}
- />
- ))}
+
-
- ✦ Agent가 준비한 내용
- {AGENT_PREPARED.summary}
- {AGENT_PREPARED.note}
+
+
+ 준비 완료 · 4건
+
+ {AGENT_PREPARED.prepared.map((item) => (
+
+ ✓
+ {item.label}
+
+ ))}
+
+
-
{AGENT_PREPARED.readyLabel}
-
- {AGENT_PREPARED.ready.map((item) => (
-
- ✓ {item.label}
-
- ))}
-
+
+ HR 확인 필요 · 2건
+
+ {AGENT_PREPARED.review.map((item) => (
+
+ !
+ {item.label}
+ {item.description}
+
+ ))}
+
+
-
-
{AGENT_PREPARED.needsInfoLabel}
- {AGENT_PREPARED.needsInfo.map((item) => (
-
-
- ! {item.label}
-
-
{item.note}
-
- ))}
+
+ 승인 후 진행 · 2건
+
+ {AGENT_PREPARED.afterApproval.map((item) => (
+
+ →
+ {item.label}
+ {item.description}
+
+ ))}
+
+
-
-
{AGENT_PREPARED.afterApprovalLabel}
- {AGENT_PREPARED.afterApproval.map((item) => (
-
-
- → {item.label}
-
-
{item.note}
-
- ))}
)}
diff --git a/src/pages/DashboardPage/assets/agent-spark.svg b/src/pages/DashboardPage/assets/agent-spark.svg
new file mode 100644
index 0000000..20ff920
--- /dev/null
+++ b/src/pages/DashboardPage/assets/agent-spark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/pages/DashboardPage/assets/command-submit.svg b/src/pages/DashboardPage/assets/command-submit.svg
new file mode 100644
index 0000000..44ae101
--- /dev/null
+++ b/src/pages/DashboardPage/assets/command-submit.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/pages/DashboardPage/assets/metric-approval.svg b/src/pages/DashboardPage/assets/metric-approval.svg
new file mode 100644
index 0000000..f1bfe1e
--- /dev/null
+++ b/src/pages/DashboardPage/assets/metric-approval.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/pages/DashboardPage/assets/metric-due.svg b/src/pages/DashboardPage/assets/metric-due.svg
new file mode 100644
index 0000000..ee794f2
--- /dev/null
+++ b/src/pages/DashboardPage/assets/metric-due.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/pages/DashboardPage/assets/metric-info.svg b/src/pages/DashboardPage/assets/metric-info.svg
new file mode 100644
index 0000000..ee80a4c
--- /dev/null
+++ b/src/pages/DashboardPage/assets/metric-info.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/pages/DashboardPage/assets/metric-response.svg b/src/pages/DashboardPage/assets/metric-response.svg
new file mode 100644
index 0000000..c00500e
--- /dev/null
+++ b/src/pages/DashboardPage/assets/metric-response.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/pages/DashboardPage/dashboardData.ts b/src/pages/DashboardPage/dashboardData.ts
index a962920..20cce7c 100644
--- a/src/pages/DashboardPage/dashboardData.ts
+++ b/src/pages/DashboardPage/dashboardData.ts
@@ -1,109 +1,144 @@
-import type { WorkItemUrgency } from '../../components/ui/WorkItemRow/WorkItemRow'
-import { EXAMPLE_PROMPTS } from '../CreateWorkPage/createWorkData'
+import metricApprovalIcon from './assets/metric-approval.svg'
+import metricDueIcon from './assets/metric-due.svg'
+import metricInfoIcon from './assets/metric-info.svg'
+import metricResponseIcon from './assets/metric-response.svg'
-// TODO(backend): 이 파일의 상수는 데모용 목데이터. 실제 연동 시 아래 엔드포인트로 대체
-// - GET /api/dashboard/work-items -> TODAY_WORK_ITEMS
-// - GET /api/dashboard/top-approval -> TOP_APPROVAL
-// - GET /api/dashboard/metrics -> METRIC_STRIP
-// - GET /api/dashboard/agent-progress -> AGENT_PREPARED
+// TODO(backend): 이 파일의 상수는 Figma HOME-001을 재현하기 위한 Prototype 데이터다.
+// Dashboard Projection API가 준비되면 동일한 ViewModel 형태로 응답을 정규화한다.
-export interface WorkItem {
+export type DashboardWorkStatus = '승인대기' | '요청전송' | '서류대기'
+export type DashboardWorkTone = 'warning' | 'critical' | 'info'
+
+export interface DashboardWorkItem {
id: string
title: string
- meta: string
+ status: DashboardWorkStatus
+ schedule: string
+ assignee?: string
nextAction: string
- urgency: WorkItemUrgency
+ urgency: DashboardWorkTone
}
-// Figma HOME-001(node 1291:209) "오늘의 우선 업무" 목록.
-export const TODAY_WORK_ITEMS: WorkItem[] = [
+export const TODAY_WORK_ITEMS: DashboardWorkItem[] = [
{
id: 'WI-1',
title: '응웬반A 체류연장 요청문',
- meta: 'D-2 · 담당 김민지',
+ status: '승인대기',
+ schedule: 'D-2',
+ assignee: '담당 김민지',
nextAction: '승인 검토',
- urgency: 'critical',
+ urgency: 'warning',
},
{
id: 'WI-2',
title: '외국인등록증 사본 제출',
- meta: '오늘 마감 · 담당 이지연',
+ status: '요청전송',
+ schedule: 'D-0',
nextAction: '요청 현황',
- urgency: 'warning',
+ urgency: 'critical',
},
{
id: 'WI-3',
title: '7월 외부기관 제출자료',
- meta: '이번 주 · 담당 박서준',
+ status: '서류대기',
+ schedule: 'D-3',
nextAction: '증빙 등록',
- urgency: 'neutral',
+ urgency: 'info',
},
]
-// Figma HOME-001의 "먼저 검토할 승인 업무" 카드 — 우선순위가 가장 높은 승인 1건을 크게 보여준다.
-export const TOP_APPROVAL = {
- requestedLabel: '요청 · 3시간 전',
+export const APPROVAL_QUEUE = {
+ blockingCount: 2,
+ totalCount: 7,
+ oldestValue: '3시간 전',
title: '응웬반A 체류연장 요청문 승인',
meta: '응웬반A · D-2 · 담당 김민지',
note: '승인을 완료하면 근로자 안내 단계가 활성화됩니다.',
- actionLabel: '승인 검토',
}
-export type MetricIconKey = 'check' | 'calendar' | 'warning' | 'response'
+export const AI_REQUEST_PROMPT_CHIPS = [
+ '체류기간 연장',
+ '누락 문서 확인',
+ '승인 대기 정리',
+ '근로자 요청',
+]
+
+export type DashboardMetricTone = 'warning' | 'info' | 'critical' | 'success'
export interface DashboardMetric {
id: string
label: string
value: number
- icon: MetricIconKey
+ iconSrc: string
+ tone: DashboardMetricTone
}
export const METRIC_STRIP: DashboardMetric[] = [
- { id: 'pending-approval', label: '승인 대기', value: 7, icon: 'check' },
- { id: 'due-today', label: '오늘 마감', value: 6, icon: 'calendar' },
- { id: 'needs-info', label: '정보 보완', value: 1, icon: 'warning' },
- { id: 'worker-response', label: '근로자 응답', value: 8, icon: 'response' },
+ {
+ id: 'pending-approval',
+ label: '승인 대기',
+ value: APPROVAL_QUEUE.totalCount,
+ iconSrc: metricApprovalIcon,
+ tone: 'warning',
+ },
+ {
+ id: 'due-today',
+ label: '오늘 마감',
+ value: 6,
+ iconSrc: metricDueIcon,
+ tone: 'info',
+ },
+ {
+ id: 'needs-info',
+ label: '정보 보완',
+ value: 1,
+ iconSrc: metricInfoIcon,
+ tone: 'critical',
+ },
+ {
+ id: 'worker-response',
+ label: '근로자 응답',
+ value: 8,
+ iconSrc: metricResponseIcon,
+ tone: 'success',
+ },
]
-// Figma HOME-001의 상단 "Agent 업무 요청" 입력 박스 안내문·예시 태그.
-export const COMMAND_BAR = {
- title: 'Agent 업무 요청',
- placeholder: '처리할 업무를 자연어로 입력해 주세요. 예: 응웬반A의 체류기간 연장 준비',
-}
-
-// Figma HOME-001(node 1291:209)의 자연어 입력 프롬프트 칩은 CreateWorkPage의 예시 칩과 동일 문구.
-export const AI_REQUEST_PROMPT_CHIPS = EXAMPLE_PROMPTS
-
export interface AgentPreparedItem {
id: string
label: string
+ description?: string
}
-export interface AgentPendingItem {
- id: string
- label: string
- note: string
-}
-
-// Figma HOME-001 우측 "Agent가 준비한 내용" 패널.
export const AGENT_PREPARED = {
- summary: '준비 완료 4건 · HR 확인 필요 2건',
- note: 'Agent는 초안까지만 준비하며, 검토와 승인은 담당자가 수행합니다.',
- readyLabel: '준비 완료 · 4건',
- ready: [
- { id: 'ready-1', label: '필요 문서 5개 확인' },
- { id: 'ready-2', label: '체류연장 요청문 초안' },
- { id: 'ready-3', label: '기존 계약·체류 정보 연결' },
- { id: 'ready-4', label: '유사 업무 중복 여부 확인' },
- ] as AgentPreparedItem[],
- needsInfoLabel: 'HR 확인 필요 · 2건',
- needsInfo: [
- { id: 'needs-1', label: '여권 만료일 확인', note: '여권 원본과 만료일을 확인한 뒤 승인합니다.' },
- { id: 'needs-2', label: '추천 마감일·담당자 확인', note: '업무량과 제출 기한을 확인한 뒤 확정합니다.' },
- ] as AgentPendingItem[],
- afterApprovalLabel: '승인 후 진행 · 2건',
+ prepared: [
+ { id: 'documents', label: '필요 문서 5개 확인' },
+ { id: 'draft', label: '체류연장 요청문 초안' },
+ { id: 'connected', label: '기존 계약·체류 정보 연결' },
+ { id: 'duplicate', label: '유사 업무 중복 여부 확인' },
+ ] satisfies AgentPreparedItem[],
+ review: [
+ {
+ id: 'passport',
+ label: '여권 만료일 확인',
+ description: '여권 원본과 만료일을 확인한 뒤 승인합니다.',
+ },
+ {
+ id: 'deadline',
+ label: '추천 마감일·담당자 확인',
+ description: '업무량과 제출 기한을 확인한 뒤 확정합니다.',
+ },
+ ] satisfies AgentPreparedItem[],
afterApproval: [
- { id: 'after-1', label: '근로자 요청문 확인', note: '승인되면 근로자 요청 확인 단계가 열립니다.' },
- { id: 'after-2', label: '보안 링크 발급 준비', note: '승인 후 담당자가 발급하고 근로자에게 전달합니다.' },
- ] as AgentPendingItem[],
+ {
+ id: 'worker-request',
+ label: '근로자 요청문 확인',
+ description: '승인되면 근로자 요청 확인 단계가 열립니다.',
+ },
+ {
+ id: 'secure-link',
+ label: '보안 링크 발급 준비',
+ description: '승인 후 담당자가 발급하고 근로자에게 전달합니다.',
+ },
+ ] satisfies AgentPreparedItem[],
}
diff --git a/src/pages/WorkListPage/WorkInboxDetail.tsx b/src/pages/WorkListPage/WorkInboxDetail.tsx
index a5a4927..d939969 100644
--- a/src/pages/WorkListPage/WorkInboxDetail.tsx
+++ b/src/pages/WorkListPage/WorkInboxDetail.tsx
@@ -18,6 +18,23 @@ interface WorkInboxDetailProps {
onOpenTask: (taskId: string) => void
}
+const NATIONALITY_LABEL: Record = {
+ VN: '베트남',
+ ID: '인도네시아',
+ KH: '캄보디아',
+ NP: '네팔',
+ MM: '미얀마',
+ PH: '필리핀',
+ TH: '태국',
+}
+
+function getWorkerMeta(group: WorkInboxWorkerGroup): string {
+ const nationality =
+ NATIONALITY_LABEL[group.worker.nationality_code] ?? group.worker.nationality_code
+ const workStatus = group.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)
@@ -33,8 +50,6 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
const progress = getWorkInboxCaseProgress(activeCase)
const due = getDuePresentation(activeTask.task.due_date)
const activeStatus = getTaskStatusPresentation(activeTask.task.status)
- const headerStatus =
- due.tone === 'critical' ? { label: '긴급', tone: 'critical' as const } : activeStatus
const reviewTasks = group.tasks.filter((item) => isReviewTask(item.task.status))
const detailTitleId = `work-inbox-detail-${group.worker.worker_id}`
@@ -47,6 +62,10 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
showToast('판단 근거 보기는 준비 중입니다.')
}
+ function handleOpenWorkerMenu() {
+ showToast('근로자 업무 메뉴는 준비 중입니다.')
+ }
+
return (
-
{headerStatus.label}
+
+ ···
+
-
- Agent 제안 · {due.label}, {getWorkflowLabel(activeTask)} 확인 필요
-
+
+
+ Agent 제안 · {due.label}, {getWorkflowLabel(activeTask)} 확인 필요
+
-
-
-
- 우선 Case {Math.max(activeCaseIndex, 0) + 1}/{group.cases.length}
-
-
onOpenTask(activeTask.task.task_id)}
- >
- Case 열기 →
-
-
-
- {activeCase.caseId &&
Case {activeCase.caseId}
}
-
{activeTask.task.title}
-
- {getWorkflowLabel(activeTask)} · {due.label} · {activeStatus.label}
-
-
-
- 진행 {progress.completed}/{progress.total}
-
-
-
- {group.cases.length > 1 && (
-
- 다른 Case 열기 →
+
+
+
+ 우선 업무 건 · {Math.max(activeCaseIndex, 0) + 1}/{group.cases.length}
+
+
onOpenTask(activeTask.task.task_id)}
+ >
+ 업무 건 열기
+
+ ›
+
- )}
-
-
-
-
-
-
- 검토할 업무
-
- {reviewTasks.length}건
+
+
+ {activeCase.caseId &&
Case {activeCase.caseId} }
+
{activeTask.task.title}
+
+ {getWorkflowLabel(activeTask)} · {due.label} · {activeStatus.label}
+
+
+
+ {progress.completed}/{progress.total}
+
+
+
+ {group.cases.length > 1 && (
+
+ 다른 Case 열기 →
+
+ )}
+
- {reviewTasks.length === 0 ? (
-
현재 검토할 업무가 없습니다.
- ) : (
-
- {reviewTasks.map((item, index) => {
- const taskDue = getDuePresentation(item.task.due_date)
- const taskStatus = getTaskStatusPresentation(item.task.status)
- return (
-
-
-
{item.task.title}
-
- {getWorkflowLabel(item)} · {taskStatus.label}
-
-
- {taskDue.label}
- onOpenTask(item.task.task_id)}
- >
- {getReviewActionLabel(item.task.status)}
-
-
- )
- })}
+
+
+
+ 검토할 업무
+
+ {reviewTasks.length}건
- )}
-
-
-
-
- 현재 결정
-
-
- 근거 보기 →
-
-
-
-
+ {reviewTasks.length === 0 ? (
+
현재 검토할 업무가 없습니다.
+ ) : (
+
+ {reviewTasks.map((item, index) => {
+ const taskDue = getDuePresentation(item.task.due_date)
+ const taskStatus = getTaskStatusPresentation(item.task.status)
+ return (
+
+
+
{item.task.title}
+
+ {getWorkflowLabel(item)} · {taskStatus.label}
+
+
+ {taskDue.label}
+ onOpenTask(item.task.task_id)}
+ >
+ {getReviewActionLabel(item.task.status)}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ 현재 결정
+
+
+ 근거 보기
+
+ ›
+
+
+
+
{getDecisionSummary(activeTask.task.status)}
-
{activeStatus.label}
+
+ 진행 업무 건 {group.cases.length}개 · 확인할 업무 {reviewTasks.length}개 · 자동
+ 확정되지 않음
+
-
- 진행 업무 건 {group.cases.length}개 · 확인할 업무 {reviewTasks.length}개 · 자동 확정되지
- 않음
-
-
-
+
+
)
}
diff --git a/src/pages/WorkListPage/WorkInboxTargetList.tsx b/src/pages/WorkListPage/WorkInboxTargetList.tsx
index 5460abf..a6ce70f 100644
--- a/src/pages/WorkListPage/WorkInboxTargetList.tsx
+++ b/src/pages/WorkListPage/WorkInboxTargetList.tsx
@@ -46,7 +46,6 @@ export function WorkInboxTargetList({
근로자 {totalCount}명
-
업무 연결 기준
{capNotice &&
{capNotice}
}
diff --git a/src/pages/WorkListPage/WorkListPage.module.css b/src/pages/WorkListPage/WorkListPage.module.css
index 297dd57..1487792 100644
--- a/src/pages/WorkListPage/WorkListPage.module.css
+++ b/src/pages/WorkListPage/WorkListPage.module.css
@@ -1,6 +1,6 @@
.page {
width: 100%;
- margin: -20px 0 0;
+ margin: -21px 0 0;
}
.headline {
@@ -14,7 +14,7 @@
.description {
margin: 5px 0 0;
font-size: 14px;
- line-height: 1.5;
+ line-height: 20px;
color: var(--text-secondary);
}
@@ -31,6 +31,9 @@
height: 40px;
max-width: none;
padding: 0 14px;
+}
+
+.searchInput input {
font-size: 13px;
}
@@ -74,17 +77,21 @@
.workspace {
display: grid;
grid-template-columns: clamp(280px, 28.82%, 332px) minmax(0, 1fr);
- min-height: 520px;
+ min-height: 0;
height: calc(100svh - 208px);
- max-height: 712px;
+ max-height: none;
margin-top: 0;
overflow: hidden;
background: var(--surface-default);
- border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-8);
+ box-shadow: inset 0 0 0 1px var(--border-default);
}
.noticeStack + .workspace {
+ margin-top: 0;
+}
+
+.noticeStack:not(:empty) + .workspace {
margin-top: var(--fowoco-spacing-12);
}
@@ -116,12 +123,6 @@
color: var(--text-primary);
}
-.listCountNote {
- font-size: 11px;
- white-space: nowrap;
- color: var(--text-secondary);
-}
-
.capNotice {
flex-shrink: 0;
margin: var(--fowoco-spacing-8) 0 0;
@@ -143,6 +144,12 @@
margin-top: var(--fowoco-spacing-12);
padding: 0;
overflow-y: auto;
+ scrollbar-width: none;
+}
+
+.targetList::-webkit-scrollbar,
+.detailPanel::-webkit-scrollbar {
+ display: none;
}
.targetOption {
@@ -224,8 +231,11 @@
.detailPanel {
min-width: 0;
min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
padding: var(--fowoco-spacing-16) var(--fowoco-spacing-24);
- overflow-y: auto;
+ overflow: hidden;
background: var(--surface-default);
}
@@ -237,6 +247,49 @@
min-height: 72px;
}
+.moreButton {
+ display: inline-flex;
+ flex: 0 0 40px;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ background: var(--surface-default);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+ font-family: inherit;
+ font-size: 16px;
+ line-height: 20px;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.caseSummaryScroll {
+ min-height: 0;
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: var(--fowoco-spacing-16);
+ overflow-x: hidden;
+ overflow-y: auto;
+ scrollbar-width: none;
+}
+
+.caseSummaryScroll::-webkit-scrollbar {
+ display: none;
+}
+
+.moreButton:hover {
+ border-color: var(--brand-primary);
+ color: var(--brand-primary);
+}
+
+.moreButton:focus-visible {
+ outline: 2px solid var(--brand-primary);
+ outline-offset: 2px;
+}
+
.detailName {
margin: 0;
font-size: 18px;
@@ -253,6 +306,7 @@
}
.agentSuggestion {
+ flex-shrink: 0;
min-height: 40px;
margin: 0;
padding: 9px 16px;
@@ -264,7 +318,8 @@
}
.priorityCase {
- margin-top: var(--fowoco-spacing-16);
+ flex-shrink: 0;
+ margin: 0;
padding: 0;
overflow: hidden;
border: 1px solid var(--border-default);
@@ -287,6 +342,11 @@
padding: 12px 16px;
}
+.decisionHeader {
+ padding-right: 21px;
+ padding-left: 21px;
+}
+
.sectionHeadingRow {
display: flex;
align-items: center;
@@ -294,32 +354,45 @@
gap: var(--fowoco-spacing-12);
}
-.caseEyebrow,
-.caseIdentifier {
+.caseEyebrow {
margin: 0;
font-size: 16px;
font-weight: 700;
- color: var(--text-primary);
-}
-
-.caseIdentifier {
- margin: 0 0 var(--fowoco-spacing-4);
- font-size: 12px;
- font-weight: 500;
- color: var(--brand-primary);
+ line-height: 20px;
+ color: var(--brand-dark);
}
.textLink {
- padding: 4px;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--fowoco-spacing-8);
+ min-height: 40px;
+ padding: 0;
background: none;
border: none;
font-family: inherit;
- font-size: 12px;
- font-weight: 700;
+ font-size: 14px;
+ font-weight: 500;
color: var(--brand-primary);
cursor: pointer;
}
+.linkChevron {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ margin: 0;
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+ background: var(--surface-default);
+ font-size: 22px;
+ font-weight: 400;
+ line-height: 1;
+ color: var(--text-secondary);
+}
+
.textLink:hover {
text-decoration: underline;
}
@@ -333,14 +406,14 @@
margin: 0;
font-size: 16px;
font-weight: 500;
- line-height: 1.45;
+ line-height: 24px;
color: var(--text-primary);
}
.caseMeta {
- margin: 6px 0 0;
- font-size: 12px;
- line-height: 1.5;
+ margin: var(--fowoco-spacing-4) 0 0;
+ font-size: 13px;
+ line-height: 20px;
color: var(--text-secondary);
}
@@ -350,7 +423,9 @@
align-items: center;
gap: var(--fowoco-spacing-12);
margin-top: var(--fowoco-spacing-4);
+ min-height: 20px;
font-size: 13px;
+ line-height: 20px;
color: var(--text-secondary);
}
@@ -381,7 +456,8 @@
.detailSection,
.decisionSection {
- margin-top: var(--fowoco-spacing-16);
+ flex-shrink: 0;
+ margin: 0;
}
.sectionTitle {
@@ -409,7 +485,7 @@
display: flex;
flex-direction: column;
gap: var(--fowoco-spacing-8);
- margin-top: var(--fowoco-spacing-12);
+ margin-top: var(--fowoco-spacing-8);
}
.reviewTaskRow {
@@ -462,21 +538,19 @@
}
.decisionBody {
- padding: 8px 16px 12px;
-}
-
-.decisionStatusRow {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: var(--fowoco-spacing-12);
+ min-height: 64px;
+ padding: 8px 7px 12px;
}
.decisionSummary {
margin: 0;
font-size: 14px;
line-height: 1.6;
- color: var(--text-primary);
+ color: var(--text-secondary);
+}
+
+.decisionHeader .sectionTitle {
+ color: var(--brand-dark);
}
.decisionMeta {
diff --git a/src/pages/WorkListPage/WorkListPage.test.tsx b/src/pages/WorkListPage/WorkListPage.test.tsx
index 9d46ab6..ad32baf 100644
--- a/src/pages/WorkListPage/WorkListPage.test.tsx
+++ b/src/pages/WorkListPage/WorkListPage.test.tsx
@@ -244,7 +244,7 @@ describe('WorkListPage', () => {
expect(
screen.getByRole('heading', { name: '체류연장 업무 초안', level: 3 }),
).toBeInTheDocument()
- expect(screen.getByText('진행 1/2')).toBeInTheDocument()
+ expect(screen.getByText('1/2')).toBeInTheDocument()
expect(
screen.getByRole('progressbar', { name: /체류연장 업무 초안 Case 진행률/ }),
).toHaveAttribute('value', '1')
@@ -288,7 +288,7 @@ describe('WorkListPage', () => {
const user = userEvent.setup()
renderPage()
- const search = await screen.findByLabelText('근로자·Case·업무 검색')
+ const search = await screen.findByLabelText('근로자·업무 건·지금 할 일 검색')
await user.type(search, 'Contract Review')
await waitFor(() => {
@@ -326,7 +326,7 @@ describe('WorkListPage', () => {
const user = userEvent.setup()
renderPage()
- await user.click(await screen.findByRole('button', { name: 'Case 열기 →' }))
+ await user.click(await screen.findByRole('button', { name: '업무 건 열기' }))
expect(await screen.findByText('업무 상세 T-1')).toBeInTheDocument()
expect(screen.queryByText('업무 상세 CASE-1')).not.toBeInTheDocument()
@@ -406,7 +406,10 @@ describe('WorkListPage', () => {
const user = userEvent.setup()
renderPage()
- await user.type(await screen.findByLabelText('근로자·Case·업무 검색'), '존재하지 않는 검색어')
+ await user.type(
+ await screen.findByLabelText('근로자·업무 건·지금 할 일 검색'),
+ '존재하지 않는 검색어',
+ )
expect(await screen.findByText('검색 결과가 없습니다')).toBeInTheDocument()
})
@@ -428,13 +431,17 @@ describe('WorkListPage', () => {
await user.click(await screen.findByRole('option', { name: /응우옌 안/ }))
- expect(screen.getByText('우선 Case 1/2')).toBeInTheDocument()
- expect(screen.getByRole('heading', { level: 3, name: '체류연장 업무 초안' })).toBeInTheDocument()
+ expect(screen.getByText('우선 업무 건 · 1/2')).toBeInTheDocument()
+ expect(
+ screen.getByRole('heading', { level: 3, name: '체류연장 업무 초안' }),
+ ).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '다른 Case 열기 →' }))
- expect(screen.getByText('우선 Case 2/2')).toBeInTheDocument()
- expect(screen.getByRole('heading', { level: 3, name: '근로자 안내문 초안' })).toBeInTheDocument()
+ expect(screen.getByText('우선 업무 건 · 2/2')).toBeInTheDocument()
+ expect(
+ screen.getByRole('heading', { level: 3, name: '근로자 안내문 초안' }),
+ ).toBeInTheDocument()
})
it('shows a placeholder toast for "근거 보기"', async () => {
@@ -442,7 +449,7 @@ describe('WorkListPage', () => {
const user = userEvent.setup()
renderPage('/tasks', { withToasts: true })
- await user.click(await screen.findByRole('button', { name: '근거 보기 →' }))
+ await user.click(await screen.findByRole('button', { name: '근거 보기' }))
expect(screen.getByText('판단 근거 보기는 준비 중입니다.')).toBeInTheDocument()
})
diff --git a/src/pages/WorkListPage/WorkListPage.tsx b/src/pages/WorkListPage/WorkListPage.tsx
index b23ee6c..47b32fb 100644
--- a/src/pages/WorkListPage/WorkListPage.tsx
+++ b/src/pages/WorkListPage/WorkListPage.tsx
@@ -67,7 +67,6 @@ function TaskStateWorkspace({
근로자 {workers.length}명
-
업무 상태 미확인
{visibleWorkers.map((worker) => {
@@ -185,15 +184,15 @@ export function WorkListPage() {
= {
DRAFT: '초안 검토',
NEEDS_INFO: '정보 확인',
- READY_FOR_REVIEW: '검토하기',
+ READY_FOR_REVIEW: '초안 검토',
APPROVED: '실행 확인',
WAITING_WORKER: '대기 확인',
WAITING_EXTERNAL: '진행 확인',
@@ -40,8 +40,15 @@ export function getTaskStatusPresentation(status: TaskStatus): {
label: string
tone: StatusTone
} {
+ const workInboxLabel: Partial> = {
+ DRAFT: '서류 대기',
+ NEEDS_INFO: '처리 필요',
+ READY_FOR_REVIEW: '승인 대기',
+ WAITING_WORKER: '요청 전송',
+ }
+
return {
- label: TASK_STATUS_LABEL[status],
+ label: workInboxLabel[status] ?? TASK_STATUS_LABEL[status],
tone: TASK_STATUS_TONE[status],
}
}
From 62a2134222a44f96d24e7628f7e1974e91b66886 Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 18:07:19 +0900
Subject: [PATCH 08/10] =?UTF-8?q?refactor(dashboard):=20Task=20API=20?=
=?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=98=84=ED=99=A9=20=EC=97=B0=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../DashboardPage/DashboardPage.module.css | 45 +++
.../DashboardPage/DashboardPage.test.tsx | 208 +++++++++----
src/pages/DashboardPage/DashboardPage.tsx | 194 +++++++-----
src/pages/DashboardPage/dashboardData.ts | 275 +++++++++++-------
4 files changed, 485 insertions(+), 237 deletions(-)
diff --git a/src/pages/DashboardPage/DashboardPage.module.css b/src/pages/DashboardPage/DashboardPage.module.css
index cddaebd..2920bde 100644
--- a/src/pages/DashboardPage/DashboardPage.module.css
+++ b/src/pages/DashboardPage/DashboardPage.module.css
@@ -314,6 +314,35 @@
cursor: pointer;
}
+.priorityEmpty {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 72px;
+ margin-top: 15px;
+ padding: 12px 15px;
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+}
+
+.priorityEmpty p {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.priorityEmpty button {
+ height: 40px;
+ padding: 8px 16px;
+ background: var(--surface-default);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+ font-family: inherit;
+ font-size: 12px;
+ color: var(--text-primary);
+ cursor: pointer;
+}
+
.todayTasks {
display: flex;
flex-direction: column;
@@ -349,6 +378,15 @@
display: flex;
flex-direction: column;
gap: 8px;
+ max-height: 184px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+.capNotice {
+ margin: -2px 0 0;
+ font-size: 11px;
+ color: var(--text-secondary);
}
.agentPrepared {
@@ -423,6 +461,13 @@
color: var(--text-primary);
}
+.preparedEmpty {
+ margin: 0;
+ font-size: 11px;
+ line-height: 18px;
+ color: var(--text-secondary);
+}
+
.preparedSection ul {
display: flex;
flex-direction: column;
diff --git a/src/pages/DashboardPage/DashboardPage.test.tsx b/src/pages/DashboardPage/DashboardPage.test.tsx
index d1b8638..7e45fd0 100644
--- a/src/pages/DashboardPage/DashboardPage.test.tsx
+++ b/src/pages/DashboardPage/DashboardPage.test.tsx
@@ -1,92 +1,192 @@
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { MemoryRouter, Route, Routes } from 'react-router-dom'
-import { describe, expect, it } from 'vitest'
+import { MemoryRouter, Route, Routes, useParams } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TaskPageResponse, TaskSummaryResponse } from '../../api/tasks'
import { DashboardPage } from './DashboardPage'
-import styles from './DashboardPage.module.css'
-import {
- AGENT_PREPARED,
- AI_REQUEST_PROMPT_CHIPS,
- APPROVAL_QUEUE,
- METRIC_STRIP,
- TODAY_WORK_ITEMS,
-} from './dashboardData'
-
-function renderPage(demoState = 'success') {
+import { AI_REQUEST_PROMPT_CHIPS } from './dashboardData'
+
+function jsonResponse(body: unknown, init: ResponseInit = {}) {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ ...init,
+ })
+}
+
+function dateFromToday(offset: number) {
+ const date = new Date()
+ date.setHours(12, 0, 0, 0)
+ date.setDate(date.getDate() + offset)
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
+function task(
+ taskId: string,
+ overrides: Partial = {},
+): TaskSummaryResponse {
+ return {
+ task_id: taskId,
+ worker_id: 'W-1',
+ case_id: null,
+ task_type: 'STAY_PERIOD_EXTENSION',
+ workflow_id: 'WF-1',
+ workflow_catalog_version: '1',
+ title: `업무 ${taskId}`,
+ source: 'MANUAL',
+ status: 'DRAFT',
+ due_date: null,
+ content_revision: 1,
+ version: 1,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ ...overrides,
+ }
+}
+
+const TASKS = [
+ task('T-1', {
+ title: '응웬반A 체류연장 요청문',
+ source: 'AI_CANDIDATE',
+ status: 'READY_FOR_REVIEW',
+ due_date: dateFromToday(1),
+ }),
+ task('T-2', {
+ title: '계약 정보 보완',
+ status: 'NEEDS_INFO',
+ due_date: dateFromToday(5),
+ }),
+ task('T-3', {
+ title: '외국인등록증 사본 제출',
+ status: 'WAITING_WORKER',
+ due_date: dateFromToday(0),
+ }),
+ task('T-4', {
+ title: 'Agent 생성 체류연장 초안',
+ source: 'AI_CANDIDATE',
+ status: 'DRAFT',
+ due_date: dateFromToday(10),
+ }),
+ task('T-5', {
+ title: '완료된 업무',
+ status: 'COMPLETED',
+ due_date: dateFromToday(-1),
+ }),
+]
+
+function taskPage(
+ items: TaskSummaryResponse[],
+ totalElements = items.length,
+): TaskPageResponse {
+ return {
+ items,
+ page: 0,
+ size: 100,
+ total_elements: totalElements,
+ total_pages: totalElements > 100 ? 2 : 1,
+ }
+}
+
+function TaskDetailProbe() {
+ const { taskId } = useParams()
+ return 업무 상세 {taskId}
+}
+
+function renderPage() {
return render(
-
+
} />
업무 생성 페이지} />
+ 업무함} />
+ } />
,
)
}
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(taskPage(TASKS))))
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
describe('DashboardPage', () => {
- it('renders the blocking approval headline from the HOME-001 structure', () => {
+ it('renders metrics and work rows from the Task API response', async () => {
renderPage()
+
expect(
- screen.getByRole('heading', {
- name: `지금 확인이 필요한 승인 ${APPROVAL_QUEUE.blockingCount}건이 있습니다.`,
+ await screen.findByRole('heading', {
+ name: '지금 확인이 필요한 승인 1건이 있습니다.',
}),
).toBeInTheDocument()
+ expect(screen.getAllByText('1건 ›')).toHaveLength(4)
+ expect(screen.getAllByText('응웬반A 체류연장 요청문').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('외국인등록증 사본 제출').length).toBeGreaterThan(0)
+ expect(screen.queryByText('완료된 업무')).not.toBeInTheDocument()
+
+ const requestedUrl = String(vi.mocked(fetch).mock.calls[0][0])
+ expect(requestedUrl).toContain('/tasks?')
+ expect(requestedUrl).toContain('size=100')
})
- it('renders every work item row', () => {
+ it('uses actual Task status groups in the Agent prepared panel', async () => {
renderPage()
- for (const item of TODAY_WORK_ITEMS) {
- expect(screen.getByText(item.title)).toBeInTheDocument()
- }
+
+ expect(await screen.findByText('Agent 생성 초안 · 1건')).toBeInTheDocument()
+ expect(screen.getByText('담당자 확인 필요 · 2건')).toBeInTheDocument()
+ expect(screen.getByText('응답·기관 대기 · 1건')).toBeInTheDocument()
+ expect(screen.getAllByText('Agent 생성 체류연장 초안').length).toBeGreaterThan(0)
})
- it('renders the Figma status label and next action for every priority work item', () => {
+ it('opens the actual Task ID from the priority approval', async () => {
+ const user = userEvent.setup()
renderPage()
- for (const item of TODAY_WORK_ITEMS) {
- expect(screen.getByText(item.status)).toBeInTheDocument()
- expect(screen.getAllByText(item.nextAction).length).toBeGreaterThan(0)
- }
+
+ await user.click((await screen.findAllByRole('button', { name: '승인 검토' }))[0])
+
+ expect(await screen.findByText('업무 상세 T-1')).toBeInTheDocument()
})
- it('shows a loading state', () => {
- renderPage('loading')
+ it('shows the loading state while the Task API is pending', () => {
+ vi.mocked(fetch).mockReturnValue(new Promise(() => {}))
+ renderPage()
+
expect(screen.getByText('업무 현황을 불러오는 중입니다')).toBeInTheDocument()
+ expect(screen.queryByText(/지금 확인이 필요한 승인/)).not.toBeInTheDocument()
})
- it('shows an empty state with a shortcut to create work', () => {
- renderPage('empty')
- expect(screen.getByText('오늘 처리할 업무가 없습니다')).toBeInTheDocument()
- expect(screen.getByRole('button', { name: '업무 만들기' })).toBeInTheDocument()
- })
+ it('shows an honest empty state when no task exists', async () => {
+ vi.mocked(fetch).mockResolvedValue(jsonResponse(taskPage([])))
+ renderPage()
- it('shows an error state with a retry action', () => {
- renderPage('error')
- expect(screen.getByText('업무 현황을 불러오지 못했습니다')).toBeInTheDocument()
- expect(screen.getByRole('button', { name: '다시 시도' })).toBeInTheDocument()
+ expect(await screen.findByText('등록된 업무가 없습니다')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '업무 만들기' })).toBeInTheDocument()
})
- it('renders every metric strip card', () => {
+ it('shows an error state and retries the Task API request', async () => {
+ vi.mocked(fetch)
+ .mockRejectedValueOnce(new TypeError('network'))
+ .mockResolvedValueOnce(jsonResponse(taskPage(TASKS)))
+ const user = userEvent.setup()
renderPage()
- for (const metric of METRIC_STRIP) {
- expect(screen.getByText(`${metric.value}건 ›`)).toBeInTheDocument()
- }
+
+ await user.click(await screen.findByRole('button', { name: '다시 시도' }))
+
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2))
+ expect((await screen.findAllByText('응웬반A 체류연장 요청문')).length).toBeGreaterThan(0)
})
- it('renders every Agent prepared group', () => {
+ it('renders a safe cap notice when the API has more than 100 tasks', async () => {
+ vi.mocked(fetch).mockResolvedValue(jsonResponse(taskPage(TASKS, 101)))
renderPage()
- const items = [
- ...AGENT_PREPARED.prepared,
- ...AGENT_PREPARED.review,
- ...AGENT_PREPARED.afterApproval,
- ]
- for (const item of items) {
- expect(screen.getByText(item.label)).toBeInTheDocument()
- }
- })
- it('uses the Figma desktop grid class for the success view', () => {
- const { container } = renderPage()
- expect(container.querySelector(`.${styles.dashboardGrid}`)).toBeInTheDocument()
+ expect(await screen.findByText(/최근 100건 기준입니다/)).toBeInTheDocument()
})
it('navigates to work creation with the chosen prompt chip prefilled', async () => {
diff --git a/src/pages/DashboardPage/DashboardPage.tsx b/src/pages/DashboardPage/DashboardPage.tsx
index d33274e..850dd03 100644
--- a/src/pages/DashboardPage/DashboardPage.tsx
+++ b/src/pages/DashboardPage/DashboardPage.tsx
@@ -1,37 +1,45 @@
+import { useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
+import { fetchTasks } from '../../api/tasks'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
-import { WorkItemRow, type WorkItemStatusTone } from '../../components/ui/WorkItemRow/WorkItemRow'
-import { useAsyncDemoData } from '../../hooks/useAsyncDemoData'
+import { WorkItemRow } from '../../components/ui/WorkItemRow/WorkItemRow'
+import { useApiQuery } from '../../hooks/useApiQuery'
import agentSparkIcon from './assets/agent-spark.svg'
import commandSubmitIcon from './assets/command-submit.svg'
import styles from './DashboardPage.module.css'
import {
- AGENT_PREPARED,
AI_REQUEST_PROMPT_CHIPS,
- APPROVAL_QUEUE,
- METRIC_STRIP,
- TODAY_WORK_ITEMS,
- type DashboardWorkStatus,
+ buildAgentPrepared,
+ buildDashboardMetrics,
+ buildDashboardWorkItems,
+ buildPriorityApproval,
} from './dashboardData'
-const STATUS_TONE: Record = {
- 승인대기: 'warning',
- 요청전송: 'primary',
- 서류대기: 'neutral',
-}
-
export function DashboardPage() {
const navigate = useNavigate()
- const status = useAsyncDemoData(TODAY_WORK_ITEMS.length === 0)
+ const taskFetcher = useCallback(() => fetchTasks({ size: 100 }), [])
+ const isEmpty = useCallback((page: { items: unknown[] }) => page.items.length === 0, [])
+ const { status, data: taskPage, error, refetch } = useApiQuery(taskFetcher, isEmpty)
+ const tasks = useMemo(() => taskPage?.items ?? [], [taskPage])
+ const metrics = useMemo(() => buildDashboardMetrics(tasks), [tasks])
+ const workItems = useMemo(() => buildDashboardWorkItems(tasks), [tasks])
+ const priorityApproval = useMemo(() => buildPriorityApproval(tasks), [tasks])
+ const agentPrepared = useMemo(() => buildAgentPrepared(tasks), [tasks])
+ const pendingApprovalCount = metrics.find((metric) => metric.id === 'pending-approval')?.value ?? 0
+
+ const headline =
+ status === 'success'
+ ? `지금 확인이 필요한 승인 ${pendingApprovalCount}건이 있습니다.`
+ : status === 'empty'
+ ? '현재 등록된 업무가 없습니다.'
+ : '업무 현황을 확인하고 있습니다.'
return (
@@ -67,7 +75,7 @@ export function DashboardPage() {
@@ -78,9 +86,9 @@ export function DashboardPage() {
navigate('/dashboard', { replace: true })}
+ onAction={refetch}
/>
)}
@@ -89,7 +97,7 @@ export function DashboardPage() {
navigate('/tasks/new')}
@@ -101,12 +109,12 @@ export function DashboardPage() {
- {METRIC_STRIP.map((metric) => (
+ {metrics.map((metric) => (
navigate(`/tasks?view=${metric.id}`)}
+ onClick={() => navigate('/tasks')}
>
{metric.label}
@@ -122,49 +130,68 @@ export function DashboardPage() {
먼저 검토할 승인 업무
- 요청 · {APPROVAL_QUEUE.oldestValue}
+
+ {priorityApproval ? `요청 · ${priorityApproval.requestedLabel}` : '승인 대기 0건'}
+
-
-
-
-
{APPROVAL_QUEUE.title}
-
{APPROVAL_QUEUE.meta}
-
{APPROVAL_QUEUE.note}
+ {priorityApproval ? (
+
+
+
+ {priorityApproval.title}
+ {priorityApproval.meta}
+ {priorityApproval.note}
+
+
navigate(`/tasks/${priorityApproval.id}`)}
+ >
+ 승인 검토
+
+
navigate(`/tasks/${priorityApproval.id}`)}
+ >
+ ›
+
+
+ ) : (
+
+
현재 담당자 승인을 기다리는 업무가 없습니다.
navigate('/tasks')}>
- 승인 검토
+ 업무함 보기
-
navigate('/tasks')}
- >
- ›
-
-
+ )}
오늘의 우선 업무
-
지금 할 일 · {TODAY_WORK_ITEMS.length}건
+
지금 할 일 · {workItems.length}건
- {TODAY_WORK_ITEMS.map((item) => (
+ {workItems.map((item) => (
navigate(`/tasks/${item.id}`)}
/>
))}
+ {taskPage && taskPage.total_elements > 100 && (
+
+ 최근 100건 기준입니다. 전체 업무는 업무함에서 확인해 주세요.
+
+ )}
@@ -174,47 +201,62 @@ export function DashboardPage() {
Agent가 준비한 내용
-
준비 완료 4건 · HR 확인 필요 2건
-
Agent는 초안까지만 준비하며, 검토와 승인은 담당자가 수행합니다.
+
+ 연결된 업무 {agentPrepared.connectedCount}건 · 담당자 확인 필요{' '}
+ {agentPrepared.review.length}건
+
+
Task 상태만 표시하며, 문서 준비와 승인 결과는 각 API 응답을 따릅니다.
- 준비 완료 · 4건
-
- {AGENT_PREPARED.prepared.map((item) => (
-
- ✓
- {item.label}
-
- ))}
-
+ Agent 생성 초안 · {agentPrepared.prepared.length}건
+ {agentPrepared.prepared.length > 0 ? (
+
+ {agentPrepared.prepared.map((item) => (
+
+ ✓
+ {item.label}
+
+ ))}
+
+ ) : (
+ 현재 표시할 Agent 초안이 없습니다.
+ )}
- HR 확인 필요 · 2건
-
- {AGENT_PREPARED.review.map((item) => (
-
- !
- {item.label}
- {item.description}
-
- ))}
-
+ 담당자 확인 필요 · {agentPrepared.review.length}건
+ {agentPrepared.review.length > 0 ? (
+
+ {agentPrepared.review.map((item) => (
+
+ !
+ {item.label}
+ {item.description}
+
+ ))}
+
+ ) : (
+ 현재 확인이 필요한 업무가 없습니다.
+ )}
- 승인 후 진행 · 2건
-
- {AGENT_PREPARED.afterApproval.map((item) => (
-
- →
- {item.label}
- {item.description}
-
- ))}
-
+ 응답·기관 대기 · {agentPrepared.afterApproval.length}건
+ {agentPrepared.afterApproval.length > 0 ? (
+
+ {agentPrepared.afterApproval.map((item) => (
+
+ →
+ {item.label}
+ {item.description}
+
+ ))}
+
+ ) : (
+ 현재 대기 중인 업무가 없습니다.
+ )}
diff --git a/src/pages/DashboardPage/dashboardData.ts b/src/pages/DashboardPage/dashboardData.ts
index 20cce7c..35956bf 100644
--- a/src/pages/DashboardPage/dashboardData.ts
+++ b/src/pages/DashboardPage/dashboardData.ts
@@ -1,61 +1,15 @@
+import type { TaskStatus, TaskSummaryResponse } from '../../api/tasks'
+import type {
+ WorkItemStatusTone,
+ WorkItemUrgency,
+} from '../../components/ui/WorkItemRow/WorkItemRow'
+import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
+import { daysUntil } from '../../utils/urgency'
import metricApprovalIcon from './assets/metric-approval.svg'
import metricDueIcon from './assets/metric-due.svg'
import metricInfoIcon from './assets/metric-info.svg'
import metricResponseIcon from './assets/metric-response.svg'
-// TODO(backend): 이 파일의 상수는 Figma HOME-001을 재현하기 위한 Prototype 데이터다.
-// Dashboard Projection API가 준비되면 동일한 ViewModel 형태로 응답을 정규화한다.
-
-export type DashboardWorkStatus = '승인대기' | '요청전송' | '서류대기'
-export type DashboardWorkTone = 'warning' | 'critical' | 'info'
-
-export interface DashboardWorkItem {
- id: string
- title: string
- status: DashboardWorkStatus
- schedule: string
- assignee?: string
- nextAction: string
- urgency: DashboardWorkTone
-}
-
-export const TODAY_WORK_ITEMS: DashboardWorkItem[] = [
- {
- id: 'WI-1',
- title: '응웬반A 체류연장 요청문',
- status: '승인대기',
- schedule: 'D-2',
- assignee: '담당 김민지',
- nextAction: '승인 검토',
- urgency: 'warning',
- },
- {
- id: 'WI-2',
- title: '외국인등록증 사본 제출',
- status: '요청전송',
- schedule: 'D-0',
- nextAction: '요청 현황',
- urgency: 'critical',
- },
- {
- id: 'WI-3',
- title: '7월 외부기관 제출자료',
- status: '서류대기',
- schedule: 'D-3',
- nextAction: '증빙 등록',
- urgency: 'info',
- },
-]
-
-export const APPROVAL_QUEUE = {
- blockingCount: 2,
- totalCount: 7,
- oldestValue: '3시간 전',
- title: '응웬반A 체류연장 요청문 승인',
- meta: '응웬반A · D-2 · 담당 김민지',
- note: '승인을 완료하면 근로자 안내 단계가 활성화됩니다.',
-}
-
export const AI_REQUEST_PROMPT_CHIPS = [
'체류기간 연장',
'누락 문서 확인',
@@ -73,72 +27,179 @@ export interface DashboardMetric {
tone: DashboardMetricTone
}
-export const METRIC_STRIP: DashboardMetric[] = [
- {
- id: 'pending-approval',
- label: '승인 대기',
- value: APPROVAL_QUEUE.totalCount,
- iconSrc: metricApprovalIcon,
- tone: 'warning',
- },
- {
- id: 'due-today',
- label: '오늘 마감',
- value: 6,
- iconSrc: metricDueIcon,
- tone: 'info',
- },
- {
- id: 'needs-info',
- label: '정보 보완',
- value: 1,
- iconSrc: metricInfoIcon,
- tone: 'critical',
- },
- {
- id: 'worker-response',
- label: '근로자 응답',
- value: 8,
- iconSrc: metricResponseIcon,
- tone: 'success',
- },
-]
+export interface DashboardWorkItem {
+ id: string
+ title: string
+ status: string
+ statusTone: WorkItemStatusTone
+ schedule: string
+ nextAction: string
+ urgency: WorkItemUrgency
+}
+
+export interface DashboardPriorityApproval {
+ id: string
+ title: string
+ meta: string
+ note: string
+ requestedLabel: string
+}
-export interface AgentPreparedItem {
+export interface DashboardAgentItem {
id: string
label: string
description?: string
}
-export const AGENT_PREPARED = {
- prepared: [
- { id: 'documents', label: '필요 문서 5개 확인' },
- { id: 'draft', label: '체류연장 요청문 초안' },
- { id: 'connected', label: '기존 계약·체류 정보 연결' },
- { id: 'duplicate', label: '유사 업무 중복 여부 확인' },
- ] satisfies AgentPreparedItem[],
- review: [
+export interface DashboardAgentPrepared {
+ connectedCount: number
+ prepared: DashboardAgentItem[]
+ review: DashboardAgentItem[]
+ afterApproval: DashboardAgentItem[]
+}
+
+const STATUS_PRESENTATION: Record<
+ TaskStatus,
+ { label: string; tone: WorkItemStatusTone; action: string }
+> = {
+ DRAFT: { label: '서류 대기', tone: 'neutral', action: '초안 검토' },
+ NEEDS_INFO: { label: '정보 보완', tone: 'warning', action: '정보 확인' },
+ READY_FOR_REVIEW: { label: '승인 대기', tone: 'warning', action: '승인 검토' },
+ APPROVED: { label: '승인 완료', tone: 'primary', action: '실행 확인' },
+ WAITING_WORKER: { label: '요청 전송', tone: 'primary', action: '요청 현황' },
+ WAITING_EXTERNAL: { label: '기관 대기', tone: 'neutral', action: '진행 확인' },
+ COMPLETED: { label: '완료', tone: 'primary', action: '완료 확인' },
+ CANCELLED: { label: '취소', tone: 'neutral', action: '취소 확인' },
+}
+
+function isOpenTask(task: TaskSummaryResponse) {
+ return task.status !== 'COMPLETED' && task.status !== 'CANCELLED'
+}
+
+function compareDueDate(a: TaskSummaryResponse, b: TaskSummaryResponse) {
+ if (!a.due_date && !b.due_date) return a.updated_at.localeCompare(b.updated_at)
+ if (!a.due_date) return 1
+ if (!b.due_date) return -1
+ return a.due_date.localeCompare(b.due_date)
+}
+
+function getUrgency(dueDate: string | null): WorkItemUrgency {
+ const days = daysUntil(dueDate)
+ if (days !== null && days <= 0) return 'critical'
+ if (days !== null && days <= 7) return 'warning'
+ if (days !== null && days <= 30) return 'info'
+ return 'neutral'
+}
+
+function getRequestedLabel(updatedAt: string, now = new Date()) {
+ const elapsed = Math.max(0, now.getTime() - new Date(updatedAt).getTime())
+ const hours = Math.floor(elapsed / (60 * 60 * 1000))
+ if (hours < 1) return '방금 전'
+ if (hours < 24) return `${hours}시간 전`
+ return `${Math.floor(hours / 24)}일 전`
+}
+
+export function buildDashboardMetrics(tasks: TaskSummaryResponse[]): DashboardMetric[] {
+ const openTasks = tasks.filter(isOpenTask)
+ return [
{
- id: 'passport',
- label: '여권 만료일 확인',
- description: '여권 원본과 만료일을 확인한 뒤 승인합니다.',
+ id: 'pending-approval',
+ label: '승인 대기',
+ value: openTasks.filter((task) => task.status === 'READY_FOR_REVIEW').length,
+ iconSrc: metricApprovalIcon,
+ tone: 'warning',
},
{
- id: 'deadline',
- label: '추천 마감일·담당자 확인',
- description: '업무량과 제출 기한을 확인한 뒤 확정합니다.',
+ id: 'due-today',
+ label: '오늘 마감',
+ value: openTasks.filter((task) => daysUntil(task.due_date) === 0).length,
+ iconSrc: metricDueIcon,
+ tone: 'info',
},
- ] satisfies AgentPreparedItem[],
- afterApproval: [
{
- id: 'worker-request',
- label: '근로자 요청문 확인',
- description: '승인되면 근로자 요청 확인 단계가 열립니다.',
+ id: 'needs-info',
+ label: '정보 보완',
+ value: openTasks.filter((task) => task.status === 'NEEDS_INFO').length,
+ iconSrc: metricInfoIcon,
+ tone: 'critical',
},
{
- id: 'secure-link',
- label: '보안 링크 발급 준비',
- description: '승인 후 담당자가 발급하고 근로자에게 전달합니다.',
+ id: 'worker-response',
+ label: '응답 대기',
+ value: openTasks.filter((task) => task.status === 'WAITING_WORKER').length,
+ iconSrc: metricResponseIcon,
+ tone: 'success',
},
- ] satisfies AgentPreparedItem[],
+ ]
+}
+
+export function buildDashboardWorkItems(tasks: TaskSummaryResponse[]): DashboardWorkItem[] {
+ return tasks
+ .filter(isOpenTask)
+ .sort(compareDueDate)
+ .slice(0, 5)
+ .map((task) => {
+ const presentation = STATUS_PRESENTATION[task.status]
+ const due = getOperationalDateViewModel('TASK_DUE', task.due_date)
+ return {
+ id: task.task_id,
+ title: task.title,
+ status: presentation.label,
+ statusTone: presentation.tone,
+ schedule: due.relative ?? '기한 미정',
+ nextAction: presentation.action,
+ urgency: getUrgency(task.due_date),
+ }
+ })
+}
+
+export function buildPriorityApproval(
+ tasks: TaskSummaryResponse[],
+ now = new Date(),
+): DashboardPriorityApproval | null {
+ const task = tasks
+ .filter((item) => item.status === 'READY_FOR_REVIEW')
+ .sort(compareDueDate)[0]
+ if (!task) return null
+
+ const due = getOperationalDateViewModel('TASK_DUE', task.due_date)
+ return {
+ id: task.task_id,
+ title: task.title,
+ meta: `${due.relative ?? '기한 미정'} · 승인 대기`,
+ note: 'Task API에서 담당자 검토가 필요한 상태로 확인됐습니다.',
+ requestedLabel: getRequestedLabel(task.updated_at, now),
+ }
+}
+
+export function buildAgentPrepared(tasks: TaskSummaryResponse[]): DashboardAgentPrepared {
+ const openTasks = tasks.filter(isOpenTask)
+ const prepared = openTasks
+ .filter((task) => task.source === 'AI_CANDIDATE' && task.status === 'DRAFT')
+ .slice(0, 4)
+ .map((task) => ({ id: task.task_id, label: task.title }))
+ const review = openTasks
+ .filter((task) => task.status === 'NEEDS_INFO' || task.status === 'READY_FOR_REVIEW')
+ .slice(0, 4)
+ .map((task) => ({
+ id: task.task_id,
+ label: task.title,
+ description:
+ task.status === 'NEEDS_INFO'
+ ? '필수 정보를 보완한 뒤 다시 검토합니다.'
+ : '상세 내용을 확인한 뒤 담당자가 결정합니다.',
+ }))
+ const afterApproval = openTasks
+ .filter((task) => task.status === 'WAITING_WORKER' || task.status === 'WAITING_EXTERNAL')
+ .slice(0, 4)
+ .map((task) => ({
+ id: task.task_id,
+ label: task.title,
+ description:
+ task.status === 'WAITING_WORKER'
+ ? '근로자 응답을 기다리고 있습니다.'
+ : '외부기관 처리 결과를 기다리고 있습니다.',
+ }))
+
+ return { connectedCount: openTasks.length, prepared, review, afterApproval }
}
From e81c3c458a16c37df6b68a6cde15a818ecd40025 Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 18:33:24 +0900
Subject: [PATCH 09/10] =?UTF-8?q?fix(auth):=20=EA=B0=9C=EB=B0=9C=20?=
=?UTF-8?q?=ED=94=84=EB=A1=9D=EC=8B=9C=EC=99=80=20=EC=84=B8=EC=85=98=20?=
=?UTF-8?q?=EB=B3=B5=EC=9B=90=20=EC=95=88=EC=A0=95=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 1 +
README.md | 8 +++++
src/store/authStore.test.ts | 22 +++++++++++++
src/store/authStore.ts | 63 ++++++++++++++++++++++---------------
vite.config.ts | 8 +++++
5 files changed, 77 insertions(+), 25 deletions(-)
create mode 100644 .env.example
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2bf124d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+VITE_API_BASE_URL=/api/v1
diff --git a/README.md b/README.md
index f42b8e6..4f49723 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,17 @@ React + TypeScript 기반 프론트엔드 : HR 대시보드, 업무카드, 근
```bash
npm install
+cp .env.example .env
npm run dev
```
+## 로컬 백엔드 연결
+
+개발 서버는 `/api` 요청을 `http://127.0.0.1:8080`으로 전달합니다. 프론트에서는
+`VITE_API_BASE_URL=/api/v1`을 사용해야 로그인 Refresh Cookie가 같은 출처로 유지됩니다.
+백엔드 주소를 `VITE_API_BASE_URL`에 직접 넣으면 로그인 직후 요청은 성공해도 새로고침 시
+세션 복원이 실패할 수 있습니다.
+
## 스크립트
| 명령 | 설명 |
diff --git a/src/store/authStore.test.ts b/src/store/authStore.test.ts
index 8f166dc..293f3c1 100644
--- a/src/store/authStore.test.ts
+++ b/src/store/authStore.test.ts
@@ -111,6 +111,28 @@ describe('useAuthStore.logout', () => {
})
describe('useAuthStore.restoreSession', () => {
+ it('does not send duplicate refresh requests while restoration is in progress', async () => {
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(
+ jsonResponse({
+ access_token: 'refreshed-token',
+ token_type: 'Bearer',
+ expires_in_seconds: 900,
+ expires_at: '2026-07-22T01:15:00Z',
+ }),
+ )
+ .mockResolvedValueOnce(jsonResponse({ user_id: 'u-1', company_id: 'c-1', roles: ['HR'] }))
+
+ const firstRestore = useAuthStore.getState().restoreSession()
+ const secondRestore = useAuthStore.getState().restoreSession()
+
+ expect(secondRestore).toBe(firstRestore)
+ await Promise.all([firstRestore, secondRestore])
+
+ expect(fetch).toHaveBeenCalledTimes(2)
+ expect(useAuthStore.getState().user?.role).toBe('HR')
+ })
+
it('restores the user from a valid refresh cookie plus /auth/me', async () => {
// 이 프로젝트의 테스트 환경에서는 Node 내장 localStorage가 jsdom 것보다 먼저 잡혀
// 저장이 조용히 실패할 수 있다 (구현도 이 상황을 try/catch로 감내하도록 설계했다).
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
index ae1b9ce..294e932 100644
--- a/src/store/authStore.ts
+++ b/src/store/authStore.ts
@@ -101,6 +101,8 @@ function toApiErrorMessage(error: unknown, fallback: string): string {
return error instanceof ApiError ? getErrorMessage(error) : fallback
}
+let sessionRestorePromise: Promise | null = null
+
export const useAuthStore = create((set) => {
// client.ts는 이 스토어를 모르는 채로 동작하므로(순환 참조 방지), refresh 재시도까지
// 실패했을 때 로그아웃 처리를 여기서 콜백으로 연결해준다.
@@ -144,31 +146,42 @@ export const useAuthStore = create((set) => {
set({ user: null, status: 'ready' })
},
- restoreSession: async () => {
- set({ status: 'restoring' })
- try {
- const refreshBody = await apiFetch('/auth/refresh', {
- method: 'POST',
- skipAuthRetry: true,
- })
- setAccessToken(refreshBody.access_token)
-
- const me = await apiFetch('/auth/me')
- const persisted = readPersistedProfile()
- set({
- user: {
- name: persisted?.name ?? '사용자',
- workplace: persisted?.workplace ?? '',
- role: me.roles[0] ?? '',
- },
- status: 'ready',
- })
- } catch {
- // 쿠키가 없거나 만료됐으면 로그인 화면으로 보내는 게 정상 흐름이라 에러로 취급하지 않는다.
- setAccessToken(null)
- clearPersistedProfile()
- set({ user: null, status: 'ready' })
- }
+ restoreSession: () => {
+ // React StrictMode는 개발 환경에서 mount effect를 두 번 실행할 수 있다. Refresh Token은
+ // 요청마다 회전하므로 같은 쿠키로 복원 요청을 동시에 보내면 한쪽이 실패해 정상 세션까지
+ // 로그아웃 처리될 수 있다. 동시에 호출되면 진행 중인 하나의 Promise를 공유한다.
+ if (sessionRestorePromise) return sessionRestorePromise
+
+ sessionRestorePromise = (async () => {
+ set({ status: 'restoring' })
+ try {
+ const refreshBody = await apiFetch('/auth/refresh', {
+ method: 'POST',
+ skipAuthRetry: true,
+ })
+ setAccessToken(refreshBody.access_token)
+
+ const me = await apiFetch('/auth/me')
+ const persisted = readPersistedProfile()
+ set({
+ user: {
+ name: persisted?.name ?? '사용자',
+ workplace: persisted?.workplace ?? '',
+ role: me.roles[0] ?? '',
+ },
+ status: 'ready',
+ })
+ } catch {
+ // 쿠키가 없거나 만료됐으면 로그인 화면으로 보내는 게 정상 흐름이라 에러로 취급하지 않는다.
+ setAccessToken(null)
+ clearPersistedProfile()
+ set({ user: null, status: 'ready' })
+ } finally {
+ sessionRestorePromise = null
+ }
+ })()
+
+ return sessionRestorePromise
},
}
})
diff --git a/vite.config.ts b/vite.config.ts
index d74bb4f..cab75ff 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -4,6 +4,14 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:8080',
+ changeOrigin: true,
+ },
+ },
+ },
test: {
environment: 'jsdom',
globals: true,
From 3ebfe1905f1fb38edd1ffe2e5135f8de2889473c Mon Sep 17 00:00:00 2001
From: hywznn
Date: Tue, 4 Aug 2026 18:40:04 +0900
Subject: [PATCH 10/10] =?UTF-8?q?feat(dashboard):=20Agent=20=EC=9A=94?=
=?UTF-8?q?=EC=B2=AD=20=EC=9E=85=EB=A0=A5=20=EC=97=B0=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../CreateWorkPage/CreateWorkPage.test.tsx | 10 +++-
.../DashboardPage/DashboardPage.module.css | 51 +++++++++++++++----
.../DashboardPage/DashboardPage.test.tsx | 28 ++++++++--
src/pages/DashboardPage/DashboardPage.tsx | 40 +++++++++++----
4 files changed, 104 insertions(+), 25 deletions(-)
diff --git a/src/pages/CreateWorkPage/CreateWorkPage.test.tsx b/src/pages/CreateWorkPage/CreateWorkPage.test.tsx
index 60de10e..2c0c176 100644
--- a/src/pages/CreateWorkPage/CreateWorkPage.test.tsx
+++ b/src/pages/CreateWorkPage/CreateWorkPage.test.tsx
@@ -19,9 +19,9 @@ afterEach(() => {
useToastStore.setState({ toasts: [] })
})
-function renderPage() {
+function renderPage(initialEntry: string | { pathname: string; state?: unknown } = '/tasks/new') {
render(
-
+
{
+ it('uses a request forwarded from the dashboard as the initial input', () => {
+ renderPage({ pathname: '/tasks/new', state: { prefill: '응웬반A 체류기간 연장 준비' } })
+
+ expect(screen.getByLabelText('업무 요청 내용')).toHaveValue('응웬반A 체류기간 연장 준비')
+ })
+
it('disables the analyze button until a request is entered', async () => {
const user = userEvent.setup()
renderPage()
diff --git a/src/pages/DashboardPage/DashboardPage.module.css b/src/pages/DashboardPage/DashboardPage.module.css
index 2920bde..d242980 100644
--- a/src/pages/DashboardPage/DashboardPage.module.css
+++ b/src/pages/DashboardPage/DashboardPage.module.css
@@ -55,10 +55,9 @@
color: var(--brand-primary);
}
-.commandInput {
+.commandForm {
display: flex;
align-items: center;
- justify-content: space-between;
width: calc(100% - 37px);
height: 40px;
margin: 6px 18px 0;
@@ -67,23 +66,52 @@
background: rgba(207, 227, 227, 0.7);
border: 0;
border-radius: 21px;
+}
+
+.commandForm:focus-within {
+ box-shadow: 0 0 0 2px rgba(7, 132, 127, 0.18);
+}
+
+.commandInput {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ outline: 0;
font-family: inherit;
font-size: 13px;
font-weight: 500;
line-height: 22px;
color: #465b5d;
text-align: left;
- cursor: pointer;
}
-.commandInput span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+.commandInput::placeholder {
+ color: #465b5d;
+ opacity: 1;
}
-.commandInput img {
+.commandSubmit {
+ display: flex;
flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 3px;
+ background: transparent;
+ border: 0;
+ border-radius: 50%;
+ cursor: pointer;
+}
+
+.commandSubmit:disabled {
+ cursor: default;
+ opacity: 0.58;
+}
+
+.commandSubmit img {
width: 18px;
height: 18px;
}
@@ -110,6 +138,11 @@
cursor: pointer;
}
+.promptChip[aria-pressed='true'] {
+ background: #d7ebea;
+ color: var(--brand-primary);
+}
+
.dashboardGrid {
display: grid;
grid-template-columns: minmax(0, 767px) minmax(0, 359px);
@@ -578,7 +611,7 @@
min-height: 144px;
}
- .commandInput {
+ .commandForm {
width: 100%;
margin-right: 0;
margin-left: 0;
diff --git a/src/pages/DashboardPage/DashboardPage.test.tsx b/src/pages/DashboardPage/DashboardPage.test.tsx
index 7e45fd0..57356fb 100644
--- a/src/pages/DashboardPage/DashboardPage.test.tsx
+++ b/src/pages/DashboardPage/DashboardPage.test.tsx
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { MemoryRouter, Route, Routes, useParams } from 'react-router-dom'
+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 { DashboardPage } from './DashboardPage'
@@ -95,12 +95,18 @@ function TaskDetailProbe() {
return 업무 상세 {taskId}
}
+function WorkCreateProbe() {
+ const location = useLocation()
+ const prefill = (location.state as { prefill?: string } | null)?.prefill
+ return 업무 생성 {prefill}
+}
+
function renderPage() {
return render(
} />
- 업무 생성 페이지} />
+ } />
업무함} />
} />
@@ -189,12 +195,26 @@ describe('DashboardPage', () => {
expect(await screen.findByText(/최근 100건 기준입니다/)).toBeInTheDocument()
})
- it('navigates to work creation with the chosen prompt chip prefilled', async () => {
+ it('fills the input from a prompt chip and forwards it on submit', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: AI_REQUEST_PROMPT_CHIPS[0] }))
+ const requestInput = screen.getByRole('textbox', { name: 'Agent 업무 요청' })
+ expect(requestInput).toHaveValue(AI_REQUEST_PROMPT_CHIPS[0])
+
+ await user.click(screen.getByRole('button', { name: '업무 요청 계속하기' }))
+
+ expect(await screen.findByText(`업무 생성 ${AI_REQUEST_PROMPT_CHIPS[0]}`)).toBeInTheDocument()
+ })
+
+ it('accepts a natural-language request directly and submits it with Enter', async () => {
+ const user = userEvent.setup()
+ renderPage()
+
+ const requestInput = screen.getByRole('textbox', { name: 'Agent 업무 요청' })
+ await user.type(requestInput, '응웬반A 체류기간 연장 준비{Enter}')
- expect(await screen.findByText('업무 생성 페이지')).toBeInTheDocument()
+ expect(await screen.findByText('업무 생성 응웬반A 체류기간 연장 준비')).toBeInTheDocument()
})
})
diff --git a/src/pages/DashboardPage/DashboardPage.tsx b/src/pages/DashboardPage/DashboardPage.tsx
index 850dd03..03d2fe4 100644
--- a/src/pages/DashboardPage/DashboardPage.tsx
+++ b/src/pages/DashboardPage/DashboardPage.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo } from 'react'
+import { useCallback, useMemo, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { fetchTasks } from '../../api/tasks'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
@@ -17,6 +17,7 @@ import {
export function DashboardPage() {
const navigate = useNavigate()
+ const [agentRequest, setAgentRequest] = useState('')
const taskFetcher = useCallback(() => fetchTasks({ size: 100 }), [])
const isEmpty = useCallback((page: { items: unknown[] }) => page.items.length === 0, [])
const { status, data: taskPage, error, refetch } = useApiQuery(taskFetcher, isEmpty)
@@ -34,6 +35,13 @@ export function DashboardPage() {
? '현재 등록된 업무가 없습니다.'
: '업무 현황을 확인하고 있습니다.'
+ function handleAgentRequestSubmit(event: FormEvent) {
+ event.preventDefault()
+ const prefill = agentRequest.trim()
+ if (!prefill) return
+ navigate('/tasks/new', { state: { prefill } })
+ }
+
return (
@@ -48,21 +56,33 @@ export function DashboardPage() {
Agent 업무 요청
- navigate('/tasks/new')}
- >
- 처리할 업무를 자연어로 입력해 주세요. 예: 응웬반A의 체류기간 연장 준비
-
-
+
{AI_REQUEST_PROMPT_CHIPS.map((chip) => (
navigate('/tasks/new', { state: { prefill: chip } })}
+ aria-pressed={agentRequest === chip}
+ onClick={() => setAgentRequest(chip)}
>
{chip}