# Phase 3 — 작업(Tasks) 페이지 > **한 줄 요약**: 폴더(업무/개인/즐겨찾기) 트리 사이드바 + 칸반/리스트 뷰 + 무한 중첩 하위작업 + 작업 상세 드로어 + 리스크 레이더를 백엔드 REST API와 연동해 완성한다. > > 📌 **이 문서는 `dev/` 문서 세트의 일부입니다 — 먼저 `overview.md`를 읽으세요.** > 선행: `phase-0-foundation.md`(스캐폴딩) · `phase-1-design-system.md`(토큰/셸/Icon) · `phase-2-backend.md`(모델/시드/REST/리스크 서비스). > 다음: `phase-4-inbox.md`(인박스). --- ## 1. 개요 & 목표 이 phase가 끝나면 `/tasks` 라우트에서 다음이 **시드 데이터로 실제 동작**한다. 1. **폴더 트리 사이드바** — 상단 3개 영역(업무[폴더 아이콘] · 개인[하트] · 즐겨찾기[별])을 체브론으로 펼치면 하위 프로젝트가 **무한 중첩**으로 나타난다. 선택된 폴더만 검은 배경(`--fill`/`--on-fill`), 나머지는 옅은 회색(`--glass-2`). 각 영역 끝에 `+ 새 프로젝트` 인라인 추가, 우측에 작업수 배지. 2. **필터** — 폴더/프로젝트 클릭 → 해당 영역(`area=work|life`)·프로젝트 작업만 보여준다. `즐겨찾기` = `pinned` 프로젝트의 작업만. 3. **뷰 토글** — `칸반`(5컬럼: todo/doing/waiting/review/done) · `리스트`(상태별 그룹). 원본 디자인에 캘린더 토글은 없으며(`VIEWS` 배열은 `kanban`·`list` 2개), CONTRACT의 SegmentToggle은 칸반/리스트로 구현하고 캘린더는 "준비 중" placeholder로 둔다(아래 §4 참고). 4. **작업 카드** — 제목/담당자 아바타/마감(임박 강조)/우선순위 플래그/하위작업 진행률/위임 칩. 칸반 카드 픽셀 재현. 5. **무한 중첩 하위작업** — 칸반/리스트에서는 카드에 진행률 표시, 상세 드로어에서 재귀 트리로 펼침/드릴다운. 6. **작업 상세 드로어(DetailPanel)** — notes(HTML 리치 에디터), 댓글 스레드, 담당자/마감/우선순위/상태 편집, 하위작업 트리, 브레드크럼 드릴다운, Auto-Scaffolding(하위작업 제안), 위임. 7. **리스크 레이더** — 업무 폴더 스코프(`area==='work'`)일 때만 `GET /api/risks?area=work` 호출 → 지연위험(coral)/업무쏠림(amber)/의존성(violet) 카드 최대 3건, CTA로 해당 작업 드로어 열기. 8. **상태 영속** — 선택/펼침/뷰/즐겨찾기는 `localStorage`(키 `ari.tasks.*`), 작업·프로젝트 데이터는 서버. 생성/수정은 API로 영속. **비목표(Out of scope)**: 드래그앤드롭으로 칸반 카드 이동(원본엔 있으나 MVP에선 상태 메뉴/드롭다운으로 대체 가능 — 단, E2E에서는 PATCH 왕복으로 검증). 실시간 협업, 멀티유저. 캘린더 뷰. --- ## 2. 선행 조건 / 산출물 ### 2.1 선행 조건 (의존 phase) | 의존 | 무엇을 제공받나 | |---|---| | `phase-0-foundation.md` | 모노레포(`frontend/`·`backend/`), pnpm·uv, `dev` 스크립트, Next 프록시(`/api` → FastAPI) | | `phase-1-design-system.md` | `styles/tokens.css`(전 토큰), `components/Icon.tsx`(중앙 paths 맵), `components/Topbar.tsx`, `ThemeProvider`(next-themes), `app/layout.tsx` 셸, `lib/cx.ts` | | `phase-2-backend.md` | `task`/`project`/`folder`/`person`/`task_comment` 테이블 + 시드, `GET /api/tree`·`GET /api/tasks`·`PATCH /api/tasks/{id}`·`POST /api/projects`·`POST /api/tasks/{id}/scaffold`·`GET /api/risks` 엔드포인트 | ### 2.2 산출물 (Deliverables) ``` frontend/ ├─ app/tasks/page.tsx # 작업 페이지 엔트리 (서버 컴포넌트 셸 → 클라 TasksClient) ├─ components/tasks/ │ ├─ TasksClient.tsx # 상태 오케스트레이션 (선택/펼침/뷰/드로어) │ ├─ TaskSidebar.tsx # 폴더 트리 사이드바 │ ├─ TreeNode.tsx # 재귀 프로젝트 노드 │ ├─ TreeAdd.tsx # 인라인 새 프로젝트/폴더 생성 │ ├─ TaskToolbar.tsx # 브레드크럼 + 뷰 세그 + 검색 + 새 작업 │ ├─ KanbanView.tsx # 5컬럼 칸반 │ ├─ KanbanCard.tsx # 작업 카드 │ ├─ ListView.tsx # 상태별 그룹 리스트 │ ├─ RiskRadar.tsx # 리스크 레이더 │ ├─ DetailPanel.tsx # 작업 상세 드로어 │ ├─ SubRow.tsx # 재귀 하위작업 행 (드릴다운) │ ├─ RichNotes.tsx # contentEditable 리치 노트 │ ├─ Comments.tsx # 댓글 스레드 │ └─ ScaffoldPanel.tsx # Auto-Scaffolding 패널 ├─ lib/ │ ├─ tasks/api.ts # 작업/트리/리스크 API 클라이언트 │ ├─ tasks/store.ts # localStorage 영속 (ari.tasks.*) │ ├─ tasks/tree.ts # 트리 유틸 (findPath/editTree/buildIndex/stat/pct) │ └─ types.ts (확장) # Task, Project, Folder, Risk 타입 ├─ styles/tasks.css # tasks.css 이식 (또는 CSS Module) ├─ tests/tasks/*.test.tsx # Vitest 컴포넌트 테스트 └─ playwright/tasks.spec.ts # E2E ``` 백엔드 산출물은 `phase-2-backend.md`에서 완성되며, 이 문서는 그 계약을 소비한다. 단, 이 phase에서 **리스크 서비스 이식 검증**(§7)과 **scaffold 응답 형태**(§3.10·§4.4)를 확정한다. --- ## 3. 상세 구현 (파일별·단계별) ### 3.0 데이터 흐름 개요 ``` [FastAPI] [Next.js /tasks] GET /api/tree ───────────────► TaskSidebar (folder[]→project[] 중첩) GET /api/tasks?area=&project_id= ► KanbanView / ListView (중첩 task 트리) GET /api/risks?area=work ───────► RiskRadar POST /api/projects ─────────────► 새 프로젝트 (사이드바 인라인) PATCH /api/tasks/{id} ──────────► 상태/필드 수정 (드로어·칸반) POST /api/tasks/{id}/comments ──► 댓글 POST /api/tasks/{id}/scaffold ──► 하위작업 제안/생성 ``` > **원본과의 차이(중요)**: 원본 `tasks.jsx`는 **전부 localStorage 트리**(`ariT.tree`, `ariT.ptree`)로 동작하는 순수 프론트 프로토타입이다. MVP에서는 **데이터는 서버가 원천(source of truth)**, **UI 상태(선택/펼침/뷰/즐겨찾기 핀)만 localStorage**로 옮긴다. localStorage 키 접두사도 원본 `ariT.` → CONTRACT 규약 `ari.tasks.`로 변경한다. ### 3.1 타입 정의 — `frontend/lib/types.ts` (확장분) CONTRACT의 데이터모델·필드명을 그대로 따른다. 원본 데이터 필드(`assignee`, `project`, `est`, `delegated`, `comments[].who/time`)는 백엔드 스키마 필드명(`assignee_id`, `project_id`, …)으로 정규화하되, 표시 로직은 동일하게 유지한다. ```ts // frontend/lib/types.ts (Phase 3 추가분) export type Tone = 'blue' | 'violet' | 'coral' | 'green' | 'amber' | 'ink' | 'faint'; export type Status = 'todo' | 'doing' | 'waiting' | 'review' | 'done'; export type Prio = '높음' | '보통' | '낮음'; export interface Person { id: string; // "jiwoo" name: string; // "지우" initial: string; // "지" color: string; // "var(--blue)" | "oklch(0.66 0.13 200)" is_me: boolean; } export interface Folder { id: string; // "work" | "life" name: string; // "업무" | "개인" tone: Tone; // ink | blue icon: string; // "folder" | "heart" sort_order: number; is_system: boolean; projects: Project[]; // GET /api/tree 시 중첩 } export interface Project { id: string; // "biz" | "biz-report" | "me" | "life-trip" folder_id: string; // "work" | "life" parent_id: string | null; name: string; // "경영 전략" | "분기 리포트" tone: Tone; sort_order: number; pinned: boolean; children: Project[]; // 트리 응답 시 중첩 (무한) task_count: number; // 미완료 작업 수 (배지) } export interface TaskComment { id: string; task_id: string; person_id: string; // who text: string; created_at: string; // 표시는 "방금"/"2시간 전" 등 상대시간 } export interface Task { id: string; // "k1" | "kx3" | "n1001" project_id: string; // "biz-report" parent_id: string | null; title: string; status: Status; assignee_id: string; // "jiwoo" due: string | null; // "2026-06-08" (ISO date) — 표시는 "6/8" prio: Prio; notes: string; // HTML 허용 est: string; // "2시간" | "반나절" delegated: boolean; sort_order: number; created_at: string; updated_at: string; comments: TaskComment[]; children: Task[]; // 무한 중첩 하위작업 } export interface Risk { kind: '지연 위험' | '업무 쏠림' | '의존성'; icon: 'clock' | 'scale' | 'link'; tone: 'coral' | 'amber' | 'violet'; text: string; // 백엔드가 한국어 문장으로 완성, 강조어는 **굵게** 마크다운으로 감쌈 (프론트 Bolded 파서가 로 변환) task_id: string | null;// 있으면 CTA로 드로어 열기 cta: string | null; // "작업 열기" | "후속 작업 보기" } ``` > **due 포맷 주의**: 원본 데이터는 `"06-08"`(월-일) 형태이고 `dueDay`/`dueLabel`이 그걸 파싱한다. 백엔드는 ISO `date`(`2026-06-08`)로 저장하므로, 표시 유틸 `dueDay`/`dueLabel`을 ISO에 맞게 재작성한다(§3.4). 리스크 계산의 `TODAY=8`(6월 8일)도 백엔드 상수로 유지한다. ### 3.2 API 클라이언트 — `frontend/lib/tasks/api.ts` ```ts import type { Folder, Task, Risk, Project, Person, TaskComment } from '@/lib/types'; const J = { 'Content-Type': 'application/json' }; async function ok(r: Response): Promise { if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json() as Promise; } export const tasksApi = { people: () => fetch('/api/people').then(ok), tree: () => fetch('/api/tree').then(ok), // 필터 우선순위: project_id(+하위) > area(work|life) > 전체. status/assignee는 추가 필터. 중첩 트리 반환. list: (p: { area?: 'work' | 'life'; project_id?: string; status?: string; assignee?: string }) => { const q = new URLSearchParams(Object.entries(p).filter(([, v]) => v != null) as [string, string][]); return fetch(`/api/tasks?${q}`).then(ok); }, get: (id: string) => fetch(`/api/tasks/${id}`).then(ok), create: (body: Partial & { title: string; project_id: string }) => fetch('/api/tasks', { method: 'POST', headers: J, body: JSON.stringify(body) }).then(ok), patch: (id: string, body: Partial) => fetch(`/api/tasks/${id}`, { method: 'PATCH', headers: J, body: JSON.stringify(body) }).then(ok), remove: (id: string) => fetch(`/api/tasks/${id}`, { method: 'DELETE' }).then((r) => r.ok), addComment: (id: string, body: { person_id: string; text: string }) => fetch(`/api/tasks/${id}/comments`, { method: 'POST', headers: J, body: JSON.stringify(body) }).then(ok), // create=false면 생성하지 않고 제안만, true면 실제 하위작업 생성. use_llm으로 LLM 분해 선택. scaffold: (id: string, body: { create?: boolean; use_llm?: boolean } = {}) => fetch(`/api/tasks/${id}/scaffold`, { method: 'POST', headers: J, body: JSON.stringify(body) }) .then(ok<{ kind: string; icon: string; items: { title: string; est: string }[]; created: boolean; created_task_ids: string[] }>), risks: (area: 'work' | 'life' = 'work') => fetch(`/api/risks?area=${area}`).then(ok), createProject: (body: { folder_id: string; parent_id?: string; name: string; tone?: string }) => fetch('/api/projects', { method: 'POST', headers: J, body: JSON.stringify(body) }).then(ok), pinProject: (id: string) => fetch(`/api/projects/${id}/pin`, { method: 'POST' }).then(ok), }; ``` ### 3.3 localStorage 영속 — `frontend/lib/tasks/store.ts` 원본 `load/save`(키 `ariT.`)를 그대로 옮기되 접두사만 `ari.tasks.`로. **데이터가 아니라 UI 상태만** 저장한다. ```ts const PREFIX = 'ari.tasks.'; export function loadLS(key: string, fallback: T): T { if (typeof window === 'undefined') return fallback; try { const v = localStorage.getItem(PREFIX + key); return v === null ? fallback : (JSON.parse(v) as T); } catch { return fallback; } } export function saveLS(key: string, value: T): void { if (typeof window === 'undefined') return; try { localStorage.setItem(PREFIX + key, JSON.stringify(value)); } catch { /* quota */ } } ``` 저장 키 목록 (원본 매핑): | 원본(`ariT.`) | 이 phase(`ari.tasks.`) | 의미 | 기본값 | |---|---|---|---| | `view` | `view` | 'kanban' \| 'list' | `'kanban'` | | `sel` | `sel` | 선택된 폴더/프로젝트/`'fav'` id | `'work'` | | `expanded` | `expanded` | `{ [id]: boolean }` 펼침 상태 | `{ work:true, life:true }` | | `pinned` | (서버 `project.pinned`로 이전) | 즐겨찾기 | — | > 원본은 `pinned`도 localStorage였으나, MVP에서는 `project.pinned`(서버) + `POST /api/projects/{id}/pin` 토글로 이전한다. `expanded`/`sel`/`view`만 localStorage. ### 3.4 트리 유틸 — `frontend/lib/tasks/tree.ts` 원본 `tasks.jsx`의 트리 함수(`findPath`/`getNode`/`editTree`/`removeFromTree`/`stat`/`pct`/`dueDay`/`dueLabel`)와 `tasks-data.js`의 `buildIndex`를 TS로 이식. **편집 함수는 낙관적 업데이트(optimistic)용으로만 쓰고, 진짜 영속은 API**. ```ts import type { Task, Project, Folder, Status } from '@/lib/types'; /* ---- 작업 트리 (무한 중첩) ---- */ export function findPath(nodes: Task[], id: string, trail: Task[] = []): Task[] | null { for (const n of nodes) { const t = [...trail, n]; if (n.id === id) return t; if (n.children?.length) { const r = findPath(n.children, id, t); if (r) return r; } } return null; } export const getNode = (nodes: Task[], id: string): Task | null => { const p = findPath(nodes, id); return p ? p[p.length - 1] : null; }; export function editTree(nodes: Task[], id: string, fn: (n: Task) => Task): Task[] { return nodes.map((n) => n.id === id ? fn(n) : n.children?.length ? { ...n, children: editTree(n.children, id, fn) } : n, ); } export function removeFromTree(nodes: Task[], id: string): Task[] { return nodes .filter((n) => n.id !== id) .map((n) => (n.children?.length ? { ...n, children: removeFromTree(n.children, id) } : n)); } export const stat = (node: Task) => { const c = node.children ?? []; return { total: c.length, done: c.filter((x) => x.status === 'done').length }; }; export const pct = (node: Task) => { const s = stat(node); return s.total ? Math.round((s.done / s.total) * 100) : 0; }; /* ---- 마감 표시 (ISO date 기준; 원본은 "06-08" → MVP는 "2026-06-08") ---- */ export const dueDay = (due: string | null): number => (due ? new Date(due).getUTCDate() : 99); export const dueLabel = (due: string | null): string => { if (!due) return '—'; const d = new Date(due); return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`; // "6/8" }; export const TODAY = 8; // 6월 8일 — 리스크/임박 계산 기준 (CONTRACT 고정) export const isSoon = (t: Task) => t.status !== 'done' && dueDay(t.due) <= TODAY; /* ---- 프로젝트 인덱스 (buildIndex 이식; tree.ts에서 사용) ---- */ export interface ProjIndexRec { id: string; name: string; tone: string; folder: boolean; parentId: string | null; depth: number; hasChildren: boolean; } export function buildIndex(folders: Folder[]) { const projects: ProjIndexRec[] = []; const byId: Record = {}; const walk = (nodes: (Project | Folder)[], parentId: string | null, depth: number) => { for (const n of nodes) { const children = (n as Project).children ?? (n as Folder).projects ?? []; const rec: ProjIndexRec = { id: n.id, name: n.name, tone: (n as Project).tone ?? 'faint', folder: 'is_system' in n, parentId, depth, hasChildren: children.length > 0, }; projects.push(rec); byId[n.id] = rec; if (children.length) walk(children, n.id, depth + 1); } }; walk(folders, null, 0); const isDescendant = (pid: string, ancestorId: string): boolean => { let cur: ProjIndexRec | undefined = byId[pid]; while (cur) { if (cur.id === ancestorId) return true; cur = cur.parentId ? byId[cur.parentId] : undefined; } return false; }; const pathOf = (id: string): ProjIndexRec[] => { const out: ProjIndexRec[] = []; let cur: ProjIndexRec | undefined = byId[id]; while (cur) { out.unshift(cur); cur = cur.parentId ? byId[cur.parentId] : undefined; } return out; }; return { projects, byId, isDescendant, pathOf }; } ``` > **필터의 핵심(원본 `inScope`)**: 폴더/프로젝트/즐겨찾기 모두 `isDescendant(task.project_id, selectedId)`로 처리한다. 백엔드 `GET /api/tasks`의 필터 우선순위(정본): `project_id` 지정 시 → 그 프로젝트(+하위 프로젝트) 작업, 아니고 `area` 지정 시 → 그 영역(`work|life`) 작업, 둘 다 없으면 → 전체. `status`/`assignee`는 추가 필터. 백엔드가 이 1차 필터를 하므로, 프론트는 검색어(`q`) 필터와 `fav` 스코프(여러 핀 프로젝트 OR)만 추가로 적용한다. ### 3.5 사이드바 — `components/tasks/TaskSidebar.tsx` + `TreeNode.tsx` + `TreeAdd.tsx` 원본 `tasks.jsx`의 `.subnav` 구조와 `TreeNode`/`TreeAdd` 로직을 이식. 상단 3개 영역: **업무(folder 아이콘)**, **개인(heart)**, **즐겨찾기(star)**. 핵심 동작(원본 그대로): - 영역 행(`.tree-row.folder`)을 클릭 → `setSel(folder.id)` + `expanded[id]=true`(클릭 시 강제 펼침). - 체브론 클릭은 `stopPropagation` 후 `toggle(id)`만 (선택과 분리). - 선택된 폴더만 `.on` → 검은 배경(`--fill`/`--on-fill`). 비선택은 `--glass-2`. - 프로젝트 노드(`TreeNode`)는 재귀, `paddingLeft = 8 + depth * 15`(px) — 무한 중첩 들여쓰기. - 미완료 작업수 배지: 폴더는 `.tree-badge`, 프로젝트는 `.tree-count`. - 호버 시 우측 별(`.tree-pin`) 노출, 핀이면 `--amber`로 항상 노출. - 각 폴더 펼침 끝에 `TreeAdd`("새 프로젝트") 인라인. ```tsx // components/tasks/TreeNode.tsx 'use client'; import { Icon } from '@/components/Icon'; import { cx } from '@/lib/cx'; import type { Project } from '@/lib/types'; interface Props { node: Project; depth: number; sel: string; onSelect: (id: string) => void; expanded: Record; onToggle: (id: string) => void; onPin: (id: string) => void; counts: Record; onAddProj: (parentId: string, name: string) => void; } export function TreeNode({ node, depth, sel, onSelect, expanded, onToggle, onPin, counts, onAddProj }: Props) { const hasCh = node.children.length > 0; const open = !!expanded[node.id]; return ( <>
onSelect(node.id)} > {hasCh ? ( ) : } {node.name} {counts[node.id] > 0 && {counts[node.id]}}
{hasCh && open && node.children.map((c) => ( ))} ); } ``` ```tsx // components/tasks/TreeAdd.tsx — 인라인 생성 (폴더/프로젝트 공용) 'use client'; import { useState } from 'react'; import { Icon } from '@/components/Icon'; export function TreeAdd({ depth, placeholder, onAdd }: { depth: number; placeholder: string; onAdd: (name: string) => void }) { const [editing, setEditing] = useState(false); const [val, setVal] = useState(''); const commit = () => { const v = val.trim(); if (v) onAdd(v); setVal(''); setEditing(false); }; if (!editing) { return ( ); } return (
setVal(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') { setVal(''); setEditing(false); } }} onBlur={commit} />
); } ``` ```tsx // components/tasks/TaskSidebar.tsx (요지) 'use client'; import { Fragment } from 'react'; import { Icon } from '@/components/Icon'; import { cx } from '@/lib/cx'; import { TreeNode } from './TreeNode'; import { TreeAdd } from './TreeAdd'; import type { Folder, Person } from '@/lib/types'; interface Props { folders: Folder[]; me: Person; sel: string; onSelect: (id: string) => void; expanded: Record; onToggle: (id: string) => void; onPin: (id: string) => void; counts: Record; // 프로젝트/폴더 id → 미완료 수 pinnedProjects: Project[]; // pinned=true 프로젝트 평탄 목록 onAddProject: (folderId: string, name: string) => void; } export function TaskSidebar(p: Props) { return ( ); } ``` > **문구 인용(원본 그대로)**: 사이드바 제목 `작업`, 폴더 라벨 `업무`/`개인`/`즐겨찾기`, 즐겨찾기 빈 상태 `제목 옆 별표를 눌러 모아두세요`, 새 프로젝트 버튼 `새 프로젝트`, 푸터 `지우님` / `Pro 플랜`. ### 3.6 메인 영역 — `TaskToolbar.tsx` 브레드크럼(`.crumbs`) + 슬림 헤더(`.thead-slim`) + 툴바(`.ttoolbar`: 뷰 세그·검색·새 작업). - **브레드크럼**: `워크스페이스 › 업무` 또는 `워크스페이스 › 경영 전략 › 분기 리포트`. 프로젝트 경로는 `buildIndex().pathOf(sel)`로 계산, 각 단계는 클릭 시 `setSel`. 마지막 단계는 `.cur`(컬러 점 `--{tone}`). - **슬림 헤더**: 좌측 아이콘(폴더면 `folder`/프로젝트 잎이면 `hash`/스코프면 `folder`/`heart`/`star`), `N개 작업 · M개 완료` 텍스트(`.ts-meta`), 우측 `즐겨찾기`/`즐겨찾기됨` 토글(`.th-pin`). - **뷰 세그(`.view-seg`)**: `칸반`(grid 아이콘) · `리스트`(list 아이콘). `.on` = 검은 배경(`--fill`). CONTRACT의 SegmentToggle을 이 두 옵션으로 사용. (캘린더는 §4 참고) - **검색(`.t-search`)**: `작업 검색…` placeholder. 입력값 `q`로 클라 필터. - **새 작업(`.t-new`)**: `+ 새 작업` — `POST /api/tasks`(현재 스코프 대표 프로젝트, status=todo, title="새 작업") → 칸반으로 전환 → 생성 작업 드로어 자동 오픈. ```tsx // 새 작업 대표 프로젝트 결정 (원본 onAdd 로직) function repProject(sel: string, folders: Folder[]): string { if (sel === 'work') return 'biz'; // 업무 대표 if (sel === 'life') return 'me'; // 개인 대표 if (sel === 'fav') return 'me'; // 즐겨찾기 스코프면 일상으로 return sel; // 프로젝트 선택 시 그 프로젝트 } ``` ### 3.7 칸반 — `KanbanView.tsx` + `KanbanCard.tsx` 5컬럼 고정(라벨/accent는 `STATUS_META`): | status | label | accent(`kcol-dot`) | |---|---|---| | `todo` | 할 일 | `var(--faint)` | | `doing` | 진행 중 | `var(--blue)` | | `waiting` | 대기 중 | `var(--violet)` | | `review` | 검토 | `var(--coral)` | | `done` | 완료 | `var(--green)` | ```tsx // components/tasks/KanbanCard.tsx 'use client'; import { Icon } from '@/components/Icon'; import { cx } from '@/lib/cx'; import { stat, pct, dueLabel, isSoon } from '@/lib/tasks/tree'; import type { Task, Person, Project } from '@/lib/types'; export function KanbanCard({ task, people, projects, onOpen }: { task: Task; people: Record; projects: Record; onOpen: (id: string) => void; }) { const s = stat(task); const soon = isSoon(task); const proj = projects[task.project_id]; const person = people[task.assignee_id]; return (
onOpen(task.id)}>
{proj && ( {proj.name} )}
{task.title}
{s.total > 0 && (
{s.done}/{s.total}
)}
{dueLabel(task.due)} {task.prio} {task.delegated && 위임} {person &&
{person.initial}
}
); } ``` ```tsx // components/tasks/KanbanView.tsx (요지) — 컬럼별 빠른 추가 + (옵션)DnD const COLUMNS = [ { id: 'todo', label: '할 일', accent: 'var(--faint)' }, { id: 'doing', label: '진행 중', accent: 'var(--blue)' }, { id: 'waiting', label: '대기 중', accent: 'var(--violet)' }, { id: 'review', label: '검토', accent: 'var(--coral)' }, { id: 'done', label: '완료', accent: 'var(--green)' }, ] as const; export function KanbanView({ tasks, people, projects, onOpen, onMove, onAdd }: { /* ... */ }) { // 각 컬럼 = tasks.filter(t => t.status === col.id) // 헤더: kcol-dot(accent) + kcol-label + kcol-count + kcol-add(+) // 빈 컬럼: "여기로 작업을 옮겨보세요" // 빠른 추가 input: "작업 입력 후 Enter…" → onAdd(status, text) } ``` **드래그앤드롭(선택 구현)**: 원본은 HTML5 `draggable` + `onDragStart/onDrop` + `onMove(dragId, col.id)`. MVP 권장 경로는 두 가지 — (a) 원본 DnD를 그대로 이식하되 드롭 시 `PATCH /api/tasks/{id} { status }` 호출, (b) DnD 생략하고 카드 `more`→드로어→상태 메뉴로 이동. **테스트(§7)는 (b) 경로(상태 메뉴)를 기준으로 하고, DnD 구현 시 추가 E2E를 더한다.** 어느 경우든 상태 변경은 서버 PATCH로 영속. **onAdd(빠른 추가)**: `POST /api/tasks { title, project_id: repProject(sel), status, assignee_id:"jiwoo", prio:"보통", due:null }` → 응답을 트리에 prepend. 원본 빠른추가 input placeholder `작업 입력 후 Enter…` 유지. ### 3.8 리스트 — `ListView.tsx` 상태별 그룹(`columns` 순서), 각 그룹은 `마감 빠른 순` 정렬, 빈 그룹은 숨김. 테이블 컬럼: `작업 | 프로젝트 | 담당 | 마감 | 우선순위 | 진행`. 좁은 화면(`hide-sm`)에서 프로젝트/담당/우선순위 숨김. ```tsx // 그리드: grid-template-columns: minmax(0,2.6fr) 1.1fr 70px 70px 86px 40px; // 행 클릭 → onOpen(task.id) // 헤더 라벨(원본 그대로): 작업 / 프로젝트 / 담당 / 마감 / 우선순위 / 진행 // 빈 상태: "표시할 작업이 없어요" ``` ### 3.9 리스크 레이더 — `RiskRadar.tsx` 원본 `tasks-risk.jsx`의 **계산은 백엔드(`risk.py`)로 이식**(CONTRACT). 프론트는 `GET /api/risks?area=work`(기본 `work`) 결과를 렌더만 한다. **선택 스코프가 업무 폴더(`sel==='work'`, 즉 `area==='work'`)일 때만 표시**한다(원본: 업무 스코프에서만). 개인/즐겨찾기 스코프나 특정 프로젝트 선택 시에는 숨긴다. 백엔드 계산 규칙(원본 `computeRisks` 1:1 이식 — `phase-2-backend.md`에서 구현, 여기서 계약 명시): 1. **지연 위험(coral, clock)**: `status != done && due 있음 && dueDay(due) <= TODAY(8)` 중 1건. 문장: `**{title}** — 오늘({M/D}) 마감인데 아직 {시작 전|진행 중|대기 중|검토 중}이에요`. `task_id` 포함, cta=`작업 열기`. 2. **업무 쏠림(amber, scale)**: 미완료 작업을 담당자별 카운트, `top >= avg*1.5 && top >= 4`일 때 1건. 본인(`is_me`)이면 `미완료 작업 **N건**이 내게 몰려 있어요 — 팀 평균의 X배. 벅찬 작업은 **위임 & 추적**으로 넘겨보세요`, 타인이면 `**{name}님**에게 미완료 작업 **N건**이 몰려 있어요 — 평균의 X배, 일부 재배분을 추천드려요`. `task_id` 없음(cta 없음). 3. **의존성(violet, link)**: 제목 기반 `DEPS`(고정 2건) 중 둘 다 미완료인 가장 임박한 1건. `**{blocker}**이(가) 늦어지면 **{blocked}**({M/D})까지 함께 밀려요`. `task_id`=blocked, cta=`후속 작업 보기`. ```python # backend/app/services/risk.py — DEPS (원본 동일, 제목 기반) DEPS = [ {"blocker": "예산 섹션 작성", "blocked": "경영진 검토 요청 메일"}, {"blocker": "데이터 전처리 파이프라인", "blocked": "사용자 인터뷰 5건 정리"}, ] TODAY = 8 # 6월 8일 STATUS_LABEL = {"todo": "시작 전", "doing": "진행 중", "waiting": "대기 중", "review": "검토 중"} # 정렬·임계값·문장은 tasks-risk.jsx와 동일하게 유지하고 결과는 risks[:3] ``` 최대 3건. `text`의 강조어는 항상 `**...**`(마크다운 굵게)로 감싸 내려보내고(`risk.py`), 프론트는 `Bolded` 파서가 `**` → ``만 변환(XSS 안전을 위해 dangerouslySetInnerHTML 금지, 텍스트 분할로 처리). ```tsx // components/tasks/RiskRadar.tsx 'use client'; import { Icon } from '@/components/Icon'; import type { Risk } from '@/lib/types'; function Bolded({ text }: { text: string }) { // "**A**B" → AB (정규식 split, dangerouslySetInnerHTML 미사용) return <>{text.split(/(\*\*[^*]+\*\*)/).map((s, i) => s.startsWith('**') ? {s.slice(2, -2)} : {s})}; } export function RiskRadar({ risks, onOpen }: { risks: Risk[]; onOpen: (id: string) => void }) { if (!risks.length) return null; return (
리스크 레이더마감 · 업무량 · 의존성을 아리가 훑었어요
{risks.map((r, i) => (
{r.kind}

{r.task_id && r.cta && ( )}
))}
); } ``` > **아이콘**: 리스크 아이콘(`radar`/`scale`/`link`)은 `phase-1` 중앙 paths 맵(`paths.ts`, 단일 출처)에 이미 포함되어 있으므로 `Icon name="radar|scale|link"`로 그대로 사용한다(여기서 별도 추가/정의하지 않는다). 참고용 path(원본 `tasks-risk.jsx`의 `RP` 딕셔너리 근거 — `phase-1`이 정본): > - `radar`: `M19.07 4.93A10 10 0 0 0 6.99 3.34|M4 6h.01|M2.29 9.62a10 10 0 1 0 19.02-1.27|M16.24 7.76a6 6 0 1 0 1.07 7.04|M12 18h.01|M17.99 11.66a6 6 0 0 1-2.22 5.01|M12 12a2 2 0 1 0 4 0 2 2 0 0 0-4 0z|M13.41 10.59l5.66-5.66` > - `scale`(원본 users): `M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|M22 21v-2a4 4 0 0 0-3-3.87|M16 3.13a4 4 0 0 1 0 7.75` > - `link`(원본 branch): `M6 3v12|M18 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M15 12H9a3 3 0 0 0-3 3` ### 3.10 상세 드로어 — `DetailPanel.tsx` (+ `SubRow`, `RichNotes`, `Comments`, `ScaffoldPanel`) 원본 `DetailPanel`은 **포커스 기반 브레드크럼 드릴다운**: `focusId`(현재 보는 노드) + `rootId`(드로어를 연 최초 노드). 하위작업 행(`SubRow`)을 클릭하면 `focusId`가 그 하위로 바뀌고, 브레드크럼은 `rootId`부터 `focusId`까지 경로(`findPath`)를 보여준다. 레이아웃(`tasks.css`): - `.dpanel`: `width:60vw; min-width:600px`, 우측 슬라이드인(`slidein 0.24s`), 백드롭(`.dp-backdrop` blur(3px)). - `.dp-head`: 위로 버튼(`back`) + 브레드크럼(`.dp-crumbs`) + 프로젝트 태그(`ktag`) + 닫기(`x`). - `.dp-grid`: 2열 `minmax(0,1fr) 340px`. 좌측(`.dp-main`): 제목/완료체크 → 노트 → 하위작업 → 코멘트. 우측(`.dp-rail`, `--card-2` 배경): 담당자/마감/우선순위/상태 + 위임. - `.dp-foot`: 삭제(danger) / 상위로(crumbs>1) / 완료(primary). - Esc → 닫기. `focusId` 변경 시 scaffold/adding/menu 리셋. ```tsx // components/tasks/DetailPanel.tsx (구조 + 서버 연동 포인트만 발췌) 'use client'; import { useEffect, useState } from 'react'; import { Icon } from '@/components/Icon'; import { cx } from '@/lib/cx'; import { findPath, stat } from '@/lib/tasks/tree'; import { RichNotes } from './RichNotes'; import { Comments } from './Comments'; import { SubRow } from './SubRow'; import { ScaffoldPanel } from './ScaffoldPanel'; import type { Task, Person, Project } from '@/lib/types'; const STATUS_META: Record = { todo: { label: '할 일', c: 'var(--faint)' }, doing: { label: '진행 중', c: 'var(--blue)' }, waiting: { label: '대기 중', c: 'var(--violet)' }, review: { label: '검토', c: 'var(--coral)' }, done: { label: '완료', c: 'var(--green)' }, }; interface Props { tasks: Task[]; focusId: string; rootId: string; people: Record; projects: Record; onFocus: (id: string) => void; onClose: () => void; onCheck: (id: string) => void; // 완료 토글 → PATCH status onField: (id: string, patch: Partial) => void; // 상태/담당/우선순위 → PATCH onAddChild: (parentId: string, title: string) => void; // POST /api/tasks {parent_id} onScaffold: (parentId: string) => void; // POST /api/tasks/{id}/scaffold {create:true} onDelegate: (id: string, who: string) => void; // PATCH {assignee_id, status:'waiting', delegated:true} onDelete: (id: string) => void; // DELETE onNotes: (id: string, html: string) => void; // PATCH {notes} (debounce) onAddComment: (id: string, text: string) => void; // POST /api/tasks/{id}/comments } export function DetailPanel(p: Props) { const path = findPath(p.tasks, p.focusId) ?? []; const node = path[path.length - 1]; const [menu, setMenu] = useState(false); const [expanded, setExpanded] = useState>({}); const [adding, setAdding] = useState(false); useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') p.onClose(); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [p]); useEffect(() => { setAdding(false); setMenu(false); }, [p.focusId]); if (!node) return null; const rootIdx = path.findIndex((n) => n.id === p.rootId); const crumbs = rootIdx >= 0 ? path.slice(rootIdx) : path; const person = p.people[node.assignee_id]; const s = stat(node); const sm = STATUS_META[node.status]; return ( <>
); } ``` **핵심 인터랙션(원본 문구·동작 그대로)**: | 요소 | 동작 | 서버 | |---|---|---| | `.dp-check` 완료 토글 | status `done` ↔ `todo` | `PATCH {status}` | | 상태 pill `.status-pill` → `.status-menu` | 5개 상태 선택 | `PATCH {status}` | | 담당자/마감/우선순위(`.dp-mi`) | 편집(드롭다운/날짜) | `PATCH {assignee_id\|due\|prio}` | | 노트(`RichNotes`) | contentEditable, `자유롭게 메모하세요…` | `PATCH {notes}`(blur/debounce) | | 하위작업(`SubRow`) | 펼침/체크/드릴다운 | check→`PATCH`, drill→`onFocus` | | `하위 작업 추가` | input `하위 작업 입력 후 Enter…` | `POST /api/tasks {parent_id}` | | Scaffold trigger | `아리에게 업무 쪼개기 맡기기` | `POST scaffold {create:false}` | | Scaffold 적용 | `N개 하위 작업 모두 추가` | `POST scaffold {create:true}` | | 코멘트 | `코멘트 추가…` 입력 | `POST /api/tasks/{id}/comments` | | 위임(`.delegate-sug`) | `{name}님께 위임하기` | `PATCH {assignee_id, status:'waiting', delegated:true}` | | 삭제(`.btn.danger`) | 노드 삭제 | `DELETE` | **Auto-Scaffolding(원본 `pickScaffold` → 백엔드 `scaffold.py`)**: 트리거 클릭 → `POST /api/tasks/{id}/scaffold` body `{create:false, use_llm:false}` → `{kind, icon, items:[{title,est}], created:false, created_task_ids:[]}` 미리보기 패널(`.scaffold-panel`) 렌더 → `N개 하위 작업 모두 추가` 클릭 시 body `{create:true}`로 재호출 → 하위작업 생성(`created:true`, `created_task_ids[]` 반환). 트리거 문구 인용: 제목 `아리에게 업무 쪼개기 맡기기`, 설명 `이 작업을 단계별 하위 작업으로 자동 분해하고 예상 시간을 배분해요`, 패널 헤더 `{kind} 워크플로우 추천`, `{N}단계 · 예상 시간 자동 배분`. 백엔드 `pickScaffold` 정규식·템플릿은 `tasks-data.js`와 동일: | 정규식 매치 | kind | 단계 수 | |---|---|---| | `(논문\|연구\|리서치\|실험\|학회\|발표\|조사\|인터뷰)` | 연구 · 논문 작성 | 5 | | `(구현\|개발\|API\|로직\|컴포넌트\|버그\|배포\|인증\|앱)` | 개발 업무 | 4 | | `(리포트\|문서\|보고\|자료\|작성\|초안\|회고\|정산)` | 문서 · 리포트 | 5 | | (그 외) | 일반 업무 | 4 | **위임 추천(원본 `suggestCollaborator`)**: 같은 프로젝트의 비-본인 담당자 중 최다 인원을 제안. 개인 프로젝트(`me`)/즐겨찾기는 제안 안 함. 본인 담당 & 미완료일 때만 추천. 문구: `직접 하기 벅차다면 이 프로젝트를 함께 하는 {name}님께 위임할 수 있어요. 넘긴 뒤엔 '대기 중'으로 바꿔 진행 상황을 추적해 드릴게요.` 위임됨 상태(`.delegated-note`): `{name}님께 위임됨` / `완료를 기다리는 중 · 끝나면 알려드릴게요`. 이 추천 계산은 클라에서 가능(현재 로드된 트리 기준)하나, 일관성을 위해 위임 실행은 `PATCH`로 영속. **SubRow(재귀)**: `paddingLeft = 6 + depth * 22`(px). 트위스트(`chev` 회전), 체크박스(`tick`), 제목, 메타(`branch N/M`/`clock est`/`cal 마감`/아바타/드릴`chev`). 하위작업 없을 때 빈 상태: `아직 하위 작업이 없어요. 위 버튼으로 아리에게 맡기거나 직접 추가하세요.` **RichNotes**: 툴바 `제목/굵게/기울임/밑줄/글머리 목록/번호 목록/인용`(`execCommand` 사용; MVP에선 deprecated이나 프로토타입과 동일하게 유지하거나 간단 마크다운으로 대체 가능). placeholder `자유롭게 메모하세요 — 제목, 목록, 인용을 섞어 쓸 수 있어요.` 저장은 `onChange`→debounce(800ms)→`PATCH {notes}`. **렌더 시 notes(HTML)는 신뢰된 자체 데이터지만, 사용자 입력을 그대로 저장/표시하므로 DOMPurify로 sanitize 후 dangerouslySetInnerHTML** 권장. **Comments**: 빈 상태 `아직 코멘트가 없어요. 첫 의견을 남겨보세요.`, 입력 placeholder `코멘트 추가…`, 작성자 본인은 `(나)` 표기, 시간은 상대(`방금`/`2시간 전`/`어제`). `POST /api/tasks/{id}/comments {person_id:"jiwoo", text}`. ### 3.11 오케스트레이터 — `TasksClient.tsx` 원본 `App`의 상태/핸들러를 서버 데이터 + 낙관적 업데이트로 재구성. ```tsx // components/tasks/TasksClient.tsx (요지) 'use client'; import { useEffect, useMemo, useState } from 'react'; import useSWR, { mutate } from 'swr'; // 또는 react-query import { tasksApi } from '@/lib/tasks/api'; import { loadLS, saveLS } from '@/lib/tasks/store'; import { buildIndex } from '@/lib/tasks/tree'; import type { Folder, Task, Risk, Person } from '@/lib/types'; export function TasksClient() { // UI 상태 (localStorage) const [view, setView] = useState<'kanban' | 'list'>(() => loadLS('view', 'kanban')); const [sel, setSel ] = useState(() => loadLS('sel', 'work')); const [expanded, setExpanded] = useState>(() => ({ work: true, life: true, ...loadLS('expanded', {}) })); const [q, setQ] = useState(''); const [focusId, setFocusId] = useState(null); const [rootId, setRootId ] = useState(null); useEffect(() => saveLS('view', view), [view]); useEffect(() => saveLS('sel', sel), [sel]); useEffect(() => saveLS('expanded', expanded), [expanded]); // 서버 데이터 const area: 'work' | 'life' | undefined = sel === 'work' ? 'work' : sel === 'life' ? 'life' : undefined; const { data: folders } = useSWR('tree', tasksApi.tree); const { data: people } = useSWR('people', tasksApi.people); const { data: tasks } = useSWR(['tasks', area, sel], () => tasksApi.list({ area, project_id: area ? undefined : (sel === 'fav' ? undefined : sel) })); // 리스크 레이더는 업무 폴더 스코프(area==='work')일 때만 호출/표시 (원본: 업무 스코프 전용) const { data: risks } = useSWR( area === 'work' ? ['risks', 'work'] : null, () => tasksApi.risks('work')); const ix = useMemo(() => folders ? buildIndex(folders) : null, [folders]); // counts: ix.projects → task_count 매핑 (트리 응답에 포함) // 필터: 검색 q + fav 스코프(여러 pinned OR) // 핸들러: onMove/onField/onCheck/onAdd/onAddChild/onScaffold/onDelegate/onDelete/onNotes/onAddComment // → 각각 tasksApi.* 호출 후 mutate(['tasks',...]) + (필요시) mutate(['risks','work']) // → 낙관적: editTree로 즉시 반영, 실패 시 롤백 // ... } ``` 영속 규칙: - **새 프로젝트**: `POST /api/projects { folder_id, parent_id?, name }` → `mutate('tree')`, `expanded[parentId]=true`. tone은 원본처럼 `TONES[name.length % 5]`로 프론트가 제안하거나 백엔드 기본값. - **핀 토글**: `POST /api/projects/{id}/pin` → `mutate('tree')`. - **상태 이동/필드 수정**: `PATCH` → `mutate(['tasks',...])`, 업무 스코프면 `mutate(['risks','work'])`(리스크 재계산). - **새 작업/하위작업/scaffold/댓글/삭제**: 각 API 후 `mutate`. --- ## 4. 데이터/타입/API 계약 (이 phase 관련) ### 4.1 `GET /api/tree` 응답 예시 ```json [ { "id": "work", "name": "업무", "tone": "ink", "icon": "folder", "sort_order": 0, "is_system": true, "projects": [ { "id": "biz", "folder_id": "work", "parent_id": null, "name": "경영 전략", "tone": "coral", "sort_order": 0, "pinned": false, "task_count": 5, "children": [ { "id": "biz-okr", "folder_id": "work", "parent_id": "biz", "name": "2026 Q2 OKR", "tone": "coral", "pinned": false, "task_count": 1, "children": [] }, { "id": "biz-report", "folder_id": "work", "parent_id": "biz", "name": "분기 리포트", "tone": "coral", "pinned": false, "task_count": 1, "children": [] }, { "id": "biz-budget", "folder_id": "work", "parent_id": "biz", "name": "예산 관리", "tone": "coral", "pinned": false, "task_count": 0, "children": [] } ] }, { "id": "onb", "folder_id": "work", "parent_id": null, "name": "온보딩 리디자인", "tone": "violet", "pinned": false, "task_count": 3, "children": [ { "id": "onb-research", "folder_id": "work", "parent_id": "onb", "name": "사용자 리서치", "tone": "violet", "pinned": false, "children": [] }, { "id": "onb-wire", "folder_id": "work", "parent_id": "onb", "name": "와이어프레임", "tone": "violet", "pinned": true, "children": [] }, { "id": "onb-voice", "folder_id": "work", "parent_id": "onb", "name": "음성 인터페이스", "tone": "violet", "pinned": false, "children": [] } ] }, { "id": "team", "folder_id": "work", "parent_id": null, "name": "팀 운영", "tone": "green", "pinned": false, "children": [ { "id": "team-hr", "folder_id": "work", "parent_id": "team", "name": "채용 & 온보딩", "tone": "green", "children": [] }, { "id": "team-retro", "folder_id": "work", "parent_id": "team", "name": "회고", "tone": "green", "children": [] } ] } ] }, { "id": "life", "name": "개인", "tone": "blue", "icon": "heart", "is_system": true, "projects": [ { "id": "me", "folder_id": "life", "parent_id": null, "name": "일상", "tone": "blue", "pinned": false, "children": [] }, { "id": "life-trip", "folder_id": "life", "parent_id": null, "name": "여행 — 한국", "tone": "coral", "pinned": false, "children": [] }, { "id": "life-fam", "folder_id": "life", "parent_id": null, "name": "가족", "tone": "green", "pinned": false, "children": [] } ] } ] ``` ### 4.2 `GET /api/tasks?area=work` 응답 예시(요지) ```json [ { "id": "k1", "project_id": "biz-report", "parent_id": null, "title": "분기 리포트 초안 마무리", "status": "doing", "assignee_id": "jiwoo", "due": "2026-06-08", "prio": "높음", "notes": "

목표

경영진 미팅용 Q2 성과 리포트...

", "est": "", "delegated": false, "comments": [ { "id": "c1", "task_id": "k1", "person_id": "hyunwoo", "text": "매출 섹션은 제가 오늘 안으로 넘겨드릴게요.", "created_at": "2026-06-08T07:00:00Z" } ], "children": [ { "id": "kx7", "project_id": "biz-report", "parent_id": "k1", "title": "매출 섹션 작성", "status": "doing", "assignee_id": "jiwoo", "due": "2026-06-09", "prio": "높음", "children": [ { "id": "kx8", "title": "MRR / ARR 표 정리", "status": "done", "assignee_id": "jiwoo", "children": [] }, { "id": "kx9", "title": "전분기 대비 증감 코멘트", "status": "doing","assignee_id": "jiwoo", "children": [] }, { "id": "kx10", "title": "예측 시나리오 3종", "status": "todo", "assignee_id": "jiwoo", "children": [] } ] } ] } ] ``` ### 4.3 `PATCH /api/tasks/{id}` (상태 이동) ```http PATCH /api/tasks/kx9 Content-Type: application/json { "status": "done" } ``` 응답: 갱신된 `Task`(중첩 children 포함). 부모의 진행률(`stat`/`pct`)은 프론트에서 children 기반으로 재계산. ### 4.4 `POST /api/tasks/{id}/scaffold` ```http POST /api/tasks/k6/scaffold # k6="OKR 중간 점검 자료 준비" → 문서·리포트? 아니면 generic Content-Type: application/json { "create": false, "use_llm": false } ``` ```json { "kind": "일반 업무", "icon": "list", "items": [ { "title": "범위 정의 & 자료 수집", "est": "1시간" }, { "title": "초안 작성", "est": "2시간" }, { "title": "리뷰 & 피드백 반영", "est": "1시간" }, { "title": "최종 정리 & 공유", "est": "30분" } ], "created": false, "created_task_ids": [] } ``` body `{ "create": true }`면 위 items를 하위작업으로 생성하고 `{ "kind":..., "icon":..., "items":..., "created": true, "created_task_ids": ["...", ...] }` 반환. ### 4.5 `GET /api/risks?area=work` ```json [ { "kind": "지연 위험", "icon": "clock", "tone": "coral", "text": "**분기 리포트 초안 마무리** — 오늘(6/8) 마감인데 아직 진행 중이에요", "task_id": "k1", "cta": "작업 열기" }, { "kind": "업무 쏠림", "icon": "scale", "tone": "amber", "text": "미완료 작업 **8건**이 내게 몰려 있어요 — 팀 평균의 2.1배. 벅찬 작업은 **위임 & 추적**으로 넘겨보세요", "task_id": null, "cta": null }, { "kind": "의존성", "icon": "link", "tone": "violet", "text": "**예산 섹션 작성**이(가) 늦어지면 **경영진 검토 요청 메일**(6/10)까지 함께 밀려요", "task_id": "...", "cta": "후속 작업 보기" } ] ``` --- ## 5. 디자인 충실도 노트 (REF 인용) 기준 파일: `design-reference/assets/tasks.css`, `tasks.jsx`. 토큰은 `phase-1`에서 `styles/tokens.css`로 이식 완료된 것을 사용. ### 5.1 레이아웃·치수 (tasks.css 직접 인용) | 요소 | 값 | 출처(tasks.css) | |---|---|---| | 페이지 컨테이너 `.tpage` | `max-width:1620px; padding:0 26px 30px` | L87 | | 본문 `.twork` | `display:flex; gap:18px; padding-top:12px` | L162 | | 사이드바 `.subnav` | `width:252px; sticky top:86px; max-height:calc(100vh-104px); border-radius:22px(--radius)` | L165-176 | | 사이드바 제목 `.sn-title` | `font:Onest 19px/700; padding:4px 8px 12px` | L177-181 | | 트리 행 `.tree-row` | `gap:7px; padding:7px 8px; border-radius:10px; font-size:13.5px` | L212-217 | | 트리 들여쓰기 | `paddingLeft = 8 + depth*15` (px) | tasks.jsx L150,773 | | 폴더 행 `.tree-row.folder` | `bg:var(--glass-2); border:1px var(--glass-brd); margin-top:6px; radius:11px` | L765-769 | | 폴더 선택 `.tree-row.folder.on` | `bg:var(--fill); color:var(--on-fill); border:transparent` | L776-779 | | 폴더 배지 `.tree-badge` | `font:11px/700; bg:var(--glass-2); radius:999px; padding:1px 8px; min-width:20px` | L810-815 | | 칸반 보드 `.kboard` | `grid-auto-columns:minmax(264px,1fr); gap:14px; overflow-x:auto` | L356-360 | | 칸반 컬럼 `.kcol` | `bg:var(--glass); radius:22px; padding:12px 11px; min-height:120px` | L363-369 | | 칸반 드롭오버 `.kcol.over` | `box-shadow ...0 0 0 2px rgba(79,114,224,.25)` | L370 | | 작업 카드 `.kcard` | `bg:var(--card); border:1px var(--line); radius:14px; padding:12px 13px; gap:9px` | L381-386 | | 카드 호버 | `transform:translateY(-2px); box-shadow:var(--shadow)` | L387 | | 완료 카드 `.done-card` | `opacity:.72; title line-through color:var(--muted)` | L389-390 | | 진행 바 `.bar` | `height:5px; radius:99px; bg:var(--line); fill:var(--ink-2)` | L352-353 | | 리스트 그리드 `.lhead/.lrow` | `grid: minmax(0,2.6fr) 1.1fr 70px 70px 86px 40px; padding:0 18px` | L430-434 | | 리스트 행 높이 `.lrow` | `min-height:56px; border-bottom:1px var(--line)` | L438 | | 드로어 `.dpanel` | `width:60vw; min-width:600px; border-left:1px var(--line); slidein 0.24s` | L460-468 | | 드로어 그리드 `.dp-grid` | `grid: minmax(0,1fr) 340px` | L516 | | 드로어 레일 `.dp-rail` | `border-left:1px var(--line); bg:var(--card-2)` | L518 | | 드로어 제목 `.dp-title` | `font:Onest 26px/700; line-height:1.22` | L519 | | 하위행 들여쓰기 `.subrow` | `paddingLeft = 6 + depth*22` (px) | tasks.jsx L395 | | 리스크 레이더 `.rradar` | `gap:16px; padding:13px 16px; bg:var(--glass); radius:14px(--radius-sm)` | L820-826 | | 리스크 아이콘 `.rr-ic` | `36×36; radius:11px; bg:var(--fill); color:var(--coral)` | L828-831 | | 리스크 카드 톤 | `.t-coral→--coral / .t-amber→--amber / .t-violet→--violet` | L843-845 | ### 5.2 색·상태 (tasks.jsx / tasks-data.js 인용) - 상태 컬럼 라벨·accent: `할 일/--faint`, `진행 중/--blue`, `대기 중/--violet`, `검토/--coral`, `완료/--green` (tasks-data.js L84-90, tasks.jsx L82-88). - 우선순위 색: `.prio-높음→--coral`, `.prio-보통→--amber`, `.prio-낮음→--muted` (tasks.css L349-351). - 마감 임박 `.due.soon`: `color:var(--coral); font-weight:600` — `dueDay(due) <= 8 && status!=done` (tasks.css L346, tasks.jsx L198). - 위임 칩 `.deleg-chip`: `color:var(--violet); bg:color-mix(violet 13%)` (tasks.css L641-647). - 사람 색: 지우 `--blue`, 현우 `--violet`, 민서 `--green`, 재호 `--coral`, 수아 `oklch(0.66 0.13 200)` (tasks-data.js L76-82). ### 5.3 한국어 문구 (그대로 사용) | 위치 | 문구 | |---|---| | 사이드바 제목 | `작업` | | 폴더 라벨 | `업무` · `개인` · `즐겨찾기` | | 새 프로젝트 | `새 프로젝트` | | 즐겨찾기 빈 상태 | `제목 옆 별표를 눌러 모아두세요` | | 슬림 헤더 | `{N}개 작업 · {M}개 완료` · `즐겨찾기` / `즐겨찾기됨` | | 검색 | `작업 검색…` | | 새 작업 버튼 | `새 작업` | | 칸반 빠른추가 | `작업 입력 후 Enter…` | | 칸반 빈 컬럼 | `여기로 작업을 옮겨보세요` | | 리스트 빈 상태 | `표시할 작업이 없어요` | | 리스트 헤더 | `작업 / 프로젝트 / 담당 / 마감 / 우선순위 / 진행` | | 리스크 레이더 헤더 | `리스크 레이더` / `마감 · 업무량 · 의존성을 아리가 훑었어요` | | 리스크 CTA | `작업 열기` / `후속 작업 보기` | | Scaffold 트리거 | `아리에게 업무 쪼개기 맡기기` / `이 작업을 단계별 하위 작업으로 자동 분해하고 예상 시간을 배분해요` | | Scaffold 적용 | `{N}개 하위 작업 모두 추가` | | 하위작업 빈 상태 | `아직 하위 작업이 없어요. 위 버튼으로 아리에게 맡기거나 직접 추가하세요.` | | 하위작업 추가 | `하위 작업 추가` / `하위 작업 입력 후 Enter…` | | 노트 placeholder | `자유롭게 메모하세요 — 제목, 목록, 인용을 섞어 쓸 수 있어요.` | | 코멘트 | `코멘트 추가…` / `아직 코멘트가 없어요. 첫 의견을 남겨보세요.` | | 위임 추천 | `직접 하기 벅차다면 … {name}님께 위임할 수 있어요. … '대기 중'으로 바꿔 진행 상황을 추적해 드릴게요.` | | 위임됨 | `{name}님께 위임됨` / `완료를 기다리는 중 · 끝나면 알려드릴게요` | | 드로어 푸터 | `상위로` / `완료` | | 사이드바 푸터 | `{name}님` / `Pro 플랜` | ### 5.4 애니메이션 - 진입: `.entered .twork > * { rise 0.45s }`, `.tmain` delay 0.05s (L741-743). - 드로어: `.dp-backdrop fade 0.18s` + `.dpanel slidein 0.24s cubic-bezier(0.2,0.7,0.2,1)` (L457,467). - scaffold 패널: `rise 0.3s` (L675). - `prefers-reduced-motion`에서는 애니메이션 제거(접근성, §7 axe). ### 5.5 반응형 (tasks.css L746-761) - `≤1080px`: `.subnav` 숨김, 리스크 레이더 세로 배치(`.rradar flex-direction:column`). - `≤860px`: 드로어 `width:100vw`, `.dp-grid` 1열. - `≤760px`: `.mainnav` 숨김, 리스트 컬럼 축소(`hide-sm` 제거 → 4열). --- ## 6. 상태 처리 (로딩/빈/에러/오프라인) & 엣지 케이스 | 상황 | 처리 | |---|---| | **초기 로딩** | 사이드바: 폴더 3개 스켈레톤(`.tree-row` 골격 펄스). 메인: 칸반 5컬럼 헤더 + 카드 3~4개 스켈레톤(`shimmer`). 리스크 레이더: 로드 전 미표시. | | **빈 영역(작업 0)** | 칸반: 각 컬럼 `여기로 작업을 옮겨보세요`. 리스트: `표시할 작업이 없어요`. | | **즐겨찾기 비었을 때** | 사이드바 `제목 옆 별표를 눌러 모아두세요`. 메인: 핀 0개면 `즐겨찾기한 프로젝트가 없어요` 안내 + 별표 방법. | | **검색 결과 0** | `"{q}"에 해당하는 작업이 없어요` (q 강조). | | **API 에러(트리/작업)** | 인라인 에러 배너 `작업을 불러오지 못했어요` + `다시 시도` 버튼(`mutate`). 사이드바는 마지막 캐시 유지. | | **리스크 API 에러** | 레이더 영역 조용히 숨김(핵심 작업 흐름 방해 금지). | | **PATCH 실패(상태 이동)** | 낙관적 업데이트 롤백 + 토스트 `상태를 바꾸지 못했어요`. | | **scaffold 미가용(LLM off)** | 백엔드 heuristic(`pickScaffold`) 폴백으로 항상 응답. 그래도 실패 시 `지금은 자동 분해를 못 했어요` + 수동 추가 안내. | | **오프라인** | `navigator.onLine=false` 시 상단 배너 `오프라인 — 변경은 연결되면 반영돼요`. 쓰기 차단 또는 큐잉(MVP는 차단 + 안내). | | **노트 동시 편집** | 단일 유저 가정. blur 시 PATCH, 마지막 쓰기 우선. | | **삭제한 작업이 드로어 포커스** | `onDelete`: rootId면 드로어 닫기, 하위면 부모로 `focusId` 이동(원본 동작). | | **순환 참조 방지** | 하위작업 추가 시 자기 자신/조상에 붙이지 못하게 백엔드 검증(400). | | **due가 과거/없음** | `dueLabel` `—`, soon 계산은 `dueDay<=8`만. | --- ## 7. 테스팅 & 검증 (가장 중요) ### 7.1 실행 명령 ```bash # 백엔드 (리스크 서비스 정확성 — 가장 중요) cd backend && uv run pytest tests/test_risks.py tests/test_tasks.py tests/test_scaffold.py -v # 프론트 컴포넌트 (Vitest + RTL) cd frontend && pnpm test # 전체 cd frontend && pnpm test tasks # 작업 관련만 # E2E (Playwright) — 백엔드+프론트 dev 서버 기동 후 cd frontend && pnpm playwright test playwright/tasks.spec.ts # 접근성 (axe) — Playwright 통합 cd frontend && pnpm playwright test playwright/tasks.a11y.spec.ts ``` ### 7.2 백엔드 테스트 — 리스크 정확성 (pytest) 원본 `computeRisks`를 시드 데이터로 검증. **반드시 포함**: ```python # backend/tests/test_risks.py def test_risk_delay_top1(client): # k1(분기 리포트 초안 마무리, due 6/8, doing) → 지연 위험 1건 r = client.get("/api/risks?area=work").json() delay = [x for x in r if x["kind"] == "지연 위험"] assert len(delay) == 1 assert delay[0]["tone"] == "coral" and delay[0]["icon"] == "clock" assert delay[0]["task_id"] == "k1" assert "6/8" in delay[0]["text"] and "진행 중" in delay[0]["text"] assert delay[0]["cta"] == "작업 열기" def test_risk_dependency(client): # 예산 섹션 작성(미완) → 경영진 검토 요청 메일(미완) : violet, cta="후속 작업 보기" r = client.get("/api/risks?area=work").json() dep = [x for x in r if x["kind"] == "의존성"] assert len(dep) == 1 and dep[0]["tone"] == "violet" assert "예산 섹션 작성" in dep[0]["text"] and "경영진 검토 요청 메일" in dep[0]["text"] assert dep[0]["cta"] == "후속 작업 보기" def test_risk_overload_threshold(client): # 업무 쏠림: top >= avg*1.5 && top >= 4 일 때만. 임계 미만이면 없음. r = client.get("/api/risks?area=work").json() over = [x for x in r if x["kind"] == "업무 쏠림"] assert len(over) <= 1 if over: assert over[0]["tone"] == "amber" and over[0]["task_id"] is None def test_risks_max_three(client): assert len(client.get("/api/risks?area=work").json()) <= 3 def test_risks_life_scope_empty_or_no_delay(client): # 개인 스코프는 리스크 레이더 비표시 대상(프론트), 백엔드는 area=life 별도 처리 r = client.get("/api/risks?area=life") assert r.status_code == 200 ``` ```python # backend/tests/test_tasks.py — 필터/이동/CRUD def test_tasks_filter_by_area(client): work = client.get("/api/tasks?area=work").json() ids = collect_project_ids(work) # 모든 노드의 project_id 수집 assert all(p.startswith(("biz","onb","team")) for p in ids) def test_tasks_filter_by_project(client): rep = client.get("/api/tasks?project_id=biz-report").json() assert any(t["id"] == "k1" for t in rep) def test_patch_status_persists(client): client.patch("/api/tasks/k4", json={"status": "done"}) assert client.get("/api/tasks/k4").json()["status"] == "done" def test_create_subtask_parent(client): body = {"title":"새 하위","project_id":"biz-report","parent_id":"k1","status":"todo"} child = client.post("/api/tasks", json=body).json() parent = client.get("/api/tasks/k1").json() assert any(c["id"] == child["id"] for c in parent["children"]) def test_cannot_parent_to_self_or_descendant(client): res = client.patch("/api/tasks/k1", json={"parent_id":"k1"}) assert res.status_code == 400 def test_add_comment(client): c = client.post("/api/tasks/k1/comments", json={"person_id":"jiwoo","text":"확인"}).json() assert c["person_id"] == "jiwoo" ``` ```python # backend/tests/test_scaffold.py — pickScaffold 정규식 import pytest @pytest.mark.parametrize("title,kind", [ ("사용자 인터뷰 5건 정리", "연구 · 논문 작성"), ("API 엔드포인트 개발", "개발 업무"), ("분기 리포트 작성", "문서 · 리포트"), ("장보기", "일반 업무"), ]) def test_pick_scaffold(client, title, kind): t = client.post("/api/tasks", json={"title":title,"project_id":"me"}).json() out = client.post(f"/api/tasks/{t['id']}/scaffold", json={"create": False}).json() assert out["kind"] == kind and len(out["items"]) >= 4 assert out["created"] is False and out["created_task_ids"] == [] def test_scaffold_apply_creates_children(client): t = client.post("/api/tasks", json={"title":"개발 업무 x","project_id":"me"}).json() out = client.post(f"/api/tasks/{t['id']}/scaffold", json={"create": True}).json() parent = client.get(f"/api/tasks/{t['id']}").json() assert out["created"] is True assert len(parent["children"]) == len(out["items"]) == len(out["created_task_ids"]) ``` ### 7.3 프론트 컴포넌트 테스트 (Vitest + RTL) ```ts // tests/tasks/tree.test.ts — 트리 유틸 test('findPath 무한 중첩 경로', () => { /* k1 > kx7 > kx9 경로 길이 3 */ }); test('editTree 깊은 노드 갱신', () => { /* kx9 status done → 변경, 형제 불변 */ }); test('stat/pct 진행률', () => { /* children 2done/3total → pct 67 */ }); test('isDescendant 필터', () => { /* isDescendant("biz-report","work")===true */ }); test('dueLabel ISO → "6/8"', () => { expect(dueLabel('2026-06-08')).toBe('6/8'); }); test('isSoon: due<=8 && !done', () => { /* 06-08 todo → true, 06-08 done → false */ }); ``` ```tsx // tests/tasks/TaskSidebar.test.tsx test('폴더 펼침/접힘', () => { // 업무 행 체브론 클릭 → 하위 프로젝트(경영 전략/온보딩 리디자인/팀 운영) 표시 }); test('폴더 선택 시 .on(검은배경)', () => { // 업무 클릭 → tree-row.folder.on 클래스 + aria 선택 }); test('프로젝트 선택 → onSelect 호출', () => { /* 분기 리포트 클릭 */ }); test('핀 토글 호출', () => { /* star 버튼 클릭 → onPin("onb-wire") */ }); test('즐겨찾기 빈 상태 문구', () => { // pinnedProjects=[] → "제목 옆 별표를 눌러 모아두세요" }); test('새 프로젝트 인라인 입력 Enter → onAddProject', () => { /* TreeAdd commit */ }); ``` ```tsx // tests/tasks/KanbanCard.test.tsx test('카드: 제목/프로젝트태그/담당아바타/마감/우선순위 렌더', () => {}); test('하위작업 있으면 진행바+N/M 표시', () => {}); test('마감 임박(06-08, doing) → .due.soon', () => {}); test('완료 카드 → .done-card + line-through', () => {}); test('위임 → .deleg-chip "위임"', () => {}); test('우선순위 클래스 prio-높음/보통/낮음', () => {}); ``` ```tsx // tests/tasks/RiskRadar.test.tsx test('risks=[] → null 렌더', () => {}); test('3종 카드 tone 클래스(t-coral/t-amber/t-violet)', () => {}); test('**굵게** → 변환(dangerouslySetInnerHTML 미사용)', () => {}); test('task_id 있으면 CTA 버튼, 클릭 시 onOpen(id)', () => {}); test('업무쏠림(task_id null)은 CTA 없음', () => {}); ``` ```tsx // tests/tasks/ListView.test.tsx test('상태별 그룹, 빈 그룹 숨김', () => {}); test('마감 빠른 순 정렬', () => {}); test('행 클릭 → onOpen', () => {}); ``` ```tsx // tests/tasks/DetailPanel.test.tsx test('브레드크럼: root→focus 경로', () => {}); test('완료 체크 → onCheck', () => {}); test('상태 메뉴에서 선택 → onField({status})', () => {}); test('하위작업 추가 Enter → onAddChild', () => {}); test('SubRow 드릴다운 → onFocus(child)', () => {}); test('scaffold 트리거 → ScaffoldPanel, 적용 → onScaffold', () => {}); test('코멘트 입력 Enter → onAddComment', () => {}); test('위임 추천 노출 조건(본인+미완+같은프로젝트 동료)', () => {}); test('Esc → onClose', () => {}); ``` ### 7.4 E2E (Playwright) — `playwright/tasks.spec.ts` > 백엔드는 시드 DB(테스트용 별도 SQLite)로 기동. 각 테스트 전 DB 리셋(seed) 권장. ```ts test('초기 진입: 업무 스코프 + 칸반 + 리스크 레이더', async ({ page }) => { await page.goto('/tasks'); await expect(page.locator('.tree-row.folder.on .tree-name')).toHaveText('업무'); await expect(page.locator('.kboard .kcol')).toHaveCount(5); await expect(page.locator('.rradar')).toBeVisible(); await expect(page.locator('.rr-ht b')).toHaveText('리스크 레이더'); }); test('폴더 펼침 → 프로젝트 필터', async ({ page }) => { await page.goto('/tasks'); await page.locator('.tree-row.folder', { hasText: '업무' }).locator('.tree-chev').click(); await page.locator('.tree-row', { hasText: '분기 리포트' }).click(); // 분기 리포트 작업만 (k1) 보임, 다른 프로젝트 작업 안 보임 await expect(page.locator('.kcard-title', { hasText: '분기 리포트 초안 마무리' })).toBeVisible(); await expect(page.locator('.kcard-title', { hasText: '사용자 인터뷰 5건 정리' })).toHaveCount(0); await expect(page.locator('.crumbs .cur')).toContainText('분기 리포트'); }); test('새 프로젝트 생성 (API 영속)', async ({ page }) => { await page.goto('/tasks'); await page.locator('.tree-add', { hasText: '새 프로젝트' }).first().click(); await page.locator('.tree-addrow input').fill('신규 프로젝트'); await page.locator('.tree-addrow input').press('Enter'); await expect(page.locator('.tree-name', { hasText: '신규 프로젝트' })).toBeVisible(); await page.reload(); // 새로고침 후에도 유지 → 서버 영속 확인 await expect(page.locator('.tree-name', { hasText: '신규 프로젝트' })).toBeVisible(); }); test('뷰 전환 칸반 ↔ 리스트', async ({ page }) => { await page.goto('/tasks'); await page.locator('.view-seg button', { hasText: '리스트' }).click(); await expect(page.locator('.lgroups')).toBeVisible(); await expect(page.locator('.lgroup-head')).toHaveCountGreaterThan(0); await page.locator('.view-seg button', { hasText: '칸반' }).click(); await expect(page.locator('.kboard')).toBeVisible(); }); test('작업 상태 이동 API 왕복 + 영속', async ({ page }) => { await page.goto('/tasks'); await page.locator('.kcard', { hasText: '구독 결제 카드 갱신' }).click(); await page.locator('.status-pill').click(); await page.locator('.status-menu button', { hasText: '완료' }).click(); await page.locator('.dp-close').click(); // 완료 컬럼에 카드 이동 확인 await expect(page.locator('.kcol', { hasText: '완료' }).locator('.kcard', { hasText: '구독 결제 카드 갱신' })).toBeVisible(); await page.reload(); await expect(page.locator('.kcol', { hasText: '완료' }).locator('.kcard', { hasText: '구독 결제 카드 갱신' })).toBeVisible(); }); test('드로어 열기 → 하위작업 드릴다운 → 댓글', async ({ page }) => { await page.goto('/tasks'); await page.locator('.kcard', { hasText: '분기 리포트 초안 마무리' }).click(); await expect(page.locator('.dpanel')).toBeVisible(); await page.locator('.subrow', { hasText: '매출 섹션 작성' }).click(); await expect(page.locator('.dp-crumbs .cz.cur')).toHaveText('매출 섹션 작성'); // 댓글 await page.locator('.dp-crumbs .cz', { hasText: '분기 리포트 초안 마무리' }).click(); await page.locator('.cmt-compose input').fill('진행 상황 공유드립니다'); await page.locator('.cmt-compose input').press('Enter'); await expect(page.locator('.cmt-text', { hasText: '진행 상황 공유드립니다' })).toBeVisible(); }); test('리스크 레이더 CTA → 작업 드로어 스크롤/열기', async ({ page }) => { await page.goto('/tasks'); await page.locator('.rr-item.t-coral .rr-cta', { hasText: '작업 열기' }).click(); await expect(page.locator('.dpanel .dp-title')).toContainText('분기 리포트 초안 마무리'); }); test('Auto-Scaffolding 미리보기 → 적용', async ({ page }) => { await page.goto('/tasks'); await page.locator('.kcard', { hasText: 'OKR 중간 점검 자료 준비' }).click(); await page.locator('.scaffold-trigger', { hasText: '아리에게 업무 쪼개기 맡기기' }).click(); await expect(page.locator('.scaffold-panel .sp-item')).toHaveCountGreaterThan(0); const n = await page.locator('.scaffold-panel .sp-item').count(); await page.locator('.sp-apply').click(); await expect(page.locator('.subrow')).toHaveCount(n + /* 기존 하위 수 */ 2); }); test('즐겨찾기: 핀 토글 → 즐겨찾기 스코프 필터', async ({ page }) => { await page.goto('/tasks'); await page.locator('.tree-row', { hasText: '와이어프레임' }).locator('.tree-pin').click(); await page.locator('.tree-row.folder', { hasText: '즐겨찾기' }).click(); await expect(page.locator('.tree-name', { hasText: '와이어프레임' })).toBeVisible(); }); ``` ### 7.5 접근성 (axe) — `playwright/tasks.a11y.spec.ts` ```ts import AxeBuilder from '@axe-core/playwright'; test('작업 페이지 a11y (칸반)', async ({ page }) => { await page.goto('/tasks'); const r = await new AxeBuilder({ page }).analyze(); expect(r.violations).toEqual([]); }); test('드로어 a11y (role=dialog, 포커스 트랩, Esc)', async ({ page }) => { await page.goto('/tasks'); await page.locator('.kcard').first().click(); await expect(page.locator('[role=dialog][aria-label="작업 상세"]')).toBeVisible(); const r = await new AxeBuilder({ page }).include('.dpanel').analyze(); expect(r.violations).toEqual([]); await page.keyboard.press('Escape'); await expect(page.locator('.dpanel')).toHaveCount(0); }); ``` 체크: 모든 아이콘 버튼 `aria-label`, 드로어 `role="dialog"`+`aria-label`, 포커스 트랩, 색 대비(`--muted`/`--faint` on glass 4.5:1), `prefers-reduced-motion` 애니메이션 제거. ### 7.6 수동 QA 체크리스트 - [ ] `/tasks` 진입 시 업무 스코프 + 칸반 + 리스크 레이더 3건 이내 표시. - [ ] 사이드바: 업무/개인/즐겨찾기 체브론 펼침, 무한 중첩(경영 전략 > 분기 리포트) 들여쓰기 정확. - [ ] 선택 폴더만 검은 배경(`--fill`), 나머지 옅은 회색(`--glass-2`). - [ ] 폴더/프로젝트 클릭 시 메인 작업 필터 + 브레드크럼 갱신 + 작업수 배지 정확. - [ ] 즐겨찾기 비었을 때 안내 문구, 핀 후 즐겨찾기 스코프에서 보임, 새로고침 후 유지. - [ ] 새 프로젝트 인라인 추가 → 즉시 트리 반영 + 새로고침 유지(서버). - [ ] 칸반 카드: 제목/태그/담당/마감(임박 coral)/우선순위/진행바/위임칩 디자인 원본 일치. - [ ] 칸반 빠른 추가(+) → Enter로 작업 생성, 컬럼에 즉시 표시. - [ ] 상태 이동(드로어 상태 메뉴 또는 DnD) → 컬럼 이동 + 새로고침 유지. - [ ] 리스트 뷰: 상태별 그룹, 마감순 정렬, 빈 그룹 숨김, hide-sm 반응형. - [ ] 드로어: 60vw 슬라이드인, 브레드크럼 드릴다운, 노트 편집/저장, 하위작업 펼침/체크/추가, 댓글 등록, 담당/마감/우선순위/상태 편집 영속. - [ ] Scaffold: 트리거 → 미리보기(kind/단계/예상시간) → 모두 추가 → 하위작업 생성. - [ ] 위임 추천(본인+미완+같은 프로젝트 동료)만 노출, 위임 후 대기 중 + 위임됨 노트. - [ ] 리스크 CTA 클릭 → 해당 작업 드로어 오픈. - [ ] 테마 토글(라이트/다크) 시 모든 토큰 정상 전환. - [ ] 반응형: 1080px 사이드바 숨김, 860px 드로어 전체폭, 760px 메인내비 숨김. - [ ] 빈/로딩/에러/오프라인 상태 모두 안내 표시. ### 7.7 통과 기준 - 백엔드 pytest 전부 통과(특히 `test_risks.py` 3종 + max3 + 의존성 + 쏠림 임계). - 프론트 Vitest 전부 통과. - Playwright E2E 9개 시나리오 + a11y 2개 통과. - axe violations 0건. - 수동 QA 전 항목 체크. --- ## 8. 완료 기준 (Definition of Done) - [ ] `/tasks` 라우트가 셸(Topbar) 안에서 렌더되고 메인내비 `작업` 탭 active. - [ ] 폴더 트리 사이드바(업무/개인/즐겨찾기) + 무한 중첩 프로젝트 펼침/선택 동작. - [ ] 필터(area/project/fav) + 검색 + 브레드크럼 + 작업수 배지 정확. - [ ] 칸반(5컬럼) · 리스트(상태별 그룹) 2뷰 전환 동작, SegmentToggle 영속. - [ ] 작업 카드 픽셀 재현(제목/담당/마감/우선순위/진행/위임). - [ ] 작업 상세 드로어: 노트(HTML)/댓글/하위작업 트리/메타 편집/scaffold/위임/삭제, 브레드크럼 드릴다운. - [ ] 리스크 레이더: `GET /api/risks?area=work` → 지연/쏠림/의존성 카드, CTA로 드로어 열기, 계산이 `tasks-risk.jsx`와 동일. - [ ] 생성(작업/하위작업/프로젝트)·수정(상태/필드/노트/댓글)·삭제가 모두 API로 **영속**(새로고침 유지). - [ ] UI 상태(선택/펼침/뷰)는 `ari.tasks.*` localStorage 영속. - [ ] 로딩/빈/에러/오프라인 상태 처리. - [ ] 모든 테스트(§7) 통과 + axe 0건. - [ ] 디자인 토큰/문구/치수가 §5 표와 일치. --- ## 9. 다음 단계 작업 페이지가 완성되면 **`phase-4-inbox.md`**(인박스)로 진행한다. 인박스는 캡처 → 분류 → **작업으로 실체화(federation)**할 때 이 phase에서 만든 `POST /api/tasks`/`GET /api/tree`/작업 트리 구조를 재사용한다(인박스 `confirm` → `task` 생성 + `materialized_task_id` 연결). 또한 인박스에서 만든 작업(`k20` 한국행 비행기 티켓 등)이 이 작업 페이지의 `life-trip` 프로젝트에 그대로 나타나는 연합 흐름을 `phase-6-integration.md`에서 E2E로 검증한다. 페이지 빌드 순서: **작업(이 문서) → 인박스(`phase-4-inbox.md`) → 대시보드(`phase-5-dashboard.md`) → 통합(`phase-6-integration.md`)**.