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
86 changes: 86 additions & 0 deletions src/api/cases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { apiFetch } from './client'
import type { TaskStatus, TaskType } from './tasks'

// fowoco/server CaseController 기준 (#88 업무함 Case·Workflow Snapshot 조회).
export type CaseDisplayStatus =
| 'DOCUMENT_PENDING'
| 'REQUEST_SENT'
| 'REVIEW_REQUIRED'
| 'COMPLETED'
| 'CANCELLED'

export type CaseLifecycleStatus = 'ACTIVE' | 'COMPLETED' | 'CANCELLED'
export type CasePriority = 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW'

export interface CaseTaskResponse {
task_id: string
task_type: TaskType
title: string
status: TaskStatus
due_date: string | null
}

export interface CaseProgressResponse {
completed_steps: number
total_steps: number
percentage: number
}

export interface CaseSummaryResponse {
case_id: string
worker_id: string
worker_display_name: string
title: string
display_status: CaseDisplayStatus
has_unread_response: boolean
priority: CasePriority
progress: CaseProgressResponse
due_date: string | null
current_task: CaseTaskResponse | null
updated_at: string
}

export interface CasePageResponse {
items: CaseSummaryResponse[]
page: number
size: number
total_elements: number
total_pages: number
}

export interface CaseReadinessResponse {
completed_checklist_items: number
total_checklist_items: number
verified_documents: number
total_documents: number
pending_approvals: number
approved_approvals: number
worker_responses: number
evidence_items: number
}

export interface CaseProjectionResponse extends CaseSummaryResponse {
lifecycle_status: CaseLifecycleStatus
readiness: CaseReadinessResponse
tasks: CaseTaskResponse[]
workflow_catalog_version: string
workflow_snapshot: Record<string, unknown>
}

export interface FetchCasesParams {
keyword?: string
page?: number
size?: number
}

export function fetchCases(params: FetchCasesParams = {}): Promise<CasePageResponse> {
const query = new URLSearchParams()
if (params.keyword) query.set('keyword', params.keyword)
query.set('page', String(params.page ?? 0))
query.set('size', String(params.size ?? 100))
return apiFetch<CasePageResponse>(`/cases?${query.toString()}`)
}

export function fetchCaseProjection(caseId: string): Promise<CaseProjectionResponse> {
return apiFetch<CaseProjectionResponse>(`/cases/${encodeURIComponent(caseId)}/projection`)
}
115 changes: 62 additions & 53 deletions src/pages/WorkListPage/WorkInboxDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'
import { Button } from '../../components/ui/Button/Button'
import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel'
import { useToastStore } from '../../store/toastStore'
import { getWorkInboxCaseProgress, type WorkInboxWorkerGroup } from './workInboxModel'
import type { WorkInboxWorkerGroup } from './workInboxModel'
import {
getCaseDisplayStatusPresentation,
getDecisionSummary,
getDuePresentation,
getReviewActionLabel,
getTaskStatusPresentation,
getWorkflowLabel,
isReviewTask,
isReviewCase,
} from './workInboxPresentation'
import styles from './WorkListPage.module.css'

Expand All @@ -29,33 +30,33 @@ const NATIONALITY_LABEL: Record<string, string> = {
}

function getWorkerMeta(group: WorkInboxWorkerGroup): string {
const nationality =
NATIONALITY_LABEL[group.worker.nationality_code] ?? group.worker.nationality_code
const workStatus = group.worker.work_status === 'ACTIVE' ? '재직' : '근무 상태 확인 필요'
const worker = group.worker
if (!worker) return '근무 정보 확인 필요'
const nationality = NATIONALITY_LABEL[worker.nationality_code] ?? worker.nationality_code
const workStatus = worker.work_status === 'ACTIVE' ? '재직' : '근무 상태 확인 필요'
return `${nationality} · ${workStatus} · 비자·근무 정보 미등록`
}

export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
const showToast = useToastStore((state) => state.showToast)
const [activeCaseKey, setActiveCaseKey] = useState(group.primaryCase.key)
const [activeCaseId, setActiveCaseId] = useState(group.primaryCase.case_id)

// 근로자를 바꾸면 새 근로자의 우선 Case로 되돌린다.
useEffect(() => {
setActiveCaseKey(group.primaryCase.key)
}, [group.worker.worker_id, group.primaryCase.key])
setActiveCaseId(group.primaryCase.case_id)
}, [group.workerId, group.primaryCase.case_id])

const activeCaseIndex = group.cases.findIndex((item) => item.key === activeCaseKey)
const activeCaseIndex = group.cases.findIndex((item) => item.case_id === activeCaseId)
const activeCase = group.cases[activeCaseIndex] ?? group.primaryCase
const activeTask = activeCase.primaryTask
const progress = getWorkInboxCaseProgress(activeCase)
const due = getDuePresentation(activeTask.task.due_date)
const activeStatus = getTaskStatusPresentation(activeTask.task.status)
const reviewTasks = group.tasks.filter((item) => isReviewTask(item.task.status))
const detailTitleId = `work-inbox-detail-${group.worker.worker_id}`
const activeTask = activeCase.current_task
const due = getDuePresentation(activeTask?.due_date ?? activeCase.due_date)
const activeStatus = getCaseDisplayStatusPresentation(activeCase.display_status)
const reviewCases = group.cases.filter((item) => isReviewCase(item.display_status))
const detailTitleId = `work-inbox-detail-${group.workerId}`

function handleOpenOtherCase() {
const nextIndex = (Math.max(activeCaseIndex, 0) + 1) % group.cases.length
setActiveCaseKey(group.cases[nextIndex].key)
setActiveCaseId(group.cases[nextIndex].case_id)
}

function handleViewEvidence() {
Expand All @@ -71,15 +72,15 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
<header className={styles.detailHeader}>
<div>
<h2 id={detailTitleId} className={styles.detailName}>
{group.worker.display_name}
{group.workerDisplayName}
</h2>
<p className={styles.detailMeta}>{getWorkerMeta(group)}</p>
</div>
<div className={styles.detailHeaderActions}>
<button
type="button"
className={styles.moreButton}
aria-label={`${group.worker.display_name} 업무 메뉴`}
aria-label={`${group.workerDisplayName} 업무 메뉴`}
onClick={handleOpenWorkerMenu}
>
<span aria-hidden="true">···</span>
Expand All @@ -89,40 +90,42 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {

<div className={styles.caseSummaryScroll}>
<p className={styles.agentSuggestion}>
Agent 제안 · {due.label}, {getWorkflowLabel(activeTask)} 확인 필요
Agent 제안 · {due.label}, {activeCase.title} 확인 필요
</p>

<div className={styles.priorityCase}>
<div className={styles.priorityCaseHeader}>
<p className={styles.caseEyebrow}>
우선 업무 건 · {Math.max(activeCaseIndex, 0) + 1}/{group.cases.length}
</p>
<button
type="button"
className={styles.textLink}
onClick={() => onOpenTask(activeTask.task.task_id)}
>
<span>업무 건 열기</span>
<span className={styles.linkChevron} aria-hidden="true">
</span>
</button>
{activeTask && (
<button
type="button"
className={styles.textLink}
onClick={() => onOpenTask(activeTask.task_id)}
>
<span>업무 건 열기</span>
<span className={styles.linkChevron} aria-hidden="true">
</span>
</button>
)}
</div>
<div className={styles.priorityCaseBody}>
{activeCase.caseId && <span className={styles.srOnly}>Case {activeCase.caseId}</span>}
<h3 className={styles.caseTitle}>{activeTask.task.title}</h3>
<span className={styles.srOnly}>Case {activeCase.case_id}</span>
<h3 className={styles.caseTitle}>{activeTask?.title ?? activeCase.title}</h3>
<p className={styles.caseMeta}>
{getWorkflowLabel(activeTask)} · {due.label} · {activeStatus.label}
{activeCase.title} · {due.label} · {activeStatus.label}
</p>
<div className={styles.progressRow}>
<span>
{progress.completed}/{progress.total}
{activeCase.progress.completed_steps}/{activeCase.progress.total_steps}
</span>
<progress
className={styles.progress}
value={progress.completed}
max={Math.max(progress.total, 1)}
aria-label={`${activeTask.task.title} Case 진행률`}
value={activeCase.progress.completed_steps}
max={Math.max(activeCase.progress.total_steps, 1)}
aria-label={`${activeCase.title} Case 진행률`}
/>
</div>
{group.cases.length > 1 && (
Expand All @@ -138,32 +141,36 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
<h3 id="review-task-title" className={styles.sectionTitle}>
검토할 업무
</h3>
<span className={styles.srOnly}>{reviewTasks.length}건</span>
<span className={styles.srOnly}>{reviewCases.length}건</span>
</div>

{reviewTasks.length === 0 ? (
{reviewCases.length === 0 ? (
<p className={styles.sectionEmpty}>현재 검토할 업무가 없습니다.</p>
) : (
<div className={styles.reviewTaskList}>
{reviewTasks.map((item, index) => {
const taskDue = getDuePresentation(item.task.due_date)
const taskStatus = getTaskStatusPresentation(item.task.status)
{reviewCases.map((item, index) => {
const task = item.current_task
const taskDue = getDuePresentation(task?.due_date ?? item.due_date)
const taskStatus = task ? getTaskStatusPresentation(task.status) : null
return (
<article key={item.task.task_id} className={styles.reviewTaskRow}>
<article key={item.case_id} className={styles.reviewTaskRow}>
<div className={styles.reviewTaskContent}>
<h4 className={styles.reviewTaskTitle}>{item.task.title}</h4>
<h4 className={styles.reviewTaskTitle}>{task?.title ?? item.title}</h4>
<p className={styles.reviewTaskMeta}>
{getWorkflowLabel(item)} · {taskStatus.label}
{task ? getWorkflowLabel(task) : item.title} ·{' '}
{taskStatus?.label ?? getCaseDisplayStatusPresentation(item.display_status).label}
</p>
</div>
<StatusLabel tone={taskDue.tone}>{taskDue.label}</StatusLabel>
<Button
variant={index === 0 ? 'primary' : 'secondary'}
className={styles.reviewTaskButton}
onClick={() => onOpenTask(item.task.task_id)}
>
{getReviewActionLabel(item.task.status)}
</Button>
{task && (
<Button
variant={index === 0 ? 'primary' : 'secondary'}
className={styles.reviewTaskButton}
onClick={() => onOpenTask(task.task_id)}
>
{getReviewActionLabel(task.status)}
</Button>
)}
</article>
)
})}
Expand All @@ -184,9 +191,11 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
</button>
</div>
<div className={styles.decisionBody}>
<p className={styles.decisionSummary}>{getDecisionSummary(activeTask.task.status)}</p>
<p className={styles.decisionSummary}>
{activeTask ? getDecisionSummary(activeTask.status) : activeStatus.label}
</p>
<p className={styles.decisionMeta}>
진행 업무 건 {group.cases.length}개 · 확인할 업무 {reviewTasks.length}개 · 자동
진행 업무 건 {group.cases.length}개 · 확인할 업무 {reviewCases.length}개 · 자동
확정되지 않음
</p>
</div>
Expand Down
26 changes: 13 additions & 13 deletions src/pages/WorkListPage/WorkInboxTargetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function WorkInboxTargetList({
function moveSelection(event: KeyboardEvent<HTMLDivElement>, currentIndex: number) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelect(groups[currentIndex].worker.worker_id)
onSelect(groups[currentIndex].workerId)
return
}

Expand All @@ -39,8 +39,8 @@ export function WorkInboxTargetList({
event.preventDefault()
const nextGroup = groups[nextIndex]
if (!nextGroup) return
onSelect(nextGroup.worker.worker_id)
optionRefs.current.get(nextGroup.worker.worker_id)?.focus()
onSelect(nextGroup.workerId)
optionRefs.current.get(nextGroup.workerId)?.focus()
}

return (
Expand All @@ -55,27 +55,27 @@ export function WorkInboxTargetList({

<div className={styles.targetList} role="listbox" aria-label="업무 대상 근로자">
{groups.map((group, index) => {
const task = group.primaryTask
const due = getDuePresentation(task.task.due_date)
const reviewStage = getReviewStageLink(task.task.status)
const selected = group.worker.worker_id === selectedWorkerId
const currentTask = group.primaryCase.current_task
const due = getDuePresentation(currentTask?.due_date ?? group.primaryCase.due_date)
const reviewStage = getReviewStageLink(currentTask?.status ?? 'DRAFT')
const selected = group.workerId === selectedWorkerId

return (
<div
key={group.worker.worker_id}
key={group.workerId}
ref={(node) => {
if (node) optionRefs.current.set(group.worker.worker_id, node)
else optionRefs.current.delete(group.worker.worker_id)
if (node) optionRefs.current.set(group.workerId, node)
else optionRefs.current.delete(group.workerId)
}}
role="option"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={`${styles.targetOption} ${selected ? styles.targetOptionSelected : ''}`}
onClick={() => onSelect(group.worker.worker_id)}
onClick={() => onSelect(group.workerId)}
onKeyDown={(event) => moveSelection(event, index)}
>
<span className={styles.targetOptionTop}>
<span className={styles.targetName}>{group.worker.display_name}</span>
<span className={styles.targetName}>{group.workerDisplayName}</span>
<Link
to={reviewStage.href}
className={`${statusLabelStyles.label} ${statusLabelStyles[reviewStage.tone]}`}
Expand All @@ -85,7 +85,7 @@ export function WorkInboxTargetList({
</Link>
</span>
<span className={styles.targetMeta}>
{getWorkflowLabel(task)} · {due.label}
{currentTask ? getWorkflowLabel(currentTask) : group.primaryCase.title} · {due.label}
</span>
</div>
)
Expand Down
Loading
Loading