From 93a5d3d8ad444d090662d4234964f31f7c8bb382 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:52:32 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/documents-tab.tsx | 125 ++++++++++++++++ src/components/cases/expenses-tab.tsx | 193 +++++++++++++++++++++++++ src/components/cases/invoices-tab.tsx | 116 +++++++++++++++ src/components/cases/status-tab.tsx | 122 ++++++++++++++++ src/components/cases/time-tab.tsx | 175 ++++++++++++++++++++++ src/routeTree.gen.ts | 21 +++ src/routes/cases.$caseId.tsx | 170 ++++++++++++++++++++++ 7 files changed, 922 insertions(+) create mode 100644 src/components/cases/documents-tab.tsx create mode 100644 src/components/cases/expenses-tab.tsx create mode 100644 src/components/cases/invoices-tab.tsx create mode 100644 src/components/cases/status-tab.tsx create mode 100644 src/components/cases/time-tab.tsx create mode 100644 src/routes/cases.$caseId.tsx diff --git a/src/components/cases/documents-tab.tsx b/src/components/cases/documents-tab.tsx new file mode 100644 index 0000000..38b827e --- /dev/null +++ b/src/components/cases/documents-tab.tsx @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Upload, FileText, Download, Trash2, Loader2 } from "lucide-react"; +import { formatDate } from "@/lib/format"; +import { toast } from "sonner"; + +export function CaseDocumentsTab({ caseId }: { caseId: string }) { + const { user } = useAuth(); + const [docs, setDocs] = useState([]); + const [uploading, setUploading] = useState(false); + const [description, setDescription] = useState(""); + const fileRef = useRef(null); + + const load = async () => { + const { data } = await supabase + .from("documents") + .select("*, uploader:profiles!documents_uploaded_by_fkey(full_name, email)") + .eq("case_id", caseId) + .order("created_at", { ascending: false }); + setDocs(data ?? []); + }; + + useEffect(() => { load(); }, [caseId]); + + const onUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (file.size > 50 * 1024 * 1024) { toast.error("File too large (50MB max)"); return; } + setUploading(true); + const path = `${caseId}/${Date.now()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`; + const { error: upErr } = await supabase.storage.from("case-documents").upload(path, file); + if (upErr) { toast.error("Upload failed", { description: upErr.message }); setUploading(false); return; } + const { error: insErr } = await supabase.from("documents").insert({ + case_id: caseId, + name: file.name, + storage_path: path, + mime_type: file.type, + size_bytes: file.size, + description: description.trim() || null, + uploaded_by: user?.id, + }); + if (insErr) toast.error("Save failed", { description: insErr.message }); + else { toast.success("Uploaded"); setDescription(""); load(); } + setUploading(false); + if (fileRef.current) fileRef.current.value = ""; + }; + + const download = async (doc: any) => { + const { data, error } = await supabase.storage.from("case-documents").createSignedUrl(doc.storage_path, 60); + if (error) { toast.error(error.message); return; } + window.open(data.signedUrl, "_blank"); + }; + + const del = async (doc: any) => { + if (!confirm(`Delete ${doc.name}?`)) return; + await supabase.storage.from("case-documents").remove([doc.storage_path]); + const { error } = await supabase.from("documents").delete().eq("id", doc.id); + if (error) toast.error(error.message); + else { toast.success("Deleted"); load(); } + }; + + return ( +
+ + +
+ + setDescription(e.target.value)} placeholder="e.g. Settlement draft v2" maxLength={300} /> +
+ + +
+
+ + + + + + + + + + + + + + + {docs.length === 0 && ( + + )} + {docs.map((d) => ( + + + + + + + + ))} + +
NameDescriptionUploaded byDateActions
No documents uploaded.
+
+ + {d.name} +
+
+ {d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : ""} +
+
{d.description || "—"}{d.uploader?.full_name || d.uploader?.email || "—"}{formatDate(d.created_at)} + + +
+
+
+
+ ); +} diff --git a/src/components/cases/expenses-tab.tsx b/src/components/cases/expenses-tab.tsx new file mode 100644 index 0000000..b321e42 --- /dev/null +++ b/src/components/cases/expenses-tab.tsx @@ -0,0 +1,193 @@ +import { useEffect, useRef, useState } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Plus, Trash2, Loader2, Receipt as ReceiptIcon } from "lucide-react"; +import { formatCurrency, formatDate } from "@/lib/format"; +import { toast } from "sonner"; + +export function CaseExpensesTab({ caseId }: { caseId: string }) { + const { user } = useAuth(); + const [items, setItems] = useState([]); + const [showForm, setShowForm] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState({ + expense_date: new Date().toISOString().slice(0, 10), + description: "", + amount: "", + billable: true, + }); + const [receipt, setReceipt] = useState(null); + const fileRef = useRef(null); + + const load = async () => { + const { data } = await supabase + .from("expenses") + .select("*, user:profiles!expenses_user_id_fkey(full_name, email)") + .eq("case_id", caseId) + .order("expense_date", { ascending: false }); + setItems(data ?? []); + }; + + useEffect(() => { load(); }, [caseId]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + const amount = parseFloat(form.amount); + if (isNaN(amount) || amount < 0) { toast.error("Invalid amount"); return; } + if (!form.description.trim()) { toast.error("Description required"); return; } + setSubmitting(true); + let receipt_storage_path: string | null = null; + if (receipt) { + const path = `${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) { toast.error("Receipt upload failed", { description: upErr.message }); setSubmitting(false); return; } + receipt_storage_path = path; + } + const { error } = await supabase.from("expenses").insert({ + case_id: 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) toast.error(error.message); + else { + toast.success("Expense added"); + setShowForm(false); + setReceipt(null); + setForm({ ...form, amount: "", description: "" }); + if (fileRef.current) fileRef.current.value = ""; + load(); + } + }; + + const del = async (item: any) => { + if (!confirm("Delete this expense?")) return; + if (item.receipt_storage_path) await supabase.storage.from("case-receipts").remove([item.receipt_storage_path]); + const { error } = await supabase.from("expenses").delete().eq("id", item.id); + if (error) toast.error(error.message); else { toast.success("Deleted"); load(); } + }; + + const downloadReceipt = async (path: string) => { + const { data, error } = await supabase.storage.from("case-receipts").createSignedUrl(path, 60); + if (error) { toast.error(error.message); return; } + window.open(data.signedUrl, "_blank"); + }; + + const totals = items.reduce( + (acc, e) => ({ + total: acc.total + Number(e.amount), + billable: acc.billable + (e.billable ? Number(e.amount) : 0), + unbilled: acc.unbilled + (e.billable && !e.invoice_id ? Number(e.amount) : 0), + }), + { total: 0, billable: 0, unbilled: 0 }, + ); + + return ( +
+
+
+ + + +
+ +
+ + {showForm && ( + + +
+
+ + setForm({ ...form, expense_date: e.target.value })} required /> +
+
+ + setForm({ ...form, amount: e.target.value })} required /> +
+
+ + setForm({ ...form, description: e.target.value })} required maxLength={500} /> +
+
+ +
+
+ + setReceipt(e.target.files?.[0] ?? null)} /> +
+
+ +
+
+
+
+ )} + + + + + + + + + + + + + + + + {items.length === 0 && } + {items.map((e) => ( + + + + + + + + + ))} + +
DateDescriptionUserAmountStatus
No expenses.
{formatDate(e.expense_date)} + {e.description} + {e.receipt_storage_path && ( + + )} + {e.user?.full_name || e.user?.email}{formatCurrency(e.amount)}{!e.billable ? "Non-billable" : e.invoice_id ? "Invoiced" : "Unbilled"} + {!e.invoice_id && } +
+
+
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/components/cases/invoices-tab.tsx b/src/components/cases/invoices-tab.tsx new file mode 100644 index 0000000..b6ed1db --- /dev/null +++ b/src/components/cases/invoices-tab.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { FilePlus, Loader2 } from "lucide-react"; +import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format"; +import { toast } from "sonner"; + +export function CaseInvoicesTab({ caseRecord }: { caseRecord: any }) { + const { user } = useAuth(); + const [invoices, setInvoices] = useState([]); + const [unbilledTime, setUnbilledTime] = useState([]); + const [unbilledExpenses, setUnbilledExpenses] = useState([]); + const [generating, setGenerating] = useState(false); + + const load = async () => { + const [{ data: invs }, { data: t }, { data: ex }] = await Promise.all([ + supabase.from("invoices").select("*").eq("case_id", caseRecord.id).order("created_at", { ascending: false }), + supabase.from("time_entries").select("*").eq("case_id", caseRecord.id).eq("billable", true).is("invoice_id", null), + supabase.from("expenses").select("*").eq("case_id", caseRecord.id).eq("billable", true).is("invoice_id", null), + ]); + setInvoices(invs ?? []); + setUnbilledTime(t ?? []); + setUnbilledExpenses(ex ?? []); + }; + + useEffect(() => { load(); }, [caseRecord.id]); + + const timeTotal = unbilledTime.reduce((s, e) => s + Number(e.hours) * Number(e.hourly_rate), 0); + const expensesTotal = unbilledExpenses.reduce((s, e) => s + Number(e.amount), 0); + const subtotal = timeTotal + expensesTotal; + + const generate = async () => { + if (subtotal <= 0) { toast.error("Nothing to invoice"); return; } + setGenerating(true); + const yr = new Date().getFullYear(); + const num = `INV-${yr}-${Math.floor(1000 + Math.random() * 9000)}`; + const due = new Date(); due.setDate(due.getDate() + 30); + const { data: inv, error } = await supabase.from("invoices").insert({ + invoice_number: num, + client_id: caseRecord.client.id, + case_id: caseRecord.id, + status: "draft", + issue_date: new Date().toISOString().slice(0, 10), + due_date: due.toISOString().slice(0, 10), + subtotal, + tax: 0, + total: subtotal, + created_by: user?.id, + }).select("id").single(); + if (error || !inv) { toast.error(error?.message || "Failed"); setGenerating(false); return; } + // Link entries + const timeIds = unbilledTime.map((t) => t.id); + const expIds = unbilledExpenses.map((e) => e.id); + if (timeIds.length) await supabase.from("time_entries").update({ invoice_id: inv.id }).in("id", timeIds); + if (expIds.length) await supabase.from("expenses").update({ invoice_id: inv.id }).in("id", expIds); + setGenerating(false); + toast.success(`Invoice ${num} created (draft)`); + load(); + }; + + return ( +
+ + +
+
Unbilled work
+
+ {unbilledTime.length} time entries · {unbilledExpenses.length} expenses +
+
{formatCurrency(subtotal)}
+
+ +
+
+ + + + + + + + + + + + + + + {invoices.length === 0 && } + {invoices.map((i) => ( + + + + + + + + ))} + +
Invoice #IssuedDueStatusTotal
No invoices yet.
+ + {i.invoice_number} + + {formatDate(i.issue_date)}{formatDate(i.due_date)}{i.status}{formatCurrency(i.total)}
+
+
+
+ ); +} diff --git a/src/components/cases/status-tab.tsx b/src/components/cases/status-tab.tsx new file mode 100644 index 0000000..dc634f2 --- /dev/null +++ b/src/components/cases/status-tab.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +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 { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Plus, Trash2, Loader2 } from "lucide-react"; +import { formatDateTime } from "@/lib/format"; +import { toast } from "sonner"; + +export function CaseStatusTab({ caseId }: { caseId: string }) { + const { user, isAdmin } = useAuth(); + const [items, setItems] = useState([]); + const [showForm, setShowForm] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState({ title: "", body: "" }); + + const load = async () => { + const { data } = await supabase + .from("status_updates") + .select("*, user:profiles!status_updates_created_by_fkey(full_name, email)") + .eq("case_id", caseId) + .order("created_at", { ascending: false }); + setItems(data ?? []); + }; + + useEffect(() => { load(); }, [caseId]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.title.trim() || !form.body.trim()) { toast.error("Title and body required"); return; } + setSubmitting(true); + const { error } = await supabase.from("status_updates").insert({ + case_id: caseId, + title: form.title.trim(), + body: form.body.trim(), + created_by: user?.id, + }); + setSubmitting(false); + if (error) toast.error(error.message); + else { toast.success("Update logged"); setShowForm(false); setForm({ title: "", body: "" }); load(); } + }; + + const del = async (item: any) => { + if (!confirm("Delete this update?")) return; + const { error } = await supabase.from("status_updates").delete().eq("id", item.id); + if (error) toast.error(error.message); else { toast.success("Deleted"); load(); } + }; + + return ( +
+
+ +
+ + {showForm && ( + + +
+
+ + setForm({ ...form, title: e.target.value })} required maxLength={200} /> +
+
+ +