mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
7.6 KiB
TypeScript
171 lines
7.6 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useState } from "react";
|
|
import { accounting } from "@/lib/accountingClient";
|
|
import { useCompanyId } from "./lib/useCompanyId";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Plus, Pencil, Trash2, Building2, Loader2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { EmptyState } from "./components/EmptyState";
|
|
|
|
const EMPTY = { name: "", email: "", phone: "", address: "" };
|
|
|
|
export default function AccountingVendorsPage() {
|
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
|
const cid = companyId ?? "";
|
|
const qc = useQueryClient();
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const [editId, setEditId] = useState<string | null>(null);
|
|
const [form, setForm] = useState({ ...EMPTY });
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const { data: vendors = [] } = useQuery({
|
|
queryKey: ["vendors", cid],
|
|
enabled: !!cid,
|
|
queryFn: async () =>
|
|
(await accounting.from("vendors").select("*").eq("company_id", cid).order("name")).data ?? [],
|
|
});
|
|
|
|
const resetForm = () => { setEditId(null); setForm({ ...EMPTY }); };
|
|
|
|
const openEdit = (v: any) => {
|
|
setEditId(v.id);
|
|
setForm({ name: v.name ?? "", email: v.email ?? "", phone: v.phone ?? "", address: v.address ?? "" });
|
|
setOpen(true);
|
|
};
|
|
|
|
const save = async () => {
|
|
if (!form.name.trim()) return toast.error("Name required");
|
|
setSaving(true);
|
|
if (editId) {
|
|
const { error } = await accounting.from("vendors").update(form).eq("id", editId);
|
|
if (error) { setSaving(false); return toast.error(error.message); }
|
|
toast.success("Vendor updated");
|
|
} else {
|
|
const { error } = await accounting.from("vendors").insert({ ...form, company_id: cid });
|
|
if (error) { setSaving(false); return toast.error(error.message); }
|
|
toast.success("Vendor added");
|
|
}
|
|
setSaving(false);
|
|
setOpen(false);
|
|
resetForm();
|
|
qc.invalidateQueries({ queryKey: ["vendors", cid] });
|
|
qc.invalidateQueries({ queryKey: ["vendors-lookup", cid] });
|
|
};
|
|
|
|
const remove = async (id: string) => {
|
|
if (!confirm("Delete this vendor?")) return;
|
|
const { error } = await accounting.from("vendors").delete().eq("id", id);
|
|
if (error) return toast.error(error.message);
|
|
qc.invalidateQueries({ queryKey: ["vendors", cid] });
|
|
qc.invalidateQueries({ queryKey: ["vendors-lookup", cid] });
|
|
};
|
|
|
|
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
|
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
|
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">Vendors</h1>
|
|
<p className="text-sm text-muted-foreground">{(vendors as any[]).length} vendor{(vendors as any[]).length !== 1 ? "s" : ""}</p>
|
|
</div>
|
|
<Button onClick={() => { resetForm(); setOpen(true); }}>
|
|
<Plus className="mr-1 h-4 w-4" /> New vendor
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
{(vendors as any[]).length === 0 ? (
|
|
<EmptyState icon={Building2} title="No vendors yet" description="Add your first vendor to start tracking bills and payments." />
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Phone</TableHead>
|
|
<TableHead>Address</TableHead>
|
|
<TableHead className="w-20"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(vendors as any[]).map((v: any) => (
|
|
<TableRow key={v.id} className="hover:bg-muted/40">
|
|
<TableCell className="font-medium">{v.name}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{v.email ? <a href={`mailto:${v.email}`} className="text-primary hover:underline">{v.email}</a> : "—"}
|
|
</TableCell>
|
|
<TableCell className="text-sm">{v.phone ?? "—"}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">{v.address ?? "—"}</TableCell>
|
|
<TableCell>
|
|
<div className="flex justify-end gap-1">
|
|
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(v)}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => remove(v.id)}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* New / Edit dialog */}
|
|
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) resetForm(); }}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{editId ? "Edit vendor" : "New vendor"}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label>Name *</Label>
|
|
<Input maxLength={120} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Acme Services, LLC" autoFocus />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label>Email</Label>
|
|
<Input type="email" maxLength={255} value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} placeholder="billing@acme.com" />
|
|
</div>
|
|
<div>
|
|
<Label>Phone</Label>
|
|
<Input maxLength={40} value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="(555) 000-0000" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label>Address <span className="text-muted-foreground text-xs font-normal">(prints on checks)</span></Label>
|
|
<Textarea
|
|
rows={3}
|
|
value={form.address}
|
|
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
|
placeholder={"123 Main Street\nSuite 100\nCity, ST 12345"}
|
|
className="resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => { setOpen(false); resetForm(); }}>Cancel</Button>
|
|
<Button onClick={save} disabled={saving || !form.name.trim()}>
|
|
{saving ? "Saving…" : editId ? "Save changes" : "Add vendor"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|