Added archive & CSV support
X-Lovable-Edit-ID: edt-85fd1765-99eb-4ec0-a073-f841aab7241f Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -176,6 +176,7 @@ function ContactPickerDialog({
|
||||
supabase
|
||||
.from("contacts")
|
||||
.select("id, name, company, contact_type, email")
|
||||
.is("archived_at", null)
|
||||
.order("name")
|
||||
.limit(200)
|
||||
.then(({ data }) => setRows(data ?? []));
|
||||
|
||||
@@ -50,7 +50,7 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const [{ data: cs }, { data: firm }] = await Promise.all([
|
||||
supabase.from("cases").select("id, case_number, title").eq("client_id", clientId),
|
||||
supabase.from("cases").select("id, case_number, title").eq("client_id", clientId).is("archived_at", null),
|
||||
supabase.from("firm_settings").select("default_tax_rate").maybeSingle(),
|
||||
]);
|
||||
const caseIds = (cs ?? []).map((c) => c.id);
|
||||
|
||||
@@ -53,10 +53,11 @@ function useClientsAndCases(open: boolean) {
|
||||
if (!open) return;
|
||||
(async () => {
|
||||
const [{ data: cs }, { data: ks }] = await Promise.all([
|
||||
supabase.from("clients").select("id, name").order("name"),
|
||||
supabase.from("clients").select("id, name").is("archived_at", null).order("name"),
|
||||
supabase
|
||||
.from("cases")
|
||||
.select("id, case_number, title, client_id, default_hourly_rate")
|
||||
.is("archived_at", null)
|
||||
.order("case_number", { ascending: false }),
|
||||
]);
|
||||
setClients(cs ?? []);
|
||||
|
||||
@@ -185,6 +185,7 @@ export type Database = {
|
||||
}
|
||||
cases: {
|
||||
Row: {
|
||||
archived_at: string | null
|
||||
assigned_attorney_id: string | null
|
||||
case_caption: string | null
|
||||
case_number: string
|
||||
@@ -221,6 +222,7 @@ export type Database = {
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
archived_at?: string | null
|
||||
assigned_attorney_id?: string | null
|
||||
case_caption?: string | null
|
||||
case_number: string
|
||||
@@ -257,6 +259,7 @@ export type Database = {
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
archived_at?: string | null
|
||||
assigned_attorney_id?: string | null
|
||||
case_caption?: string | null
|
||||
case_number?: string
|
||||
@@ -356,6 +359,7 @@ export type Database = {
|
||||
address_line1: string | null
|
||||
address_line2: string | null
|
||||
annual_interest_rate: number | null
|
||||
archived_at: string | null
|
||||
board_members: Json
|
||||
city: string | null
|
||||
client_type: Database["public"]["Enums"]["client_type"]
|
||||
@@ -378,6 +382,7 @@ export type Database = {
|
||||
address_line1?: string | null
|
||||
address_line2?: string | null
|
||||
annual_interest_rate?: number | null
|
||||
archived_at?: string | null
|
||||
board_members?: Json
|
||||
city?: string | null
|
||||
client_type?: Database["public"]["Enums"]["client_type"]
|
||||
@@ -400,6 +405,7 @@ export type Database = {
|
||||
address_line1?: string | null
|
||||
address_line2?: string | null
|
||||
annual_interest_rate?: number | null
|
||||
archived_at?: string | null
|
||||
board_members?: Json
|
||||
city?: string | null
|
||||
client_type?: Database["public"]["Enums"]["client_type"]
|
||||
@@ -786,6 +792,7 @@ export type Database = {
|
||||
Row: {
|
||||
address_line1: string | null
|
||||
address_line2: string | null
|
||||
archived_at: string | null
|
||||
city: string | null
|
||||
company: string | null
|
||||
contact_type: string
|
||||
@@ -805,6 +812,7 @@ export type Database = {
|
||||
Insert: {
|
||||
address_line1?: string | null
|
||||
address_line2?: string | null
|
||||
archived_at?: string | null
|
||||
city?: string | null
|
||||
company?: string | null
|
||||
contact_type?: string
|
||||
@@ -824,6 +832,7 @@ export type Database = {
|
||||
Update: {
|
||||
address_line1?: string | null
|
||||
address_line2?: string | null
|
||||
archived_at?: string | null
|
||||
city?: string | null
|
||||
company?: string | null
|
||||
contact_type?: string
|
||||
@@ -1447,6 +1456,7 @@ export type Database = {
|
||||
homeowners: {
|
||||
Row: {
|
||||
address: string | null
|
||||
archived_at: string | null
|
||||
client_id: string
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
@@ -1463,6 +1473,7 @@ export type Database = {
|
||||
}
|
||||
Insert: {
|
||||
address?: string | null
|
||||
archived_at?: string | null
|
||||
client_id: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
@@ -1479,6 +1490,7 @@ export type Database = {
|
||||
}
|
||||
Update: {
|
||||
address?: string | null
|
||||
archived_at?: string | null
|
||||
client_id?: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type ArchivableTable = "clients" | "cases" | "contacts" | "homeowners";
|
||||
|
||||
const LABELS: Record<ArchivableTable, string> = {
|
||||
clients: "Client",
|
||||
cases: "Case",
|
||||
contacts: "Contact",
|
||||
homeowners: "Homeowner",
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive (soft) or restore a row by setting/clearing `archived_at`.
|
||||
* Returns true on success.
|
||||
*/
|
||||
export async function setArchived(
|
||||
table: ArchivableTable,
|
||||
id: string,
|
||||
archived: boolean,
|
||||
): Promise<boolean> {
|
||||
const patch = { archived_at: archived ? new Date().toISOString() : null };
|
||||
const { error } = await (supabase.from(table) as any).update(patch).eq("id", id);
|
||||
if (error) {
|
||||
toast.error(`Could not ${archived ? "archive" : "restore"} ${LABELS[table].toLowerCase()}`, {
|
||||
description: error.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
toast.success(`${LABELS[table]} ${archived ? "archived" : "restored"}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Coerce common CSV truthy/falsy values into a boolean. */
|
||||
export function parseArchivedFlag(v: unknown): boolean {
|
||||
if (typeof v === "boolean") return v;
|
||||
const s = String(v ?? "").trim().toLowerCase();
|
||||
return s === "true" || s === "yes" || s === "1" || s === "y" || s === "archived";
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export async function fetchClients(): Promise<ClientLite[]> {
|
||||
.select(
|
||||
"id,name,address_line1,address_line2,city,state,postal_code,primary_contact_email,primary_contact_phone,annual_interest_rate",
|
||||
)
|
||||
.is("archived_at", null)
|
||||
.order("name");
|
||||
return (data ?? []) as ClientLite[];
|
||||
}
|
||||
@@ -41,6 +42,7 @@ export async function fetchHomeowners(clientId: string): Promise<HomeownerLite[]
|
||||
.from("homeowners")
|
||||
.select("id,client_id,first_name,last_name,unit_number,address,email,phone,opening_balance")
|
||||
.eq("client_id", clientId)
|
||||
.is("archived_at", null)
|
||||
.order("last_name");
|
||||
return (data ?? []) as HomeownerLite[];
|
||||
}
|
||||
@@ -193,6 +195,7 @@ export async function fetchAccessibleCases(): Promise<CaseLite[]> {
|
||||
const { data } = await supabase
|
||||
.from("cases")
|
||||
.select("id,case_number,title")
|
||||
.is("archived_at", null)
|
||||
.order("opened_at", { ascending: false })
|
||||
.limit(500);
|
||||
return (data ?? []) as CaseLite[];
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag } from "lucide-react";
|
||||
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
|
||||
import { CaseCallLogsTab } from "@/components/cases/call-logs-tab";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
@@ -20,6 +20,7 @@ import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
|
||||
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
|
||||
import { CaseCollectionsTab } from "@/components/cases/collections-tab";
|
||||
import { CaseCustomFieldsTab } from "@/components/cases/custom-fields-tab";
|
||||
import { setArchived } from "@/lib/archive";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
@@ -100,6 +101,11 @@ function CaseDetail() {
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-xs uppercase tracking-widest text-muted-foreground">{data.case_number}</span>
|
||||
<Badge variant="outline" className={statusBadgeClass(data.status)}>{data.status.replace("_", " ")}</Badge>
|
||||
{data.archived_at && (
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground">
|
||||
<Archive className="h-3 w-3 mr-1" /> Archived
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="font-serif text-3xl">{data.title}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
@@ -128,6 +134,18 @@ function CaseDetail() {
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (await setArchived("cases", caseId, !data.archived_at)) load();
|
||||
}}
|
||||
>
|
||||
{data.archived_at ? (
|
||||
<><ArchiveRestore className="h-4 w-4 mr-2" /> Restore</>
|
||||
) : (
|
||||
<><Archive className="h-4 w-4 mr-2" /> Archive</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+43
-10
@@ -6,10 +6,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { Plus, Search, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { setArchived } from "@/lib/archive";
|
||||
|
||||
export const Route = createFileRoute("/cases/")({
|
||||
component: () => (
|
||||
@@ -23,22 +25,34 @@ function CasesList() {
|
||||
const [cases, setCases] = useState<any[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [view, setView] = useState<"active" | "archived">("active");
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await supabase
|
||||
.from("cases")
|
||||
.select("*, client:clients(name), assignee:profiles!cases_assigned_attorney_id_fkey(full_name, email)")
|
||||
.order("updated_at", { ascending: false });
|
||||
setCases(data ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { data } = await supabase
|
||||
.from("cases")
|
||||
.select("*, client:clients(name), assignee:profiles!cases_assigned_attorney_id_fkey(full_name, email)")
|
||||
.order("updated_at", { ascending: false });
|
||||
setCases(data ?? []);
|
||||
})();
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const filtered = cases.filter((c) => {
|
||||
const visible = cases.filter((c) =>
|
||||
view === "archived" ? c.archived_at : !c.archived_at,
|
||||
);
|
||||
const filtered = visible.filter((c) => {
|
||||
const okStatus = statusFilter === "all" || c.status === statusFilter;
|
||||
const okQ = [c.title, c.case_number, c.client?.name].filter(Boolean).join(" ").toLowerCase().includes(q.toLowerCase());
|
||||
return okStatus && okQ;
|
||||
});
|
||||
const archivedCount = cases.filter((c) => c.archived_at).length;
|
||||
const activeCount = cases.length - archivedCount;
|
||||
|
||||
const onArchive = async (id: string, archived: boolean) => {
|
||||
if (await setArchived("cases", id, archived)) load();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -53,6 +67,12 @@ function CasesList() {
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as "active" | "archived")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">Active ({activeCount})</TabsTrigger>
|
||||
<TabsTrigger value="archived">Archived ({archivedCount})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search cases…" className="pl-9" />
|
||||
@@ -81,11 +101,14 @@ function CasesList() {
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Attorney</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Opened</th>
|
||||
<th className="text-right px-4 py-3 font-medium w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 && (
|
||||
<tr><td colSpan={5} className="text-center py-12 text-muted-foreground">No cases.</td></tr>
|
||||
<tr><td colSpan={6} className="text-center py-12 text-muted-foreground">
|
||||
{view === "archived" ? "No archived cases." : "No cases."}
|
||||
</td></tr>
|
||||
)}
|
||||
{filtered.map((c) => (
|
||||
<tr key={c.id} className="border-t hover:bg-muted/30">
|
||||
@@ -101,6 +124,16 @@ function CasesList() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{c.assignee?.full_name || c.assignee?.email || "—"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(c.opened_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={c.archived_at ? "Restore" : "Archive"}
|
||||
onClick={() => onArchive(c.id, !c.archived_at)}
|
||||
>
|
||||
{c.archived_at ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -47,7 +47,7 @@ function NewCase() {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [{ data: cs }, { data: us }] = await Promise.all([
|
||||
supabase.from("clients").select("id, name").order("name"),
|
||||
supabase.from("clients").select("id, name").is("archived_at", null).order("name"),
|
||||
supabase.from("profiles").select("id, full_name, email").order("full_name"),
|
||||
]);
|
||||
setClients(cs ?? []);
|
||||
|
||||
@@ -9,13 +9,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ClientFormDialog } from "@/components/clients/client-form-dialog";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact, Receipt } from "lucide-react";
|
||||
import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact, Receipt, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
|
||||
import { formatCurrency, formatDate, formatDateTime, statusBadgeClass } from "@/lib/format";
|
||||
import { toast } from "sonner";
|
||||
import { downloadStatusReport } from "@/lib/status-pdf";
|
||||
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
|
||||
import { TrustAccountPanel } from "@/components/trust/trust-account-panel";
|
||||
import { setArchived } from "@/lib/archive";
|
||||
|
||||
export const Route = createFileRoute("/clients/$clientId")({
|
||||
component: () => (
|
||||
@@ -123,11 +124,30 @@ function ClientDetail() {
|
||||
description={client.management_company || client.client_type.toUpperCase()}
|
||||
actions={
|
||||
<>
|
||||
{client.archived_at && (
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground">
|
||||
<Archive className="h-3 w-3 mr-1" /> Archived
|
||||
</Badge>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button variant="outline" onClick={() => setEditOpen(true)}>
|
||||
<Edit className="h-4 w-4 mr-2" /> Edit
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (await setArchived("clients", client.id, !client.archived_at)) load();
|
||||
}}
|
||||
>
|
||||
{client.archived_at ? (
|
||||
<><ArchiveRestore className="h-4 w-4 mr-2" /> Restore</>
|
||||
) : (
|
||||
<><Archive className="h-4 w-4 mr-2" /> Archive</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setInvoiceOpen(true)}>
|
||||
<Receipt className="h-4 w-4 mr-2" /> Generate invoice
|
||||
</Button>
|
||||
|
||||
@@ -6,10 +6,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ClientFormDialog } from "@/components/clients/client-form-dialog";
|
||||
import { Plus, Search, Building2, User, Briefcase as Building } from "lucide-react";
|
||||
import { Plus, Search, Building2, User, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { setArchived } from "@/lib/archive";
|
||||
|
||||
export const Route = createFileRoute("/clients/")({
|
||||
component: () => (
|
||||
@@ -23,6 +25,7 @@ function ClientsList() {
|
||||
const [clients, setClients] = useState<any[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [view, setView] = useState<"active" | "archived">("active");
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await supabase
|
||||
@@ -36,7 +39,10 @@ function ClientsList() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const filtered = clients.filter((c) =>
|
||||
const visible = clients.filter((c) =>
|
||||
view === "archived" ? c.archived_at : !c.archived_at,
|
||||
);
|
||||
const filtered = visible.filter((c) =>
|
||||
[c.name, c.management_company, c.primary_contact_name, c.primary_contact_email]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
@@ -44,9 +50,16 @@ function ClientsList() {
|
||||
.includes(q.toLowerCase()),
|
||||
);
|
||||
|
||||
const archivedCount = clients.filter((c) => c.archived_at).length;
|
||||
const activeCount = clients.length - archivedCount;
|
||||
|
||||
const typeIcon = (t: string) =>
|
||||
t === "hoa" || t === "condo" ? Building2 : User;
|
||||
|
||||
const onArchive = async (id: string, archived: boolean) => {
|
||||
if (await setArchived("clients", id, archived)) load();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
@@ -60,14 +73,22 @@ function ClientsList() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="relative mb-4 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search by name, contact, manager…"
|
||||
className="pl-9"
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as "active" | "archived")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">Active ({activeCount})</TabsTrigger>
|
||||
<TabsTrigger value="archived">Archived ({archivedCount})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search by name, contact, manager…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
@@ -80,13 +101,18 @@ function ClientsList() {
|
||||
<th className="text-left px-4 py-3 font-medium">Contact</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Units</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Created</th>
|
||||
<th className="text-right px-4 py-3 font-medium w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-12 text-muted-foreground">
|
||||
{clients.length === 0 ? "No clients yet. Add your first one." : "No matches."}
|
||||
<td colSpan={6} className="text-center py-12 text-muted-foreground">
|
||||
{visible.length === 0
|
||||
? view === "archived"
|
||||
? "No archived clients."
|
||||
: "No clients yet. Add your first one."
|
||||
: "No matches."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -118,6 +144,20 @@ function ClientsList() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{c.num_units ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(c.created_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={c.archived_at ? "Restore" : "Archive"}
|
||||
onClick={() => onArchive(c.id, !c.archived_at)}
|
||||
>
|
||||
{c.archived_at ? (
|
||||
<ArchiveRestore className="h-4 w-4" />
|
||||
) : (
|
||||
<Archive className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,8 +8,9 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Edit, Trash2, Mail, Phone, MapPin, Building2, Briefcase, Users } from "lucide-react";
|
||||
import { ArrowLeft, Edit, Trash2, Mail, Phone, MapPin, Building2, Briefcase, Users, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { ContactFormDialog, CONTACT_TYPES } from "@/components/contacts/contact-form-dialog";
|
||||
import { setArchived } from "@/lib/archive";
|
||||
|
||||
export const Route = createFileRoute("/contacts/$contactId")({
|
||||
component: () => (
|
||||
@@ -87,12 +88,31 @@ function ContactDetail() {
|
||||
title={contact.name}
|
||||
description={[contact.title, contact.company].filter(Boolean).join(" · ") || typeLabel}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditOpen(true)}><Edit className="h-4 w-4 mr-2" /> Edit</Button>
|
||||
<Button variant="outline" onClick={remove}><Trash2 className="h-4 w-4 mr-2" /> Delete</Button>
|
||||
</>
|
||||
) : null
|
||||
<>
|
||||
{contact.archived_at && (
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground">
|
||||
<Archive className="h-3 w-3 mr-1" /> Archived
|
||||
</Badge>
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditOpen(true)}><Edit className="h-4 w-4 mr-2" /> Edit</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (await setArchived("contacts", contact.id, !contact.archived_at)) load();
|
||||
}}
|
||||
>
|
||||
{contact.archived_at ? (
|
||||
<><ArchiveRestore className="h-4 w-4 mr-2" /> Restore</>
|
||||
) : (
|
||||
<><Archive className="h-4 w-4 mr-2" /> Archive</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={remove}><Trash2 className="h-4 w-4 mr-2" /> Delete</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Search, Mail, Phone, Building2, ChevronRight, Edit, Trash2, Briefcase, Users } from "lucide-react";
|
||||
import { Plus, Search, Mail, Phone, Building2, ChevronRight, Edit, Trash2, Briefcase, Users, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { ContactFormDialog, CONTACT_TYPES, type ContactRecord } from "@/components/contacts/contact-form-dialog";
|
||||
import { setArchived } from "@/lib/archive";
|
||||
|
||||
export const Route = createFileRoute("/contacts/")({
|
||||
component: () => (
|
||||
@@ -25,6 +27,7 @@ interface ContactRow extends Required<Pick<ContactRecord, "name">>, ContactRecor
|
||||
created_by: string | null;
|
||||
case_links: number;
|
||||
client_links: number;
|
||||
archived_at: string | null;
|
||||
}
|
||||
|
||||
function ContactsIndex() {
|
||||
@@ -33,6 +36,7 @@ function ContactsIndex() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [q, setQ] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [view, setView] = useState<"active" | "archived">("active");
|
||||
const [editing, setEditing] = useState<ContactRecord | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -65,6 +69,7 @@ function ContactsIndex() {
|
||||
const filtered = useMemo(() => {
|
||||
const term = q.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (view === "archived" ? !r.archived_at : !!r.archived_at) return false;
|
||||
if (typeFilter !== "all" && r.contact_type !== typeFilter) return false;
|
||||
if (!term) return true;
|
||||
return (
|
||||
@@ -74,7 +79,10 @@ function ContactsIndex() {
|
||||
(r.phone ?? "").toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [rows, q, typeFilter]);
|
||||
}, [rows, q, typeFilter, view]);
|
||||
|
||||
const archivedCount = rows.filter((r) => r.archived_at).length;
|
||||
const activeCount = rows.length - archivedCount;
|
||||
|
||||
const remove = async (id: string, name: string) => {
|
||||
if (!confirm(`Delete contact "${name}"? This will also remove all case and client links.`)) return;
|
||||
@@ -87,6 +95,10 @@ function ContactsIndex() {
|
||||
setRows((r) => r.filter((x) => x.id !== id));
|
||||
};
|
||||
|
||||
const onArchive = async (id: string, archived: boolean) => {
|
||||
if (await setArchived("contacts", id, archived)) load();
|
||||
};
|
||||
|
||||
const startNew = () => {
|
||||
setEditing(null);
|
||||
setOpen(true);
|
||||
@@ -110,6 +122,12 @@ function ContactsIndex() {
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4 flex flex-col sm:flex-row gap-3">
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as "active" | "archived")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">Active ({activeCount})</TabsTrigger>
|
||||
<TabsTrigger value="archived">Archived ({archivedCount})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -138,7 +156,7 @@ function ContactsIndex() {
|
||||
<p className="p-6 text-sm text-muted-foreground">Loading…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="p-6 text-sm text-muted-foreground text-center">
|
||||
{rows.length === 0 ? "No contacts yet." : "No contacts match your filters."}
|
||||
{rows.length === 0 ? "No contacts yet." : view === "archived" ? "No archived contacts." : "No contacts match your filters."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
@@ -171,16 +189,26 @@ function ContactsIndex() {
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
{canEdit && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => startEdit(c)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => remove(c.id, c.name)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={c.archived_at ? "Restore" : "Archive"}
|
||||
onClick={() => onArchive(c.id, !c.archived_at)}
|
||||
>
|
||||
{c.archived_at ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" onClick={() => startEdit(c)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => remove(c.id, c.name)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -111,6 +111,7 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
management: "management_company", managementcompany: "management_company",
|
||||
units: "num_units", numunits: "num_units",
|
||||
notes: "notes",
|
||||
archived: "_archived", isarchived: "_archived", status: "_archived",
|
||||
},
|
||||
numeric: ["num_units", "annual_interest_rate"],
|
||||
transform: (r) => {
|
||||
@@ -145,6 +146,7 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
zip: "postal_code", zipcode: "postal_code", postalcode: "postal_code",
|
||||
type: "contact_type", contacttype: "contact_type",
|
||||
notes: "notes",
|
||||
archived: "_archived", isarchived: "_archived",
|
||||
},
|
||||
transform: (r) => {
|
||||
if (!r.name && (r._first || r._last)) {
|
||||
@@ -197,6 +199,7 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
firstname: "first_name", lastname: "last_name",
|
||||
email: "email", phone: "phone",
|
||||
notes: "notes",
|
||||
archived: "_archived", isarchived: "_archived",
|
||||
},
|
||||
transform: (r, ctx) => {
|
||||
const cid = r._clientext ? ctx.clientByExt.get(String(r._clientext)) : null;
|
||||
@@ -243,6 +246,7 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
opposingcounselphone: "opposing_counsel_phone",
|
||||
claimamount: "claim_amount", settlementamount: "settlement_amount",
|
||||
hourlyrate: "default_hourly_rate", rate: "default_hourly_rate",
|
||||
archived: "_archived", isarchived: "_archived",
|
||||
},
|
||||
numeric: ["claim_amount", "settlement_amount", "default_hourly_rate"],
|
||||
dateCols: ["opened_at", "closed_at", "filing_date", "next_hearing_date", "statute_of_limitations"],
|
||||
@@ -574,6 +578,15 @@ function ImportPage() {
|
||||
const transformed = cfg.transform ? cfg.transform(row, ctx) : row;
|
||||
if (!transformed) { skipped++; continue; }
|
||||
|
||||
// archived → archived_at (only on tables that support it)
|
||||
if ("_archived" in transformed) {
|
||||
const flag = transformed._archived;
|
||||
delete transformed._archived;
|
||||
if (["clients", "cases", "contacts", "homeowners"].includes(cfg.table)) {
|
||||
transformed.archived_at = toBool(flag) ? new Date().toISOString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
// required check
|
||||
const missing = cfg.required.find((k) => !transformed[k] && transformed[k] !== 0);
|
||||
if (missing) { skipped++; continue; }
|
||||
|
||||
@@ -48,6 +48,7 @@ function StatusUpdatesPage() {
|
||||
const { data, error } = await supabase
|
||||
.from("cases")
|
||||
.select("id, case_number, title, client:clients(name)")
|
||||
.is("archived_at", null)
|
||||
.order("opened_at", { ascending: false })
|
||||
.limit(500);
|
||||
if (error) toast.error("Could not load cases", { description: error.message });
|
||||
|
||||
@@ -93,7 +93,7 @@ function TasksPage() {
|
||||
.order("due_date", { ascending: true, nullsFirst: false }),
|
||||
supabase.from("task_assignees").select("task_id, user_id"),
|
||||
supabase.from("profiles").select("id, full_name, email"),
|
||||
supabase.from("cases").select("id, case_number, title"),
|
||||
supabase.from("cases").select("id, case_number, title").is("archived_at", null),
|
||||
]);
|
||||
setTasks((t.data ?? []) as TaskRow[]);
|
||||
setAssignees((a.data ?? []) as AssigneeRow[]);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Add archived_at to the four entity tables
|
||||
ALTER TABLE public.clients ADD COLUMN archived_at timestamptz;
|
||||
ALTER TABLE public.cases ADD COLUMN archived_at timestamptz;
|
||||
ALTER TABLE public.contacts ADD COLUMN archived_at timestamptz;
|
||||
ALTER TABLE public.homeowners ADD COLUMN archived_at timestamptz;
|
||||
|
||||
CREATE INDEX idx_clients_archived_at ON public.clients (archived_at);
|
||||
CREATE INDEX idx_cases_archived_at ON public.cases (archived_at);
|
||||
CREATE INDEX idx_contacts_archived_at ON public.contacts (archived_at);
|
||||
CREATE INDEX idx_homeowners_archived_at ON public.homeowners (archived_at);
|
||||
Reference in New Issue
Block a user