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

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

+ {analysisError && ( +

+ {analysisError} +

+ )} diff --git a/src/pages/ReviewWorkPage/AiRunReview.module.css b/src/pages/ReviewWorkPage/AiRunReview.module.css index 03b2c54..59bd7eb 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.module.css +++ b/src/pages/ReviewWorkPage/AiRunReview.module.css @@ -187,9 +187,17 @@ margin-top: 16px; } +.fieldGridSingle { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + margin-top: 16px; +} + @media (max-width: 560px) { .fieldGrid, - .fieldGridTwo { + .fieldGridTwo, + .fieldGridSingle { grid-template-columns: 1fr; } } @@ -273,6 +281,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; @@ -284,6 +307,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 daee650..0f7b49d 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('does not render an evidence field when the API has no evidence span', async () => { mockCatalogAndWorkers() render( @@ -278,7 +278,114 @@ describe('AiRunReview 분석 근거', () => { , ) - expect(await screen.findByText('현재 분석 API에서 제공하지 않음')).toBeInTheDocument() + expect((await screen.findAllByText('요청 유형')).length).toBeGreaterThan(0) + expect(screen.queryByText('판단 기준')).not.toBeInTheDocument() + expect(screen.queryByText('입력한 요청 전체를 기준으로 분류했습니다.')).not.toBeInTheDocument() + }) +}) + +describe('AiRunReview 실패 안내', () => { + it('explains how to recover when the worker target is not found', () => { + render( + + + , + ) + + expect(screen.getAllByText('근로자를 찾지 못했습니다.').length).toBeGreaterThan(0) + expect( + screen.getAllByText( + '등록된 정확한 이름으로 요청을 수정하거나 근로자 목록에서 이름을 확인해 주세요.', + ).length, + ).toBeGreaterThan(0) + expect(screen.getByText(/참고 코드: TARGET_NOT_FOUND/)).toBeInTheDocument() + expect(screen.queryByText('분석을 계속할 수 없습니다')).not.toBeInTheDocument() + }) +}) + +describe('AiRunReview 추가 정보 입력', () => { + it('renders due_at as year, month, day and time dropdowns defaulted to today', () => { + render( + + + , + ) + + 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') }) }) @@ -316,7 +423,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 +451,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 +468,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 +489,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..c4a1414 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.tsx +++ b/src/pages/ReviewWorkPage/AiRunReview.tsx @@ -28,15 +28,77 @@ interface AiRunReviewProps { initialDraft?: WorkRequestDraft | null } +interface FailurePresentation { + title: string + description: string +} + +function failurePresentation(errorCode: string | null): FailurePresentation { + switch (errorCode) { + case 'TARGET_NOT_FOUND': + return { + title: '근로자를 찾지 못했습니다.', + description: + '등록된 정확한 이름으로 요청을 수정하거나 근로자 목록에서 이름을 확인해 주세요.', + } + case 'TARGET_AMBIGUOUS': + return { + title: '근로자를 한 명으로 특정할 수 없습니다.', + description: '같은 이름의 근로자가 있을 수 있습니다. 더 구체적인 이름으로 요청해 주세요.', + } + case 'UNSUPPORTED_INTENT': + return { + title: '아직 지원하지 않는 업무 요청입니다.', + description: '체류기간 연장 등 현재 지원하는 업무로 요청을 수정해 주세요.', + } + case 'CONTEXT_ROUND_LIMIT': + return { + title: '필요한 정보 확인을 마치지 못했습니다.', + description: '요청 내용을 조금 더 구체적으로 작성한 뒤 다시 분석해 주세요.', + } + case 'TARGET_CHANGED': + return { + title: '분석 중 근로자 정보가 변경되었습니다.', + description: '최신 근로자 정보를 확인한 뒤 같은 요청을 다시 분석해 주세요.', + } + case 'RUNTIME_DISABLED': + case 'RUNTIME_UNAVAILABLE': + case 'TRANSPORT_FAILURE': + case 'DEADLINE_EXCEEDED': + case 'CIRCUIT_OPEN': + case 'BULKHEAD_FULL': + return { + title: 'Agent에 일시적으로 연결할 수 없습니다.', + description: '잠시 후 같은 요청을 다시 분석해 주세요.', + } + case 'UNSUPPORTED_WORKFLOW': + case 'FORBIDDEN_FIELD': + case 'INVALID_CONTEXT_RESPONSE': + case 'INVALID_RESPONSE_CONTRACT': + case 'CONTRACT_VERSION_MISMATCH': + case 'KNOWLEDGE_VERSION_MISMATCH': + return { + title: 'Agent 처리 설정을 확인해야 합니다.', + description: '입력 내용은 유지되었습니다. 관리자 확인 후 다시 시도해 주세요.', + } + default: + return { + title: 'Agent가 요청을 완료하지 못했습니다.', + description: '입력 내용은 유지됩니다. 같은 요청을 다시 분석하거나 내용을 수정해 주세요.', + } + } +} + function statusPresentation(run: AiRunResponse): { title: string description: string tone: StatusTone } { if (run.status === 'FAILED') { + const failure = failurePresentation(run.error_code) return { - title: 'Agent가 요청을 완료하지 못했습니다.', - description: '입력 내용은 유지됩니다. 같은 요청을 다시 분석하거나 내용을 수정해 주세요.', + title: failure.title, + description: failure.description, tone: 'critical', } } @@ -73,16 +135,160 @@ function statusPresentation(run: AiRunResponse): { } function inputType(question: AiRunQuestion) { - return question.input_type.toUpperCase() === 'DATE' ? 'date' : 'text' + const declaredType = question.input_type.toUpperCase() + 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 { + label: '업무 준비 완료 희망일', + help: '이 날짜와 시간까지 필요한 정보와 서류 준비를 마칠 예정입니다.', + } + } + return { + label: question.label, + help: null, + } } 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 @@ -95,6 +301,7 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { const [error, setError] = useState(null) const decisionKeys = useRef(new Map()) const presentation = statusPresentation(run) + const failure = failurePresentation(run.error_code) const hasCandidates = run.candidates.length > 0 const catalogFetcher = useCallback(() => { @@ -231,11 +438,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(() => { @@ -391,46 +594,71 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) {

Agent가 확인한 요청

사용자가 입력한 원문

{run.instruction}

-
+

요청 유형

{intentLabel(run.detected_intent)}

-
-

분석 근거

-

- {run.evidence ?? '현재 분석 API에서 제공하지 않음'} -

-
+ {run.evidence && ( +
+

분석 근거

+

{run.evidence}

+
+ )}
{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 + const labelId = `ai-question-${question.slot_key}-label` + return ( +
+

+ {questionView.label} + {question.required ? ' *' : ''} +

+ {questionView.help && ( +

+ {questionView.help} +

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

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

@@ -439,12 +667,11 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { {run.status === 'FAILED' && (
-

분석을 계속할 수 없습니다

-

- 오류 코드: {run.error_code ?? 'UNKNOWN_ERROR'} -

+

{failure.title}

+

{failure.description}

- 업무는 생성되지 않았습니다. 원문은 그대로 유지됩니다. + 업무는 생성되지 않았습니다. 원문은 그대로 유지됩니다. 참고 코드:{' '} + {run.error_code ?? 'UNKNOWN_ERROR'}

)} @@ -587,8 +814,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..936807f 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,7 @@ describe('ReviewWorkPage', () => { renderPage('/tasks/new/review', { aiRun }) expect(screen.getByText(aiRun.instruction)).toBeInTheDocument() - expect(screen.getByLabelText('신청 목표일을 입력해 주세요. *')).toBeInTheDocument() + expect(screen.getByRole('group', { name: '업무 준비 완료 희망일 *' })).toBeInTheDocument() }) it('redirects to the request input screen when there is no aiRunId to review', async () => {