feat(task): JIRA형 작업 카드 — 상세 패널(설명·담당자·우선순위·라벨·진척) + 댓글
All checks were successful
build-and-push / build (push) Successful in 30s
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:
parent
3a260c207b
commit
7f62f41134
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 },
|
||||||
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
13
src/types.ts
13
src/types.ts
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user