You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1420 lines
77 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 파서가 <b>로 변환)
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<T>(r: Response): Promise<T> {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json() as Promise<T>;
}
export const tasksApi = {
people: () => fetch('/api/people').then(ok<Person[]>),
tree: () => fetch('/api/tree').then(ok<Folder[]>),
// 필터 우선순위: 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<Task[]>);
},
get: (id: string) => fetch(`/api/tasks/${id}`).then(ok<Task>),
create: (body: Partial<Task> & { title: string; project_id: string }) =>
fetch('/api/tasks', { method: 'POST', headers: J, body: JSON.stringify(body) }).then(ok<Task>),
patch: (id: string, body: Partial<Task>) =>
fetch(`/api/tasks/${id}`, { method: 'PATCH', headers: J, body: JSON.stringify(body) }).then(ok<Task>),
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<TaskComment>),
// 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<Risk[]>),
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<Project>),
pinProject: (id: string) => fetch(`/api/projects/${id}/pin`, { method: 'POST' }).then(ok<Project>),
};
```
### 3.3 localStorage 영속 — `frontend/lib/tasks/store.ts`
원본 `load/save`(키 `ariT.`)를 그대로 옮기되 접두사만 `ari.tasks.`로. **데이터가 아니라 UI 상태만** 저장한다.
```ts
const PREFIX = 'ari.tasks.';
export function loadLS<T>(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<T>(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<string, ProjIndexRec> = {};
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<string, boolean>; onToggle: (id: string) => void;
onPin: (id: string) => void;
counts: Record<string, number>;
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 (
<>
<div
className={cx('tree-row', sel === node.id && 'on')}
style={{ paddingLeft: 8 + depth * 15 }}
onClick={() => onSelect(node.id)}
>
{hasCh ? (
<button className={cx('tree-chev', open && 'open')}
onClick={(e) => { e.stopPropagation(); onToggle(node.id); }} aria-label="펼치기">
<Icon name="chev" />
</button>
) : <span className="tree-chev-spacer" />}
<span className="tree-dot" style={{ background: `var(--${node.tone})` }} />
<span className="tree-name">{node.name}</span>
{counts[node.id] > 0 && <span className="tree-count">{counts[node.id]}</span>}
<button className={cx('tree-pin', node.pinned && 'on')}
onClick={(e) => { e.stopPropagation(); onPin(node.id); }} aria-label="즐겨찾기">
<Icon name="star" strokeWidth={node.pinned ? 0 : 1.9} />
</button>
</div>
{hasCh && open && node.children.map((c) => (
<TreeNode key={c.id} node={c} depth={depth + 1}
sel={sel} onSelect={onSelect} expanded={expanded} onToggle={onToggle}
onPin={onPin} counts={counts} onAddProj={onAddProj} />
))}
</>
);
}
```
```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 (
<button className="tree-add" style={{ paddingLeft: 8 + depth * 15 }} onClick={() => setEditing(true)}>
<Icon name="plus" /> {placeholder}
</button>
);
}
return (
<div className="tree-addrow" style={{ paddingLeft: 8 + depth * 15 }}>
<input autoFocus value={val} placeholder={placeholder}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') { setVal(''); setEditing(false); } }}
onBlur={commit} />
</div>
);
}
```
```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<string, boolean>; onToggle: (id: string) => void;
onPin: (id: string) => void;
counts: Record<string, number>; // 프로젝트/폴더 id → 미완료 수
pinnedProjects: Project[]; // pinned=true 프로젝트 평탄 목록
onAddProject: (folderId: string, name: string) => void;
}
export function TaskSidebar(p: Props) {
return (
<aside className="subnav">
<div className="sn-title">작업</div>
<div className="sn-scroll">
{/* 업무 / 개인 */}
{p.folders.map((f) => {
const open = !!p.expanded[f.id];
return (
<Fragment key={f.id}>
<div
className={cx('tree-row folder', p.sel === f.id && 'on')}
style={{ paddingLeft: 8 }}
onClick={() => { p.onSelect(f.id); if (!open) p.onToggle(f.id); }}
>
<button className={cx('tree-chev', open && 'open')}
onClick={(e) => { e.stopPropagation(); p.onToggle(f.id); }} aria-label="펼치기">
<Icon name="chev" />
</button>
<span className="tree-fold"><Icon name={f.icon || 'folder'} /></span>
<span className="tree-name">{f.name}</span>
<span className="tree-badge">{p.counts[f.id] || 0}</span>
</div>
{open && f.projects.map((n) => (
<TreeNode key={n.id} node={n} depth={1}
sel={p.sel} onSelect={p.onSelect} expanded={p.expanded} onToggle={p.onToggle}
onPin={p.onPin} counts={p.counts} onAddProj={(pid, name) => p.onAddProject(pid, name)} />
))}
{open && <TreeAdd depth={1} placeholder="새 프로젝트" onAdd={(name) => p.onAddProject(f.id, name)} />}
</Fragment>
);
})}
{/* 즐겨찾기 */}
<div className={cx('tree-row folder', p.sel === 'fav' && 'on')} style={{ paddingLeft: 8 }}
onClick={() => { p.onSelect('fav'); if (!p.expanded.fav) p.onToggle('fav'); }}>
<button className={cx('tree-chev', p.expanded.fav && 'open')}
onClick={(e) => { e.stopPropagation(); p.onToggle('fav'); }} aria-label="펼치기">
<Icon name="chev" />
</button>
<span className="tree-fold"><Icon name="star" /></span>
<span className="tree-name">즐겨찾기</span>
<span className="tree-badge">{p.pinnedProjects.length}</span>
</div>
{p.expanded.fav && (
p.pinnedProjects.length > 0
? p.pinnedProjects.map((pr) => (
<div key={pr.id} className={cx('tree-row', p.sel === pr.id && 'on')}
style={{ paddingLeft: 8 + 15 }} onClick={() => p.onSelect(pr.id)}>
<span className="tree-chev-spacer" />
<span className="tree-dot" style={{ background: `var(--${pr.tone})` }} />
<span className="tree-name">{pr.name}</span>
<button className="tree-pin on" onClick={(e) => { e.stopPropagation(); p.onPin(pr.id); }} aria-label="즐겨찾기 해제">
<Icon name="star" strokeWidth={0} />
</button>
</div>
))
: <div className="sn-empty" style={{ paddingLeft: 8 + 15 }}><Icon name="star" />제목 별표를 눌러 모아두세요</div>
)}
</div>
<div className="sn-foot">
<div className="av">{p.me.initial}</div>
<div className="txt"><b>{p.me.name}</b><span>Pro 플랜</span></div>
</div>
</aside>
);
}
```
> **문구 인용(원본 그대로)**: 사이드바 제목 `작업`, 폴더 라벨 `업무`/`개인`/`즐겨찾기`, 즐겨찾기 빈 상태 `제목 옆 별표를 눌러 모아두세요`, 새 프로젝트 버튼 `새 프로젝트`, 푸터 `지우님` / `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<string, Person>; projects: Record<string, Project>;
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 (
<div className={cx('kcard', task.status === 'done' && 'done-card')} onClick={() => onOpen(task.id)}>
<div className="kcard-top">
{proj && (
<span className="ktag">
<span className="pdot" style={{ background: `var(--${proj.tone})` }} />
<span>{proj.name}</span>
</span>
)}
<button className="kmore" onClick={(e) => { e.stopPropagation(); onOpen(task.id); }} aria-label="더보기"><Icon name="more" /></button>
</div>
<div className="kcard-title">{task.title}</div>
{s.total > 0 && (
<div className="ksub">
<div className="bar"><span style={{ width: pct(task) + '%' }} /></div>
<span className="ksub-num"><Icon name="branch" /> {s.done}/{s.total}</span>
</div>
)}
<div className="kfoot">
<span className={cx('due', soon && 'soon')}><Icon name="cal" /> {dueLabel(task.due)}</span>
<span className={cx('prio-flag', `prio-${task.prio}`)}><Icon name="flag" /> {task.prio}</span>
{task.delegated && <span className="deleg-chip"><Icon name="users" />위임</span>}
{person && <div className="av" style={{ background: person.color }} title={person.name}>{person.initial}</div>}
</div>
</div>
);
}
```
```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` 파서가 `**``<b>`만 변환(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" → <b>A</b>B (정규식 split, dangerouslySetInnerHTML 미사용)
return <>{text.split(/(\*\*[^*]+\*\*)/).map((s, i) =>
s.startsWith('**') ? <b key={i}>{s.slice(2, -2)}</b> : <span key={i}>{s}</span>)}</>;
}
export function RiskRadar({ risks, onOpen }: { risks: Risk[]; onOpen: (id: string) => void }) {
if (!risks.length) return null;
return (
<div className="rradar">
<div className="rr-head">
<span className="rr-ic"><Icon name="radar" /></span>
<div className="rr-ht"><b>리스크 레이더</b><span>마감 · 업무량 · 의존성을 아리가 훑었어요</span></div>
</div>
<div className="rr-items">
{risks.map((r, i) => (
<div key={i} className={`rr-item t-${r.tone}`}>
<span className="rr-item-ic"><Icon name={r.icon} /></span>
<div className="rr-body">
<span className="rr-kind">{r.kind}</span>
<p className="rr-text"><Bolded text={r.text} /></p>
</div>
{r.task_id && r.cta && (
<button className="rr-cta" onClick={() => onOpen(r.task_id!)}>{r.cta}<Icon name="chev" /></button>
)}
</div>
))}
</div>
</div>
);
}
```
> **아이콘**: 리스크 아이콘(`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<string, { label: string; c: string }> = {
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<string, Person>; projects: Record<string, Project>;
onFocus: (id: string) => void; onClose: () => void;
onCheck: (id: string) => void; // 완료 토글 → PATCH status
onField: (id: string, patch: Partial<Task>) => 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<Record<string, boolean>>({});
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 (
<>
<div className="dp-backdrop" onClick={p.onClose} />
<aside className="dpanel" role="dialog" aria-label="작업 상세">
{/* dp-head: 위로 / 브레드크럼 / 프로젝트태그 / 닫기 */}
{/* dp-grid: 좌 main (제목·체크 / 노트 / 하위작업 / 코멘트) + 우 rail (담당/마감/우선순위/상태/위임) */}
{/* dp-foot: 삭제 / 상위로 / 완료 */}
</aside>
</>
);
}
```
**핵심 인터랙션(원본 문구·동작 그대로)**:
| 요소 | 동작 | 서버 |
|---|---|---|
| `.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`)/즐겨찾기는 제안 안 함. 본인 담당 & 미완료일 때만 추천. 문구: `직접 하기 벅차다면 이 프로젝트를 함께 하는 <b>{name}</b>님께 위임할 수 있어요. 넘긴 뒤엔 '대기 중'으로 바꿔 진행 상황을 추적해 드릴게요.` 위임됨 상태(`.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<string>(() => loadLS('sel', 'work'));
const [expanded, setExpanded] = useState<Record<string, boolean>>(() =>
({ work: true, life: true, ...loadLS('expanded', {}) }));
const [q, setQ] = useState('');
const [focusId, setFocusId] = useState<string | null>(null);
const [rootId, setRootId ] = useState<string | null>(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<Folder[]>('tree', tasksApi.tree);
const { data: people } = useSWR<Person[]>('people', tasksApi.people);
const { data: tasks } = useSWR<Task[]>(['tasks', area, sel],
() => tasksApi.list({ area, project_id: area ? undefined : (sel === 'fav' ? undefined : sel) }));
// 리스크 레이더는 업무 폴더 스코프(area==='work')일 때만 호출/표시 (원본: 업무 스코프 전용)
const { data: risks } = useSWR<Risk[]>(
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": "<h3>목표</h3><p>경영진 미팅용 Q2 성과 리포트...</p>",
"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('**굵게** → <b> 변환(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`)**.