RJBULK-APP/actions/statements.ts
SJCoetz 1efa979443 feat: initial setup of rj bulk supplies backoffice app
Adds complete initial codebase for the RJ Bulk Supplies fuel delivery backoffice:
- Authentication pages and protected route layouts
- Customer, supplier, fuel purchase, and delivery note management
- Custom reporting and customer statement sending tools
- Full UI component library and Tailwind styling
- Server actions, API routes, and PocketBase integration
- Development scripts and project configuration updates
- Updated README and package metadata
2026-07-19 20:38:16 +02:00

171 lines
6.6 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Resend } from "resend";
import { pbCreate, pbList, pbUpdate } from "@/lib/pocketbase";
import { getAuth } from "@/lib/auth";
import { pbId } from "@/lib/pb";
import { generateCustomerStatement } from "@/lib/pdf/customer-statement";
import type { Customer, DeliveryNote, FuelSettings } from "@/lib/types";
export type ActionResult = { ok: true; data?: any } | { ok: false; error: string };
const sendSchema = z.object({
customer: z.string().min(1, "Customer is required"),
range: z.enum(["this-month", "last-month", "custom"]).default("this-month"),
start: z.string().optional(),
end: z.string().optional(),
recipient_email: z.string().email("Valid email required"),
include_pricing: z.coerce.boolean().default(true),
});
function rangeDates(range: string, start?: string, end?: string): { start: string; end: string } {
const now = new Date();
if (range === "this-month") {
const s = new Date(now.getFullYear(), now.getMonth(), 1);
const e = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return { start: s.toISOString().slice(0, 10), end: e.toISOString().slice(0, 10) };
}
if (range === "last-month") {
const s = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const e = new Date(now.getFullYear(), now.getMonth(), 0);
return { start: s.toISOString().slice(0, 10), end: e.toISOString().slice(0, 10) };
}
if (range === "custom" && start && end) {
return { start, end };
}
return { start: start || "", end: end || "" };
}
export async function previewStatement(_: unknown, formData: FormData): Promise<ActionResult> {
// For preview, we just need to return the data — the actual PDF is generated by the API route.
// But we can validate here too.
const parsed = sendSchema.safeParse({
customer: formData.get("customer"),
range: formData.get("range") || "this-month",
start: formData.get("start") || undefined,
end: formData.get("end") || undefined,
recipient_email: formData.get("recipient_email"),
include_pricing: formData.get("include_pricing") === "on" || formData.get("include_pricing") === "true",
});
if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
return { ok: true, data: parsed.data };
}
export async function sendStatement(_: unknown, formData: FormData): Promise<ActionResult> {
const { token, user } = await getAuth();
if (!token) return { ok: false, error: "Not signed in" };
const parsed = sendSchema.safeParse({
customer: formData.get("customer"),
range: formData.get("range") || "this-month",
start: formData.get("start") || undefined,
end: formData.get("end") || undefined,
recipient_email: formData.get("recipient_email"),
include_pricing: formData.get("include_pricing") === "on" || formData.get("include_pricing") === "true",
});
if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
const { start, end } = rangeDates(parsed.data.range, parsed.data.start, parsed.data.end);
if (!start || !end) return { ok: false, error: "Date range is required" };
try {
// Load customer
const customer = await pbList<Customer>("customers", { perPage: 1, filter: `id = "${parsed.data.customer}"` }, token);
if (customer.items.length === 0) return { ok: false, error: "Customer not found" };
const cust = customer.items[0];
// Load notes in range
const notes = await pbList<DeliveryNote>(
"delivery_notes",
{
perPage: 1000,
filter: `customer = "${parsed.data.customer}" && date >= "${start}" && date <= "${end}"`,
sort: "date",
},
token,
);
if (notes.items.length === 0) {
return { ok: false, error: "No delivery notes in this range" };
}
// Load settings
const settings = await pbList<FuelSettings>("fuel_settings", { perPage: 1 }, token);
const settingsRow = settings.items[0] || null;
// Generate PDF
const pdfBytes = await generateCustomerStatement({
customer: cust,
notes: notes.items,
startDate: start,
endDate: end,
includePricing: parsed.data.include_pricing,
companySettings: settingsRow,
});
// Email via Resend
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) return { ok: false, error: "RESEND_API_KEY not configured" };
const fromAddr = process.env.RESEND_FROM || settingsRow?.statement_from_email || "statements@rjbulk.co.za";
const resend = new Resend(apiKey);
const cc = settingsRow?.statement_cc_email ? [settingsRow.statement_cc_email] : undefined;
const totalQty = notes.items.reduce((a, n) => a + n.qty_litres_supplied, 0);
const totalValue = notes.items.reduce((a, n) => a + (n.total_value || 0), 0);
const result = await resend.emails.send({
from: fromAddr,
to: parsed.data.recipient_email,
cc,
subject: `Delivery Statement — ${cust.name} (${start} to ${end})`,
html: `<p>Hi ${cust.name},</p>
<p>Please find your delivery statement attached for the period <strong>${start}</strong> to <strong>${end}</strong>.</p>
<p>Total litres delivered: <strong>${totalQty.toFixed(2)} L</strong><br/>
Total value: <strong>R ${totalValue.toFixed(2)}</strong></p>
<p>Thank you for your business.</p>
<p>— RJ Bulk Supplies</p>`,
attachments: [
{
filename: `delivery_statement_${cust.name.replace(/\s+/g, "_")}_${start.replace(/-/g, "")}.pdf`,
content: Buffer.from(pdfBytes),
},
],
});
if (result.error) {
return { ok: false, error: result.error.message || "Email send failed" };
}
// Notes stay "Pending" — the customer pays separately (EFT / CASH / CARD).
// We don't auto-flip the status here; the owner marks notes as paid manually.
// Log
await pbCreate("statement_log", {
id: pbId(),
action: "Email Sent",
details: `${cust.name} — ${notes.items.length} note(s) — ${start} to ${end}`,
actor: user?.id,
}, token);
revalidatePath("/delivery-notes");
revalidatePath("/statements");
return { ok: true };
} catch (err: any) {
return { ok: false, error: err?.message || "Failed to send statement" };
}
}
export async function logPreview(customerId: string, customerName: string, start: string, end: string): Promise<void> {
const { token, user } = await getAuth();
if (!token) return;
try {
await pbCreate("statement_log", {
id: pbId(),
action: "Preview Generated",
details: `${customerName} — ${start} to ${end}`,
actor: user?.id,
}, token);
} catch {}
}