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:36:41 +00:00
co-authored by renee-png
parent 6f0f6f3787
commit 43c46a7c01
+365
View File
@@ -0,0 +1,365 @@
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/lib/auth";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Clock, Loader2, Plus, Receipt as ReceiptIcon } from "lucide-react";
import { toast } from "sonner";
import { roundToTenth } from "@/lib/timer";
interface ClientOpt {
id: string;
name: string;
}
interface CaseOpt {
id: string;
case_number: string;
title: string;
client_id: string;
default_hourly_rate: number | null;
}
function useClientsAndCases(open: boolean) {
const [clients, setClients] = useState<ClientOpt[]>([]);
const [cases, setCases] = useState<CaseOpt[]>([]);
useEffect(() => {
if (!open) return;
(async () => {
const [{ data: cs }, { data: ks }] = await Promise.all([
supabase.from("clients").select("id, name").order("name"),
supabase
.from("cases")
.select("id, case_number, title, client_id, default_hourly_rate")
.order("case_number", { ascending: false }),
]);
setClients(cs ?? []);
setCases(ks ?? []);
})();
}, [open]);
return { clients, cases };
}
/* -------------------- Quick add: Time -------------------- */
export function QuickAddTime() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const { clients, cases } = useClientsAndCases(open);
const [profileRate, setProfileRate] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
clientId: "",
caseId: "",
work_date: new Date().toISOString().slice(0, 10),
hours: "",
rate: "",
description: "",
billable: true,
});
useEffect(() => {
if (!open || !user?.id) return;
(async () => {
const { data } = await supabase.from("profiles").select("hourly_rate").eq("id", user.id).maybeSingle();
setProfileRate((data as any)?.hourly_rate ?? null);
})();
}, [open, user?.id]);
const filteredCases = useMemo(
() => (form.clientId ? cases.filter((c) => c.client_id === form.clientId) : []),
[cases, form.clientId],
);
const activeCase = useMemo(() => cases.find((c) => c.id === form.caseId), [cases, form.caseId]);
// Auto-fill rate when case changes (case rate beats profile rate)
useEffect(() => {
if (!open) return;
const auto = activeCase?.default_hourly_rate ?? profileRate ?? null;
if (auto != null && !form.rate) setForm((f) => ({ ...f, rate: String(auto) }));
}, [activeCase?.default_hourly_rate, profileRate, open]);
const reset = () => {
setForm({
clientId: "",
caseId: "",
work_date: new Date().toISOString().slice(0, 10),
hours: "",
rate: "",
description: "",
billable: true,
});
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user?.id) return toast.error("Not signed in");
if (!form.caseId) return toast.error("Select a case");
if (!form.description.trim()) return toast.error("Description required");
const hours = roundToTenth(parseFloat(form.hours));
if (!hours) return toast.error("Hours must be greater than 0");
const rate = parseFloat(form.rate || "0");
setSubmitting(true);
const { error } = await supabase.from("time_entries").insert({
case_id: form.caseId,
user_id: user.id,
work_date: form.work_date,
hours,
hourly_rate: rate,
description: form.description.trim(),
billable: form.billable,
});
setSubmitting(false);
if (error) return toast.error(error.message);
toast.success(`Logged ${hours.toFixed(1)} hr`);
reset();
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="h-9 gap-1.5">
<Clock className="h-4 w-4" />
<Plus className="h-3.5 w-3.5" /> Time
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Log time</DialogTitle>
<DialogDescription>Hours round up to the nearest 1/10 (6 minutes).</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Client</Label>
<Select
value={form.clientId}
onValueChange={(v) => setForm({ ...form, clientId: v, caseId: "" })}
>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Case</Label>
<Select
value={form.caseId}
onValueChange={(v) => setForm({ ...form, caseId: v })}
disabled={!form.clientId}
>
<SelectTrigger><SelectValue placeholder={form.clientId ? "Select" : "Pick client"} /></SelectTrigger>
<SelectContent>
{filteredCases.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No cases.</div>
) : filteredCases.map((c) => (
<SelectItem key={c.id} value={c.id}>
<span className="text-muted-foreground mr-2">{c.case_number}</span>{c.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Date</Label>
<Input type="date" value={form.work_date} onChange={(e) => setForm({ ...form, work_date: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hours</Label>
<Input type="number" step="0.1" min="0.1" value={form.hours} onChange={(e) => setForm({ ...form, hours: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Rate ($)</Label>
<Input type="number" step="0.01" min="0" value={form.rate} onChange={(e) => setForm({ ...form, rate: e.target.value })} required />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} required maxLength={1000} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
<DialogFooter>
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save entry
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
/* -------------------- Quick add: Expense -------------------- */
export function QuickAddExpense() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const { clients, cases } = useClientsAndCases(open);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
clientId: "",
caseId: "",
expense_date: new Date().toISOString().slice(0, 10),
description: "",
amount: "",
billable: true,
});
const [receipt, setReceipt] = useState<File | null>(null);
const filteredCases = useMemo(
() => (form.clientId ? cases.filter((c) => c.client_id === form.clientId) : []),
[cases, form.clientId],
);
const reset = () => {
setForm({
clientId: "",
caseId: "",
expense_date: new Date().toISOString().slice(0, 10),
description: "",
amount: "",
billable: true,
});
setReceipt(null);
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user?.id) return toast.error("Not signed in");
if (!form.caseId) return toast.error("Select a case");
if (!form.description.trim()) return toast.error("Description required");
const amount = parseFloat(form.amount);
if (Number.isNaN(amount) || amount < 0) return toast.error("Invalid amount");
setSubmitting(true);
let receipt_storage_path: string | null = null;
if (receipt) {
const path = `${form.caseId}/${Date.now()}-${receipt.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
const { error: upErr } = await supabase.storage.from("case-receipts").upload(path, receipt);
if (upErr) {
setSubmitting(false);
return toast.error("Receipt upload failed", { description: upErr.message });
}
receipt_storage_path = path;
}
const { error } = await supabase.from("expenses").insert({
case_id: form.caseId,
user_id: user.id,
expense_date: form.expense_date,
description: form.description.trim(),
amount,
billable: form.billable,
receipt_storage_path,
});
setSubmitting(false);
if (error) return toast.error(error.message);
toast.success("Expense added");
reset();
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="h-9 gap-1.5">
<ReceiptIcon className="h-4 w-4" />
<Plus className="h-3.5 w-3.5" /> Expense
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add expense</DialogTitle>
<DialogDescription>Attach a receipt if you have one.</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Client</Label>
<Select
value={form.clientId}
onValueChange={(v) => setForm({ ...form, clientId: v, caseId: "" })}
>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Case</Label>
<Select
value={form.caseId}
onValueChange={(v) => setForm({ ...form, caseId: v })}
disabled={!form.clientId}
>
<SelectTrigger><SelectValue placeholder={form.clientId ? "Select" : "Pick client"} /></SelectTrigger>
<SelectContent>
{filteredCases.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No cases.</div>
) : filteredCases.map((c) => (
<SelectItem key={c.id} value={c.id}>
<span className="text-muted-foreground mr-2">{c.case_number}</span>{c.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Date</Label>
<Input type="date" value={form.expense_date} onChange={(e) => setForm({ ...form, expense_date: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Amount ($)</Label>
<Input type="number" step="0.01" min="0" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} required />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} required maxLength={500} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Receipt (optional)</Label>
<Input type="file" onChange={(e) => setReceipt(e.target.files?.[0] ?? null)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
<DialogFooter>
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save expense
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}