265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|