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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user