Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
4715dcc4f7
commit
f20ca5bd67
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user