Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 13 additions & 25 deletions src/pages/CreateWorkPage/CreateWorkPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport'
import { useToastStore } from '../../store/toastStore'
import { CreateWorkPage } from './CreateWorkPage'
import { DEFAULT_ORIGINAL_REQUEST, WORKFLOW_TASKS } from './createWorkData'

function jsonResponse(body: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(body), {
Expand Down Expand Up @@ -67,36 +66,34 @@ function renderPage(initialEntry: string | { pathname: string; state?: unknown }
}

describe('CreateWorkPage', () => {
it('shows the request forwarded from the dashboard as the original request', () => {
it('prefills the request forwarded from the dashboard as editable text', () => {
renderPage({ pathname: '/tasks/new', state: { prefill: '응웬반A 체류기간 연장 준비' } })

expect(screen.getByText('응웬반A 체류기간 연장 준비')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '원본 요청' })).toHaveValue(
'응웬반A 체류기간 연장 준비',
)
})

it('falls back to a default original request when nothing was forwarded', () => {
it('starts with an empty, editable request when nothing was forwarded', () => {
renderPage()

expect(screen.getByText(DEFAULT_ORIGINAL_REQUEST.split('\n')[0])).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '원본 요청' })).toHaveValue('')
expect(screen.getByRole('button', { name: '정보 보완' })).toBeDisabled()
})

it('renders every candidate task in the work item', () => {
renderPage()

for (const task of WORKFLOW_TASKS) {
expect(screen.getByText(task.title)).toBeInTheDocument()
}
})

it('navigates to the review flow when moving to information review', async () => {
it('navigates to the review flow with the text the user typed', async () => {
const user = userEvent.setup()
renderPage()

await user.type(screen.getByRole('textbox', { name: '원본 요청' }), '직접 입력한 요청')
await user.click(screen.getByRole('button', { name: '정보 보완' }))

expect(await screen.findByText(/검토 화면\?aiRunId=A-1/)).toBeInTheDocument()
const analyzeCall = vi.mocked(fetch).mock.calls.find(([url]) => String(url).endsWith('/ai-runs'))
const analyzeCall = vi
.mocked(fetch)
.mock.calls.find(([url]) => String(url).endsWith('/ai-runs'))
expect(JSON.parse((analyzeCall?.[1] as RequestInit).body as string)).toEqual({
instruction: DEFAULT_ORIGINAL_REQUEST,
instruction: '직접 입력한 요청',
})
})

Expand All @@ -117,13 +114,4 @@ describe('CreateWorkPage', () => {

expect(screen.getByRole('dialog', { name: '파일 가져오기 · 파일 확인' })).toBeInTheDocument()
})

it('shows a toast when editing the original request', async () => {
const user = userEvent.setup()
renderPage()

await user.click(screen.getByRole('button', { name: '원문 수정' }))

expect(screen.getByText('원문 수정은 준비 중입니다.')).toBeInTheDocument()
})
})
141 changes: 36 additions & 105 deletions src/pages/CreateWorkPage/CreateWorkPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,54 +4,33 @@ import { ApiError, getErrorMessage } from '../../api/errors'
import { createAiRun } from '../../api/aiRuns'
import { Button } from '../../components/ui/Button/Button'
import { WorkflowStepIndicator } from '../../components/ui/WorkflowStepIndicator/WorkflowStepIndicator'
import { useToastStore } from '../../store/toastStore'
import { REVIEW_STEPS } from '../ReviewWorkPage/reviewWorkData'
import styles from '../ReviewWorkPage/ReviewWorkPage.module.css'
import {
ACTION_DOCK,
AGENT_SCOPE_NOTE,
COMPOUND_REQUEST_NOTE,
DEFAULT_ORIGINAL_REQUEST,
REQUIRED_INFO_COUNT,
REQUIRED_INFO_ROWS,
SCENARIO_STATUS_LABEL,
TARGET_CASE,
UNDERSTOOD_WORK,
WORKFLOW_TASKS,
type InfoSourceTone,
} from './createWorkData'
import { AGENT_SCOPE_NOTE } from './createWorkData'
import { ImportWizardModal } from './importWizard/ImportWizardModal'
import {
saveActiveWorkRequestDraft,
saveAiRunWorkRequestDraft,
type WorkRequestDraft,
} from './workRequestDraft'

const TASK_STATUS_TONE: Record<string, string> = {
current: 'pillBrand',
pending: 'pillAmber',
blocked: 'pillNeutral',
}

const INFO_SOURCE_TONE: Record<InfoSourceTone, string> = {
neutral: 'pillNeutral',
brand: 'pillBrand',
amber: 'pillAmber',
}

export function CreateWorkPage() {
const navigate = useNavigate()
const location = useLocation()
// HOME-001 대시보드의 AI 요청 프롬프트 칩에서 넘어온 경우 선택한 문구를 원본 요청으로 보여준다.
const routeState = location.state as { prefill?: string; request?: string } | null
const originalRequest = routeState?.request ?? routeState?.prefill ?? DEFAULT_ORIGINAL_REQUEST
// HOME-001 대시보드의 AI 요청 프롬프트 칩·업무함의 "업무 준비" 액션에서 넘어온 경우
// 선택한 문구를 원본 요청 초안으로 미리 채운다. 없으면 빈 값으로 시작해서 직접 입력한다.
const routeState = location.state as {
prefill?: string
request?: string
workerId?: string
} | null
const [requestText, setRequestText] = useState(routeState?.request ?? routeState?.prefill ?? '')
const [importWizardOpen, setImportWizardOpen] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const [analysisError, setAnalysisError] = useState<string | null>(null)
const showToast = useToastStore((state) => state.showToast)

async function handleAnalyze() {
const instruction = originalRequest.trim()
const instruction = requestText.trim()
if (instruction === '' || analyzing) return

setAnalyzing(true)
Expand All @@ -60,9 +39,9 @@ export function CreateWorkPage() {
// UI 분류값이나 Intent 코드를 붙이지 않고 사용자가 입력한 원문만 전달한다.
const aiRun = await createAiRun(instruction, globalThis.crypto.randomUUID())
const draft: WorkRequestDraft = {
request: originalRequest,
request: requestText,
mode: 'nl',
workerId: '',
workerId: routeState?.workerId ?? '',
attachments: [],
}
saveActiveWorkRequestDraft(draft)
Expand All @@ -79,11 +58,6 @@ export function CreateWorkPage() {
}
}

function handleEditRequest() {
// TODO(backend): PATCH /api/work-items/draft -> 원문 수정 반영
showToast('원문 수정은 준비 중입니다.')
}

function handleStepClick(index: number) {
if (index === 0) return
navigate(`/tasks/new/review?step=${index}`)
Expand All @@ -101,12 +75,11 @@ export function CreateWorkPage() {

<div className={styles.headerRow}>
<div>
<h1 className={styles.headline}>요청을 업무 단위로 확인해 주세요</h1>
<h1 className={styles.headline}>요청을 자연어로 입력해 주세요</h1>
<p className={styles.description}>
원문과 기존 데이터를 바탕으로 상위 업무 건의 지금 할 일을 정리했습니다.
입력한 원문을 그대로 분석해서 처리할 업무 후보를 준비합니다.
</p>
</div>
<span className={styles.scenarioPill}>{SCENARIO_STATUS_LABEL}</span>
</div>

<div className={styles.workspace}>
Expand All @@ -116,71 +89,27 @@ export function CreateWorkPage() {
<h2 className={styles.railCardTitle}>원본 요청</h2>
<span className={`${styles.pill} ${styles.pillBrand}`}>HR 입력</span>
</div>
{originalRequest.split('\n').map((line) => (
<p key={line} className={styles.railCardMeta}>
{line}
</p>
))}
</div>

<div className={styles.railCard}>
<div className={styles.railCardTitleRow}>
<div>
<p className={styles.railCardTitleBrand}>Agent가 이해한 요청</p>
<p className={styles.railCardValue}>{UNDERSTOOD_WORK.title}</p>
</div>
<span className={styles.railCardMeta}>{UNDERSTOOD_WORK.note}</span>
</div>
<textarea
className={styles.requestTextarea}
value={requestText}
onChange={(event) => setRequestText(event.target.value)}
placeholder="예: 응웬반A의 체류기간 연장 준비를 진행해줘. 여권 사본이 없으면 제출을 안내해줘."
rows={5}
maxLength={2000}
aria-label="원본 요청"
/>
</div>

<h2 className={styles.panelTitle}>업무 건의 지금 할 일</h2>
{WORKFLOW_TASKS.map((task) => (
<div
key={task.title}
className={`${styles.taskCard} ${task.status === 'current' ? styles.taskCardCurrent : ''}`}
>
<div>
<p className={styles.taskTitle}>{task.title}</p>
<p className={styles.taskMeta}>{task.meta}</p>
</div>
<span className={`${styles.pill} ${styles[TASK_STATUS_TONE[task.status]]}`}>
{task.statusLabel}
</span>
</div>
))}

<p className={styles.footnote}>{COMPOUND_REQUEST_NOTE}</p>
<button type="button" className={styles.railLink} onClick={() => setImportWizardOpen(true)}>
<button
type="button"
className={styles.railLink}
onClick={() => setImportWizardOpen(true)}
>
파일로 근로자 명단 가져오기 →
</button>
</div>

<div className={styles.railStack}>
<div className={styles.railCard}>
<p className={styles.railCardTitle}>대상 근로자 · 업무 건</p>
<p className={styles.railCardValue}>{TARGET_CASE.workerName}</p>
<p className={styles.railCardMeta}>{TARGET_CASE.meta}</p>
<p className={styles.railCardMeta}>{TARGET_CASE.progress}</p>
</div>

<div className={styles.railCard}>
<div className={styles.railCardTitleRow}>
<p className={styles.railCardTitle}>필수정보 충족 현황</p>
<span className={styles.railCardCount}>{REQUIRED_INFO_COUNT}</span>
</div>
{REQUIRED_INFO_ROWS.map((row) => (
<div key={row.label} className={styles.infoRow}>
<span className={styles.infoRowLabel}>{row.label}</span>
<span className={styles.infoRowValueGroup}>
<span className={styles.infoRowValue}>{row.value}</span>
<span className={`${styles.pill} ${styles[INFO_SOURCE_TONE[row.sourceTone]]}`}>
{row.source}
</span>
</span>
</div>
))}
</div>

<div className={styles.railCardSubtle}>
<p className={styles.railCardTitleBrand}>Agent가 준비하는 범위</p>
<p className={styles.railCardMeta}>{AGENT_SCOPE_NOTE}</p>
Expand All @@ -190,14 +119,16 @@ export function CreateWorkPage() {

<div className={styles.actionDock}>
<div>
<p className={styles.dockTitle}>{ACTION_DOCK.title}</p>
<p className={styles.dockSubtitle}>{ACTION_DOCK.subtitle}</p>
<p className={styles.dockTitle}>
원문을 분석하면 다음 화면에서 처리할 업무 후보를 확인합니다.
</p>
</div>
<div className={styles.dockActions}>
<Button variant="secondary" onClick={handleEditRequest}>
원문 수정
</Button>
<Button onClick={handleAnalyze} isLoading={analyzing} disabled={originalRequest.trim() === ''}>
<Button
onClick={handleAnalyze}
isLoading={analyzing}
disabled={requestText.trim() === ''}
>
정보 보완
</Button>
</div>
Expand Down
77 changes: 2 additions & 75 deletions src/pages/CreateWorkPage/createWorkData.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,2 @@
// TODO(backend): 이 파일 전체는 Figma PWF3/Screen/REVIEW-001/01 Request Input 목업의 목데이터다.
// POST /api/work-items/analyze 등 실제 API 연동 시 아래 목데이터를 대체한다.

export const DEFAULT_ORIGINAL_REQUEST = '응우옌 반 A의 재계약과 연장 준비를 진행해줘.\n여권 사본이 없으면 8월 7일까지 제출하도록 안내해줘.'

export const SCENARIO_STATUS_LABEL = '업무 건 · 재계약 · 연장 준비'

export const UNDERSTOOD_WORK = {
title: '재계약 및 연장 준비',
note: '상위 업무 건에 지금 할 일 3개 연결',
}

export type WorkflowTaskStatus = 'current' | 'pending' | 'blocked'

export interface WorkflowTask {
title: string
meta: string
status: WorkflowTaskStatus
statusLabel: string
}

export const WORKFLOW_TASKS: WorkflowTask[] = [
{
title: '재계약 조건 확인',
meta: '현재 처리 절차 · 지금 시작 가능',
status: 'current',
statusLabel: '현재 처리 절차',
},
{
title: '여권 사본 요청',
meta: '8월 7일까지 · 보안 링크 준비',
status: 'pending',
statusLabel: '정보 보완',
},
{
title: '취업활동기간 연장 준비',
meta: '서명된 계약서 등록 후 시작',
status: 'blocked',
statusLabel: '선행 대기',
},
]

export const COMPOUND_REQUEST_NOTE = '복합 요청은 거절하지 않고 하나의 업무 건 아래 지금 할 일로 나눕니다.'

export const TARGET_CASE = {
workerName: '응우옌 반 A',
meta: '베트남 · E-9 · 담당 김하나',
progress: '재계약 · 연장 준비 · 진행 4/12',
}

export type InfoSourceTone = 'neutral' | 'brand' | 'amber'

export interface RequiredInfoRow {
label: string
value: string
source: string
sourceTone: InfoSourceTone
}

export const REQUIRED_INFO_COUNT = '필수정보 7/9'

export const REQUIRED_INFO_ROWS: RequiredInfoRow[] = [
{ label: '근로자', value: '응우옌 반 A', source: '기존 등록 정보', sourceTone: 'neutral' },
{ label: '계속 고용 의사', value: '계속 고용', source: 'HR 입력', sourceTone: 'brand' },
{ label: '여권 사본', value: '보유 없음', source: '기존 등록 정보', sourceTone: 'neutral' },
{ label: '제출 기한', value: '2026.08.07', source: 'HR 입력', sourceTone: 'brand' },
{ label: '여권 만료일', value: '여권 사본 확인 필요', source: '근로자 제출', sourceTone: 'amber' },
]

export const AGENT_SCOPE_NOTE = '처리 절차와 값의 출처를 제안하며, 실제 지금 할 일 등록은 HR이 확정합니다.'

export const ACTION_DOCK = {
title: '상위 업무 건에 지금 할 일 후보 3개를 준비했습니다.',
subtitle: '내부 코드와 분류 근거는 상세 명세에서만 확인합니다.',
}
export const AGENT_SCOPE_NOTE =
'처리 절차와 값의 출처를 제안하며, 실제 지금 할 일 등록은 HR이 확정합니다.'
19 changes: 19 additions & 0 deletions src/pages/ReviewWorkPage/ReviewWorkPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,25 @@
border-radius: var(--fowoco-radius-6);
}

.requestTextarea {
width: 100%;
box-sizing: border-box;
margin-top: var(--fowoco-spacing-12);
padding: var(--fowoco-spacing-12);
background: var(--surface-default);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-6);
font-size: 14px;
font-family: inherit;
color: var(--text-primary);
resize: vertical;
}

.requestTextarea:focus-visible {
outline: 2px solid var(--brand-primary);
outline-offset: 2px;
}

.railCardTitleRow {
display: flex;
align-items: center;
Expand Down