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