Added flat fee pricing type

X-Lovable-Edit-ID: edt-38987d83-816d-4b55-82ae-dab2dbdf350b
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 15:47:36 +00:00
co-authored by renee-png
4 changed files with 97 additions and 24 deletions
+40 -13
View File
@@ -39,6 +39,7 @@ interface FeeItem {
name: string;
description: string | null;
amount: number;
pricing_type: "hourly" | "flat";
}
export function HeaderTimer() {
@@ -67,7 +68,7 @@ export function HeaderTimer() {
supabase.from("profiles").select("hourly_rate").eq("id", user.id).maybeSingle(),
supabase
.from("fee_schedule_items")
.select("id, name, description, amount")
.select("id, name, description, amount, pricing_type")
.eq("active", true)
.eq("billable", true)
.eq("category", "time")
@@ -96,6 +97,8 @@ export function HeaderTimer() {
[feeItems, feeItemId],
);
const isFlatFee = selectedFee?.pricing_type === "flat";
const effectiveRate =
selectedFee?.amount ?? activeCase?.default_hourly_rate ?? profileRate ?? 0;
@@ -111,15 +114,18 @@ export function HeaderTimer() {
if (!user?.id) return toast.error("Not signed in");
if (!timer.caseId) return toast.error("Select a case before saving");
if (!timer.description.trim()) return toast.error("Description required");
if (elapsedMs < 1000) return toast.error("Timer hasn't recorded any time yet");
if (elapsedMs < 1000 && !isFlatFee) return toast.error("Timer hasn't recorded any time yet");
setSaving(true);
const { hours } = consume();
const { hours: tracked } = consume();
// For a flat-fee item, store 1 hour at the flat amount so the total = flat amount.
const hours = isFlatFee ? 1 : tracked;
const rate = isFlatFee ? selectedFee!.amount : effectiveRate;
const { error } = await supabase.from("time_entries").insert({
case_id: timer.caseId,
user_id: user.id,
work_date: new Date().toISOString().slice(0, 10),
hours,
hourly_rate: effectiveRate,
hourly_rate: rate,
description: timer.description.trim(),
billable,
});
@@ -128,9 +134,14 @@ export function HeaderTimer() {
toast.error(error.message);
return;
}
toast.success(`Saved ${hours.toFixed(1)} hr`, {
description: `${formatCurrency(hours * effectiveRate)} at ${formatCurrency(effectiveRate)}/hr`,
});
toast.success(
isFlatFee ? `Saved flat fee` : `Saved ${hours.toFixed(1)} hr`,
{
description: isFlatFee
? formatCurrency(rate)
: `${formatCurrency(hours * rate)} at ${formatCurrency(rate)}/hr`,
},
);
setBillable(true);
setFeeItemId("");
setOpen(false);
@@ -247,7 +258,10 @@ export function HeaderTimer() {
{feeItems.map((f) => (
<SelectItem key={f.id} value={f.id}>
<span className="mr-2">{f.name}</span>
<span className="text-muted-foreground">{formatCurrency(f.amount)}/hr</span>
<span className="text-muted-foreground">
{formatCurrency(f.amount)}
{f.pricing_type === "flat" ? " flat" : "/hr"}
</span>
</SelectItem>
))}
</SelectContent>
@@ -268,9 +282,15 @@ export function HeaderTimer() {
<div className="grid grid-cols-2 gap-3 pt-1">
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Rate</div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
{isFlatFee ? "Flat fee" : "Rate"}
</div>
<div className="text-sm font-medium mt-0.5">
{effectiveRate ? `${formatCurrency(effectiveRate)}/hr` : "—"}
{effectiveRate
? isFlatFee
? formatCurrency(effectiveRate)
: `${formatCurrency(effectiveRate)}/hr`
: "—"}
</div>
<div className="text-[10px] text-muted-foreground">
{selectedFee
@@ -289,17 +309,24 @@ export function HeaderTimer() {
</div>
<p className="text-[10px] text-muted-foreground">
Time saves rounded up to the nearest 1/10 hour (6 minutes).
{isFlatFee
? "Flat fee items save at the fixed amount regardless of elapsed time."
: "Time saves rounded up to the nearest 1/10 hour (6 minutes)."}
</p>
<Button
className="w-full"
onClick={handleSave}
disabled={saving || !timer.caseId || !timer.description.trim() || elapsedMs < 1000}
disabled={
saving ||
!timer.caseId ||
!timer.description.trim() ||
(!isFlatFee && elapsedMs < 1000)
}
>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
<Square className="h-4 w-4 mr-2" />
Stop & save
{isFlatFee ? "Save flat fee" : "Stop & save"}
</Button>
</div>
</PopoverContent>
+3
View File
@@ -1490,6 +1490,7 @@ export type Database = {
description: string | null
id: string
name: string
pricing_type: string
sort_order: number
updated_at: string
}
@@ -1503,6 +1504,7 @@ export type Database = {
description?: string | null
id?: string
name: string
pricing_type?: string
sort_order?: number
updated_at?: string
}
@@ -1516,6 +1518,7 @@ export type Database = {
description?: string | null
id?: string
name?: string
pricing_type?: string
sort_order?: number
updated_at?: string
}
+45 -11
View File
@@ -45,6 +45,7 @@ interface FeeItem {
name: string;
description: string | null;
category: "time" | "expense";
pricing_type: "hourly" | "flat";
amount: number;
billable: boolean;
active: boolean;
@@ -98,7 +99,7 @@ function FeeSchedulePage() {
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 })}>
<Button onClick={() => setEditing({ category: "time", pricing_type: "hourly", billable: true, active: true, amount: 0 })}>
<Plus className="h-4 w-4 mr-2" /> Add fee item
</Button>
</div>
@@ -202,11 +203,13 @@ function FeeSection({
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(Number(i.amount))}
{unit && (
<span className="text-muted-foreground text-xs ml-1">
{unit}
</span>
)}
{(() => {
const isHourly = i.category === "time" && i.pricing_type !== "flat";
const u = isHourly ? "/ hr" : i.category === "time" ? " flat" : unit;
return u ? (
<span className="text-muted-foreground text-xs ml-1">{u}</span>
) : null;
})()}
</TableCell>
<TableCell>
{i.billable ? (
@@ -279,6 +282,8 @@ function FeeEditDialog({
name: form.name.trim(),
description: form.description?.trim() || null,
category: form.category,
pricing_type:
form.category === "expense" ? "flat" : form.pricing_type ?? "hourly",
amount: Number(form.amount) || 0,
billable: form.billable ?? true,
active: form.active ?? true,
@@ -316,27 +321,56 @@ function FeeEditDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 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" })
setForm({
...form,
category: v as "time" | "expense",
pricing_type:
v === "expense" ? "flat" : form.pricing_type ?? "hourly",
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="time">Time (hourly rate)</SelectItem>
<SelectItem value="expense">Expense (flat amount)</SelectItem>
<SelectItem value="time">Time</SelectItem>
<SelectItem value="expense">Expense</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Pricing</Label>
<Select
value={
form.category === "expense"
? "flat"
: form.pricing_type ?? "hourly"
}
onValueChange={(v) =>
setForm({ ...form, pricing_type: v as "hourly" | "flat" })
}
disabled={form.category === "expense"}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly rate</SelectItem>
<SelectItem value="flat">Flat fee</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">
{form.category === "expense" ? "Amount ($)" : "Rate ($ / hr)"}
{form.category !== "expense" && form.pricing_type !== "flat"
? "Rate ($ / hr)"
: "Amount ($)"}
</Label>
<Input
type="number"
@@ -0,0 +1,9 @@
ALTER TABLE public.fee_schedule_items
ADD COLUMN IF NOT EXISTS pricing_type text NOT NULL DEFAULT 'hourly';
ALTER TABLE public.fee_schedule_items
DROP CONSTRAINT IF EXISTS fee_schedule_items_pricing_type_check;
ALTER TABLE public.fee_schedule_items
ADD CONSTRAINT fee_schedule_items_pricing_type_check
CHECK (pricing_type IN ('hourly', 'flat'));