From b917607e457ab632f1bd1a38782eafe1889bfb8d Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 01:02:44 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(dashboard):=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=EC=97=90=EC=84=9C=20AI=20=EB=B6=84=EC=84=9D?= =?UTF-8?q?=EC=9D=84=20=EB=B0=94=EB=A1=9C=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DashboardPage/DashboardPage.module.css | 7 ++ .../DashboardPage/DashboardPage.test.tsx | 72 +++++++++++++++++-- src/pages/DashboardPage/DashboardPage.tsx | 48 +++++++++++-- 3 files changed, 114 insertions(+), 13 deletions(-) diff --git a/src/pages/DashboardPage/DashboardPage.module.css b/src/pages/DashboardPage/DashboardPage.module.css index c0b5983..b0cdbaf 100644 --- a/src/pages/DashboardPage/DashboardPage.module.css +++ b/src/pages/DashboardPage/DashboardPage.module.css @@ -238,6 +238,13 @@ color: var(--text-secondary); } +.commandError { + margin: 5px 0 0; + font-size: 11px; + line-height: 17px; + color: var(--color-danger-700, #b42318); +} + .quickPromptPanel { min-width: 0; padding: 16px 18px; diff --git a/src/pages/DashboardPage/DashboardPage.test.tsx b/src/pages/DashboardPage/DashboardPage.test.tsx index f97db96..3f5aa94 100644 --- a/src/pages/DashboardPage/DashboardPage.test.tsx +++ b/src/pages/DashboardPage/DashboardPage.test.tsx @@ -3,6 +3,7 @@ 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 { DashboardTodayResponse } from '../../api/dashboard' +import type { AiRunResponse } from '../../api/aiRuns' import { DashboardTodayProvider } from '../../components/layout/DashboardTodayProvider' import { DashboardPage } from './DashboardPage' import { AI_REQUEST_PROMPT_CHIPS } from './dashboardData' @@ -103,10 +104,10 @@ function WorkerDetailProbe() { return

근로자 상세 {workerId}

} -function WorkCreateProbe() { +function WorkReviewProbe() { const location = useLocation() - const prefill = (location.state as { prefill?: string } | null)?.prefill - return

업무 생성 {prefill}

+ const state = location.state as { draft?: { request?: string } } | null + return

{`업무 검토 ${state?.draft?.request ?? ''}${location.search}`}

} function WorkListProbe() { @@ -120,7 +121,7 @@ function renderPage() { } /> - } /> + } /> } /> } /> } /> @@ -130,6 +131,23 @@ function renderPage() { ) } +const AI_RUN_RESPONSE: AiRunResponse = { + ai_run_id: 'AI-RUN-1', + request_id: 'REQUEST-1', + instruction: '응웬반A 체류기간 연장 준비', + status: 'SUCCEEDED', + analysis_outcome: 'CONTEXT_REQUIRED', + detected_intent: 'EXPIRY_RENEWAL', + evidence: null, + error_code: null, + attempt_count: 1, + version: 1, + questions: [], + candidates: [], + created_at: '2026-08-18T00:00:00Z', + updated_at: '2026-08-18T00:00:00Z', +} + beforeEach(() => { vi.stubGlobal( 'fetch', @@ -138,6 +156,9 @@ beforeEach(() => { if (url.includes('/workers')) { return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 })) } + if (url.includes('/ai-runs')) { + return Promise.resolve(jsonResponse(AI_RUN_RESPONSE)) + } return Promise.resolve(jsonResponse(TODAY_RESPONSE)) }), ) @@ -307,7 +328,7 @@ describe('DashboardPage', () => { expect((await screen.findAllByText('응웬반A 체류연장 요청문')).length).toBeGreaterThan(0) }) - it('fills the input from a prompt chip and forwards it on submit', async () => { + it('fills the input from a prompt chip and starts analysis with one submit', async () => { const user = userEvent.setup() renderPage() @@ -318,7 +339,14 @@ describe('DashboardPage', () => { await user.click(screen.getByRole('button', { name: '업무 분석' })) - expect(await screen.findByText(`업무 생성 ${AI_REQUEST_PROMPT_CHIPS[0]}`)).toBeInTheDocument() + expect( + await screen.findByText(`업무 검토 ${AI_REQUEST_PROMPT_CHIPS[0]}?aiRunId=AI-RUN-1`), + ).toBeInTheDocument() + const aiRunCall = vi + .mocked(fetch) + .mock.calls.find(([input]) => String(input).includes('/ai-runs')) + expect(aiRunCall?.[1]).toMatchObject({ method: 'POST' }) + expect(aiRunCall?.[1]?.body).toBe(JSON.stringify({ instruction: AI_REQUEST_PROMPT_CHIPS[0] })) }) it('accepts a natural-language request directly and submits it with Enter', async () => { @@ -328,6 +356,36 @@ describe('DashboardPage', () => { const requestInput = await screen.findByRole('textbox', { name: '업무 내용' }) await user.type(requestInput, '응웬반A 체류기간 연장 준비{Enter}') - expect(await screen.findByText('업무 생성 응웬반A 체류기간 연장 준비')).toBeInTheDocument() + expect( + await screen.findByText('업무 검토 응웬반A 체류기간 연장 준비?aiRunId=AI-RUN-1'), + ).toBeInTheDocument() + }) + + it('shows an API error without leaving the dashboard', async () => { + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/workers')) { + return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 })) + } + if (url.includes('/ai-runs')) { + return Promise.resolve( + jsonResponse( + { code: 'AI_UNAVAILABLE', message: 'AI 분석 서비스를 사용할 수 없습니다.' }, + { status: 503 }, + ), + ) + } + return Promise.resolve(jsonResponse(TODAY_RESPONSE)) + }) + const user = userEvent.setup() + renderPage() + + await user.type(await screen.findByRole('textbox', { name: '업무 내용' }), '체류연장 준비') + await user.click(screen.getByRole('button', { name: '업무 분석' })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'AI 분석 서비스를 사용할 수 없습니다.', + ) + expect(screen.getByRole('heading', { name: '오늘의 업무를 확인하세요.' })).toBeInTheDocument() }) }) diff --git a/src/pages/DashboardPage/DashboardPage.tsx b/src/pages/DashboardPage/DashboardPage.tsx index 50e0109..c90f205 100644 --- a/src/pages/DashboardPage/DashboardPage.tsx +++ b/src/pages/DashboardPage/DashboardPage.tsx @@ -1,5 +1,7 @@ import { useCallback, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from 'react' import { useNavigate } from 'react-router-dom' +import { createAiRun } from '../../api/aiRuns' +import { ApiError, getErrorMessage } from '../../api/errors' import { fetchWorkers } from '../../api/workers' import { useDashboardToday } from '../../components/layout/dashboardTodayContext' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' @@ -15,10 +17,17 @@ import { buildDashboardWorkItems, buildUpcomingExpiries, } from './dashboardData' +import { + saveActiveWorkRequestDraft, + saveAiRunWorkRequestDraft, + type WorkRequestDraft, +} from '../CreateWorkPage/workRequestDraft' export function DashboardPage() { const navigate = useNavigate() const [agentRequest, setAgentRequest] = useState('') + const [analyzing, setAnalyzing] = useState(false) + const [analysisError, setAnalysisError] = useState(null) const agentRequestRef = useRef(null) const { status, data: today, error, refetch, lastUpdatedAt } = useDashboardToday() // 우선 업무 카드의 근로자 이름을 표시하기 위한 조회 — today API의 priority_tasks에는 @@ -71,11 +80,33 @@ export function DashboardPage() { const headline = status === 'empty' ? '현재 등록된 업무가 없습니다.' : '오늘의 업무를 확인하세요.' - function handleAgentRequestSubmit(event: FormEvent) { + async function handleAgentRequestSubmit(event: FormEvent) { event.preventDefault() - const prefill = agentRequest.trim() - if (!prefill) return - navigate('/tasks/new', { state: { prefill } }) + const instruction = agentRequest.trim() + if (!instruction || analyzing) return + + setAnalyzing(true) + setAnalysisError(null) + try { + const aiRun = await createAiRun(instruction, globalThis.crypto.randomUUID()) + const draft: WorkRequestDraft = { + request: instruction, + mode: 'nl', + workerId: '', + attachments: [], + } + saveActiveWorkRequestDraft(draft) + saveAiRunWorkRequestDraft(aiRun.ai_run_id, draft) + navigate(`/tasks/new/review?aiRunId=${encodeURIComponent(aiRun.ai_run_id)}`, { + state: { aiRun, draft }, + }) + } catch (error) { + setAnalysisError( + error instanceof ApiError ? getErrorMessage(error) : '요청을 분석하지 못했습니다.', + ) + } finally { + setAnalyzing(false) + } } function handleAgentRequestKeyDown(event: KeyboardEvent) { @@ -142,15 +173,20 @@ export function DashboardPage() {

입력한 원문 그대로 분석합니다 · Enter로 분석 · Shift+Enter로 줄바꿈

+ {analysisError && ( +

+ {analysisError} +

+ )} From 664dece8863b6a70b0e7c7d1e7b5dd6119515a4a Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 01:12:22 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(ai-run):=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EC=99=80=20=EC=B6=94=EA=B0=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=9E=85=EB=A0=A5=EC=9D=84=20=EC=9D=B4=ED=95=B4?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EC=89=BD=EA=B2=8C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReviewWorkPage/AiRunReview.module.css | 15 ++++ src/pages/ReviewWorkPage/AiRunReview.test.tsx | 30 +++++-- src/pages/ReviewWorkPage/AiRunReview.tsx | 84 ++++++++++++------- .../ReviewWorkPage/ReviewWorkPage.test.tsx | 7 +- 4 files changed, 99 insertions(+), 37 deletions(-) diff --git a/src/pages/ReviewWorkPage/AiRunReview.module.css b/src/pages/ReviewWorkPage/AiRunReview.module.css index 03b2c54..bb4f1b2 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.module.css +++ b/src/pages/ReviewWorkPage/AiRunReview.module.css @@ -273,6 +273,21 @@ margin-top: 16px; } +.questionLabel { + display: block; + margin: 12px 0 4px; + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} + +.questionHelp { + margin: 0 0 8px; + font-size: 12px; + line-height: 18px; + color: var(--text-secondary); +} + .questionInput { width: 100%; min-height: 42px; diff --git a/src/pages/ReviewWorkPage/AiRunReview.test.tsx b/src/pages/ReviewWorkPage/AiRunReview.test.tsx index daee650..3cfe581 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.test.tsx +++ b/src/pages/ReviewWorkPage/AiRunReview.test.tsx @@ -122,7 +122,7 @@ function needsInfoRun(): AiRunResponse { { slot_key: 'due_at', label: '신청 목표일을 입력해 주세요.', - input_type: 'DATE', + input_type: 'TEXT', required: true, answer: null, }, @@ -270,7 +270,7 @@ describe('AiRunReview 분석 근거', () => { expect(await screen.findByText('체류만료일이 30일 이내로 확인됨')).toBeInTheDocument() }) - it('falls back to a placeholder when the API has no evidence yet', async () => { + it('explains that the full request was used when the API has no evidence span', async () => { mockCatalogAndWorkers() render( @@ -278,7 +278,23 @@ describe('AiRunReview 분석 근거', () => { , ) - expect(await screen.findByText('현재 분석 API에서 제공하지 않음')).toBeInTheDocument() + expect(await screen.findByText('입력한 요청 전체를 기준으로 분류했습니다.')).toBeInTheDocument() + }) +}) + +describe('AiRunReview 추가 정보 입력', () => { + it('renders due_at as a user-friendly date and time picker even when Server sends TEXT', () => { + render( + + + , + ) + + const dueAtInput = screen.getByLabelText('업무 준비 완료 희망일 *') + expect(dueAtInput).toHaveAttribute('type', 'datetime-local') + expect( + screen.getByText('이 날짜와 시간까지 필요한 정보와 서류 준비를 마칠 예정입니다.'), + ).toBeInTheDocument() }) }) @@ -316,7 +332,7 @@ describe('AiRunReview progress updates', () => { renderProcessingReview() - expect(await screen.findByLabelText('신청 목표일을 입력해 주세요. *')).toBeInTheDocument() + expect(await screen.findByLabelText('업무 준비 완료 희망일 *')).toBeInTheDocument() expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).includes('/events'))).toBe(true) expect( vi @@ -344,7 +360,7 @@ describe('AiRunReview progress updates', () => { ) await vi.advanceTimersByTimeAsync(1300) - expect(await screen.findByLabelText('신청 목표일을 입력해 주세요. *')).toBeInTheDocument() + expect(await screen.findByLabelText('업무 준비 완료 희망일 *')).toBeInTheDocument() }) it('polls while an SSE connection stays open without delivering an event', async () => { @@ -361,7 +377,7 @@ describe('AiRunReview progress updates', () => { await vi.advanceTimersByTimeAsync(1300) - expect(await screen.findByLabelText('신청 목표일을 입력해 주세요. *')).toBeInTheDocument() + expect(await screen.findByLabelText('업무 준비 완료 희망일 *')).toBeInTheDocument() }) it('keeps polling when PLAN succeeded but context enrichment is still running', async () => { @@ -382,6 +398,6 @@ describe('AiRunReview progress updates', () => { ) await vi.advanceTimersByTimeAsync(1300) - expect(await screen.findByLabelText('신청 목표일을 입력해 주세요. *')).toBeInTheDocument() + expect(await screen.findByLabelText('업무 준비 완료 희망일 *')).toBeInTheDocument() }) }) diff --git a/src/pages/ReviewWorkPage/AiRunReview.tsx b/src/pages/ReviewWorkPage/AiRunReview.tsx index 7001d8a..8f32f5a 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.tsx +++ b/src/pages/ReviewWorkPage/AiRunReview.tsx @@ -73,7 +73,24 @@ function statusPresentation(run: AiRunResponse): { } function inputType(question: AiRunQuestion) { - return question.input_type.toUpperCase() === 'DATE' ? 'date' : 'text' + const declaredType = question.input_type.toUpperCase() + if (question.slot_key === 'due_at' || declaredType === 'DATETIME') return 'datetime-local' + if (declaredType === 'DATE' || question.slot_key.endsWith('_date')) return 'date' + if (declaredType === 'NUMBER' || declaredType === 'MONEY') return 'number' + return 'text' +} + +function questionPresentation(question: AiRunQuestion) { + if (question.slot_key === 'due_at') { + return { + label: '업무 준비 완료 희망일', + help: '이 날짜와 시간까지 필요한 정보와 서류 준비를 마칠 예정입니다.', + } + } + return { + label: question.label, + help: null, + } } export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { @@ -397,9 +414,9 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) {

{intentLabel(run.detected_intent)}

-

분석 근거

+

{run.evidence ? '분석 근거' : '판단 기준'}

- {run.evidence ?? '현재 분석 API에서 제공하지 않음'} + {run.evidence ?? '입력한 요청 전체를 기준으로 분류했습니다.'}

@@ -408,29 +425,41 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { {run.analysis_outcome === 'NEEDS_INFO' && (

HR이 확인할 정보 {run.questions.length}개

- {run.questions.map((question) => ( -
- - - setAnswers((current) => ({ - ...current, - [question.slot_key]: event.target.value, - })) - } - /> -
- ))} + {run.questions.map((question) => { + const questionView = questionPresentation(question) + const helpId = questionView.help + ? `ai-question-${question.slot_key}-help` + : undefined + return ( +
+ + {questionView.help && ( +

+ {questionView.help} +

+ )} + + setAnswers((current) => ({ + ...current, + [question.slot_key]: event.target.value, + })) + } + /> +
+ ) + })}

답변은 현재 분석 실행에 저장되며 새 분석 시도로 이어집니다.

@@ -587,8 +616,7 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) {

)}

- 분석 근거는 API가 실제 근거 데이터를 제공할 때만 표시합니다. 근거 없는 확률 점수는 사용하지 - 않습니다. + Agent가 원문에서 실제 근거 구절을 제공한 경우에만 해당 구절을 표시합니다.

) diff --git a/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx b/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx index 64b5860..e451d21 100644 --- a/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx +++ b/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx @@ -42,7 +42,7 @@ describe('ReviewWorkPage', () => { { slot_key: 'due_at', label: '신청 목표일을 입력해 주세요.', - input_type: 'DATE', + input_type: 'TEXT', required: true, answer: null, }, @@ -55,7 +55,10 @@ describe('ReviewWorkPage', () => { renderPage('/tasks/new/review', { aiRun }) expect(screen.getByText(aiRun.instruction)).toBeInTheDocument() - expect(screen.getByLabelText('신청 목표일을 입력해 주세요. *')).toBeInTheDocument() + expect(screen.getByLabelText('업무 준비 완료 희망일 *')).toHaveAttribute( + 'type', + 'datetime-local', + ) }) it('redirects to the request input screen when there is no aiRunId to review', async () => { From f83c89a50dcec18af420c135fcacb33b8d69c56c Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 08:48:50 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(ai-run):=20=EB=A7=88=EA=B0=90=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EC=84=A0=ED=83=9D=ED=98=95=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReviewWorkPage/AiRunReview.module.css | 23 +++ src/pages/ReviewWorkPage/AiRunReview.test.tsx | 68 ++++++- src/pages/ReviewWorkPage/AiRunReview.tsx | 190 +++++++++++++++--- .../ReviewWorkPage/ReviewWorkPage.test.tsx | 5 +- 4 files changed, 252 insertions(+), 34 deletions(-) diff --git a/src/pages/ReviewWorkPage/AiRunReview.module.css b/src/pages/ReviewWorkPage/AiRunReview.module.css index bb4f1b2..004a0dc 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.module.css +++ b/src/pages/ReviewWorkPage/AiRunReview.module.css @@ -299,6 +299,29 @@ box-sizing: border-box; } +.dateTimeSelect { + display: grid; + grid-template-columns: repeat(3, minmax(88px, 1fr)) repeat(2, minmax(76px, 0.8fr)); + gap: 8px; +} + +.dateTimeSelect select { + width: 100%; + min-height: 42px; + padding: 8px 10px; + color: var(--text-primary); + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + font: inherit; +} + +@media (max-width: 760px) { + .dateTimeSelect { + grid-template-columns: repeat(3, minmax(72px, 1fr)); + } +} + .errorCard { padding: var(--fowoco-spacing-20) var(--fowoco-spacing-24); background: var(--fowoco-red-50, #fff1f1); diff --git a/src/pages/ReviewWorkPage/AiRunReview.test.tsx b/src/pages/ReviewWorkPage/AiRunReview.test.tsx index 3cfe581..b7aa343 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.test.tsx +++ b/src/pages/ReviewWorkPage/AiRunReview.test.tsx @@ -283,19 +283,81 @@ describe('AiRunReview 분석 근거', () => { }) describe('AiRunReview 추가 정보 입력', () => { - it('renders due_at as a user-friendly date and time picker even when Server sends TEXT', () => { + it('renders due_at as year, month, day and time dropdowns defaulted to today', () => { render( , ) - const dueAtInput = screen.getByLabelText('업무 준비 완료 희망일 *') - expect(dueAtInput).toHaveAttribute('type', 'datetime-local') + const today = new Date() + expect(screen.getByRole('group', { name: '업무 준비 완료 희망일 *' })).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: '업무 준비 완료 희망일 연도' })).toHaveValue( + String(today.getFullYear()), + ) + expect(screen.getByRole('combobox', { name: '업무 준비 완료 희망일 월' })).toHaveValue( + String(today.getMonth() + 1), + ) + expect(screen.getByRole('combobox', { name: '업무 준비 완료 희망일 일' })).toHaveValue( + String(today.getDate()), + ) + expect(screen.getByRole('combobox', { name: '업무 준비 완료 희망 시' })).toHaveValue('18') + expect(screen.getByRole('combobox', { name: '업무 준비 완료 희망 분' })).toHaveValue('0') expect( screen.getByText('이 날짜와 시간까지 필요한 정보와 서류 준비를 마칠 예정입니다.'), ).toBeInTheDocument() }) + + it('combines dropdown selections into the Server due_at answer format', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(needsInfoRun())) + const user = userEvent.setup() + render( + + + , + ) + + const nextYear = new Date().getFullYear() + 1 + await user.selectOptions( + screen.getByRole('combobox', { name: '업무 준비 완료 희망일 연도' }), + String(nextYear), + ) + await user.selectOptions( + screen.getByRole('combobox', { name: '업무 준비 완료 희망일 월' }), + '12', + ) + await user.selectOptions( + screen.getByRole('combobox', { name: '업무 준비 완료 희망일 일' }), + '20', + ) + await user.selectOptions(screen.getByRole('combobox', { name: '업무 준비 완료 희망 시' }), '17') + await user.selectOptions(screen.getByRole('combobox', { name: '업무 준비 완료 희망 분' }), '30') + await user.click(screen.getByRole('button', { name: '답변하고 다시 분석' })) + + const answerCall = vi + .mocked(fetch) + .mock.calls.find(([input]) => String(input).includes('/answers')) + expect(JSON.parse(String(answerCall?.[1]?.body))).toEqual({ + expected_version: 2, + answers: { due_at: `${nextYear}-12-20T17:30` }, + }) + }) + + it('restores a date-only Server answer without replacing it with today', () => { + const run = needsInfoRun() + run.questions[0] = { ...run.questions[0], answer: '2027-02-03' } + + render( + + + , + ) + + expect(screen.getByRole('combobox', { name: '업무 준비 완료 희망일 연도' })).toHaveValue('2027') + expect(screen.getByRole('combobox', { name: '업무 준비 완료 희망일 월' })).toHaveValue('2') + expect(screen.getByRole('combobox', { name: '업무 준비 완료 희망일 일' })).toHaveValue('3') + expect(screen.getByRole('combobox', { name: '업무 준비 완료 희망 시' })).toHaveValue('18') + }) }) describe('AiRunReview progress updates', () => { diff --git a/src/pages/ReviewWorkPage/AiRunReview.tsx b/src/pages/ReviewWorkPage/AiRunReview.tsx index 8f32f5a..bca28a6 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.tsx +++ b/src/pages/ReviewWorkPage/AiRunReview.tsx @@ -74,12 +74,141 @@ function statusPresentation(run: AiRunResponse): { function inputType(question: AiRunQuestion) { const declaredType = question.input_type.toUpperCase() - if (question.slot_key === 'due_at' || declaredType === 'DATETIME') return 'datetime-local' + if (declaredType === 'DATETIME') return 'datetime-local' if (declaredType === 'DATE' || question.slot_key.endsWith('_date')) return 'date' if (declaredType === 'NUMBER' || declaredType === 'MONEY') return 'number' return 'text' } +const DEFAULT_DUE_HOUR = 18 + +function localDateTimeValue(date = new Date()) { + 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}T${String(DEFAULT_DUE_HOUR).padStart(2, '0')}:00` +} + +function normalizeDueAtValue(value?: string | null) { + if (!value) return localDateTimeValue() + const dateTimeMatch = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/) + if (dateTimeMatch) return `${dateTimeMatch[1]}T${dateTimeMatch[2]}:${dateTimeMatch[3]}` + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T18:00` + return localDateTimeValue() +} + +function initialAnswer(question: AiRunQuestion) { + if (question.slot_key === 'due_at') return normalizeDueAtValue(question.answer) + return question.answer ?? '' +} + +function initialAnswers(questions: AiRunQuestion[]) { + return Object.fromEntries( + questions.map((question) => [question.slot_key, initialAnswer(question)]), + ) +} + +interface DueAtSelectProps { + value: string + labelId: string + helpId?: string + onChange: (value: string) => void +} + +function DueAtSelect({ value, labelId, helpId, onChange }: DueAtSelectProps) { + const normalized = normalizeDueAtValue(value) + const [datePart, timePart] = normalized.split('T') + const [year, month, day] = datePart.split('-').map(Number) + const [hour, minute] = timePart.split(':').map(Number) + const currentYear = new Date().getFullYear() + const firstYear = Math.min(currentYear, year) + const years = Array.from( + { length: Math.max(4, year - firstYear + 1) }, + (_, index) => firstYear + index, + ) + const daysInMonth = new Date(year, month, 0).getDate() + const minuteOptions = [0, 30].includes(minute) ? [0, 30] : [0, 30, minute].sort((a, b) => a - b) + + function update( + next: Partial<{ year: number; month: number; day: number; hour: number; minute: number }>, + ) { + const nextYear = next.year ?? year + const nextMonth = next.month ?? month + const maxDay = new Date(nextYear, nextMonth, 0).getDate() + const nextDay = Math.min(next.day ?? day, maxDay) + const nextHour = next.hour ?? hour + const nextMinute = next.minute ?? minute + onChange( + `${nextYear}-${String(nextMonth).padStart(2, '0')}-${String(nextDay).padStart(2, '0')}T${String(nextHour).padStart(2, '0')}:${String(nextMinute).padStart(2, '0')}`, + ) + } + + return ( +
+ + + + + +
+ ) +} + function questionPresentation(question: AiRunQuestion) { if (question.slot_key === 'due_at') { return { @@ -97,9 +226,7 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { const navigate = useNavigate() const [run, setRun] = useState(initialRun) const [answers, setAnswers] = useState>(() => - Object.fromEntries( - initialRun.questions.map((question) => [question.slot_key, question.answer ?? '']), - ), + initialAnswers(initialRun.questions), ) const [selectedCandidateId, setSelectedCandidateId] = useState(() => initialRun.candidates.length === 1 && initialRun.candidates[0].missing_slots.length === 0 @@ -248,11 +375,7 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { }, [isProcessing, run.ai_run_id]) useEffect(() => { - setAnswers( - Object.fromEntries( - run.questions.map((question) => [question.slot_key, question.answer ?? '']), - ), - ) + setAnswers(initialAnswers(run.questions)) }, [run.questions]) useEffect(() => { @@ -430,33 +553,46 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { const helpId = questionView.help ? `ai-question-${question.slot_key}-help` : undefined + const labelId = `ai-question-${question.slot_key}-label` return (
-
) })} diff --git a/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx b/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx index e451d21..936807f 100644 --- a/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx +++ b/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx @@ -55,10 +55,7 @@ describe('ReviewWorkPage', () => { renderPage('/tasks/new/review', { aiRun }) expect(screen.getByText(aiRun.instruction)).toBeInTheDocument() - expect(screen.getByLabelText('업무 준비 완료 희망일 *')).toHaveAttribute( - 'type', - 'datetime-local', - ) + expect(screen.getByRole('group', { name: '업무 준비 완료 희망일 *' })).toBeInTheDocument() }) it('redirects to the request input screen when there is no aiRunId to review', async () => { From f7eb05e572fb872c9105c05f55ee7dc181732eef Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 10:42:06 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(ai-run):=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=95=88=EB=82=B4=EC=99=80=20=ED=95=84?= =?UTF-8?q?=EC=88=98=20=ED=95=AD=EB=AA=A9=20=EB=8F=99=EC=9E=91=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/AgentSummary/AgentSummary.module.css | 8 +- .../ui/AgentSummary/AgentSummary.tsx | 5 +- .../CaseDetailPage/CaseDetailPage.module.css | 4 + .../CaseDetailPage/CaseDetailPage.test.tsx | 11 +++ src/pages/CaseDetailPage/CaseDetailPage.tsx | 9 ++ .../ReviewWorkPage/AiRunReview.module.css | 10 ++- src/pages/ReviewWorkPage/AiRunReview.test.tsx | 33 ++++++- src/pages/ReviewWorkPage/AiRunReview.tsx | 90 ++++++++++++++++--- 8 files changed, 144 insertions(+), 26 deletions(-) diff --git a/src/components/ui/AgentSummary/AgentSummary.module.css b/src/components/ui/AgentSummary/AgentSummary.module.css index a6a548a..497bed8 100644 --- a/src/components/ui/AgentSummary/AgentSummary.module.css +++ b/src/components/ui/AgentSummary/AgentSummary.module.css @@ -32,11 +32,5 @@ .action { align-self: flex-start; - font-size: 14px; - font-weight: 500; - color: var(--brand-primary); - background: none; - border: none; - padding: 0; - cursor: pointer; + margin-top: var(--fowoco-spacing-4); } diff --git a/src/components/ui/AgentSummary/AgentSummary.tsx b/src/components/ui/AgentSummary/AgentSummary.tsx index 9590651..e68680f 100644 --- a/src/components/ui/AgentSummary/AgentSummary.tsx +++ b/src/components/ui/AgentSummary/AgentSummary.tsx @@ -1,4 +1,5 @@ import styles from './AgentSummary.module.css' +import { Button } from '../Button/Button' export interface AgentSummaryProps { headline: string @@ -13,9 +14,9 @@ export function AgentSummary({ headline, body, actionLabel, onAction }: AgentSum

AGENT가 준비한 다음 행동

{headline}

{body}

- + ) } diff --git a/src/pages/CaseDetailPage/CaseDetailPage.module.css b/src/pages/CaseDetailPage/CaseDetailPage.module.css index 8d25c11..37a59ff 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.module.css +++ b/src/pages/CaseDetailPage/CaseDetailPage.module.css @@ -259,6 +259,10 @@ text-align: center; } +.workflowAction { + margin-top: var(--fowoco-spacing-16); +} + .stepTitle { margin: 0; font-size: 14px; diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index 582324b..d1004d6 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -938,6 +938,17 @@ describe('CaseDetailPage', () => { expect(await screen.findByText(/완료 처리 불가 · 승인 · 필수 체크리스트/)).toBeInTheDocument() }) + it('offers a visible action that opens the checklist required for progress', async () => { + const user = userEvent.setup() + mockTaskAndActivities() + renderPage() + + await user.click(await screen.findByRole('button', { name: '필수 항목 확인하기' })) + + expect(screen.getByRole('tab', { name: '체크리스트' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('button', { name: /여권 사본 확인/ })).toBeInTheDocument() + }) + it('blocks completion and offers retry when document readiness cannot be verified', async () => { const user = userEvent.setup() mockTaskAndActivities({}, [], errorResponse(503, 'SERVICE_UNAVAILABLE', '서류 상태 확인 지연')) diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index ac271d3..5945702 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -1155,6 +1155,15 @@ export function CaseDetailPage() { })} )} + {!checklistReady && ( + + )} {approvalReady && workerGuideReady && (