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>
401 lines
20 KiB
TypeScript
401 lines
20 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 { Textarea } from "@/components/ui/textarea";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
|
import { Plus, Wrench, Search, ChevronRight, Loader2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { money, fmtDate } from "./lib/format";
|
|
|
|
const PRIORITIES = [
|
|
{ value: "low", label: "Low", cls: "bg-slate-100 text-slate-700" },
|
|
{ value: "normal", label: "Normal", cls: "bg-blue-100 text-blue-700" },
|
|
{ value: "high", label: "High", cls: "bg-amber-100 text-amber-700" },
|
|
{ value: "urgent", label: "Urgent", cls: "bg-red-100 text-red-700" },
|
|
];
|
|
const STATUSES = [
|
|
{ value: "open", label: "Open", cls: "bg-blue-100 text-blue-700" },
|
|
{ value: "in_progress", label: "In Progress", cls: "bg-amber-100 text-amber-700" },
|
|
{ value: "on_hold", label: "On Hold", cls: "bg-slate-100 text-slate-700" },
|
|
{ value: "completed", label: "Completed", cls: "bg-emerald-100 text-emerald-700" },
|
|
{ value: "cancelled", label: "Cancelled", cls: "bg-red-100 text-red-700" },
|
|
];
|
|
|
|
const CATEGORIES = [
|
|
"General Maintenance", "Plumbing", "Electrical", "HVAC", "Landscaping",
|
|
"Roofing", "Painting", "Cleaning", "Pest Control", "Security", "Pool/Spa", "Other",
|
|
];
|
|
|
|
const EMPTY = {
|
|
title: "", description: "", priority: "normal", status: "open", category: "",
|
|
customer_id: "", vendor_id: "", assigned_to: "", property_address: "",
|
|
requested_date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
|
|
scheduled_date: "", completed_date: "", estimated_cost: "", actual_cost: "", notes: "",
|
|
};
|
|
|
|
export default function AccountingWorkOrdersPage() {
|
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
|
const cid = companyId ?? "";
|
|
const cur = "USD";
|
|
const qc = useQueryClient();
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const [editId, setEditId] = useState<string | null>(null);
|
|
const [detailId, setDetailId] = useState<string | null>(null);
|
|
const [form, setForm] = useState({ ...EMPTY });
|
|
const [search, setSearch] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const f = (patch: Partial<typeof EMPTY>) => setForm((prev) => ({ ...prev, ...patch }));
|
|
|
|
const { data: workOrders = [] } = useQuery({
|
|
queryKey: ["work-orders", cid],
|
|
enabled: !!cid,
|
|
queryFn: async () =>
|
|
(await accounting.from("work_orders").select("*, customers(name), vendors(name)").eq("company_id", cid).order("requested_date", { ascending: false })).data ?? [],
|
|
});
|
|
|
|
const { data: homeowners = [] } = useQuery({
|
|
queryKey: ["customers", cid],
|
|
enabled: !!cid,
|
|
queryFn: async () =>
|
|
(await accounting.from("customers").select("id,name,property_address").eq("company_id", cid).order("name")).data ?? [],
|
|
});
|
|
|
|
const { data: vendors = [] } = useQuery({
|
|
queryKey: ["vendors-lookup", cid],
|
|
enabled: !!cid,
|
|
queryFn: async () =>
|
|
(await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [],
|
|
});
|
|
|
|
const filtered = (workOrders as any[]).filter((wo) => {
|
|
if (statusFilter !== "all" && wo.status !== statusFilter) return false;
|
|
if (search) {
|
|
const q = search.toLowerCase();
|
|
if (!(wo.number + wo.title + (wo.customers?.name ?? "") + (wo.property_address ?? "")).toLowerCase().includes(q)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const detail = detailId ? (workOrders as any[]).find((w) => w.id === detailId) : null;
|
|
|
|
const nextNumber = async () => {
|
|
const { count } = await accounting.from("work_orders").select("*", { count: "exact", head: true }).eq("company_id", cid);
|
|
return `WO-${String((count ?? 0) + 1).padStart(5, "0")}`;
|
|
};
|
|
|
|
const openNew = async () => {
|
|
setEditId(null);
|
|
setForm({ ...EMPTY, requested_date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }) });
|
|
setOpen(true);
|
|
};
|
|
|
|
const openEdit = (wo: any) => {
|
|
setEditId(wo.id);
|
|
setForm({
|
|
title: wo.title, description: wo.description ?? "", priority: wo.priority,
|
|
status: wo.status, category: wo.category ?? "", customer_id: wo.customer_id ?? "",
|
|
vendor_id: wo.vendor_id ?? "", assigned_to: wo.assigned_to ?? "",
|
|
property_address: wo.property_address ?? "",
|
|
requested_date: wo.requested_date, scheduled_date: wo.scheduled_date ?? "",
|
|
completed_date: wo.completed_date ?? "",
|
|
estimated_cost: wo.estimated_cost ? String(wo.estimated_cost) : "",
|
|
actual_cost: wo.actual_cost ? String(wo.actual_cost) : "",
|
|
notes: wo.notes ?? "",
|
|
});
|
|
setOpen(true);
|
|
};
|
|
|
|
const save = async () => {
|
|
if (!form.title.trim()) return toast.error("Title required");
|
|
setSaving(true);
|
|
try {
|
|
const payload: any = {
|
|
company_id: cid,
|
|
title: form.title,
|
|
description: form.description || null,
|
|
priority: form.priority,
|
|
status: form.status,
|
|
category: form.category || null,
|
|
customer_id: form.customer_id || null,
|
|
vendor_id: form.vendor_id || null,
|
|
assigned_to: form.assigned_to || null,
|
|
property_address: form.property_address || null,
|
|
requested_date: form.requested_date,
|
|
scheduled_date: form.scheduled_date || null,
|
|
completed_date: form.completed_date || null,
|
|
estimated_cost: form.estimated_cost ? parseFloat(form.estimated_cost) : null,
|
|
actual_cost: form.actual_cost ? parseFloat(form.actual_cost) : null,
|
|
notes: form.notes || null,
|
|
};
|
|
|
|
if (editId) {
|
|
const { error } = await accounting.from("work_orders").update(payload).eq("id", editId);
|
|
if (error) throw new Error(error.message);
|
|
toast.success("Work order updated");
|
|
} else {
|
|
payload.number = await nextNumber();
|
|
const { error } = await accounting.from("work_orders").insert(payload);
|
|
if (error) throw new Error(error.message);
|
|
toast.success("Work order created");
|
|
}
|
|
setOpen(false);
|
|
qc.invalidateQueries({ queryKey: ["work-orders", cid] });
|
|
} catch (e: any) {
|
|
toast.error(e?.message ?? "Failed to save");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const remove = async (id: string) => {
|
|
if (!confirm("Delete this work order?")) return;
|
|
await accounting.from("work_orders").delete().eq("id", id);
|
|
if (detailId === id) setDetailId(null);
|
|
qc.invalidateQueries({ queryKey: ["work-orders", cid] });
|
|
toast.success("Deleted");
|
|
};
|
|
|
|
const priorityMeta = (v: string) => PRIORITIES.find((p) => p.value === v) ?? PRIORITIES[1];
|
|
const statusMeta = (v: string) => STATUSES.find((s) => s.value === v) ?? STATUSES[0];
|
|
|
|
// Summary counts
|
|
const counts = (workOrders as any[]).reduce((acc: any, wo: any) => {
|
|
acc[wo.status] = (acc[wo.status] ?? 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
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">Work Orders</h1>
|
|
<p className="text-sm text-muted-foreground">Track maintenance requests, repairs, and vendor assignments</p>
|
|
</div>
|
|
<Button onClick={openNew}><Plus className="h-4 w-4 mr-1" /> New Work Order</Button>
|
|
</div>
|
|
|
|
{/* Status summary strip */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{STATUSES.map((s) => (
|
|
<button
|
|
key={s.value}
|
|
onClick={() => setStatusFilter(statusFilter === s.value ? "all" : s.value)}
|
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
|
statusFilter === s.value ? s.cls + " ring-2 ring-offset-1 ring-current" : "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
}`}
|
|
>
|
|
{s.label} {counts[s.value] ? `(${counts[s.value]})` : ""}
|
|
</button>
|
|
))}
|
|
{statusFilter !== "all" && (
|
|
<button onClick={() => setStatusFilter("all")} className="rounded-full px-3 py-1 text-xs text-muted-foreground hover:text-foreground">
|
|
Show all
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative max-w-sm">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input className="pl-9" placeholder="Search #, title, homeowner, property…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[90px]">#</TableHead>
|
|
<TableHead>Title</TableHead>
|
|
<TableHead>Homeowner</TableHead>
|
|
<TableHead>Category</TableHead>
|
|
<TableHead>Priority</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Scheduled</TableHead>
|
|
<TableHead className="text-right">Est. Cost</TableHead>
|
|
<TableHead></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.map((wo: any) => {
|
|
const p = priorityMeta(wo.priority);
|
|
const s = statusMeta(wo.status);
|
|
return (
|
|
<TableRow key={wo.id} className="cursor-pointer hover:bg-muted/50" onClick={() => setDetailId(wo.id)}>
|
|
<TableCell className="font-mono text-xs text-muted-foreground">{wo.number}</TableCell>
|
|
<TableCell className="font-medium max-w-[200px] truncate">{wo.title}</TableCell>
|
|
<TableCell className="text-sm">{wo.customers?.name ?? "—"}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">{wo.category ?? "—"}</TableCell>
|
|
<TableCell><Badge className={`${p.cls} border-0 text-xs`}>{p.label}</Badge></TableCell>
|
|
<TableCell><Badge className={`${s.cls} border-0 text-xs`}>{s.label}</Badge></TableCell>
|
|
<TableCell className="text-sm">{wo.scheduled_date ? fmtDate(wo.scheduled_date) : "—"}</TableCell>
|
|
<TableCell className="text-right text-sm">{wo.estimated_cost ? money(wo.estimated_cost, cur) : "—"}</TableCell>
|
|
<TableCell>
|
|
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={(e) => { e.stopPropagation(); setDetailId(wo.id); }}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
{filtered.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={9} className="text-center text-muted-foreground py-10">
|
|
No work orders{statusFilter !== "all" ? ` with status "${statusFilter}"` : ""} yet.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Create / Edit dialog */}
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) setEditId(null); setOpen(o); }}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{editId ? "Edit Work Order" : "New Work Order"}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="col-span-2">
|
|
<Label>Title *</Label>
|
|
<Input value={form.title} onChange={(e) => f({ title: e.target.value })} placeholder="e.g. Repair pool pump" />
|
|
</div>
|
|
<div>
|
|
<Label>Priority</Label>
|
|
<Select value={form.priority} onValueChange={(v) => f({ priority: v })}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>{PRIORITIES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Status</Label>
|
|
<Select value={form.status} onValueChange={(v) => f({ status: v })}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>{STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Category</Label>
|
|
<Select value={form.category} onValueChange={(v) => f({ category: v })}>
|
|
<SelectTrigger><SelectValue placeholder="Select category" /></SelectTrigger>
|
|
<SelectContent>{CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Homeowner</Label>
|
|
<Select value={form.customer_id} onValueChange={(v) => {
|
|
const hw = (homeowners as any[]).find((h: any) => h.id === v);
|
|
f({ customer_id: v, property_address: hw?.property_address ?? form.property_address });
|
|
}}>
|
|
<SelectTrigger><SelectValue placeholder="Select homeowner" /></SelectTrigger>
|
|
<SelectContent>{(homeowners as any[]).map((h: any) => <SelectItem key={h.id} value={h.id}>{h.name}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Vendor</Label>
|
|
<Select value={form.vendor_id} onValueChange={(v) => f({ vendor_id: v })}>
|
|
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger>
|
|
<SelectContent>{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Assigned to</Label>
|
|
<Input value={form.assigned_to} onChange={(e) => f({ assigned_to: e.target.value })} placeholder="Staff name or role" />
|
|
</div>
|
|
<div className="col-span-2">
|
|
<Label>Property address</Label>
|
|
<Input value={form.property_address} onChange={(e) => f({ property_address: e.target.value })} placeholder="Auto-filled from homeowner" />
|
|
</div>
|
|
<div className="col-span-2">
|
|
<Label>Description</Label>
|
|
<Textarea rows={3} value={form.description} onChange={(e) => f({ description: e.target.value })} placeholder="Describe the issue or work needed" />
|
|
</div>
|
|
<div>
|
|
<Label>Requested date</Label>
|
|
<Input type="date" value={form.requested_date} onChange={(e) => f({ requested_date: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label>Scheduled date</Label>
|
|
<Input type="date" value={form.scheduled_date} onChange={(e) => f({ scheduled_date: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label>Completed date</Label>
|
|
<Input type="date" value={form.completed_date} onChange={(e) => f({ completed_date: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label>Estimated cost</Label>
|
|
<Input type="number" min={0} step="0.01" placeholder="0.00" value={form.estimated_cost} onChange={(e) => f({ estimated_cost: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label>Actual cost</Label>
|
|
<Input type="number" min={0} step="0.01" placeholder="0.00" value={form.actual_cost} onChange={(e) => f({ actual_cost: e.target.value })} />
|
|
</div>
|
|
<div className="col-span-2">
|
|
<Label>Internal notes</Label>
|
|
<Textarea rows={2} value={form.notes} onChange={(e) => f({ notes: e.target.value })} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
|
<Button onClick={save} disabled={saving}>{saving ? "Saving…" : editId ? "Save changes" : "Create work order"}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Detail sheet */}
|
|
<Sheet open={!!detail} onOpenChange={(o) => !o && setDetailId(null)}>
|
|
<SheetContent className="min-w-[440px] overflow-y-auto">
|
|
<SheetHeader>
|
|
<SheetTitle className="flex items-center gap-2">
|
|
<Wrench className="h-4 w-4" /> {detail?.number}
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
{detail && (
|
|
<div className="mt-4 space-y-4 text-sm">
|
|
<div className="text-lg font-semibold">{detail.title}</div>
|
|
<div className="flex gap-2">
|
|
<Badge className={`${priorityMeta(detail.priority).cls} border-0`}>{priorityMeta(detail.priority).label}</Badge>
|
|
<Badge className={`${statusMeta(detail.status).cls} border-0`}>{statusMeta(detail.status).label}</Badge>
|
|
{detail.category && <Badge variant="outline">{detail.category}</Badge>}
|
|
</div>
|
|
{detail.description && <p className="text-muted-foreground">{detail.description}</p>}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div><div className="text-xs text-muted-foreground">Homeowner</div><div>{detail.customers?.name ?? "—"}</div></div>
|
|
<div><div className="text-xs text-muted-foreground">Vendor</div><div>{detail.vendors?.name ?? "—"}</div></div>
|
|
<div><div className="text-xs text-muted-foreground">Property</div><div>{detail.property_address ?? "—"}</div></div>
|
|
<div><div className="text-xs text-muted-foreground">Assigned to</div><div>{detail.assigned_to ?? "—"}</div></div>
|
|
<div><div className="text-xs text-muted-foreground">Requested</div><div>{fmtDate(detail.requested_date)}</div></div>
|
|
<div><div className="text-xs text-muted-foreground">Scheduled</div><div>{detail.scheduled_date ? fmtDate(detail.scheduled_date) : "—"}</div></div>
|
|
<div><div className="text-xs text-muted-foreground">Completed</div><div>{detail.completed_date ? fmtDate(detail.completed_date) : "—"}</div></div>
|
|
<div><div className="text-xs text-muted-foreground">Est. cost</div><div>{detail.estimated_cost ? money(detail.estimated_cost, cur) : "—"}</div></div>
|
|
<div><div className="text-xs text-muted-foreground">Actual cost</div><div>{detail.actual_cost ? money(detail.actual_cost, cur) : "—"}</div></div>
|
|
</div>
|
|
{detail.notes && <div><div className="text-xs text-muted-foreground">Notes</div><div>{detail.notes}</div></div>}
|
|
<div className="flex gap-2 pt-2">
|
|
<Button size="sm" onClick={() => { setDetailId(null); openEdit(detail); }}>Edit</Button>
|
|
<Button size="sm" variant="destructive" onClick={() => remove(detail.id)}>Delete</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
);
|
|
}
|