# 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`)**.
