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() {
@@ -95,7 +118,7 @@ export function DashboardPage() {
@@ -162,10 +185,7 @@ export function DashboardPage() {
{priorityApproval.meta}
{priorityApproval.note}
-