diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2bf124d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+VITE_API_BASE_URL=/api/v1
diff --git a/README.md b/README.md
index f42b8e6..4f49723 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,17 @@ React + TypeScript 기반 프론트엔드 : HR 대시보드, 업무카드, 근
```bash
npm install
+cp .env.example .env
npm run dev
```
+## 로컬 백엔드 연결
+
+개발 서버는 `/api` 요청을 `http://127.0.0.1:8080`으로 전달합니다. 프론트에서는
+`VITE_API_BASE_URL=/api/v1`을 사용해야 로그인 Refresh Cookie가 같은 출처로 유지됩니다.
+백엔드 주소를 `VITE_API_BASE_URL`에 직접 넣으면 로그인 직후 요청은 성공해도 새로고침 시
+세션 복원이 실패할 수 있습니다.
+
## 스크립트
| 명령 | 설명 |
diff --git a/src/pages/CreateWorkPage/CreateWorkPage.test.tsx b/src/pages/CreateWorkPage/CreateWorkPage.test.tsx
index 60de10e..2c0c176 100644
--- a/src/pages/CreateWorkPage/CreateWorkPage.test.tsx
+++ b/src/pages/CreateWorkPage/CreateWorkPage.test.tsx
@@ -19,9 +19,9 @@ afterEach(() => {
useToastStore.setState({ toasts: [] })
})
-function renderPage() {
+function renderPage(initialEntry: string | { pathname: string; state?: unknown } = '/tasks/new') {
render(
-
+
{
+ it('uses a request forwarded from the dashboard as the initial input', () => {
+ renderPage({ pathname: '/tasks/new', state: { prefill: '응웬반A 체류기간 연장 준비' } })
+
+ expect(screen.getByLabelText('업무 요청 내용')).toHaveValue('응웬반A 체류기간 연장 준비')
+ })
+
it('disables the analyze button until a request is entered', async () => {
const user = userEvent.setup()
renderPage()
diff --git a/src/pages/DashboardPage/DashboardPage.module.css b/src/pages/DashboardPage/DashboardPage.module.css
index cddaebd..d242980 100644
--- a/src/pages/DashboardPage/DashboardPage.module.css
+++ b/src/pages/DashboardPage/DashboardPage.module.css
@@ -55,10 +55,9 @@
color: var(--brand-primary);
}
-.commandInput {
+.commandForm {
display: flex;
align-items: center;
- justify-content: space-between;
width: calc(100% - 37px);
height: 40px;
margin: 6px 18px 0;
@@ -67,23 +66,52 @@
background: rgba(207, 227, 227, 0.7);
border: 0;
border-radius: 21px;
+}
+
+.commandForm:focus-within {
+ box-shadow: 0 0 0 2px rgba(7, 132, 127, 0.18);
+}
+
+.commandInput {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ outline: 0;
font-family: inherit;
font-size: 13px;
font-weight: 500;
line-height: 22px;
color: #465b5d;
text-align: left;
- cursor: pointer;
}
-.commandInput span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+.commandInput::placeholder {
+ color: #465b5d;
+ opacity: 1;
}
-.commandInput img {
+.commandSubmit {
+ display: flex;
flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 3px;
+ background: transparent;
+ border: 0;
+ border-radius: 50%;
+ cursor: pointer;
+}
+
+.commandSubmit:disabled {
+ cursor: default;
+ opacity: 0.58;
+}
+
+.commandSubmit img {
width: 18px;
height: 18px;
}
@@ -110,6 +138,11 @@
cursor: pointer;
}
+.promptChip[aria-pressed='true'] {
+ background: #d7ebea;
+ color: var(--brand-primary);
+}
+
.dashboardGrid {
display: grid;
grid-template-columns: minmax(0, 767px) minmax(0, 359px);
@@ -314,6 +347,35 @@
cursor: pointer;
}
+.priorityEmpty {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 72px;
+ margin-top: 15px;
+ padding: 12px 15px;
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+}
+
+.priorityEmpty p {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.priorityEmpty button {
+ height: 40px;
+ padding: 8px 16px;
+ background: var(--surface-default);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+ font-family: inherit;
+ font-size: 12px;
+ color: var(--text-primary);
+ cursor: pointer;
+}
+
.todayTasks {
display: flex;
flex-direction: column;
@@ -349,6 +411,15 @@
display: flex;
flex-direction: column;
gap: 8px;
+ max-height: 184px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+.capNotice {
+ margin: -2px 0 0;
+ font-size: 11px;
+ color: var(--text-secondary);
}
.agentPrepared {
@@ -423,6 +494,13 @@
color: var(--text-primary);
}
+.preparedEmpty {
+ margin: 0;
+ font-size: 11px;
+ line-height: 18px;
+ color: var(--text-secondary);
+}
+
.preparedSection ul {
display: flex;
flex-direction: column;
@@ -533,7 +611,7 @@
min-height: 144px;
}
- .commandInput {
+ .commandForm {
width: 100%;
margin-right: 0;
margin-left: 0;
diff --git a/src/pages/DashboardPage/DashboardPage.test.tsx b/src/pages/DashboardPage/DashboardPage.test.tsx
index d1b8638..57356fb 100644
--- a/src/pages/DashboardPage/DashboardPage.test.tsx
+++ b/src/pages/DashboardPage/DashboardPage.test.tsx
@@ -1,100 +1,220 @@
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { MemoryRouter, Route, Routes } from 'react-router-dom'
-import { describe, expect, it } from 'vitest'
+import { MemoryRouter, Route, Routes, useLocation, useParams } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TaskPageResponse, TaskSummaryResponse } from '../../api/tasks'
import { DashboardPage } from './DashboardPage'
-import styles from './DashboardPage.module.css'
-import {
- AGENT_PREPARED,
- AI_REQUEST_PROMPT_CHIPS,
- APPROVAL_QUEUE,
- METRIC_STRIP,
- TODAY_WORK_ITEMS,
-} from './dashboardData'
-
-function renderPage(demoState = 'success') {
+import { AI_REQUEST_PROMPT_CHIPS } from './dashboardData'
+
+function jsonResponse(body: unknown, init: ResponseInit = {}) {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ ...init,
+ })
+}
+
+function dateFromToday(offset: number) {
+ const date = new Date()
+ date.setHours(12, 0, 0, 0)
+ date.setDate(date.getDate() + offset)
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
+function task(
+ taskId: string,
+ overrides: Partial = {},
+): TaskSummaryResponse {
+ return {
+ task_id: taskId,
+ worker_id: 'W-1',
+ case_id: null,
+ task_type: 'STAY_PERIOD_EXTENSION',
+ workflow_id: 'WF-1',
+ workflow_catalog_version: '1',
+ title: `업무 ${taskId}`,
+ source: 'MANUAL',
+ status: 'DRAFT',
+ due_date: null,
+ content_revision: 1,
+ version: 1,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ ...overrides,
+ }
+}
+
+const TASKS = [
+ task('T-1', {
+ title: '응웬반A 체류연장 요청문',
+ source: 'AI_CANDIDATE',
+ status: 'READY_FOR_REVIEW',
+ due_date: dateFromToday(1),
+ }),
+ task('T-2', {
+ title: '계약 정보 보완',
+ status: 'NEEDS_INFO',
+ due_date: dateFromToday(5),
+ }),
+ task('T-3', {
+ title: '외국인등록증 사본 제출',
+ status: 'WAITING_WORKER',
+ due_date: dateFromToday(0),
+ }),
+ task('T-4', {
+ title: 'Agent 생성 체류연장 초안',
+ source: 'AI_CANDIDATE',
+ status: 'DRAFT',
+ due_date: dateFromToday(10),
+ }),
+ task('T-5', {
+ title: '완료된 업무',
+ status: 'COMPLETED',
+ due_date: dateFromToday(-1),
+ }),
+]
+
+function taskPage(
+ items: TaskSummaryResponse[],
+ totalElements = items.length,
+): TaskPageResponse {
+ return {
+ items,
+ page: 0,
+ size: 100,
+ total_elements: totalElements,
+ total_pages: totalElements > 100 ? 2 : 1,
+ }
+}
+
+function TaskDetailProbe() {
+ const { taskId } = useParams()
+ return 업무 상세 {taskId}
+}
+
+function WorkCreateProbe() {
+ const location = useLocation()
+ const prefill = (location.state as { prefill?: string } | null)?.prefill
+ return 업무 생성 {prefill}
+}
+
+function renderPage() {
return render(
-
+
} />
- 업무 생성 페이지
} />
+ } />
+ 업무함} />
+ } />
,
)
}
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(taskPage(TASKS))))
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
describe('DashboardPage', () => {
- it('renders the blocking approval headline from the HOME-001 structure', () => {
+ it('renders metrics and work rows from the Task API response', async () => {
renderPage()
+
expect(
- screen.getByRole('heading', {
- name: `지금 확인이 필요한 승인 ${APPROVAL_QUEUE.blockingCount}건이 있습니다.`,
+ await screen.findByRole('heading', {
+ name: '지금 확인이 필요한 승인 1건이 있습니다.',
}),
).toBeInTheDocument()
+ expect(screen.getAllByText('1건 ›')).toHaveLength(4)
+ expect(screen.getAllByText('응웬반A 체류연장 요청문').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('외국인등록증 사본 제출').length).toBeGreaterThan(0)
+ expect(screen.queryByText('완료된 업무')).not.toBeInTheDocument()
+
+ const requestedUrl = String(vi.mocked(fetch).mock.calls[0][0])
+ expect(requestedUrl).toContain('/tasks?')
+ expect(requestedUrl).toContain('size=100')
})
- it('renders every work item row', () => {
+ it('uses actual Task status groups in the Agent prepared panel', async () => {
renderPage()
- for (const item of TODAY_WORK_ITEMS) {
- expect(screen.getByText(item.title)).toBeInTheDocument()
- }
+
+ expect(await screen.findByText('Agent 생성 초안 · 1건')).toBeInTheDocument()
+ expect(screen.getByText('담당자 확인 필요 · 2건')).toBeInTheDocument()
+ expect(screen.getByText('응답·기관 대기 · 1건')).toBeInTheDocument()
+ expect(screen.getAllByText('Agent 생성 체류연장 초안').length).toBeGreaterThan(0)
})
- it('renders the Figma status label and next action for every priority work item', () => {
+ it('opens the actual Task ID from the priority approval', async () => {
+ const user = userEvent.setup()
renderPage()
- for (const item of TODAY_WORK_ITEMS) {
- expect(screen.getByText(item.status)).toBeInTheDocument()
- expect(screen.getAllByText(item.nextAction).length).toBeGreaterThan(0)
- }
+
+ await user.click((await screen.findAllByRole('button', { name: '승인 검토' }))[0])
+
+ expect(await screen.findByText('업무 상세 T-1')).toBeInTheDocument()
})
- it('shows a loading state', () => {
- renderPage('loading')
+ it('shows the loading state while the Task API is pending', () => {
+ vi.mocked(fetch).mockReturnValue(new Promise(() => {}))
+ renderPage()
+
expect(screen.getByText('업무 현황을 불러오는 중입니다')).toBeInTheDocument()
+ expect(screen.queryByText(/지금 확인이 필요한 승인/)).not.toBeInTheDocument()
})
- it('shows an empty state with a shortcut to create work', () => {
- renderPage('empty')
- expect(screen.getByText('오늘 처리할 업무가 없습니다')).toBeInTheDocument()
+ it('shows an honest empty state when no task exists', async () => {
+ vi.mocked(fetch).mockResolvedValue(jsonResponse(taskPage([])))
+ renderPage()
+
+ expect(await screen.findByText('등록된 업무가 없습니다')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '업무 만들기' })).toBeInTheDocument()
})
- it('shows an error state with a retry action', () => {
- renderPage('error')
- expect(screen.getByText('업무 현황을 불러오지 못했습니다')).toBeInTheDocument()
- expect(screen.getByRole('button', { name: '다시 시도' })).toBeInTheDocument()
+ it('shows an error state and retries the Task API request', async () => {
+ vi.mocked(fetch)
+ .mockRejectedValueOnce(new TypeError('network'))
+ .mockResolvedValueOnce(jsonResponse(taskPage(TASKS)))
+ const user = userEvent.setup()
+ renderPage()
+
+ await user.click(await screen.findByRole('button', { name: '다시 시도' }))
+
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2))
+ expect((await screen.findAllByText('응웬반A 체류연장 요청문')).length).toBeGreaterThan(0)
})
- it('renders every metric strip card', () => {
+ it('renders a safe cap notice when the API has more than 100 tasks', async () => {
+ vi.mocked(fetch).mockResolvedValue(jsonResponse(taskPage(TASKS, 101)))
renderPage()
- for (const metric of METRIC_STRIP) {
- expect(screen.getByText(`${metric.value}건 ›`)).toBeInTheDocument()
- }
+
+ expect(await screen.findByText(/최근 100건 기준입니다/)).toBeInTheDocument()
})
- it('renders every Agent prepared group', () => {
+ it('fills the input from a prompt chip and forwards it on submit', async () => {
+ const user = userEvent.setup()
renderPage()
- const items = [
- ...AGENT_PREPARED.prepared,
- ...AGENT_PREPARED.review,
- ...AGENT_PREPARED.afterApproval,
- ]
- for (const item of items) {
- expect(screen.getByText(item.label)).toBeInTheDocument()
- }
- })
- it('uses the Figma desktop grid class for the success view', () => {
- const { container } = renderPage()
- expect(container.querySelector(`.${styles.dashboardGrid}`)).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: AI_REQUEST_PROMPT_CHIPS[0] }))
+ const requestInput = screen.getByRole('textbox', { name: 'Agent 업무 요청' })
+ expect(requestInput).toHaveValue(AI_REQUEST_PROMPT_CHIPS[0])
+
+ await user.click(screen.getByRole('button', { name: '업무 요청 계속하기' }))
+
+ expect(await screen.findByText(`업무 생성 ${AI_REQUEST_PROMPT_CHIPS[0]}`)).toBeInTheDocument()
})
- it('navigates to work creation with the chosen prompt chip prefilled', async () => {
+ it('accepts a natural-language request directly and submits it with Enter', async () => {
const user = userEvent.setup()
renderPage()
- await user.click(screen.getByRole('button', { name: AI_REQUEST_PROMPT_CHIPS[0] }))
+ const requestInput = screen.getByRole('textbox', { name: 'Agent 업무 요청' })
+ await user.type(requestInput, '응웬반A 체류기간 연장 준비{Enter}')
- expect(await screen.findByText('업무 생성 페이지')).toBeInTheDocument()
+ expect(await screen.findByText('업무 생성 응웬반A 체류기간 연장 준비')).toBeInTheDocument()
})
})
diff --git a/src/pages/DashboardPage/DashboardPage.tsx b/src/pages/DashboardPage/DashboardPage.tsx
index d33274e..03d2fe4 100644
--- a/src/pages/DashboardPage/DashboardPage.tsx
+++ b/src/pages/DashboardPage/DashboardPage.tsx
@@ -1,37 +1,53 @@
+import { useCallback, useMemo, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
+import { fetchTasks } from '../../api/tasks'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
-import { WorkItemRow, type WorkItemStatusTone } from '../../components/ui/WorkItemRow/WorkItemRow'
-import { useAsyncDemoData } from '../../hooks/useAsyncDemoData'
+import { WorkItemRow } from '../../components/ui/WorkItemRow/WorkItemRow'
+import { useApiQuery } from '../../hooks/useApiQuery'
import agentSparkIcon from './assets/agent-spark.svg'
import commandSubmitIcon from './assets/command-submit.svg'
import styles from './DashboardPage.module.css'
import {
- AGENT_PREPARED,
AI_REQUEST_PROMPT_CHIPS,
- APPROVAL_QUEUE,
- METRIC_STRIP,
- TODAY_WORK_ITEMS,
- type DashboardWorkStatus,
+ buildAgentPrepared,
+ buildDashboardMetrics,
+ buildDashboardWorkItems,
+ buildPriorityApproval,
} from './dashboardData'
-const STATUS_TONE: Record = {
- 승인대기: 'warning',
- 요청전송: 'primary',
- 서류대기: 'neutral',
-}
-
export function DashboardPage() {
const navigate = useNavigate()
- const status = useAsyncDemoData(TODAY_WORK_ITEMS.length === 0)
+ const [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 headline =
+ status === 'success'
+ ? `지금 확인이 필요한 승인 ${pendingApprovalCount}건이 있습니다.`
+ : status === 'empty'
+ ? '현재 등록된 업무가 없습니다.'
+ : '업무 현황을 확인하고 있습니다.'
+
+ function handleAgentRequestSubmit(event: FormEvent) {
+ event.preventDefault()
+ const prefill = agentRequest.trim()
+ if (!prefill) return
+ navigate('/tasks/new', { state: { prefill } })
+ }
return (
@@ -40,21 +56,33 @@ export function DashboardPage() {
Agent 업무 요청
-
+
{AI_REQUEST_PROMPT_CHIPS.map((chip) => (
@@ -67,7 +95,7 @@ export function DashboardPage() {
@@ -78,9 +106,9 @@ export function DashboardPage() {
navigate('/dashboard', { replace: true })}
+ onAction={refetch}
/>
)}
@@ -89,7 +117,7 @@ export function DashboardPage() {
navigate('/tasks/new')}
@@ -101,12 +129,12 @@ export function DashboardPage() {
- {METRIC_STRIP.map((metric) => (
+ {metrics.map((metric) => (