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:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user