Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user