From 61c962303361fa182ffc5e7ad0bb744006e97654 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:02:12 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- .../bulk-convert-to-collections-dialog.tsx | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 src/components/cases/bulk-convert-to-collections-dialog.tsx diff --git a/src/components/cases/bulk-convert-to-collections-dialog.tsx b/src/components/cases/bulk-convert-to-collections-dialog.tsx new file mode 100644 index 0000000..6fce9bd --- /dev/null +++ b/src/components/cases/bulk-convert-to-collections-dialog.tsx @@ -0,0 +1,344 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +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 { toast } from "sonner"; +import { Loader2, Search, Users, AlertTriangle } from "lucide-react"; + +interface BulkCase { + id: string; + title: string; + case_number: string; + client_id: string | null; + client?: { name?: string | null } | null; +} + +interface Props { + open: boolean; + onOpenChange: (v: boolean) => void; + cases: BulkCase[]; + onCreated?: () => void; +} + +/** + * Bulk variant of ConvertToCollectionsDialog: opens the homeowner picker once + * and applies the same selection to every selected case. Only cases that + * share the dominant client are eligible — the rest are skipped (and listed). + * + * Default initial status is "none"; existing collections on a case are + * preserved and not duplicated for already-attached homeowners. + */ +export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCreated }: Props) { + const { user } = useAuth(); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [homeowners, setHomeowners] = useState([]); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [includeEmpty, setIncludeEmpty] = useState(false); + const [emptyName, setEmptyName] = useState(""); + + // Determine the dominant client across selected cases. + const { eligibleCases, skippedCases, clientId } = useMemo(() => { + const counts = new Map(); + for (const c of cases) { + if (!c.client_id) continue; + counts.set(c.client_id, (counts.get(c.client_id) ?? 0) + 1); + } + let dominant: string | null = null; + let max = 0; + for (const [cid, n] of counts) { + if (n > max) { dominant = cid; max = n; } + } + const eligible = cases.filter((c) => c.client_id && c.client_id === dominant); + const skipped = cases.filter((c) => !c.client_id || c.client_id !== dominant); + return { eligibleCases: eligible, skippedCases: skipped, clientId: dominant }; + }, [cases]); + + useEffect(() => { + if (!open) return; + setSelected(new Set()); + setSearch(""); + setIncludeEmpty(false); + setEmptyName(""); + if (!clientId) { setHomeowners([]); return; } + (async () => { + setLoading(true); + const { data: hos, error } = await supabase + .from("homeowners") + .select("id, first_name, last_name, unit_number, address") + .eq("client_id", clientId) + .is("archived_at", null) + .order("last_name"); + if (error) toast.error(error.message); + setHomeowners(hos ?? []); + setLoading(false); + })(); + }, [open, clientId]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return homeowners; + return homeowners.filter((h) => + `${h.last_name ?? ""} ${h.first_name ?? ""} ${h.unit_number ?? ""} ${h.address ?? ""}` + .toLowerCase() + .includes(q), + ); + }, [homeowners, search]); + + const allChecked = filtered.length > 0 && filtered.every((h) => selected.has(h.id)); + const someChecked = filtered.some((h) => selected.has(h.id)); + + 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 toggleAll = () => { + if (allChecked) { + setSelected((prev) => { + const next = new Set(prev); + filtered.forEach((h) => next.delete(h.id)); + return next; + }); + } else { + setSelected((prev) => { + const next = new Set(prev); + filtered.forEach((h) => next.add(h.id)); + return next; + }); + } + }; + + const submit = async () => { + if (eligibleCases.length === 0) { + toast.error("No selected cases share a client"); + return; + } + if (selected.size === 0 && !includeEmpty) { + toast.error("Pick at least one homeowner or enable an empty collection"); + return; + } + setSubmitting(true); + try { + // Preload existing (case_id, homeowner_id) pairs to skip duplicates. + const caseIds = eligibleCases.map((c) => c.id); + const { data: existing, error: exErr } = await supabase + .from("collections") + .select("case_id, homeowner_id") + .in("case_id", caseIds); + if (exErr) throw exErr; + const existingPairs = new Set( + (existing ?? []) + .filter((row: any) => !!row.homeowner_id) + .map((row: any) => `${row.case_id}::${row.homeowner_id}`), + ); + + const rows: any[] = []; + for (const c of eligibleCases) { + for (const homeownerId of selected) { + if (existingPairs.has(`${c.id}::${homeownerId}`)) continue; + rows.push({ + case_id: c.id, + homeowner_id: homeownerId, + status: "none", + created_by: user?.id, + }); + } + if (includeEmpty) { + rows.push({ + case_id: c.id, + homeowner_id: null, + status: "none", + name: emptyName.trim() || null, + created_by: user?.id, + }); + } + } + + if (rows.length === 0) { + toast.info("Every selected homeowner already has a collection on these cases"); + setSubmitting(false); + return; + } + + const { error } = await supabase.from("collections").insert(rows); + if (error) throw error; + + toast.success( + `Created ${rows.length} collection${rows.length === 1 ? "" : "s"} across ${eligibleCases.length} case${eligibleCases.length === 1 ? "" : "s"}`, + ); + onOpenChange(false); + onCreated?.(); + } catch (err: any) { + toast.error(err?.message ?? "Could not create collections"); + } finally { + setSubmitting(false); + } + }; + + const dominantClientName = + eligibleCases[0]?.client?.name ?? (clientId ? "shared client" : "—"); + const projectedCount = eligibleCases.length * (selected.size + (includeEmpty ? 1 : 0)); + + return ( + + + + + Designate cases as collections + + + Pick homeowners once and create matching collections under every selected case that + shares the same client. Initial status defaults to "None"; duplicates are skipped. + + + +
+
+
+ Applying to:{" "} + + {eligibleCases.length} case{eligibleCases.length === 1 ? "" : "s"} + {" "} + under {dominantClientName} +
+ {skippedCases.length > 0 && ( +
+ + + {skippedCases.length} selected case{skippedCases.length === 1 ? "" : "s"}{" "} + skipped (different or missing client). Run them separately. + +
+ )} +
+ + {!clientId ? ( +
+ None of the selected cases have a client, so no homeowners can be loaded. +
+ ) : ( + <> +
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ +
+ {loading ? ( +

Loading…

+ ) : homeowners.length === 0 ? ( +

+ No homeowners on this client yet — add some on the client page first. +

+ ) : ( + <> +
+ + + {allChecked ? "Unselect all visible" : "Select all visible"} + + + {selected.size} selected + +
+
    + {filtered.map((h) => ( +
  • toggle(h.id)} + > + toggle(h.id)} + onClick={(e) => e.stopPropagation()} + /> +
    +
    + {h.last_name}, {h.first_name} +
    + {(h.unit_number || h.address) && ( +
    + {h.unit_number ? `Unit ${h.unit_number}` : ""} + {h.unit_number && h.address ? " · " : ""} + {h.address ?? ""} +
    + )} +
    +
  • + ))} + {filtered.length === 0 && ( +
  • No matches.
  • + )} +
+ + )} +
+ +
+ + {includeEmpty && ( +
+ + setEmptyName(e.target.value)} + /> +
+ )} +
+ + )} +
+ + + + + +
+
+ ); +}