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 라우트에서 다음이 시드 데이터로 실제 동작한다.
- 폴더 트리 사이드바 — 상단 3개 영역(업무[폴더 아이콘] · 개인[하트] · 즐겨찾기[별])을 체브론으로 펼치면 하위 프로젝트가 무한 중첩으로 나타난다. 선택된 폴더만 검은 배경(
--fill/--on-fill), 나머지는 옅은 회색(--glass-2). 각 영역 끝에+ 새 프로젝트인라인 추가, 우측에 작업수 배지. - 필터 — 폴더/프로젝트 클릭 → 해당 영역(
area=work|life)·프로젝트 작업만 보여준다.즐겨찾기=pinned프로젝트의 작업만. - 뷰 토글 —
칸반(5컬럼: todo/doing/waiting/review/done) ·리스트(상태별 그룹). 원본 디자인에 캘린더 토글은 없으며(VIEWS배열은kanban·list2개), CONTRACT의 SegmentToggle은 칸반/리스트로 구현하고 캘린더는 "준비 중" placeholder로 둔다(아래 §4 참고). - 작업 카드 — 제목/담당자 아바타/마감(임박 강조)/우선순위 플래그/하위작업 진행률/위임 칩. 칸반 카드 픽셀 재현.
- 무한 중첩 하위작업 — 칸반/리스트에서는 카드에 진행률 표시, 상세 드로어에서 재귀 트리로 펼침/드릴다운.
- 작업 상세 드로어(DetailPanel) — notes(HTML 리치 에디터), 댓글 스레드, 담당자/마감/우선순위/상태 편집, 하위작업 트리, 브레드크럼 드릴다운, Auto-Scaffolding(하위작업 제안), 위임.
- 리스크 레이더 — 업무 폴더 스코프(
area==='work')일 때만GET /api/risks?area=work호출 → 지연위험(coral)/업무쏠림(amber)/의존성(violet) 카드 최대 3건, CTA로 해당 작업 드로어 열기. - 상태 영속 — 선택/펼침/뷰/즐겨찾기는
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이 그걸 파싱한다. 백엔드는 ISOdate(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.js의 buildIndex를 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(클릭 시 강제 펼침). - 체브론 클릭은
stopPropagation후toggle(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에서 구현, 여기서 계약 명시):
- 지연 위험(coral, clock):
status != done && due 있음 && dueDay(due) <= TODAY(8)중 1건. 문장:**{title}** — 오늘({M/D}) 마감인데 아직 {시작 전|진행 중|대기 중|검토 중}이에요.task_id포함, cta=작업 열기. - 업무 쏠림(amber, scale): 미완료 작업을 담당자별 카운트,
top >= avg*1.5 && top >= 4일 때 1건. 본인(is_me)이면미완료 작업 **N건**이 내게 몰려 있어요 — 팀 평균의 X배. 벅찬 작업은 **위임 & 추적**으로 넘겨보세요, 타인이면**{name}님**에게 미완료 작업 **N건**이 몰려 있어요 — 평균의 X배, 일부 재배분을 추천드려요.task_id없음(cta 없음). - 의존성(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.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.66scale(원본 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.75link(원본 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-backdropblur(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 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의 상태/핸들러를 서버 데이터 + 낙관적 업데이트로 재구성.
// 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 응답 예시
[
{
"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: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 },.tmaindelay 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-grid1열.≤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.py3종 + 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).