Added Settings page & fee picker

X-Lovable-Edit-ID: edt-96978c38-1920-45d0-825e-a295b8d314a1
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:54:30 +00:00
co-authored by renee-png
8 changed files with 1164 additions and 0 deletions
+2
View File
@@ -12,6 +12,7 @@ import {
LogOut,
Scale,
LayoutDashboard,
Settings,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
@@ -29,6 +30,7 @@ const NAV: NavItem[] = [
{ to: "/cases", label: "Cases", icon: Briefcase },
{ to: "/invoices", label: "Invoices", icon: Receipt },
{ to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true },
{ to: "/settings", label: "Settings", icon: Settings, adminOnly: true },
];
export function AppShell({ children }: { children: ReactNode }) {
+89
View File
@@ -37,6 +37,13 @@ interface CaseOpt {
client_id: string;
default_hourly_rate: number | null;
}
interface FeeItem {
id: string;
name: string;
category: "time" | "expense";
amount: number;
billable: boolean;
}
function useClientsAndCases(open: boolean) {
const [clients, setClients] = useState<ClientOpt[]>([]);
@@ -60,11 +67,31 @@ function useClientsAndCases(open: boolean) {
return { clients, cases };
}
function useFeeItems(open: boolean, category: "time" | "expense") {
const [items, setItems] = useState<FeeItem[]>([]);
useEffect(() => {
if (!open) return;
(async () => {
const { data } = await supabase
.from("fee_schedule_items")
.select("id, name, category, amount, billable")
.eq("category", category)
.eq("active", true)
.order("sort_order")
.order("name");
setItems((data ?? []) as FeeItem[]);
})();
}, [open, category]);
return items;
}
/* -------------------- Quick add: Time -------------------- */
export function QuickAddTime() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const { clients, cases } = useClientsAndCases(open);
const fees = useFeeItems(open, "time");
const [feeId, setFeeId] = useState("");
const [profileRate, setProfileRate] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
@@ -108,6 +135,7 @@ export function QuickAddTime() {
description: "",
billable: true,
});
setFeeId("");
};
const submit = async (e: React.FormEvent) => {
@@ -182,6 +210,35 @@ export function QuickAddTime() {
</Select>
</div>
</div>
{fees.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Fee item (optional)</Label>
<Select
value={feeId}
onValueChange={(v) => {
setFeeId(v);
const fee = fees.find((f) => f.id === v);
if (fee) {
setForm((f) => ({
...f,
rate: String(fee.amount),
billable: fee.billable,
description: f.description || fee.name,
}));
}
}}
>
<SelectTrigger><SelectValue placeholder="Pick from fee schedule…" /></SelectTrigger>
<SelectContent>
{fees.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.name} — ${Number(f.amount).toFixed(2)}/hr
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Date</Label>
@@ -221,6 +278,8 @@ export function QuickAddExpense() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const { clients, cases } = useClientsAndCases(open);
const fees = useFeeItems(open, "expense");
const [feeId, setFeeId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
clientId: "",
@@ -247,6 +306,7 @@ export function QuickAddExpense() {
billable: true,
});
setReceipt(null);
setFeeId("");
};
const submit = async (e: React.FormEvent) => {
@@ -330,6 +390,35 @@ export function QuickAddExpense() {
</Select>
</div>
</div>
{fees.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Fee item (optional)</Label>
<Select
value={feeId}
onValueChange={(v) => {
setFeeId(v);
const fee = fees.find((f) => f.id === v);
if (fee) {
setForm((f) => ({
...f,
amount: String(fee.amount),
billable: fee.billable,
description: f.description || fee.name,
}));
}
}}
>
<SelectTrigger><SelectValue placeholder="Pick from fee schedule…" /></SelectTrigger>
<SelectContent>
{fees.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.name} — ${Number(f.amount).toFixed(2)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Date</Label>
+105
View File
@@ -405,6 +405,111 @@ export type Database = {
},
]
}
fee_schedule_items: {
Row: {
active: boolean
amount: number
billable: boolean
category: string
created_at: string
created_by: string | null
description: string | null
id: string
name: string
sort_order: number
updated_at: string
}
Insert: {
active?: boolean
amount?: number
billable?: boolean
category: string
created_at?: string
created_by?: string | null
description?: string | null
id?: string
name: string
sort_order?: number
updated_at?: string
}
Update: {
active?: boolean
amount?: number
billable?: boolean
category?: string
created_at?: string
created_by?: string | null
description?: string | null
id?: string
name?: string
sort_order?: number
updated_at?: string
}
Relationships: []
}
firm_settings: {
Row: {
address_line1: string | null
address_line2: string | null
city: string | null
company_name: string | null
contact_email: string | null
contact_phone: string | null
country: string | null
created_at: string
default_tax_rate: number | null
footer_note: string | null
id: string
invoice_prefix: string | null
logo_storage_path: string | null
postal_code: string | null
state: string | null
updated_at: string
updated_by: string | null
website: string | null
}
Insert: {
address_line1?: string | null
address_line2?: string | null
city?: string | null
company_name?: string | null
contact_email?: string | null
contact_phone?: string | null
country?: string | null
created_at?: string
default_tax_rate?: number | null
footer_note?: string | null
id?: string
invoice_prefix?: string | null
logo_storage_path?: string | null
postal_code?: string | null
state?: string | null
updated_at?: string
updated_by?: string | null
website?: string | null
}
Update: {
address_line1?: string | null
address_line2?: string | null
city?: string | null
company_name?: string | null
contact_email?: string | null
contact_phone?: string | null
country?: string | null
created_at?: string
default_tax_rate?: number | null
footer_note?: string | null
id?: string
invoice_prefix?: string | null
logo_storage_path?: string | null
postal_code?: string | null
state?: string | null
updated_at?: string
updated_by?: string | null
website?: string | null
}
Relationships: []
}
homeowners: {
Row: {
address: string | null
+71
View File
@@ -10,10 +10,13 @@
import { Route as rootRouteImport } from './routes/__root'
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'
@@ -24,6 +27,11 @@ const SetupRoute = SetupRouteImport.update({
path: '/setup',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -34,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/',
@@ -44,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',
@@ -68,13 +86,16 @@ const AdminUsersRoute = AdminUsersRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/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
@@ -84,33 +105,41 @@ export interface FileRoutesByTo {
'/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 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
fullPaths:
| '/'
| '/login'
| '/settings'
| '/setup'
| '/admin/users'
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/settings/fees'
| '/cases/'
| '/clients/'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -120,24 +149,30 @@ export interface FileRouteTypes {
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/settings/fees'
| '/cases'
| '/clients'
| '/settings'
id:
| '__root__'
| '/'
| '/login'
| '/settings'
| '/setup'
| '/admin/users'
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/settings/fees'
| '/cases/'
| '/clients/'
| '/settings/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
SettingsRoute: typeof SettingsRouteWithChildren
SetupRoute: typeof SetupRoute
AdminUsersRoute: typeof AdminUsersRoute
CasesCaseIdRoute: typeof CasesCaseIdRoute
@@ -156,6 +191,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SetupRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@@ -170,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'
@@ -184,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'
@@ -215,9 +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: 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>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { createFileRoute, Link, useLocation } from "@tanstack/react-router";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Outlet } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/settings")({
component: SettingsLayout,
});
const TABS = [
{ to: "/settings", label: "Company", exact: true },
{ to: "/settings/fees", label: "Fee schedule" },
];
function SettingsLayout() {
const location = useLocation();
return (
<ProtectedLayout adminOnly>
<PageContainer>
<PageHeader
title="Settings"
description="Configure firm-wide information and billing defaults."
/>
<div className="flex gap-1 border-b mb-6">
{TABS.map((t) => {
const active = t.exact
? location.pathname === t.to
: location.pathname.startsWith(t.to);
return (
<Link
key={t.to}
to={t.to}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
active
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</Link>
);
})}
</div>
<Outlet />
</PageContainer>
</ProtectedLayout>
);
}
@@ -0,0 +1,100 @@
-- 1. Firm settings (single-row table keyed by a fixed id)
CREATE TABLE IF NOT EXISTS public.firm_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_name text,
contact_email text,
contact_phone text,
website text,
address_line1 text,
address_line2 text,
city text,
state text,
postal_code text,
country text,
logo_storage_path text,
invoice_prefix text,
default_tax_rate numeric,
footer_note text,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.firm_settings ENABLE ROW LEVEL SECURITY;
CREATE POLICY firm_settings_select_auth ON public.firm_settings
FOR SELECT TO authenticated USING (true);
CREATE POLICY firm_settings_insert_admin ON public.firm_settings
FOR INSERT TO authenticated
WITH CHECK (public.is_admin(auth.uid()));
CREATE POLICY firm_settings_update_admin ON public.firm_settings
FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()));
CREATE POLICY firm_settings_delete_admin ON public.firm_settings
FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()));
CREATE TRIGGER firm_settings_set_updated_at
BEFORE UPDATE ON public.firm_settings
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- 2. Fee schedule items
CREATE TABLE IF NOT EXISTS public.fee_schedule_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
category text NOT NULL CHECK (category IN ('time','expense')),
amount numeric NOT NULL DEFAULT 0,
billable boolean NOT NULL DEFAULT true,
active boolean NOT NULL DEFAULT true,
sort_order int NOT NULL DEFAULT 0,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_fee_schedule_items_category ON public.fee_schedule_items(category);
ALTER TABLE public.fee_schedule_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY fee_schedule_items_select_auth ON public.fee_schedule_items
FOR SELECT TO authenticated USING (true);
CREATE POLICY fee_schedule_items_insert_admin ON public.fee_schedule_items
FOR INSERT TO authenticated
WITH CHECK (public.is_admin(auth.uid()));
CREATE POLICY fee_schedule_items_update_admin ON public.fee_schedule_items
FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()));
CREATE POLICY fee_schedule_items_delete_admin ON public.fee_schedule_items
FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()));
CREATE TRIGGER fee_schedule_items_set_updated_at
BEFORE UPDATE ON public.fee_schedule_items
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- 3. Public logos bucket
INSERT INTO storage.buckets (id, name, public)
VALUES ('firm-logos', 'firm-logos', true)
ON CONFLICT (id) DO NOTHING;
CREATE POLICY firm_logos_select_public ON storage.objects
FOR SELECT TO public
USING (bucket_id = 'firm-logos');
CREATE POLICY firm_logos_insert_auth ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'firm-logos');
CREATE POLICY firm_logos_update_admin ON storage.objects
FOR UPDATE TO authenticated
USING (bucket_id = 'firm-logos' AND public.is_admin(auth.uid()));
CREATE POLICY firm_logos_delete_admin ON storage.objects
FOR DELETE TO authenticated
USING (bucket_id = 'firm-logos' AND public.is_admin(auth.uid()));