Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 19:11:51 +00:00
co-authored by renee-png
parent 4aac55853c
commit 759e1e3373
@@ -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<any[]>([]);
const [selected, setSelected] = useState<Set<string>>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Import open {kind === "time" ? "time entries" : "expenses"}</DialogTitle>
<DialogDescription>
Unbilled, billable {kind === "time" ? "time" : "expense"} entries from this client's other cases. Selected items will be moved to this case.
</DialogDescription>
</DialogHeader>
{!clientId ? (
<div className="py-12 text-center text-sm text-muted-foreground">
This case has no client, so there are no related entries to import.
</div>
) : (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search description, case, user…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="max-h-[420px] overflow-auto rounded-md border border-border/60">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground sticky top-0">
<tr>
<th className="px-3 py-2 w-9">
<Checkbox
checked={allChecked}
onCheckedChange={(c) => {
if (c) setSelected(new Set(filtered.map((r) => r.id)));
else setSelected(new Set());
}}
/>
</th>
<th className="text-left px-3 py-2 font-medium">Date</th>
<th className="text-left px-3 py-2 font-medium">From case</th>
<th className="text-left px-3 py-2 font-medium">Description</th>
{kind === "time" && <th className="text-right px-3 py-2 font-medium">Hours</th>}
<th className="text-right px-3 py-2 font-medium">Amount</th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={kind === "time" ? 6 : 5} className="text-center py-10 text-muted-foreground">
<Loader2 className="h-5 w-5 mx-auto animate-spin" />
</td></tr>
)}
{!loading && filtered.length === 0 && (
<tr><td colSpan={kind === "time" ? 6 : 5} className="text-center py-10 text-muted-foreground">
<Inbox className="h-6 w-6 mx-auto mb-2 opacity-40" />
No open entries to import.
</td></tr>
)}
{!loading && filtered.map((r) => {
const amount = kind === "time"
? Number(r.hours) * Number(r.hourly_rate)
: Number(r.amount);
return (
<tr key={r.id} className="border-t hover:bg-muted/30">
<td className="px-3 py-2">
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatDate(kind === "time" ? r.work_date : r.expense_date)}
</td>
<td className="px-3 py-2 text-xs">
{r._case ? (
<span className="text-muted-foreground">{r._case.case_number} · <span className="text-foreground">{r._case.title}</span></span>
) : "—"}
</td>
<td className="px-3 py-2 max-w-md truncate">{r.description}</td>
{kind === "time" && (
<td className="px-3 py-2 text-right tabular-nums">{Number(r.hours).toFixed(2)}</td>
)}
<td className="px-3 py-2 text-right tabular-nums font-medium">{formatCurrency(amount)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{selected.size} selected · {filtered.length} available
</span>
<span className="font-serif text-lg tabular-nums">{formatCurrency(total)}</span>
</div>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={importSelected} disabled={working || selected.size === 0 || !clientId}>
{working && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Import {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}