// frontend/components/tasks/TasksClient.tsx — 작업 페이지 오케스트레이터 "use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "next/navigation"; 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 = { 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("work"); const [expanded, setExpanded] = useState>({ work: true, life: true, biz: true, onb: true }); const [q, setQ] = useState(""); const [hydrated, setHydrated] = useState(false); const [focusId, setFocusId] = useState(null); const [rootId, setRootId] = useState(null); const shellRef = useRef(null); const notesTimer = useRef | null>(null); const didMount = useRef(false); // URL 로 열린 작업을 표현 — /tasks/ 세그먼트(옵셔널 캐치올). // 주소 갱신은 router 대신 history.replaceState(얕은 갱신) — router.replace 는 이 라우트를 // 리마운트시켜 뷰/선택 상태가 날아가고 open 루프가 발생하기 때문. const params = useParams(); const routeId = Array.isArray(params.slug) ? params.slug[0] : undefined; // 클라이언트에서만 localStorage 복원 (SSR 안정) useEffect(() => { setView(loadLS("view", "kanban")); setSel(loadLS("sel", "work")); setExpanded({ work: true, life: true, biz: true, onb: true, ...loadLS>("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("tasks/tree", tasksApi.tree); const { data: people } = useSWR("tasks/people", tasksApi.people); const { data: allTasks, error: tasksErr, isLoading: tasksLoading, } = useSWR("tasks/all", () => tasksApi.list()); const ix = useMemo(() => (folders ? buildIndex(folders) : null), [folders]); const projRecord = useMemo>(() => { const rec: Record = {}; if (folders) flattenProjects(folders).forEach((p) => (rec[p.id] = p)); return rec; }, [folders]); const peopleRecord = useMemo>(() => { const rec: Record = {}; (people || []).forEach((p) => (rec[p.id] = p)); return rec; }, [people]); const me = useMemo( () => (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( activeSphere === "work" ? "risks/work" : null, () => tasksApi.risks("work"), ); // counts (top-level 미완료 작업 기준) const counts = useMemo>(() => { const c: Record = {}; 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); }; // URL → 작업: routeId(주소의 )가 바뀌면 해당 작업 상세를 연다(직접 입력·뒤로가기·딥링크). useEffect(() => { if (!routeId) { close(); return; } if (routeId === focusId) return; // 이미 열려 있음 open(routeId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [routeId]); // 작업 → URL: focusId 가 바뀌면 주소를 맞춘다(초기 마운트는 위 effect 가 처리하므로 skip). useEffect(() => { if (!didMount.current) { didMount.current = true; return; } const target = focusId ? `/tasks/${focusId}` : "/tasks"; if (window.location.pathname !== target) window.history.replaceState(null, "", target); }, [focusId]); 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) => { 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 onEditComment = async (id: string, cid: string, text: string) => { await tasksApi.editComment(id, cid, text); mutate("tasks/all"); }; const onDeleteComment = async (id: string, cid: string) => { await tasksApi.deleteComment(id, cid); mutate("tasks/all"); }; // 같은 컬럼 내 드래그 정렬 → sort_order 재배치(낙관적 후 refresh). const onReorder = async (ids: string[]) => { await tasksApi.reorder(ids); refresh(); }; 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 (
{folders && people ? ( ) : ( )}
{/* 브레드크럼 */}
{isScope ? ( <> {SCOPES[sel].label} ) : ( path.map((pr, i) => ( {i === path.length - 1 ? ( {pr.name} ) : ( )} )) )}
{/* 슬림 헤더 */}
{scopeAll.length}개 작업 · {doneCount}개 완료 {rec && ( )}
{/* 툴바 */}
setQ(e.target.value)} placeholder="작업 검색…" />
{/* 에러 */} {(treeErr || tasksErr) && (
작업을 불러오지 못했어요
)} {/* 리스크 레이더 — 업무 스코프에서만 */} {activeSphere === "work" && risks && } {/* 본문 */} {tasksLoading && !allTasks ? (
{[0, 1, 2, 3, 4].map((i) => (
))}
) : view === "kanban" ? ( ) : ( )}
{focusId && ( )}
); }