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
72 changes: 55 additions & 17 deletions src/pages/DashboardPage/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,16 @@ function renderPage() {
}

beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(TODAY_RESPONSE)))
vi.stubGlobal(
'fetch',
vi.fn((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 }))
}
return Promise.resolve(jsonResponse(TODAY_RESPONSE))
}),
)
})

afterEach(() => {
Expand All @@ -147,20 +156,32 @@ describe('DashboardPage', () => {
name: '오늘의 업무를 확인하세요.',
}),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: '승인 대기 2건 업무함에서 보기' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '오늘 마감 1건 업무함에서 보기' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '정보 보완 1건 업무함에서 보기' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '응답 대기 3건 업무함에서 보기' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '승인 대기 2건 업무함에서 보기' }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '오늘 마감 1건 업무함에서 보기' }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '정보 보완 1건 업무함에서 보기' }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '응답 대기 3건 업무함에서 보기' }),
).toBeInTheDocument()
expect(screen.getAllByText('응웬반A 체류연장 요청문').length).toBeGreaterThan(0)
expect(screen.getAllByText('응웬반A').length).toBeGreaterThan(0)
expect(screen.getByText(/처리 기한 .*D-1/)).toBeInTheDocument()
expect(screen.getAllByText('담당자').length).toBeGreaterThan(0)
expect(screen.getByText('체류기간 만료')).toBeInTheDocument()
expect(screen.getByText('여권 사본 만료')).toBeInTheDocument()

const requestedUrl = String(vi.mocked(fetch).mock.calls[0][0])
expect(requestedUrl).toContain('/dashboard/today?timezone=Asia%2FSeoul')
expect(requestedUrl).not.toContain('/tasks?')
const todayCall = vi
.mocked(fetch)
.mock.calls.find(([input]) => String(input).includes('/dashboard/today'))
expect(String(todayCall?.[0])).toContain('/dashboard/today?timezone=Asia%2FSeoul')
expect(vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes('/tasks?'))).toBe(
false,
)
})

it('renders the recommendation groups returned by the Today API', async () => {
Expand Down Expand Up @@ -201,7 +222,13 @@ describe('DashboardPage', () => {
display_name: `만료 근로자 ${index + 1}`,
})),
}
vi.mocked(fetch).mockResolvedValue(jsonResponse(previewResponse))
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 }))
}
return Promise.resolve(jsonResponse(previewResponse))
})

renderPage()

Expand All @@ -225,9 +252,7 @@ describe('DashboardPage', () => {
const user = userEvent.setup()
renderPage()

await user.click(
await screen.findByRole('button', { name: '정보 보완 1건 업무함에서 보기' }),
)
await user.click(await screen.findByRole('button', { name: '정보 보완 1건 업무함에서 보기' }))

expect(await screen.findByText('업무함 ?focus=needs-info')).toBeInTheDocument()
})
Expand All @@ -249,23 +274,36 @@ describe('DashboardPage', () => {
})

it('shows an honest empty state when the Today projection is empty', async () => {
vi.mocked(fetch).mockResolvedValue(jsonResponse(EMPTY_RESPONSE))
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 }))
}
return Promise.resolve(jsonResponse(EMPTY_RESPONSE))
})
renderPage()

expect(await screen.findByText('등록된 업무가 없습니다')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '업무 만들기' })).toBeInTheDocument()
})

it('shows an error state and retries the Today API request', async () => {
vi.mocked(fetch)
.mockRejectedValueOnce(new TypeError('network'))
.mockResolvedValueOnce(jsonResponse(TODAY_RESPONSE))
let todayCallCount = 0
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 }))
}
todayCallCount += 1
if (todayCallCount === 1) return Promise.reject(new TypeError('network'))
return Promise.resolve(jsonResponse(TODAY_RESPONSE))
})
const user = userEvent.setup()
renderPage()

await user.click(await screen.findByRole('button', { name: '다시 시도' }))

await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2))
await waitFor(() => expect(todayCallCount).toBe(2))
expect((await screen.findAllByText('응웬반A 체류연장 요청문')).length).toBeGreaterThan(0)
})

Expand Down
127 changes: 68 additions & 59 deletions src/pages/DashboardPage/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from 'react'
import { useCallback, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { fetchWorkers } from '../../api/workers'
import { useDashboardToday } from '../../components/layout/dashboardTodayContext'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
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'
Expand All @@ -19,11 +21,16 @@ export function DashboardPage() {
const [agentRequest, setAgentRequest] = useState('')
const agentRequestRef = useRef<HTMLTextAreaElement>(null)
const { status, data: today, error, refetch, lastUpdatedAt } = useDashboardToday()
// 우선 업무 카드의 근로자 이름을 표시하기 위한 조회 — today API의 priority_tasks에는
// worker_id만 있고 이름이 없다.
const { data: workerPage } = useApiQuery(useCallback(() => fetchWorkers({ size: 100 }), []))
const metrics = useMemo(() => (today ? buildDashboardMetrics(today.summary_counts) : []), [today])
const workItems = useMemo(
() =>
today ? buildDashboardWorkItems(today.priority_tasks, today.upcoming_7_days) : [],
[today],
today
? buildDashboardWorkItems(today.priority_tasks, today.upcoming_7_days, workerPage?.items)
: [],
[today, workerPage],
)
const agentPrepared = useMemo(
() =>
Expand Down Expand Up @@ -94,7 +101,9 @@ export function DashboardPage() {
{status === 'success' && (
<div className={styles.updateInfo}>
<span>최근 갱신 {lastUpdatedAt ?? '확인 중'}</span>
<button type="button" onClick={refetch}>새로고침</button>
<button type="button" onClick={refetch}>
새로고침
</button>
</div>
)}
</header>
Expand All @@ -112,59 +121,59 @@ export function DashboardPage() {
</header>

<div id="agent-request-body" className={styles.agentRequestBody}>
<div className={styles.requestComposer}>
<form className={styles.commandForm} onSubmit={handleAgentRequestSubmit}>
<label className={styles.visuallyHidden} htmlFor="agent-work-request">
업무 내용
</label>
<div className={styles.commandField}>
<textarea
ref={agentRequestRef}
id="agent-work-request"
className={styles.commandInput}
value={agentRequest}
onChange={(event) => setAgentRequest(event.target.value)}
onKeyDown={handleAgentRequestKeyDown}
placeholder="예: 응웬반A의 체류기간 연장 준비"
aria-describedby="agent-request-hint"
rows={2}
maxLength={2000}
/>
<button
type="submit"
className={styles.commandSubmit}
disabled={agentRequest.trim() === ''}
>
<img src={commandSubmitIcon} alt="" aria-hidden="true" />
<span>업무 분석</span>
</button>
</div>
<p id="agent-request-hint" className={styles.commandHint}>
입력한 원문 그대로 분석합니다 · Enter로 분석 · Shift+Enter로 줄바꿈
</p>
</form>
</div>

<aside className={styles.quickPromptPanel} aria-label="빠른 요청">
<div className={styles.promptPanelHeader}>
<strong>빠른 요청</strong>
<span>자주 쓰는 업무로 시작하세요.</span>
</div>
<div className={styles.promptChips}>
{AI_REQUEST_PROMPT_CHIPS.map((chip) => (
<button
key={chip}
type="button"
className={styles.promptChip}
aria-pressed={agentRequest === chip}
onClick={() => handlePromptChipClick(chip)}
>
{chip}
</button>
))}
<div className={styles.requestComposer}>
<form className={styles.commandForm} onSubmit={handleAgentRequestSubmit}>
<label className={styles.visuallyHidden} htmlFor="agent-work-request">
업무 내용
</label>
<div className={styles.commandField}>
<textarea
ref={agentRequestRef}
id="agent-work-request"
className={styles.commandInput}
value={agentRequest}
onChange={(event) => setAgentRequest(event.target.value)}
onKeyDown={handleAgentRequestKeyDown}
placeholder="예: 응웬반A의 체류기간 연장 준비"
aria-describedby="agent-request-hint"
rows={2}
maxLength={2000}
/>
<button
type="submit"
className={styles.commandSubmit}
disabled={agentRequest.trim() === ''}
>
<img src={commandSubmitIcon} alt="" aria-hidden="true" />
<span>업무 분석</span>
</button>
</div>
</aside>
<p id="agent-request-hint" className={styles.commandHint}>
입력한 원문 그대로 분석합니다 · Enter로 분석 · Shift+Enter로 줄바꿈
</p>
</form>
</div>

<aside className={styles.quickPromptPanel} aria-label="빠른 요청">
<div className={styles.promptPanelHeader}>
<strong>빠른 요청</strong>
<span>자주 쓰는 업무로 시작하세요.</span>
</div>
<div className={styles.promptChips}>
{AI_REQUEST_PROMPT_CHIPS.map((chip) => (
<button
key={chip}
type="button"
className={styles.promptChip}
aria-pressed={agentRequest === chip}
onClick={() => handlePromptChipClick(chip)}
>
{chip}
</button>
))}
</div>
</aside>
</div>
</section>

{status === 'loading' && (
Expand Down Expand Up @@ -257,7 +266,9 @@ export function DashboardPage() {
/>
))
) : (
<p className={styles.sectionEmpty}>현재 담당자가 바로 처리할 업무가 없습니다.</p>
<p className={styles.sectionEmpty}>
현재 담당자가 바로 처리할 업무가 없습니다.
</p>
)}
</div>
</div>
Expand Down Expand Up @@ -331,9 +342,7 @@ export function DashboardPage() {
</div>
<h2 id="agent-prepared-title">Agent 작업 공간</h2>
<div className={styles.preparedIntro}>
<strong>
연결된 업무 {agentPrepared.connectedCount}건
</strong>
<strong>연결된 업무 {agentPrepared.connectedCount}건</strong>
<p>Agent가 준비하거나 이어서 처리할 업무만 모았습니다.</p>
</div>

Expand Down
10 changes: 7 additions & 3 deletions src/pages/DashboardPage/dashboardData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,14 @@ export function buildDashboardMetrics(counts: DashboardSummaryCountsResponse): D
export function buildDashboardWorkItems(
tasks: DashboardTaskSummaryResponse[],
upcomingExpiries: UpcomingExpiryItemResponse[] = [],
workers: { worker_id: string; display_name: string }[] = [],
): DashboardWorkItem[] {
const workerNameById = new Map(
upcomingExpiries.map((item) => [item.worker_id, item.display_name]),
)
// upcoming_7_days에는 마감 임박 근로자만 있어서 우선 업무의 근로자 이름이 종종
// 비어 보였다. 전체 근로자 목록을 우선 사용하고, 못 찾으면 upcoming_7_days로 보완한다.
const workerNameById = new Map([
...upcomingExpiries.map((item): [string, string] => [item.worker_id, item.display_name]),
...workers.map((worker): [string, string] => [worker.worker_id, worker.display_name]),
])

return tasks.map((task) => {
const presentation = STATUS_PRESENTATION[task.status]
Expand Down