diff --git a/src/components/cases/import-unbilled-dialog.tsx b/src/components/cases/import-unbilled-dialog.tsx new file mode 100644 index 0000000..898230a --- /dev/null +++ b/src/components/cases/import-unbilled-dialog.tsx @@ -0,0 +1,219 @@ +import { useEffect, useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Loader2, Search, Inbox } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { formatCurrency, formatDate } from "@/lib/format"; +import { toast } from "sonner"; + +type Kind = "time" | "expenses"; + +interface Props { + open: boolean; + onOpenChange: (v: boolean) => void; + kind: Kind; + caseId: string; + clientId: string | null; + onImported?: () => void; +} + +/** + * Pulls billable, unbilled (invoice_id IS NULL) entries from OTHER cases that + * share the same client as the current case, and re-assigns their case_id to + * the current case. + */ +export function ImportUnbilledDialog({ open, onOpenChange, kind, caseId, clientId, onImported }: Props) { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [q, setQ] = useState(""); + const [working, setWorking] = useState(false); + + const table = kind === "time" ? "time_entries" : "expenses"; + const dateCol = kind === "time" ? "work_date" : "expense_date"; + + useEffect(() => { + if (!open) return; + setSelected(new Set()); + setQ(""); + if (!clientId) { setRows([]); return; } + (async () => { + setLoading(true); + // Find all cases for this client (excluding the current case) + const { data: cases } = await supabase + .from("cases") + .select("id, case_number, title") + .eq("client_id", clientId) + .neq("id", caseId); + const caseIds = (cases ?? []).map((c) => c.id); + const caseMap = Object.fromEntries((cases ?? []).map((c) => [c.id, c])); + if (caseIds.length === 0) { + setRows([]); + setLoading(false); + return; + } + const select = kind === "time" + ? "id, work_date, description, hours, hourly_rate, case_id, user:profiles!time_entries_user_id_fkey(full_name, email)" + : "id, expense_date, description, amount, case_id, user:profiles!expenses_user_id_fkey(full_name, email)"; + const { data, error } = await supabase + .from(table) + .select(select) + .in("case_id", caseIds) + .eq("billable", true) + .is("invoice_id", null) + .order(dateCol, { ascending: false }) + .limit(1000); + if (error) toast.error(error.message); + setRows((data ?? []).map((r: any) => ({ ...r, _case: caseMap[r.case_id] }))); + setLoading(false); + })(); + }, [open, clientId, caseId, kind]); + + const filtered = useMemo(() => { + const s = q.trim().toLowerCase(); + if (!s) return rows; + return rows.filter((r) => + (r.description ?? "").toLowerCase().includes(s) || + (r._case?.case_number ?? "").toLowerCase().includes(s) || + (r._case?.title ?? "").toLowerCase().includes(s) || + (r.user?.full_name ?? "").toLowerCase().includes(s), + ); + }, [rows, q]); + + const total = useMemo(() => { + return filtered + .filter((r) => selected.has(r.id)) + .reduce((s, r) => s + (kind === "time" ? Number(r.hours) * Number(r.hourly_rate) : Number(r.amount)), 0); + }, [filtered, selected, kind]); + + const toggle = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const importSelected = async () => { + if (selected.size === 0) { toast.error("Select at least one entry"); return; } + setWorking(true); + const ids = Array.from(selected); + const { error } = await supabase.from(table).update({ case_id: caseId }).in("id", ids); + setWorking(false); + if (error) { toast.error(error.message); return; } + toast.success(`Imported ${ids.length} ${kind === "time" ? "time entries" : "expenses"}`); + onImported?.(); + onOpenChange(false); + }; + + const allChecked = filtered.length > 0 && filtered.every((r) => selected.has(r.id)); + + return ( + + + + Import open {kind === "time" ? "time entries" : "expenses"} + + Unbilled, billable {kind === "time" ? "time" : "expense"} entries from this client's other cases. Selected items will be moved to this case. + + + + {!clientId ? ( +
+ This case has no client, so there are no related entries to import. +
+ ) : ( + <> +
+ + setQ(e.target.value)} + /> +
+ +
+ + + + + + + + {kind === "time" && } + + + + + {loading && ( + + )} + {!loading && filtered.length === 0 && ( + + )} + {!loading && filtered.map((r) => { + const amount = kind === "time" + ? Number(r.hours) * Number(r.hourly_rate) + : Number(r.amount); + return ( + + + + + + {kind === "time" && ( + + )} + + + ); + })} + +
+ { + if (c) setSelected(new Set(filtered.map((r) => r.id))); + else setSelected(new Set()); + }} + /> + DateFrom caseDescriptionHoursAmount
+ +
+ + No open entries to import. +
+ toggle(r.id)} /> + + {formatDate(kind === "time" ? r.work_date : r.expense_date)} + + {r._case ? ( + {r._case.case_number} · {r._case.title} + ) : "—"} + {r.description}{Number(r.hours).toFixed(2)}{formatCurrency(amount)}
+
+ +
+ + {selected.size} selected · {filtered.length} available + + {formatCurrency(total)} +
+ + )} + + + + + +
+
+ ); +}