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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user