diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 6f5e4e9..db29988 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -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 }) { diff --git a/src/components/quick-add/quick-add.tsx b/src/components/quick-add/quick-add.tsx index 3b31f49..1995942 100644 --- a/src/components/quick-add/quick-add.tsx +++ b/src/components/quick-add/quick-add.tsx @@ -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([]); @@ -60,11 +67,31 @@ function useClientsAndCases(open: boolean) { return { clients, cases }; } +function useFeeItems(open: boolean, category: "time" | "expense") { + const [items, setItems] = useState([]); + 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(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() { + {fees.length > 0 && ( +
+ + +
+ )}
@@ -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() {
+ {fees.length > 0 && ( +
+ + +
+ )}
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index d57bd90..2d612e2 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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 diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b1c7c1a..2be5b92 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, 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" + } + /> +
+
+ +