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