Utilities module: property browser (list/search/detail) + trust renewals

Plan step 5. Properties, services and trust accounts become a first-class
browser the way /polizas is for insurance.

API (apps/api/src/properties):
  GET /properties         search over address, customer, service account
                          number, meter, trust number and phones; filters for
                          service kind, municipality, trust bank, trust bucket
                          (with|without|active|expiring|expired|undated) and
                          hasServices; 5 sorts
  GET /properties/stats   properties/owners/services/trusts, renewal counts,
                          service mix per kind
  GET /properties/facets  kinds, municipalities, banks — all with counts
  GET /properties/:id     services, fideicomiso, linked policy, owner and
                          sibling properties, owner-level utility ledger

Web: /servicios (renewals-first browser, clickable stat cells and service-mix
strip) and /servicios/[id]. Property cards on /clientes/[id] and linked
properties on /polizas/[id] now navigate into it.

Data findings baked into the design:
  - The trust deadline staff chase is trust_accounts.dueDate2 (DATMEX vence2),
    one year after vence1 on 531 of 541 dated trusts: 18 due within 30 days,
    119 already overdue. Every renewal bucket keys off dueDate2 alone.
  - properties.zone is dead (1444 of 1519 null, the rest near-unique), so the
    geographic filter is the municipality carried in the predial service's
    notes (ROSARITO 566 / TIJUANA 221 / ENSENADA 152, 939/939 populated).
  - PropertyService.notes means a different thing per kind (municipality, CFE
    PAR/IMPAR cycle, gas supply type, cable provider) and is labelled as such.
  - 240 of 1519 properties have no service rows at all — its own bucket.

Sorting by trust due date scopes to properties that have a trust, since MySQL
would otherwise float the ~966 trust-less NULLs above every real due date;
the sort label and the result meta both say so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 22:04:07 -07:00
co-authored by Claude Opus 4.8
parent c291bc8d4c
commit 61193586a5
14 changed files with 2086 additions and 7 deletions
@@ -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));
}
}