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() { -
+ {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 ? '확인' : '보완 필요'}

)}
-
-

완료 조건

-

현재 서버 상태와 필수 조건을 기준으로 확인합니다.

+
+

완료까지 필요한 조건

+

현재 진행을 막는 조건을 먼저 확인하세요.

+ {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} +

+ )}
-

제출 결과는 담당자에게 전달되며 같은 요청의 중복 제출은 차단됩니다.

+

다음 화면에서 요청받은 파일을 선택해 제출할 수 있습니다.

) } 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}>

사진 또는 파일을
추가해 주세요

-

여권 사진면 전체가 보이고 글자가 흐리지 않은지 확인해 주세요.

+

+ 여권 사진면 전체가 보이고 글자가 흐리지 않은지 확인해 주세요. +

-

- 업로드가 끝난 뒤 제출 응답까지 접수되어야 담당자 화면에 반영됩니다. -

+

제출한 파일은 회사 인사팀 담당자가 확인합니다.

) } 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 && ( + + )}
- {group.cases.length > 1 && ( - - )}
@@ -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" />