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:
@@ -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>
|
||||
|
||||
@@ -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,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'));
|
||||
Reference in New Issue
Block a user