diff --git a/src/components/Kanban.tsx b/src/components/Kanban.tsx index 2999e5a..acf284d 100644 --- a/src/components/Kanban.tsx +++ b/src/components/Kanban.tsx @@ -11,8 +11,8 @@ const LANE_DOT: Record = { }; export function Kanban({ - tasks, onMove, readOnly, -}: { tasks: ProjectTask[]; onMove: (taskId: string, lane: Lane) => void; readOnly?: boolean }) { + tasks, onMove, onCardClick, readOnly, +}: { tasks: ProjectTask[]; onMove: (taskId: string, lane: Lane) => void; onCardClick?: (t: ProjectTask) => void; readOnly?: boolean }) { const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); function onDragEnd(e: DragEndEvent) { @@ -28,14 +28,14 @@ export function Kanban({
{LANES.map((lane) => ( - t.lane === lane)} readOnly={readOnly} /> + t.lane === lane)} onCardClick={onCardClick} readOnly={readOnly} /> ))}
); } -function Column({ lane, tasks, readOnly }: { lane: Lane; tasks: ProjectTask[]; readOnly?: boolean }) { +function Column({ lane, tasks, onCardClick, readOnly }: { lane: Lane; tasks: ProjectTask[]; onCardClick?: (t: ProjectTask) => void; readOnly?: boolean }) { const { setNodeRef, isOver } = useDroppable({ id: lane }); return (
@@ -45,22 +45,23 @@ function Column({ lane, tasks, readOnly }: { lane: Lane; tasks: ProjectTask[]; r {tasks.length}
- {tasks.map((t) => )} + {tasks.map((t) => )} {tasks.length === 0 &&
비어 있음
}
); } -function KanbanCard({ task, readOnly }: { task: ProjectTask; readOnly?: boolean }) { +function KanbanCard({ task, onCardClick, readOnly }: { task: ProjectTask; onCardClick?: (t: ProjectTask) => void; readOnly?: boolean }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, disabled: readOnly }); const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)` } : undefined; return (
{ if (!isDragging) onCardClick?.(task); }} className={classNames( "bg-surface border border-border rounded-control p-3 shadow-card", - readOnly ? "" : "cursor-grab active:cursor-grabbing", isDragging && "opacity-60" + readOnly ? "" : "cursor-pointer active:cursor-grabbing", isDragging && "opacity-60" )} >
{task.title}
diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx index 2299a0a..d16bece 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -6,7 +6,7 @@ import { } from "lucide-react"; import { getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles, - getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, + getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask, upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl, deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject, updateProject, @@ -99,12 +99,14 @@ function Overview({ project: p }: { project: Project }) { function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) { const qc = useQueryClient(); const q = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) }); + const [editId, setEditId] = useState(null); const [email, setEmail] = useState(""); const [portion, setPortion] = useState(""); const [role, setRole] = useState("작업자"); - const add = useMutation({ - mutationFn: () => upsertProjectMember(projectId, { memberEmail: email, portion: parseFloat(portion) || 0, role }), - onSuccess: () => { qc.invalidateQueries({ queryKey: ["pm", projectId] }); setEmail(""); setPortion(""); }, + const reset = () => { setEditId(null); setEmail(""); setPortion(""); setRole("작업자"); }; + const save = useMutation({ + mutationFn: () => upsertProjectMember(projectId, { id: editId ?? undefined, memberEmail: email, portion: parseFloat(portion) || 0, role }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["pm", projectId] }); reset(); }, }); const del = useMutation({ mutationFn: (pmId: string) => deleteProjectMember(pmId), onSuccess: () => qc.invalidateQueries({ queryKey: ["pm", projectId] }) }); @@ -123,7 +125,10 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean } {pm.memberEmail} {pm.role} {pm.portion}% - {isAdmin && } + {isAdmin && + + + } ))} {(q.data?.length ?? 0) === 0 && 작업자가 없습니다} @@ -131,10 +136,11 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean } {isAdmin && (
- setEmail(e.target.value)} className="w-56" /> + setEmail(e.target.value)} className="w-56" disabled={!!editId} /> setRole(e.target.value)} className="w-32" /> setPortion(e.target.value)} className="w-24" /> - + + {editId && }
)}
@@ -146,6 +152,7 @@ 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 q = useQuery({ queryKey: ["tasks", projectId], queryFn: () => getTasks(projectId) }); const move = useMutation({ mutationFn: ({ taskId, lane }: { taskId: string; lane: Lane }) => updateTask(taskId, { lane }), @@ -169,11 +176,19 @@ function Timeline({ projectId, isAdmin }: { projectId: string; isAdmin: boolean {q.isLoading ? : tasks.length === 0 ? : ( <> {view === "gantt" && } - {view === "kanban" && move.mutate({ taskId, lane })} readOnly={!isAdmin && false} />} + {view === "kanban" && move.mutate({ taskId, lane })} onCardClick={isAdmin ? (t) => setEditTask(t) : undefined} />} {view === "calendar" && } )} - {open && setOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["tasks", projectId] })} />} + {tasks.length > 0 && isAdmin &&

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

} + {(open || editTask) && ( + { setOpen(false); setEditTask(null); }} + onDone={() => qc.invalidateQueries({ queryKey: ["tasks", projectId] })} + /> + )} ); } @@ -228,19 +243,28 @@ function CalendarView({ tasks }: { tasks: ProjectTask[] }) { ); } -function TaskModal({ projectId, onClose, onDone }: { projectId: string; onClose: () => void; onDone: () => void }) { - const [form, setForm] = useState({ title: "", lane: "todo", start: "", end: "", assignee: "", progress: "0" }); +function TaskModal({ projectId, task, onClose, onDone }: { projectId: string; task?: ProjectTask | null; onClose: () => void; onDone: () => void }) { + 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), + }); + 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: () => createTask(projectId, { ...form, lane: form.lane as Lane, progress: parseInt(form.progress) || 0 }), + 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, lane: e.target.value as Lane })}> {Object.entries(LANE_LABELS).map(([k, v]) => )} setForm({ ...form, progress: e.target.value })} /> @@ -257,8 +281,12 @@ function TaskModal({ projectId, onClose, onDone }: { projectId: string; onClose: function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) { const qc = useQueryClient(); const q = useQuery({ queryKey: ["contacts", projectId], queryFn: () => getContacts(projectId) }); - const [form, setForm] = useState({ name: "", title: "", phone: "", email: "" }); - const add = useMutation({ mutationFn: () => upsertContact(projectId, form), onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts", projectId] }); setForm({ name: "", title: "", phone: "", email: "" }); } }); + const empty = { id: "", name: "", title: "", phone: "", email: "" }; + const [form, setForm] = useState(empty); + const save = useMutation({ + mutationFn: () => upsertContact(projectId, { id: form.id || undefined, name: form.name, title: form.title, phone: form.phone, email: form.email }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts", projectId] }); setForm(empty); }, + }); const del = useMutation({ mutationFn: (cId: string) => deleteContact(cId), onSuccess: () => qc.invalidateQueries({ queryKey: ["contacts", projectId] }) }); return ( @@ -268,7 +296,10 @@ function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean {(q.data ?? []).map((c) => ( {c.name}{c.title}{c.phone}{c.email} - {isAdmin && } + {isAdmin && + + + } ))} {(q.data?.length ?? 0) === 0 && 담당자가 없습니다} @@ -279,7 +310,8 @@ function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean setForm({ ...form, title: e.target.value })} className="w-32" /> setForm({ ...form, phone: e.target.value })} className="w-36" /> setForm({ ...form, email: e.target.value })} className="w-44" /> - + + {form.id && }
)}
@@ -356,8 +388,15 @@ function ContractTab({ projectId }: { projectId: string }) { } function Payments({ projectId, payments, onChange }: { projectId: string; payments: PaymentSplit[]; onChange: () => void }) { - const [form, setForm] = useState({ label: "", amount: "", expectedDate: "" }); - const add = useMutation({ mutationFn: () => createPayment(projectId, { label: form.label, amount: parseFloat(form.amount) || 0, expectedDate: form.expectedDate }), onSuccess: () => { onChange(); setForm({ label: "", amount: "", expectedDate: "" }); } }); + const empty = { id: "", label: "", amount: "", expectedDate: "" }; + const [form, setForm] = useState(empty); + const save = useMutation({ + mutationFn: () => { + const body = { label: form.label, amount: parseFloat(form.amount) || 0, expectedDate: form.expectedDate }; + return form.id ? updatePayment(form.id, body) : createPayment(projectId, body); + }, + onSuccess: () => { onChange(); setForm(empty); }, + }); const togglePaid = useMutation({ mutationFn: (p: PaymentSplit) => updatePayment(p.id, { paid: !p.paid, paidDate: !p.paid ? new Date().toISOString().slice(0, 10) : "" }), onSuccess: onChange }); const del = useMutation({ mutationFn: (id: string) => deletePayment(id), onSuccess: onChange }); const total = payments.reduce((s, p) => s + p.amount, 0); @@ -377,7 +416,10 @@ function Payments({ projectId, payments, onChange }: { projectId: string; paymen {formatDate(p.expectedDate)} {p.paid ? formatDate(p.paidDate) : "—"} - + + + + ))} {payments.length === 0 && 분할 항목이 없습니다} @@ -388,7 +430,8 @@ function Payments({ projectId, payments, onChange }: { projectId: string; paymen setForm({ ...form, label: e.target.value })} className="w-36" placeholder="예: 계약금" /> setForm({ ...form, amount: e.target.value })} className="w-40" /> setForm({ ...form, expectedDate: e.target.value })} /> - + + {form.id && } ); diff --git a/src/pages/admin/Accounting.tsx b/src/pages/admin/Accounting.tsx index b29721e..05c62df 100644 --- a/src/pages/admin/Accounting.tsx +++ b/src/pages/admin/Accounting.tsx @@ -3,21 +3,23 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, } from "recharts"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus, Trash2, Pencil } from "lucide-react"; import { - getAccountingSummary, getTransactions, createTransaction, deleteTransaction, getTaxes, createTax, + getAccountingSummary, getTransactions, createTransaction, updateTransaction, deleteTransaction, + getTaxes, createTax, updateTax, deleteTax, } from "@/lib/api"; import { Card, Button, Stat, Badge, Tabs, PageHeader, Modal, Field, Input, Select, EmptyState, LoadingState, } from "@/components/ui"; import { formatKRW, formatWon, formatDate, TXN_LABELS } from "@/lib/format"; -import type { TxnKind } from "@/types"; +import type { Transaction, TxnKind } from "@/types"; export function AccountingPage() { const qc = useQueryClient(); const [tab, setTab] = useState("overview"); const [txOpen, setTxOpen] = useState(false); + const [editTx, setEditTx] = useState(null); const sumQ = useQuery({ queryKey: ["acct-summary"], queryFn: () => getAccountingSummary() }); const txQ = useQuery({ queryKey: ["transactions"], queryFn: () => getTransactions() }); const taxQ = useQuery({ queryKey: ["taxes"], queryFn: getTaxes }); @@ -82,7 +84,10 @@ export function AccountingPage() { {t.counterparty || "—"} {t.memo} = 0 ? "#067647" : "#B42318" }}>{formatWon(t.amount)} - + + + + ))} @@ -94,23 +99,29 @@ export function AccountingPage() { - {txOpen && setTxOpen(false)} onDone={() => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["acct-summary"] }); }} />} + {(txOpen || editTx) && { setTxOpen(false); setEditTx(null); }} onDone={() => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["acct-summary"] }); }} />} ); } -function TxModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) { - const [f, setF] = useState({ date: new Date().toISOString().slice(0, 10), kind: "income" as TxnKind, amount: "", counterparty: "", memo: "" }); +function TxModal({ tx, onClose, onDone }: { tx?: Transaction | null; onClose: () => void; onDone: () => void }) { + const [f, setF] = useState({ + date: tx?.date ?? new Date().toISOString().slice(0, 10), + kind: (tx?.kind ?? "income") as TxnKind, + amount: tx ? String(Math.abs(tx.amount)) : "", + counterparty: tx?.counterparty ?? "", memo: tx?.memo ?? "", + }); const m = useMutation({ mutationFn: () => { let amt = parseFloat(f.amount) || 0; if (f.kind !== "income" && amt > 0) amt = -amt; // expenses stored negative - return createTransaction({ ...f, amount: amt }); + const body = { date: f.date, kind: f.kind, amount: amt, counterparty: f.counterparty, memo: f.memo }; + return tx ? updateTransaction(tx.id, body) : createTransaction(body); }, onSuccess: () => { onDone(); onClose(); }, }); return ( - }>
@@ -126,18 +137,31 @@ function TxModal({ onClose, onDone }: { onClose: () => void; onDone: () => void } function TaxTab({ taxes, onChange }: { taxes: any[]; onChange: () => void }) { - const [f, setF] = useState({ period: "", type: "부가세", amount: "", dueDate: "" }); - const add = useMutation({ mutationFn: () => createTax({ period: f.period, type: f.type, amount: parseFloat(f.amount) || 0, dueDate: f.dueDate }), onSuccess: () => { onChange(); setF({ period: "", type: "부가세", amount: "", dueDate: "" }); } }); + const empty = { id: "", period: "", type: "부가세", amount: "", dueDate: "" }; + const [f, setF] = useState(empty); + const save = useMutation({ + mutationFn: () => { + const body = { period: f.period, type: f.type, amount: parseFloat(f.amount) || 0, dueDate: f.dueDate }; + return f.id ? updateTax(f.id, body) : createTax(body); + }, + onSuccess: () => { onChange(); setF(empty); }, + }); + const togglePaid = useMutation({ mutationFn: (t: any) => updateTax(t.id, { paid: !t.paid }), onSuccess: onChange }); + const del = useMutation({ mutationFn: (id: string) => deleteTax(id), onSuccess: onChange }); return (
- + {taxes.map((t) => ( - + + ))} - {taxes.length === 0 && } + {taxes.length === 0 && }
기간종류금액납부기한상태
기간종류금액납부기한상태
{t.period}{t.type}{formatWon(t.amount)}{formatDate(t.dueDate)}{t.paid ? : }
+ + +
세금 항목이 없습니다
세금 항목이 없습니다
@@ -145,7 +169,8 @@ function TaxTab({ taxes, onChange }: { taxes: any[]; onChange: () => void }) { setF({ ...f, type: e.target.value })} className="w-28" /> setF({ ...f, amount: e.target.value })} className="w-36" /> setF({ ...f, dueDate: e.target.value })} /> - + + {f.id && }
);