Added bulk homeowner picker

X-Lovable-Edit-ID: edt-946e0937-bf43-4c16-ad4f-0cde24679326
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 20:03:51 +00:00
co-authored by renee-png
3 changed files with 372 additions and 1 deletions
@@ -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<any[]>([]);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<string>>(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<string, number>();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="font-serif flex items-center gap-2">
<Users className="h-4 w-4" /> Designate cases as collections
</DialogTitle>
<DialogDescription>
Pick homeowners once and create matching collections under every selected case that
shares the same client. Initial status defaults to "None"; duplicates are skipped.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
<div>
<span className="text-muted-foreground">Applying to:</span>{" "}
<span className="font-medium">
{eligibleCases.length} case{eligibleCases.length === 1 ? "" : "s"}
</span>{" "}
under <span className="font-medium">{dominantClientName}</span>
</div>
{skippedCases.length > 0 && (
<div className="mt-1 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
{skippedCases.length} selected case{skippedCases.length === 1 ? "" : "s"}{" "}
skipped (different or missing client). Run them separately.
</span>
</div>
)}
</div>
{!clientId ? (
<div className="rounded-md border p-4 text-sm text-muted-foreground italic">
None of the selected cases have a client, so no homeowners can be loaded.
</div>
) : (
<>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search homeowners…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="border rounded-md max-h-72 overflow-y-auto">
{loading ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : homeowners.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground italic">
No homeowners on this client yet — add some on the client page first.
</p>
) : (
<>
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/30 text-xs">
<Checkbox
checked={allChecked ? true : someChecked ? "indeterminate" : false}
onCheckedChange={toggleAll}
/>
<span className="text-muted-foreground">
{allChecked ? "Unselect all visible" : "Select all visible"}
</span>
<span className="ml-auto text-muted-foreground">
{selected.size} selected
</span>
</div>
<ul className="divide-y">
{filtered.map((h) => (
<li
key={h.id}
className="flex items-center gap-3 px-3 py-2 text-sm hover:bg-muted/50 cursor-pointer"
onClick={() => toggle(h.id)}
>
<Checkbox
checked={selected.has(h.id)}
onCheckedChange={() => toggle(h.id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1">
<div className="font-medium">
{h.last_name}, {h.first_name}
</div>
{(h.unit_number || h.address) && (
<div className="text-xs text-muted-foreground">
{h.unit_number ? `Unit ${h.unit_number}` : ""}
{h.unit_number && h.address ? " · " : ""}
{h.address ?? ""}
</div>
)}
</div>
</li>
))}
{filtered.length === 0 && (
<li className="p-3 text-sm text-muted-foreground italic">No matches.</li>
)}
</ul>
</>
)}
</div>
<div className="border rounded-md p-3 space-y-2 bg-muted/20">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={includeEmpty}
onCheckedChange={(v) => setIncludeEmpty(!!v)}
/>
<span>Also create one empty collection per case (no homeowner)</span>
</label>
{includeEmpty && (
<div>
<Label className="text-xs">Optional name applied to each empty collection</Label>
<Input
placeholder="e.g. Unassigned ledger"
value={emptyName}
onChange={(e) => setEmptyName(e.target.value)}
/>
</div>
)}
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={submit}
disabled={
submitting ||
eligibleCases.length === 0 ||
(selected.size === 0 && !includeEmpty)
}
>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create up to {projectedCount} collection{projectedCount === 1 ? "" : "s"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+9
View File
@@ -1046,3 +1046,12 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+19 -1
View File
@@ -10,11 +10,12 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Checkbox } from "@/components/ui/checkbox";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { supabase } from "@/integrations/supabase/client";
import { Plus, Search, Archive, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { Plus, Search, Archive, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown, Users } from "lucide-react";
import { formatDate, statusBadgeClass } from "@/lib/format";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { setArchived } from "@/lib/archive";
import { NewCaseDialog } from "@/components/cases/new-case-dialog";
import { BulkConvertToCollectionsDialog } from "@/components/cases/bulk-convert-to-collections-dialog";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
@@ -38,6 +39,7 @@ function CasesList() {
const [statusFilter, setStatusFilter] = useState<string>("all");
const [view, setView] = useState<"active" | "archived">("active");
const [newOpen, setNewOpen] = useState(false);
const [bulkCollectionsOpen, setBulkCollectionsOpen] = useState(false);
const [sortKey, setSortKey] = useState<SortKey>("last_activity");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -393,9 +395,25 @@ function CasesList() {
<Button size="sm" onClick={bulkAssignStatus} disabled={busy || !assignStatus}>
Apply
</Button>
<div className="mx-2 h-5 w-px bg-border" />
<Button
size="sm"
variant="outline"
onClick={() => setBulkCollectionsOpen(true)}
disabled={busy}
>
<Users className="h-4 w-4 mr-2" /> Designate as collections
</Button>
</div>
)}
<BulkConvertToCollectionsDialog
open={bulkCollectionsOpen}
onOpenChange={setBulkCollectionsOpen}
cases={cases.filter((c) => selected.has(c.id))}
onCreated={() => { setSelected(new Set()); load(); }}
/>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">