feat: 2차 수정/삭제 일괄 — 작업자 portion·담당자·분할입금·작업(칸반 카드)·거래·세금 수정/삭제
All checks were successful
build-and-push / build (push) Successful in 34s
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:
parent
851a19ea5f
commit
c29e3af9c2
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user