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
+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>
);
}