|
|
# Phase 3 — 작업(Tasks) 페이지
|
|
|
|
|
|
> **한 줄 요약**: 폴더(업무/개인/즐겨찾기) 트리 사이드바 + 칸반/리스트 뷰 + 무한 중첩 하위작업 + 작업 상세 드로어 + 리스크 레이더를 백엔드 REST API와 연동해 완성한다.
|
|
|
>
|
|
|
> 📌 **이 문서는 `dev/` 문서 세트의 일부입니다 — 먼저 `overview.md`를 읽으세요.**
|
|
|
> 선행: `phase-0-foundation.md`(스캐폴딩) · `phase-1-design-system.md`(토큰/셸/Icon) · `phase-2-backend.md`(모델/시드/REST/리스크 서비스).
|
|
|
> 다음: `phase-4-inbox.md`(인박스).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1. 개요 & 목표
|
|
|
|
|
|
이 phase가 끝나면 `/tasks` 라우트에서 다음이 **시드 데이터로 실제 동작**한다.
|
|
|
|
|
|
1. **폴더 트리 사이드바** — 상단 3개 영역(업무[폴더 아이콘] · 개인[하트] · 즐겨찾기[별])을 체브론으로 펼치면 하위 프로젝트가 **무한 중첩**으로 나타난다. 선택된 폴더만 검은 배경(`--fill`/`--on-fill`), 나머지는 옅은 회색(`--glass-2`). 각 영역 끝에 `+ 새 프로젝트` 인라인 추가, 우측에 작업수 배지.
|
|
|
2. **필터** — 폴더/프로젝트 클릭 → 해당 영역(`area=work|life`)·프로젝트 작업만 보여준다. `즐겨찾기` = `pinned` 프로젝트의 작업만.
|
|
|
3. **뷰 토글** — `칸반`(5컬럼: todo/doing/waiting/review/done) · `리스트`(상태별 그룹). 원본 디자인에 캘린더 토글은 없으며(`VIEWS` 배열은 `kanban`·`list` 2개), CONTRACT의 SegmentToggle은 칸반/리스트로 구현하고 캘린더는 "준비 중" placeholder로 둔다(아래 §4 참고).
|
|
|
4. **작업 카드** — 제목/담당자 아바타/마감(임박 강조)/우선순위 플래그/하위작업 진행률/위임 칩. 칸반 카드 픽셀 재현.
|
|
|
5. **무한 중첩 하위작업** — 칸반/리스트에서는 카드에 진행률 표시, 상세 드로어에서 재귀 트리로 펼침/드릴다운.
|
|
|
6. **작업 상세 드로어(DetailPanel)** — notes(HTML 리치 에디터), 댓글 스레드, 담당자/마감/우선순위/상태 편집, 하위작업 트리, 브레드크럼 드릴다운, Auto-Scaffolding(하위작업 제안), 위임.
|
|
|
7. **리스크 레이더** — 업무 폴더 스코프(`area==='work'`)일 때만 `GET /api/risks?area=work` 호출 → 지연위험(coral)/업무쏠림(amber)/의존성(violet) 카드 최대 3건, CTA로 해당 작업 드로어 열기.
|
|
|
8. **상태 영속** — 선택/펼침/뷰/즐겨찾기는 `localStorage`(키 `ari.tasks.*`), 작업·프로젝트 데이터는 서버. 생성/수정은 API로 영속.
|
|
|
|
|
|
**비목표(Out of scope)**: 드래그앤드롭으로 칸반 카드 이동(원본엔 있으나 MVP에선 상태 메뉴/드롭다운으로 대체 가능 — 단, E2E에서는 PATCH 왕복으로 검증). 실시간 협업, 멀티유저. 캘린더 뷰.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 2. 선행 조건 / 산출물
|
|
|
|
|
|
### 2.1 선행 조건 (의존 phase)
|
|
|
|
|
|
| 의존 | 무엇을 제공받나 |
|
|
|
|---|---|
|
|
|
| `phase-0-foundation.md` | 모노레포(`frontend/`·`backend/`), pnpm·uv, `dev` 스크립트, Next 프록시(`/api` → FastAPI) |
|
|
|
| `phase-1-design-system.md` | `styles/tokens.css`(전 토큰), `components/Icon.tsx`(중앙 paths 맵), `components/Topbar.tsx`, `ThemeProvider`(next-themes), `app/layout.tsx` 셸, `lib/cx.ts` |
|
|
|
| `phase-2-backend.md` | `task`/`project`/`folder`/`person`/`task_comment` 테이블 + 시드, `GET /api/tree`·`GET /api/tasks`·`PATCH /api/tasks/{id}`·`POST /api/projects`·`POST /api/tasks/{id}/scaffold`·`GET /api/risks` 엔드포인트 |
|
|
|
|
|
|
### 2.2 산출물 (Deliverables)
|
|
|
|
|
|
```
|
|
|
frontend/
|
|
|
├─ app/tasks/page.tsx # 작업 페이지 엔트리 (서버 컴포넌트 셸 → 클라 TasksClient)
|
|
|
├─ components/tasks/
|
|
|
│ ├─ TasksClient.tsx # 상태 오케스트레이션 (선택/펼침/뷰/드로어)
|
|
|
│ ├─ TaskSidebar.tsx # 폴더 트리 사이드바
|
|
|
│ ├─ TreeNode.tsx # 재귀 프로젝트 노드
|
|
|
│ ├─ TreeAdd.tsx # 인라인 새 프로젝트/폴더 생성
|
|
|
│ ├─ TaskToolbar.tsx # 브레드크럼 + 뷰 세그 + 검색 + 새 작업
|
|
|
│ ├─ KanbanView.tsx # 5컬럼 칸반
|
|
|
│ ├─ KanbanCard.tsx # 작업 카드
|
|
|
│ ├─ ListView.tsx # 상태별 그룹 리스트
|
|
|
│ ├─ RiskRadar.tsx # 리스크 레이더
|
|
|
│ ├─ DetailPanel.tsx # 작업 상세 드로어
|
|
|
│ ├─ SubRow.tsx # 재귀 하위작업 행 (드릴다운)
|
|
|
│ ├─ RichNotes.tsx # contentEditable 리치 노트
|
|
|
│ ├─ Comments.tsx # 댓글 스레드
|
|
|
│ └─ ScaffoldPanel.tsx # Auto-Scaffolding 패널
|
|
|
├─ lib/
|
|
|
│ ├─ tasks/api.ts # 작업/트리/리스크 API 클라이언트
|
|
|
│ ├─ tasks/store.ts # localStorage 영속 (ari.tasks.*)
|
|
|
│ ├─ tasks/tree.ts # 트리 유틸 (findPath/editTree/buildIndex/stat/pct)
|
|
|
│ └─ types.ts (확장) # Task, Project, Folder, Risk 타입
|
|
|
├─ styles/tasks.css # tasks.css 이식 (또는 CSS Module)
|
|
|
├─ tests/tasks/*.test.tsx # Vitest 컴포넌트 테스트
|
|
|
└─ playwright/tasks.spec.ts # E2E
|
|
|
```
|
|
|
|
|
|
백엔드 산출물은 `phase-2-backend.md`에서 완성되며, 이 문서는 그 계약을 소비한다. 단, 이 phase에서 **리스크 서비스 이식 검증**(§7)과 **scaffold 응답 형태**(§3.10·§4.4)를 확정한다.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 3. 상세 구현 (파일별·단계별)
|
|
|
|
|
|
### 3.0 데이터 흐름 개요
|
|
|
|
|
|
```
|
|
|
[FastAPI] [Next.js /tasks]
|
|
|
GET /api/tree ───────────────► TaskSidebar (folder[]→project[] 중첩)
|
|
|
GET /api/tasks?area=&project_id= ► KanbanView / ListView (중첩 task 트리)
|
|
|
GET /api/risks?area=work ───────► RiskRadar
|
|
|
POST /api/projects ─────────────► 새 프로젝트 (사이드바 인라인)
|
|
|
PATCH /api/tasks/{id} ──────────► 상태/필드 수정 (드로어·칸반)
|
|
|
POST /api/tasks/{id}/comments ──► 댓글
|
|
|
POST /api/tasks/{id}/scaffold ──► 하위작업 제안/생성
|
|
|
```
|
|
|
|
|
|
> **원본과의 차이(중요)**: 원본 `tasks.jsx`는 **전부 localStorage 트리**(`ariT.tree`, `ariT.ptree`)로 동작하는 순수 프론트 프로토타입이다. MVP에서는 **데이터는 서버가 원천(source of truth)**, **UI 상태(선택/펼침/뷰/즐겨찾기 핀)만 localStorage**로 옮긴다. localStorage 키 접두사도 원본 `ariT.` → CONTRACT 규약 `ari.tasks.`로 변경한다.
|
|
|
|
|
|
### 3.1 타입 정의 — `frontend/lib/types.ts` (확장분)
|
|
|
|
|
|
CONTRACT의 데이터모델·필드명을 그대로 따른다. 원본 데이터 필드(`assignee`, `project`, `est`, `delegated`, `comments[].who/time`)는 백엔드 스키마 필드명(`assignee_id`, `project_id`, …)으로 정규화하되, 표시 로직은 동일하게 유지한다.
|
|
|
|
|
|
```ts
|
|
|
// frontend/lib/types.ts (Phase 3 추가분)
|
|
|
|
|
|
export type Tone = 'blue' | 'violet' | 'coral' | 'green' | 'amber' | 'ink' | 'faint';
|
|
|
export type Status = 'todo' | 'doing' | 'waiting' | 'review' | 'done';
|
|
|
export type Prio = '높음' | '보통' | '낮음';
|
|
|
|
|
|
export interface Person {
|
|
|
id: string; // "jiwoo"
|
|
|
name: string; // "지우"
|
|
|
initial: string; // "지"
|
|
|
color: string; // "var(--blue)" | "oklch(0.66 0.13 200)"
|
|
|
is_me: boolean;
|
|
|
}
|
|
|
|
|
|
export interface Folder {
|
|
|
id: string; // "work" | "life"
|
|
|
name: string; // "업무" | "개인"
|
|
|
tone: Tone; // ink | blue
|
|
|
icon: string; // "folder" | "heart"
|
|
|
sort_order: number;
|
|
|
is_system: boolean;
|
|
|
projects: Project[]; // GET /api/tree 시 중첩
|
|
|
}
|
|
|
|
|
|
export interface Project {
|
|
|
id: string; // "biz" | "biz-report" | "me" | "life-trip"
|
|
|
folder_id: string; // "work" | "life"
|
|
|
parent_id: string | null;
|
|
|
name: string; // "경영 전략" | "분기 리포트"
|
|
|
tone: Tone;
|
|
|
sort_order: number;
|
|
|
pinned: boolean;
|
|
|
children: Project[]; // 트리 응답 시 중첩 (무한)
|
|
|
task_count: number; // 미완료 작업 수 (배지)
|
|
|
}
|
|
|
|
|
|
export interface TaskComment {
|
|
|
id: string;
|
|
|
task_id: string;
|
|
|
person_id: string; // who
|
|
|
text: string;
|
|
|
created_at: string; // 표시는 "방금"/"2시간 전" 등 상대시간
|
|
|
}
|
|
|
|
|
|
export interface Task {
|
|
|
id: string; // "k1" | "kx3" | "n1001"
|
|
|
project_id: string; // "biz-report"
|
|
|
parent_id: string | null;
|
|
|
title: string;
|
|
|
status: Status;
|
|
|
assignee_id: string; // "jiwoo"
|
|
|
due: string | null; // "2026-06-08" (ISO date) — 표시는 "6/8"
|
|
|
prio: Prio;
|
|
|
notes: string; // HTML 허용
|
|
|
est: string; // "2시간" | "반나절"
|
|
|
delegated: boolean;
|
|
|
sort_order: number;
|
|
|
created_at: string;
|
|
|
updated_at: string;
|
|
|
comments: TaskComment[];
|
|
|
children: Task[]; // 무한 중첩 하위작업
|
|
|
}
|
|
|
|
|
|
export interface Risk {
|
|
|
kind: '지연 위험' | '업무 쏠림' | '의존성';
|
|
|
icon: 'clock' | 'scale' | 'link';
|
|
|
tone: 'coral' | 'amber' | 'violet';
|
|
|
text: string; // 백엔드가 한국어 문장으로 완성, 강조어는 **굵게** 마크다운으로 감쌈 (프론트 Bolded 파서가 <b>로 변환)
|
|
|
task_id: string | null;// 있으면 CTA로 드로어 열기
|
|
|
cta: string | null; // "작업 열기" | "후속 작업 보기"
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> **due 포맷 주의**: 원본 데이터는 `"06-08"`(월-일) 형태이고 `dueDay`/`dueLabel`이 그걸 파싱한다. 백엔드는 ISO `date`(`2026-06-08`)로 저장하므로, 표시 유틸 `dueDay`/`dueLabel`을 ISO에 맞게 재작성한다(§3.4). 리스크 계산의 `TODAY=8`(6월 8일)도 백엔드 상수로 유지한다.
|
|
|
|
|
|
### 3.2 API 클라이언트 — `frontend/lib/tasks/api.ts`
|
|
|
|
|
|
```ts
|
|
|
import type { Folder, Task, Risk, Project, Person, TaskComment } from '@/lib/types';
|
|
|
|
|
|
const J = { 'Content-Type': 'application/json' };
|
|
|
async function ok<T>(r: Response): Promise<T> {
|
|
|
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
|
return r.json() as Promise<T>;
|
|
|
}
|
|
|
|
|
|
export const tasksApi = {
|
|
|
people: () => fetch('/api/people').then(ok<Person[]>),
|
|
|
|
|
|
tree: () => fetch('/api/tree').then(ok<Folder[]>),
|
|
|
|
|
|
// 필터 우선순위: project_id(+하위) > area(work|life) > 전체. status/assignee는 추가 필터. 중첩 트리 반환.
|
|
|
list: (p: { area?: 'work' | 'life'; project_id?: string; status?: string; assignee?: string }) => {
|
|
|
const q = new URLSearchParams(Object.entries(p).filter(([, v]) => v != null) as [string, string][]);
|
|
|
return fetch(`/api/tasks?${q}`).then(ok<Task[]>);
|
|
|
},
|
|
|
|
|
|
get: (id: string) => fetch(`/api/tasks/${id}`).then(ok<Task>),
|
|
|
|
|
|
create: (body: Partial<Task> & { title: string; project_id: string }) =>
|
|
|
fetch('/api/tasks', { method: 'POST', headers: J, body: JSON.stringify(body) }).then(ok<Task>),
|
|
|
|
|
|
patch: (id: string, body: Partial<Task>) =>
|
|
|
fetch(`/api/tasks/${id}`, { method: 'PATCH', headers: J, body: JSON.stringify(body) }).then(ok<Task>),
|
|
|
|
|
|
remove: (id: string) => fetch(`/api/tasks/${id}`, { method: 'DELETE' }).then((r) => r.ok),
|
|
|
|
|
|
addComment: (id: string, body: { person_id: string; text: string }) =>
|
|
|
fetch(`/api/tasks/${id}/comments`, { method: 'POST', headers: J, body: JSON.stringify(body) }).then(ok<TaskComment>),
|
|
|
|
|
|
// create=false면 생성하지 않고 제안만, true면 실제 하위작업 생성. use_llm으로 LLM 분해 선택.
|
|
|
scaffold: (id: string, body: { create?: boolean; use_llm?: boolean } = {}) =>
|
|
|
fetch(`/api/tasks/${id}/scaffold`, { method: 'POST', headers: J, body: JSON.stringify(body) })
|
|
|
.then(ok<{ kind: string; icon: string; items: { title: string; est: string }[]; created: boolean; created_task_ids: string[] }>),
|
|
|
|
|
|
risks: (area: 'work' | 'life' = 'work') => fetch(`/api/risks?area=${area}`).then(ok<Risk[]>),
|
|
|
|
|
|
createProject: (body: { folder_id: string; parent_id?: string; name: string; tone?: string }) =>
|
|
|
fetch('/api/projects', { method: 'POST', headers: J, body: JSON.stringify(body) }).then(ok<Project>),
|
|
|
|
|
|
pinProject: (id: string) => fetch(`/api/projects/${id}/pin`, { method: 'POST' }).then(ok<Project>),
|
|
|
};
|
|
|
```
|
|
|
|
|
|
### 3.3 localStorage 영속 — `frontend/lib/tasks/store.ts`
|
|
|
|
|
|
원본 `load/save`(키 `ariT.`)를 그대로 옮기되 접두사만 `ari.tasks.`로. **데이터가 아니라 UI 상태만** 저장한다.
|
|
|
|
|
|
```ts
|
|
|
const PREFIX = 'ari.tasks.';
|
|
|
export function loadLS<T>(key: string, fallback: T): T {
|
|
|
if (typeof window === 'undefined') return fallback;
|
|
|
try {
|
|
|
const v = localStorage.getItem(PREFIX + key);
|
|
|
return v === null ? fallback : (JSON.parse(v) as T);
|
|
|
} catch { return fallback; }
|
|
|
}
|
|
|
export function saveLS<T>(key: string, value: T): void {
|
|
|
if (typeof window === 'undefined') return;
|
|
|
try { localStorage.setItem(PREFIX + key, JSON.stringify(value)); } catch { /* quota */ }
|
|
|
}
|
|
|
```
|
|
|
|
|
|
저장 키 목록 (원본 매핑):
|
|
|
|
|
|
| 원본(`ariT.`) | 이 phase(`ari.tasks.`) | 의미 | 기본값 |
|
|
|
|---|---|---|---|
|
|
|
| `view` | `view` | 'kanban' \| 'list' | `'kanban'` |
|
|
|
| `sel` | `sel` | 선택된 폴더/프로젝트/`'fav'` id | `'work'` |
|
|
|
| `expanded` | `expanded` | `{ [id]: boolean }` 펼침 상태 | `{ work:true, life:true }` |
|
|
|
| `pinned` | (서버 `project.pinned`로 이전) | 즐겨찾기 | — |
|
|
|
|
|
|
> 원본은 `pinned`도 localStorage였으나, MVP에서는 `project.pinned`(서버) + `POST /api/projects/{id}/pin` 토글로 이전한다. `expanded`/`sel`/`view`만 localStorage.
|
|
|
|
|
|
### 3.4 트리 유틸 — `frontend/lib/tasks/tree.ts`
|
|
|
|
|
|
원본 `tasks.jsx`의 트리 함수(`findPath`/`getNode`/`editTree`/`removeFromTree`/`stat`/`pct`/`dueDay`/`dueLabel`)와 `tasks-data.js`의 `buildIndex`를 TS로 이식. **편집 함수는 낙관적 업데이트(optimistic)용으로만 쓰고, 진짜 영속은 API**.
|
|
|
|
|
|
```ts
|
|
|
import type { Task, Project, Folder, Status } from '@/lib/types';
|
|
|
|
|
|
/* ---- 작업 트리 (무한 중첩) ---- */
|
|
|
export function findPath(nodes: Task[], id: string, trail: Task[] = []): Task[] | null {
|
|
|
for (const n of nodes) {
|
|
|
const t = [...trail, n];
|
|
|
if (n.id === id) return t;
|
|
|
if (n.children?.length) { const r = findPath(n.children, id, t); if (r) return r; }
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
export const getNode = (nodes: Task[], id: string): Task | null => {
|
|
|
const p = findPath(nodes, id);
|
|
|
return p ? p[p.length - 1] : null;
|
|
|
};
|
|
|
export function editTree(nodes: Task[], id: string, fn: (n: Task) => Task): Task[] {
|
|
|
return nodes.map((n) =>
|
|
|
n.id === id ? fn(n) : n.children?.length ? { ...n, children: editTree(n.children, id, fn) } : n,
|
|
|
);
|
|
|
}
|
|
|
export function removeFromTree(nodes: Task[], id: string): Task[] {
|
|
|
return nodes
|
|
|
.filter((n) => n.id !== id)
|
|
|
.map((n) => (n.children?.length ? { ...n, children: removeFromTree(n.children, id) } : n));
|
|
|
}
|
|
|
export const stat = (node: Task) => {
|
|
|
const c = node.children ?? [];
|
|
|
return { total: c.length, done: c.filter((x) => x.status === 'done').length };
|
|
|
};
|
|
|
export const pct = (node: Task) => {
|
|
|
const s = stat(node);
|
|
|
return s.total ? Math.round((s.done / s.total) * 100) : 0;
|
|
|
};
|
|
|
|
|
|
/* ---- 마감 표시 (ISO date 기준; 원본은 "06-08" → MVP는 "2026-06-08") ---- */
|
|
|
export const dueDay = (due: string | null): number => (due ? new Date(due).getUTCDate() : 99);
|
|
|
export const dueLabel = (due: string | null): string => {
|
|
|
if (!due) return '—';
|
|
|
const d = new Date(due);
|
|
|
return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`; // "6/8"
|
|
|
};
|
|
|
export const TODAY = 8; // 6월 8일 — 리스크/임박 계산 기준 (CONTRACT 고정)
|
|
|
export const isSoon = (t: Task) => t.status !== 'done' && dueDay(t.due) <= TODAY;
|
|
|
|
|
|
/* ---- 프로젝트 인덱스 (buildIndex 이식; tree.ts에서 사용) ---- */
|
|
|
export interface ProjIndexRec { id: string; name: string; tone: string; folder: boolean; parentId: string | null; depth: number; hasChildren: boolean; }
|
|
|
export function buildIndex(folders: Folder[]) {
|
|
|
const projects: ProjIndexRec[] = [];
|
|
|
const byId: Record<string, ProjIndexRec> = {};
|
|
|
const walk = (nodes: (Project | Folder)[], parentId: string | null, depth: number) => {
|
|
|
for (const n of nodes) {
|
|
|
const children = (n as Project).children ?? (n as Folder).projects ?? [];
|
|
|
const rec: ProjIndexRec = {
|
|
|
id: n.id, name: n.name, tone: (n as Project).tone ?? 'faint',
|
|
|
folder: 'is_system' in n, parentId, depth, hasChildren: children.length > 0,
|
|
|
};
|
|
|
projects.push(rec); byId[n.id] = rec;
|
|
|
if (children.length) walk(children, n.id, depth + 1);
|
|
|
}
|
|
|
};
|
|
|
walk(folders, null, 0);
|
|
|
const isDescendant = (pid: string, ancestorId: string): boolean => {
|
|
|
let cur: ProjIndexRec | undefined = byId[pid];
|
|
|
while (cur) { if (cur.id === ancestorId) return true; cur = cur.parentId ? byId[cur.parentId] : undefined; }
|
|
|
return false;
|
|
|
};
|
|
|
const pathOf = (id: string): ProjIndexRec[] => {
|
|
|
const out: ProjIndexRec[] = []; let cur: ProjIndexRec | undefined = byId[id];
|
|
|
while (cur) { out.unshift(cur); cur = cur.parentId ? byId[cur.parentId] : undefined; }
|
|
|
return out;
|
|
|
};
|
|
|
return { projects, byId, isDescendant, pathOf };
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> **필터의 핵심(원본 `inScope`)**: 폴더/프로젝트/즐겨찾기 모두 `isDescendant(task.project_id, selectedId)`로 처리한다. 백엔드 `GET /api/tasks`의 필터 우선순위(정본): `project_id` 지정 시 → 그 프로젝트(+하위 프로젝트) 작업, 아니고 `area` 지정 시 → 그 영역(`work|life`) 작업, 둘 다 없으면 → 전체. `status`/`assignee`는 추가 필터. 백엔드가 이 1차 필터를 하므로, 프론트는 검색어(`q`) 필터와 `fav` 스코프(여러 핀 프로젝트 OR)만 추가로 적용한다.
|
|
|
|
|
|
### 3.5 사이드바 — `components/tasks/TaskSidebar.tsx` + `TreeNode.tsx` + `TreeAdd.tsx`
|
|
|
|
|
|
원본 `tasks.jsx`의 `.subnav` 구조와 `TreeNode`/`TreeAdd` 로직을 이식. 상단 3개 영역: **업무(folder 아이콘)**, **개인(heart)**, **즐겨찾기(star)**.
|
|
|
|
|
|
핵심 동작(원본 그대로):
|
|
|
|
|
|
- 영역 행(`.tree-row.folder`)을 클릭 → `setSel(folder.id)` + `expanded[id]=true`(클릭 시 강제 펼침).
|
|
|
- 체브론 클릭은 `stopPropagation` 후 `toggle(id)`만 (선택과 분리).
|
|
|
- 선택된 폴더만 `.on` → 검은 배경(`--fill`/`--on-fill`). 비선택은 `--glass-2`.
|
|
|
- 프로젝트 노드(`TreeNode`)는 재귀, `paddingLeft = 8 + depth * 15`(px) — 무한 중첩 들여쓰기.
|
|
|
- 미완료 작업수 배지: 폴더는 `.tree-badge`, 프로젝트는 `.tree-count`.
|
|
|
- 호버 시 우측 별(`.tree-pin`) 노출, 핀이면 `--amber`로 항상 노출.
|
|
|
- 각 폴더 펼침 끝에 `TreeAdd`("새 프로젝트") 인라인.
|
|
|
|
|
|
```tsx
|
|
|
// components/tasks/TreeNode.tsx
|
|
|
'use client';
|
|
|
import { Icon } from '@/components/Icon';
|
|
|
import { cx } from '@/lib/cx';
|
|
|
import type { Project } from '@/lib/types';
|
|
|
|
|
|
interface Props {
|
|
|
node: Project; depth: number;
|
|
|
sel: string; onSelect: (id: string) => void;
|
|
|
expanded: Record<string, boolean>; onToggle: (id: string) => void;
|
|
|
onPin: (id: string) => void;
|
|
|
counts: Record<string, number>;
|
|
|
onAddProj: (parentId: string, name: string) => void;
|
|
|
}
|
|
|
export function TreeNode({ node, depth, sel, onSelect, expanded, onToggle, onPin, counts, onAddProj }: Props) {
|
|
|
const hasCh = node.children.length > 0;
|
|
|
const open = !!expanded[node.id];
|
|
|
return (
|
|
|
<>
|
|
|
<div
|
|
|
className={cx('tree-row', sel === node.id && 'on')}
|
|
|
style={{ paddingLeft: 8 + depth * 15 }}
|
|
|
onClick={() => onSelect(node.id)}
|
|
|
>
|
|
|
{hasCh ? (
|
|
|
<button className={cx('tree-chev', open && 'open')}
|
|
|
onClick={(e) => { e.stopPropagation(); onToggle(node.id); }} aria-label="펼치기">
|
|
|
<Icon name="chev" />
|
|
|
</button>
|
|
|
) : <span className="tree-chev-spacer" />}
|
|
|
<span className="tree-dot" style={{ background: `var(--${node.tone})` }} />
|
|
|
<span className="tree-name">{node.name}</span>
|
|
|
{counts[node.id] > 0 && <span className="tree-count">{counts[node.id]}</span>}
|
|
|
<button className={cx('tree-pin', node.pinned && 'on')}
|
|
|
onClick={(e) => { e.stopPropagation(); onPin(node.id); }} aria-label="즐겨찾기">
|
|
|
<Icon name="star" strokeWidth={node.pinned ? 0 : 1.9} />
|
|
|
</button>
|
|
|
</div>
|
|
|
{hasCh && open && node.children.map((c) => (
|
|
|
<TreeNode key={c.id} node={c} depth={depth + 1}
|
|
|
sel={sel} onSelect={onSelect} expanded={expanded} onToggle={onToggle}
|
|
|
onPin={onPin} counts={counts} onAddProj={onAddProj} />
|
|
|
))}
|
|
|
</>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// components/tasks/TreeAdd.tsx — 인라인 생성 (폴더/프로젝트 공용)
|
|
|
'use client';
|
|
|
import { useState } from 'react';
|
|
|
import { Icon } from '@/components/Icon';
|
|
|
|
|
|
export function TreeAdd({ depth, placeholder, onAdd }: { depth: number; placeholder: string; onAdd: (name: string) => void }) {
|
|
|
const [editing, setEditing] = useState(false);
|
|
|
const [val, setVal] = useState('');
|
|
|
const commit = () => { const v = val.trim(); if (v) onAdd(v); setVal(''); setEditing(false); };
|
|
|
if (!editing) {
|
|
|
return (
|
|
|
<button className="tree-add" style={{ paddingLeft: 8 + depth * 15 }} onClick={() => setEditing(true)}>
|
|
|
<Icon name="plus" /> {placeholder}
|
|
|
</button>
|
|
|
);
|
|
|
}
|
|
|
return (
|
|
|
<div className="tree-addrow" style={{ paddingLeft: 8 + depth * 15 }}>
|
|
|
<input autoFocus value={val} placeholder={placeholder}
|
|
|
onChange={(e) => setVal(e.target.value)}
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') { setVal(''); setEditing(false); } }}
|
|
|
onBlur={commit} />
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// components/tasks/TaskSidebar.tsx (요지)
|
|
|
'use client';
|
|
|
import { Fragment } from 'react';
|
|
|
import { Icon } from '@/components/Icon';
|
|
|
import { cx } from '@/lib/cx';
|
|
|
import { TreeNode } from './TreeNode';
|
|
|
import { TreeAdd } from './TreeAdd';
|
|
|
import type { Folder, Person } from '@/lib/types';
|
|
|
|
|
|
interface Props {
|
|
|
folders: Folder[]; me: Person;
|
|
|
sel: string; onSelect: (id: string) => void;
|
|
|
expanded: Record<string, boolean>; onToggle: (id: string) => void;
|
|
|
onPin: (id: string) => void;
|
|
|
counts: Record<string, number>; // 프로젝트/폴더 id → 미완료 수
|
|
|
pinnedProjects: Project[]; // pinned=true 프로젝트 평탄 목록
|
|
|
onAddProject: (folderId: string, name: string) => void;
|
|
|
}
|
|
|
export function TaskSidebar(p: Props) {
|
|
|
return (
|
|
|
<aside className="subnav">
|
|
|
<div className="sn-title">작업</div>
|
|
|
<div className="sn-scroll">
|
|
|
{/* 업무 / 개인 */}
|
|
|
{p.folders.map((f) => {
|
|
|
const open = !!p.expanded[f.id];
|
|
|
return (
|
|
|
<Fragment key={f.id}>
|
|
|
<div
|
|
|
className={cx('tree-row folder', p.sel === f.id && 'on')}
|
|
|
style={{ paddingLeft: 8 }}
|
|
|
onClick={() => { p.onSelect(f.id); if (!open) p.onToggle(f.id); }}
|
|
|
>
|
|
|
<button className={cx('tree-chev', open && 'open')}
|
|
|
onClick={(e) => { e.stopPropagation(); p.onToggle(f.id); }} aria-label="펼치기">
|
|
|
<Icon name="chev" />
|
|
|
</button>
|
|
|
<span className="tree-fold"><Icon name={f.icon || 'folder'} /></span>
|
|
|
<span className="tree-name">{f.name}</span>
|
|
|
<span className="tree-badge">{p.counts[f.id] || 0}</span>
|
|
|
</div>
|
|
|
{open && f.projects.map((n) => (
|
|
|
<TreeNode key={n.id} node={n} depth={1}
|
|
|
sel={p.sel} onSelect={p.onSelect} expanded={p.expanded} onToggle={p.onToggle}
|
|
|
onPin={p.onPin} counts={p.counts} onAddProj={(pid, name) => p.onAddProject(pid, name)} />
|
|
|
))}
|
|
|
{open && <TreeAdd depth={1} placeholder="새 프로젝트" onAdd={(name) => p.onAddProject(f.id, name)} />}
|
|
|
</Fragment>
|
|
|
);
|
|
|
})}
|
|
|
|
|
|
{/* 즐겨찾기 */}
|
|
|
<div className={cx('tree-row folder', p.sel === 'fav' && 'on')} style={{ paddingLeft: 8 }}
|
|
|
onClick={() => { p.onSelect('fav'); if (!p.expanded.fav) p.onToggle('fav'); }}>
|
|
|
<button className={cx('tree-chev', p.expanded.fav && 'open')}
|
|
|
onClick={(e) => { e.stopPropagation(); p.onToggle('fav'); }} aria-label="펼치기">
|
|
|
<Icon name="chev" />
|
|
|
</button>
|
|
|
<span className="tree-fold"><Icon name="star" /></span>
|
|
|
<span className="tree-name">즐겨찾기</span>
|
|
|
<span className="tree-badge">{p.pinnedProjects.length}</span>
|
|
|
</div>
|
|
|
{p.expanded.fav && (
|
|
|
p.pinnedProjects.length > 0
|
|
|
? p.pinnedProjects.map((pr) => (
|
|
|
<div key={pr.id} className={cx('tree-row', p.sel === pr.id && 'on')}
|
|
|
style={{ paddingLeft: 8 + 15 }} onClick={() => p.onSelect(pr.id)}>
|
|
|
<span className="tree-chev-spacer" />
|
|
|
<span className="tree-dot" style={{ background: `var(--${pr.tone})` }} />
|
|
|
<span className="tree-name">{pr.name}</span>
|
|
|
<button className="tree-pin on" onClick={(e) => { e.stopPropagation(); p.onPin(pr.id); }} aria-label="즐겨찾기 해제">
|
|
|
<Icon name="star" strokeWidth={0} />
|
|
|
</button>
|
|
|
</div>
|
|
|
))
|
|
|
: <div className="sn-empty" style={{ paddingLeft: 8 + 15 }}><Icon name="star" />제목 옆 별표를 눌러 모아두세요</div>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
<div className="sn-foot">
|
|
|
<div className="av">{p.me.initial}</div>
|
|
|
<div className="txt"><b>{p.me.name}님</b><span>Pro 플랜</span></div>
|
|
|
</div>
|
|
|
</aside>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> **문구 인용(원본 그대로)**: 사이드바 제목 `작업`, 폴더 라벨 `업무`/`개인`/`즐겨찾기`, 즐겨찾기 빈 상태 `제목 옆 별표를 눌러 모아두세요`, 새 프로젝트 버튼 `새 프로젝트`, 푸터 `지우님` / `Pro 플랜`.
|
|
|
|
|
|
### 3.6 메인 영역 — `TaskToolbar.tsx`
|
|
|
|
|
|
브레드크럼(`.crumbs`) + 슬림 헤더(`.thead-slim`) + 툴바(`.ttoolbar`: 뷰 세그·검색·새 작업).
|
|
|
|
|
|
- **브레드크럼**: `워크스페이스 › 업무` 또는 `워크스페이스 › 경영 전략 › 분기 리포트`. 프로젝트 경로는 `buildIndex().pathOf(sel)`로 계산, 각 단계는 클릭 시 `setSel`. 마지막 단계는 `.cur`(컬러 점 `--{tone}`).
|
|
|
- **슬림 헤더**: 좌측 아이콘(폴더면 `folder`/프로젝트 잎이면 `hash`/스코프면 `folder`/`heart`/`star`), `N개 작업 · M개 완료` 텍스트(`.ts-meta`), 우측 `즐겨찾기`/`즐겨찾기됨` 토글(`.th-pin`).
|
|
|
- **뷰 세그(`.view-seg`)**: `칸반`(grid 아이콘) · `리스트`(list 아이콘). `.on` = 검은 배경(`--fill`). CONTRACT의 SegmentToggle을 이 두 옵션으로 사용. (캘린더는 §4 참고)
|
|
|
- **검색(`.t-search`)**: `작업 검색…` placeholder. 입력값 `q`로 클라 필터.
|
|
|
- **새 작업(`.t-new`)**: `+ 새 작업` — `POST /api/tasks`(현재 스코프 대표 프로젝트, status=todo, title="새 작업") → 칸반으로 전환 → 생성 작업 드로어 자동 오픈.
|
|
|
|
|
|
```tsx
|
|
|
// 새 작업 대표 프로젝트 결정 (원본 onAdd 로직)
|
|
|
function repProject(sel: string, folders: Folder[]): string {
|
|
|
if (sel === 'work') return 'biz'; // 업무 대표
|
|
|
if (sel === 'life') return 'me'; // 개인 대표
|
|
|
if (sel === 'fav') return 'me'; // 즐겨찾기 스코프면 일상으로
|
|
|
return sel; // 프로젝트 선택 시 그 프로젝트
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 3.7 칸반 — `KanbanView.tsx` + `KanbanCard.tsx`
|
|
|
|
|
|
5컬럼 고정(라벨/accent는 `STATUS_META`):
|
|
|
|
|
|
| status | label | accent(`kcol-dot`) |
|
|
|
|---|---|---|
|
|
|
| `todo` | 할 일 | `var(--faint)` |
|
|
|
| `doing` | 진행 중 | `var(--blue)` |
|
|
|
| `waiting` | 대기 중 | `var(--violet)` |
|
|
|
| `review` | 검토 | `var(--coral)` |
|
|
|
| `done` | 완료 | `var(--green)` |
|
|
|
|
|
|
```tsx
|
|
|
// components/tasks/KanbanCard.tsx
|
|
|
'use client';
|
|
|
import { Icon } from '@/components/Icon';
|
|
|
import { cx } from '@/lib/cx';
|
|
|
import { stat, pct, dueLabel, isSoon } from '@/lib/tasks/tree';
|
|
|
import type { Task, Person, Project } from '@/lib/types';
|
|
|
|
|
|
export function KanbanCard({ task, people, projects, onOpen }: {
|
|
|
task: Task; people: Record<string, Person>; projects: Record<string, Project>;
|
|
|
onOpen: (id: string) => void;
|
|
|
}) {
|
|
|
const s = stat(task);
|
|
|
const soon = isSoon(task);
|
|
|
const proj = projects[task.project_id];
|
|
|
const person = people[task.assignee_id];
|
|
|
return (
|
|
|
<div className={cx('kcard', task.status === 'done' && 'done-card')} onClick={() => onOpen(task.id)}>
|
|
|
<div className="kcard-top">
|
|
|
{proj && (
|
|
|
<span className="ktag">
|
|
|
<span className="pdot" style={{ background: `var(--${proj.tone})` }} />
|
|
|
<span>{proj.name}</span>
|
|
|
</span>
|
|
|
)}
|
|
|
<button className="kmore" onClick={(e) => { e.stopPropagation(); onOpen(task.id); }} aria-label="더보기"><Icon name="more" /></button>
|
|
|
</div>
|
|
|
<div className="kcard-title">{task.title}</div>
|
|
|
{s.total > 0 && (
|
|
|
<div className="ksub">
|
|
|
<div className="bar"><span style={{ width: pct(task) + '%' }} /></div>
|
|
|
<span className="ksub-num"><Icon name="branch" /> {s.done}/{s.total}</span>
|
|
|
</div>
|
|
|
)}
|
|
|
<div className="kfoot">
|
|
|
<span className={cx('due', soon && 'soon')}><Icon name="cal" /> {dueLabel(task.due)}</span>
|
|
|
<span className={cx('prio-flag', `prio-${task.prio}`)}><Icon name="flag" /> {task.prio}</span>
|
|
|
{task.delegated && <span className="deleg-chip"><Icon name="users" />위임</span>}
|
|
|
{person && <div className="av" style={{ background: person.color }} title={person.name}>{person.initial}</div>}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// components/tasks/KanbanView.tsx (요지) — 컬럼별 빠른 추가 + (옵션)DnD
|
|
|
const COLUMNS = [
|
|
|
{ id: 'todo', label: '할 일', accent: 'var(--faint)' },
|
|
|
{ id: 'doing', label: '진행 중', accent: 'var(--blue)' },
|
|
|
{ id: 'waiting', label: '대기 중', accent: 'var(--violet)' },
|
|
|
{ id: 'review', label: '검토', accent: 'var(--coral)' },
|
|
|
{ id: 'done', label: '완료', accent: 'var(--green)' },
|
|
|
] as const;
|
|
|
|
|
|
export function KanbanView({ tasks, people, projects, onOpen, onMove, onAdd }: { /* ... */ }) {
|
|
|
// 각 컬럼 = tasks.filter(t => t.status === col.id)
|
|
|
// 헤더: kcol-dot(accent) + kcol-label + kcol-count + kcol-add(+)
|
|
|
// 빈 컬럼: "여기로 작업을 옮겨보세요"
|
|
|
// 빠른 추가 input: "작업 입력 후 Enter…" → onAdd(status, text)
|
|
|
}
|
|
|
```
|
|
|
|
|
|
**드래그앤드롭(선택 구현)**: 원본은 HTML5 `draggable` + `onDragStart/onDrop` + `onMove(dragId, col.id)`. MVP 권장 경로는 두 가지 — (a) 원본 DnD를 그대로 이식하되 드롭 시 `PATCH /api/tasks/{id} { status }` 호출, (b) DnD 생략하고 카드 `more`→드로어→상태 메뉴로 이동. **테스트(§7)는 (b) 경로(상태 메뉴)를 기준으로 하고, DnD 구현 시 추가 E2E를 더한다.** 어느 경우든 상태 변경은 서버 PATCH로 영속.
|
|
|
|
|
|
**onAdd(빠른 추가)**: `POST /api/tasks { title, project_id: repProject(sel), status, assignee_id:"jiwoo", prio:"보통", due:null }` → 응답을 트리에 prepend. 원본 빠른추가 input placeholder `작업 입력 후 Enter…` 유지.
|
|
|
|
|
|
### 3.8 리스트 — `ListView.tsx`
|
|
|
|
|
|
상태별 그룹(`columns` 순서), 각 그룹은 `마감 빠른 순` 정렬, 빈 그룹은 숨김. 테이블 컬럼: `작업 | 프로젝트 | 담당 | 마감 | 우선순위 | 진행`. 좁은 화면(`hide-sm`)에서 프로젝트/담당/우선순위 숨김.
|
|
|
|
|
|
```tsx
|
|
|
// 그리드: grid-template-columns: minmax(0,2.6fr) 1.1fr 70px 70px 86px 40px;
|
|
|
// 행 클릭 → onOpen(task.id)
|
|
|
// 헤더 라벨(원본 그대로): 작업 / 프로젝트 / 담당 / 마감 / 우선순위 / 진행
|
|
|
// 빈 상태: "표시할 작업이 없어요"
|
|
|
```
|
|
|
|
|
|
### 3.9 리스크 레이더 — `RiskRadar.tsx`
|
|
|
|
|
|
원본 `tasks-risk.jsx`의 **계산은 백엔드(`risk.py`)로 이식**(CONTRACT). 프론트는 `GET /api/risks?area=work`(기본 `work`) 결과를 렌더만 한다. **선택 스코프가 업무 폴더(`sel==='work'`, 즉 `area==='work'`)일 때만 표시**한다(원본: 업무 스코프에서만). 개인/즐겨찾기 스코프나 특정 프로젝트 선택 시에는 숨긴다.
|
|
|
|
|
|
백엔드 계산 규칙(원본 `computeRisks` 1:1 이식 — `phase-2-backend.md`에서 구현, 여기서 계약 명시):
|
|
|
|
|
|
1. **지연 위험(coral, clock)**: `status != done && due 있음 && dueDay(due) <= TODAY(8)` 중 1건. 문장: `**{title}** — 오늘({M/D}) 마감인데 아직 {시작 전|진행 중|대기 중|검토 중}이에요`. `task_id` 포함, cta=`작업 열기`.
|
|
|
2. **업무 쏠림(amber, scale)**: 미완료 작업을 담당자별 카운트, `top >= avg*1.5 && top >= 4`일 때 1건. 본인(`is_me`)이면 `미완료 작업 **N건**이 내게 몰려 있어요 — 팀 평균의 X배. 벅찬 작업은 **위임 & 추적**으로 넘겨보세요`, 타인이면 `**{name}님**에게 미완료 작업 **N건**이 몰려 있어요 — 평균의 X배, 일부 재배분을 추천드려요`. `task_id` 없음(cta 없음).
|
|
|
3. **의존성(violet, link)**: 제목 기반 `DEPS`(고정 2건) 중 둘 다 미완료인 가장 임박한 1건. `**{blocker}**이(가) 늦어지면 **{blocked}**({M/D})까지 함께 밀려요`. `task_id`=blocked, cta=`후속 작업 보기`.
|
|
|
|
|
|
```python
|
|
|
# backend/app/services/risk.py — DEPS (원본 동일, 제목 기반)
|
|
|
DEPS = [
|
|
|
{"blocker": "예산 섹션 작성", "blocked": "경영진 검토 요청 메일"},
|
|
|
{"blocker": "데이터 전처리 파이프라인", "blocked": "사용자 인터뷰 5건 정리"},
|
|
|
]
|
|
|
TODAY = 8 # 6월 8일
|
|
|
STATUS_LABEL = {"todo": "시작 전", "doing": "진행 중", "waiting": "대기 중", "review": "검토 중"}
|
|
|
# 정렬·임계값·문장은 tasks-risk.jsx와 동일하게 유지하고 결과는 risks[:3]
|
|
|
```
|
|
|
|
|
|
최대 3건. `text`의 강조어는 항상 `**...**`(마크다운 굵게)로 감싸 내려보내고(`risk.py`), 프론트는 `Bolded` 파서가 `**` → `<b>`만 변환(XSS 안전을 위해 dangerouslySetInnerHTML 금지, 텍스트 분할로 처리).
|
|
|
|
|
|
```tsx
|
|
|
// components/tasks/RiskRadar.tsx
|
|
|
'use client';
|
|
|
import { Icon } from '@/components/Icon';
|
|
|
import type { Risk } from '@/lib/types';
|
|
|
|
|
|
function Bolded({ text }: { text: string }) {
|
|
|
// "**A**B" → <b>A</b>B (정규식 split, dangerouslySetInnerHTML 미사용)
|
|
|
return <>{text.split(/(\*\*[^*]+\*\*)/).map((s, i) =>
|
|
|
s.startsWith('**') ? <b key={i}>{s.slice(2, -2)}</b> : <span key={i}>{s}</span>)}</>;
|
|
|
}
|
|
|
|
|
|
export function RiskRadar({ risks, onOpen }: { risks: Risk[]; onOpen: (id: string) => void }) {
|
|
|
if (!risks.length) return null;
|
|
|
return (
|
|
|
<div className="rradar">
|
|
|
<div className="rr-head">
|
|
|
<span className="rr-ic"><Icon name="radar" /></span>
|
|
|
<div className="rr-ht"><b>리스크 레이더</b><span>마감 · 업무량 · 의존성을 아리가 훑었어요</span></div>
|
|
|
</div>
|
|
|
<div className="rr-items">
|
|
|
{risks.map((r, i) => (
|
|
|
<div key={i} className={`rr-item t-${r.tone}`}>
|
|
|
<span className="rr-item-ic"><Icon name={r.icon} /></span>
|
|
|
<div className="rr-body">
|
|
|
<span className="rr-kind">{r.kind}</span>
|
|
|
<p className="rr-text"><Bolded text={r.text} /></p>
|
|
|
</div>
|
|
|
{r.task_id && r.cta && (
|
|
|
<button className="rr-cta" onClick={() => onOpen(r.task_id!)}>{r.cta}<Icon name="chev" /></button>
|
|
|
)}
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> **아이콘**: 리스크 아이콘(`radar`/`scale`/`link`)은 `phase-1` 중앙 paths 맵(`paths.ts`, 단일 출처)에 이미 포함되어 있으므로 `Icon name="radar|scale|link"`로 그대로 사용한다(여기서 별도 추가/정의하지 않는다). 참고용 path(원본 `tasks-risk.jsx`의 `RP` 딕셔너리 근거 — `phase-1`이 정본):
|
|
|
> - `radar`: `M19.07 4.93A10 10 0 0 0 6.99 3.34|M4 6h.01|M2.29 9.62a10 10 0 1 0 19.02-1.27|M16.24 7.76a6 6 0 1 0 1.07 7.04|M12 18h.01|M17.99 11.66a6 6 0 0 1-2.22 5.01|M12 12a2 2 0 1 0 4 0 2 2 0 0 0-4 0z|M13.41 10.59l5.66-5.66`
|
|
|
> - `scale`(원본 users): `M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|M22 21v-2a4 4 0 0 0-3-3.87|M16 3.13a4 4 0 0 1 0 7.75`
|
|
|
> - `link`(원본 branch): `M6 3v12|M18 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M15 12H9a3 3 0 0 0-3 3`
|
|
|
|
|
|
### 3.10 상세 드로어 — `DetailPanel.tsx` (+ `SubRow`, `RichNotes`, `Comments`, `ScaffoldPanel`)
|
|
|
|
|
|
원본 `DetailPanel`은 **포커스 기반 브레드크럼 드릴다운**: `focusId`(현재 보는 노드) + `rootId`(드로어를 연 최초 노드). 하위작업 행(`SubRow`)을 클릭하면 `focusId`가 그 하위로 바뀌고, 브레드크럼은 `rootId`부터 `focusId`까지 경로(`findPath`)를 보여준다.
|
|
|
|
|
|
레이아웃(`tasks.css`):
|
|
|
- `.dpanel`: `width:60vw; min-width:600px`, 우측 슬라이드인(`slidein 0.24s`), 백드롭(`.dp-backdrop` blur(3px)).
|
|
|
- `.dp-head`: 위로 버튼(`back`) + 브레드크럼(`.dp-crumbs`) + 프로젝트 태그(`ktag`) + 닫기(`x`).
|
|
|
- `.dp-grid`: 2열 `minmax(0,1fr) 340px`. 좌측(`.dp-main`): 제목/완료체크 → 노트 → 하위작업 → 코멘트. 우측(`.dp-rail`, `--card-2` 배경): 담당자/마감/우선순위/상태 + 위임.
|
|
|
- `.dp-foot`: 삭제(danger) / 상위로(crumbs>1) / 완료(primary).
|
|
|
- Esc → 닫기. `focusId` 변경 시 scaffold/adding/menu 리셋.
|
|
|
|
|
|
```tsx
|
|
|
// components/tasks/DetailPanel.tsx (구조 + 서버 연동 포인트만 발췌)
|
|
|
'use client';
|
|
|
import { useEffect, useState } from 'react';
|
|
|
import { Icon } from '@/components/Icon';
|
|
|
import { cx } from '@/lib/cx';
|
|
|
import { findPath, stat } from '@/lib/tasks/tree';
|
|
|
import { RichNotes } from './RichNotes';
|
|
|
import { Comments } from './Comments';
|
|
|
import { SubRow } from './SubRow';
|
|
|
import { ScaffoldPanel } from './ScaffoldPanel';
|
|
|
import type { Task, Person, Project } from '@/lib/types';
|
|
|
|
|
|
const STATUS_META: Record<string, { label: string; c: string }> = {
|
|
|
todo: { label: '할 일', c: 'var(--faint)' },
|
|
|
doing: { label: '진행 중', c: 'var(--blue)' },
|
|
|
waiting: { label: '대기 중', c: 'var(--violet)' },
|
|
|
review: { label: '검토', c: 'var(--coral)' },
|
|
|
done: { label: '완료', c: 'var(--green)' },
|
|
|
};
|
|
|
|
|
|
interface Props {
|
|
|
tasks: Task[]; focusId: string; rootId: string;
|
|
|
people: Record<string, Person>; projects: Record<string, Project>;
|
|
|
onFocus: (id: string) => void; onClose: () => void;
|
|
|
onCheck: (id: string) => void; // 완료 토글 → PATCH status
|
|
|
onField: (id: string, patch: Partial<Task>) => void; // 상태/담당/우선순위 → PATCH
|
|
|
onAddChild: (parentId: string, title: string) => void; // POST /api/tasks {parent_id}
|
|
|
onScaffold: (parentId: string) => void; // POST /api/tasks/{id}/scaffold {create:true}
|
|
|
onDelegate: (id: string, who: string) => void; // PATCH {assignee_id, status:'waiting', delegated:true}
|
|
|
onDelete: (id: string) => void; // DELETE
|
|
|
onNotes: (id: string, html: string) => void; // PATCH {notes} (debounce)
|
|
|
onAddComment: (id: string, text: string) => void; // POST /api/tasks/{id}/comments
|
|
|
}
|
|
|
|
|
|
export function DetailPanel(p: Props) {
|
|
|
const path = findPath(p.tasks, p.focusId) ?? [];
|
|
|
const node = path[path.length - 1];
|
|
|
const [menu, setMenu] = useState(false);
|
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
|
const [adding, setAdding] = useState(false);
|
|
|
|
|
|
useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') p.onClose(); };
|
|
|
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [p]);
|
|
|
useEffect(() => { setAdding(false); setMenu(false); }, [p.focusId]);
|
|
|
if (!node) return null;
|
|
|
|
|
|
const rootIdx = path.findIndex((n) => n.id === p.rootId);
|
|
|
const crumbs = rootIdx >= 0 ? path.slice(rootIdx) : path;
|
|
|
const person = p.people[node.assignee_id];
|
|
|
const s = stat(node); const sm = STATUS_META[node.status];
|
|
|
|
|
|
return (
|
|
|
<>
|
|
|
<div className="dp-backdrop" onClick={p.onClose} />
|
|
|
<aside className="dpanel" role="dialog" aria-label="작업 상세">
|
|
|
{/* dp-head: 위로 / 브레드크럼 / 프로젝트태그 / 닫기 */}
|
|
|
{/* dp-grid: 좌 main (제목·체크 / 노트 / 하위작업 / 코멘트) + 우 rail (담당/마감/우선순위/상태/위임) */}
|
|
|
{/* dp-foot: 삭제 / 상위로 / 완료 */}
|
|
|
</aside>
|
|
|
</>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
**핵심 인터랙션(원본 문구·동작 그대로)**:
|
|
|
|
|
|
| 요소 | 동작 | 서버 |
|
|
|
|---|---|---|
|
|
|
| `.dp-check` 완료 토글 | status `done` ↔ `todo` | `PATCH {status}` |
|
|
|
| 상태 pill `.status-pill` → `.status-menu` | 5개 상태 선택 | `PATCH {status}` |
|
|
|
| 담당자/마감/우선순위(`.dp-mi`) | 편집(드롭다운/날짜) | `PATCH {assignee_id\|due\|prio}` |
|
|
|
| 노트(`RichNotes`) | contentEditable, `자유롭게 메모하세요…` | `PATCH {notes}`(blur/debounce) |
|
|
|
| 하위작업(`SubRow`) | 펼침/체크/드릴다운 | check→`PATCH`, drill→`onFocus` |
|
|
|
| `하위 작업 추가` | input `하위 작업 입력 후 Enter…` | `POST /api/tasks {parent_id}` |
|
|
|
| Scaffold trigger | `아리에게 업무 쪼개기 맡기기` | `POST scaffold {create:false}` |
|
|
|
| Scaffold 적용 | `N개 하위 작업 모두 추가` | `POST scaffold {create:true}` |
|
|
|
| 코멘트 | `코멘트 추가…` 입력 | `POST /api/tasks/{id}/comments` |
|
|
|
| 위임(`.delegate-sug`) | `{name}님께 위임하기` | `PATCH {assignee_id, status:'waiting', delegated:true}` |
|
|
|
| 삭제(`.btn.danger`) | 노드 삭제 | `DELETE` |
|
|
|
|
|
|
**Auto-Scaffolding(원본 `pickScaffold` → 백엔드 `scaffold.py`)**: 트리거 클릭 → `POST /api/tasks/{id}/scaffold` body `{create:false, use_llm:false}` → `{kind, icon, items:[{title,est}], created:false, created_task_ids:[]}` 미리보기 패널(`.scaffold-panel`) 렌더 → `N개 하위 작업 모두 추가` 클릭 시 body `{create:true}`로 재호출 → 하위작업 생성(`created:true`, `created_task_ids[]` 반환). 트리거 문구 인용: 제목 `아리에게 업무 쪼개기 맡기기`, 설명 `이 작업을 단계별 하위 작업으로 자동 분해하고 예상 시간을 배분해요`, 패널 헤더 `{kind} 워크플로우 추천`, `{N}단계 · 예상 시간 자동 배분`. 백엔드 `pickScaffold` 정규식·템플릿은 `tasks-data.js`와 동일:
|
|
|
|
|
|
| 정규식 매치 | kind | 단계 수 |
|
|
|
|---|---|---|
|
|
|
| `(논문\|연구\|리서치\|실험\|학회\|발표\|조사\|인터뷰)` | 연구 · 논문 작성 | 5 |
|
|
|
| `(구현\|개발\|API\|로직\|컴포넌트\|버그\|배포\|인증\|앱)` | 개발 업무 | 4 |
|
|
|
| `(리포트\|문서\|보고\|자료\|작성\|초안\|회고\|정산)` | 문서 · 리포트 | 5 |
|
|
|
| (그 외) | 일반 업무 | 4 |
|
|
|
|
|
|
**위임 추천(원본 `suggestCollaborator`)**: 같은 프로젝트의 비-본인 담당자 중 최다 인원을 제안. 개인 프로젝트(`me`)/즐겨찾기는 제안 안 함. 본인 담당 & 미완료일 때만 추천. 문구: `직접 하기 벅차다면 이 프로젝트를 함께 하는 <b>{name}</b>님께 위임할 수 있어요. 넘긴 뒤엔 '대기 중'으로 바꿔 진행 상황을 추적해 드릴게요.` 위임됨 상태(`.delegated-note`): `{name}님께 위임됨` / `완료를 기다리는 중 · 끝나면 알려드릴게요`. 이 추천 계산은 클라에서 가능(현재 로드된 트리 기준)하나, 일관성을 위해 위임 실행은 `PATCH`로 영속.
|
|
|
|
|
|
**SubRow(재귀)**: `paddingLeft = 6 + depth * 22`(px). 트위스트(`chev` 회전), 체크박스(`tick`), 제목, 메타(`branch N/M`/`clock est`/`cal 마감`/아바타/드릴`chev`). 하위작업 없을 때 빈 상태: `아직 하위 작업이 없어요. 위 버튼으로 아리에게 맡기거나 직접 추가하세요.`
|
|
|
|
|
|
**RichNotes**: 툴바 `제목/굵게/기울임/밑줄/글머리 목록/번호 목록/인용`(`execCommand` 사용; MVP에선 deprecated이나 프로토타입과 동일하게 유지하거나 간단 마크다운으로 대체 가능). placeholder `자유롭게 메모하세요 — 제목, 목록, 인용을 섞어 쓸 수 있어요.` 저장은 `onChange`→debounce(800ms)→`PATCH {notes}`. **렌더 시 notes(HTML)는 신뢰된 자체 데이터지만, 사용자 입력을 그대로 저장/표시하므로 DOMPurify로 sanitize 후 dangerouslySetInnerHTML** 권장.
|
|
|
|
|
|
**Comments**: 빈 상태 `아직 코멘트가 없어요. 첫 의견을 남겨보세요.`, 입력 placeholder `코멘트 추가…`, 작성자 본인은 `(나)` 표기, 시간은 상대(`방금`/`2시간 전`/`어제`). `POST /api/tasks/{id}/comments {person_id:"jiwoo", text}`.
|
|
|
|
|
|
### 3.11 오케스트레이터 — `TasksClient.tsx`
|
|
|
|
|
|
원본 `App`의 상태/핸들러를 서버 데이터 + 낙관적 업데이트로 재구성.
|
|
|
|
|
|
```tsx
|
|
|
// components/tasks/TasksClient.tsx (요지)
|
|
|
'use client';
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
import useSWR, { mutate } from 'swr'; // 또는 react-query
|
|
|
import { tasksApi } from '@/lib/tasks/api';
|
|
|
import { loadLS, saveLS } from '@/lib/tasks/store';
|
|
|
import { buildIndex } from '@/lib/tasks/tree';
|
|
|
import type { Folder, Task, Risk, Person } from '@/lib/types';
|
|
|
|
|
|
export function TasksClient() {
|
|
|
// UI 상태 (localStorage)
|
|
|
const [view, setView] = useState<'kanban' | 'list'>(() => loadLS('view', 'kanban'));
|
|
|
const [sel, setSel ] = useState<string>(() => loadLS('sel', 'work'));
|
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>(() =>
|
|
|
({ work: true, life: true, ...loadLS('expanded', {}) }));
|
|
|
const [q, setQ] = useState('');
|
|
|
const [focusId, setFocusId] = useState<string | null>(null);
|
|
|
const [rootId, setRootId ] = useState<string | null>(null);
|
|
|
|
|
|
useEffect(() => saveLS('view', view), [view]);
|
|
|
useEffect(() => saveLS('sel', sel), [sel]);
|
|
|
useEffect(() => saveLS('expanded', expanded), [expanded]);
|
|
|
|
|
|
// 서버 데이터
|
|
|
const area: 'work' | 'life' | undefined =
|
|
|
sel === 'work' ? 'work' : sel === 'life' ? 'life' : undefined;
|
|
|
const { data: folders } = useSWR<Folder[]>('tree', tasksApi.tree);
|
|
|
const { data: people } = useSWR<Person[]>('people', tasksApi.people);
|
|
|
const { data: tasks } = useSWR<Task[]>(['tasks', area, sel],
|
|
|
() => tasksApi.list({ area, project_id: area ? undefined : (sel === 'fav' ? undefined : sel) }));
|
|
|
// 리스크 레이더는 업무 폴더 스코프(area==='work')일 때만 호출/표시 (원본: 업무 스코프 전용)
|
|
|
const { data: risks } = useSWR<Risk[]>(
|
|
|
area === 'work' ? ['risks', 'work'] : null,
|
|
|
() => tasksApi.risks('work'));
|
|
|
|
|
|
const ix = useMemo(() => folders ? buildIndex(folders) : null, [folders]);
|
|
|
// counts: ix.projects → task_count 매핑 (트리 응답에 포함)
|
|
|
// 필터: 검색 q + fav 스코프(여러 pinned OR)
|
|
|
// 핸들러: onMove/onField/onCheck/onAdd/onAddChild/onScaffold/onDelegate/onDelete/onNotes/onAddComment
|
|
|
// → 각각 tasksApi.* 호출 후 mutate(['tasks',...]) + (필요시) mutate(['risks','work'])
|
|
|
// → 낙관적: editTree로 즉시 반영, 실패 시 롤백
|
|
|
// ...
|
|
|
}
|
|
|
```
|
|
|
|
|
|
영속 규칙:
|
|
|
- **새 프로젝트**: `POST /api/projects { folder_id, parent_id?, name }` → `mutate('tree')`, `expanded[parentId]=true`. tone은 원본처럼 `TONES[name.length % 5]`로 프론트가 제안하거나 백엔드 기본값.
|
|
|
- **핀 토글**: `POST /api/projects/{id}/pin` → `mutate('tree')`.
|
|
|
- **상태 이동/필드 수정**: `PATCH` → `mutate(['tasks',...])`, 업무 스코프면 `mutate(['risks','work'])`(리스크 재계산).
|
|
|
- **새 작업/하위작업/scaffold/댓글/삭제**: 각 API 후 `mutate`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 4. 데이터/타입/API 계약 (이 phase 관련)
|
|
|
|
|
|
### 4.1 `GET /api/tree` 응답 예시
|
|
|
|
|
|
```json
|
|
|
[
|
|
|
{
|
|
|
"id": "work", "name": "업무", "tone": "ink", "icon": "folder",
|
|
|
"sort_order": 0, "is_system": true,
|
|
|
"projects": [
|
|
|
{ "id": "biz", "folder_id": "work", "parent_id": null, "name": "경영 전략",
|
|
|
"tone": "coral", "sort_order": 0, "pinned": false, "task_count": 5,
|
|
|
"children": [
|
|
|
{ "id": "biz-okr", "folder_id": "work", "parent_id": "biz", "name": "2026 Q2 OKR", "tone": "coral", "pinned": false, "task_count": 1, "children": [] },
|
|
|
{ "id": "biz-report", "folder_id": "work", "parent_id": "biz", "name": "분기 리포트", "tone": "coral", "pinned": false, "task_count": 1, "children": [] },
|
|
|
{ "id": "biz-budget", "folder_id": "work", "parent_id": "biz", "name": "예산 관리", "tone": "coral", "pinned": false, "task_count": 0, "children": [] }
|
|
|
] },
|
|
|
{ "id": "onb", "folder_id": "work", "parent_id": null, "name": "온보딩 리디자인",
|
|
|
"tone": "violet", "pinned": false, "task_count": 3,
|
|
|
"children": [
|
|
|
{ "id": "onb-research", "folder_id": "work", "parent_id": "onb", "name": "사용자 리서치", "tone": "violet", "pinned": false, "children": [] },
|
|
|
{ "id": "onb-wire", "folder_id": "work", "parent_id": "onb", "name": "와이어프레임", "tone": "violet", "pinned": true, "children": [] },
|
|
|
{ "id": "onb-voice", "folder_id": "work", "parent_id": "onb", "name": "음성 인터페이스", "tone": "violet", "pinned": false, "children": [] }
|
|
|
] },
|
|
|
{ "id": "team", "folder_id": "work", "parent_id": null, "name": "팀 운영", "tone": "green", "pinned": false, "children": [
|
|
|
{ "id": "team-hr", "folder_id": "work", "parent_id": "team", "name": "채용 & 온보딩", "tone": "green", "children": [] },
|
|
|
{ "id": "team-retro", "folder_id": "work", "parent_id": "team", "name": "회고", "tone": "green", "children": [] }
|
|
|
] }
|
|
|
]
|
|
|
},
|
|
|
{
|
|
|
"id": "life", "name": "개인", "tone": "blue", "icon": "heart", "is_system": true,
|
|
|
"projects": [
|
|
|
{ "id": "me", "folder_id": "life", "parent_id": null, "name": "일상", "tone": "blue", "pinned": false, "children": [] },
|
|
|
{ "id": "life-trip", "folder_id": "life", "parent_id": null, "name": "여행 — 한국", "tone": "coral", "pinned": false, "children": [] },
|
|
|
{ "id": "life-fam", "folder_id": "life", "parent_id": null, "name": "가족", "tone": "green", "pinned": false, "children": [] }
|
|
|
]
|
|
|
}
|
|
|
]
|
|
|
```
|
|
|
|
|
|
### 4.2 `GET /api/tasks?area=work` 응답 예시(요지)
|
|
|
|
|
|
```json
|
|
|
[
|
|
|
{
|
|
|
"id": "k1", "project_id": "biz-report", "parent_id": null,
|
|
|
"title": "분기 리포트 초안 마무리", "status": "doing", "assignee_id": "jiwoo",
|
|
|
"due": "2026-06-08", "prio": "높음",
|
|
|
"notes": "<h3>목표</h3><p>경영진 미팅용 Q2 성과 리포트...</p>",
|
|
|
"est": "", "delegated": false,
|
|
|
"comments": [
|
|
|
{ "id": "c1", "task_id": "k1", "person_id": "hyunwoo", "text": "매출 섹션은 제가 오늘 안으로 넘겨드릴게요.", "created_at": "2026-06-08T07:00:00Z" }
|
|
|
],
|
|
|
"children": [
|
|
|
{ "id": "kx7", "project_id": "biz-report", "parent_id": "k1", "title": "매출 섹션 작성",
|
|
|
"status": "doing", "assignee_id": "jiwoo", "due": "2026-06-09", "prio": "높음",
|
|
|
"children": [
|
|
|
{ "id": "kx8", "title": "MRR / ARR 표 정리", "status": "done", "assignee_id": "jiwoo", "children": [] },
|
|
|
{ "id": "kx9", "title": "전분기 대비 증감 코멘트", "status": "doing","assignee_id": "jiwoo", "children": [] },
|
|
|
{ "id": "kx10", "title": "예측 시나리오 3종", "status": "todo", "assignee_id": "jiwoo", "children": [] }
|
|
|
] }
|
|
|
]
|
|
|
}
|
|
|
]
|
|
|
```
|
|
|
|
|
|
### 4.3 `PATCH /api/tasks/{id}` (상태 이동)
|
|
|
|
|
|
```http
|
|
|
PATCH /api/tasks/kx9
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{ "status": "done" }
|
|
|
```
|
|
|
응답: 갱신된 `Task`(중첩 children 포함). 부모의 진행률(`stat`/`pct`)은 프론트에서 children 기반으로 재계산.
|
|
|
|
|
|
### 4.4 `POST /api/tasks/{id}/scaffold`
|
|
|
|
|
|
```http
|
|
|
POST /api/tasks/k6/scaffold # k6="OKR 중간 점검 자료 준비" → 문서·리포트? 아니면 generic
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{ "create": false, "use_llm": false }
|
|
|
```
|
|
|
```json
|
|
|
{ "kind": "일반 업무", "icon": "list", "items": [
|
|
|
{ "title": "범위 정의 & 자료 수집", "est": "1시간" },
|
|
|
{ "title": "초안 작성", "est": "2시간" },
|
|
|
{ "title": "리뷰 & 피드백 반영", "est": "1시간" },
|
|
|
{ "title": "최종 정리 & 공유", "est": "30분" }
|
|
|
], "created": false, "created_task_ids": [] }
|
|
|
```
|
|
|
body `{ "create": true }`면 위 items를 하위작업으로 생성하고 `{ "kind":..., "icon":..., "items":..., "created": true, "created_task_ids": ["...", ...] }` 반환.
|
|
|
|
|
|
### 4.5 `GET /api/risks?area=work`
|
|
|
|
|
|
```json
|
|
|
[
|
|
|
{ "kind": "지연 위험", "icon": "clock", "tone": "coral",
|
|
|
"text": "**분기 리포트 초안 마무리** — 오늘(6/8) 마감인데 아직 진행 중이에요",
|
|
|
"task_id": "k1", "cta": "작업 열기" },
|
|
|
{ "kind": "업무 쏠림", "icon": "scale", "tone": "amber",
|
|
|
"text": "미완료 작업 **8건**이 내게 몰려 있어요 — 팀 평균의 2.1배. 벅찬 작업은 **위임 & 추적**으로 넘겨보세요",
|
|
|
"task_id": null, "cta": null },
|
|
|
{ "kind": "의존성", "icon": "link", "tone": "violet",
|
|
|
"text": "**예산 섹션 작성**이(가) 늦어지면 **경영진 검토 요청 메일**(6/10)까지 함께 밀려요",
|
|
|
"task_id": "...", "cta": "후속 작업 보기" }
|
|
|
]
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5. 디자인 충실도 노트 (REF 인용)
|
|
|
|
|
|
기준 파일: `design-reference/assets/tasks.css`, `tasks.jsx`. 토큰은 `phase-1`에서 `styles/tokens.css`로 이식 완료된 것을 사용.
|
|
|
|
|
|
### 5.1 레이아웃·치수 (tasks.css 직접 인용)
|
|
|
|
|
|
| 요소 | 값 | 출처(tasks.css) |
|
|
|
|---|---|---|
|
|
|
| 페이지 컨테이너 `.tpage` | `max-width:1620px; padding:0 26px 30px` | L87 |
|
|
|
| 본문 `.twork` | `display:flex; gap:18px; padding-top:12px` | L162 |
|
|
|
| 사이드바 `.subnav` | `width:252px; sticky top:86px; max-height:calc(100vh-104px); border-radius:22px(--radius)` | L165-176 |
|
|
|
| 사이드바 제목 `.sn-title` | `font:Onest 19px/700; padding:4px 8px 12px` | L177-181 |
|
|
|
| 트리 행 `.tree-row` | `gap:7px; padding:7px 8px; border-radius:10px; font-size:13.5px` | L212-217 |
|
|
|
| 트리 들여쓰기 | `paddingLeft = 8 + depth*15` (px) | tasks.jsx L150,773 |
|
|
|
| 폴더 행 `.tree-row.folder` | `bg:var(--glass-2); border:1px var(--glass-brd); margin-top:6px; radius:11px` | L765-769 |
|
|
|
| 폴더 선택 `.tree-row.folder.on` | `bg:var(--fill); color:var(--on-fill); border:transparent` | L776-779 |
|
|
|
| 폴더 배지 `.tree-badge` | `font:11px/700; bg:var(--glass-2); radius:999px; padding:1px 8px; min-width:20px` | L810-815 |
|
|
|
| 칸반 보드 `.kboard` | `grid-auto-columns:minmax(264px,1fr); gap:14px; overflow-x:auto` | L356-360 |
|
|
|
| 칸반 컬럼 `.kcol` | `bg:var(--glass); radius:22px; padding:12px 11px; min-height:120px` | L363-369 |
|
|
|
| 칸반 드롭오버 `.kcol.over` | `box-shadow ...0 0 0 2px rgba(79,114,224,.25)` | L370 |
|
|
|
| 작업 카드 `.kcard` | `bg:var(--card); border:1px var(--line); radius:14px; padding:12px 13px; gap:9px` | L381-386 |
|
|
|
| 카드 호버 | `transform:translateY(-2px); box-shadow:var(--shadow)` | L387 |
|
|
|
| 완료 카드 `.done-card` | `opacity:.72; title line-through color:var(--muted)` | L389-390 |
|
|
|
| 진행 바 `.bar` | `height:5px; radius:99px; bg:var(--line); fill:var(--ink-2)` | L352-353 |
|
|
|
| 리스트 그리드 `.lhead/.lrow` | `grid: minmax(0,2.6fr) 1.1fr 70px 70px 86px 40px; padding:0 18px` | L430-434 |
|
|
|
| 리스트 행 높이 `.lrow` | `min-height:56px; border-bottom:1px var(--line)` | L438 |
|
|
|
| 드로어 `.dpanel` | `width:60vw; min-width:600px; border-left:1px var(--line); slidein 0.24s` | L460-468 |
|
|
|
| 드로어 그리드 `.dp-grid` | `grid: minmax(0,1fr) 340px` | L516 |
|
|
|
| 드로어 레일 `.dp-rail` | `border-left:1px var(--line); bg:var(--card-2)` | L518 |
|
|
|
| 드로어 제목 `.dp-title` | `font:Onest 26px/700; line-height:1.22` | L519 |
|
|
|
| 하위행 들여쓰기 `.subrow` | `paddingLeft = 6 + depth*22` (px) | tasks.jsx L395 |
|
|
|
| 리스크 레이더 `.rradar` | `gap:16px; padding:13px 16px; bg:var(--glass); radius:14px(--radius-sm)` | L820-826 |
|
|
|
| 리스크 아이콘 `.rr-ic` | `36×36; radius:11px; bg:var(--fill); color:var(--coral)` | L828-831 |
|
|
|
| 리스크 카드 톤 | `.t-coral→--coral / .t-amber→--amber / .t-violet→--violet` | L843-845 |
|
|
|
|
|
|
### 5.2 색·상태 (tasks.jsx / tasks-data.js 인용)
|
|
|
|
|
|
- 상태 컬럼 라벨·accent: `할 일/--faint`, `진행 중/--blue`, `대기 중/--violet`, `검토/--coral`, `완료/--green` (tasks-data.js L84-90, tasks.jsx L82-88).
|
|
|
- 우선순위 색: `.prio-높음→--coral`, `.prio-보통→--amber`, `.prio-낮음→--muted` (tasks.css L349-351).
|
|
|
- 마감 임박 `.due.soon`: `color:var(--coral); font-weight:600` — `dueDay(due) <= 8 && status!=done` (tasks.css L346, tasks.jsx L198).
|
|
|
- 위임 칩 `.deleg-chip`: `color:var(--violet); bg:color-mix(violet 13%)` (tasks.css L641-647).
|
|
|
- 사람 색: 지우 `--blue`, 현우 `--violet`, 민서 `--green`, 재호 `--coral`, 수아 `oklch(0.66 0.13 200)` (tasks-data.js L76-82).
|
|
|
|
|
|
### 5.3 한국어 문구 (그대로 사용)
|
|
|
|
|
|
| 위치 | 문구 |
|
|
|
|---|---|
|
|
|
| 사이드바 제목 | `작업` |
|
|
|
| 폴더 라벨 | `업무` · `개인` · `즐겨찾기` |
|
|
|
| 새 프로젝트 | `새 프로젝트` |
|
|
|
| 즐겨찾기 빈 상태 | `제목 옆 별표를 눌러 모아두세요` |
|
|
|
| 슬림 헤더 | `{N}개 작업 · {M}개 완료` · `즐겨찾기` / `즐겨찾기됨` |
|
|
|
| 검색 | `작업 검색…` |
|
|
|
| 새 작업 버튼 | `새 작업` |
|
|
|
| 칸반 빠른추가 | `작업 입력 후 Enter…` |
|
|
|
| 칸반 빈 컬럼 | `여기로 작업을 옮겨보세요` |
|
|
|
| 리스트 빈 상태 | `표시할 작업이 없어요` |
|
|
|
| 리스트 헤더 | `작업 / 프로젝트 / 담당 / 마감 / 우선순위 / 진행` |
|
|
|
| 리스크 레이더 헤더 | `리스크 레이더` / `마감 · 업무량 · 의존성을 아리가 훑었어요` |
|
|
|
| 리스크 CTA | `작업 열기` / `후속 작업 보기` |
|
|
|
| Scaffold 트리거 | `아리에게 업무 쪼개기 맡기기` / `이 작업을 단계별 하위 작업으로 자동 분해하고 예상 시간을 배분해요` |
|
|
|
| Scaffold 적용 | `{N}개 하위 작업 모두 추가` |
|
|
|
| 하위작업 빈 상태 | `아직 하위 작업이 없어요. 위 버튼으로 아리에게 맡기거나 직접 추가하세요.` |
|
|
|
| 하위작업 추가 | `하위 작업 추가` / `하위 작업 입력 후 Enter…` |
|
|
|
| 노트 placeholder | `자유롭게 메모하세요 — 제목, 목록, 인용을 섞어 쓸 수 있어요.` |
|
|
|
| 코멘트 | `코멘트 추가…` / `아직 코멘트가 없어요. 첫 의견을 남겨보세요.` |
|
|
|
| 위임 추천 | `직접 하기 벅차다면 … {name}님께 위임할 수 있어요. … '대기 중'으로 바꿔 진행 상황을 추적해 드릴게요.` |
|
|
|
| 위임됨 | `{name}님께 위임됨` / `완료를 기다리는 중 · 끝나면 알려드릴게요` |
|
|
|
| 드로어 푸터 | `상위로` / `완료` |
|
|
|
| 사이드바 푸터 | `{name}님` / `Pro 플랜` |
|
|
|
|
|
|
### 5.4 애니메이션
|
|
|
|
|
|
- 진입: `.entered .twork > * { rise 0.45s }`, `.tmain` delay 0.05s (L741-743).
|
|
|
- 드로어: `.dp-backdrop fade 0.18s` + `.dpanel slidein 0.24s cubic-bezier(0.2,0.7,0.2,1)` (L457,467).
|
|
|
- scaffold 패널: `rise 0.3s` (L675).
|
|
|
- `prefers-reduced-motion`에서는 애니메이션 제거(접근성, §7 axe).
|
|
|
|
|
|
### 5.5 반응형 (tasks.css L746-761)
|
|
|
|
|
|
- `≤1080px`: `.subnav` 숨김, 리스크 레이더 세로 배치(`.rradar flex-direction:column`).
|
|
|
- `≤860px`: 드로어 `width:100vw`, `.dp-grid` 1열.
|
|
|
- `≤760px`: `.mainnav` 숨김, 리스트 컬럼 축소(`hide-sm` 제거 → 4열).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 6. 상태 처리 (로딩/빈/에러/오프라인) & 엣지 케이스
|
|
|
|
|
|
| 상황 | 처리 |
|
|
|
|---|---|
|
|
|
| **초기 로딩** | 사이드바: 폴더 3개 스켈레톤(`.tree-row` 골격 펄스). 메인: 칸반 5컬럼 헤더 + 카드 3~4개 스켈레톤(`shimmer`). 리스크 레이더: 로드 전 미표시. |
|
|
|
| **빈 영역(작업 0)** | 칸반: 각 컬럼 `여기로 작업을 옮겨보세요`. 리스트: `표시할 작업이 없어요`. |
|
|
|
| **즐겨찾기 비었을 때** | 사이드바 `제목 옆 별표를 눌러 모아두세요`. 메인: 핀 0개면 `즐겨찾기한 프로젝트가 없어요` 안내 + 별표 방법. |
|
|
|
| **검색 결과 0** | `"{q}"에 해당하는 작업이 없어요` (q 강조). |
|
|
|
| **API 에러(트리/작업)** | 인라인 에러 배너 `작업을 불러오지 못했어요` + `다시 시도` 버튼(`mutate`). 사이드바는 마지막 캐시 유지. |
|
|
|
| **리스크 API 에러** | 레이더 영역 조용히 숨김(핵심 작업 흐름 방해 금지). |
|
|
|
| **PATCH 실패(상태 이동)** | 낙관적 업데이트 롤백 + 토스트 `상태를 바꾸지 못했어요`. |
|
|
|
| **scaffold 미가용(LLM off)** | 백엔드 heuristic(`pickScaffold`) 폴백으로 항상 응답. 그래도 실패 시 `지금은 자동 분해를 못 했어요` + 수동 추가 안내. |
|
|
|
| **오프라인** | `navigator.onLine=false` 시 상단 배너 `오프라인 — 변경은 연결되면 반영돼요`. 쓰기 차단 또는 큐잉(MVP는 차단 + 안내). |
|
|
|
| **노트 동시 편집** | 단일 유저 가정. blur 시 PATCH, 마지막 쓰기 우선. |
|
|
|
| **삭제한 작업이 드로어 포커스** | `onDelete`: rootId면 드로어 닫기, 하위면 부모로 `focusId` 이동(원본 동작). |
|
|
|
| **순환 참조 방지** | 하위작업 추가 시 자기 자신/조상에 붙이지 못하게 백엔드 검증(400). |
|
|
|
| **due가 과거/없음** | `dueLabel` `—`, soon 계산은 `dueDay<=8`만. |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 7. 테스팅 & 검증 (가장 중요)
|
|
|
|
|
|
### 7.1 실행 명령
|
|
|
|
|
|
```bash
|
|
|
# 백엔드 (리스크 서비스 정확성 — 가장 중요)
|
|
|
cd backend && uv run pytest tests/test_risks.py tests/test_tasks.py tests/test_scaffold.py -v
|
|
|
|
|
|
# 프론트 컴포넌트 (Vitest + RTL)
|
|
|
cd frontend && pnpm test # 전체
|
|
|
cd frontend && pnpm test tasks # 작업 관련만
|
|
|
|
|
|
# E2E (Playwright) — 백엔드+프론트 dev 서버 기동 후
|
|
|
cd frontend && pnpm playwright test playwright/tasks.spec.ts
|
|
|
|
|
|
# 접근성 (axe) — Playwright 통합
|
|
|
cd frontend && pnpm playwright test playwright/tasks.a11y.spec.ts
|
|
|
```
|
|
|
|
|
|
### 7.2 백엔드 테스트 — 리스크 정확성 (pytest)
|
|
|
|
|
|
원본 `computeRisks`를 시드 데이터로 검증. **반드시 포함**:
|
|
|
|
|
|
```python
|
|
|
# backend/tests/test_risks.py
|
|
|
def test_risk_delay_top1(client):
|
|
|
# k1(분기 리포트 초안 마무리, due 6/8, doing) → 지연 위험 1건
|
|
|
r = client.get("/api/risks?area=work").json()
|
|
|
delay = [x for x in r if x["kind"] == "지연 위험"]
|
|
|
assert len(delay) == 1
|
|
|
assert delay[0]["tone"] == "coral" and delay[0]["icon"] == "clock"
|
|
|
assert delay[0]["task_id"] == "k1"
|
|
|
assert "6/8" in delay[0]["text"] and "진행 중" in delay[0]["text"]
|
|
|
assert delay[0]["cta"] == "작업 열기"
|
|
|
|
|
|
def test_risk_dependency(client):
|
|
|
# 예산 섹션 작성(미완) → 경영진 검토 요청 메일(미완) : violet, cta="후속 작업 보기"
|
|
|
r = client.get("/api/risks?area=work").json()
|
|
|
dep = [x for x in r if x["kind"] == "의존성"]
|
|
|
assert len(dep) == 1 and dep[0]["tone"] == "violet"
|
|
|
assert "예산 섹션 작성" in dep[0]["text"] and "경영진 검토 요청 메일" in dep[0]["text"]
|
|
|
assert dep[0]["cta"] == "후속 작업 보기"
|
|
|
|
|
|
def test_risk_overload_threshold(client):
|
|
|
# 업무 쏠림: top >= avg*1.5 && top >= 4 일 때만. 임계 미만이면 없음.
|
|
|
r = client.get("/api/risks?area=work").json()
|
|
|
over = [x for x in r if x["kind"] == "업무 쏠림"]
|
|
|
assert len(over) <= 1
|
|
|
if over:
|
|
|
assert over[0]["tone"] == "amber" and over[0]["task_id"] is None
|
|
|
|
|
|
def test_risks_max_three(client):
|
|
|
assert len(client.get("/api/risks?area=work").json()) <= 3
|
|
|
|
|
|
def test_risks_life_scope_empty_or_no_delay(client):
|
|
|
# 개인 스코프는 리스크 레이더 비표시 대상(프론트), 백엔드는 area=life 별도 처리
|
|
|
r = client.get("/api/risks?area=life")
|
|
|
assert r.status_code == 200
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# backend/tests/test_tasks.py — 필터/이동/CRUD
|
|
|
def test_tasks_filter_by_area(client):
|
|
|
work = client.get("/api/tasks?area=work").json()
|
|
|
ids = collect_project_ids(work) # 모든 노드의 project_id 수집
|
|
|
assert all(p.startswith(("biz","onb","team")) for p in ids)
|
|
|
|
|
|
def test_tasks_filter_by_project(client):
|
|
|
rep = client.get("/api/tasks?project_id=biz-report").json()
|
|
|
assert any(t["id"] == "k1" for t in rep)
|
|
|
|
|
|
def test_patch_status_persists(client):
|
|
|
client.patch("/api/tasks/k4", json={"status": "done"})
|
|
|
assert client.get("/api/tasks/k4").json()["status"] == "done"
|
|
|
|
|
|
def test_create_subtask_parent(client):
|
|
|
body = {"title":"새 하위","project_id":"biz-report","parent_id":"k1","status":"todo"}
|
|
|
child = client.post("/api/tasks", json=body).json()
|
|
|
parent = client.get("/api/tasks/k1").json()
|
|
|
assert any(c["id"] == child["id"] for c in parent["children"])
|
|
|
|
|
|
def test_cannot_parent_to_self_or_descendant(client):
|
|
|
res = client.patch("/api/tasks/k1", json={"parent_id":"k1"})
|
|
|
assert res.status_code == 400
|
|
|
|
|
|
def test_add_comment(client):
|
|
|
c = client.post("/api/tasks/k1/comments", json={"person_id":"jiwoo","text":"확인"}).json()
|
|
|
assert c["person_id"] == "jiwoo"
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# backend/tests/test_scaffold.py — pickScaffold 정규식
|
|
|
import pytest
|
|
|
@pytest.mark.parametrize("title,kind", [
|
|
|
("사용자 인터뷰 5건 정리", "연구 · 논문 작성"),
|
|
|
("API 엔드포인트 개발", "개발 업무"),
|
|
|
("분기 리포트 작성", "문서 · 리포트"),
|
|
|
("장보기", "일반 업무"),
|
|
|
])
|
|
|
def test_pick_scaffold(client, title, kind):
|
|
|
t = client.post("/api/tasks", json={"title":title,"project_id":"me"}).json()
|
|
|
out = client.post(f"/api/tasks/{t['id']}/scaffold", json={"create": False}).json()
|
|
|
assert out["kind"] == kind and len(out["items"]) >= 4
|
|
|
assert out["created"] is False and out["created_task_ids"] == []
|
|
|
|
|
|
def test_scaffold_apply_creates_children(client):
|
|
|
t = client.post("/api/tasks", json={"title":"개발 업무 x","project_id":"me"}).json()
|
|
|
out = client.post(f"/api/tasks/{t['id']}/scaffold", json={"create": True}).json()
|
|
|
parent = client.get(f"/api/tasks/{t['id']}").json()
|
|
|
assert out["created"] is True
|
|
|
assert len(parent["children"]) == len(out["items"]) == len(out["created_task_ids"])
|
|
|
```
|
|
|
|
|
|
### 7.3 프론트 컴포넌트 테스트 (Vitest + RTL)
|
|
|
|
|
|
```ts
|
|
|
// tests/tasks/tree.test.ts — 트리 유틸
|
|
|
test('findPath 무한 중첩 경로', () => { /* k1 > kx7 > kx9 경로 길이 3 */ });
|
|
|
test('editTree 깊은 노드 갱신', () => { /* kx9 status done → 변경, 형제 불변 */ });
|
|
|
test('stat/pct 진행률', () => { /* children 2done/3total → pct 67 */ });
|
|
|
test('isDescendant 필터', () => { /* isDescendant("biz-report","work")===true */ });
|
|
|
test('dueLabel ISO → "6/8"', () => { expect(dueLabel('2026-06-08')).toBe('6/8'); });
|
|
|
test('isSoon: due<=8 && !done', () => { /* 06-08 todo → true, 06-08 done → false */ });
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// tests/tasks/TaskSidebar.test.tsx
|
|
|
test('폴더 펼침/접힘', () => {
|
|
|
// 업무 행 체브론 클릭 → 하위 프로젝트(경영 전략/온보딩 리디자인/팀 운영) 표시
|
|
|
});
|
|
|
test('폴더 선택 시 .on(검은배경)', () => {
|
|
|
// 업무 클릭 → tree-row.folder.on 클래스 + aria 선택
|
|
|
});
|
|
|
test('프로젝트 선택 → onSelect 호출', () => { /* 분기 리포트 클릭 */ });
|
|
|
test('핀 토글 호출', () => { /* star 버튼 클릭 → onPin("onb-wire") */ });
|
|
|
test('즐겨찾기 빈 상태 문구', () => {
|
|
|
// pinnedProjects=[] → "제목 옆 별표를 눌러 모아두세요"
|
|
|
});
|
|
|
test('새 프로젝트 인라인 입력 Enter → onAddProject', () => { /* TreeAdd commit */ });
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// tests/tasks/KanbanCard.test.tsx
|
|
|
test('카드: 제목/프로젝트태그/담당아바타/마감/우선순위 렌더', () => {});
|
|
|
test('하위작업 있으면 진행바+N/M 표시', () => {});
|
|
|
test('마감 임박(06-08, doing) → .due.soon', () => {});
|
|
|
test('완료 카드 → .done-card + line-through', () => {});
|
|
|
test('위임 → .deleg-chip "위임"', () => {});
|
|
|
test('우선순위 클래스 prio-높음/보통/낮음', () => {});
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// tests/tasks/RiskRadar.test.tsx
|
|
|
test('risks=[] → null 렌더', () => {});
|
|
|
test('3종 카드 tone 클래스(t-coral/t-amber/t-violet)', () => {});
|
|
|
test('**굵게** → <b> 변환(dangerouslySetInnerHTML 미사용)', () => {});
|
|
|
test('task_id 있으면 CTA 버튼, 클릭 시 onOpen(id)', () => {});
|
|
|
test('업무쏠림(task_id null)은 CTA 없음', () => {});
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// tests/tasks/ListView.test.tsx
|
|
|
test('상태별 그룹, 빈 그룹 숨김', () => {});
|
|
|
test('마감 빠른 순 정렬', () => {});
|
|
|
test('행 클릭 → onOpen', () => {});
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// tests/tasks/DetailPanel.test.tsx
|
|
|
test('브레드크럼: root→focus 경로', () => {});
|
|
|
test('완료 체크 → onCheck', () => {});
|
|
|
test('상태 메뉴에서 선택 → onField({status})', () => {});
|
|
|
test('하위작업 추가 Enter → onAddChild', () => {});
|
|
|
test('SubRow 드릴다운 → onFocus(child)', () => {});
|
|
|
test('scaffold 트리거 → ScaffoldPanel, 적용 → onScaffold', () => {});
|
|
|
test('코멘트 입력 Enter → onAddComment', () => {});
|
|
|
test('위임 추천 노출 조건(본인+미완+같은프로젝트 동료)', () => {});
|
|
|
test('Esc → onClose', () => {});
|
|
|
```
|
|
|
|
|
|
### 7.4 E2E (Playwright) — `playwright/tasks.spec.ts`
|
|
|
|
|
|
> 백엔드는 시드 DB(테스트용 별도 SQLite)로 기동. 각 테스트 전 DB 리셋(seed) 권장.
|
|
|
|
|
|
```ts
|
|
|
test('초기 진입: 업무 스코프 + 칸반 + 리스크 레이더', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await expect(page.locator('.tree-row.folder.on .tree-name')).toHaveText('업무');
|
|
|
await expect(page.locator('.kboard .kcol')).toHaveCount(5);
|
|
|
await expect(page.locator('.rradar')).toBeVisible();
|
|
|
await expect(page.locator('.rr-ht b')).toHaveText('리스크 레이더');
|
|
|
});
|
|
|
|
|
|
test('폴더 펼침 → 프로젝트 필터', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await page.locator('.tree-row.folder', { hasText: '업무' }).locator('.tree-chev').click();
|
|
|
await page.locator('.tree-row', { hasText: '분기 리포트' }).click();
|
|
|
// 분기 리포트 작업만 (k1) 보임, 다른 프로젝트 작업 안 보임
|
|
|
await expect(page.locator('.kcard-title', { hasText: '분기 리포트 초안 마무리' })).toBeVisible();
|
|
|
await expect(page.locator('.kcard-title', { hasText: '사용자 인터뷰 5건 정리' })).toHaveCount(0);
|
|
|
await expect(page.locator('.crumbs .cur')).toContainText('분기 리포트');
|
|
|
});
|
|
|
|
|
|
test('새 프로젝트 생성 (API 영속)', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await page.locator('.tree-add', { hasText: '새 프로젝트' }).first().click();
|
|
|
await page.locator('.tree-addrow input').fill('신규 프로젝트');
|
|
|
await page.locator('.tree-addrow input').press('Enter');
|
|
|
await expect(page.locator('.tree-name', { hasText: '신규 프로젝트' })).toBeVisible();
|
|
|
await page.reload(); // 새로고침 후에도 유지 → 서버 영속 확인
|
|
|
await expect(page.locator('.tree-name', { hasText: '신규 프로젝트' })).toBeVisible();
|
|
|
});
|
|
|
|
|
|
test('뷰 전환 칸반 ↔ 리스트', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await page.locator('.view-seg button', { hasText: '리스트' }).click();
|
|
|
await expect(page.locator('.lgroups')).toBeVisible();
|
|
|
await expect(page.locator('.lgroup-head')).toHaveCountGreaterThan(0);
|
|
|
await page.locator('.view-seg button', { hasText: '칸반' }).click();
|
|
|
await expect(page.locator('.kboard')).toBeVisible();
|
|
|
});
|
|
|
|
|
|
test('작업 상태 이동 API 왕복 + 영속', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await page.locator('.kcard', { hasText: '구독 결제 카드 갱신' }).click();
|
|
|
await page.locator('.status-pill').click();
|
|
|
await page.locator('.status-menu button', { hasText: '완료' }).click();
|
|
|
await page.locator('.dp-close').click();
|
|
|
// 완료 컬럼에 카드 이동 확인
|
|
|
await expect(page.locator('.kcol', { hasText: '완료' }).locator('.kcard', { hasText: '구독 결제 카드 갱신' })).toBeVisible();
|
|
|
await page.reload();
|
|
|
await expect(page.locator('.kcol', { hasText: '완료' }).locator('.kcard', { hasText: '구독 결제 카드 갱신' })).toBeVisible();
|
|
|
});
|
|
|
|
|
|
test('드로어 열기 → 하위작업 드릴다운 → 댓글', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await page.locator('.kcard', { hasText: '분기 리포트 초안 마무리' }).click();
|
|
|
await expect(page.locator('.dpanel')).toBeVisible();
|
|
|
await page.locator('.subrow', { hasText: '매출 섹션 작성' }).click();
|
|
|
await expect(page.locator('.dp-crumbs .cz.cur')).toHaveText('매출 섹션 작성');
|
|
|
// 댓글
|
|
|
await page.locator('.dp-crumbs .cz', { hasText: '분기 리포트 초안 마무리' }).click();
|
|
|
await page.locator('.cmt-compose input').fill('진행 상황 공유드립니다');
|
|
|
await page.locator('.cmt-compose input').press('Enter');
|
|
|
await expect(page.locator('.cmt-text', { hasText: '진행 상황 공유드립니다' })).toBeVisible();
|
|
|
});
|
|
|
|
|
|
test('리스크 레이더 CTA → 작업 드로어 스크롤/열기', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await page.locator('.rr-item.t-coral .rr-cta', { hasText: '작업 열기' }).click();
|
|
|
await expect(page.locator('.dpanel .dp-title')).toContainText('분기 리포트 초안 마무리');
|
|
|
});
|
|
|
|
|
|
test('Auto-Scaffolding 미리보기 → 적용', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await page.locator('.kcard', { hasText: 'OKR 중간 점검 자료 준비' }).click();
|
|
|
await page.locator('.scaffold-trigger', { hasText: '아리에게 업무 쪼개기 맡기기' }).click();
|
|
|
await expect(page.locator('.scaffold-panel .sp-item')).toHaveCountGreaterThan(0);
|
|
|
const n = await page.locator('.scaffold-panel .sp-item').count();
|
|
|
await page.locator('.sp-apply').click();
|
|
|
await expect(page.locator('.subrow')).toHaveCount(n + /* 기존 하위 수 */ 2);
|
|
|
});
|
|
|
|
|
|
test('즐겨찾기: 핀 토글 → 즐겨찾기 스코프 필터', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await page.locator('.tree-row', { hasText: '와이어프레임' }).locator('.tree-pin').click();
|
|
|
await page.locator('.tree-row.folder', { hasText: '즐겨찾기' }).click();
|
|
|
await expect(page.locator('.tree-name', { hasText: '와이어프레임' })).toBeVisible();
|
|
|
});
|
|
|
```
|
|
|
|
|
|
### 7.5 접근성 (axe) — `playwright/tasks.a11y.spec.ts`
|
|
|
|
|
|
```ts
|
|
|
import AxeBuilder from '@axe-core/playwright';
|
|
|
test('작업 페이지 a11y (칸반)', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
const r = await new AxeBuilder({ page }).analyze();
|
|
|
expect(r.violations).toEqual([]);
|
|
|
});
|
|
|
test('드로어 a11y (role=dialog, 포커스 트랩, Esc)', async ({ page }) => {
|
|
|
await page.goto('/tasks');
|
|
|
await page.locator('.kcard').first().click();
|
|
|
await expect(page.locator('[role=dialog][aria-label="작업 상세"]')).toBeVisible();
|
|
|
const r = await new AxeBuilder({ page }).include('.dpanel').analyze();
|
|
|
expect(r.violations).toEqual([]);
|
|
|
await page.keyboard.press('Escape');
|
|
|
await expect(page.locator('.dpanel')).toHaveCount(0);
|
|
|
});
|
|
|
```
|
|
|
|
|
|
체크: 모든 아이콘 버튼 `aria-label`, 드로어 `role="dialog"`+`aria-label`, 포커스 트랩, 색 대비(`--muted`/`--faint` on glass 4.5:1), `prefers-reduced-motion` 애니메이션 제거.
|
|
|
|
|
|
### 7.6 수동 QA 체크리스트
|
|
|
|
|
|
- [ ] `/tasks` 진입 시 업무 스코프 + 칸반 + 리스크 레이더 3건 이내 표시.
|
|
|
- [ ] 사이드바: 업무/개인/즐겨찾기 체브론 펼침, 무한 중첩(경영 전략 > 분기 리포트) 들여쓰기 정확.
|
|
|
- [ ] 선택 폴더만 검은 배경(`--fill`), 나머지 옅은 회색(`--glass-2`).
|
|
|
- [ ] 폴더/프로젝트 클릭 시 메인 작업 필터 + 브레드크럼 갱신 + 작업수 배지 정확.
|
|
|
- [ ] 즐겨찾기 비었을 때 안내 문구, 핀 후 즐겨찾기 스코프에서 보임, 새로고침 후 유지.
|
|
|
- [ ] 새 프로젝트 인라인 추가 → 즉시 트리 반영 + 새로고침 유지(서버).
|
|
|
- [ ] 칸반 카드: 제목/태그/담당/마감(임박 coral)/우선순위/진행바/위임칩 디자인 원본 일치.
|
|
|
- [ ] 칸반 빠른 추가(+) → Enter로 작업 생성, 컬럼에 즉시 표시.
|
|
|
- [ ] 상태 이동(드로어 상태 메뉴 또는 DnD) → 컬럼 이동 + 새로고침 유지.
|
|
|
- [ ] 리스트 뷰: 상태별 그룹, 마감순 정렬, 빈 그룹 숨김, hide-sm 반응형.
|
|
|
- [ ] 드로어: 60vw 슬라이드인, 브레드크럼 드릴다운, 노트 편집/저장, 하위작업 펼침/체크/추가, 댓글 등록, 담당/마감/우선순위/상태 편집 영속.
|
|
|
- [ ] Scaffold: 트리거 → 미리보기(kind/단계/예상시간) → 모두 추가 → 하위작업 생성.
|
|
|
- [ ] 위임 추천(본인+미완+같은 프로젝트 동료)만 노출, 위임 후 대기 중 + 위임됨 노트.
|
|
|
- [ ] 리스크 CTA 클릭 → 해당 작업 드로어 오픈.
|
|
|
- [ ] 테마 토글(라이트/다크) 시 모든 토큰 정상 전환.
|
|
|
- [ ] 반응형: 1080px 사이드바 숨김, 860px 드로어 전체폭, 760px 메인내비 숨김.
|
|
|
- [ ] 빈/로딩/에러/오프라인 상태 모두 안내 표시.
|
|
|
|
|
|
### 7.7 통과 기준
|
|
|
|
|
|
- 백엔드 pytest 전부 통과(특히 `test_risks.py` 3종 + max3 + 의존성 + 쏠림 임계).
|
|
|
- 프론트 Vitest 전부 통과.
|
|
|
- Playwright E2E 9개 시나리오 + a11y 2개 통과.
|
|
|
- axe violations 0건.
|
|
|
- 수동 QA 전 항목 체크.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 8. 완료 기준 (Definition of Done)
|
|
|
|
|
|
- [ ] `/tasks` 라우트가 셸(Topbar) 안에서 렌더되고 메인내비 `작업` 탭 active.
|
|
|
- [ ] 폴더 트리 사이드바(업무/개인/즐겨찾기) + 무한 중첩 프로젝트 펼침/선택 동작.
|
|
|
- [ ] 필터(area/project/fav) + 검색 + 브레드크럼 + 작업수 배지 정확.
|
|
|
- [ ] 칸반(5컬럼) · 리스트(상태별 그룹) 2뷰 전환 동작, SegmentToggle 영속.
|
|
|
- [ ] 작업 카드 픽셀 재현(제목/담당/마감/우선순위/진행/위임).
|
|
|
- [ ] 작업 상세 드로어: 노트(HTML)/댓글/하위작업 트리/메타 편집/scaffold/위임/삭제, 브레드크럼 드릴다운.
|
|
|
- [ ] 리스크 레이더: `GET /api/risks?area=work` → 지연/쏠림/의존성 카드, CTA로 드로어 열기, 계산이 `tasks-risk.jsx`와 동일.
|
|
|
- [ ] 생성(작업/하위작업/프로젝트)·수정(상태/필드/노트/댓글)·삭제가 모두 API로 **영속**(새로고침 유지).
|
|
|
- [ ] UI 상태(선택/펼침/뷰)는 `ari.tasks.*` localStorage 영속.
|
|
|
- [ ] 로딩/빈/에러/오프라인 상태 처리.
|
|
|
- [ ] 모든 테스트(§7) 통과 + axe 0건.
|
|
|
- [ ] 디자인 토큰/문구/치수가 §5 표와 일치.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 9. 다음 단계
|
|
|
|
|
|
작업 페이지가 완성되면 **`phase-4-inbox.md`**(인박스)로 진행한다. 인박스는 캡처 → 분류 → **작업으로 실체화(federation)**할 때 이 phase에서 만든 `POST /api/tasks`/`GET /api/tree`/작업 트리 구조를 재사용한다(인박스 `confirm` → `task` 생성 + `materialized_task_id` 연결). 또한 인박스에서 만든 작업(`k20` 한국행 비행기 티켓 등)이 이 작업 페이지의 `life-trip` 프로젝트에 그대로 나타나는 연합 흐름을 `phase-6-integration.md`에서 E2E로 검증한다.
|
|
|
|
|
|
페이지 빌드 순서: **작업(이 문서) → 인박스(`phase-4-inbox.md`) → 대시보드(`phase-5-dashboard.md`) → 통합(`phase-6-integration.md`)**.
|