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>
);
}