diff --git a/src/components/Gantt.tsx b/src/components/Gantt.tsx index f7280ac..d6b3b77 100644 --- a/src/components/Gantt.tsx +++ b/src/components/Gantt.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import type { ProjectTask } from "@/types"; -import { LANE_LABELS } from "@/lib/format"; +import { LANE_LABELS, classNames } from "@/lib/format"; const LANE_COLOR: Record = { 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 // 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 starts = tasks.map((t) => parse(t.start)).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 color = LANE_COLOR[t.lane] ?? "#03143F"; return ( -
+
onTaskClick?.(t)}>
{t.title}
= { @@ -64,10 +64,32 @@ function KanbanCard({ task, onCardClick, readOnly }: { task: ProjectTask; onCard readOnly ? "" : "cursor-pointer active:cursor-grabbing", isDragging && "opacity-60" )} > + {task.labels && task.labels.length > 0 && ( +
+ {task.labels.map((l) => ( + {l} + ))} +
+ )}
{task.title}
-
- {formatDate(task.start)} - {task.assignee ? task.assignee.split("@")[0] : ""} +
+
+ {task.priority && PRIORITY_META[task.priority] && ( + + {PRIORITY_META[task.priority].label} + + )} + {formatDate(task.start)} +
+ {task.assignee && ( + + + {task.assignee.slice(0, 1).toUpperCase()} + + {task.assignee.split("@")[0]} + + )}
{task.progress > 0 && (
diff --git a/src/lib/api.ts b/src/lib/api.ts index 6375041..d7e4f51 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4,7 +4,7 @@ import type { Company, Contract, ContractFile, Dashboard, Department, IncentiveConfig, LeaveBalance, LeaveRequest, Me, Member, MyIncentive, NavItem, Notification, OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember, - ProjectTask, Settlement, SimResult, TaxRecord, Timesheet, Transaction, + ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction, UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind, } from "@/types"; @@ -141,6 +141,12 @@ export const updateTask = (tId: string, b: Partial) => api.patch(`/tasks/${tId}`, b).then((r) => r.data); export const deleteTask = (tId: string) => api.delete(`/tasks/${tId}`).then((r) => r.data); +export const getTaskComments = (tId: string) => + api.get(`/tasks/${tId}/comments`).then((r) => r.data); +export const createTaskComment = (tId: string, body: string) => + api.post(`/tasks/${tId}/comments`, { body }).then((r) => r.data); +export const deleteTaskComment = (cId: string) => api.delete(`/comments/${cId}`).then((r) => r.data); + /* ---- contract (admin) ---- */ export const getContract = (id: string) => api.get(`/projects/${id}/contract`).then((r) => r.data); diff --git a/src/lib/format.ts b/src/lib/format.ts index 73ae01b..6459b19 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -145,3 +145,11 @@ export const LANE_LABELS: Record = { review: "검토", done: "완료", }; + +// 작업 우선순위 (JIRA형). order로 정렬·뱃지 색 통일. +export const PRIORITY_META: Record = { + 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 }, +}; diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx index a6e9a8f..f51a0e8 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useParams, Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -7,6 +7,7 @@ import { import { getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles, getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask, + getTaskComments, createTaskComment, deleteTaskComment, upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl, deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject, updateProject, @@ -21,9 +22,10 @@ import { Kanban } from "@/components/Kanban"; import { MemberSelect } from "@/components/MemberSelect"; import { useFieldSuggestions, ROLE_SUGGESTIONS } from "@/lib/suggest"; 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"; -import type { Lane, PaymentSplit, Project, ProjectTask } from "@/types"; +import type { Lane, PaymentSplit, Project, ProjectTask, TaskPriority } from "@/types"; export function ProjectDetailPage() { const { id = "" } = useParams(); @@ -152,9 +154,9 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean } /* ---- timeline: gantt / kanban / calendar ---- */ function Timeline({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) { const qc = useQueryClient(); - const [view, setView] = useState<"gantt" | "kanban" | "calendar">("gantt"); - const [open, setOpen] = useState(false); - const [editTask, setEditTask] = useState(null); + const [view, setView] = useState<"gantt" | "kanban" | "calendar">("kanban"); + const [creating, setCreating] = useState(false); + const [openId, setOpenId] = useState(null); const q = useQuery({ queryKey: ["tasks", projectId], queryFn: () => getTasks(projectId) }); const move = useMutation({ 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] }), }); const tasks = q.data ?? []; + const current = openId ? tasks.find((t) => t.id === openId) ?? null : null; return (
- setView("gantt")} icon={} label="간트" /> setView("kanban")} icon={} label="칸반" /> + setView("gantt")} icon={} label="간트" /> setView("calendar")} icon={} label="캘린더" />
- +
- {q.isLoading ? : tasks.length === 0 ? : ( + {q.isLoading ? : tasks.length === 0 ? : ( <> - {view === "gantt" && } - {view === "kanban" && move.mutate({ taskId, lane })} onCardClick={isAdmin ? (t) => setEditTask(t) : undefined} />} - {view === "calendar" && } + {view === "gantt" && setOpenId(t.id)} />} + {view === "kanban" && move.mutate({ taskId, lane })} onCardClick={(t) => setOpenId(t.id)} />} + {view === "calendar" && setOpenId(t.id)} />} )} - {tasks.length > 0 && isAdmin &&

※ 칸반 보기에서 작업 카드를 누르면 수정·삭제할 수 있습니다.

} - {(open || editTask) && ( - { setOpen(false); setEditTask(null); }} - onDone={() => qc.invalidateQueries({ queryKey: ["tasks", projectId] })} - /> - )} + {tasks.length > 0 &&

※ 작업을 누르면 상세(설명·담당자·우선순위·라벨·댓글)를 보고 편집할 수 있습니다.

} + {creating && setCreating(false)} onCreated={(t) => { setCreating(false); setOpenId(t.id); }} />} + {current && setOpenId(null)} />}
); } @@ -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 [ym, setYm] = useState({ y: now.getFullYear(), m: now.getMonth() }); const first = new Date(ym.y, ym.m, 1); @@ -234,7 +231,10 @@ function CalendarView({ tasks }: { tasks: ProjectTask[] }) {
{day}
{tasksOn(day).slice(0, 3).map((t) => ( -
{t.title}
+ ))}
} @@ -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 assigneeOpts = (pmQ.data ?? []).map((m) => ({ + return (pmQ.data ?? []).map((m) => ({ value: m.memberEmail, label: m.memberEmail.split("@")[0], 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 ( - - {task && } - + }>
- setForm({ ...form, title: e.target.value })} /> + setForm({ ...form, title: e.target.value })} placeholder="예: 기술문서 초안 작성" />
- setForm({ ...form, progress: e.target.value })} /> + setForm({ ...form, start: e.target.value })} /> setForm({ ...form, end: e.target.value })} />
- setForm({ ...form, assignee: v })} - options={assigneeOpts} placeholder="작업자 중 선택" dropUp /> + setForm({ ...form, assignee: v })} options={assigneeOpts} placeholder="작업자 중 선택" dropUp />
); } +// 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) => updateTask(task.id, b), + onSuccess: () => qc.invalidateQueries({ queryKey: ["tasks", projectId] }), + }); + const apply = (b: Partial) => { 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 ( + + {LANE_LABELS[cur.lane]} + 작업 상세 + } + footer={<> + {isAdmin && } + + }> +
+ {/* 본문: 제목·설명·댓글 */} +
+ setCur((c) => ({ ...c, title: e.target.value }))} + onBlur={() => { if (cur.title !== task.title && cur.title.trim()) apply({ title: cur.title }); }} + placeholder="작업명" + /> +
+ 설명 +