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.

317 lines
11 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// frontend/components/tasks/DetailPanel.tsx — 작업 상세 드로어 (포커스 드릴다운)
"use client";
import { Fragment, useEffect, useState } from "react";
import { Icon } from "@/components/Icon";
import { cx } from "@/lib/cx";
import { COLUMNS, STATUS_META, dueDay, findPath, flattenTasks, stat } from "@/lib/tasks/tree";
import type { Person, Project, Status, Task } from "@/lib/types";
import { Av, Tag } from "./bits";
import { Comments } from "./Comments";
import { RichNotes } from "./RichNotes";
import { ScaffoldPanel } from "./ScaffoldPanel";
import { SubRow } from "./SubRow";
/** 같은 프로젝트를 함께 하는 비-본인 담당자 중 최다 인원 제안 (원본 suggestCollaborator) */
function suggestCollaborator(tasks: Task[], projectId: string): string | null {
if (!projectId || projectId === "me") return null; // 개인 프로젝트 제외
const counts: Record<string, number> = {};
for (const n of flattenTasks(tasks)) {
if (n.assignee_id && n.assignee_id !== "jiwoo" && n.project_id === projectId) {
counts[n.assignee_id] = (counts[n.assignee_id] || 0) + 1;
}
}
const ppl = Object.keys(counts);
if (!ppl.length) return null;
ppl.sort((a, b) => counts[b] - counts[a]);
return ppl[0];
}
interface Props {
tasks: Task[];
focusId: string;
rootId: string | null;
people: Record<string, Person>;
projects: Record<string, Project>;
me: Person;
onFocus: (id: string) => void;
onClose: () => void;
onCheck: (id: string) => void;
onField: (id: string, patch: Partial<Task>) => void;
onAddChild: (parentId: string, title: string) => void;
onScaffolded: () => void;
onDelegate: (id: string, who: string) => void;
onDelete: (id: string) => void;
onNotes: (id: string, html: string) => void;
onAddComment: (id: string, text: string) => void;
}
export function DetailPanel(p: Props) {
const [menu, setMenu] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [adding, setAdding] = useState(false);
const [subText, setSubText] = useState("");
const path = findPath(p.tasks, p.focusId) ?? [];
const node = path[path.length - 1];
useEffect(() => {
setAdding(false);
setMenu(false);
}, [p.focusId]);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") p.onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [p]);
if (!node) return null;
const rootIdx = p.rootId ? path.findIndex((n) => n.id === p.rootId) : -1;
const crumbs = rootIdx >= 0 ? path.slice(rootIdx) : path;
const person = node.assignee_id ? p.people[node.assignee_id] : null;
const s = stat(node);
const sm = STATUS_META[node.status];
const proj = p.projects[node.project_id];
const comments = node.comments || [];
const onExpand = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }));
const submitSub = () => {
if (subText.trim()) p.onAddChild(node.id, subText.trim());
setSubText("");
setAdding(false);
};
const collabWho =
node.assignee_id === "jiwoo" && node.status !== "done"
? suggestCollaborator(p.tasks, node.project_id)
: null;
return (
<>
<div className="dp-backdrop" onClick={p.onClose} />
<aside className="dpanel" role="dialog" aria-label="작업 상세" aria-modal="true">
<div className="dp-head">
<button
className="dp-up"
onClick={() => (crumbs.length > 1 ? p.onFocus(crumbs[crumbs.length - 2].id) : p.onClose())}
aria-label="위로"
>
<Icon name="back" />
</button>
<div className="dp-crumbs">
{crumbs.map((c, i) => (
<Fragment key={c.id}>
{i > 0 && (
<span className="cz-sep">
<Icon name="chev" />
</span>
)}
{i === crumbs.length - 1 ? (
<span className="cz cur">{c.title}</span>
) : (
<button className="cz" onClick={() => p.onFocus(c.id)}>
{c.title}
</button>
)}
</Fragment>
))}
</div>
<Tag project={proj} />
<button className="dp-close" onClick={p.onClose} aria-label="닫기">
<Icon name="x" />
</button>
</div>
<div className="dp-grid">
{/* 좌측 — 본문 */}
<div className="dp-main">
<div className="dp-title-row">
<button
className={cx("dp-check", node.status === "done" && "done")}
onClick={() => p.onCheck(node.id)}
aria-label="완료 토글"
>
<Icon name="tick" w={3} />
</button>
<h2 className={cx("dp-title", node.status === "done" && "done")}>{node.title}</h2>
</div>
<div className="dp-label">
<Icon name="file" />
</div>
<RichNotes
taskId={node.id}
value={node.notes}
onChange={(html) => p.onNotes(node.id, html)}
/>
<div className="dp-label">
<Icon name="branch" /> {" "}
<span className="cnt">
{s.done}/{s.total}
</span>
</div>
<div className="subs">
<ScaffoldPanel
taskId={node.id}
show={node.status !== "done"}
onApplied={p.onScaffolded}
/>
{s.total > 0 ? (
node.children.map((c) => (
<SubRow
key={c.id}
node={c}
depth={0}
people={p.people}
expanded={expanded}
onExpand={onExpand}
onCheck={p.onCheck}
onFocus={p.onFocus}
/>
))
) : (
!adding && (
<div className="subempty">
<Icon name="branch" /> .
.
</div>
)
)}
{adding ? (
<input
className="sub-input"
autoFocus
value={subText}
placeholder="하위 작업 입력 후 Enter…"
onChange={(e) => setSubText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitSub();
if (e.key === "Escape") setAdding(false);
}}
onBlur={submitSub}
/>
) : (
<button className="sub-add" onClick={() => setAdding(true)}>
<Icon name="plus" />
</button>
)}
</div>
<div className="dp-label">
<Icon name="msg" /> <span className="cnt">{comments.length}</span>
</div>
<Comments
list={comments}
people={p.people}
me={p.me}
onAdd={(text) => p.onAddComment(node.id, text)}
/>
</div>
{/* 우측 — 메타 */}
<div className="dp-rail">
<div className="dp-meta">
<div className="dp-mi">
<div className="ml">
<Icon name="user" />
</div>
<div className="dp-mv">
<Av person={person} /> {person ? person.name : "미정"}
{person?.is_me ? " (나)" : ""}
</div>
</div>
<div className="dp-mi">
<div className="ml">
<Icon name="cal" />
</div>
<div className="dp-mv">{node.due ? `6월 ${dueDay(node.due)}` : "미정"}</div>
</div>
<div className="dp-mi">
<div className="ml">
<Icon name="flag" />
</div>
<div className={cx("dp-mv", `prio-${node.prio}`)}>
<Icon name="flag" /> {node.prio}
</div>
</div>
<div className="dp-mi">
<div className="ml">
<Icon name="list" />
</div>
<div className="status-wrap">
<button className="status-pill" onClick={() => setMenu((o) => !o)}>
<span className="pdot" style={{ background: sm.c }} /> {sm.label}{" "}
<Icon name="chev" />
</button>
{menu && (
<div className="status-menu">
{COLUMNS.map((c) => (
<button
key={c.id}
onClick={() => {
p.onField(node.id, { status: c.id as Status });
setMenu(false);
}}
>
<span className="pdot" style={{ background: STATUS_META[c.id].c }} />{" "}
{c.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
{node.delegated ? (
<div className="delegated-note">
<Av person={person} />
<div className="dn-tx">
<b>{person ? person.name : ""} </b>
<span> · </span>
</div>
</div>
) : (
collabWho &&
p.people[collabWho] && (
<div className="delegate-sug">
<div className="ds-head">
<Icon name="spark" />
&
</div>
<p>
<b>{p.people[collabWho].name}</b>
. .
</p>
<button className="ds-btn" onClick={() => p.onDelegate(node.id, collabWho)}>
<Icon name="users" />
{p.people[collabWho].name}
</button>
</div>
)
)}
</div>
</div>
<div className="dp-foot">
<button className="btn danger" onClick={() => p.onDelete(node.id)} aria-label="삭제">
<Icon name="trash" />
</button>
{crumbs.length > 1 && (
<button
className="btn ghost"
onClick={() => p.onFocus(crumbs[crumbs.length - 2].id)}
>
<Icon name="back" />
</button>
)}
<button className="btn primary" onClick={p.onClose}>
<Icon name="check" />
</button>
</div>
</aside>
</>
);
}