diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index c676208..047b648 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/settings.fees.tsx b/src/routes/settings.fees.tsx new file mode 100644 index 0000000..9d922e3 --- /dev/null +++ b/src/routes/settings.fees.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState | 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 ( +
+
+

+ 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. +

+ +
+ + } + unit="/ hr" + items={timeItems} + loading={loading} + onEdit={setEditing} + onDelete={remove} + /> + + } + unit="" + items={expenseItems} + loading={loading} + onEdit={setEditing} + onDelete={remove} + /> + + setEditing(null)} + onSaved={load} + /> +
+ ); +} + +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 ( +
+

+ {icon} + {title} +

+ + + {loading ? ( +
+ Loading… +
+ ) : items.length === 0 ? ( +
+ No {title.toLowerCase()} configured yet. +
+ ) : ( + + + + Name + Description + Amount + Billable + Active + + + + + {items.map((i) => ( + + {i.name} + + {i.description || "—"} + + + {formatCurrency(Number(i.amount))} + {unit && ( + + {unit} + + )} + + + {i.billable ? ( + + Billable + + ) : ( + No + )} + + + {i.active ? ( + + Active + + ) : ( + + Inactive + + )} + + + + + + + ))} + +
+ )} +
+
+
+ ); +} + +function FeeEditDialog({ + item, + onClose, + onSaved, +}: { + item: Partial | null; + onClose: () => void; + onSaved: () => void; +}) { + const { user } = useAuth(); + const [form, setForm] = useState>({}); + 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 ( + !v && onClose()}> + + + + {form.id ? "Edit fee item" : "Add fee item"} + + + Time fees feed the hourly rate dropdown; expense fees feed the + expense amount picker. + + +
+
+
+ + +
+
+ + + setForm({ ...form, amount: parseFloat(e.target.value) || 0 }) + } + /> +
+
+
+ + setForm({ ...form, name: e.target.value })} + placeholder={ + form.category === "expense" + ? "e.g. Filing fee" + : "e.g. Senior partner rate" + } + /> +
+
+ +