feat(task): JIRA형 작업 카드 — 상세 패널(설명·담당자·우선순위·라벨·진척) + 댓글
All checks were successful
build-and-push / build (push) Successful in 30s

- TaskDetailModal: 좌측 제목·설명·댓글 / 우측 속성, 필드 즉시저장(autosave)
- CreateTaskModal: 핵심 필드만 입력 후 상세 카드로 이동
- 칸반 카드에 우선순위 뱃지·라벨 칩·담당자 아바타·진척바
- 간트 막대/캘린더 항목 클릭 → 상세 열기
- 우선순위 메타(PRIORITY_META), 작업 댓글 API, 타입 확장

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 09:09:20 +09:00
parent 3a260c207b
commit 7f62f41134
6 changed files with 244 additions and 49 deletions

View File

@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { ProjectTask } from "@/types"; import type { ProjectTask } from "@/types";
import { LANE_LABELS } from "@/lib/format"; import { LANE_LABELS, classNames } from "@/lib/format";
const LANE_COLOR: Record<string, string> = { const LANE_COLOR: Record<string, string> = {
todo: "#98A2B3", doing: "#2E90FA", review: "#7A5AF8", done: "#12B76A", todo: "#98A2B3", doing: "#2E90FA", review: "#7A5AF8", done: "#12B76A",
@ -15,7 +15,7 @@ function parse(d: string): number {
// Lightweight SVG-free Gantt: a day-scaled track with one bar per task. Matches // Lightweight SVG-free Gantt: a day-scaled track with one bar per task. Matches
// the real calendar by positioning bars on an absolute date axis. // the real calendar by positioning bars on an absolute date axis.
export function Gantt({ tasks }: { tasks: ProjectTask[] }) { export function Gantt({ tasks, onTaskClick }: { tasks: ProjectTask[]; onTaskClick?: (t: ProjectTask) => void }) {
const { min, max, months } = useMemo(() => { const { min, max, months } = useMemo(() => {
const starts = tasks.map((t) => parse(t.start)).filter(Boolean); const starts = tasks.map((t) => parse(t.start)).filter(Boolean);
const ends = tasks.map((t) => parse(t.end)).filter(Boolean); const ends = tasks.map((t) => parse(t.end)).filter(Boolean);
@ -63,7 +63,9 @@ export function Gantt({ tasks }: { tasks: ProjectTask[] }) {
const width = Math.max(1.5, ((e - s) / span) * 100); const width = Math.max(1.5, ((e - s) / span) * 100);
const color = LANE_COLOR[t.lane] ?? "#03143F"; const color = LANE_COLOR[t.lane] ?? "#03143F";
return ( return (
<div key={t.id} className="flex items-center h-9 group"> <div key={t.id}
className={classNames("flex items-center h-9 group", onTaskClick && "cursor-pointer hover:bg-canvas rounded-md")}
onClick={() => onTaskClick?.(t)}>
<div className="w-[200px] shrink-0 pr-3 text-sm text-ink truncate">{t.title}</div> <div className="w-[200px] shrink-0 pr-3 text-sm text-ink truncate">{t.title}</div>
<div className="relative flex-1 h-full"> <div className="relative flex-1 h-full">
<div <div

View File

@ -3,7 +3,7 @@ import {
type DragEndEvent, useDroppable, useDraggable, type DragEndEvent, useDroppable, useDraggable,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import type { Lane, ProjectTask } from "@/types"; import type { Lane, ProjectTask } from "@/types";
import { LANE_LABELS, formatDate, classNames } from "@/lib/format"; import { LANE_LABELS, PRIORITY_META, formatDate, classNames } from "@/lib/format";
const LANES: Lane[] = ["todo", "doing", "review", "done"]; const LANES: Lane[] = ["todo", "doing", "review", "done"];
const LANE_DOT: Record<Lane, string> = { const LANE_DOT: Record<Lane, string> = {
@ -64,10 +64,32 @@ function KanbanCard({ task, onCardClick, readOnly }: { task: ProjectTask; onCard
readOnly ? "" : "cursor-pointer active:cursor-grabbing", isDragging && "opacity-60" readOnly ? "" : "cursor-pointer active:cursor-grabbing", isDragging && "opacity-60"
)} )}
> >
{task.labels && task.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1.5">
{task.labels.map((l) => (
<span key={l} className="text-[10px] leading-none bg-chip-bg text-navy rounded-pill px-1.5 py-1">{l}</span>
))}
</div>
)}
<div className="text-sm font-medium text-ink">{task.title}</div> <div className="text-sm font-medium text-ink">{task.title}</div>
<div className="flex items-center justify-between mt-2 text-[11px] text-ink-muted"> <div className="flex items-center justify-between mt-2 gap-2">
<span className="tabular">{formatDate(task.start)}</span> <div className="flex items-center gap-1.5 min-w-0">
<span>{task.assignee ? task.assignee.split("@")[0] : ""}</span> {task.priority && PRIORITY_META[task.priority] && (
<span className="text-[10px] leading-none font-medium rounded-pill px-1.5 py-1 shrink-0"
style={{ color: PRIORITY_META[task.priority].fg, background: PRIORITY_META[task.priority].bg }}>
{PRIORITY_META[task.priority].label}
</span>
)}
<span className="text-[11px] text-ink-muted tabular truncate">{formatDate(task.start)}</span>
</div>
{task.assignee && (
<span className="flex items-center gap-1 text-[11px] text-ink-muted min-w-0">
<span className="w-5 h-5 rounded-full bg-navy text-white text-[10px] font-bold flex items-center justify-center shrink-0">
{task.assignee.slice(0, 1).toUpperCase()}
</span>
<span className="truncate max-w-[80px]">{task.assignee.split("@")[0]}</span>
</span>
)}
</div> </div>
{task.progress > 0 && ( {task.progress > 0 && (
<div className="h-1 rounded-pill bg-divider mt-2 overflow-hidden"> <div className="h-1 rounded-pill bg-divider mt-2 overflow-hidden">

View File

@ -4,7 +4,7 @@ import type {
Company, Contract, ContractFile, Dashboard, Department, IncentiveConfig, Company, Contract, ContractFile, Dashboard, Department, IncentiveConfig,
LeaveBalance, LeaveRequest, Me, Member, MyIncentive, NavItem, Notification, LeaveBalance, LeaveRequest, Me, Member, MyIncentive, NavItem, Notification,
OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember, OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember,
ProjectTask, Settlement, SimResult, TaxRecord, Timesheet, Transaction, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction,
UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind, UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind,
} from "@/types"; } from "@/types";
@ -141,6 +141,12 @@ export const updateTask = (tId: string, b: Partial<ProjectTask>) =>
api.patch<ProjectTask>(`/tasks/${tId}`, b).then((r) => r.data); api.patch<ProjectTask>(`/tasks/${tId}`, b).then((r) => r.data);
export const deleteTask = (tId: string) => api.delete(`/tasks/${tId}`).then((r) => r.data); export const deleteTask = (tId: string) => api.delete(`/tasks/${tId}`).then((r) => r.data);
export const getTaskComments = (tId: string) =>
api.get<TaskComment[]>(`/tasks/${tId}/comments`).then((r) => r.data);
export const createTaskComment = (tId: string, body: string) =>
api.post<TaskComment>(`/tasks/${tId}/comments`, { body }).then((r) => r.data);
export const deleteTaskComment = (cId: string) => api.delete(`/comments/${cId}`).then((r) => r.data);
/* ---- contract (admin) ---- */ /* ---- contract (admin) ---- */
export const getContract = (id: string) => export const getContract = (id: string) =>
api.get<Contract | null>(`/projects/${id}/contract`).then((r) => r.data); api.get<Contract | null>(`/projects/${id}/contract`).then((r) => r.data);

View File

@ -145,3 +145,11 @@ export const LANE_LABELS: Record<string, string> = {
review: "검토", review: "검토",
done: "완료", done: "완료",
}; };
// 작업 우선순위 (JIRA형). order로 정렬·뱃지 색 통일.
export const PRIORITY_META: Record<string, { label: string; fg: string; bg: string; order: number }> = {
urgent: { label: "긴급", fg: "#B42318", bg: "#FEE4E2", order: 0 },
high: { label: "높음", fg: "#B54708", bg: "#FEF0C7", order: 1 },
medium: { label: "보통", fg: "#175CD3", bg: "#D1E9FF", order: 2 },
low: { label: "낮음", fg: "#475467", bg: "#F2F4F7", order: 3 },
};

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom"; import { useParams, Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -7,6 +7,7 @@ import {
import { import {
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles, getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask, getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask,
getTaskComments, createTaskComment, deleteTaskComment,
upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl, upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl,
deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject, deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject,
updateProject, updateProject,
@ -21,9 +22,10 @@ import { Kanban } from "@/components/Kanban";
import { MemberSelect } from "@/components/MemberSelect"; import { MemberSelect } from "@/components/MemberSelect";
import { useFieldSuggestions, ROLE_SUGGESTIONS } from "@/lib/suggest"; import { useFieldSuggestions, ROLE_SUGGESTIONS } from "@/lib/suggest";
import { import {
formatDate, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS, classNames, formatDate, formatDateTime, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS,
PRIORITY_META, classNames,
} from "@/lib/format"; } from "@/lib/format";
import type { Lane, PaymentSplit, Project, ProjectTask } from "@/types"; import type { Lane, PaymentSplit, Project, ProjectTask, TaskPriority } from "@/types";
export function ProjectDetailPage() { export function ProjectDetailPage() {
const { id = "" } = useParams(); const { id = "" } = useParams();
@ -152,9 +154,9 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }
/* ---- timeline: gantt / kanban / calendar ---- */ /* ---- timeline: gantt / kanban / calendar ---- */
function Timeline({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) { function Timeline({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [view, setView] = useState<"gantt" | "kanban" | "calendar">("gantt"); const [view, setView] = useState<"gantt" | "kanban" | "calendar">("kanban");
const [open, setOpen] = useState(false); const [creating, setCreating] = useState(false);
const [editTask, setEditTask] = useState<ProjectTask | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const q = useQuery({ queryKey: ["tasks", projectId], queryFn: () => getTasks(projectId) }); const q = useQuery({ queryKey: ["tasks", projectId], queryFn: () => getTasks(projectId) });
const move = useMutation({ const move = useMutation({
mutationFn: ({ taskId, lane }: { taskId: string; lane: Lane }) => updateTask(taskId, { lane }), mutationFn: ({ taskId, lane }: { taskId: string; lane: Lane }) => updateTask(taskId, { lane }),
@ -164,33 +166,28 @@ function Timeline({ projectId, isAdmin }: { projectId: string; isAdmin: boolean
onSettled: () => qc.invalidateQueries({ queryKey: ["tasks", projectId] }), onSettled: () => qc.invalidateQueries({ queryKey: ["tasks", projectId] }),
}); });
const tasks = q.data ?? []; const tasks = q.data ?? [];
const current = openId ? tasks.find((t) => t.id === openId) ?? null : null;
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="inline-flex rounded-control border border-border overflow-hidden"> <div className="inline-flex rounded-control border border-border overflow-hidden">
<ViewBtn active={view === "gantt"} onClick={() => setView("gantt")} icon={<GanttChartSquare size={15} />} label="간트" />
<ViewBtn active={view === "kanban"} onClick={() => setView("kanban")} icon={<Columns3 size={15} />} label="칸반" /> <ViewBtn active={view === "kanban"} onClick={() => setView("kanban")} icon={<Columns3 size={15} />} label="칸반" />
<ViewBtn active={view === "gantt"} onClick={() => setView("gantt")} icon={<GanttChartSquare size={15} />} label="간트" />
<ViewBtn active={view === "calendar"} onClick={() => setView("calendar")} icon={<CalendarDays size={15} />} label="캘린더" /> <ViewBtn active={view === "calendar"} onClick={() => setView("calendar")} icon={<CalendarDays size={15} />} label="캘린더" />
</div> </div>
<Button size="sm" icon={<Plus size={14} />} onClick={() => setOpen(true)}> </Button> <Button size="sm" icon={<Plus size={14} />} onClick={() => setCreating(true)}> </Button>
</div> </div>
{q.isLoading ? <LoadingState /> : tasks.length === 0 ? <EmptyState title="작업이 없습니다" /> : ( {q.isLoading ? <LoadingState /> : tasks.length === 0 ? <EmptyState title="작업이 없습니다" description="작업을 추가하면 칸반·간트·캘린더에 표시됩니다." /> : (
<> <>
{view === "gantt" && <Gantt tasks={tasks} />} {view === "gantt" && <Gantt tasks={tasks} onTaskClick={(t) => setOpenId(t.id)} />}
{view === "kanban" && <Kanban tasks={tasks} onMove={(taskId, lane) => move.mutate({ taskId, lane })} onCardClick={isAdmin ? (t) => setEditTask(t) : undefined} />} {view === "kanban" && <Kanban tasks={tasks} onMove={(taskId, lane) => move.mutate({ taskId, lane })} onCardClick={(t) => setOpenId(t.id)} />}
{view === "calendar" && <CalendarView tasks={tasks} />} {view === "calendar" && <CalendarView tasks={tasks} onTaskClick={(t) => setOpenId(t.id)} />}
</> </>
)} )}
{tasks.length > 0 && isAdmin && <p className="text-xs text-ink-muted mt-2"> · .</p>} {tasks.length > 0 && <p className="text-xs text-ink-muted mt-2"> (····) .</p>}
{(open || editTask) && ( {creating && <CreateTaskModal projectId={projectId} onClose={() => setCreating(false)} onCreated={(t) => { setCreating(false); setOpenId(t.id); }} />}
<TaskModal {current && <TaskDetailModal projectId={projectId} task={current} isAdmin={isAdmin} onClose={() => setOpenId(null)} />}
projectId={projectId}
task={editTask}
onClose={() => { setOpen(false); setEditTask(null); }}
onDone={() => qc.invalidateQueries({ queryKey: ["tasks", projectId] })}
/>
)}
</div> </div>
); );
} }
@ -203,7 +200,7 @@ function ViewBtn({ active, onClick, icon, label }: { active: boolean; onClick: (
); );
} }
function CalendarView({ tasks }: { tasks: ProjectTask[] }) { function CalendarView({ tasks, onTaskClick }: { tasks: ProjectTask[]; onTaskClick?: (t: ProjectTask) => void }) {
const now = new Date(); const now = new Date();
const [ym, setYm] = useState({ y: now.getFullYear(), m: now.getMonth() }); const [ym, setYm] = useState({ y: now.getFullYear(), m: now.getMonth() });
const first = new Date(ym.y, ym.m, 1); const first = new Date(ym.y, ym.m, 1);
@ -234,7 +231,10 @@ function CalendarView({ tasks }: { tasks: ProjectTask[] }) {
<div className="text-xs text-ink-muted mb-1">{day}</div> <div className="text-xs text-ink-muted mb-1">{day}</div>
<div className="space-y-1"> <div className="space-y-1">
{tasksOn(day).slice(0, 3).map((t) => ( {tasksOn(day).slice(0, 3).map((t) => (
<div key={t.id} className="text-[10px] px-1.5 py-0.5 rounded bg-navy-subtle text-navy truncate">{t.title}</div> <button key={t.id} onClick={() => onTaskClick?.(t)}
className="block w-full text-left text-[10px] px-1.5 py-0.5 rounded bg-navy-subtle text-navy truncate hover:bg-navy-subtle/70">
{t.title}
</button>
))} ))}
</div> </div>
</>} </>}
@ -245,49 +245,193 @@ function CalendarView({ tasks }: { tasks: ProjectTask[] }) {
); );
} }
function TaskModal({ projectId, task, onClose, onDone }: { projectId: string; task?: ProjectTask | null; onClose: () => void; onDone: () => void }) { // 프로젝트 작업자(ProjectMember) → 담당자 선택 옵션
function useAssigneeOptions(projectId: string) {
const pmQ = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) }); const pmQ = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) });
const assigneeOpts = (pmQ.data ?? []).map((m) => ({ return (pmQ.data ?? []).map((m) => ({
value: m.memberEmail, value: m.memberEmail,
label: m.memberEmail.split("@")[0], label: m.memberEmail.split("@")[0],
sub: [m.memberEmail, m.role].filter(Boolean).join(" · "), sub: [m.memberEmail, m.role].filter(Boolean).join(" · "),
})); }));
const [form, setForm] = useState({ }
title: task?.title ?? "", lane: task?.lane ?? "todo", start: task?.start ?? "",
end: task?.end ?? "", assignee: task?.assignee ?? "", progress: String(task?.progress ?? 0), // 작업 추가: 핵심 필드만. 생성 후 상세 카드로 이동해 설명·라벨·댓글을 채운다.
function CreateTaskModal({ projectId, onClose, onCreated }: { projectId: string; onClose: () => void; onCreated: (t: ProjectTask) => void }) {
const qc = useQueryClient();
const assigneeOpts = useAssigneeOptions(projectId);
const [form, setForm] = useState({ title: "", lane: "todo" as Lane, priority: "medium" as TaskPriority, assignee: "", start: "", end: "" });
const create = useMutation({
mutationFn: () => createTask(projectId, { ...form }),
onSuccess: (t) => { qc.invalidateQueries({ queryKey: ["tasks", projectId] }); onCreated(t); },
}); });
const body = () => ({ title: form.title, lane: form.lane as Lane, start: form.start, end: form.end, assignee: form.assignee, progress: parseInt(form.progress) || 0 });
const m = useMutation({
mutationFn: () => (task ? updateTask(task.id, body()) : createTask(projectId, body())),
onSuccess: () => { onDone(); onClose(); },
});
const del = useMutation({ mutationFn: () => deleteTask(task!.id), onSuccess: () => { onDone(); onClose(); } });
return ( return (
<Modal open onClose={onClose} title={task ? "작업 수정" : "작업 추가"} <Modal open onClose={onClose} title="작업 추가"
footer={<> footer={<>
{task && <Button variant="danger" onClick={() => del.mutate()} className="mr-auto"></Button>}
<Button variant="secondary" onClick={onClose}></Button> <Button variant="secondary" onClick={onClose}></Button>
<Button disabled={!form.title || m.isPending} onClick={() => m.mutate()}>{task ? "저장" : "추가"}</Button> <Button disabled={!form.title || create.isPending} onClick={() => create.mutate()}></Button>
</>}> </>}>
<div className="space-y-4"> <div className="space-y-4">
<Field label="작업명"><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></Field> <Field label="작업명"><Input value={form.title} autoFocus onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="예: 기술문서 초안 작성" /></Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="상태"><Select value={form.lane} onChange={(e) => setForm({ ...form, lane: e.target.value as Lane })}> <Field label="상태"><Select value={form.lane} onChange={(e) => setForm({ ...form, lane: e.target.value as Lane })}>
{Object.entries(LANE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)} {Object.entries(LANE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select></Field> </Select></Field>
<Field label="진척 %"><Input type="number" value={form.progress} onChange={(e) => setForm({ ...form, progress: e.target.value })} /></Field> <Field label="우선순위"><Select value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value as TaskPriority })}>
{Object.entries(PRIORITY_META).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</Select></Field>
<Field label="시작일"><Input type="date" value={form.start} onChange={(e) => setForm({ ...form, start: e.target.value })} /></Field> <Field label="시작일"><Input type="date" value={form.start} onChange={(e) => setForm({ ...form, start: e.target.value })} /></Field>
<Field label="종료일"><Input type="date" value={form.end} onChange={(e) => setForm({ ...form, end: e.target.value })} /></Field> <Field label="종료일"><Input type="date" value={form.end} onChange={(e) => setForm({ ...form, end: e.target.value })} /></Field>
</div> </div>
<Field label="담당자" hint={assigneeOpts.length === 0 ? "먼저 '작업자' 탭에서 작업자를 추가하세요." : undefined}> <Field label="담당자" hint={assigneeOpts.length === 0 ? "먼저 '작업자' 탭에서 작업자를 추가하세요." : undefined}>
<MemberSelect value={form.assignee} onChange={(v) => setForm({ ...form, assignee: v })} <MemberSelect value={form.assignee} onChange={(v) => setForm({ ...form, assignee: v })} options={assigneeOpts} placeholder="작업자 중 선택" dropUp />
options={assigneeOpts} placeholder="작업자 중 선택" dropUp />
</Field> </Field>
</div> </div>
</Modal> </Modal>
); );
} }
// JIRA형 작업 상세: 좌측 본문(설명·댓글) + 우측 속성(상태·담당자·우선순위·일정·라벨).
// 필드 변경은 즉시 저장(autosave). 삭제는 관리자만.
function TaskDetailModal({ projectId, task, isAdmin, onClose }: { projectId: string; task: ProjectTask; isAdmin: boolean; onClose: () => void }) {
const qc = useQueryClient();
const { me } = useAuth();
const myEmail = me?.user.email ?? "";
const assigneeOpts = useAssigneeOptions(projectId);
const [cur, setCur] = useState(task);
useEffect(() => { setCur(task); }, [task.id]); // 다른 작업으로 바뀔 때만 리셋
const patch = useMutation({
mutationFn: (b: Partial<ProjectTask>) => updateTask(task.id, b),
onSuccess: () => qc.invalidateQueries({ queryKey: ["tasks", projectId] }),
});
const apply = (b: Partial<ProjectTask>) => { setCur((c) => ({ ...c, ...b })); patch.mutate(b); };
const del = useMutation({ mutationFn: () => deleteTask(task.id), onSuccess: () => { qc.invalidateQueries({ queryKey: ["tasks", projectId] }); onClose(); } });
const cQ = useQuery({ queryKey: ["task-comments", task.id], queryFn: () => getTaskComments(task.id) });
const [comment, setComment] = useState("");
const invalidateC = () => qc.invalidateQueries({ queryKey: ["task-comments", task.id] });
const addC = useMutation({ mutationFn: () => createTaskComment(task.id, comment), onSuccess: () => { setComment(""); invalidateC(); } });
const delC = useMutation({ mutationFn: (id: string) => deleteTaskComment(id), onSuccess: invalidateC });
const labels = cur.labels ?? [];
const [labelInput, setLabelInput] = useState("");
const addLabel = () => {
const v = labelInput.trim();
if (v && !labels.includes(v)) apply({ labels: [...labels, v] });
setLabelInput("");
};
const removeLabel = (l: string) => apply({ labels: labels.filter((x) => x !== l) });
return (
<Modal open onClose={onClose} wide
title={<span className="flex items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-pill bg-chip-bg text-navy text-xs font-medium px-2 py-0.5">{LANE_LABELS[cur.lane]}</span>
<span className="text-ink-muted font-normal"> </span>
</span>}
footer={<>
{isAdmin && <Button variant="danger" onClick={() => { if (confirm("이 작업을 삭제하시겠습니까?")) del.mutate(); }} className="mr-auto"></Button>}
<Button variant="secondary" onClick={onClose}></Button>
</>}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* 본문: 제목·설명·댓글 */}
<div className="lg:col-span-2 space-y-5">
<input
className="form-input text-base font-semibold" value={cur.title}
onChange={(e) => setCur((c) => ({ ...c, title: e.target.value }))}
onBlur={() => { if (cur.title !== task.title && cur.title.trim()) apply({ title: cur.title }); }}
placeholder="작업명"
/>
<div>
<span className="form-label"></span>
<Textarea value={cur.description}
onChange={(e) => setCur((c) => ({ ...c, description: e.target.value }))}
onBlur={() => { if (cur.description !== task.description) apply({ description: cur.description }); }}
placeholder="작업 내용을 자세히 적어주세요. (배경·범위·체크리스트 등)"
style={{ minHeight: 140 }} />
</div>
<div>
<div className="text-sm font-bold text-ink mb-2"> {(cQ.data?.length ?? 0) > 0 ? `(${cQ.data!.length})` : ""}</div>
<div className="space-y-3 mb-3">
{(cQ.data ?? []).map((c) => (
<div key={c.id} className="flex gap-2.5">
<span className="w-7 h-7 rounded-full bg-navy text-white text-[11px] font-bold flex items-center justify-center shrink-0">
{(c.authorEmail.slice(0, 1) || "?").toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink">{c.authorEmail.split("@")[0]}</span>
<span className="text-[11px] text-ink-muted tabular">{formatDateTime(c.createdAt)}</span>
{(isAdmin || c.authorEmail === myEmail) && (
<button className="ml-auto text-ink-muted hover:text-money-out" onClick={() => delC.mutate(c.id)}><Trash2 size={13} /></button>
)}
</div>
<p className="text-sm text-ink-secondary whitespace-pre-wrap break-words mt-0.5">{c.body}</p>
</div>
</div>
))}
{(cQ.data?.length ?? 0) === 0 && <p className="text-sm text-ink-muted"> .</p>}
</div>
<div className="flex items-end gap-2">
<Textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder="댓글 입력…" style={{ minHeight: 40 }} />
<Button disabled={!comment.trim() || addC.isPending} onClick={() => addC.mutate()}></Button>
</div>
</div>
</div>
{/* 속성 사이드바 */}
<div className="space-y-3">
<Prop label="상태">
<Select value={cur.lane} onChange={(e) => apply({ lane: e.target.value as Lane })}>
{Object.entries(LANE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</Prop>
<Prop label="담당자">
<MemberSelect value={cur.assignee} onChange={(v) => apply({ assignee: v })} options={assigneeOpts} placeholder="작업자 중 선택" />
</Prop>
<Prop label="우선순위">
<Select value={cur.priority} onChange={(e) => apply({ priority: e.target.value as TaskPriority })}>
<option value=""></option>
{Object.entries(PRIORITY_META).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</Select>
</Prop>
<Prop label="진척 %">
<Input type="number" min={0} max={100} value={cur.progress}
onChange={(e) => setCur((c) => ({ ...c, progress: parseInt(e.target.value) || 0 }))}
onBlur={() => { if (cur.progress !== task.progress) apply({ progress: cur.progress }); }} />
</Prop>
<div className="grid grid-cols-2 gap-2">
<Prop label="시작일"><Input type="date" value={cur.start} onChange={(e) => apply({ start: e.target.value })} /></Prop>
<Prop label="종료일"><Input type="date" value={cur.end} onChange={(e) => apply({ end: e.target.value })} /></Prop>
</div>
<Prop label="라벨">
<div className="flex flex-wrap gap-1 mb-1.5">
{labels.map((l) => (
<span key={l} className="inline-flex items-center gap-1 text-[11px] bg-chip-bg text-navy rounded-pill px-2 py-0.5">
{l}<button onClick={() => removeLabel(l)} className="hover:text-money-out">×</button>
</span>
))}
{labels.length === 0 && <span className="text-[11px] text-ink-muted"></span>}
</div>
<Input value={labelInput} onChange={(e) => setLabelInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addLabel(); } }}
placeholder="라벨 입력 후 Enter" />
</Prop>
</div>
</div>
</Modal>
);
}
function Prop({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="form-label">{label}</span>
{children}
</label>
);
}
/* ---- contacts ---- */ /* ---- contacts ---- */
function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) { function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) {
const qc = useQueryClient(); const qc = useQueryClient();

View File

@ -211,11 +211,16 @@ export interface ClientContact {
} }
export type Lane = "todo" | "doing" | "review" | "done"; export type Lane = "todo" | "doing" | "review" | "done";
export type TaskPriority = "low" | "medium" | "high" | "urgent";
export interface ProjectTask { export interface ProjectTask {
id: string; id: string;
projectId: string; projectId: string;
title: string; title: string;
description: string;
lane: Lane; lane: Lane;
priority: TaskPriority | "";
labels: string[] | null;
start: string; start: string;
end: string; end: string;
assignee: string; assignee: string;
@ -225,6 +230,14 @@ export interface ProjectTask {
color: string; color: string;
} }
export interface TaskComment {
id: string;
taskId: string;
authorEmail: string;
body: string;
createdAt: string;
}
export interface Contract { export interface Contract {
id: string; id: string;
projectId: string; projectId: string;