diff --git a/RESUME.md b/RESUME.md index d881dcd..b218d78 100644 --- a/RESUME.md +++ b/RESUME.md @@ -216,9 +216,24 @@ Homebrew. No Access ODBC driver, `node_modules` not installed, staging Parquet n premium, so every premium headline and the premium sort use `netPremium` (populated on 2377/2378). This also fixed a live bug on the customer detail page, which was showing "$0.00 Total" for 1585 policies. - - **NEXT:** plan step 5 — Utilities module (properties / services / trust accounts as a - first-class browser, the way `/polizas` now is for insurance), then step 6, the shared - billing/statements view. + - **Utilities module (plan step 5) — DONE**: `apps/api/src/properties/` (`GET /properties` + with search over address / customer / service account number / meter / trust number / + phones, filters for service kind, municipality, trust bank, trust bucket + (with|without|active|expiring|expired|undated) and `hasServices`, 5 sorts; + `/properties/stats`, `/properties/facets`, `/properties/:id`) + web `/servicios` + (renewals-first property browser with clickable stat cells and a clickable service-mix + strip) and `/servicios/[id]` (services, fideicomiso, linked policy, owner + sibling + properties, owner-level utility ledger, documents). Cross-links both ways with the + customer and policy views. **Data findings:** (a) the trust renewal date staff chase is + `trust_accounts.dueDate2` — DATMEX's `vence2`, one year after `vence1` on 531 of 541 + dated trusts (18 due within 30 days, 119 already overdue); (b) `properties.zone` is + effectively dead (1444 of 1519 null, the rest near-unique), so it is not a facet; + (c) the municipality that bills a property lives in the *predial* service's `notes` + (ROSARITO 566 / TIJUANA 221 / ENSENADA 152, 939/939 populated) — that is the real + geographic filter. `PropertyService.notes` means something different per kind + (municipality / CFE PAR-IMPAR cycle / gas supply type), so the UI labels it per kind. + 240 of 1519 properties have no service rows at all — surfaced as its own bucket. + - **NEXT:** plan step 6 — the shared billing/statements view across both business lines. - Full pipeline reproducible in one command: `run_all.py --env ` runs customers -> properties -> policies -> transactions -> bank in order (all idempotent); add `--stage` to re-extract from the Access files first. Verified end-to-end against dev. diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c0c760f..6a8ea3b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,7 @@ import { UsersModule } from "./users/users.module"; import { AuthModule } from "./auth/auth.module"; import { CustomersModule } from "./customers/customers.module"; import { PoliciesModule } from "./policies/policies.module"; +import { PropertiesModule } from "./properties/properties.module"; import { AppController } from "./app.controller"; @Module({ @@ -15,6 +16,7 @@ import { AppController } from "./app.controller"; AuthModule, CustomersModule, PoliciesModule, + PropertiesModule, ], controllers: [AppController], }) diff --git a/apps/api/src/properties/properties.controller.ts b/apps/api/src/properties/properties.controller.ts new file mode 100644 index 0000000..b09a3cf --- /dev/null +++ b/apps/api/src/properties/properties.controller.ts @@ -0,0 +1,98 @@ +import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { ServiceKind } from "@jorgecuadros/database"; +import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { + PropertiesService, + type PropertySort, + type TrustFilter, +} from "./properties.service"; + +const KINDS: ServiceKind[] = [ + "WATER", + "ELECTRIC", + "GAS", + "CABLE", + "PROPERTY_TAX", + "FEDERAL_ZONE", + "ALARM", + "OTHER", +]; + +const TRUST_FILTERS: TrustFilter[] = [ + "with", + "without", + "active", + "expiring", + "expired", + "undated", +]; + +const SORTS: PropertySort[] = [ + "customer", + "address", + "services_desc", + "trust_due_asc", + "trust_due_desc", +]; + +/** Clamped trust-renewal window; 30 days matches the policies module. */ +function parseDays(days?: string): number { + return Math.min(365, Math.max(1, Number(days) || 30)); +} + +@UseGuards(AuthenticatedGuard) +@Controller("properties") +export class PropertiesController { + constructor(private readonly properties: PropertiesService) {} + + @Get("stats") + stats(@Query("days") days?: string) { + return this.properties.stats(parseDays(days)); + } + + @Get("facets") + facets() { + return this.properties.facets(); + } + + @Get() + list( + @Query("query") query?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("serviceKind") serviceKind?: string, + @Query("municipality") municipality?: string, + @Query("bank") bank?: string, + @Query("trust") trust?: string, + @Query("hasServices") hasServices?: string, + @Query("customerId") customerId?: string, + @Query("days") days?: string, + @Query("sort") sort?: string, + ) { + return this.properties.list({ + query, + page: Math.max(1, Number(page) || 1), + pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)), + serviceKind: KINDS.includes(serviceKind as ServiceKind) + ? (serviceKind as ServiceKind) + : undefined, + municipality: municipality || undefined, + bank: bank || undefined, + trust: TRUST_FILTERS.includes(trust as TrustFilter) + ? (trust as TrustFilter) + : undefined, + hasServices: + hasServices === "true" ? true : hasServices === "false" ? false : undefined, + customerId: customerId || undefined, + days: parseDays(days), + sort: SORTS.includes(sort as PropertySort) + ? (sort as PropertySort) + : "customer", + }); + } + + @Get(":id") + detail(@Param("id") id: string, @Query("days") days?: string) { + return this.properties.detail(id, parseDays(days)); + } +} diff --git a/apps/api/src/properties/properties.module.ts b/apps/api/src/properties/properties.module.ts new file mode 100644 index 0000000..73d8234 --- /dev/null +++ b/apps/api/src/properties/properties.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { PropertiesController } from "./properties.controller"; +import { PropertiesService } from "./properties.service"; + +@Module({ + controllers: [PropertiesController], + providers: [PropertiesService], +}) +export class PropertiesModule {} diff --git a/apps/api/src/properties/properties.service.ts b/apps/api/src/properties/properties.service.ts new file mode 100644 index 0000000..cf7a578 --- /dev/null +++ b/apps/api/src/properties/properties.service.ts @@ -0,0 +1,446 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, ServiceKind } from "@jorgecuadros/database"; +import { PrismaService } from "../prisma/prisma.service"; + +/** + * Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2` + * against today. The migration loaded DATMEX's `vence1`/`vence2` pair as + * `dueDate1`/`dueDate2`; on 531 of the 541 dated trusts `dueDate2` is exactly + * one year after `dueDate1`, so `dueDate2` is the *next* annual due date — the + * one staff chase — and `dueDate1` is the period it renewed from. + * + * `undated` is a real bucket, not an error: 12 trusts carry no dates at all. + */ +export type TrustStatus = "active" | "expiring" | "expired" | "undated"; + +/** `with`/`without` filter on the whole property set; the rest are trust buckets. */ +export type TrustFilter = "with" | "without" | TrustStatus; + +export type PropertySort = + | "customer" + | "address" + | "services_desc" + | "trust_due_asc" + | "trust_due_desc"; + +export interface ListParams { + query?: string; + page: number; + pageSize: number; + serviceKind?: ServiceKind; + /** Municipality from the predial service's notes — see `facets()`. */ + municipality?: string; + bank?: string; + trust?: TrustFilter; + /** false = properties with no service rows at all (240 of 1519). */ + hasServices?: boolean; + customerId?: string; + /** Window in days for the `expiring` trust bucket. */ + days: number; + sort: PropertySort; +} + +/** Municipality lives in the predial service's `notes` (939/939 populated, + * exactly three values). FEDERAL_ZONE's notes hold the same idea but also + * carry non-municipality values like "SUSPENDIDO", so predial is the source. */ +const MUNICIPALITY_KIND: ServiceKind = "PROPERTY_TAX"; + +/** Midnight today, UTC — trust dates are stored date-only at 00:00 UTC. */ +function today(): Date { + const now = new Date(); + return new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); +} + +function addDays(d: Date, days: number): Date { + return new Date(d.getTime() + days * 86400000); +} + +function trustStatusOf( + dueDate: Date | null | undefined, + from: Date, + soon: Date, +): TrustStatus { + if (!dueDate) return "undated"; + if (dueDate < from) return "expired"; + return dueDate <= soon ? "expiring" : "active"; +} + +function daysUntil(dueDate: Date | null | undefined, from: Date): number | null { + if (!dueDate) return null; + return Math.round((dueDate.getTime() - from.getTime()) / 86400000); +} + +@Injectable() +export class PropertiesService { + constructor(private readonly prisma: PrismaService) {} + + private trustWhere( + trust: TrustFilter | undefined, + days: number, + ): Prisma.PropertyWhereInput { + const from = today(); + switch (trust) { + case "with": + return { trustAccount: { isNot: null } }; + case "without": + return { trustAccount: { is: null } }; + case "active": + return { trustAccount: { dueDate2: { gte: from } } }; + case "expiring": + return { + trustAccount: { dueDate2: { gte: from, lte: addDays(from, days) } }, + }; + case "expired": + return { trustAccount: { dueDate2: { lt: from } } }; + case "undated": + return { trustAccount: { is: { dueDate2: null } } }; + default: + return {}; + } + } + + private orderBy(sort: PropertySort): Prisma.PropertyOrderByWithRelationInput[] { + switch (sort) { + case "address": + return [{ addressLine1: "asc" }, { addressLine2: "asc" }]; + case "services_desc": + return [{ services: { _count: "desc" } }, { customer: { name: "asc" } }]; + case "trust_due_asc": + return [{ trustAccount: { dueDate2: "asc" } }]; + case "trust_due_desc": + return [{ trustAccount: { dueDate2: "desc" } }]; + default: + // Nameless customers last, same rule the customer list uses. + return [ + { customer: { nameMissing: "asc" } }, + { customer: { name: "asc" } }, + { addressLine1: "asc" }, + ]; + } + } + + /** Property list with search, service/trust/municipality filters, paginated. */ + async list(params: ListParams) { + const { + query, + page, + pageSize, + serviceKind, + municipality, + bank, + trust, + hasServices, + customerId, + days, + sort, + } = params; + + const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)]; + + // Sorting by trust due date is only meaningful for properties that have a + // trust; MySQL would otherwise float the ~966 trust-less rows (NULL first + // on ASC) above every real due date. Scoping is explicit in the UI label. + if (sort === "trust_due_asc" || sort === "trust_due_desc") { + and.push({ trustAccount: { isNot: null } }); + } + + if (query && query.trim()) { + const q = query.trim(); + and.push({ + OR: [ + { addressLine1: { contains: q } }, + { addressLine2: { contains: q } }, + { phone1: { contains: q } }, + { phone2: { contains: q } }, + { phone3: { contains: q } }, + { zone: { contains: q } }, + { legacyId: { contains: q } }, + { customer: { name: { contains: q } } }, + { services: { some: { accountNumber: { contains: q } } } }, + { services: { some: { meterNumber: { contains: q } } } }, + { trustAccount: { trustNumber: { contains: q } } }, + ], + }); + } + if (serviceKind) and.push({ services: { some: { kind: serviceKind } } }); + if (municipality) + and.push({ + services: { some: { kind: MUNICIPALITY_KIND, notes: municipality } }, + }); + if (bank) and.push({ trustAccount: { bankName: bank } }); + if (hasServices !== undefined) + and.push(hasServices ? { services: { some: {} } } : { services: { none: {} } }); + if (customerId) and.push({ customerId }); + + const where: Prisma.PropertyWhereInput = { AND: and }; + + const [total, rows] = await this.prisma.$transaction([ + this.prisma.property.count({ where }), + this.prisma.property.findMany({ + where, + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: this.orderBy(sort), + select: { + id: true, + addressLine1: true, + addressLine2: true, + phone1: true, + phone2: true, + phone3: true, + zone: true, + customer: { + select: { id: true, name: true, city: true, state: true }, + }, + services: { + select: { id: true, kind: true, active: true, notes: true }, + }, + trustAccount: { + select: { + bankName: true, + trustNumber: true, + bankFee: true, + dueDate1: true, + dueDate2: true, + }, + }, + _count: { select: { services: true, documents: true } }, + }, + }), + ]); + + const from = today(); + const soon = addDays(from, days); + + const items = rows.map((r) => { + const predial = r.services.find((s) => s.kind === MUNICIPALITY_KIND); + return { + id: r.id, + addressLine1: r.addressLine1, + addressLine2: r.addressLine2, + zone: r.zone, + phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[], + customerId: r.customer.id, + customerName: r.customer.name, + customerCity: r.customer.city, + customerState: r.customer.state, + municipality: predial?.notes ?? null, + services: r.services.map((s) => ({ + id: s.id, + kind: s.kind, + active: s.active, + })), + serviceCount: r._count.services, + activeServiceCount: r.services.filter((s) => s.active).length, + documentCount: r._count.documents, + trust: r.trustAccount + ? { + bankName: r.trustAccount.bankName, + trustNumber: r.trustAccount.trustNumber, + bankFee: r.trustAccount.bankFee, + dueDate1: r.trustAccount.dueDate1, + dueDate2: r.trustAccount.dueDate2, + status: trustStatusOf(r.trustAccount.dueDate2, from, soon), + daysToDue: daysUntil(r.trustAccount.dueDate2, from), + } + : null, + }; + }); + + return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) }; + } + + /** Top-line counts for the utilities page header. */ + async stats(days: number) { + const from = today(); + const soon = addDays(from, days); + + const [ + properties, + owners, + services, + withoutServices, + trusts, + trustExpiring, + trustExpired, + documents, + ] = await this.prisma.$transaction([ + this.prisma.property.count(), + this.prisma.customer.count({ where: { properties: { some: {} } } }), + this.prisma.propertyService.count(), + this.prisma.property.count({ where: { services: { none: {} } } }), + this.prisma.property.count({ where: { trustAccount: { isNot: null } } }), + this.prisma.property.count({ + where: { trustAccount: { dueDate2: { gte: from, lte: soon } } }, + }), + this.prisma.property.count({ + where: { trustAccount: { dueDate2: { lt: from } } }, + }), + this.prisma.serviceDocument.count(), + ]); + + // Service mix, per kind — the operational headline for this line of + // business (how many bills of each type the office pays every month). + const byKind = await this.prisma.propertyService.groupBy({ + by: ["kind"], + _count: { _all: true }, + orderBy: { _count: { kind: "desc" } }, + }); + + const activeByKind = await this.prisma.propertyService.groupBy({ + by: ["kind"], + where: { active: true }, + _count: { _all: true }, + }); + const activeMap = new Map(activeByKind.map((r) => [r.kind, r._count._all])); + + return { + properties, + owners, + services, + withoutServices, + trusts, + trustExpiring, + trustExpired, + documents, + days, + byKind: byKind.map((r) => ({ + kind: r.kind, + count: r._count._all, + active: activeMap.get(r.kind) ?? 0, + })), + }; + } + + /** Filter dropdown options, with counts so empty choices are visible. */ + async facets() { + // Kept as separate awaits rather than one $transaction: Prisma's groupBy + // result type is lost when the calls are widened into a promise array. + const kinds = await this.prisma.propertyService.groupBy({ + by: ["kind"], + _count: { _all: true }, + orderBy: { _count: { kind: "desc" } }, + }); + const municipalities = await this.prisma.propertyService.groupBy({ + by: ["notes"], + where: { kind: MUNICIPALITY_KIND, notes: { not: null } }, + _count: { _all: true }, + orderBy: { _count: { notes: "desc" } }, + }); + const banks = await this.prisma.trustAccount.groupBy({ + by: ["bankName"], + where: { bankName: { not: null } }, + _count: { _all: true }, + orderBy: { _count: { bankName: "desc" } }, + }); + + return { + kinds: kinds.map((k) => ({ kind: k.kind, count: k._count._all })), + municipalities: municipalities.map((m) => ({ + name: m.notes as string, + count: m._count._all, + })), + banks: banks.map((b) => ({ + name: b.bankName as string, + count: b._count._all, + })), + }; + } + + /** Full property view: services, trust, documents, owner and siblings. */ + async detail(id: string, days: number) { + const property = await this.prisma.property.findUnique({ + where: { id }, + include: { + customer: { + select: { + id: true, + name: true, + nameSource: true, + addressLine1: true, + city: true, + state: true, + phone: true, + mobile: true, + email: true, + _count: { select: { properties: true, policies: true } }, + }, + }, + services: { orderBy: { kind: "asc" } }, + trustAccount: true, + documents: true, + policy: { + select: { + id: true, + policyNumber: true, + policyTo: true, + policyType: { select: { name: true } }, + }, + }, + }, + }); + + if (!property) { + throw new NotFoundException(`Property ${id} not found`); + } + + // Other properties of the same owner, so staff can hop between them + // without going back through the customer file. + const siblings = await this.prisma.property.findMany({ + where: { customerId: property.customerId, id: { not: id } }, + orderBy: [{ addressLine1: "asc" }], + select: { + id: true, + addressLine1: true, + addressLine2: true, + zone: true, + _count: { select: { services: true } }, + }, + }); + + // Utility-domain ledger for the OWNER, not for this property: the legacy + // data ties payments to the customer, never to a specific property, so + // these are shown as the customer's service movements. + const transactions = await this.prisma.transaction.findMany({ + where: { customerId: property.customerId, domain: "UTILITY" }, + orderBy: { transactionDate: "desc" }, + take: 12, + include: { type: true }, + }); + const ledger = await this.prisma.transaction.groupBy({ + by: ["currency"], + where: { customerId: property.customerId, domain: "UTILITY" }, + _sum: { amount: true }, + _count: { _all: true }, + }); + + const from = today(); + const predial = property.services.find((s) => s.kind === MUNICIPALITY_KIND); + + return { + ...property, + municipality: predial?.notes ?? null, + trustStatus: trustStatusOf( + property.trustAccount?.dueDate2, + from, + addDays(from, days), + ), + daysToTrustDue: daysUntil(property.trustAccount?.dueDate2, from), + siblings: siblings.map((s) => ({ + id: s.id, + addressLine1: s.addressLine1, + addressLine2: s.addressLine2, + zone: s.zone, + serviceCount: s._count.services, + })), + customerTransactions: transactions, + customerLedger: ledger.map((l) => ({ + currency: l.currency, + total: l._sum.amount, + count: l._count._all, + })), + }; + } +} diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index 780405f..c276990 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -300,7 +300,11 @@ function PropertyCard({ p }: { p: Property }) { return (
-
{addr || "Propiedad"}
+
+ + {addr || "Propiedad"} + +
{p.zone && Zona: {p.zone}} {phones.length > 0 && ( diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 765fe7b..3d348ff 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1684,3 +1684,139 @@ button { .policy-num-link:hover { color: var(--brand-600); } + +/* ============================================================================ + Utilities — service mix strip (doubles as the service-kind filter) + ========================================================================== */ +.mix-strip { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 14px; + padding: 0 2px; +} +.mix-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 11px; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 999px; + cursor: pointer; + font: inherit; + color: inherit; + transition: border-color 0.15s, background 0.15s; +} +.mix-chip:hover { + border-color: var(--brand-600); +} +.mix-chip.selected { + border-color: var(--brand-600); + box-shadow: inset 0 0 0 1px var(--brand-600); +} +.mix-glyph { + font-size: 13px; + color: var(--brand-600); +} +.mix-body { + display: inline-flex; + align-items: baseline; + gap: 6px; +} +.mix-body strong { + font-family: var(--font-mono); + font-feature-settings: "tnum" 1; + font-size: 14px; + color: var(--brand-700); +} +.mix-label { + font-size: 12.5px; + color: var(--muted); +} +.mix-inactive { + font-size: 11px; + color: var(--muted); + border-left: 1px solid var(--line); + padding-left: 8px; +} + +/* ============================================================================ + Utilities — property list rows + ========================================================================== */ +.prop-row .cust-name { + flex-wrap: wrap; + gap: 9px; +} +.prop-side { + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 5px; + flex: 0 0 auto; +} +.svc-chips { + display: flex; + gap: 5px; + flex-wrap: wrap; + justify-content: flex-end; +} +.svc-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 7px; + background: var(--surface-2); + border: 1px solid var(--line); + font-size: 12px; + color: var(--brand-600); +} +.svc-chip.inactive { + opacity: 0.45; +} +@media (max-width: 620px) { + .prop-side { + align-items: flex-start; + text-align: left; + margin-top: 8px; + } + .svc-chips { + justify-content: flex-start; + } +} + +/* Sibling properties on the property detail are navigable. */ +.linked-prop.link { + display: block; + color: inherit; + text-decoration: none; + transition: color 0.15s; +} +.linked-prop.link:hover { + color: var(--brand-600); +} +.inline-link { + color: var(--brand-600); + text-decoration: none; + font-weight: 600; +} +.inline-link:hover { + text-decoration: underline; +} + +/* Visually hidden, still read by screen readers (service kind chips). */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/apps/web/src/app/polizas/[id]/page.tsx b/apps/web/src/app/polizas/[id]/page.tsx index cc2bf3a..e9a6ea4 100644 --- a/apps/web/src/app/polizas/[id]/page.tsx +++ b/apps/web/src/app/polizas/[id]/page.tsx @@ -199,11 +199,15 @@ function ClienteSection({ data }: { data: PolicyDetail }) {
Propiedades cubiertas
{data.properties.map((p) => ( -
+ {[p.addressLine1, p.addressLine2].filter(Boolean).join(", ") || "Propiedad"} {p.zone && · Zona {p.zone}} -
+ ))}
)} diff --git a/apps/web/src/app/servicios/[id]/page.tsx b/apps/web/src/app/servicios/[id]/page.tsx new file mode 100644 index 0000000..021c1d8 --- /dev/null +++ b/apps/web/src/app/servicios/[id]/page.tsx @@ -0,0 +1,566 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +import { getProperty } from "@/lib/api"; +import { + expiryPhrase, + formatDate, + formatMoney, + formatNumber, + serviceKindGlyph, + serviceKindLabel, + serviceNoteLabel, + SIN_NOMBRE, + trustStatusLabel, +} from "@/lib/labels"; +import type { PropertyDetail, Service, Transaction } from "@/lib/types"; + +export default function PropiedadDetailPage({ + params, +}: { + params: { id: string }; +}) { + // Next 14 passes `params` as a plain object here — no `use()` unwrapping. + const { id } = params; + return ( + + + + ); +} + +function Detail({ id }: { id: string }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + setLoading(true); + setError(null); + getProperty(id) + .then((d) => { + if (alive) { + setData(d); + setLoading(false); + } + }) + .catch((e) => { + if (alive) { + setError( + e?.status === 404 + ? "No encontramos esta propiedad." + : e?.message ?? "No se pudo cargar la propiedad.", + ); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [id]); + + if (loading) return ; + + if (error) + return ( + <> + +
+ {error} +
+ + ); + + if (!data) return null; + + return ( +
+ + + + + + {data.policy && } + + +
+ ); +} + +function BackLink() { + return ( + + ← Volver a Propiedades + + ); +} + +/* ------------------------------------------------------------------ Hero */ +function Hero({ data }: { data: PropertyDetail }) { + const addr = [data.addressLine1, data.addressLine2].filter(Boolean).join(", "); + const phones = [data.phone1, data.phone2, data.phone3].filter(Boolean); + const provenance = [data.legacySourceTable, data.legacyId] + .filter(Boolean) + .join(" #"); + const activos = data.services.filter((s) => s.active).length; + const phrase = expiryPhrase(data.daysToTrustDue); + + const facts: { label: string; value: string }[] = [ + { label: "Cliente", value: data.customer.name }, + { + label: "Servicios", + value: + data.services.length === 0 + ? "Ninguno" + : `${activos} activos de ${data.services.length}`, + }, + { label: "Municipio", value: data.municipality || "—" }, + { + label: "Fideicomiso", + value: data.trustAccount + ? formatDate(data.trustAccount.dueDate2) + : "Sin fideicomiso", + }, + { label: "Teléfonos", value: phones.join(" · ") || "—" }, + ]; + + return ( +
+
+
+

{addr || "Propiedad sin dirección"}

+
+ {data.zone ? `Zona ${data.zone} · ` : ""} + {data.customer.name} +
+ {provenance && ( +
Origen: {provenance}
+ )} +
+
+ {data.municipality && ( + + {data.municipality} + + )} + {data.trustAccount ? ( + + Fideicomiso · {trustStatusLabel(data.trustStatus)} + {phrase && data.trustStatus !== "expired" ? ` · ${phrase}` : ""} + + ) : ( + Sin fideicomiso + )} + {data.services.length === 0 && ( + Sin servicios + )} +
+
+
+ {facts.map((f) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+
+ ); +} + +/* -------------------------------------------------------------- Cliente */ +function ClienteSection({ data }: { data: PropertyDetail }) { + const c = data.customer; + const location = [c.city?.replace(/,\s*$/, ""), c.state] + .filter(Boolean) + .join(", "); + + return ( +
+ +
+ +
+
+ {c.name} +
+
+ {location && {location}} + {location && (c.phone || c.mobile || c.email) && ( + · + )} + {(c.phone || c.mobile) && {c.phone || c.mobile}} + {c.email && ( + <> + · + {c.email} + + )} + {c._count.policies > 0 && ( + <> + · + + {c._count.policies}{" "} + {c._count.policies === 1 ? "póliza" : "pólizas"} + + + )} +
+
+ Ver expediente → + + + {data.siblings.length > 0 && ( +
+
+ Otras propiedades de este cliente ({data.siblings.length}) +
+ {data.siblings.map((s) => ( + + {[s.addressLine1, s.addressLine2].filter(Boolean).join(", ") || + "Propiedad"} + + {s.zone ? ` · Zona ${s.zone}` : ""} · {s.serviceCount}{" "} + {s.serviceCount === 1 ? "servicio" : "servicios"} + + + ))} +
+ )} +
+
+ ); +} + +/* ------------------------------------------------------------- Servicios */ +function ServiciosSection({ data }: { data: PropertyDetail }) { + return ( +
+ +
+ {data.services.length === 0 ? ( +
+ Esta propiedad no tiene servicios registrados. +
+ ) : ( +
+ {data.services.map((s) => ( + + ))} +
+ )} +
+
+ ); +} + +function ServiceCard({ s }: { s: Service }) { + const noteLabel = serviceNoteLabel(s.kind); + return ( +
+
+ + + {serviceKindGlyph(s.kind)} + + {serviceKindLabel(s.kind)} + + {!s.active && Inactivo} +
+
+ {s.accountNumber && ( + + Cuenta: {s.accountNumber} + + )} + {s.meterNumber && ( + + Medidor: {s.meterNumber} + + )} + {s.route && ( + + Ruta: {s.route} + + )} + {s.dueDay && Día de pago: {s.dueDay}} + {s.notes && ( + + {noteLabel ? `${noteLabel}: ` : ""} + {s.notes} + + )} +
+
+ ); +} + +/* ---------------------------------------------------------- Fideicomiso */ +function FideicomisoSection({ data }: { data: PropertyDetail }) { + const t = data.trustAccount; + const phrase = expiryPhrase(data.daysToTrustDue); + + return ( +
+ +
+ {!t ? ( +
+ Esta propiedad no tiene fideicomiso registrado. +
+ ) : ( + <> +
+ + + + + + +
+
+ La comisión bancaria se cobra cada año en el próximo + vencimiento; el sistema anterior guardaba el par de fechas + (vence1 / vence2) del periodo en curso y del siguiente. +
+ + )} +
+
+ ); +} + +/* --------------------------------------------------------------- Póliza */ +function PolizaSection({ data }: { data: PropertyDetail }) { + const p = data.policy!; + return ( +
+ +
+ +
+
{p.policyNumber || "—"}
+
+ {p.policyType?.name && {p.policyType.name}} + {p.policyTo && ( + <> + · + Vence {formatDate(p.policyTo)} + + )} +
+
+ Ver póliza → + +
+
+ ); +} + +/* ---------------------------------------------------------- Movimientos */ +function MovimientosSection({ data }: { data: PropertyDetail }) { + return ( +
+ + + {data.customerLedger.length > 0 && ( +
+ {data.customerLedger.map((row) => ( +
+
+ + Servicios · {row.currency} +
+
+ {formatMoney(row.total, row.currency)} +
+
+ {formatNumber(row.count)}{" "} + {row.count === 1 ? "movimiento" : "movimientos"} +
+
+ ))} +
+ )} + +
+ {data.customerTransactions.length === 0 ? ( +
Sin movimientos de servicios.
+ ) : ( +
+ + + + + + + + + + + + {data.customerTransactions.map((t) => ( + + ))} + +
FechaTipoPeriodoReferenciaMonto
+
+ )} +
+ Los movimientos pertenecen al cliente, no a esta propiedad: el + sistema anterior nunca ligó un pago a una propiedad concreta. Ver el{" "} + + estado de cuenta completo + + . +
+
+
+ ); +} + +function TxRow({ t }: { t: Transaction }) { + const num = t.amount != null ? Number(t.amount) : NaN; + const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos"; + return ( + + + {formatDate(t.transactionDate)} + + {t.type?.nameEs || t.type?.nameEn || "—"} + {t.period || "—"} + {t.reference || "—"} + + + {formatMoney(t.amount, t.currency)} + {" "} + {t.currency} + + + ); +} + +/* ----------------------------------------------------------- Documentos */ +function DocumentosSection({ data }: { data: PropertyDetail }) { + return ( +
+ +
+ {data.documents.length === 0 ? ( +
+ No hay documentos registrados para esta propiedad. +
+ ) : ( + <> +
+ {data.documents.map((d, i) => ( +
+ + ▤ + +
+
{d.documentType || "Documento"}
+
{d.storageKey || "—"}
+
+
+ ))} +
+
+ Los archivos se almacenan en el object storage (storageKey); no se + descargan desde esta vista. +
+ + )} +
+
+ ); +} + +/* ------------------------------------------------------------ helpers */ +function KV({ + label, + value, +}: { + label: string; + value: string | null | undefined; +}) { + return ( +
+
{label}
+
{value || "—"}
+
+ ); +} + +function SectionHead({ + rule, + title, + count, + countSuffix, +}: { + rule: string; + title: string; + count?: number; + countSuffix?: string; +}) { + return ( +
+ +

{title}

+ {count != null && ( + + {count} {countSuffix ?? ""} + + )} +
+ ); +} + +function DetailSkeleton() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/servicios/page.tsx b/apps/web/src/app/servicios/page.tsx new file mode 100644 index 0000000..7911e17 --- /dev/null +++ b/apps/web/src/app/servicios/page.tsx @@ -0,0 +1,578 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +import { + EXPIRY_WINDOW_DAYS, + getPropertyFacets, + getPropertyStats, + listProperties, +} from "@/lib/api"; +import { + expiryPhrase, + formatDate, + formatNumber, + serviceKindGlyph, + serviceKindLabel, + SIN_NOMBRE, + trustStatusLabel, +} from "@/lib/labels"; +import type { + PropertyFacets, + PropertyListItem, + PropertyListResponse, + PropertySort, + PropertyStats, + ServiceKind, +} from "@/lib/types"; + +/** + * The buckets staff actually work from. Trust (fideicomiso) renewals are the + * recurring deadline in this line of business, so they get first-class filters + * next to the "nothing enrolled yet" bucket that flags incomplete records. + */ +type Focus = "all" | "trust" | "expiring" | "expired" | "no_services"; + +const FOCUS_FILTERS: { key: Focus; label: string }[] = [ + { key: "all", label: "Todas" }, + { key: "expiring", label: "Fideicomiso por vencer" }, + { key: "expired", label: "Fideicomiso vencido" }, + { key: "trust", label: "Con fideicomiso" }, + { key: "no_services", label: "Sin servicios" }, +]; + +const SORTS: { key: PropertySort; label: string }[] = [ + { key: "customer", label: "Cliente (A–Z)" }, + { key: "address", label: "Dirección (A–Z)" }, + { key: "services_desc", label: "Más servicios" }, + { + key: "trust_due_asc", + label: "Vencimiento de fideicomiso (solo con fideicomiso)", + }, + { + key: "trust_due_desc", + label: "Vencimiento más lejano (solo con fideicomiso)", + }, +]; + +/** Focus bucket → the query the API understands. */ +function focusQuery(focus: Focus) { + switch (focus) { + case "trust": + return { trust: "with" as const }; + case "expiring": + return { trust: "expiring" as const }; + case "expired": + return { trust: "expired" as const }; + case "no_services": + return { hasServices: false }; + default: + return {}; + } +} + +export default function ServiciosPage() { + return ( + + + + ); +} + +function ServiciosBrowser() { + const [stats, setStats] = useState(null); + const [facets, setFacets] = useState(null); + + const [query, setQuery] = useState(""); + const [focus, setFocus] = useState("all"); + const [serviceKind, setServiceKind] = useState(""); + const [municipality, setMunicipality] = useState(""); + const [bank, setBank] = useState(""); + const [sort, setSort] = useState("customer"); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const debounceRef = useRef>(); + + useEffect(() => { + getPropertyStats().then(setStats).catch(() => setStats(null)); + getPropertyFacets().then(setFacets).catch(() => setFacets(null)); + }, []); + + const runSearch = useCallback( + (p: number) => { + setLoading(true); + setError(null); + listProperties({ + query: query || undefined, + serviceKind: serviceKind || undefined, + municipality: municipality || undefined, + bank: bank || undefined, + sort, + days: EXPIRY_WINDOW_DAYS, + page: p, + pageSize: 25, + ...focusQuery(focus), + }) + .then((res) => { + setData(res); + setLoading(false); + }) + .catch((e) => { + setError(e?.message ?? "No se pudieron cargar las propiedades."); + setLoading(false); + }); + }, + [query, focus, serviceKind, municipality, bank, sort], + ); + + // Debounced re-query whenever any filter changes; always back to page 1. + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => runSearch(1), 280); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [runSearch]); + + /** Picking a renewal bucket also switches the sort to due-date order — + * a list of renewals sorted by customer name isn't a worklist. */ + function pickFocus(f: Focus) { + setFocus(f); + if (f === "expiring" || f === "expired") setSort("trust_due_asc"); + else if (sort === "trust_due_asc" || sort === "trust_due_desc") + setSort("customer"); + } + + function goToPage(p: number) { + runSearch(p); + if (typeof window !== "undefined") + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + const filtered = + query !== "" || + focus !== "all" || + serviceKind !== "" || + municipality !== "" || + bank !== ""; + + return ( + <> +
+

Administración de servicios

+

Propiedades

+ + setServiceKind(k === serviceKind ? "" : k)} + /> +
+ +
+
+ + ⌕ + + setQuery(e.target.value)} + placeholder="Buscar por dirección, cliente, cuenta, medidor, fideicomiso…" + aria-label="Buscar propiedades" + /> +
+
+ {FOCUS_FILTERS.map((f) => ( + + ))} +
+
+ +
+ + + + + + + + + {filtered && ( + + )} +
+ + {data && !loading && !error && ( +
+ {data.total === 0 + ? "Sin resultados" + : `${formatNumber(data.total)} ${ + data.total === 1 ? "propiedad" : "propiedades" + }`} + {query ? ` para “${query}”` : ""} + {(sort === "trust_due_asc" || sort === "trust_due_desc") && + " · solo propiedades con fideicomiso"} +
+ )} + + {error ? ( +
+ {error} +
+ ) : loading ? ( + + ) : data && data.items.length === 0 ? ( + + ) : ( + <> +
+ {data?.items.map((p) => ( + + ))} +
+ {data && data.pageCount > 1 && ( + + )} + + )} + + ); +} + +/** Counts double as filter shortcuts — clicking a cell applies that bucket. */ +function StatStrip({ + stats, + focus, + onPickFocus, +}: { + stats: PropertyStats | null; + focus: Focus; + onPickFocus: (f: Focus) => void; +}) { + if (!stats) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + const cells: { + key: Focus; + value: number; + label: string; + accent?: boolean; + }[] = [ + { key: "all", value: stats.properties, label: "Propiedades", accent: true }, + { + key: "expiring", + value: stats.trustExpiring, + label: `Fideicomisos en ${stats.days} días`, + accent: true, + }, + { key: "expired", value: stats.trustExpired, label: "Fideicomisos vencidos" }, + { key: "trust", value: stats.trusts, label: "Con fideicomiso" }, + { key: "no_services", value: stats.withoutServices, label: "Sin servicios" }, + ]; + + return ( +
+ {cells.map((c) => ( + + ))} +
+
{formatNumber(stats.services)}
+
Servicios · {formatNumber(stats.owners)} clientes
+
+
+ ); +} + +/** The monthly workload, per service type — also the fastest kind filter. */ +function ServiceMixStrip({ + stats, + serviceKind, + onPickKind, +}: { + stats: PropertyStats | null; + serviceKind: ServiceKind | ""; + onPickKind: (k: ServiceKind) => void; +}) { + if (!stats || stats.byKind.length === 0) return null; + return ( +
+ Servicios administrados + {stats.byKind.map((k) => ( + + ))} +
+ ); +} + +function PropertyRow({ p }: { p: PropertyListItem }) { + const addr = [p.addressLine1, p.addressLine2].filter(Boolean).join(", "); + const location = [p.customerCity?.replace(/,\s*$/, ""), p.customerState] + .filter(Boolean) + .join(", "); + const phrase = p.trust ? expiryPhrase(p.trust.daysToDue) : null; + + return ( + +
+
+ {addr || "Propiedad sin dirección"} + {p.municipality && ( + + {p.municipality} + + )} + {p.trust && ( + + Fideicomiso · {trustStatusLabel(p.trust.status)} + + )} + {p.serviceCount === 0 && ( + Sin servicios + )} +
+
+ + {p.customerName} + + {location && ( + <> + · + {location} + + )} + {p.zone && ( + <> + · + Zona {p.zone} + + )} + {p.phones.length > 0 && ( + <> + · + {p.phones[0]} + + )} +
+
+
+
+ {p.services.map((s) => ( + + {serviceKindGlyph(s.kind)} + {serviceKindLabel(s.kind)} + + ))} + {p.services.length === 0 && } +
+ {p.trust?.dueDate2 && ( + <> +
+ Vence {formatDate(p.trust.dueDate2)} +
+ {phrase && ( +
{phrase}
+ )} + + )} +
+ + ); +} + +function Pager({ + page, + pageCount, + onChange, +}: { + page: number; + pageCount: number; + onChange: (p: number) => void; +}) { + return ( + + ); +} + +function ListSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ); +} + +function EmptyState({ query }: { query: string }) { + return ( +
+
+ ⌕ +
+

Sin resultados

+

+ {query + ? `No encontramos propiedades para “${query}”.` + : "No hay propiedades que coincidan con los filtros."} +

+
+ ); +} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index aa12bed..d89a146 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -13,6 +13,7 @@ import type { AuthUser } from "@/lib/types"; */ const NAV = [ { href: "/clientes", label: "Clientes" }, + { href: "/servicios", label: "Propiedades" }, { href: "/polizas", label: "Pólizas" }, ]; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ca70f67..70403f9 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -13,6 +13,13 @@ import type { PolicySort, PolicyStats, PolicyStatus, + PropertyDetail, + PropertyFacets, + PropertyListResponse, + PropertySort, + PropertyStats, + ServiceKind, + TrustFilter, } from "./types"; export const API_ORIGIN = @@ -154,3 +161,54 @@ export function getPolicy( ): Promise { return apiFetch(`/policies/${id}?days=${days}`); } + +/* ----------------------------------------------------- Utilities module */ + +export interface PropertyQuery { + query?: string; + page?: number; + pageSize?: number; + serviceKind?: ServiceKind; + municipality?: string; + bank?: string; + trust?: TrustFilter; + hasServices?: boolean; + customerId?: string; + days?: number; + sort?: PropertySort; +} + +export function listProperties(q: PropertyQuery): Promise { + const params = new URLSearchParams(); + if (q.query) params.set("query", q.query); + if (q.page) params.set("page", String(q.page)); + if (q.pageSize) params.set("pageSize", String(q.pageSize)); + if (q.serviceKind) params.set("serviceKind", q.serviceKind); + if (q.municipality) params.set("municipality", q.municipality); + if (q.bank) params.set("bank", q.bank); + if (q.trust) params.set("trust", q.trust); + if (q.hasServices !== undefined) + params.set("hasServices", String(q.hasServices)); + if (q.customerId) params.set("customerId", q.customerId); + if (q.days) params.set("days", String(q.days)); + if (q.sort) params.set("sort", q.sort); + const qs = params.toString(); + return apiFetch(`/properties${qs ? `?${qs}` : ""}`); +} + +export function getPropertyStats( + days: number = EXPIRY_WINDOW_DAYS, +): Promise { + return apiFetch(`/properties/stats?days=${days}`); +} + +export function getPropertyFacets(): Promise { + return apiFetch("/properties/facets"); +} + +export function getProperty( + id: string, + days: number = EXPIRY_WINDOW_DAYS, +): Promise { + return apiFetch(`/properties/${id}?days=${days}`); +} diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index c8d7d9d..0f59cc7 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -1,6 +1,11 @@ // Spanish label maps + formatting helpers. Single source of truth for i18n. -import type { PolicyStatus, ServiceKind, TransactionDomain } from "./types"; +import type { + PolicyStatus, + ServiceKind, + TransactionDomain, + TrustStatus, +} from "./types"; /** * Placeholder the migration writes when a legacy record had no name and none @@ -49,6 +54,37 @@ export function serviceKindGlyph(kind: ServiceKind): string { return SERVICE_KIND_GLYPH[kind] ?? "•"; } +/** + * Free-text detail the migration parked in `PropertyService.notes`, which + * means something different per service kind: the municipality that bills the + * predial / zona federal, the CFE billing cycle (PAR/IMPAR), and the gas + * supply type. Used to label the note instead of dumping a bare string. + */ +export const SERVICE_NOTE_LABELS: Record = { + PROPERTY_TAX: "Municipio", + FEDERAL_ZONE: "Municipio", + ELECTRIC: "Ciclo", + GAS: "Suministro", + CABLE: "Proveedor", +}; + +export function serviceNoteLabel(kind: ServiceKind): string | null { + return SERVICE_NOTE_LABELS[kind] ?? null; +} + +// ----- fideicomisos (trusts) ----- + +export const TRUST_STATUS_LABELS: Record = { + active: "Vigente", + expiring: "Por vencer", + expired: "Vencido", + undated: "Sin fecha", +}; + +export function trustStatusLabel(status: TrustStatus): string { + return TRUST_STATUS_LABELS[status] ?? status; +} + // ----- policies ----- export const POLICY_STATUS_LABELS: Record = { diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index a3acf2d..ecfc92e 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -317,6 +317,132 @@ export interface PolicyDetail { }[]; } +/* ----------------------------------------------------- Utilities module */ + +/** + * Trust (fideicomiso) renewal bucket computed by the API from the trust's + * `dueDate2` — the *next* annual due date. "undated" is a real bucket: a + * handful of migrated trusts carry no dates at all. + */ +export type TrustStatus = "active" | "expiring" | "expired" | "undated"; + +/** `with`/`without` filter the whole property set; the rest are trust buckets. */ +export type TrustFilter = "with" | "without" | TrustStatus; + +export type PropertySort = + | "customer" + | "address" + | "services_desc" + | "trust_due_asc" + | "trust_due_desc"; + +export interface TrustSummary { + bankName: string | null; + trustNumber: string | null; + bankFee: string | null; + dueDate1: string | null; + dueDate2: string | null; + status: TrustStatus; + /** Days until `dueDate2`; negative when overdue, null when undated. */ + daysToDue: number | null; +} + +export interface PropertyListItem { + id: string; + addressLine1: string | null; + addressLine2: string | null; + zone: string | null; + phones: string[]; + customerId: string; + customerName: string; + customerCity: string | null; + customerState: string | null; + /** Municipality, read off the predial service's notes. */ + municipality: string | null; + services: { id: string; kind: ServiceKind; active: boolean }[]; + serviceCount: number; + activeServiceCount: number; + documentCount: number; + trust: TrustSummary | null; +} + +export interface PropertyListResponse { + items: PropertyListItem[]; + total: number; + page: number; + pageSize: number; + pageCount: number; +} + +export interface PropertyStats { + properties: number; + owners: number; + services: number; + withoutServices: number; + trusts: number; + trustExpiring: number; + trustExpired: number; + documents: number; + days: number; + byKind: { kind: ServiceKind; count: number; active: number }[]; +} + +export interface PropertyFacets { + kinds: { kind: ServiceKind; count: number }[]; + municipalities: { name: string; count: number }[]; + banks: { name: string; count: number }[]; +} + +export interface PropertyOwnerRef { + id: string; + name: string; + nameSource: string | null; + addressLine1: string | null; + city: string | null; + state: string | null; + phone: string | null; + mobile: string | null; + email: string | null; + _count: { properties: number; policies: number }; +} + +export interface PropertyDetail { + id: string; + customerId: string; + addressLine1: string | null; + addressLine2: string | null; + phone1: string | null; + phone2: string | null; + phone3: string | null; + zone: string | null; + legacySourceTable: string | null; + legacyId: string | null; + customer: PropertyOwnerRef; + services: Service[]; + trustAccount: (TrustAccount & { propertyId?: string }) | null; + documents: DocumentRef[]; + policy: { + id: string; + policyNumber: string | null; + policyTo: string | null; + policyType: { name: string } | null; + } | null; + municipality: string | null; + trustStatus: TrustStatus; + daysToTrustDue: number | null; + siblings: { + id: string; + addressLine1: string | null; + addressLine2: string | null; + zone: string | null; + serviceCount: number; + }[]; + /** Owner-level utility movements — the legacy data never tied a payment to + * a specific property, so these belong to the customer, not to this file. */ + customerTransactions: Transaction[]; + customerLedger: { currency: string; total: string | null; count: number }[]; +} + export type TransactionDomain = "UTILITY" | "INSURANCE" | "TRUST" | string; export interface TransactionType {