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