Added combine & merge tools

X-Lovable-Edit-ID: edt-7f3e5272-f9c1-4df0-a21b-bfb97fab628d
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-20 00:15:03 +00:00
co-authored by renee-png
4 changed files with 430 additions and 25 deletions
@@ -72,6 +72,9 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
const [selected, setSelected] = useState<Set<string>>(new Set());
const [includeEmpty, setIncludeEmpty] = useState(false);
const [emptyName, setEmptyName] = useState("");
// When true (default), create ONE combined collection per case using the
// first selected contact on that case as the primary homeowner.
const [combine, setCombine] = useState(true);
const { eligibleCases, skippedCases, clientId } = useMemo(() => {
const counts = new Map<string, number>();
@@ -95,6 +98,7 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
setSearch("");
setIncludeEmpty(false);
setEmptyName("");
setCombine(true);
if (eligibleCases.length === 0) {
setContactRows([]);
return;
@@ -245,16 +249,47 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
}
const rows: any[] = [];
for (const r of selectedRows) {
const homeownerId = contactToHomeowner.get(r.contact_id);
if (!homeownerId) continue;
if (existingPairs.has(`${r.case_id}::${homeownerId}`)) continue;
rows.push({
case_id: r.case_id,
homeowner_id: homeownerId,
status: "none",
created_by: user?.id,
});
// Cases that already have ANY collection — skip in combine mode to avoid duplicates.
const casesWithExisting = new Set<string>();
for (const e of (existing ?? []) as any[]) casesWithExisting.add(e.case_id);
if (combine) {
// Group selected contacts by case, pick first as primary, attach others by name.
const byCase = new Map<string, typeof selectedRows>();
for (const r of selectedRows) {
if (!byCase.has(r.case_id)) byCase.set(r.case_id, [] as any);
byCase.get(r.case_id)!.push(r);
}
for (const [caseId, group] of byCase) {
if (casesWithExisting.has(caseId)) continue;
const primary = group[0];
const primaryHoId = contactToHomeowner.get(primary.contact_id);
if (!primaryHoId) continue;
const others = group.slice(1).map((r) => r.name);
const name =
others.length > 0
? `${primary.name} + ${others.length} other${others.length === 1 ? "" : "s"}`
: null;
rows.push({
case_id: caseId,
homeowner_id: primaryHoId,
status: "none",
name,
created_by: user?.id,
});
}
} else {
for (const r of selectedRows) {
const homeownerId = contactToHomeowner.get(r.contact_id);
if (!homeownerId) continue;
if (existingPairs.has(`${r.case_id}::${homeownerId}`)) continue;
rows.push({
case_id: r.case_id,
homeowner_id: homeownerId,
status: "none",
created_by: user?.id,
});
}
}
if (includeEmpty) {
@@ -410,6 +445,21 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
)}
</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={combine}
onCheckedChange={(v) => setCombine(!!v)}
/>
<span>
Combine homeowners into one collection per case (recommended)
<span className="block text-xs text-muted-foreground font-normal">
First selected contact on each case becomes the primary homeowner. Cases that already have a collection are skipped.
</span>
</span>
</label>
</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
@@ -446,7 +496,9 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
}
>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create up to {projectedCount} collection{projectedCount === 1 ? "" : "s"}
{combine
? `Create up to ${eligibleCases.length + (includeEmpty ? eligibleCases.length : 0)} collection${eligibleCases.length + (includeEmpty ? eligibleCases.length : 0) === 1 ? "" : "s"}`
: `Create up to ${projectedCount} collection${projectedCount === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</DialogContent>
+15
View File
@@ -76,6 +76,7 @@ import {
} from "@dnd-kit/sortable";
import { SortableLedgerRow } from "./ledger-row";
import { ApplyCollectionWorkflowDialog } from "@/components/collections/apply-collection-workflow-dialog";
import { MergeCollectionsDialog } from "./merge-collections-dialog";
const TXN_TYPES = [
{ value: "assessment", label: "Assessment" },
@@ -148,6 +149,7 @@ export function CaseCollectionsTab({
// dialogs
const [addOpen, setAddOpen] = useState(false);
const [newHomeownerOpen, setNewHomeownerOpen] = useState(false);
const [mergeOpen, setMergeOpen] = useState(false);
const [wfCollectionId, setWfCollectionId] = useState<string | null>(null);
const load = async () => {
@@ -258,6 +260,11 @@ export function CaseCollectionsTab({
<Button variant="outline" onClick={() => setNewHomeownerOpen(true)}>
<UserPlus className="h-4 w-4 mr-2" /> New homeowner
</Button>
{collections.length >= 2 && (
<Button variant="outline" onClick={() => setMergeOpen(true)}>
<GitFork className="h-4 w-4 mr-2 rotate-180" /> Merge
</Button>
)}
<Button onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> Add collection
</Button>
@@ -363,6 +370,14 @@ export function CaseCollectionsTab({
onCreated={load}
/>
<MergeCollectionsDialog
open={mergeOpen}
onOpenChange={setMergeOpen}
caseId={caseId}
collections={collections as any}
onMerged={load}
/>
<NewHomeownerDialog
open={newHomeownerOpen}
onOpenChange={setNewHomeownerOpen}
@@ -87,6 +87,11 @@ export function ConvertToCollectionsDialog({
const [status, setStatus] = useState("none");
const [includeEmpty, setIncludeEmpty] = useState(false);
const [emptyName, setEmptyName] = useState("");
// When true (default), create ONE combined collection for the case using
// the first selected contact as the primary homeowner. When false, fall
// back to one collection per selected contact.
const [combine, setCombine] = useState(true);
const [primaryId, setPrimaryId] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
@@ -95,6 +100,8 @@ export function ConvertToCollectionsDialog({
setStatus("none");
setIncludeEmpty(false);
setEmptyName("");
setCombine(true);
setPrimaryId(null);
(async () => {
setLoading(true);
const { data: ccs, error } = await supabase
@@ -174,9 +181,10 @@ export function ConvertToCollectionsDialog({
}
setSubmitting(true);
try {
// Resolve each selected contact to a homeowner row (find by name on this client, else create).
const selectedContacts = contacts.filter((c) => selected.has(c.id));
// Resolve each selected contact to a homeowner row (find by name on
// this client, else create). Returns the homeowner_id, in input order.
let existingHomeowners: any[] = [];
if (clientId && selectedContacts.length > 0) {
const { data: hos } = await supabase
@@ -186,7 +194,7 @@ export function ConvertToCollectionsDialog({
existingHomeowners = hos ?? [];
}
const homeownerIds: string[] = [];
const homeownerIdByContact = new Map<string, string>();
for (const c of selectedContacts) {
const { first, last } = splitName(c.name);
const match = existingHomeowners.find(
@@ -195,7 +203,7 @@ export function ConvertToCollectionsDialog({
(h.last_name || "").toLowerCase().trim() === last.toLowerCase(),
);
if (match) {
homeownerIds.push(match.id);
homeownerIdByContact.set(c.id, match.id);
continue;
}
if (!clientId) {
@@ -221,28 +229,67 @@ export function ConvertToCollectionsDialog({
setSubmitting(false);
return;
}
homeownerIds.push(newHo.id);
homeownerIdByContact.set(c.id, newHo.id);
}
// Skip homeowners that already have a collection on this case
const { data: existingCols } = await supabase
.from("collections")
.select("homeowner_id")
.eq("case_id", caseId);
const existingSet = new Set(
const existingHomeownerSet = new Set(
(existingCols ?? [])
.map((r: any) => r.homeowner_id)
.filter((id: string | null): id is string => !!id),
);
const hasAnyCollection = (existingCols ?? []).length > 0;
const rows: any[] = homeownerIds
.filter((id) => !existingSet.has(id))
.map((homeowner_id) => ({
const rows: any[] = [];
if (combine && selectedContacts.length > 0) {
// ONE combined collection for the case. Use the user-picked primary,
// or the first selected contact, as homeowner_id.
const primaryContact =
selectedContacts.find((c) => c.id === primaryId) ?? selectedContacts[0];
const primaryHomeownerId = homeownerIdByContact.get(primaryContact.id);
if (!primaryHomeownerId) {
toast.error("Could not resolve a primary homeowner");
setSubmitting(false);
return;
}
if (hasAnyCollection) {
toast.info(
"This case already has a collection. Use Merge on the Collections tab to combine them.",
);
setSubmitting(false);
return;
}
const otherNames = selectedContacts
.filter((c) => c.id !== primaryContact.id)
.map((c) => c.name);
const combinedName =
otherNames.length > 0
? `${primaryContact.name} + ${otherNames.length} other${otherNames.length === 1 ? "" : "s"}`
: null;
rows.push({
case_id: caseId,
homeowner_id,
homeowner_id: primaryHomeownerId,
status,
name: combinedName,
created_by: user?.id,
}));
});
} else if (selectedContacts.length > 0) {
// Per-homeowner mode (legacy)
for (const c of selectedContacts) {
const homeowner_id = homeownerIdByContact.get(c.id)!;
if (existingHomeownerSet.has(homeowner_id)) continue;
rows.push({
case_id: caseId,
homeowner_id,
status,
created_by: user?.id,
});
}
}
if (includeEmpty) {
rows.push({
@@ -287,7 +334,9 @@ export function ConvertToCollectionsDialog({
<Users className="h-4 w-4" /> Convert case to collections
</DialogTitle>
<DialogDescription>
Select case contacts tagged as homeowner or tenant to create a collection ledger for each.
Select case contacts tagged as homeowner or tenant. By default
one combined collection is created for the case using the first
selected contact as the primary homeowner.
</DialogDescription>
</DialogHeader>
@@ -380,6 +429,43 @@ export function ConvertToCollectionsDialog({
)}
</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={combine}
onCheckedChange={(v) => setCombine(!!v)}
/>
<span>
Combine into one collection (recommended)
<span className="block text-xs text-muted-foreground font-normal">
Creates a single ledger for the case. Uncheck to make one ledger per homeowner.
</span>
</span>
</label>
{combine && selected.size > 1 && (
<div className="pt-1">
<Label className="text-xs">Primary homeowner</Label>
<Select
value={primaryId ?? Array.from(selected)[0] ?? ""}
onValueChange={(v) => setPrimaryId(v)}
>
<SelectTrigger>
<SelectValue placeholder="Pick primary homeowner" />
</SelectTrigger>
<SelectContent>
{contacts
.filter((c) => selected.has(c.id))
.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</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
@@ -407,8 +493,9 @@ export function ConvertToCollectionsDialog({
disabled={submitting || (selected.size === 0 && !includeEmpty)}
>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create {selected.size + (includeEmpty ? 1 : 0)} collection
{selected.size + (includeEmpty ? 1 : 0) === 1 ? "" : "s"}
{combine && selected.size > 0
? `Create 1 combined collection${includeEmpty ? " + 1 empty" : ""}`
: `Create ${selected.size + (includeEmpty ? 1 : 0)} collection${selected.size + (includeEmpty ? 1 : 0) === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,251 @@
import { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { supabase } from "@/integrations/supabase/client";
import { Loader2, Merge, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
interface CollectionRow {
id: string;
status: string;
name: string | null;
homeowner_id: string | null;
homeowner?: { first_name?: string | null; last_name?: string | null; unit_number?: string | null } | null;
}
export function MergeCollectionsDialog({
open,
onOpenChange,
caseId,
collections,
onMerged,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
caseId: string;
collections: CollectionRow[];
onMerged?: () => void;
}) {
const [keepId, setKeepId] = useState<string>("");
const [mergeIds, setMergeIds] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
const [counts, setCounts] = useState<Record<string, { entries: number; tasks: number }>>({});
const [loadingCounts, setLoadingCounts] = useState(false);
useEffect(() => {
if (!open) return;
setKeepId(collections[0]?.id ?? "");
setMergeIds(new Set());
setCounts({});
if (collections.length === 0) return;
(async () => {
setLoadingCounts(true);
const ids = collections.map((c) => c.id);
const [{ data: entries }, { data: tasks }] = await Promise.all([
supabase.from("collection_ledger_entries").select("collection_id").in("collection_id", ids),
supabase.from("collection_tasks").select("collection_id").in("collection_id", ids),
]);
const next: Record<string, { entries: number; tasks: number }> = {};
for (const id of ids) next[id] = { entries: 0, tasks: 0 };
for (const e of (entries ?? []) as any[]) next[e.collection_id].entries++;
for (const t of (tasks ?? []) as any[]) next[t.collection_id].tasks++;
setCounts(next);
setLoadingCounts(false);
})();
}, [open, collections]);
const labelOf = (c: CollectionRow) =>
c.name?.trim() ||
(c.homeowner
? `${c.homeowner.last_name ?? ""}, ${c.homeowner.first_name ?? ""}`.replace(/^,\s*/, "")
: "Unassigned");
const sources = useMemo(
() => collections.filter((c) => c.id !== keepId),
[collections, keepId],
);
const toggleSource = (id: string) =>
setMergeIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const submit = async () => {
if (!keepId) {
toast.error("Pick a collection to keep");
return;
}
if (mergeIds.size === 0) {
toast.error("Select at least one collection to merge in");
return;
}
setSubmitting(true);
try {
const sourceIds = Array.from(mergeIds);
const { error: e1 } = await supabase
.from("collection_ledger_entries")
.update({ collection_id: keepId })
.in("collection_id", sourceIds);
if (e1) throw e1;
const { error: e2 } = await supabase
.from("collection_payment_allocations")
.update({ collection_id: keepId })
.in("collection_id", sourceIds);
if (e2) throw e2;
const { error: e3 } = await supabase
.from("collection_tasks")
.update({ collection_id: keepId })
.in("collection_id", sourceIds);
if (e3) throw e3;
const { error: e4 } = await supabase
.from("collections")
.delete()
.in("id", sourceIds);
if (e4) throw e4;
toast.success(
`Merged ${sourceIds.length} collection${sourceIds.length === 1 ? "" : "s"} into the kept ledger`,
);
onOpenChange(false);
onMerged?.();
} catch (err: any) {
toast.error("Could not merge collections", { description: err?.message });
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="font-serif flex items-center gap-2">
<Merge className="h-4 w-4" /> Merge collections on this case
</DialogTitle>
<DialogDescription>
Combine multiple per-homeowner collections into a single ledger.
Ledger entries, payments, and workflow tasks are moved into the
kept collection. The merged-in collections are then deleted.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{collections.length < 2 ? (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This case needs at least 2 collections to merge.
</AlertDescription>
</Alert>
) : (
<>
<div>
<Label>Keep this collection (target)</Label>
<Select value={keepId} onValueChange={setKeepId}>
<SelectTrigger>
<SelectValue placeholder="Pick the kept ledger" />
</SelectTrigger>
<SelectContent>
{collections.map((c) => (
<SelectItem key={c.id} value={c.id}>
{labelOf(c)}
{counts[c.id]
? ` · ${counts[c.id].entries} entries · ${counts[c.id].tasks} tasks`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Merge these into the kept ledger</Label>
<div className="border rounded-md max-h-72 overflow-y-auto mt-1 divide-y">
{sources.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground italic">
No other collections on this case.
</p>
) : (
sources.map((c) => (
<label
key={c.id}
className="flex items-center gap-3 px-3 py-2 text-sm hover:bg-muted/50 cursor-pointer"
>
<Checkbox
checked={mergeIds.has(c.id)}
onCheckedChange={() => toggleSource(c.id)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{labelOf(c)}</span>
<Badge variant="outline" className="text-[10px] capitalize">
{c.status}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{loadingCounts
? "Loading…"
: `${counts[c.id]?.entries ?? 0} ledger entries · ${counts[c.id]?.tasks ?? 0} tasks`}
</div>
</div>
</label>
))
)}
</div>
</div>
{mergeIds.size > 0 && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This permanently deletes {mergeIds.size} collection
{mergeIds.size === 1 ? "" : "s"} after moving their data.
It cannot be undone.
</AlertDescription>
</Alert>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={submit}
disabled={submitting || collections.length < 2 || !keepId || mergeIds.size === 0}
>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Merge {mergeIds.size} into 1
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}