Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-16 22:52:32 +00:00
co-authored by renee-png
parent 54793abda3
commit 93a5d3d8ad
7 changed files with 922 additions and 0 deletions
+125
View File
@@ -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<any[]>([]);
const [uploading, setUploading] = useState(false);
const [description, setDescription] = useState("");
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="space-y-4">
<Card className="border-border/60">
<CardContent className="p-4 flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<div className="flex-1 space-y-1.5">
<Label className="text-xs">Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="e.g. Settlement draft v2" maxLength={300} />
</div>
<input ref={fileRef} type="file" className="hidden" onChange={onUpload} />
<Button onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Upload className="h-4 w-4 mr-2" />}
Upload document
</Button>
</CardContent>
</Card>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">Uploaded by</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{docs.length === 0 && (
<tr><td colSpan={5} className="text-center py-12 text-muted-foreground">No documents uploaded.</td></tr>
)}
{docs.map((d) => (
<tr key={d.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{d.name}</span>
</div>
<div className="text-xs text-muted-foreground ml-6">
{d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : ""}
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{d.description || "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{d.uploader?.full_name || d.uploader?.email || "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(d.created_at)}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" onClick={() => download(d)}><Download className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => del(d)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
+193
View File
@@ -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<any[]>([]);
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<File | null>(null);
const fileRef = useRef<HTMLInputElement>(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 (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex gap-4 text-sm">
<Stat label="Total" value={formatCurrency(totals.total)} />
<Stat label="Billable" value={formatCurrency(totals.billable)} />
<Stat label="Unbilled" value={formatCurrency(totals.unbilled)} />
</div>
<Button onClick={() => setShowForm((s) => !s)}>
<Plus className="h-4 w-4 mr-2" /> {showForm ? "Cancel" : "Add expense"}
</Button>
</div>
{showForm && (
<Card className="border-border/60">
<CardContent className="p-4">
<form onSubmit={submit} className="grid grid-cols-1 md:grid-cols-4 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 className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Description</Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} required maxLength={500} />
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
</div>
<div className="md:col-span-2">
<Label className="text-xs">Receipt (optional)</Label>
<Input ref={fileRef} type="file" onChange={(e) => setReceipt(e.target.files?.[0] ?? null)} />
</div>
<div className="md:col-span-4 flex justify-end">
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save expense
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">User</th>
<th className="text-right px-4 py-3 font-medium">Amount</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={6} className="text-center py-12 text-muted-foreground">No expenses.</td></tr>}
{items.map((e) => (
<tr key={e.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(e.expense_date)}</td>
<td className="px-4 py-3">
{e.description}
{e.receipt_storage_path && (
<Button variant="link" size="sm" className="h-auto p-0 ml-2" onClick={() => downloadReceipt(e.receipt_storage_path)}>
<ReceiptIcon className="h-3 w-3 mr-1" /> receipt
</Button>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{e.user?.full_name || e.user?.email}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(e.amount)}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{!e.billable ? "Non-billable" : e.invoice_id ? "Invoiced" : "Unbilled"}</td>
<td className="px-4 py-3 text-right">
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => del(e)}><Trash2 className="h-4 w-4 text-destructive" /></Button>}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="font-serif text-lg">{value}</div>
</div>
);
}
+116
View File
@@ -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<any[]>([]);
const [unbilledTime, setUnbilledTime] = useState<any[]>([]);
const [unbilledExpenses, setUnbilledExpenses] = useState<any[]>([]);
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 (
<div className="space-y-4">
<Card className="border-border/60 bg-muted/20">
<CardContent className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="text-sm space-y-0.5">
<div className="font-medium">Unbilled work</div>
<div className="text-muted-foreground text-xs">
{unbilledTime.length} time entries · {unbilledExpenses.length} expenses
</div>
<div className="font-serif text-2xl text-foreground mt-1">{formatCurrency(subtotal)}</div>
</div>
<Button onClick={generate} disabled={subtotal <= 0 || generating}>
{generating ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <FilePlus className="h-4 w-4 mr-2" />}
Generate invoice
</Button>
</CardContent>
</Card>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Invoice #</th>
<th className="text-left px-4 py-3 font-medium">Issued</th>
<th className="text-left px-4 py-3 font-medium">Due</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium">Total</th>
</tr>
</thead>
<tbody>
{invoices.length === 0 && <tr><td colSpan={5} className="text-center py-12 text-muted-foreground">No invoices yet.</td></tr>}
{invoices.map((i) => (
<tr key={i.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<Link to="/invoices/$invoiceId" params={{ invoiceId: i.id }} className="font-medium hover:text-primary">
{i.invoice_number}
</Link>
</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.issue_date)}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.due_date)}</td>
<td className="px-4 py-3"><Badge variant="outline" className={statusBadgeClass(i.status)}>{i.status}</Badge></td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(i.total)}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
+122
View File
@@ -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<any[]>([]);
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 (
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => setShowForm((s) => !s)}>
<Plus className="h-4 w-4 mr-2" /> {showForm ? "Cancel" : "Add status update"}
</Button>
</div>
{showForm && (
<Card className="border-border/60">
<CardContent className="p-4">
<form onSubmit={submit} className="space-y-3">
<div className="space-y-1.5">
<Label>Title</Label>
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required maxLength={200} />
</div>
<div className="space-y-1.5">
<Label>Details</Label>
<Textarea rows={4} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} required maxLength={5000} />
</div>
<div className="flex justify-end">
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Log update
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{items.length === 0 && (
<Card className="border-border/60">
<CardContent className="p-8 text-center text-muted-foreground text-sm">
No status updates yet. Log significant case events to build a timeline.
</CardContent>
</Card>
)}
<div className="relative">
{items.map((u) => {
const canDelete = isAdmin || u.created_by === user?.id;
return (
<div key={u.id} className="relative pl-8 pb-6">
<div className="absolute left-3 top-2 w-px h-full bg-border" />
<div className="absolute left-[7px] top-2 h-3 w-3 rounded-full bg-primary border-2 border-background" />
<Card className="border-border/60">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3 mb-1.5">
<div>
<h4 className="font-serif text-base">{u.title}</h4>
<div className="text-xs text-muted-foreground">
{u.user?.full_name || u.user?.email || "Unknown"} · {formatDateTime(u.created_at)}
</div>
</div>
{canDelete && (
<Button variant="ghost" size="icon" onClick={() => del(u)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
<p className="text-sm whitespace-pre-wrap">{u.body}</p>
</CardContent>
</Card>
</div>
);
})}
</div>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
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 { Checkbox } from "@/components/ui/checkbox";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { formatCurrency, formatDate } from "@/lib/format";
import { toast } from "sonner";
export function CaseTimeTab({ caseRecord }: { caseRecord: any }) {
const { user } = useAuth();
const [entries, setEntries] = useState<any[]>([]);
const [showForm, setShowForm] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
work_date: new Date().toISOString().slice(0, 10),
hours: "",
hourly_rate: caseRecord.default_hourly_rate?.toString() ?? "",
description: "",
billable: true,
});
const load = async () => {
const { data } = await supabase
.from("time_entries")
.select("*, user:profiles!time_entries_user_id_fkey(full_name, email)")
.eq("case_id", caseRecord.id)
.order("work_date", { ascending: false });
setEntries(data ?? []);
};
useEffect(() => { load(); }, [caseRecord.id]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const hours = parseFloat(form.hours);
const rate = parseFloat(form.hourly_rate || "0");
if (!hours || hours <= 0) { toast.error("Hours must be greater than 0"); return; }
if (!form.description.trim()) { toast.error("Description required"); return; }
setSubmitting(true);
const { error } = await supabase.from("time_entries").insert({
case_id: caseRecord.id,
user_id: user?.id,
work_date: form.work_date,
hours,
hourly_rate: rate,
description: form.description.trim(),
billable: form.billable,
});
setSubmitting(false);
if (error) toast.error(error.message);
else {
toast.success("Time entry added");
setShowForm(false);
setForm({ ...form, hours: "", description: "" });
load();
}
};
const del = async (id: string) => {
if (!confirm("Delete this entry?")) return;
const { error } = await supabase.from("time_entries").delete().eq("id", id);
if (error) toast.error(error.message); else { toast.success("Deleted"); load(); }
};
const totals = entries.reduce(
(acc, e) => ({
hours: acc.hours + Number(e.hours),
billable: acc.billable + (e.billable ? Number(e.hours) * Number(e.hourly_rate) : 0),
unbilled: acc.unbilled + (e.billable && !e.invoice_id ? Number(e.hours) * Number(e.hourly_rate) : 0),
}),
{ hours: 0, billable: 0, unbilled: 0 },
);
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex gap-4 text-sm">
<Stat label="Hours" value={totals.hours.toFixed(1)} />
<Stat label="Billed total" value={formatCurrency(totals.billable)} />
<Stat label="Unbilled" value={formatCurrency(totals.unbilled)} />
</div>
<Button onClick={() => setShowForm((s) => !s)}>
<Plus className="h-4 w-4 mr-2" /> {showForm ? "Cancel" : "Log time"}
</Button>
</div>
{showForm && (
<Card className="border-border/60">
<CardContent className="p-4">
<form onSubmit={submit} className="grid grid-cols-1 md:grid-cols-4 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">Hourly rate ($)</Label>
<Input type="number" step="0.01" min="0" value={form.hourly_rate} onChange={(e) => setForm({ ...form, hourly_rate: e.target.value })} required />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
</div>
<div className="md:col-span-4 space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} required maxLength={1000} />
</div>
<div className="md:col-span-4 flex justify-end">
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save entry
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-left px-4 py-3 font-medium">User</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-right px-4 py-3 font-medium">Hours</th>
<th className="text-right px-4 py-3 font-medium">Rate</th>
<th className="text-right px-4 py-3 font-medium">Amount</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{entries.length === 0 && <tr><td colSpan={8} className="text-center py-12 text-muted-foreground">No time entries.</td></tr>}
{entries.map((e) => (
<tr key={e.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(e.work_date)}</td>
<td className="px-4 py-3 text-muted-foreground">{e.user?.full_name || e.user?.email}</td>
<td className="px-4 py-3 max-w-md">{e.description}</td>
<td className="px-4 py-3 text-right tabular-nums">{Number(e.hours).toFixed(1)}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{formatCurrency(e.hourly_rate)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{e.billable ? formatCurrency(Number(e.hours) * Number(e.hourly_rate)) : "—"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{!e.billable ? "Non-billable" : e.invoice_id ? "Invoiced" : "Unbilled"}</td>
<td className="px-4 py-3 text-right">
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => del(e.id)}><Trash2 className="h-4 w-4 text-destructive" /></Button>}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="font-serif text-lg">{value}</div>
</div>
);
}
+21
View File
@@ -16,6 +16,7 @@ import { Route as ClientsIndexRouteImport } from './routes/clients.index'
import { Route as CasesIndexRouteImport } from './routes/cases.index'
import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId'
import { Route as CasesNewRouteImport } from './routes/cases.new'
import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId'
const SetupRoute = SetupRouteImport.update({
id: '/setup',
@@ -52,11 +53,17 @@ const CasesNewRoute = CasesNewRouteImport.update({
path: '/cases/new',
getParentRoute: () => rootRouteImport,
} as any)
const CasesCaseIdRoute = CasesCaseIdRouteImport.update({
id: '/cases/$caseId',
path: '/cases/$caseId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/cases/': typeof CasesIndexRoute
@@ -66,6 +73,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/cases': typeof CasesIndexRoute
@@ -76,6 +84,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/cases/': typeof CasesIndexRoute
@@ -87,6 +96,7 @@ export interface FileRouteTypes {
| '/'
| '/login'
| '/setup'
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/cases/'
@@ -96,6 +106,7 @@ export interface FileRouteTypes {
| '/'
| '/login'
| '/setup'
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/cases'
@@ -105,6 +116,7 @@ export interface FileRouteTypes {
| '/'
| '/login'
| '/setup'
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/cases/'
@@ -115,6 +127,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
SetupRoute: typeof SetupRoute
CasesCaseIdRoute: typeof CasesCaseIdRoute
CasesNewRoute: typeof CasesNewRoute
ClientsClientIdRoute: typeof ClientsClientIdRoute
CasesIndexRoute: typeof CasesIndexRoute
@@ -172,6 +185,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CasesNewRouteImport
parentRoute: typeof rootRouteImport
}
'/cases/$caseId': {
id: '/cases/$caseId'
path: '/cases/$caseId'
fullPath: '/cases/$caseId'
preLoaderRoute: typeof CasesCaseIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@@ -179,6 +199,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
SetupRoute: SetupRoute,
CasesCaseIdRoute: CasesCaseIdRoute,
CasesNewRoute: CasesNewRoute,
ClientsClientIdRoute: ClientsClientIdRoute,
CasesIndexRoute: CasesIndexRoute,
+170
View File
@@ -0,0 +1,170 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState, useCallback } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { supabase } from "@/integrations/supabase/client";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt } from "lucide-react";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { CaseDocumentsTab } from "@/components/cases/documents-tab";
import { CaseTimeTab } from "@/components/cases/time-tab";
import { CaseExpensesTab } from "@/components/cases/expenses-tab";
import { CaseStatusTab } from "@/components/cases/status-tab";
import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
export const Route = createFileRoute("/cases/$caseId")({
component: () => (
<ProtectedLayout>
<CaseDetail />
</ProtectedLayout>
),
});
function CaseDetail() {
const { caseId } = Route.useParams();
const { user, isAdmin } = useAuth();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<any[]>([]);
const load = useCallback(async () => {
const [{ data: c, error }, { data: us }] = await Promise.all([
supabase
.from("cases")
.select("*, client:clients(id, name, client_type), assignee:profiles!cases_assigned_attorney_id_fkey(id, full_name, email)")
.eq("id", caseId)
.maybeSingle(),
supabase.from("profiles").select("id, full_name, email"),
]);
if (error) toast.error("Failed to load case", { description: error.message });
setData(c);
setUsers(us ?? []);
setLoading(false);
}, [caseId]);
useEffect(() => {
load();
}, [load]);
const updateStatus = async (status: string) => {
const { error } = await supabase
.from("cases")
.update({ status, closed_at: status.startsWith("closed") ? new Date().toISOString().slice(0, 10) : null })
.eq("id", caseId);
if (error) toast.error("Could not update", { description: error.message });
else {
toast.success("Status updated");
load();
}
};
const updateAssignee = async (assigned_attorney_id: string) => {
const { error } = await supabase.from("cases").update({ assigned_attorney_id }).eq("id", caseId);
if (error) toast.error(error.message);
else { toast.success("Assignee updated"); load(); }
};
if (loading) return <PageContainer><p className="text-muted-foreground">Loading…</p></PageContainer>;
if (!data) {
return (
<PageContainer>
<p className="text-muted-foreground">Case not found or you don't have access.</p>
<Button variant="outline" className="mt-3" asChild>
<Link to="/cases"><ArrowLeft className="h-4 w-4 mr-2" /> All cases</Link>
</Button>
</PageContainer>
);
}
const canManage = isAdmin || data.assigned_attorney_id === user?.id || data.created_by === user?.id;
return (
<PageContainer>
<Button variant="ghost" size="sm" asChild className="mb-3 -ml-2">
<Link to="/cases"><ArrowLeft className="h-4 w-4 mr-1" /> All cases</Link>
</Button>
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="text-xs uppercase tracking-widest text-muted-foreground">{data.case_number}</span>
<Badge variant="outline" className={statusBadgeClass(data.status)}>{data.status.replace("_", " ")}</Badge>
</div>
<h1 className="font-serif text-3xl">{data.title}</h1>
<p className="text-sm text-muted-foreground mt-1">
<Link to="/clients/$clientId" params={{ clientId: data.client.id }} className="hover:text-primary underline-offset-4 hover:underline">
{data.client.name}
</Link>
{" · "}{data.practice_area || "—"}{" · "}opened {formatDate(data.opened_at)}
</p>
</div>
{canManage && (
<div className="flex flex-wrap gap-2">
<Select value={data.assigned_attorney_id ?? ""} onValueChange={updateAssignee}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder="Assign attorney" /></SelectTrigger>
<SelectContent>
{users.map((u) => <SelectItem key={u.id} value={u.id}>{u.full_name || u.email}</SelectItem>)}
</SelectContent>
</Select>
<Select value={data.status} onValueChange={updateStatus}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="intake">Intake</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="on_hold">On hold</SelectItem>
<SelectItem value="closed_won">Closed — won</SelectItem>
<SelectItem value="closed_lost">Closed — lost</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{data.description && (
<Card className="mb-6 border-border/60">
<CardContent className="p-4">
<p className="text-sm whitespace-pre-wrap">{data.description}</p>
</CardContent>
</Card>
)}
<Tabs defaultValue="overview">
<TabsList className="mb-4 flex-wrap h-auto">
<TabsTrigger value="overview"><Activity className="h-3.5 w-3.5 mr-1.5" />Status log</TabsTrigger>
<TabsTrigger value="documents"><FileText className="h-3.5 w-3.5 mr-1.5" />Documents</TabsTrigger>
<TabsTrigger value="time"><Clock className="h-3.5 w-3.5 mr-1.5" />Time</TabsTrigger>
<TabsTrigger value="expenses"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Expenses</TabsTrigger>
<TabsTrigger value="invoices"><Receipt className="h-3.5 w-3.5 mr-1.5" />Invoices</TabsTrigger>
</TabsList>
<TabsContent value="overview"><CaseStatusTab caseId={caseId} /></TabsContent>
<TabsContent value="documents"><CaseDocumentsTab caseId={caseId} /></TabsContent>
<TabsContent value="time"><CaseTimeTab caseRecord={data} /></TabsContent>
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={data} /></TabsContent>
</Tabs>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-4 gap-3 text-center text-xs">
<SmallStat label="Default rate" value={data.default_hourly_rate ? formatCurrency(data.default_hourly_rate) + "/hr" : "—"} />
<SmallStat label="Attorney" value={data.assignee?.full_name || data.assignee?.email || "—"} />
<SmallStat label="Closed" value={data.closed_at ? formatDate(data.closed_at) : "—"} />
<SmallStat label="Updated" value={formatDate(data.updated_at)} />
</div>
</PageContainer>
);
}
function SmallStat({ label, value }: { label: string; value: string }) {
return (
<div className="border rounded-md py-2 px-3">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="text-sm font-medium mt-0.5 truncate">{value}</div>
</div>
);
}