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.

77 KiB

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, …)으로 정규화하되, 표시 로직은 동일하게 유지한다.

// 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

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 상태만 저장한다.

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.jsbuildIndex를 TS로 이식. 편집 함수는 낙관적 업데이트(optimistic)용으로만 쓰고, 진짜 영속은 API.

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(클릭 시 강제 펼침).
  • 체브론 클릭은 stopPropagationtoggle(id)만 (선택과 분리).
  • 선택된 폴더만 .on → 검은 배경(--fill/--on-fill). 비선택은 --glass-2.
  • 프로젝트 노드(TreeNode)는 재귀, paddingLeft = 8 + depth * 15(px) — 무한 중첩 들여쓰기.
  • 미완료 작업수 배지: 폴더는 .tree-badge, 프로젝트는 .tree-count.
  • 호버 시 우측 별(.tree-pin) 노출, 핀이면 --amber로 항상 노출.
  • 각 폴더 펼침 끝에 TreeAdd("새 프로젝트") 인라인.
// 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} />
      ))}
    </>
  );
}
// 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>
  );
}
// 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="새 작업") → 칸반으로 전환 → 생성 작업 드로어 자동 오픈.
// 새 작업 대표 프로젝트 결정 (원본 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)
// 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>
  );
}
// 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)에서 프로젝트/담당/우선순위 숨김.

// 그리드: 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=후속 작업 보기.
# 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 금지, 텍스트 분할로 처리).

// 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.jsxRP 딕셔너리 근거 — 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 리셋.
// 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 donetodo 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의 상태/핸들러를 서버 데이터 + 낙관적 업데이트로 재구성.

// 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}/pinmutate('tree').
  • 상태 이동/필드 수정: PATCHmutate(['tasks',...]), 업무 스코프면 mutate(['risks','work'])(리스크 재계산).
  • 새 작업/하위작업/scaffold/댓글/삭제: 각 API 후 mutate.

4. 데이터/타입/API 계약 (이 phase 관련)

4.1 GET /api/tree 응답 예시

[
  {
    "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 응답 예시(요지)

[
  {
    "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} (상태 이동)

PATCH /api/tasks/kx9
Content-Type: application/json

{ "status": "done" }

응답: 갱신된 Task(중첩 children 포함). 부모의 진행률(stat/pct)은 프론트에서 children 기반으로 재계산.

4.4 POST /api/tasks/{id}/scaffold

POST /api/tasks/k6/scaffold     # k6="OKR 중간 점검 자료 준비" → 문서·리포트? 아니면 generic
Content-Type: application/json

{ "create": false, "use_llm": false }
{ "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

[
  { "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:600dueDay(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 실행 명령

# 백엔드 (리스크 서비스 정확성 — 가장 중요)
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를 시드 데이터로 검증. 반드시 포함:

# 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
# 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"
# 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)

// 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 */ });
// 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 */ });
// 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-높음/보통/낮음', () => {});
// 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 없음', () => {});
// tests/tasks/ListView.test.tsx
test('상태별 그룹, 빈 그룹 숨김', () => {});
test('마감 빠른 순 정렬', () => {});
test('행 클릭 → onOpen', () => {});
// 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) 권장.

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

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/작업 트리 구조를 재사용한다(인박스 confirmtask 생성 + materialized_task_id 연결). 또한 인박스에서 만든 작업(k20 한국행 비행기 티켓 등)이 이 작업 페이지의 life-trip 프로젝트에 그대로 나타나는 연합 흐름을 phase-6-integration.md에서 E2E로 검증한다.

페이지 빌드 순서: 작업(이 문서) → 인박스(phase-4-inbox.md) → 대시보드(phase-5-dashboard.md) → 통합(phase-6-integration.md).