RJBULK-APP/components/forms/send-statement-form.tsx

265 lines
11 KiB
TypeScript
Raw Normal View History

"use client";
import { useActionState, useEffect, useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select } from "@/components/ui/select";
import { Combobox } from "@/components/ui/combobox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Eye, Send, FileCheck2 } from "lucide-react";
import { bulkUpdateStatus, type ActionResult as DNActionResult } from "@/actions/delivery-notes";
import { sendStatement, type ActionResult } from "@/actions/statements";
import { toast } from "sonner";
import type { Customer, DeliveryNote } from "@/lib/types";
import { formatCurrency, formatDate, formatNumber } from "@/lib/utils";
const initial: ActionResult = { ok: false, error: "" };
interface Props {
customers: Customer[];
preselectedCustomerId?: string;
preselectedEmail?: string;
fromEmail: string;
ccEmail: string;
}
function rangeDates(range: string, customStart?: string, customEnd?: string) {
const now = new Date();
if (range === "this-month") {
return {
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
};
}
if (range === "last-month") {
return {
start: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10),
};
}
if (customStart && customEnd) return { start: customStart, end: customEnd };
return { start: "", end: "" };
}
export function SendStatementForm({ customers, preselectedCustomerId, preselectedEmail, fromEmail, ccEmail }: Props) {
const router = useRouter();
const [customerId, setCustomerId] = useState(preselectedCustomerId || "");
const [range, setRange] = useState("this-month");
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const [recipientEmail, setRecipientEmail] = useState(preselectedEmail || "");
const [includePricing, setIncludePricing] = useState(true);
const [notes, setNotes] = useState<DeliveryNote[]>([]);
const [notesLoading, setNotesLoading] = useState(false);
const [state, action, pending] = useActionState(sendStatement, initial);
const [, startMarkTransition] = useTransition();
const customerMap = useMemo(() => new Map(customers.map((c) => [c.id, c])), [customers]);
const customer = customerId ? customerMap.get(customerId) : null;
// Auto-fill recipient from customer email
useEffect(() => {
if (customer?.email && !recipientEmail) {
setRecipientEmail(customer.email);
}
}, [customer, recipientEmail]);
// Fetch pending notes for the current selection
useEffect(() => {
if (!customerId) {
setNotes([]);
return;
}
const { start, end } = rangeDates(range, customStart, customEnd);
if (!start || !end) return;
setNotesLoading(true);
fetch(`/api/notes-for-statement?customer=${customerId}&start=${start}&end=${end}`)
.then((r) => r.json())
.then((d) => setNotes(d.notes || []))
.catch(() => setNotes([]))
.finally(() => setNotesLoading(false));
}, [customerId, range, customStart, customEnd]);
useEffect(() => {
if (state.ok) {
if (state.data === undefined) {
toast.success("Statement sent");
router.refresh();
}
} else if (state.error) { toast.error(state.error); }
}, [state, router]);
const previewUrl = customerId
? `/api/statements/preview?customer=${customerId}&range=${range}${range === "custom" ? `&start=${customStart}&end=${customEnd}` : ""}&include_pricing=${includePricing}`
: "";
function onMarkInvoiced() {
if (notes.length === 0) {
toast.error("No notes to mark");
return;
}
startMarkTransition(async () => {
const res = await bulkUpdateStatus(notes.map((n) => n.id), "Invoiced");
if (res.ok) {
toast.success("Marked as Invoiced");
router.refresh();
} else {
toast.error(res.error);
}
});
}
const totalQty = notes.reduce((a, n) => a + (n.qty_litres_supplied || 0), 0);
const totalValue = notes.reduce((a, n) => a + (n.total_value || 0), 0);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Statement details</CardTitle>
<CardDescription>Choose a customer and date range, then preview or send.</CardDescription>
</CardHeader>
<CardContent>
<form action={action} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="customer">Customer *</Label>
<Combobox
id="customer"
name="customer"
value={customerId}
onChange={setCustomerId}
items={customers.map((c) => ({ value: c.id, label: c.name }))}
placeholder="Search customers…"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="range">Date range</Label>
<Select
id="range"
name="range"
value={range}
onChange={(e) => setRange(e.target.value)}
options={[
{ value: "this-month", label: "This Month" },
{ value: "last-month", label: "Last Month" },
{ value: "custom", label: "Custom" },
]}
/>
</div>
</div>
{range === "custom" && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="start">Start date</Label>
<Input id="start" name="start" type="date" value={customStart} onChange={(e) => setCustomStart(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="end">End date</Label>
<Input id="end" name="end" type="date" value={customEnd} onChange={(e) => setCustomEnd(e.target.value)} />
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="recipient_email">Recipient email *</Label>
<Input
id="recipient_email"
name="recipient_email"
type="email"
value={recipientEmail}
onChange={(e) => setRecipientEmail(e.target.value)}
required
/>
{ccEmail && (
<p className="text-xs text-muted-foreground">CC: {ccEmail}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="include_pricing" className="flex items-center gap-2">
<Checkbox
id="include_pricing"
name="include_pricing"
checked={includePricing}
onCheckedChange={(v) => setIncludePricing(!!v)}
/>
Include pricing on statement
</Label>
<p className="text-xs text-muted-foreground">
If off, the statement will only list quantities (no prices).
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pt-2">
<Button type="button" variant="outline" asChild disabled={!previewUrl}>
<a href={previewUrl} target="_blank" rel="noopener">
<Eye className="h-4 w-4" /> Preview statement
</a>
</Button>
<Button type="submit" disabled={pending || notes.length === 0}>
<Send className="h-4 w-4" /> {pending ? "Sending…" : "Send statement"}
</Button>
<Button type="button" variant="ghost" onClick={onMarkInvoiced} disabled={notes.length === 0}>
<FileCheck2 className="h-4 w-4" /> Mark as Invoiced
</Button>
<span className="ml-auto text-xs text-muted-foreground">
From: {fromEmail || "(not configured)"}
</span>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pending delivery notes</CardTitle>
<CardDescription>
{notesLoading
? "Loading…"
: notes.length === 0
? "No notes in this range"
: `${notes.length} note${notes.length !== 1 ? "s" : ""} • ${formatNumber(totalQty)} L • ${formatCurrency(totalValue)}`}
</CardDescription>
</CardHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Note #</TableHead>
<TableHead>Date</TableHead>
<TableHead>Vehicle</TableHead>
<TableHead className="text-right">Qty (L)</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{notes.length === 0 && !notesLoading && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No notes to include.
</TableCell>
</TableRow>
)}
{notes.map((n) => (
<TableRow key={n.id}>
<TableCell className="font-medium">{n.number}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(n.date)}</TableCell>
<TableCell className="text-muted-foreground">{n.vehicle_registration || "—"}</TableCell>
<TableCell className="text-right">{formatNumber(n.qty_litres_supplied)}</TableCell>
<TableCell className="text-right">{formatCurrency(n.total_value)}</TableCell>
<TableCell>{n.status ? <Badge variant="outline">{n.status}</Badge> : <Badge variant="warning">Pending</Badge>}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
);
}