Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 00:52:38 +00:00
co-authored by renee-png
parent 4715dcc4f7
commit f20ca5bd67
3 changed files with 803 additions and 6 deletions
+56 -6
View File
@@ -13,8 +13,10 @@ import { Route as SetupRouteImport } from './routes/setup'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
import { Route as ClientsIndexRouteImport } from './routes/clients.index'
import { Route as CasesIndexRouteImport } from './routes/cases.index'
import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId'
import { Route as CasesNewRouteImport } from './routes/cases.new'
import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId'
@@ -40,6 +42,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsIndexRoute = SettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SettingsRoute,
} as any)
const ClientsIndexRoute = ClientsIndexRouteImport.update({
id: '/clients/',
path: '/clients/',
@@ -50,6 +57,11 @@ const CasesIndexRoute = CasesIndexRouteImport.update({
path: '/cases/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsFeesRoute = SettingsFeesRouteImport.update({
id: '/fees',
path: '/fees',
getParentRoute: () => SettingsRoute,
} as any)
const ClientsClientIdRoute = ClientsClientIdRouteImport.update({
id: '/clients/$clientId',
path: '/clients/$clientId',
@@ -74,39 +86,44 @@ const AdminUsersRoute = AdminUsersRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/settings': typeof SettingsRoute
'/settings': typeof SettingsRouteWithChildren
'/setup': typeof SetupRoute
'/admin/users': typeof AdminUsersRoute
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/cases/': typeof CasesIndexRoute
'/clients/': typeof ClientsIndexRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/settings': typeof SettingsRoute
'/setup': typeof SetupRoute
'/admin/users': typeof AdminUsersRoute
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/cases': typeof CasesIndexRoute
'/clients': typeof ClientsIndexRoute
'/settings': typeof SettingsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/settings': typeof SettingsRoute
'/settings': typeof SettingsRouteWithChildren
'/setup': typeof SetupRoute
'/admin/users': typeof AdminUsersRoute
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/cases/': typeof CasesIndexRoute
'/clients/': typeof ClientsIndexRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -119,20 +136,23 @@ export interface FileRouteTypes {
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/settings/fees'
| '/cases/'
| '/clients/'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/login'
| '/settings'
| '/setup'
| '/admin/users'
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/settings/fees'
| '/cases'
| '/clients'
| '/settings'
id:
| '__root__'
| '/'
@@ -143,14 +163,16 @@ export interface FileRouteTypes {
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/settings/fees'
| '/cases/'
| '/clients/'
| '/settings/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
SettingsRoute: typeof SettingsRoute
SettingsRoute: typeof SettingsRouteWithChildren
SetupRoute: typeof SetupRoute
AdminUsersRoute: typeof AdminUsersRoute
CasesCaseIdRoute: typeof CasesCaseIdRoute
@@ -190,6 +212,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/': {
id: '/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof SettingsIndexRouteImport
parentRoute: typeof SettingsRoute
}
'/clients/': {
id: '/clients/'
path: '/clients'
@@ -204,6 +233,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CasesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/fees': {
id: '/settings/fees'
path: '/fees'
fullPath: '/settings/fees'
preLoaderRoute: typeof SettingsFeesRouteImport
parentRoute: typeof SettingsRoute
}
'/clients/$clientId': {
id: '/clients/$clientId'
path: '/clients/$clientId'
@@ -235,10 +271,24 @@ declare module '@tanstack/react-router' {
}
}
interface SettingsRouteChildren {
SettingsFeesRoute: typeof SettingsFeesRoute
SettingsIndexRoute: typeof SettingsIndexRoute
}
const SettingsRouteChildren: SettingsRouteChildren = {
SettingsFeesRoute: SettingsFeesRoute,
SettingsIndexRoute: SettingsIndexRoute,
}
const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
SettingsRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
SettingsRoute: SettingsRoute,
SettingsRoute: SettingsRouteWithChildren,
SetupRoute: SetupRoute,
AdminUsersRoute: AdminUsersRoute,
CasesCaseIdRoute: CasesCaseIdRoute,
+400
View File
@@ -0,0 +1,400 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Clock, Loader2, Pencil, Plus, Receipt, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { formatCurrency } from "@/lib/format";
export const Route = createFileRoute("/settings/fees")({
component: FeeSchedulePage,
});
interface FeeItem {
id: string;
name: string;
description: string | null;
category: "time" | "expense";
amount: number;
billable: boolean;
active: boolean;
sort_order: number;
}
function FeeSchedulePage() {
const [items, setItems] = useState<FeeItem[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Partial<FeeItem> | null>(null);
const load = async () => {
setLoading(true);
const { data } = await supabase
.from("fee_schedule_items")
.select("*")
.order("category")
.order("sort_order")
.order("name");
setItems((data ?? []) as FeeItem[]);
setLoading(false);
};
useEffect(() => {
load();
}, []);
const remove = async (id: string) => {
if (!confirm("Delete this fee item?")) return;
const { error } = await supabase
.from("fee_schedule_items")
.delete()
.eq("id", id);
if (error) toast.error("Could not delete", { description: error.message });
else {
toast.success("Fee item deleted");
load();
}
};
const timeItems = items.filter((i) => i.category === "time");
const expenseItems = items.filter((i) => i.category === "expense");
return (
<div className="space-y-6 max-w-5xl">
<div className="flex justify-between items-start gap-4 flex-wrap">
<p className="text-sm text-muted-foreground max-w-2xl">
Define reusable billable items. Time fees set the hourly rate when
logging time; expense fees auto-fill the amount when adding an
expense. The billable flag becomes the default for new entries.
</p>
<Button onClick={() => setEditing({ category: "time", billable: true, active: true, amount: 0 })}>
<Plus className="h-4 w-4 mr-2" /> Add fee item
</Button>
</div>
<FeeSection
title="Time fees"
icon={<Clock className="h-4 w-4 text-muted-foreground" />}
unit="/ hr"
items={timeItems}
loading={loading}
onEdit={setEditing}
onDelete={remove}
/>
<FeeSection
title="Expense fees"
icon={<Receipt className="h-4 w-4 text-muted-foreground" />}
unit=""
items={expenseItems}
loading={loading}
onEdit={setEditing}
onDelete={remove}
/>
<FeeEditDialog
item={editing}
onClose={() => setEditing(null)}
onSaved={load}
/>
</div>
);
}
function FeeSection({
title,
icon,
unit,
items,
loading,
onEdit,
onDelete,
}: {
title: string;
icon: React.ReactNode;
unit: string;
items: FeeItem[];
loading: boolean;
onEdit: (i: FeeItem) => void;
onDelete: (id: string) => void;
}) {
return (
<div>
<h3 className="font-serif text-lg flex items-center gap-2 mb-2">
{icon}
{title}
</h3>
<Card className="border-border/60">
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground text-sm">
Loading…
</div>
) : items.length === 0 ? (
<div className="p-8 text-center text-muted-foreground text-sm">
No {title.toLowerCase()} configured yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Billable</TableHead>
<TableHead>Active</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((i) => (
<TableRow key={i.id}>
<TableCell className="font-medium">{i.name}</TableCell>
<TableCell className="text-muted-foreground max-w-md">
{i.description || "—"}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(Number(i.amount))}
{unit && (
<span className="text-muted-foreground text-xs ml-1">
{unit}
</span>
)}
</TableCell>
<TableCell>
{i.billable ? (
<Badge variant="outline" className="text-[10px]">
Billable
</Badge>
) : (
<span className="text-xs text-muted-foreground">No</span>
)}
</TableCell>
<TableCell>
{i.active ? (
<Badge variant="outline" className="text-[10px]">
Active
</Badge>
) : (
<span className="text-xs text-muted-foreground">
Inactive
</span>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(i)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(i.id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function FeeEditDialog({
item,
onClose,
onSaved,
}: {
item: Partial<FeeItem> | null;
onClose: () => void;
onSaved: () => void;
}) {
const { user } = useAuth();
const [form, setForm] = useState<Partial<FeeItem>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (item) setForm(item);
}, [item]);
const submit = async () => {
if (!form.name?.trim()) return toast.error("Name required");
if (!form.category) return toast.error("Category required");
const payload: any = {
name: form.name.trim(),
description: form.description?.trim() || null,
category: form.category,
amount: Number(form.amount) || 0,
billable: form.billable ?? true,
active: form.active ?? true,
sort_order: form.sort_order ?? 0,
};
setSaving(true);
const { error } = form.id
? await supabase
.from("fee_schedule_items")
.update(payload)
.eq("id", form.id)
: await supabase
.from("fee_schedule_items")
.insert({ ...payload, created_by: user?.id });
setSaving(false);
if (error) {
toast.error("Could not save", { description: error.message });
return;
}
toast.success(form.id ? "Fee updated" : "Fee added");
onClose();
onSaved();
};
return (
<Dialog open={!!item} onOpenChange={(v) => !v && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-serif">
{form.id ? "Edit fee item" : "Add fee item"}
</DialogTitle>
<DialogDescription>
Time fees feed the hourly rate dropdown; expense fees feed the
expense amount picker.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Category</Label>
<Select
value={form.category ?? "time"}
onValueChange={(v) =>
setForm({ ...form, category: v as "time" | "expense" })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="time">Time (hourly rate)</SelectItem>
<SelectItem value="expense">Expense (flat amount)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">
{form.category === "expense" ? "Amount ($)" : "Rate ($ / hr)"}
</Label>
<Input
type="number"
step="0.01"
min="0"
value={form.amount ?? ""}
onChange={(e) =>
setForm({ ...form, amount: parseFloat(e.target.value) || 0 })
}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={form.name ?? ""}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={
form.category === "expense"
? "e.g. Filing fee"
: "e.g. Senior partner rate"
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description (optional)</Label>
<Textarea
rows={2}
value={form.description ?? ""}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.billable ?? true}
onCheckedChange={(c) =>
setForm({ ...form, billable: !!c })
}
/>
Billable by default
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.active ?? true}
onCheckedChange={(c) => setForm({ ...form, active: !!c })}
/>
Active
</label>
<div className="space-y-1.5">
<Label className="text-xs">Sort order</Label>
<Input
type="number"
value={form.sort_order ?? 0}
onChange={(e) =>
setForm({
...form,
sort_order: parseInt(e.target.value) || 0,
})
}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+347
View File
@@ -0,0 +1,347 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Upload, Trash2, Building2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/")({
component: CompanySettingsPage,
});
const EMPTY = {
company_name: "",
contact_email: "",
contact_phone: "",
website: "",
address_line1: "",
address_line2: "",
city: "",
state: "",
postal_code: "",
country: "",
invoice_prefix: "",
default_tax_rate: "",
footer_note: "",
logo_storage_path: "" as string | null | "",
};
function CompanySettingsPage() {
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [recordId, setRecordId] = useState<string | null>(null);
const [form, setForm] = useState({ ...EMPTY });
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const load = async () => {
setLoading(true);
const { data } = await supabase
.from("firm_settings")
.select("*")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (data) {
setRecordId(data.id);
setForm({
company_name: data.company_name ?? "",
contact_email: data.contact_email ?? "",
contact_phone: data.contact_phone ?? "",
website: data.website ?? "",
address_line1: data.address_line1 ?? "",
address_line2: data.address_line2 ?? "",
city: data.city ?? "",
state: data.state ?? "",
postal_code: data.postal_code ?? "",
country: data.country ?? "",
invoice_prefix: data.invoice_prefix ?? "",
default_tax_rate:
data.default_tax_rate != null ? String(data.default_tax_rate) : "",
footer_note: data.footer_note ?? "",
logo_storage_path: data.logo_storage_path ?? "",
});
if (data.logo_storage_path) {
const { data: pub } = supabase.storage
.from("firm-logos")
.getPublicUrl(data.logo_storage_path);
setLogoUrl(pub.publicUrl);
} else {
setLogoUrl(null);
}
}
setLoading(false);
};
useEffect(() => {
load();
}, []);
const update = (k: keyof typeof EMPTY, v: string) =>
setForm((f) => ({ ...f, [k]: v }));
const onLogoFile = async (file: File) => {
setUploading(true);
const ext = file.name.split(".").pop() || "png";
const path = `firm-${Date.now()}.${ext}`;
const { error } = await supabase.storage
.from("firm-logos")
.upload(path, file, { upsert: true, contentType: file.type });
if (error) {
setUploading(false);
toast.error("Upload failed", { description: error.message });
return;
}
setForm((f) => ({ ...f, logo_storage_path: path }));
const { data: pub } = supabase.storage
.from("firm-logos")
.getPublicUrl(path);
setLogoUrl(pub.publicUrl);
setUploading(false);
toast.success("Logo uploaded — remember to save");
};
const removeLogo = () => {
setForm((f) => ({ ...f, logo_storage_path: "" }));
setLogoUrl(null);
};
const save = async () => {
setSaving(true);
const payload: any = {
company_name: form.company_name || null,
contact_email: form.contact_email || null,
contact_phone: form.contact_phone || null,
website: form.website || null,
address_line1: form.address_line1 || null,
address_line2: form.address_line2 || null,
city: form.city || null,
state: form.state || null,
postal_code: form.postal_code || null,
country: form.country || null,
invoice_prefix: form.invoice_prefix || null,
default_tax_rate: form.default_tax_rate
? parseFloat(form.default_tax_rate)
: null,
footer_note: form.footer_note || null,
logo_storage_path: form.logo_storage_path || null,
updated_by: user?.id,
};
const { error } = recordId
? await supabase.from("firm_settings").update(payload).eq("id", recordId)
: await supabase.from("firm_settings").insert(payload);
setSaving(false);
if (error) {
toast.error("Could not save", { description: error.message });
return;
}
toast.success("Settings saved");
load();
};
if (loading) {
return (
<div className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6 max-w-4xl">
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" /> Company logo
</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-6">
<div className="h-24 w-24 rounded-md border bg-muted/30 flex items-center justify-center overflow-hidden">
{logoUrl ? (
<img
src={logoUrl}
alt="Firm logo"
className="h-full w-full object-contain"
/>
) : (
<Building2 className="h-8 w-8 text-muted-foreground" />
)}
</div>
<div className="flex flex-col gap-2">
<input
ref={fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onLogoFile(f);
}}
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fileInput.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
Upload logo
</Button>
{logoUrl && (
<Button variant="ghost" size="sm" onClick={removeLogo}>
<Trash2 className="h-4 w-4 mr-2" /> Remove
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
PNG, JPG or SVG recommended. Max ~2 MB.
</p>
</div>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base">
Company information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Field label="Company name">
<Input
value={form.company_name}
onChange={(e) => update("company_name", e.target.value)}
/>
</Field>
<Field label="Website">
<Input
value={form.website}
onChange={(e) => update("website", e.target.value)}
placeholder="https://"
/>
</Field>
<Field label="Contact email">
<Input
type="email"
value={form.contact_email}
onChange={(e) => update("contact_email", e.target.value)}
/>
</Field>
<Field label="Contact phone">
<Input
value={form.contact_phone}
onChange={(e) => update("contact_phone", e.target.value)}
/>
</Field>
<Field label="Address line 1" className="sm:col-span-2">
<Input
value={form.address_line1}
onChange={(e) => update("address_line1", e.target.value)}
/>
</Field>
<Field label="Address line 2" className="sm:col-span-2">
<Input
value={form.address_line2}
onChange={(e) => update("address_line2", e.target.value)}
/>
</Field>
<Field label="City">
<Input
value={form.city}
onChange={(e) => update("city", e.target.value)}
/>
</Field>
<Field label="State / Province">
<Input
value={form.state}
onChange={(e) => update("state", e.target.value)}
/>
</Field>
<Field label="Postal code">
<Input
value={form.postal_code}
onChange={(e) => update("postal_code", e.target.value)}
/>
</Field>
<Field label="Country">
<Input
value={form.country}
onChange={(e) => update("country", e.target.value)}
/>
</Field>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base">
Invoice defaults
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Field label="Invoice number prefix">
<Input
value={form.invoice_prefix}
onChange={(e) => update("invoice_prefix", e.target.value)}
placeholder="e.g. INV-"
/>
</Field>
<Field label="Default tax rate (%)">
<Input
type="number"
step="0.01"
min="0"
max="100"
value={form.default_tax_rate}
onChange={(e) => update("default_tax_rate", e.target.value)}
/>
</Field>
<Field label="Invoice footer note" className="sm:col-span-2">
<Textarea
rows={3}
value={form.footer_note}
onChange={(e) => update("footer_note", e.target.value)}
placeholder="Thank you for your business…"
/>
</Field>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save changes
</Button>
</div>
</div>
);
}
function Field({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={`space-y-1.5 ${className ?? ""}`}>
<Label className="text-xs">{label}</Label>
{children}
</div>
);
}