Added invoiced toggle actions
X-Lovable-Edit-ID: edt-97a206d8-bb3a-441b-a375-2cdbad546f13 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -6,8 +6,9 @@ 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 { Plus, Trash2, Loader2, Receipt as ReceiptIcon, CheckCircle2, Undo2 } from "lucide-react";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { ensureMarkedInvoicedPlaceholder } from "@/lib/invoice-generation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Select,
|
||||
@@ -28,6 +29,7 @@ interface FeeItem {
|
||||
export function CaseExpensesTab({ caseId }: { caseId: string }) {
|
||||
const { user } = useAuth();
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [clientId, setClientId] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
@@ -53,6 +55,35 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) {
|
||||
|
||||
useEffect(() => { load(); }, [caseId]);
|
||||
|
||||
// Load case client_id for placeholder invoice creation
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { data } = await supabase.from("cases").select("client_id").eq("id", caseId).maybeSingle();
|
||||
setClientId((data as any)?.client_id ?? null);
|
||||
})();
|
||||
}, [caseId]);
|
||||
|
||||
const toggleInvoiced = async (item: any) => {
|
||||
if (item.invoice_id) {
|
||||
const { error } = await supabase.from("expenses").update({ invoice_id: null }).eq("id", item.id);
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success("Marked as unbilled");
|
||||
load();
|
||||
} else {
|
||||
if (!user?.id) { toast.error("Not signed in"); return; }
|
||||
if (!clientId) { toast.error("Case has no client to invoice against"); return; }
|
||||
try {
|
||||
const invId = await ensureMarkedInvoicedPlaceholder(clientId, caseId, user.id);
|
||||
const { error } = await supabase.from("expenses").update({ invoice_id: invId }).eq("id", item.id);
|
||||
if (error) throw error;
|
||||
toast.success("Marked as invoiced");
|
||||
load();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? "Could not mark as invoiced");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { data } = await supabase
|
||||
@@ -234,7 +265,24 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) {
|
||||
<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>}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{e.billable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => toggleInvoiced(e)}
|
||||
title={e.invoice_id ? "Mark as unbilled" : "Mark as invoiced"}
|
||||
>
|
||||
{e.invoice_id ? (
|
||||
<><Undo2 className="h-3.5 w-3.5 mr-1" /> Unbill</>
|
||||
) : (
|
||||
<><CheckCircle2 className="h-3.5 w-3.5 mr-1" /> Invoiced</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => del(e)}><Trash2 className="h-4 w-4 text-destructive" /></Button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -7,8 +7,9 @@ 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, FilePlus, PlusCircle } from "lucide-react";
|
||||
import { Plus, Trash2, Loader2, FilePlus, PlusCircle, CheckCircle2, Undo2 } from "lucide-react";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { ensureMarkedInvoicedPlaceholder } from "@/lib/invoice-generation";
|
||||
import { roundToSixth } from "@/lib/timer";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -134,6 +135,28 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) {
|
||||
if (error) toast.error(error.message); else { toast.success("Deleted"); load(); }
|
||||
};
|
||||
|
||||
const toggleInvoiced = async (entry: any) => {
|
||||
if (entry.invoice_id) {
|
||||
// Unmark
|
||||
const { error } = await supabase.from("time_entries").update({ invoice_id: null }).eq("id", entry.id);
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success("Marked as unbilled");
|
||||
load();
|
||||
} else {
|
||||
if (!user?.id) { toast.error("Not signed in"); return; }
|
||||
if (!caseRecord.client_id) { toast.error("Case has no client to invoice against"); return; }
|
||||
try {
|
||||
const invId = await ensureMarkedInvoicedPlaceholder(caseRecord.client_id, caseRecord.id, user.id);
|
||||
const { error } = await supabase.from("time_entries").update({ invoice_id: invId }).eq("id", entry.id);
|
||||
if (error) throw error;
|
||||
toast.success("Marked as invoiced");
|
||||
load();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? "Could not mark as invoiced");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const totals = entries.reduce(
|
||||
(acc, e) => ({
|
||||
hours: acc.hours + Number(e.hours),
|
||||
@@ -286,7 +309,24 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) {
|
||||
<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>}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{e.billable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => toggleInvoiced(e)}
|
||||
title={e.invoice_id ? "Mark as unbilled" : "Mark as invoiced"}
|
||||
>
|
||||
{e.invoice_id ? (
|
||||
<><Undo2 className="h-3.5 w-3.5 mr-1" /> Unbill</>
|
||||
) : (
|
||||
<><CheckCircle2 className="h-3.5 w-3.5 mr-1" /> Invoiced</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => del(e.id)}><Trash2 className="h-4 w-4 text-destructive" /></Button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -145,6 +145,47 @@ export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promi
|
||||
return { invoiceId: inv.id, invoiceNumber: inv.invoice_number };
|
||||
}
|
||||
|
||||
// Find or create a per-client placeholder invoice used to mark items
|
||||
// as "already invoiced" outside of normal invoice generation (e.g. manual
|
||||
// flagging or imports). Items linked to this invoice are treated as billed
|
||||
// and won't appear on unbilled lists.
|
||||
export async function ensureMarkedInvoicedPlaceholder(
|
||||
clientId: string,
|
||||
caseId: string | null,
|
||||
createdBy: string,
|
||||
): Promise<string> {
|
||||
// Look for an existing placeholder for this client (any case).
|
||||
const { data: existing } = await supabase
|
||||
.from("invoices")
|
||||
.select("id")
|
||||
.eq("client_id", clientId)
|
||||
.like("invoice_number", "MARKED-INVOICED-%")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (existing?.id) return existing.id;
|
||||
|
||||
const invNumber = `MARKED-INVOICED-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const { data, error } = await supabase
|
||||
.from("invoices")
|
||||
.insert({
|
||||
client_id: clientId,
|
||||
case_id: caseId,
|
||||
invoice_number: invNumber,
|
||||
status: "void",
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
subtotal: 0,
|
||||
tax: 0,
|
||||
total: 0,
|
||||
notes: "Placeholder invoice for items manually marked as already invoiced.",
|
||||
created_by: createdBy,
|
||||
} as any)
|
||||
.select("id")
|
||||
.single();
|
||||
if (error || !data) throw error ?? new Error("Could not create placeholder invoice");
|
||||
return data.id;
|
||||
}
|
||||
|
||||
export async function recalcInvoiceTotals(invoiceId: string, taxRate?: number) {
|
||||
const { data: items } = await supabase
|
||||
.from("invoice_line_items")
|
||||
|
||||
+298
-82
@@ -6,11 +6,15 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Receipt, Search, FilePlus, Clock, DollarSign } from "lucide-react";
|
||||
import { Receipt, Search, FilePlus, Clock, DollarSign, CheckCircle2, Undo2 } from "lucide-react";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { ensureMarkedInvoicedPlaceholder } from "@/lib/invoice-generation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/invoices/")({
|
||||
component: () => (
|
||||
@@ -20,16 +24,27 @@ export const Route = createFileRoute("/invoices/")({
|
||||
),
|
||||
});
|
||||
|
||||
type UnbilledStatus = "unbilled" | "marked" | "all";
|
||||
|
||||
function InvoicesIndex() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState<string>("all");
|
||||
|
||||
const [unbilledTime, setUnbilledTime] = useState<any[]>([]);
|
||||
const [unbilledExpenses, setUnbilledExpenses] = useState<any[]>([]);
|
||||
const [time, setTime] = useState<any[]>([]);
|
||||
const [expenses, setExpenses] = useState<any[]>([]);
|
||||
const [tabsLoaded, setTabsLoaded] = useState(false);
|
||||
const [timeFilter, setTimeFilter] = useState<UnbilledStatus>("unbilled");
|
||||
const [expFilter, setExpFilter] = useState<UnbilledStatus>("unbilled");
|
||||
const [selectedTime, setSelectedTime] = useState<Set<string>>(new Set());
|
||||
const [selectedExp, setSelectedExp] = useState<Set<string>>(new Set());
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
// Map of placeholder invoice id -> true (placeholder = MARKED-INVOICED-* or IMPORT-PREBILLED-*)
|
||||
const [placeholderIds, setPlaceholderIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -39,32 +54,41 @@ function InvoicesIndex() {
|
||||
.order("created_at", { ascending: false });
|
||||
setInvoices(data ?? []);
|
||||
setLoading(false);
|
||||
|
||||
// Track placeholder invoices so we can identify "marked-invoiced" items
|
||||
const placeholders = new Set<string>(
|
||||
(data ?? [])
|
||||
.filter((i: any) =>
|
||||
typeof i.invoice_number === "string" &&
|
||||
(i.invoice_number.startsWith("MARKED-INVOICED-") || i.invoice_number.startsWith("IMPORT-PREBILLED-"))
|
||||
)
|
||||
.map((i: any) => i.id),
|
||||
);
|
||||
setPlaceholderIds(placeholders);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [{ data: time }, { data: exp }] = await Promise.all([
|
||||
supabase
|
||||
.from("time_entries")
|
||||
.select("id, work_date, description, hours, hourly_rate, billable, user_id, case:cases(id, case_number, title, client:clients(id, name)), profile:profiles!time_entries_user_id_fkey(id, full_name, email)")
|
||||
.eq("billable", true)
|
||||
.is("invoice_id", null)
|
||||
.order("work_date", { ascending: false })
|
||||
.limit(1000),
|
||||
supabase
|
||||
.from("expenses")
|
||||
.select("id, expense_date, description, amount, billable, user_id, case:cases(id, case_number, title, client:clients(id, name)), profile:profiles!expenses_user_id_fkey(id, full_name, email)")
|
||||
.eq("billable", true)
|
||||
.is("invoice_id", null)
|
||||
.order("expense_date", { ascending: false })
|
||||
.limit(1000),
|
||||
]);
|
||||
setUnbilledTime(time ?? []);
|
||||
setUnbilledExpenses(exp ?? []);
|
||||
setTabsLoaded(true);
|
||||
})();
|
||||
}, []);
|
||||
const loadUnbilled = async () => {
|
||||
const [{ data: t }, { data: e }] = await Promise.all([
|
||||
supabase
|
||||
.from("time_entries")
|
||||
.select("id, work_date, description, hours, hourly_rate, billable, user_id, invoice_id, case_id, case:cases(id, case_number, title, client_id, client:clients(id, name)), profile:profiles!time_entries_user_id_fkey(id, full_name, email)")
|
||||
.eq("billable", true)
|
||||
.order("work_date", { ascending: false })
|
||||
.limit(2000),
|
||||
supabase
|
||||
.from("expenses")
|
||||
.select("id, expense_date, description, amount, billable, user_id, invoice_id, case_id, case:cases(id, case_number, title, client_id, client:clients(id, name)), profile:profiles!expenses_user_id_fkey(id, full_name, email)")
|
||||
.eq("billable", true)
|
||||
.order("expense_date", { ascending: false })
|
||||
.limit(2000),
|
||||
]);
|
||||
setTime(t ?? []);
|
||||
setExpenses(e ?? []);
|
||||
setTabsLoaded(true);
|
||||
};
|
||||
|
||||
useEffect(() => { loadUnbilled(); }, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return invoices.filter((i) => {
|
||||
@@ -85,15 +109,102 @@ function InvoicesIndex() {
|
||||
return { outstanding, paid, count: filtered.length };
|
||||
}, [filtered]);
|
||||
|
||||
const unbilledTimeTotal = useMemo(
|
||||
() => unbilledTime.reduce((s, t) => s + Number(t.hours) * Number(t.hourly_rate), 0),
|
||||
[unbilledTime]
|
||||
// Classify each row: unbilled (no invoice_id), marked (linked to placeholder), real (linked to real invoice — not shown)
|
||||
const isPlaceholder = (invId: string | null) => !!invId && placeholderIds.has(invId);
|
||||
const rowStatus = (invId: string | null): UnbilledStatus =>
|
||||
!invId ? "unbilled" : isPlaceholder(invId) ? "marked" : "all"; // "all" sentinel for real-invoice rows we hide
|
||||
|
||||
const visibleTime = useMemo(() => {
|
||||
return time.filter((t) => {
|
||||
const s = rowStatus(t.invoice_id);
|
||||
if (s === "all" /* real invoice */) return false;
|
||||
if (timeFilter === "all") return true;
|
||||
return s === timeFilter;
|
||||
});
|
||||
}, [time, timeFilter, placeholderIds]);
|
||||
|
||||
const visibleExp = useMemo(() => {
|
||||
return expenses.filter((e) => {
|
||||
const s = rowStatus(e.invoice_id);
|
||||
if (s === "all") return false;
|
||||
if (expFilter === "all") return true;
|
||||
return s === expFilter;
|
||||
});
|
||||
}, [expenses, expFilter, placeholderIds]);
|
||||
|
||||
const visibleTimeTotal = useMemo(
|
||||
() => visibleTime.reduce((s, t) => s + Number(t.hours) * Number(t.hourly_rate), 0),
|
||||
[visibleTime],
|
||||
);
|
||||
const unbilledExpenseTotal = useMemo(
|
||||
() => unbilledExpenses.reduce((s, e) => s + Number(e.amount), 0),
|
||||
[unbilledExpenses]
|
||||
const visibleExpTotal = useMemo(
|
||||
() => visibleExp.reduce((s, e) => s + Number(e.amount), 0),
|
||||
[visibleExp],
|
||||
);
|
||||
|
||||
const toggleSel = (set: Set<string>, id: string) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
};
|
||||
|
||||
const bulkUpdate = async (
|
||||
table: "time_entries" | "expenses",
|
||||
rows: any[],
|
||||
ids: Set<string>,
|
||||
markInvoiced: boolean,
|
||||
) => {
|
||||
if (!user?.id) { toast.error("Not signed in"); return; }
|
||||
if (ids.size === 0) { toast.error("Select at least one item"); return; }
|
||||
setWorking(true);
|
||||
try {
|
||||
if (markInvoiced) {
|
||||
// Group by client_id; create/find placeholder per client.
|
||||
const byClient = new Map<string, { caseId: string; rowIds: string[] }>();
|
||||
const skipped: string[] = [];
|
||||
for (const r of rows) {
|
||||
if (!ids.has(r.id)) continue;
|
||||
const cid = r.case?.client_id ?? r.case?.client?.id ?? null;
|
||||
if (!cid || !r.case_id) { skipped.push(r.id); continue; }
|
||||
const slot = byClient.get(cid);
|
||||
if (slot) slot.rowIds.push(r.id);
|
||||
else byClient.set(cid, { caseId: r.case_id, rowIds: [r.id] });
|
||||
}
|
||||
for (const [cid, slot] of byClient) {
|
||||
const invId = await ensureMarkedInvoicedPlaceholder(cid, slot.caseId, user.id);
|
||||
const { error } = await supabase.from(table).update({ invoice_id: invId }).in("id", slot.rowIds);
|
||||
if (error) throw error;
|
||||
}
|
||||
if (skipped.length) toast.warning(`${skipped.length} item(s) skipped — missing client/case`);
|
||||
toast.success(`Marked ${ids.size - skipped.length} item(s) as invoiced`);
|
||||
} else {
|
||||
const idArr = Array.from(ids);
|
||||
const { error } = await supabase.from(table).update({ invoice_id: null }).in("id", idArr);
|
||||
if (error) throw error;
|
||||
toast.success(`Marked ${idArr.length} item(s) as unbilled`);
|
||||
}
|
||||
// Refetch invoices to refresh placeholder set + unbilled lists
|
||||
const { data: inv } = await supabase
|
||||
.from("invoices")
|
||||
.select("*, client:clients(id, name), case:cases(id, case_number, title)")
|
||||
.order("created_at", { ascending: false });
|
||||
setInvoices(inv ?? []);
|
||||
setPlaceholderIds(new Set(
|
||||
(inv ?? [])
|
||||
.filter((i: any) =>
|
||||
typeof i.invoice_number === "string" &&
|
||||
(i.invoice_number.startsWith("MARKED-INVOICED-") || i.invoice_number.startsWith("IMPORT-PREBILLED-"))
|
||||
)
|
||||
.map((i: any) => i.id),
|
||||
));
|
||||
await loadUnbilled();
|
||||
if (table === "time_entries") setSelectedTime(new Set()); else setSelectedExp(new Set());
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? "Bulk update failed");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
@@ -118,16 +229,10 @@ function InvoicesIndex() {
|
||||
<Receipt className="h-4 w-4 mr-1.5" /> Invoices
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="time">
|
||||
<Clock className="h-4 w-4 mr-1.5" /> Unbilled time
|
||||
{unbilledTime.length > 0 && (
|
||||
<Badge variant="outline" className="ml-2 h-5 px-1.5 text-[10px]">{unbilledTime.length}</Badge>
|
||||
)}
|
||||
<Clock className="h-4 w-4 mr-1.5" /> Time
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="expenses">
|
||||
<DollarSign className="h-4 w-4 mr-1.5" /> Unbilled expenses
|
||||
{unbilledExpenses.length > 0 && (
|
||||
<Badge variant="outline" className="ml-2 h-5 px-1.5 text-[10px]">{unbilledExpenses.length}</Badge>
|
||||
)}
|
||||
<DollarSign className="h-4 w-4 mr-1.5" /> Expenses
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -203,17 +308,29 @@ function InvoicesIndex() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="time" className="mt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{unbilledTime.length} unbilled time entries
|
||||
</div>
|
||||
<div className="font-serif text-xl tabular-nums">{formatCurrency(unbilledTimeTotal)}</div>
|
||||
</div>
|
||||
<UnbilledToolbar
|
||||
label="time entries"
|
||||
filter={timeFilter}
|
||||
setFilter={setTimeFilter}
|
||||
count={visibleTime.length}
|
||||
total={visibleTimeTotal}
|
||||
selectedCount={selectedTime.size}
|
||||
working={working}
|
||||
onMarkInvoiced={() => bulkUpdate("time_entries", visibleTime, selectedTime, true)}
|
||||
onMarkUnbilled={() => bulkUpdate("time_entries", visibleTime, selectedTime, false)}
|
||||
onClearSel={() => setSelectedTime(new Set())}
|
||||
/>
|
||||
<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="px-3 py-3 w-9">
|
||||
<Checkbox
|
||||
checked={visibleTime.length > 0 && selectedTime.size === visibleTime.length}
|
||||
onCheckedChange={(c) => setSelectedTime(c ? new Set(visibleTime.map((t) => t.id)) : new Set())}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Date</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Client / Case</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Description</th>
|
||||
@@ -221,20 +338,28 @@ function InvoicesIndex() {
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!tabsLoaded && <tr><td colSpan={7} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
|
||||
{tabsLoaded && unbilledTime.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
{!tabsLoaded && <tr><td colSpan={9} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
|
||||
{tabsLoaded && visibleTime.length === 0 && (
|
||||
<tr><td colSpan={9} className="text-center py-12 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No unbilled time entries.
|
||||
No matching time entries.
|
||||
</td></tr>
|
||||
)}
|
||||
{unbilledTime.map((t) => {
|
||||
{visibleTime.map((t) => {
|
||||
const amount = Number(t.hours) * Number(t.hourly_rate);
|
||||
const marked = isPlaceholder(t.invoice_id);
|
||||
return (
|
||||
<tr key={t.id} className="border-t hover:bg-muted/30">
|
||||
<td className="px-3 py-3">
|
||||
<Checkbox
|
||||
checked={selectedTime.has(t.id)}
|
||||
onCheckedChange={() => setSelectedTime((s) => toggleSel(s, t.id))}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(t.work_date)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{t.case ? (
|
||||
@@ -255,6 +380,13 @@ function InvoicesIndex() {
|
||||
<td className="px-4 py-3 text-right tabular-nums">{Number(t.hours).toFixed(2)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{formatCurrency(t.hourly_rate)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(amount)}</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{marked ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">Marked invoiced</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Unbilled</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -265,54 +397,83 @@ function InvoicesIndex() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="expenses" className="mt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{unbilledExpenses.length} unbilled expenses
|
||||
</div>
|
||||
<div className="font-serif text-xl tabular-nums">{formatCurrency(unbilledExpenseTotal)}</div>
|
||||
</div>
|
||||
<UnbilledToolbar
|
||||
label="expenses"
|
||||
filter={expFilter}
|
||||
setFilter={setExpFilter}
|
||||
count={visibleExp.length}
|
||||
total={visibleExpTotal}
|
||||
selectedCount={selectedExp.size}
|
||||
working={working}
|
||||
onMarkInvoiced={() => bulkUpdate("expenses", visibleExp, selectedExp, true)}
|
||||
onMarkUnbilled={() => bulkUpdate("expenses", visibleExp, selectedExp, false)}
|
||||
onClearSel={() => setSelectedExp(new Set())}
|
||||
/>
|
||||
<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="px-3 py-3 w-9">
|
||||
<Checkbox
|
||||
checked={visibleExp.length > 0 && selectedExp.size === visibleExp.length}
|
||||
onCheckedChange={(c) => setSelectedExp(c ? new Set(visibleExp.map((e) => e.id)) : new Set())}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Date</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Client / Case</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!tabsLoaded && <tr><td colSpan={5} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
|
||||
{tabsLoaded && unbilledExpenses.length === 0 && (
|
||||
<tr><td colSpan={5} className="text-center py-12 text-muted-foreground">
|
||||
{!tabsLoaded && <tr><td colSpan={7} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
|
||||
{tabsLoaded && visibleExp.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<DollarSign className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No unbilled expenses.
|
||||
No matching expenses.
|
||||
</td></tr>
|
||||
)}
|
||||
{unbilledExpenses.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.case ? (
|
||||
<div className="min-w-0">
|
||||
{e.case.client && (
|
||||
<Link to="/clients/$clientId" params={{ clientId: e.case.client.id }} className="text-xs text-muted-foreground hover:text-primary block truncate">
|
||||
{e.case.client.name}
|
||||
{visibleExp.map((e) => {
|
||||
const marked = isPlaceholder(e.invoice_id);
|
||||
return (
|
||||
<tr key={e.id} className="border-t hover:bg-muted/30">
|
||||
<td className="px-3 py-3">
|
||||
<Checkbox
|
||||
checked={selectedExp.has(e.id)}
|
||||
onCheckedChange={() => setSelectedExp((s) => toggleSel(s, e.id))}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(e.expense_date)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{e.case ? (
|
||||
<div className="min-w-0">
|
||||
{e.case.client && (
|
||||
<Link to="/clients/$clientId" params={{ clientId: e.case.client.id }} className="text-xs text-muted-foreground hover:text-primary block truncate">
|
||||
{e.case.client.name}
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/cases/$caseId" params={{ caseId: e.case.id }} className="hover:text-primary truncate block">
|
||||
{e.case.case_number} · {e.case.title}
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/cases/$caseId" params={{ caseId: e.case.id }} className="hover:text-primary truncate block">
|
||||
{e.case.case_number} · {e.case.title}
|
||||
</Link>
|
||||
</div>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground max-w-md truncate">{e.description}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{e.profile?.full_name || e.profile?.email || "—"}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(e.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground max-w-md truncate">{e.description}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{e.profile?.full_name || e.profile?.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">
|
||||
{marked ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">Marked invoiced</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Unbilled</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
@@ -323,6 +484,61 @@ function InvoicesIndex() {
|
||||
);
|
||||
}
|
||||
|
||||
function UnbilledToolbar({
|
||||
label,
|
||||
filter,
|
||||
setFilter,
|
||||
count,
|
||||
total,
|
||||
selectedCount,
|
||||
working,
|
||||
onMarkInvoiced,
|
||||
onMarkUnbilled,
|
||||
onClearSel,
|
||||
}: {
|
||||
label: string;
|
||||
filter: UnbilledStatus;
|
||||
setFilter: (v: UnbilledStatus) => void;
|
||||
count: number;
|
||||
total: number;
|
||||
selectedCount: number;
|
||||
working: boolean;
|
||||
onMarkInvoiced: () => void;
|
||||
onMarkUnbilled: () => void;
|
||||
onClearSel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={filter} onValueChange={(v) => setFilter(v as UnbilledStatus)}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unbilled">Unbilled only</SelectItem>
|
||||
<SelectItem value="marked">Marked invoiced</SelectItem>
|
||||
<SelectItem value="all">All (unbilled + marked)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-sm text-muted-foreground">{count} {label}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">{selectedCount} selected</span>
|
||||
<Button variant="outline" size="sm" disabled={working} onClick={onMarkInvoiced}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-1.5" /> Mark invoiced
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={working} onClick={onMarkUnbilled}>
|
||||
<Undo2 className="h-3.5 w-3.5 mr-1.5" /> Mark unbilled
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onClearSel}>Clear</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="font-serif text-xl tabular-nums ml-3">{formatCurrency(total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card className="border-border/60">
|
||||
|
||||
Reference in New Issue
Block a user