feat: 2차 수정/삭제 일괄 — 작업자 portion·담당자·분할입금·작업(칸반 카드)·거래·세금 수정/삭제
All checks were successful
build-and-push / build (push) Successful in 34s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-28 11:57:13 +09:00
parent 851a19ea5f
commit c29e3af9c2
3 changed files with 114 additions and 45 deletions

View File

@ -11,8 +11,8 @@ const LANE_DOT: Record<Lane, string> = {
};
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({
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={readOnly ? undefined : onDragEnd}>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
{LANES.map((lane) => (
<Column key={lane} lane={lane} tasks={tasks.filter((t) => t.lane === lane)} readOnly={readOnly} />
<Column key={lane} lane={lane} tasks={tasks.filter((t) => t.lane === lane)} onCardClick={onCardClick} readOnly={readOnly} />
))}
</div>
</DndContext>
);
}
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 (
<div ref={setNodeRef} className={classNames("rounded-card border bg-canvas/60 min-h-[200px] transition-colors", isOver ? "border-navy bg-navy-subtle/40" : "border-border")}>
@ -45,22 +45,23 @@ function Column({ lane, tasks, readOnly }: { lane: Lane; tasks: ProjectTask[]; r
<span className="ml-auto text-xs text-ink-muted font-num">{tasks.length}</span>
</div>
<div className="p-2 space-y-2">
{tasks.map((t) => <KanbanCard key={t.id} task={t} readOnly={readOnly} />)}
{tasks.map((t) => <KanbanCard key={t.id} task={t} onCardClick={onCardClick} readOnly={readOnly} />)}
{tasks.length === 0 && <div className="text-xs text-ink-muted text-center py-6"> </div>}
</div>
</div>
);
}
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 (
<div
ref={setNodeRef} style={style} {...(readOnly ? {} : listeners)} {...attributes}
onClick={() => { 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"
)}
>
<div className="text-sm font-medium text-ink">{task.title}</div>

View File

@ -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<string | null>(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 }
<td>{pm.memberEmail}</td>
<td>{pm.role}</td>
<td className="text-right tabular font-medium">{pm.portion}%</td>
{isAdmin && <td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(pm.id)}><Trash2 size={15} /></button></td>}
{isAdmin && <td className="text-right whitespace-nowrap">
<button className="text-ink-muted hover:text-ink mr-2" onClick={() => { setEditId(pm.id); setEmail(pm.memberEmail); setPortion(String(pm.portion)); setRole(pm.role); }}><Pencil size={15} /></button>
<button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(pm.id)}><Trash2 size={15} /></button>
</td>}
</tr>
))}
{(q.data?.length ?? 0) === 0 && <tr><td colSpan={4} className="text-center text-ink-muted py-6"> </td></tr>}
@ -131,10 +136,11 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }
</table>
{isAdmin && (
<div className="flex flex-wrap items-end gap-2 mt-4 p-3 bg-canvas rounded-control">
<Field label="작업자 이메일"><Input value={email} onChange={(e) => setEmail(e.target.value)} className="w-56" /></Field>
<Field label="작업자 이메일"><Input value={email} onChange={(e) => setEmail(e.target.value)} className="w-56" disabled={!!editId} /></Field>
<Field label="역할"><Input value={role} onChange={(e) => setRole(e.target.value)} className="w-32" /></Field>
<Field label="기여도 %"><Input type="number" value={portion} onChange={(e) => setPortion(e.target.value)} className="w-24" /></Field>
<Button icon={<Plus size={15} />} disabled={!email || add.isPending} onClick={() => add.mutate()}></Button>
<Button icon={editId ? undefined : <Plus size={15} />} disabled={!email || save.isPending} onClick={() => save.mutate()}>{editId ? "수정" : "추가"}</Button>
{editId && <Button variant="ghost" onClick={reset}></Button>}
</div>
)}
</div>
@ -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<ProjectTask | null>(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 ? <LoadingState /> : tasks.length === 0 ? <EmptyState title="작업이 없습니다" /> : (
<>
{view === "gantt" && <Gantt tasks={tasks} />}
{view === "kanban" && <Kanban tasks={tasks} onMove={(taskId, lane) => move.mutate({ taskId, lane })} readOnly={!isAdmin && false} />}
{view === "kanban" && <Kanban tasks={tasks} onMove={(taskId, lane) => move.mutate({ taskId, lane })} onCardClick={isAdmin ? (t) => setEditTask(t) : undefined} />}
{view === "calendar" && <CalendarView tasks={tasks} />}
</>
)}
{open && <TaskModal projectId={projectId} onClose={() => setOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["tasks", projectId] })} />}
{tasks.length > 0 && isAdmin && <p className="text-xs text-ink-muted mt-2"> · .</p>}
{(open || editTask) && (
<TaskModal
projectId={projectId}
task={editTask}
onClose={() => { setOpen(false); setEditTask(null); }}
onDone={() => qc.invalidateQueries({ queryKey: ["tasks", projectId] })}
/>
)}
</div>
);
}
@ -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 (
<Modal open onClose={onClose} title="작업 추가"
footer={<><Button variant="secondary" onClick={onClose}></Button><Button disabled={!form.title || m.isPending} onClick={() => m.mutate()}></Button></>}>
<Modal open onClose={onClose} title={task ? "작업 수정" : "작업 추가"}
footer={<>
{task && <Button variant="danger" onClick={() => del.mutate()} className="mr-auto"></Button>}
<Button variant="secondary" onClick={onClose}></Button>
<Button disabled={!form.title || m.isPending} onClick={() => m.mutate()}>{task ? "저장" : "추가"}</Button>
</>}>
<div className="space-y-4">
<Field label="작업명"><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></Field>
<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 })}>
<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>)}
</Select></Field>
<Field label="진척 %"><Input type="number" value={form.progress} onChange={(e) => setForm({ ...form, progress: e.target.value })} /></Field>
@ -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
<tbody>
{(q.data ?? []).map((c) => (
<tr key={c.id}><td>{c.name}</td><td>{c.title}</td><td className="tabular">{c.phone}</td><td>{c.email}</td>
{isAdmin && <td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(c.id)}><Trash2 size={15} /></button></td>}</tr>
{isAdmin && <td className="text-right whitespace-nowrap">
<button className="text-ink-muted hover:text-ink mr-2" onClick={() => setForm({ id: c.id, name: c.name, title: c.title, phone: c.phone, email: c.email })}><Pencil size={15} /></button>
<button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(c.id)}><Trash2 size={15} /></button>
</td>}</tr>
))}
{(q.data?.length ?? 0) === 0 && <tr><td colSpan={5} className="text-center text-ink-muted py-6"> </td></tr>}
</tbody>
@ -279,7 +310,8 @@ function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean
<Field label="직무"><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} className="w-32" /></Field>
<Field label="연락처"><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className="w-36" /></Field>
<Field label="이메일"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="w-44" /></Field>
<Button icon={<Plus size={15} />} disabled={!form.name || add.isPending} onClick={() => add.mutate()}></Button>
<Button icon={form.id ? undefined : <Plus size={15} />} disabled={!form.name || save.isPending} onClick={() => save.mutate()}>{form.id ? "수정" : "추가"}</Button>
{form.id && <Button variant="ghost" onClick={() => setForm(empty)}></Button>}
</div>
)}
</div>
@ -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
<td className="tabular">{formatDate(p.expectedDate)}</td>
<td className="tabular">{p.paid ? formatDate(p.paidDate) : "—"}</td>
<td><button onClick={() => togglePaid.mutate(p)}>{p.paid ? <Badge label="입금완료" fg="#067647" bg="#DCFAE6" /> : <Badge label="대기" fg="#475467" bg="#F2F4F7" />}</button></td>
<td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(p.id)}><Trash2 size={15} /></button></td>
<td className="text-right whitespace-nowrap">
<button className="text-ink-muted hover:text-ink mr-2" onClick={() => setForm({ id: p.id, label: p.label, amount: String(p.amount), expectedDate: p.expectedDate })}><Pencil size={15} /></button>
<button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(p.id)}><Trash2 size={15} /></button>
</td>
</tr>
))}
{payments.length === 0 && <tr><td colSpan={6} className="text-center text-ink-muted py-6"> </td></tr>}
@ -388,7 +430,8 @@ function Payments({ projectId, payments, onChange }: { projectId: string; paymen
<Field label="항목명"><Input value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} className="w-36" placeholder="예: 계약금" /></Field>
<Field label="금액"><Input type="number" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} className="w-40" /></Field>
<Field label="예상일"><Input type="date" value={form.expectedDate} onChange={(e) => setForm({ ...form, expectedDate: e.target.value })} /></Field>
<Button icon={<Plus size={15} />} disabled={!form.label || add.isPending} onClick={() => add.mutate()}></Button>
<Button icon={form.id ? undefined : <Plus size={15} />} disabled={!form.label || save.isPending} onClick={() => save.mutate()}>{form.id ? "수정" : "추가"}</Button>
{form.id && <Button variant="ghost" onClick={() => setForm(empty)}></Button>}
</div>
</Card>
);

View File

@ -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<Transaction | null>(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() {
<td>{t.counterparty || "—"}</td>
<td className="text-ink-secondary">{t.memo}</td>
<td className="text-right tabular font-medium" style={{ color: t.amount >= 0 ? "#067647" : "#B42318" }}>{formatWon(t.amount)}</td>
<td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={() => deleteTransaction(t.id).then(() => qc.invalidateQueries({ queryKey: ["transactions"] }))}><Trash2 size={15} /></button></td>
<td className="text-right whitespace-nowrap">
<button className="text-ink-muted hover:text-ink mr-2" onClick={() => setEditTx(t)}><Pencil size={15} /></button>
<button className="text-ink-muted hover:text-money-out" onClick={() => deleteTransaction(t.id).then(() => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["acct-summary"] }); })}><Trash2 size={15} /></button>
</td>
</tr>
))}
</tbody>
@ -94,23 +99,29 @@ export function AccountingPage() {
</div>
</Card>
{txOpen && <TxModal onClose={() => setTxOpen(false)} onDone={() => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["acct-summary"] }); }} />}
{(txOpen || editTx) && <TxModal tx={editTx} onClose={() => { setTxOpen(false); setEditTx(null); }} onDone={() => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["acct-summary"] }); }} />}
</div>
);
}
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 (
<Modal open onClose={onClose} title="거래 입력"
<Modal open onClose={onClose} title={tx ? "거래 수정" : "거래 입력"}
footer={<><Button variant="secondary" onClick={onClose}></Button><Button disabled={!f.amount || m.isPending} onClick={() => m.mutate()}></Button></>}>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@ -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 (
<div>
<table className="dense-table mb-4">
<thead><tr><th></th><th></th><th className="text-right"></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th></th><th className="text-right"></th><th></th><th></th><th></th></tr></thead>
<tbody>
{taxes.map((t) => (
<tr key={t.id}><td>{t.period}</td><td>{t.type}</td><td className="text-right tabular">{formatWon(t.amount)}</td><td className="tabular">{formatDate(t.dueDate)}</td>
<td>{t.paid ? <Badge label="납부완료" fg="#067647" bg="#DCFAE6" size="sm" /> : <Badge label="예정" fg="#B54708" bg="#FEF0C7" size="sm" />}</td></tr>
<td><button onClick={() => togglePaid.mutate(t)}>{t.paid ? <Badge label="납부완료" fg="#067647" bg="#DCFAE6" size="sm" /> : <Badge label="예정" fg="#B54708" bg="#FEF0C7" size="sm" />}</button></td>
<td className="text-right whitespace-nowrap">
<button className="text-ink-muted hover:text-ink mr-2" onClick={() => setF({ id: t.id, period: t.period, type: t.type, amount: String(t.amount), dueDate: t.dueDate })}><Pencil size={15} /></button>
<button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(t.id)}><Trash2 size={15} /></button>
</td></tr>
))}
{taxes.length === 0 && <tr><td colSpan={5} className="text-center text-ink-muted py-6"> </td></tr>}
{taxes.length === 0 && <tr><td colSpan={6} className="text-center text-ink-muted py-6"> </td></tr>}
</tbody>
</table>
<div className="flex flex-wrap items-end gap-2 p-3 bg-canvas rounded-control">
@ -145,7 +169,8 @@ function TaxTab({ taxes, onChange }: { taxes: any[]; onChange: () => void }) {
<Field label="종류"><Input value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} className="w-28" /></Field>
<Field label="금액"><Input type="number" value={f.amount} onChange={(e) => setF({ ...f, amount: e.target.value })} className="w-36" /></Field>
<Field label="납부기한"><Input type="date" value={f.dueDate} onChange={(e) => setF({ ...f, dueDate: e.target.value })} /></Field>
<Button icon={<Plus size={15} />} disabled={!f.period || add.isPending} onClick={() => add.mutate()}></Button>
<Button icon={f.id ? undefined : <Plus size={15} />} disabled={!f.period || save.isPending} onClick={() => save.mutate()}>{f.id ? "수정" : "추가"}</Button>
{f.id && <Button variant="ghost" onClick={() => setF(empty)}></Button>}
</div>
</div>
);