From c5b4ceecae6ce93de23d273c6a0c6d9a1da506a6 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sun, 9 Aug 2026 00:23:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Today=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/dashboard.test.ts | 43 ++++ src/api/dashboard.ts | 60 ++++++ .../DashboardPage/DashboardPage.module.css | 120 ++++++++++- .../DashboardPage/DashboardPage.test.tsx | 186 ++++++++++-------- src/pages/DashboardPage/DashboardPage.tsx | 150 ++++++++++---- src/pages/DashboardPage/dashboardData.ts | 169 +++++++++------- 6 files changed, 518 insertions(+), 210 deletions(-) create mode 100644 src/api/dashboard.test.ts create mode 100644 src/api/dashboard.ts diff --git a/src/api/dashboard.test.ts b/src/api/dashboard.test.ts new file mode 100644 index 0000000..6bd504c --- /dev/null +++ b/src/api/dashboard.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchDashboardToday } from './dashboard' + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + summary_counts: { + pending_approval: 1, + due_today: 2, + needs_info: 3, + worker_response: 4, + }, + priority_tasks: [], + upcoming_7_days: [], + recommendations: { + connected_count: 0, + prepared: [], + review: [], + after_approval: [], + }, + approval_count: 1, + worker_response_count: 4, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ) +}) + +afterEach(() => vi.unstubAllGlobals()) + +describe('fetchDashboardToday', () => { + it('requests the Today projection with the selected timezone', async () => { + await fetchDashboardToday('Asia/Seoul') + + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(String(url)).toContain('/dashboard/today?timezone=Asia%2FSeoul') + expect(init?.method).toBeUndefined() + }) +}) diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts new file mode 100644 index 0000000..ec7784e --- /dev/null +++ b/src/api/dashboard.ts @@ -0,0 +1,60 @@ +import type { DocumentType } from './documents' +import { apiFetch } from './client' +import type { TaskStatus } from './tasks' + +export interface DashboardSummaryCountsResponse { + pending_approval: number + due_today: number + needs_info: number + worker_response: number +} + +export interface DashboardTaskSummaryResponse { + task_id: string + worker_id: string + title: string + status: TaskStatus + due_date: string | null +} + +export type UpcomingExpiryCategory = + | 'STAY_EXPIRY' + | 'CONTRACT_END' + | 'EMPLOYMENT_PERMIT_END' + | 'EMPLOYMENT_ACTIVITY_END' + | 'DOCUMENT_EXPIRY' + +export interface UpcomingExpiryItemResponse { + worker_id: string + display_name: string + category: UpcomingExpiryCategory + expiry_date: string + document_type: DocumentType | null +} + +export interface DashboardRecommendationItemResponse { + task_id: string + title: string + status: TaskStatus +} + +export interface DashboardRecommendationsResponse { + connected_count: number + prepared: DashboardRecommendationItemResponse[] + review: DashboardRecommendationItemResponse[] + after_approval: DashboardRecommendationItemResponse[] +} + +export interface DashboardTodayResponse { + summary_counts: DashboardSummaryCountsResponse + priority_tasks: DashboardTaskSummaryResponse[] + upcoming_7_days: UpcomingExpiryItemResponse[] + recommendations: DashboardRecommendationsResponse + approval_count: number + worker_response_count: number +} + +export function fetchDashboardToday(timezone = 'Asia/Seoul'): Promise { + const query = new URLSearchParams({ timezone }) + return apiFetch(`/dashboard/today?${query.toString()}`) +} diff --git a/src/pages/DashboardPage/DashboardPage.module.css b/src/pages/DashboardPage/DashboardPage.module.css index 381058e..2156426 100644 --- a/src/pages/DashboardPage/DashboardPage.module.css +++ b/src/pages/DashboardPage/DashboardPage.module.css @@ -433,12 +433,92 @@ scrollbar-width: thin; } -.capNotice { - margin: -2px 0 0; +.sectionEmpty { + margin: 0; + padding: 16px; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-8); + font-size: 12px; + color: var(--text-secondary); +} + +.upcomingExpiry { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.expiryList { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.expiryItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 58px; + padding: 10px 12px; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-left: 3px solid var(--border-strong); + border-radius: var(--fowoco-radius-8); + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; +} + +.expiryItem:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-sm); +} + +.expiryItem > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.expiryItem strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.expiryItem small, +.expiryItem em { font-size: 11px; + font-style: normal; color: var(--text-secondary); } +.expiryItem em { + flex: 0 0 auto; +} + +.expiryItem_critical { + border-left-color: var(--status-critical); +} + +.expiryItem_warning { + border-left-color: var(--status-warning); +} + +.expiryItem_info { + border-left-color: var(--status-info); +} + .agentPrepared { grid-area: aside; display: flex; @@ -531,17 +611,39 @@ } .preparedSection li { - display: grid; - grid-template-columns: 16px minmax(0, 1fr); - column-gap: 8px; - align-items: center; + display: block; min-height: 20px; font-size: 13px; line-height: 20px; color: var(--text-primary); } -.preparedSection li strong { +.preparedItemButton { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + column-gap: 8px; + align-items: center; + width: 100%; + padding: 0; + background: transparent; + border: 0; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.preparedItemButton:hover strong { + color: var(--brand-primary); +} + +.preparedItemButton:focus-visible, +.expiryItem:focus-visible { + outline: 2px solid var(--brand-primary); + outline-offset: 2px; +} + +.preparedItemButton strong { font-weight: 500; } @@ -600,6 +702,10 @@ gap: 12px; } + .expiryList { + grid-template-columns: 1fr; + } + .priorityBody { gap: 12px; } diff --git a/src/pages/DashboardPage/DashboardPage.test.tsx b/src/pages/DashboardPage/DashboardPage.test.tsx index 57356fb..9114587 100644 --- a/src/pages/DashboardPage/DashboardPage.test.tsx +++ b/src/pages/DashboardPage/DashboardPage.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes, useLocation, useParams } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TaskPageResponse, TaskSummaryResponse } from '../../api/tasks' +import type { DashboardTodayResponse } from '../../api/dashboard' import { DashboardPage } from './DashboardPage' import { AI_REQUEST_PROMPT_CHIPS } from './dashboardData' @@ -24,70 +24,72 @@ function dateFromToday(offset: number) { 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 TODAY_RESPONSE: DashboardTodayResponse = { + summary_counts: { + pending_approval: 2, + due_today: 1, + needs_info: 1, + worker_response: 3, + }, + priority_tasks: [ + { + task_id: 'T-1', + worker_id: 'W-1', + title: '응웬반A 체류연장 요청문', + status: 'READY_FOR_REVIEW', + due_date: dateFromToday(1), + }, + { + task_id: 'T-2', + worker_id: 'W-2', + title: '계약 정보 보완', + status: 'NEEDS_INFO', + due_date: dateFromToday(5), + }, + ], + upcoming_7_days: [ + { + worker_id: 'W-1', + display_name: '응웬반A', + category: 'STAY_EXPIRY', + expiry_date: dateFromToday(3), + document_type: null, + }, + { + worker_id: 'W-2', + display_name: '아디 수르야', + category: 'DOCUMENT_EXPIRY', + expiry_date: dateFromToday(6), + document_type: 'PASSPORT_COPY', + }, + ], + recommendations: { + connected_count: 4, + prepared: [{ task_id: 'T-3', title: 'Agent 생성 체류연장 초안', status: 'DRAFT' }], + review: [{ task_id: 'T-2', title: '계약 정보 보완', status: 'NEEDS_INFO' }], + after_approval: [{ task_id: 'T-4', title: '외국인등록증 사본 제출', status: 'WAITING_WORKER' }], + }, + approval_count: 2, + worker_response_count: 3, } -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, - } +const EMPTY_RESPONSE: DashboardTodayResponse = { + summary_counts: { + pending_approval: 0, + due_today: 0, + needs_info: 0, + worker_response: 0, + }, + priority_tasks: [], + upcoming_7_days: [], + recommendations: { + connected_count: 0, + prepared: [], + review: [], + after_approval: [], + }, + approval_count: 0, + worker_response_count: 0, } function TaskDetailProbe() { @@ -95,6 +97,11 @@ function TaskDetailProbe() { return

업무 상세 {taskId}

} +function WorkerDetailProbe() { + const { workerId } = useParams() + return

근로자 상세 {workerId}

+} + function WorkCreateProbe() { const location = useLocation() const prefill = (location.state as { prefill?: string } | null)?.prefill @@ -109,13 +116,14 @@ function renderPage() { } /> 업무함

} /> } /> + } /> , ) } beforeEach(() => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(taskPage(TASKS)))) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(TODAY_RESPONSE))) }) afterEach(() => { @@ -123,43 +131,54 @@ afterEach(() => { }) describe('DashboardPage', () => { - it('renders metrics and work rows from the Task API response', async () => { + it('renders the Server Today projection without calculating from the Task list', async () => { renderPage() expect( await screen.findByRole('heading', { - name: '지금 확인이 필요한 승인 1건이 있습니다.', + name: '지금 확인이 필요한 승인 2건이 있습니다.', }), ).toBeInTheDocument() - expect(screen.getAllByText('1건 ›')).toHaveLength(4) + expect(screen.getAllByText('2건 ›')).toHaveLength(1) + expect(screen.getAllByText('1건 ›')).toHaveLength(2) + expect(screen.getAllByText('3건 ›')).toHaveLength(1) expect(screen.getAllByText('응웬반A 체류연장 요청문').length).toBeGreaterThan(0) - expect(screen.getAllByText('외국인등록증 사본 제출').length).toBeGreaterThan(0) - expect(screen.queryByText('완료된 업무')).not.toBeInTheDocument() + expect(screen.getByText('응웬반A')).toBeInTheDocument() + expect(screen.getByText('체류기간 만료')).toBeInTheDocument() + expect(screen.getByText('여권 사본 만료')).toBeInTheDocument() const requestedUrl = String(vi.mocked(fetch).mock.calls[0][0]) - expect(requestedUrl).toContain('/tasks?') - expect(requestedUrl).toContain('size=100') + expect(requestedUrl).toContain('/dashboard/today?timezone=Asia%2FSeoul') + expect(requestedUrl).not.toContain('/tasks?') }) - it('uses actual Task status groups in the Agent prepared panel', async () => { + it('renders the recommendation groups returned by the Today API', async () => { renderPage() expect(await screen.findByText('Agent 생성 초안 · 1건')).toBeInTheDocument() - expect(screen.getByText('담당자 확인 필요 · 2건')).toBeInTheDocument() + expect(screen.getByText('담당자 확인 필요 · 1건')).toBeInTheDocument() expect(screen.getByText('응답·기관 대기 · 1건')).toBeInTheDocument() + expect(screen.getByText('연결된 업무 4건 · 담당자 확인 필요 1건')).toBeInTheDocument() expect(screen.getAllByText('Agent 생성 체류연장 초안').length).toBeGreaterThan(0) }) - it('opens the actual Task ID from the priority approval', async () => { + it('opens the actual Task ID from priority and recommendation items', async () => { const user = userEvent.setup() renderPage() await user.click((await screen.findAllByRole('button', { name: '승인 검토' }))[0]) - expect(await screen.findByText('업무 상세 T-1')).toBeInTheDocument() }) - it('shows the loading state while the Task API is pending', () => { + it('opens the worker detail from an upcoming expiry item', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(await screen.findByRole('button', { name: /응웬반A.*체류기간 만료/ })) + expect(await screen.findByText('근로자 상세 W-1')).toBeInTheDocument() + }) + + it('shows the loading state while the Today API is pending', () => { vi.mocked(fetch).mockReturnValue(new Promise(() => {})) renderPage() @@ -167,18 +186,18 @@ describe('DashboardPage', () => { expect(screen.queryByText(/지금 확인이 필요한 승인/)).not.toBeInTheDocument() }) - it('shows an honest empty state when no task exists', async () => { - vi.mocked(fetch).mockResolvedValue(jsonResponse(taskPage([]))) + it('shows an honest empty state when the Today projection is empty', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(EMPTY_RESPONSE)) renderPage() expect(await screen.findByText('등록된 업무가 없습니다')).toBeInTheDocument() expect(screen.getByRole('button', { name: '업무 만들기' })).toBeInTheDocument() }) - it('shows an error state and retries the Task API request', async () => { + it('shows an error state and retries the Today API request', async () => { vi.mocked(fetch) .mockRejectedValueOnce(new TypeError('network')) - .mockResolvedValueOnce(jsonResponse(taskPage(TASKS))) + .mockResolvedValueOnce(jsonResponse(TODAY_RESPONSE)) const user = userEvent.setup() renderPage() @@ -188,13 +207,6 @@ describe('DashboardPage', () => { expect((await screen.findAllByText('응웬반A 체류연장 요청문')).length).toBeGreaterThan(0) }) - it('renders a safe cap notice when the API has more than 100 tasks', async () => { - vi.mocked(fetch).mockResolvedValue(jsonResponse(taskPage(TASKS, 101))) - renderPage() - - expect(await screen.findByText(/최근 100건 기준입니다/)).toBeInTheDocument() - }) - it('fills the input from a prompt chip and forwards it on submit', async () => { const user = userEvent.setup() renderPage() diff --git a/src/pages/DashboardPage/DashboardPage.tsx b/src/pages/DashboardPage/DashboardPage.tsx index 8e3b234..d4ade97 100644 --- a/src/pages/DashboardPage/DashboardPage.tsx +++ b/src/pages/DashboardPage/DashboardPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState, type FormEvent } from 'react' import { useNavigate } from 'react-router-dom' -import { fetchTasks } from '../../api/tasks' +import { fetchDashboardToday, type DashboardTodayResponse } from '../../api/dashboard' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { WorkItemRow } from '../../components/ui/WorkItemRow/WorkItemRow' import { useApiQuery } from '../../hooks/useApiQuery' @@ -13,20 +13,43 @@ import { buildDashboardMetrics, buildDashboardWorkItems, buildPriorityApproval, + buildUpcomingExpiries, } from './dashboardData' 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) - 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 todayFetcher = useCallback(() => fetchDashboardToday('Asia/Seoul'), []) + const isEmpty = useCallback( + (today: DashboardTodayResponse) => + today.priority_tasks.length === 0 && + today.upcoming_7_days.length === 0 && + today.recommendations.connected_count === 0 && + Object.values(today.summary_counts).every((count) => count === 0), + [], + ) + const { status, data: today, error, refetch } = useApiQuery(todayFetcher, isEmpty) + const metrics = useMemo(() => (today ? buildDashboardMetrics(today.summary_counts) : []), [today]) + const workItems = useMemo( + () => (today ? buildDashboardWorkItems(today.priority_tasks) : []), + [today], + ) + const priorityApproval = useMemo( + () => (today ? buildPriorityApproval(today.priority_tasks) : null), + [today], + ) + const agentPrepared = useMemo( + () => + today + ? buildAgentPrepared(today.recommendations) + : { connectedCount: 0, prepared: [], review: [], afterApproval: [] }, + [today], + ) + const upcomingExpiries = useMemo( + () => (today ? buildUpcomingExpiries(today.upcoming_7_days) : []), + [today], + ) + const pendingApprovalCount = today?.approval_count ?? 0 const headline = status === 'success' @@ -47,7 +70,7 @@ export function DashboardPage() {

{headline}

- Task API의 최신 상태와 기한을 기준으로 지금 확인할 업무를 정리합니다. + Today API의 최신 상태와 기한을 기준으로 지금 확인할 업무를 정리합니다.

@@ -95,7 +118,7 @@ export function DashboardPage() { @@ -162,10 +185,7 @@ export function DashboardPage() { {priorityApproval.meta} {priorityApproval.note} - @@ -194,23 +214,50 @@ export function DashboardPage() {

지금 할 일 · {workItems.length}건

- {workItems.map((item) => ( - navigate(`/tasks/${item.id}`)} - /> - ))} + {workItems.length > 0 ? ( + workItems.map((item) => ( + navigate(`/tasks/${item.id}`)} + /> + )) + ) : ( +

오늘 우선 처리할 업무가 없습니다.

+ )} +
+ + +
+
+

7일 이내 만료

+

체류·계약·서류 · {upcomingExpiries.length}건

- {taskPage && taskPage.total_elements > 100 && ( -

- 최근 100건 기준입니다. 전체 업무는 업무함에서 확인해 주세요. -

+ {upcomingExpiries.length > 0 ? ( +
    + {upcomingExpiries.map((item, index) => ( +
  • + +
  • + ))} +
+ ) : ( +

7일 이내 만료 예정 항목이 없습니다.

)}
@@ -225,7 +272,10 @@ export function DashboardPage() { 연결된 업무 {agentPrepared.connectedCount}건 · 담당자 확인 필요{' '} {agentPrepared.review.length}건 -

Task 상태만 표시하며, 문서 준비와 승인 결과는 각 API 응답을 따릅니다.

+

+ 승인 대기 {today?.approval_count ?? 0}건 · 근로자 응답{' '} + {today?.worker_response_count ?? 0}건 +

@@ -235,8 +285,14 @@ export function DashboardPage() {
    {agentPrepared.prepared.map((item) => (
  • - - {item.label} +
  • ))}
@@ -251,9 +307,15 @@ export function DashboardPage() {
    {agentPrepared.review.map((item) => (
  • - ! - {item.label} -

    {item.description}

    +
  • ))}
@@ -268,9 +330,15 @@ export function DashboardPage() {
    {agentPrepared.afterApproval.map((item) => (
  • - - {item.label} -

    {item.description}

    +
  • ))}
diff --git a/src/pages/DashboardPage/dashboardData.ts b/src/pages/DashboardPage/dashboardData.ts index 35956bf..63dc614 100644 --- a/src/pages/DashboardPage/dashboardData.ts +++ b/src/pages/DashboardPage/dashboardData.ts @@ -1,10 +1,19 @@ -import type { TaskStatus, TaskSummaryResponse } from '../../api/tasks' +import type { + DashboardRecommendationItemResponse, + DashboardRecommendationsResponse, + DashboardSummaryCountsResponse, + DashboardTaskSummaryResponse, + UpcomingExpiryCategory, + UpcomingExpiryItemResponse, +} from '../../api/dashboard' +import type { TaskStatus } from '../../api/tasks' import type { WorkItemStatusTone, WorkItemUrgency, } from '../../components/ui/WorkItemRow/WorkItemRow' -import { getOperationalDateViewModel } from '../../view-models/dateViewModel' +import { DOCUMENT_TYPE_LABEL } from '../../utils/documentLabels' import { daysUntil } from '../../utils/urgency' +import { getOperationalDateViewModel } from '../../view-models/dateViewModel' import metricApprovalIcon from './assets/metric-approval.svg' import metricDueIcon from './assets/metric-due.svg' import metricInfoIcon from './assets/metric-info.svg' @@ -58,6 +67,14 @@ export interface DashboardAgentPrepared { afterApproval: DashboardAgentItem[] } +export interface DashboardUpcomingExpiry { + workerId: string + workerName: string + label: string + dateLabel: string + urgency: WorkItemUrgency +} + const STATUS_PRESENTATION: Record< TaskStatus, { label: string; tone: WorkItemStatusTone; action: string } @@ -72,15 +89,12 @@ const STATUS_PRESENTATION: Record< 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) +const EXPIRY_CATEGORY_LABEL: Record = { + STAY_EXPIRY: '체류기간 만료', + CONTRACT_END: '근로계약 종료', + EMPLOYMENT_PERMIT_END: '고용허가 종료', + EMPLOYMENT_ACTIVITY_END: '취업활동기간 종료', + DOCUMENT_EXPIRY: '서류 만료', } function getUrgency(dueDate: string | null): WorkItemUrgency { @@ -91,75 +105,61 @@ function getUrgency(dueDate: string | null): WorkItemUrgency { 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) +export function buildDashboardMetrics(counts: DashboardSummaryCountsResponse): DashboardMetric[] { return [ { id: 'pending-approval', label: '승인 대기', - value: openTasks.filter((task) => task.status === 'READY_FOR_REVIEW').length, + value: counts.pending_approval, iconSrc: metricApprovalIcon, tone: 'warning', }, { id: 'due-today', label: '오늘 마감', - value: openTasks.filter((task) => daysUntil(task.due_date) === 0).length, + value: counts.due_today, iconSrc: metricDueIcon, tone: 'info', }, { id: 'needs-info', label: '정보 보완', - value: openTasks.filter((task) => task.status === 'NEEDS_INFO').length, + value: counts.needs_info, iconSrc: metricInfoIcon, tone: 'critical', }, { id: 'worker-response', label: '응답 대기', - value: openTasks.filter((task) => task.status === 'WAITING_WORKER').length, + value: counts.worker_response, iconSrc: metricResponseIcon, tone: 'success', }, ] } -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 buildDashboardWorkItems( + tasks: DashboardTaskSummaryResponse[], +): DashboardWorkItem[] { + return tasks.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(), + tasks: DashboardTaskSummaryResponse[], ): DashboardPriorityApproval | null { - const task = tasks - .filter((item) => item.status === 'READY_FOR_REVIEW') - .sort(compareDueDate)[0] + const task = tasks.find((item) => item.status === 'READY_FOR_REVIEW') if (!task) return null const due = getOperationalDateViewModel('TASK_DUE', task.due_date) @@ -167,39 +167,58 @@ export function buildPriorityApproval( id: task.task_id, title: task.title, meta: `${due.relative ?? '기한 미정'} · 승인 대기`, - note: 'Task API에서 담당자 검토가 필요한 상태로 확인됐습니다.', - requestedLabel: getRequestedLabel(task.updated_at, now), + note: 'Server가 오늘 우선 확인할 승인 업무로 정리했습니다.', + requestedLabel: due.relative ?? due.display, } } -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' +function mapRecommendation( + item: DashboardRecommendationItemResponse, + description?: string, +): DashboardAgentItem { + return { id: item.task_id, label: item.title, description } +} + +export function buildAgentPrepared( + recommendations: DashboardRecommendationsResponse, +): DashboardAgentPrepared { + return { + connectedCount: recommendations.connected_count, + prepared: recommendations.prepared.map((item) => mapRecommendation(item)), + review: recommendations.review.map((item) => + mapRecommendation( + item, + item.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' + ), + ), + afterApproval: recommendations.after_approval.map((item) => + mapRecommendation( + item, + item.status === 'WAITING_WORKER' ? '근로자 응답을 기다리고 있습니다.' : '외부기관 처리 결과를 기다리고 있습니다.', - })) + ), + ), + } +} - return { connectedCount: openTasks.length, prepared, review, afterApproval } +export function buildUpcomingExpiries( + items: UpcomingExpiryItemResponse[], +): DashboardUpcomingExpiry[] { + return items.map((item) => { + const date = getOperationalDateViewModel('DOCUMENT_EXPIRY', item.expiry_date) + const documentLabel = + item.category === 'DOCUMENT_EXPIRY' && item.document_type + ? DOCUMENT_TYPE_LABEL[item.document_type] + : null + return { + workerId: item.worker_id, + workerName: item.display_name, + label: documentLabel ? `${documentLabel} 만료` : EXPIRY_CATEGORY_LABEL[item.category], + dateLabel: date.relative ?? date.display, + urgency: getUrgency(item.expiry_date), + } + }) }