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 (
-

- 지금 확인이 필요한 승인 {APPROVAL_QUEUE.blockingCount}건이 있습니다. -

+

{headline}

- Agent가 필요한 자료와 다음 행동을 먼저 준비했습니다. 검토와 최종 결정은 담당자가 수행합니다. + Task API의 최신 상태와 기한을 기준으로 지금 확인할 업무를 정리합니다.

@@ -40,21 +56,33 @@ export function DashboardPage() {

Agent 업무 요청

- +
+ setAgentRequest(event.target.value)} + placeholder="처리할 업무를 자연어로 입력해 주세요. 예: 응웬반A의 체류기간 연장 준비" + aria-label="Agent 업무 요청" + maxLength={2000} + /> + +
{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) => (
+ +
+ ) : ( +
+

현재 담당자 승인을 기다리는 업무가 없습니다.

- -
+ )}

오늘의 우선 업무

-

지금 할 일 · {TODAY_WORK_ITEMS.length}건

+

지금 할 일 · {workItems.length}건

- {TODAY_WORK_ITEMS.map((item) => ( + {workItems.map((item) => ( navigate(`/tasks/${item.id}`)} /> ))}
+ {taskPage && taskPage.total_elements > 100 && ( +

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

+ )}
@@ -174,47 +221,62 @@ export function DashboardPage() {

Agent가 준비한 내용

- 준비 완료 4건 · HR 확인 필요 2건 -

Agent는 초안까지만 준비하며, 검토와 승인은 담당자가 수행합니다.

+ + 연결된 업무 {agentPrepared.connectedCount}건 · 담당자 확인 필요{' '} + {agentPrepared.review.length}건 + +

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

-

준비 완료 · 4건

-
    - {AGENT_PREPARED.prepared.map((item) => ( -
  • - - {item.label} -
  • - ))} -
+

Agent 생성 초안 · {agentPrepared.prepared.length}건

+ {agentPrepared.prepared.length > 0 ? ( +
    + {agentPrepared.prepared.map((item) => ( +
  • + + {item.label} +
  • + ))} +
+ ) : ( +

현재 표시할 Agent 초안이 없습니다.

+ )}
-

HR 확인 필요 · 2건

-
    - {AGENT_PREPARED.review.map((item) => ( -
  • - ! - {item.label} -

    {item.description}

    -
  • - ))} -
+

담당자 확인 필요 · {agentPrepared.review.length}건

+ {agentPrepared.review.length > 0 ? ( +
    + {agentPrepared.review.map((item) => ( +
  • + ! + {item.label} +

    {item.description}

    +
  • + ))} +
+ ) : ( +

현재 확인이 필요한 업무가 없습니다.

+ )}
-

승인 후 진행 · 2건

-
    - {AGENT_PREPARED.afterApproval.map((item) => ( -
  • - - {item.label} -

    {item.description}

    -
  • - ))} -
+

응답·기관 대기 · {agentPrepared.afterApproval.length}건

+ {agentPrepared.afterApproval.length > 0 ? ( +
    + {agentPrepared.afterApproval.map((item) => ( +
  • + + {item.label} +

    {item.description}

    +
  • + ))} +
+ ) : ( +

현재 대기 중인 업무가 없습니다.

+ )}
diff --git a/src/pages/DashboardPage/dashboardData.ts b/src/pages/DashboardPage/dashboardData.ts index 20cce7c..35956bf 100644 --- a/src/pages/DashboardPage/dashboardData.ts +++ b/src/pages/DashboardPage/dashboardData.ts @@ -1,61 +1,15 @@ +import type { TaskStatus, TaskSummaryResponse } from '../../api/tasks' +import type { + WorkItemStatusTone, + WorkItemUrgency, +} from '../../components/ui/WorkItemRow/WorkItemRow' +import { getOperationalDateViewModel } from '../../view-models/dateViewModel' +import { daysUntil } from '../../utils/urgency' import metricApprovalIcon from './assets/metric-approval.svg' import metricDueIcon from './assets/metric-due.svg' import metricInfoIcon from './assets/metric-info.svg' import metricResponseIcon from './assets/metric-response.svg' -// TODO(backend): 이 파일의 상수는 Figma HOME-001을 재현하기 위한 Prototype 데이터다. -// Dashboard Projection API가 준비되면 동일한 ViewModel 형태로 응답을 정규화한다. - -export type DashboardWorkStatus = '승인대기' | '요청전송' | '서류대기' -export type DashboardWorkTone = 'warning' | 'critical' | 'info' - -export interface DashboardWorkItem { - id: string - title: string - status: DashboardWorkStatus - schedule: string - assignee?: string - nextAction: string - urgency: DashboardWorkTone -} - -export const TODAY_WORK_ITEMS: DashboardWorkItem[] = [ - { - id: 'WI-1', - title: '응웬반A 체류연장 요청문', - status: '승인대기', - schedule: 'D-2', - assignee: '담당 김민지', - nextAction: '승인 검토', - urgency: 'warning', - }, - { - id: 'WI-2', - title: '외국인등록증 사본 제출', - status: '요청전송', - schedule: 'D-0', - nextAction: '요청 현황', - urgency: 'critical', - }, - { - id: 'WI-3', - title: '7월 외부기관 제출자료', - status: '서류대기', - schedule: 'D-3', - nextAction: '증빙 등록', - urgency: 'info', - }, -] - -export const APPROVAL_QUEUE = { - blockingCount: 2, - totalCount: 7, - oldestValue: '3시간 전', - title: '응웬반A 체류연장 요청문 승인', - meta: '응웬반A · D-2 · 담당 김민지', - note: '승인을 완료하면 근로자 안내 단계가 활성화됩니다.', -} - export const AI_REQUEST_PROMPT_CHIPS = [ '체류기간 연장', '누락 문서 확인', @@ -73,72 +27,179 @@ export interface DashboardMetric { tone: DashboardMetricTone } -export const METRIC_STRIP: DashboardMetric[] = [ - { - id: 'pending-approval', - label: '승인 대기', - value: APPROVAL_QUEUE.totalCount, - iconSrc: metricApprovalIcon, - tone: 'warning', - }, - { - id: 'due-today', - label: '오늘 마감', - value: 6, - iconSrc: metricDueIcon, - tone: 'info', - }, - { - id: 'needs-info', - label: '정보 보완', - value: 1, - iconSrc: metricInfoIcon, - tone: 'critical', - }, - { - id: 'worker-response', - label: '근로자 응답', - value: 8, - iconSrc: metricResponseIcon, - tone: 'success', - }, -] +export interface DashboardWorkItem { + id: string + title: string + status: string + statusTone: WorkItemStatusTone + schedule: string + nextAction: string + urgency: WorkItemUrgency +} + +export interface DashboardPriorityApproval { + id: string + title: string + meta: string + note: string + requestedLabel: string +} -export interface AgentPreparedItem { +export interface DashboardAgentItem { id: string label: string description?: string } -export const AGENT_PREPARED = { - prepared: [ - { id: 'documents', label: '필요 문서 5개 확인' }, - { id: 'draft', label: '체류연장 요청문 초안' }, - { id: 'connected', label: '기존 계약·체류 정보 연결' }, - { id: 'duplicate', label: '유사 업무 중복 여부 확인' }, - ] satisfies AgentPreparedItem[], - review: [ +export interface DashboardAgentPrepared { + connectedCount: number + prepared: DashboardAgentItem[] + review: DashboardAgentItem[] + afterApproval: DashboardAgentItem[] +} + +const STATUS_PRESENTATION: Record< + TaskStatus, + { label: string; tone: WorkItemStatusTone; action: string } +> = { + DRAFT: { label: '서류 대기', tone: 'neutral', action: '초안 검토' }, + NEEDS_INFO: { label: '정보 보완', tone: 'warning', action: '정보 확인' }, + READY_FOR_REVIEW: { label: '승인 대기', tone: 'warning', action: '승인 검토' }, + APPROVED: { label: '승인 완료', tone: 'primary', action: '실행 확인' }, + WAITING_WORKER: { label: '요청 전송', tone: 'primary', action: '요청 현황' }, + WAITING_EXTERNAL: { label: '기관 대기', tone: 'neutral', action: '진행 확인' }, + COMPLETED: { label: '완료', tone: 'primary', action: '완료 확인' }, + CANCELLED: { label: '취소', tone: 'neutral', action: '취소 확인' }, +} + +function isOpenTask(task: TaskSummaryResponse) { + return task.status !== 'COMPLETED' && task.status !== 'CANCELLED' +} + +function compareDueDate(a: TaskSummaryResponse, b: TaskSummaryResponse) { + if (!a.due_date && !b.due_date) return a.updated_at.localeCompare(b.updated_at) + if (!a.due_date) return 1 + if (!b.due_date) return -1 + return a.due_date.localeCompare(b.due_date) +} + +function getUrgency(dueDate: string | null): WorkItemUrgency { + const days = daysUntil(dueDate) + if (days !== null && days <= 0) return 'critical' + if (days !== null && days <= 7) return 'warning' + if (days !== null && days <= 30) return 'info' + return 'neutral' +} + +function getRequestedLabel(updatedAt: string, now = new Date()) { + const elapsed = Math.max(0, now.getTime() - new Date(updatedAt).getTime()) + const hours = Math.floor(elapsed / (60 * 60 * 1000)) + if (hours < 1) return '방금 전' + if (hours < 24) return `${hours}시간 전` + return `${Math.floor(hours / 24)}일 전` +} + +export function buildDashboardMetrics(tasks: TaskSummaryResponse[]): DashboardMetric[] { + const openTasks = tasks.filter(isOpenTask) + return [ { - id: 'passport', - label: '여권 만료일 확인', - description: '여권 원본과 만료일을 확인한 뒤 승인합니다.', + id: 'pending-approval', + label: '승인 대기', + value: openTasks.filter((task) => task.status === 'READY_FOR_REVIEW').length, + iconSrc: metricApprovalIcon, + tone: 'warning', }, { - id: 'deadline', - label: '추천 마감일·담당자 확인', - description: '업무량과 제출 기한을 확인한 뒤 확정합니다.', + id: 'due-today', + label: '오늘 마감', + value: openTasks.filter((task) => daysUntil(task.due_date) === 0).length, + iconSrc: metricDueIcon, + tone: 'info', }, - ] satisfies AgentPreparedItem[], - afterApproval: [ { - id: 'worker-request', - label: '근로자 요청문 확인', - description: '승인되면 근로자 요청 확인 단계가 열립니다.', + id: 'needs-info', + label: '정보 보완', + value: openTasks.filter((task) => task.status === 'NEEDS_INFO').length, + iconSrc: metricInfoIcon, + tone: 'critical', }, { - id: 'secure-link', - label: '보안 링크 발급 준비', - description: '승인 후 담당자가 발급하고 근로자에게 전달합니다.', + id: 'worker-response', + label: '응답 대기', + value: openTasks.filter((task) => task.status === 'WAITING_WORKER').length, + iconSrc: metricResponseIcon, + tone: 'success', }, - ] satisfies AgentPreparedItem[], + ] +} + +export function buildDashboardWorkItems(tasks: TaskSummaryResponse[]): DashboardWorkItem[] { + return tasks + .filter(isOpenTask) + .sort(compareDueDate) + .slice(0, 5) + .map((task) => { + const presentation = STATUS_PRESENTATION[task.status] + const due = getOperationalDateViewModel('TASK_DUE', task.due_date) + return { + id: task.task_id, + title: task.title, + status: presentation.label, + statusTone: presentation.tone, + schedule: due.relative ?? '기한 미정', + nextAction: presentation.action, + urgency: getUrgency(task.due_date), + } + }) +} + +export function buildPriorityApproval( + tasks: TaskSummaryResponse[], + now = new Date(), +): DashboardPriorityApproval | null { + const task = tasks + .filter((item) => item.status === 'READY_FOR_REVIEW') + .sort(compareDueDate)[0] + if (!task) return null + + const due = getOperationalDateViewModel('TASK_DUE', task.due_date) + return { + id: task.task_id, + title: task.title, + meta: `${due.relative ?? '기한 미정'} · 승인 대기`, + note: 'Task API에서 담당자 검토가 필요한 상태로 확인됐습니다.', + requestedLabel: getRequestedLabel(task.updated_at, now), + } +} + +export function buildAgentPrepared(tasks: TaskSummaryResponse[]): DashboardAgentPrepared { + const openTasks = tasks.filter(isOpenTask) + const prepared = openTasks + .filter((task) => task.source === 'AI_CANDIDATE' && task.status === 'DRAFT') + .slice(0, 4) + .map((task) => ({ id: task.task_id, label: task.title })) + const review = openTasks + .filter((task) => task.status === 'NEEDS_INFO' || task.status === 'READY_FOR_REVIEW') + .slice(0, 4) + .map((task) => ({ + id: task.task_id, + label: task.title, + description: + task.status === 'NEEDS_INFO' + ? '필수 정보를 보완한 뒤 다시 검토합니다.' + : '상세 내용을 확인한 뒤 담당자가 결정합니다.', + })) + const afterApproval = openTasks + .filter((task) => task.status === 'WAITING_WORKER' || task.status === 'WAITING_EXTERNAL') + .slice(0, 4) + .map((task) => ({ + id: task.task_id, + label: task.title, + description: + task.status === 'WAITING_WORKER' + ? '근로자 응답을 기다리고 있습니다.' + : '외부기관 처리 결과를 기다리고 있습니다.', + })) + + return { connectedCount: openTasks.length, prepared, review, afterApproval } } diff --git a/src/store/authStore.test.ts b/src/store/authStore.test.ts index 8f166dc..293f3c1 100644 --- a/src/store/authStore.test.ts +++ b/src/store/authStore.test.ts @@ -111,6 +111,28 @@ describe('useAuthStore.logout', () => { }) describe('useAuthStore.restoreSession', () => { + it('does not send duplicate refresh requests while restoration is in progress', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + jsonResponse({ + access_token: 'refreshed-token', + token_type: 'Bearer', + expires_in_seconds: 900, + expires_at: '2026-07-22T01:15:00Z', + }), + ) + .mockResolvedValueOnce(jsonResponse({ user_id: 'u-1', company_id: 'c-1', roles: ['HR'] })) + + const firstRestore = useAuthStore.getState().restoreSession() + const secondRestore = useAuthStore.getState().restoreSession() + + expect(secondRestore).toBe(firstRestore) + await Promise.all([firstRestore, secondRestore]) + + expect(fetch).toHaveBeenCalledTimes(2) + expect(useAuthStore.getState().user?.role).toBe('HR') + }) + it('restores the user from a valid refresh cookie plus /auth/me', async () => { // 이 프로젝트의 테스트 환경에서는 Node 내장 localStorage가 jsdom 것보다 먼저 잡혀 // 저장이 조용히 실패할 수 있다 (구현도 이 상황을 try/catch로 감내하도록 설계했다). diff --git a/src/store/authStore.ts b/src/store/authStore.ts index ae1b9ce..294e932 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -101,6 +101,8 @@ function toApiErrorMessage(error: unknown, fallback: string): string { return error instanceof ApiError ? getErrorMessage(error) : fallback } +let sessionRestorePromise: Promise | null = null + export const useAuthStore = create((set) => { // client.ts는 이 스토어를 모르는 채로 동작하므로(순환 참조 방지), refresh 재시도까지 // 실패했을 때 로그아웃 처리를 여기서 콜백으로 연결해준다. @@ -144,31 +146,42 @@ export const useAuthStore = create((set) => { set({ user: null, status: 'ready' }) }, - restoreSession: async () => { - set({ status: 'restoring' }) - try { - const refreshBody = await apiFetch('/auth/refresh', { - method: 'POST', - skipAuthRetry: true, - }) - setAccessToken(refreshBody.access_token) - - const me = await apiFetch('/auth/me') - const persisted = readPersistedProfile() - set({ - user: { - name: persisted?.name ?? '사용자', - workplace: persisted?.workplace ?? '', - role: me.roles[0] ?? '', - }, - status: 'ready', - }) - } catch { - // 쿠키가 없거나 만료됐으면 로그인 화면으로 보내는 게 정상 흐름이라 에러로 취급하지 않는다. - setAccessToken(null) - clearPersistedProfile() - set({ user: null, status: 'ready' }) - } + restoreSession: () => { + // React StrictMode는 개발 환경에서 mount effect를 두 번 실행할 수 있다. Refresh Token은 + // 요청마다 회전하므로 같은 쿠키로 복원 요청을 동시에 보내면 한쪽이 실패해 정상 세션까지 + // 로그아웃 처리될 수 있다. 동시에 호출되면 진행 중인 하나의 Promise를 공유한다. + if (sessionRestorePromise) return sessionRestorePromise + + sessionRestorePromise = (async () => { + set({ status: 'restoring' }) + try { + const refreshBody = await apiFetch('/auth/refresh', { + method: 'POST', + skipAuthRetry: true, + }) + setAccessToken(refreshBody.access_token) + + const me = await apiFetch('/auth/me') + const persisted = readPersistedProfile() + set({ + user: { + name: persisted?.name ?? '사용자', + workplace: persisted?.workplace ?? '', + role: me.roles[0] ?? '', + }, + status: 'ready', + }) + } catch { + // 쿠키가 없거나 만료됐으면 로그인 화면으로 보내는 게 정상 흐름이라 에러로 취급하지 않는다. + setAccessToken(null) + clearPersistedProfile() + set({ user: null, status: 'ready' }) + } finally { + sessionRestorePromise = null + } + })() + + return sessionRestorePromise }, } }) diff --git a/vite.config.ts b/vite.config.ts index d74bb4f..cab75ff 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,14 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: true, + }, + }, + }, test: { environment: 'jsdom', globals: true,