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:
gpt-engineer-app[bot]
2026-04-18 01:45:30 +00:00
co-authored by renee-png
17 changed files with 287 additions and 48 deletions
@@ -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);
+2 -1
View File
@@ -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 ?? []);
+12
View File
@@ -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
+39
View File
@@ -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";
}
+3
View File
@@ -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[];
+19 -1
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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 ?? []);
+21 -1
View File
@@ -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>
+52 -12
View File
@@ -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>
);
})}
+27 -7
View File
@@ -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>
</>
)}
</>
}
/>
+41 -13
View File
@@ -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>
);
+13
View File
@@ -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; }
+1
View File
@@ -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 });
+1 -1
View File
@@ -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);