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
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>
|
|
);
|
|
}
|