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

422 lines
13 KiB
TypeScript

// frontend/components/tasks/TasksClient.tsx — 작업 페이지 오케스트레이터
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { Icon } from "@/components/Icon";
import { cx } from "@/lib/cx";
import { tasksApi } from "@/lib/tasks/api";
import { loadLS, saveLS } from "@/lib/tasks/store";
import { buildIndex, findPath } from "@/lib/tasks/tree";
import type { Folder, Person, Project, Risk, Status, Task } from "@/lib/types";
import { DetailPanel } from "./DetailPanel";
import { KanbanView } from "./KanbanView";
import { ListView } from "./ListView";
import { RiskRadar } from "./RiskRadar";
import { TaskSidebar } from "./TaskSidebar";
const SCOPES: Record<string, { label: string; icon: string }> = {
work: { label: "업무", icon: "folder" },
life: { label: "개인", icon: "heart" },
fav: { label: "즐겨찾기", icon: "star" },
};
function flattenProjects(folders: Folder[]): Project[] {
const out: Project[] = [];
const walk = (ps: Project[]) => {
for (const p of ps) {
out.push(p);
if (p.children?.length) walk(p.children);
}
};
folders.forEach((f) => walk(f.projects));
return out;
}
export function TasksClient() {
const { mutate } = useSWRConfig();
// UI 상태 (localStorage)
const [view, setView] = useState<"kanban" | "list">("kanban");
const [sel, setSel] = useState<string>("work");
const [expanded, setExpanded] = useState<Record<string, boolean>>({ work: true, life: true, biz: true, onb: true });
const [q, setQ] = useState("");
const [hydrated, setHydrated] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null);
const [rootId, setRootId] = useState<string | null>(null);
const shellRef = useRef<HTMLDivElement>(null);
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 클라이언트에서만 localStorage 복원 (SSR 안정)
useEffect(() => {
setView(loadLS("view", "kanban"));
setSel(loadLS("sel", "work"));
setExpanded({
work: true,
life: true,
biz: true,
onb: true,
...loadLS<Record<string, boolean>>("expanded", {}),
});
setHydrated(true);
}, []);
useEffect(() => {
if (hydrated) saveLS("view", view);
}, [view, hydrated]);
useEffect(() => {
if (hydrated) saveLS("sel", sel);
}, [sel, hydrated]);
useEffect(() => {
if (hydrated) saveLS("expanded", expanded);
}, [expanded, hydrated]);
useEffect(() => {
const el = shellRef.current;
const id = requestAnimationFrame(() => el && el.classList.add("entered"));
return () => cancelAnimationFrame(id);
}, []);
// 서버 데이터
const { data: folders, error: treeErr } = useSWR<Folder[]>("tasks/tree", tasksApi.tree);
const { data: people } = useSWR<Person[]>("tasks/people", tasksApi.people);
const {
data: allTasks,
error: tasksErr,
isLoading: tasksLoading,
} = useSWR<Task[]>("tasks/all", () => tasksApi.list());
const ix = useMemo(() => (folders ? buildIndex(folders) : null), [folders]);
const projRecord = useMemo<Record<string, Project>>(() => {
const rec: Record<string, Project> = {};
if (folders) flattenProjects(folders).forEach((p) => (rec[p.id] = p));
return rec;
}, [folders]);
const peopleRecord = useMemo<Record<string, Person>>(() => {
const rec: Record<string, Person> = {};
(people || []).forEach((p) => (rec[p.id] = p));
return rec;
}, [people]);
const me = useMemo<Person>(
() =>
(people || []).find((p) => p.is_me) || {
id: "jiwoo",
name: "지우",
initial: "지",
color: "var(--blue)",
is_me: true,
},
[people],
);
const pinnedProjects = useMemo(
() => (folders ? flattenProjects(folders).filter((p) => p.pinned) : []),
[folders],
);
const tasks = useMemo(() => allTasks || [], [allTasks]);
// 스코프 판정
const sphereOf = (id: string) => {
if (!ix) return null;
const path = ix.pathOf(id);
return path.length ? path[0].id : null;
};
const activeSphere =
sel === "fav" ? null : sel === "work" || sel === "life" ? sel : sphereOf(sel);
// 리스크 — 업무 스코프에서만
const { data: risks } = useSWR<Risk[]>(
activeSphere === "work" ? "risks/work" : null,
() => tasksApi.risks("work"),
);
// counts (top-level 미완료 작업 기준)
const counts = useMemo<Record<string, number>>(() => {
const c: Record<string, number> = {};
if (!ix) return c;
for (const rec of ix.projects) {
c[rec.id] = tasks.filter(
(t) => t.status !== "done" && ix.isDescendant(t.project_id, rec.id),
).length;
}
return c;
}, [ix, tasks]);
// 스코프 필터
const inScope = (t: Task): boolean => {
if (!ix) return false;
if (sel === "fav") {
return pinnedProjects.some((pr) => ix.isDescendant(t.project_id, pr.id));
}
return ix.isDescendant(t.project_id, sel);
};
const scopeAll = tasks.filter(inScope);
const doneCount = scopeAll.filter((t) => t.status === "done").length;
const filtered = scopeAll.filter(
(t) => !q.trim() || t.title.toLowerCase().includes(q.trim().toLowerCase()),
);
const isScope = !!SCOPES[sel];
const rec = isScope || !ix ? null : ix.byId[sel];
const path = isScope || !ix ? [] : ix.pathOf(sel);
// ---- 핸들러 ----
const open = (id: string) => {
setRootId(id);
setFocusId(id);
};
const close = () => {
setRootId(null);
setFocusId(null);
};
const refresh = () => {
mutate("tasks/all");
mutate("tasks/tree");
if (activeSphere === "work") mutate("risks/work");
};
const onMove = async (id: string, status: Status) => {
await tasksApi.patch(id, { status });
refresh();
};
const onField = async (id: string, patch: Partial<Task>) => {
await tasksApi.patch(id, patch);
refresh();
};
const onCheck = async (id: string) => {
const node = findPath(tasks, id)?.slice(-1)[0];
if (!node) return;
await tasksApi.patch(id, { status: node.status === "done" ? "todo" : "done" });
refresh();
};
const repProject = (): string => {
if (sel === "work") return "biz";
if (sel === "life" || sel === "fav") return "me";
return sel;
};
const onAdd = async (status: Status, title: string) => {
const created = await tasksApi.create({
title,
project_id: repProject(),
status,
assignee_id: "jiwoo",
prio: "보통",
});
refresh();
return created.id;
};
const newTask = async () => {
const id = await onAdd("todo", "새 작업");
setView("kanban");
setTimeout(() => open(id), 80);
};
const onAddChild = async (parentId: string, title: string) => {
const parent = findPath(tasks, parentId)?.slice(-1)[0];
await tasksApi.create({
title,
project_id: parent ? parent.project_id : "me",
parent_id: parentId,
status: "todo",
assignee_id: parent?.assignee_id || "jiwoo",
prio: "보통",
});
refresh();
};
const onDelegate = async (id: string, who: string) => {
await tasksApi.patch(id, { assignee_id: who, status: "waiting", delegated: true });
refresh();
};
const onDelete = async (id: string) => {
const pth = findPath(tasks, id);
const parent = pth && pth.length > 1 ? pth[pth.length - 2] : null;
await tasksApi.remove(id);
refresh();
if (id === rootId) close();
else if (parent) setFocusId(parent.id);
};
const onNotes = (id: string, html: string) => {
if (notesTimer.current) clearTimeout(notesTimer.current);
notesTimer.current = setTimeout(() => {
tasksApi.patch(id, { notes: html }).then(() => mutate("tasks/all"));
}, 800);
};
const onAddComment = async (id: string, text: string) => {
await tasksApi.addComment(id, { person_id: "jiwoo", text });
mutate("tasks/all");
};
const onAddProject = async (folderId: string, name: string) => {
await tasksApi.createProject({ folder_id: folderId, name });
setExpanded((e) => ({ ...e, [folderId]: true }));
mutate("tasks/tree");
};
const onPin = async (id: string) => {
await tasksApi.pinProject(id);
mutate("tasks/tree");
};
const onToggle = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }));
const slimIcon = rec
? rec.hasChildren
? "folder"
: "hash"
: isScope
? SCOPES[sel].icon
: "inbox";
return (
<div ref={shellRef}>
<div className="twork">
{folders && people ? (
<TaskSidebar
folders={folders}
me={me}
sel={sel}
onSelect={setSel}
expanded={expanded}
onToggle={onToggle}
onPin={onPin}
counts={counts}
pinnedProjects={pinnedProjects}
onAddProject={onAddProject}
/>
) : (
<aside className="subnav">
<div className="sn-title"></div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{[0, 1, 2].map((i) => (
<div key={i} className="tsk-skel" style={{ height: 36 }} />
))}
</div>
</aside>
)}
<main className="tmain">
{/* 브레드크럼 */}
<div className="crumbs">
<button onClick={() => setSel(activeSphere || "work")}></button>
{isScope ? (
<>
<span className="sep">
<Icon name="chev" />
</span>
<span className="cur">{SCOPES[sel].label}</span>
</>
) : (
path.map((pr, i) => (
<span key={pr.id} style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
<span className="sep">
<Icon name="chev" />
</span>
{i === path.length - 1 ? (
<span className="cur">
<span className="c-dot" style={{ background: `var(--${pr.tone})` }} /> {pr.name}
</span>
) : (
<button onClick={() => setSel(pr.id)}>{pr.name}</button>
)}
</span>
))
)}
</div>
{/* 슬림 헤더 */}
<div className="thead-slim">
<span
className="ts-ic"
style={rec ? { color: `var(--${rec.tone})` } : { color: "var(--muted)" }}
>
<Icon name={slimIcon as never} />
</span>
<span className="ts-meta">
{scopeAll.length} · {doneCount}
</span>
{rec && (
<button
className={cx("th-pin", projRecord[sel]?.pinned && "on")}
onClick={() => onPin(sel)}
>
<Icon name="star" w={projRecord[sel]?.pinned ? 0 : 1.9} fill={projRecord[sel]?.pinned ? "currentColor" : "none"} />{" "}
{projRecord[sel]?.pinned ? "즐겨찾기됨" : "즐겨찾기"}
</button>
)}
</div>
{/* 툴바 */}
<div className="ttoolbar">
<div className="view-seg">
<button className={cx(view === "kanban" && "on")} onClick={() => setView("kanban")}>
<Icon name="grid" />
</button>
<button className={cx(view === "list" && "on")} onClick={() => setView("list")}>
<Icon name="list" />
</button>
</div>
<div className="t-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="작업 검색…" />
</div>
<button className="t-new" onClick={newTask}>
<Icon name="plus" />
</button>
</div>
{/* 에러 */}
{(treeErr || tasksErr) && (
<div className="tsk-error">
<Icon name="x" />
<button onClick={() => refresh()}> </button>
</div>
)}
{/* 리스크 레이더 — 업무 스코프에서만 */}
{activeSphere === "work" && risks && <RiskRadar risks={risks} onOpen={open} />}
{/* 본문 */}
{tasksLoading && !allTasks ? (
<div className="kboard">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="tsk-skel" style={{ height: 200 }} />
))}
</div>
) : view === "kanban" ? (
<KanbanView
tasks={filtered}
people={peopleRecord}
projects={projRecord}
onOpen={open}
onMove={onMove}
onAdd={onAdd}
/>
) : (
<ListView
tasks={filtered}
people={peopleRecord}
projects={projRecord}
onOpen={open}
/>
)}
</main>
</div>
{focusId && (
<DetailPanel
tasks={tasks}
focusId={focusId}
rootId={rootId}
people={peopleRecord}
projects={projRecord}
me={me}
onFocus={setFocusId}
onClose={close}
onCheck={onCheck}
onField={onField}
onAddChild={onAddChild}
onScaffolded={refresh}
onDelegate={onDelegate}
onDelete={onDelete}
onNotes={onNotes}
onAddComment={onAddComment}
/>
)}
</div>
);
}