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.

439 lines
16 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, useMemo, useState } from "react";
import { Icon } from "@/components/Icon";
import { cx } from "@/lib/cx";
import { COLUMNS, STATUS_META, findPath, flattenTasks, stat } from "@/lib/tasks/tree";
import type { Person, Prio, 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];
}
/** 우선순위 옵션 (Prio enum 1:1) + 점 색상 */
const PRIO_OPTS: { id: Prio; c: string }[] = [
{ id: "높음", c: "var(--coral)" },
{ id: "보통", c: "var(--amber)" },
{ id: "낮음", c: "var(--muted)" },
];
/** 마감일 라벨 — "6월 8일" (월 무관) */
function dueText(due: string | null): string {
if (!due) return "미정";
const d = new Date(due);
return `${d.getUTCMonth() + 1}${d.getUTCDate()}`;
}
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;
onEditComment?: (id: string, cid: string, text: string) => void;
onDeleteComment?: (id: string, cid: string) => void;
}
type MenuKind = "status" | "prio" | "assignee";
export function DetailPanel(p: Props) {
const [menu, setMenu] = useState<MenuKind | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [adding, setAdding] = useState(false);
const [subText, setSubText] = useState("");
// 담당자 셀렉터용 인원 목록 (본인 먼저)
const peopleList = useMemo(() => {
const arr = Object.values(p.people);
return arr.sort((a, b) => (a.is_me === b.is_me ? 0 : a.is_me ? -1 : 1));
}, [p.people]);
const path = findPath(p.tasks, p.focusId) ?? [];
const node = path[path.length - 1];
useEffect(() => {
setAdding(false);
setMenu(null);
}, [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)}
onEdit={
p.onEditComment ? (cid, text) => p.onEditComment!(node.id, cid, text) : undefined
}
onDelete={p.onDeleteComment ? (cid) => p.onDeleteComment!(node.id, cid) : undefined}
/>
</div>
{/* 우측 — 메타 */}
<div className="dp-rail">
<div className="dp-meta">
{/* 담당자 — 인원 목록에서 선택 */}
<div className="dp-mi">
<div className="ml">
<Icon name="user" />
</div>
<div className="status-wrap">
<button
className="status-pill assignee-pill"
onClick={() => setMenu((o) => (o === "assignee" ? null : "assignee"))}
>
{person ? (
<>
<Av person={person} /> {person.name}
{person.is_me ? " (나)" : ""}
</>
) : (
<span className="dp-unset">
<Icon name="user" />
</span>
)}
<Icon name="chev" />
</button>
{menu === "assignee" && (
<div className="status-menu">
{peopleList.map((pr) => (
<button
key={pr.id}
onClick={() => {
p.onField(node.id, { assignee_id: pr.id });
setMenu(null);
}}
>
<Av person={pr} /> {pr.name}
{pr.is_me ? " (나)" : ""}
</button>
))}
{person && (
<button
className="menu-clear"
onClick={() => {
p.onField(node.id, { assignee_id: null });
setMenu(null);
}}
>
<Icon name="x" />
</button>
)}
</div>
)}
</div>
</div>
{/* 마감일 — 날짜 입력 (비우면 null) */}
<div className="dp-mi">
<div className="ml">
<Icon name="cal" />
</div>
<div className="dp-date-wrap">
<input
className="dp-date"
type="date"
value={node.due ?? ""}
onChange={(e) =>
p.onField(node.id, { due: e.target.value ? e.target.value : null })
}
/>
<span className="dp-date-face">
<Icon name="cal" /> {dueText(node.due)}
</span>
{node.due && (
<button
className="dp-date-clear"
onClick={() => p.onField(node.id, { due: null })}
aria-label="마감일 지우기"
>
<Icon name="x" />
</button>
)}
</div>
</div>
{/* 우선순위 — 드롭다운 */}
<div className="dp-mi">
<div className="ml">
<Icon name="flag" />
</div>
<div className="status-wrap">
<button
className={cx("status-pill", `prio-${node.prio}`)}
onClick={() => setMenu((o) => (o === "prio" ? null : "prio"))}
>
<Icon name="flag" /> {node.prio} <Icon name="chev" />
</button>
{menu === "prio" && (
<div className="status-menu">
{PRIO_OPTS.map((o) => (
<button
key={o.id}
className={cx(`prio-${o.id}`, node.prio === o.id && "on")}
onClick={() => {
p.onField(node.id, { prio: o.id });
setMenu(null);
}}
>
<Icon name="flag" /> {o.id}
</button>
))}
</div>
)}
</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 === "status" ? null : "status"))}
>
<span className="pdot" style={{ background: sm.c }} /> {sm.label}{" "}
<Icon name="chev" />
</button>
{menu === "status" && (
<div className="status-menu">
{COLUMNS.map((c) => (
<button
key={c.id}
className={cx(node.status === c.id && "on")}
onClick={() => {
p.onField(node.id, { status: c.id as Status });
setMenu(null);
}}
>
<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>
</>
);
}