Added Collections tab & ledger

X-Lovable-Edit-ID: edt-797ef967-78a5-4d1a-878c-985339ceb58c
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 00:50:29 +00:00
co-authored by renee-png
6 changed files with 1555 additions and 3 deletions
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -32,6 +32,7 @@ const schema = z.object({
postal_code: z.string().trim().max(20).optional().or(z.literal("")),
management_company: z.string().trim().max(200).optional().or(z.literal("")),
num_units: z.coerce.number().int().min(0).max(100000).optional().or(z.literal("").transform(() => undefined)),
annual_interest_rate: z.coerce.number().min(0).max(100).optional().or(z.literal("").transform(() => undefined)),
board_members: z.array(boardMember),
notes: z.string().trim().max(5000).optional().or(z.literal("")),
});
@@ -66,6 +67,7 @@ export function ClientFormDialog({
postal_code: "",
management_company: "",
num_units: undefined as any,
annual_interest_rate: undefined as any,
board_members: [],
notes: "",
},
@@ -85,6 +87,7 @@ export function ClientFormDialog({
postal_code: client?.postal_code ?? "",
management_company: client?.management_company ?? "",
num_units: client?.num_units ?? (undefined as any),
annual_interest_rate: client?.annual_interest_rate ?? (undefined as any),
board_members: client?.board_members ?? [],
notes: client?.notes ?? "",
});
@@ -106,6 +109,12 @@ export function ClientFormDialog({
const payload: any = {
...values,
num_units: values.num_units || null,
annual_interest_rate:
values.annual_interest_rate === undefined ||
values.annual_interest_rate === null ||
(values.annual_interest_rate as any) === ""
? null
: values.annual_interest_rate,
// strip empty strings to nulls
...Object.fromEntries(
Object.entries(values).map(([k, v]) => [k, v === "" ? null : v]),
@@ -176,7 +185,7 @@ export function ClientFormDialog({
</div>
{clientType === "hoa" && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
<FormField
control={form.control}
name="management_company"
@@ -201,6 +210,27 @@ export function ClientFormDialog({
</FormItem>
)}
/>
<FormField
control={form.control}
name="annual_interest_rate"
render={({ field }) => (
<FormItem>
<FormLabel>Interest rate (% / yr)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
max={100}
placeholder="e.g. 18"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
+160
View File
@@ -141,6 +141,7 @@ export type Database = {
Row: {
address_line1: string | null
address_line2: string | null
annual_interest_rate: number | null
board_members: Json
city: string | null
client_type: Database["public"]["Enums"]["client_type"]
@@ -161,6 +162,7 @@ export type Database = {
Insert: {
address_line1?: string | null
address_line2?: string | null
annual_interest_rate?: number | null
board_members?: Json
city?: string | null
client_type?: Database["public"]["Enums"]["client_type"]
@@ -181,6 +183,7 @@ export type Database = {
Update: {
address_line1?: string | null
address_line2?: string | null
annual_interest_rate?: number | null
board_members?: Json
city?: string | null
client_type?: Database["public"]["Enums"]["client_type"]
@@ -200,6 +203,107 @@ export type Database = {
}
Relationships: []
}
collection_ledger_entries: {
Row: {
collection_id: string
created_at: string
created_by: string | null
credit: number
debit: number
description: string | null
entry_date: string
id: string
transaction_type: string
updated_at: string
}
Insert: {
collection_id: string
created_at?: string
created_by?: string | null
credit?: number
debit?: number
description?: string | null
entry_date?: string
id?: string
transaction_type: string
updated_at?: string
}
Update: {
collection_id?: string
created_at?: string
created_by?: string | null
credit?: number
debit?: number
description?: string | null
entry_date?: string
id?: string
transaction_type?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "collection_ledger_entries_collection_id_fkey"
columns: ["collection_id"]
isOneToOne: false
referencedRelation: "collections"
referencedColumns: ["id"]
},
]
}
collections: {
Row: {
case_id: string
closed_at: string | null
created_at: string
created_by: string | null
homeowner_id: string
id: string
notes: string | null
opened_at: string
status: string
updated_at: string
}
Insert: {
case_id: string
closed_at?: string | null
created_at?: string
created_by?: string | null
homeowner_id: string
id?: string
notes?: string | null
opened_at?: string
status?: string
updated_at?: string
}
Update: {
case_id?: string
closed_at?: string | null
created_at?: string
created_by?: string | null
homeowner_id?: string
id?: string
notes?: string | null
opened_at?: string
status?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "collections_case_id_fkey"
columns: ["case_id"]
isOneToOne: false
referencedRelation: "cases"
referencedColumns: ["id"]
},
{
foreignKeyName: "collections_homeowner_id_fkey"
columns: ["homeowner_id"]
isOneToOne: false
referencedRelation: "homeowners"
referencedColumns: ["id"]
},
]
}
documents: {
Row: {
case_id: string
@@ -301,6 +405,62 @@ export type Database = {
},
]
}
homeowners: {
Row: {
address: string | null
client_id: string
created_at: string
created_by: string | null
email: string | null
first_name: string
id: string
last_name: string
notes: string | null
opening_balance: number
phone: string | null
unit_number: string | null
updated_at: string
}
Insert: {
address?: string | null
client_id: string
created_at?: string
created_by?: string | null
email?: string | null
first_name: string
id?: string
last_name: string
notes?: string | null
opening_balance?: number
phone?: string | null
unit_number?: string | null
updated_at?: string
}
Update: {
address?: string | null
client_id?: string
created_at?: string
created_by?: string | null
email?: string | null
first_name?: string
id?: string
last_name?: string
notes?: string | null
opening_balance?: number
phone?: string | null
unit_number?: string | null
updated_at?: string
}
Relationships: [
{
foreignKeyName: "homeowners_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
]
}
invoices: {
Row: {
amount_paid: number
+9 -2
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 } from "lucide-react";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users } from "lucide-react";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { CaseDocumentsTab } from "@/components/cases/documents-tab";
import { CaseTimeTab } from "@/components/cases/time-tab";
@@ -16,6 +16,7 @@ import { CaseExpensesTab } from "@/components/cases/expenses-tab";
import { CaseStatusTab } from "@/components/cases/status-tab";
import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
import { CaseCollectionsTab } from "@/components/cases/collections-tab";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
@@ -38,7 +39,7 @@ function CaseDetail() {
const [{ data: c, error }, { data: us }] = await Promise.all([
supabase
.from("cases")
.select("*, client:clients(id, name, client_type), assignee:profiles!cases_assigned_attorney_id_fkey(id, full_name, email)")
.select("*, client:clients(id, name, client_type, annual_interest_rate), assignee:profiles!cases_assigned_attorney_id_fkey(id, full_name, email)")
.eq("id", caseId)
.maybeSingle(),
supabase.from("profiles").select("id, full_name, email"),
@@ -144,6 +145,9 @@ function CaseDetail() {
<TabsTrigger value="time"><Clock className="h-3.5 w-3.5 mr-1.5" />Time</TabsTrigger>
<TabsTrigger value="expenses"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Expenses</TabsTrigger>
<TabsTrigger value="invoices"><Receipt className="h-3.5 w-3.5 mr-1.5" />Invoices</TabsTrigger>
{data.client?.client_type === "hoa" && (
<TabsTrigger value="collections"><Users className="h-3.5 w-3.5 mr-1.5" />Collections</TabsTrigger>
)}
</TabsList>
<TabsContent value="litigation"><CaseLitigationTab caseRecord={data} canManage={canManage} onSaved={load} /></TabsContent>
<TabsContent value="overview"><CaseStatusTab caseId={caseId} /></TabsContent>
@@ -151,6 +155,9 @@ function CaseDetail() {
<TabsContent value="time"><CaseTimeTab caseRecord={data} /></TabsContent>
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={data} /></TabsContent>
{data.client?.client_type === "hoa" && (
<TabsContent value="collections"><CaseCollectionsTab caseRecord={data} /></TabsContent>
)}
</Tabs>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-4 gap-3 text-center text-xs">
+8
View File
@@ -105,6 +105,14 @@ function ClientDetail() {
<>
<DetailRow label="Units" value={client.num_units} />
<DetailRow label="Management Co." value={client.management_company} />
<DetailRow
label="Interest rate"
value={
client.annual_interest_rate != null
? `${client.annual_interest_rate}% per annum`
: null
}
/>
</>
)}
<DetailRow label="Primary contact" value={client.primary_contact_name} />
@@ -0,0 +1,142 @@
-- 1. Annual interest rate on clients (per annum, non-compounded, applied monthly)
ALTER TABLE public.clients
ADD COLUMN IF NOT EXISTS annual_interest_rate numeric;
-- 2. Homeowners (belong to a client / HOA)
CREATE TABLE IF NOT EXISTS public.homeowners (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid NOT NULL REFERENCES public.clients(id) ON DELETE CASCADE,
first_name text NOT NULL,
last_name text NOT NULL,
unit_number text,
address text,
email text,
phone text,
opening_balance numeric NOT NULL DEFAULT 0,
notes text,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_homeowners_client ON public.homeowners(client_id);
ALTER TABLE public.homeowners ENABLE ROW LEVEL SECURITY;
CREATE POLICY homeowners_select_auth ON public.homeowners
FOR SELECT TO authenticated USING (true);
CREATE POLICY homeowners_insert_auth ON public.homeowners
FOR INSERT TO authenticated WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY homeowners_update_owner ON public.homeowners
FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()) OR created_by = auth.uid());
CREATE POLICY homeowners_delete_admin ON public.homeowners
FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()) OR created_by = auth.uid());
CREATE TRIGGER homeowners_set_updated_at
BEFORE UPDATE ON public.homeowners
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- 3. Collections (one record per homeowner per case/matter)
CREATE TABLE IF NOT EXISTS public.collections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
case_id uuid NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE,
homeowner_id uuid NOT NULL REFERENCES public.homeowners(id) ON DELETE RESTRICT,
status text NOT NULL DEFAULT 'late_notice',
notes text,
opened_at date NOT NULL DEFAULT CURRENT_DATE,
closed_at date,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (case_id, homeowner_id)
);
CREATE INDEX IF NOT EXISTS idx_collections_case ON public.collections(case_id);
CREATE INDEX IF NOT EXISTS idx_collections_homeowner ON public.collections(homeowner_id);
ALTER TABLE public.collections ENABLE ROW LEVEL SECURITY;
CREATE POLICY collections_select_case ON public.collections
FOR SELECT TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE POLICY collections_insert_case ON public.collections
FOR INSERT TO authenticated
WITH CHECK (public.can_access_case(case_id, auth.uid()));
CREATE POLICY collections_update_case ON public.collections
FOR UPDATE TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE POLICY collections_delete_case ON public.collections
FOR DELETE TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE TRIGGER collections_set_updated_at
BEFORE UPDATE ON public.collections
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- 4. Collection ledger entries (debit/credit per collection)
CREATE TABLE IF NOT EXISTS public.collection_ledger_entries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
collection_id uuid NOT NULL REFERENCES public.collections(id) ON DELETE CASCADE,
entry_date date NOT NULL DEFAULT CURRENT_DATE,
transaction_type text NOT NULL,
description text,
debit numeric NOT NULL DEFAULT 0,
credit numeric NOT NULL DEFAULT 0,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cle_collection ON public.collection_ledger_entries(collection_id);
CREATE INDEX IF NOT EXISTS idx_cle_date ON public.collection_ledger_entries(entry_date);
ALTER TABLE public.collection_ledger_entries ENABLE ROW LEVEL SECURITY;
CREATE POLICY cle_select_case ON public.collection_ledger_entries
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.collections c
WHERE c.id = collection_id
AND public.can_access_case(c.case_id, auth.uid())
)
);
CREATE POLICY cle_insert_case ON public.collection_ledger_entries
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM public.collections c
WHERE c.id = collection_id
AND public.can_access_case(c.case_id, auth.uid())
)
);
CREATE POLICY cle_update_case ON public.collection_ledger_entries
FOR UPDATE TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.collections c
WHERE c.id = collection_id
AND public.can_access_case(c.case_id, auth.uid())
)
);
CREATE POLICY cle_delete_case ON public.collection_ledger_entries
FOR DELETE TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.collections c
WHERE c.id = collection_id
AND public.can_access_case(c.case_id, auth.uid())
)
);
CREATE TRIGGER cle_set_updated_at
BEFORE UPDATE ON public.collection_ledger_entries
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();