"use client"; import { Button } from "@azmr/ui/button"; import { Input } from "@azmr/ui/input"; import { MoneyField, NumberField, numberOrNull } from "@azmr/ui/number-field"; import { Form, FormErrorSummary, FormField, FormFieldArray } from "@azmr/ui/rhf"; import { zodResolver } from "@azmr/ui/zod"; import { useState } from "react"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; const GST_RATE = 0.15; const COLUMNS = "gap-3 sm:grid-cols-[minmax(0,1fr)_6rem_8rem_6rem]"; const nzd = new Intl.NumberFormat("en-NZ", { style: "currency", currency: "NZD" }); const line = z.object({ description: z.string().trim().min(1, "Enter a description"), quantity: z.number("Enter a quantity").positive("Enter a quantity above 0"), unitPrice: z.number("Enter a unit price").min(0, "Enter a price of $0 or more"), }); const schema = z.object({ lines: z.array(line).min(1, "Add at least one line").max(20, "Use 20 lines or fewer"), }); type InvoiceInput = z.input; type InvoiceOutput = z.output; type LineInput = InvoiceInput["lines"][number]; /** A line's amount in cents, so totals add up exactly. */ const lineCents = (value: Partial | undefined) => Math.round((value?.quantity ?? 0) * (value?.unitPrice ?? 0) * 100); function totals(lines: Partial[]) { const subtotal = lines.reduce((sum, value) => sum + lineCents(value), 0); const gst = Math.round(subtotal * GST_RATE); return { subtotal, gst, total: subtotal + gst }; } function LineAmount({ index, label }: { index: number; label: string }) { const value = useWatch({ name: `lines.${index}` }); return ( {nzd.format(lineCents(value) / 100)} ); } export default function FieldArrayInvoice() { const [saved, setSaved] = useState(); const form = useForm({ resolver: zodResolver(schema), defaultValues: { lines: [ { description: "Callout fee", quantity: 1, unitPrice: 95 }, { description: "Labour (hours)", quantity: 2.5, unitPrice: 88 }, ], }, shouldFocusError: false, }); return (
setSaved( `Saved ${values.lines.length} lines, ${nzd.format(totals(values.lines).total / 100)} incl. GST.`, ) } className="flex w-full max-w-3xl flex-col gap-6" > name="lines" legend="Line items" description="Prices exclude GST." itemLabel={(index) => `Line ${index + 1}`} max={20} newItem={() => ({ description: "", quantity: 1, unitPrice: 0 })} messages={{ add: "Add line" }} className="[&_[data-slot=field-array-row]]:items-start" header={
Description Qty Unit price Amount
} footer={(lines) => { const { subtotal, gst, total } = totals(lines); return (
Subtotal
{nzd.format(subtotal / 100)}
GST (15%)
{nzd.format(gst / 100)}
Total
{nzd.format(total / 100)}
); }} > {(_, row) => { // Wide screens show the column headings instead of a label on every field; each field // keeps its full name ("Line 2 Qty") for screen readers. const label = (text: string) => ( <> {row.label} {text} ); return (
); }}

{saved ?? ""}

); }