Files
acmcc/src/pages/accounting/AccountingWorkOrdersPage.tsx
T
2026-06-01 20:19:26 -04:00

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>
);
}