Added bulk convert to coll.
X-Lovable-Edit-ID: edt-48dd8c3e-da28-44d3-aba7-75b90cf536c7 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Search, Users } from "lucide-react";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "late_notice", label: "Late Notice" },
|
||||
{ value: "second_notice", label: "Second Notice" },
|
||||
{ value: "intent_to_lien", label: "Intent to Lien" },
|
||||
{ value: "attorney", label: "Attorney" },
|
||||
{ value: "payment_plan", label: "Payment Plan" },
|
||||
{ value: "resolved", label: "Resolved" },
|
||||
];
|
||||
|
||||
export function ConvertToCollectionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
caseId,
|
||||
clientId,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
caseId: string;
|
||||
clientId: string;
|
||||
onCreated?: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [homeowners, setHomeowners] = useState<any[]>([]);
|
||||
const [existingIds, setExistingIds] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState("late_notice");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelected(new Set());
|
||||
setSearch("");
|
||||
setStatus("late_notice");
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const [{ data: hos }, { data: cols }] = await Promise.all([
|
||||
supabase
|
||||
.from("homeowners")
|
||||
.select("id, first_name, last_name, unit_number, address, opening_balance")
|
||||
.eq("client_id", clientId)
|
||||
.is("archived_at", null)
|
||||
.order("last_name"),
|
||||
supabase
|
||||
.from("collections")
|
||||
.select("homeowner_id")
|
||||
.eq("case_id", caseId),
|
||||
]);
|
||||
setHomeowners(hos ?? []);
|
||||
setExistingIds(new Set((cols ?? []).map((c) => c.homeowner_id)));
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [open, caseId, clientId]);
|
||||
|
||||
const available = useMemo(
|
||||
() => homeowners.filter((h) => !existingIds.has(h.id)),
|
||||
[homeowners, existingIds],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return available;
|
||||
return available.filter((h) =>
|
||||
`${h.last_name} ${h.first_name} ${h.unit_number ?? ""} ${h.address ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
);
|
||||
}, [available, search]);
|
||||
|
||||
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 (filtered.every((h) => selected.has(h.id))) {
|
||||
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 (selected.size === 0) {
|
||||
toast.error("Pick at least one homeowner");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
const rows = Array.from(selected).map((homeowner_id) => ({
|
||||
case_id: caseId,
|
||||
homeowner_id,
|
||||
status,
|
||||
created_by: user?.id,
|
||||
}));
|
||||
const { error } = await supabase.from("collections").insert(rows);
|
||||
setSubmitting(false);
|
||||
if (error) {
|
||||
toast.error("Could not create collections", { description: error.message });
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
`Created ${rows.length} collection${rows.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
onOpenChange(false);
|
||||
onCreated?.();
|
||||
};
|
||||
|
||||
const allChecked = filtered.length > 0 && filtered.every((h) => selected.has(h.id));
|
||||
const someChecked = filtered.some((h) => selected.has(h.id));
|
||||
|
||||
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" /> Convert case to collections
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select homeowners from this client to create one collection per homeowner under this case.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Initial status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Selected</Label>
|
||||
<div className="h-9 px-3 flex items-center text-sm border rounded-md bg-muted/40">
|
||||
{selected.size} of {available.length} available
|
||||
</div>
|
||||
</div>
|
||||
</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-80 overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
||||
) : available.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground italic">
|
||||
{homeowners.length === 0
|
||||
? "No homeowners on this client yet — add some on the client page first."
|
||||
: "All homeowners on this client already have a collection on this case."}
|
||||
</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" : "Select all visible"}
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting || selected.size === 0}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Create {selected.size > 0 ? selected.size : ""} collection{selected.size === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore, ArrowRightLeft, Search } from "lucide-react";
|
||||
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore, ArrowRightLeft, Search, GitFork } from "lucide-react";
|
||||
import { ConvertToCollectionsDialog } from "@/components/cases/convert-to-collections-dialog";
|
||||
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
|
||||
import { CaseCallLogsTab } from "@/components/cases/call-logs-tab";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
@@ -42,6 +43,8 @@ function CaseDetail() {
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [clients, setClients] = useState<any[]>([]);
|
||||
const [reassignOpen, setReassignOpen] = useState(false);
|
||||
const [convertOpen, setConvertOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("litigation");
|
||||
const [clientSearch, setClientSearch] = useState("");
|
||||
const [selectedClientId, setSelectedClientId] = useState<string>("");
|
||||
const [reassigning, setReassigning] = useState(false);
|
||||
@@ -176,6 +179,11 @@ function CaseDetail() {
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4 mr-2" /> Reassign client
|
||||
</Button>
|
||||
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
|
||||
<Button variant="outline" onClick={() => setConvertOpen(true)}>
|
||||
<GitFork className="h-4 w-4 mr-2" /> Convert to collections
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
@@ -251,7 +259,18 @@ function CaseDetail() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<CaseTabs data={data} caseId={caseId} canManage={canManage} load={load} />
|
||||
<CaseTabs data={data} caseId={caseId} canManage={canManage} load={load} tab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
<ConvertToCollectionsDialog
|
||||
open={convertOpen}
|
||||
onOpenChange={setConvertOpen}
|
||||
caseId={caseId}
|
||||
clientId={data.client?.id ?? ""}
|
||||
onCreated={() => {
|
||||
setActiveTab("collections");
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 sm:grid-cols-4 gap-3 text-center text-xs">
|
||||
<SmallStat label="Default rate" value={data.default_hourly_rate ? formatCurrency(data.default_hourly_rate) + "/hr" : "—"} />
|
||||
@@ -263,10 +282,9 @@ function CaseDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
function CaseTabs({ data, caseId, canManage, load }: { data: any; caseId: string; canManage: boolean; load: () => void }) {
|
||||
const [tab, setTab] = useState("litigation");
|
||||
function CaseTabs({ data, caseId, canManage, load, tab, onTabChange }: { data: any; caseId: string; canManage: boolean; load: () => void; tab: string; onTabChange: (v: string) => void }) {
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<Tabs value={tab} onValueChange={onTabChange}>
|
||||
<TabsList className="mb-4 flex-wrap h-auto">
|
||||
<TabsTrigger value="litigation"><Scale className="h-3.5 w-3.5 mr-1.5" />Litigation</TabsTrigger>
|
||||
<TabsTrigger value="overview"><Activity className="h-3.5 w-3.5 mr-1.5" />Status log</TabsTrigger>
|
||||
@@ -285,7 +303,7 @@ function CaseTabs({ data, caseId, canManage, load }: { data: any; caseId: string
|
||||
<TabsContent value="overview"><CaseStatusTab caseId={caseId} caseLabel={`${data.case_number} — ${data.title}`} /></TabsContent>
|
||||
<TabsContent value="documents"><CaseDocumentsTab caseId={caseId} /></TabsContent>
|
||||
<TabsContent value="contacts"><ContactsLinkTab parentId={caseId} parentTable="case_contacts" /></TabsContent>
|
||||
<TabsContent value="time"><CaseTimeTab caseRecord={data} onInvoice={() => setTab("invoices")} /></TabsContent>
|
||||
<TabsContent value="time"><CaseTimeTab caseRecord={data} onInvoice={() => onTabChange("invoices")} /></TabsContent>
|
||||
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
|
||||
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={data} /></TabsContent>
|
||||
<TabsContent value="calls"><CaseCallLogsTab caseId={caseId} /></TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user