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:
@@ -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<PolicyDetail> {
|
||||
return apiFetch<PolicyDetail>(`/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<PropertyListResponse> {
|
||||
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<PropertyListResponse>(`/properties${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getPropertyStats(
|
||||
days: number = EXPIRY_WINDOW_DAYS,
|
||||
): Promise<PropertyStats> {
|
||||
return apiFetch<PropertyStats>(`/properties/stats?days=${days}`);
|
||||
}
|
||||
|
||||
export function getPropertyFacets(): Promise<PropertyFacets> {
|
||||
return apiFetch<PropertyFacets>("/properties/facets");
|
||||
}
|
||||
|
||||
export function getProperty(
|
||||
id: string,
|
||||
days: number = EXPIRY_WINDOW_DAYS,
|
||||
): Promise<PropertyDetail> {
|
||||
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<TrustStatus, string> = {
|
||||
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<PolicyStatus, string> = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user