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
+2
View File
@@ -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],
})
@@ -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));
}
}
@@ -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 {}
@@ -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,
})),
};
}
}
+5 -1
View File
@@ -300,7 +300,11 @@ function PropertyCard({ p }: { p: Property }) {
return (
<div className="prop-card">
<div className="prop-addr">{addr || "Propiedad"}</div>
<div className="prop-addr">
<Link href={`/servicios/${p.id}`} className="policy-num-link">
{addr || "Propiedad"}
</Link>
</div>
<div className="prop-meta">
{p.zone && <span>Zona: {p.zone}</span>}
{phones.length > 0 && (
+136
View File
@@ -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;
}
+6 -2
View File
@@ -199,11 +199,15 @@ function ClienteSection({ data }: { data: PolicyDetail }) {
<div className="linked-props">
<div className="kv-label">Propiedades cubiertas</div>
{data.properties.map((p) => (
<div key={p.id} className="linked-prop">
<Link
key={p.id}
href={`/servicios/${p.id}`}
className="linked-prop link"
>
{[p.addressLine1, p.addressLine2].filter(Boolean).join(", ") ||
"Propiedad"}
{p.zone && <span className="muted"> · Zona {p.zone}</span>}
</div>
</Link>
))}
</div>
)}
+566
View File
@@ -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 (
<AppShell>
<Detail id={id} />
</AppShell>
);
}
function Detail({ id }: { id: string }) {
const [data, setData] = useState<PropertyDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <DetailSkeleton />;
if (error)
return (
<>
<BackLink />
<div className="state-error" role="alert">
{error}
</div>
</>
);
if (!data) return null;
return (
<div className="rise">
<BackLink />
<Hero data={data} />
<ClienteSection data={data} />
<ServiciosSection data={data} />
<FideicomisoSection data={data} />
{data.policy && <PolizaSection data={data} />}
<MovimientosSection data={data} />
<DocumentosSection data={data} />
</div>
);
}
function BackLink() {
return (
<Link href="/servicios" className="back-link">
Volver a Propiedades
</Link>
);
}
/* ------------------------------------------------------------------ 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 (
<div className="detail-hero">
<div className="hero-top">
<div>
<h1 className="hero-name">{addr || "Propiedad sin dirección"}</h1>
<div className="hero-provenance">
{data.zone ? `Zona ${data.zone} · ` : ""}
{data.customer.name}
</div>
{provenance && (
<div className="hero-provenance">Origen: {provenance}</div>
)}
</div>
<div className="hero-badges">
{data.municipality && (
<span className="badge badge-servicios">
<span className="dot" /> {data.municipality}
</span>
)}
{data.trustAccount ? (
<span className={`badge status-${data.trustStatus}`}>
Fideicomiso · {trustStatusLabel(data.trustStatus)}
{phrase && data.trustStatus !== "expired" ? ` · ${phrase}` : ""}
</span>
) : (
<span className="badge badge-on-dark">Sin fideicomiso</span>
)}
{data.services.length === 0 && (
<span className="badge badge-neutral">Sin servicios</span>
)}
</div>
</div>
<div className="hero-facts">
{facts.map((f) => (
<div key={f.label}>
<div className="hero-fact-label">{f.label}</div>
<div className="hero-fact-value">{f.value}</div>
</div>
))}
</div>
</div>
);
}
/* -------------------------------------------------------------- Cliente */
function ClienteSection({ data }: { data: PropertyDetail }) {
const c = data.customer;
const location = [c.city?.replace(/,\s*$/, ""), c.state]
.filter(Boolean)
.join(", ");
return (
<section className="section">
<SectionHead rule="datos" title="Cliente" />
<div className="card">
<Link href={`/clientes/${c.id}`} className="owner-link">
<div>
<div
className={`owner-name${
c.name === SIN_NOMBRE ? " cust-name-missing" : ""
}`}
>
{c.name}
</div>
<div className="cust-sub">
{location && <span>{location}</span>}
{location && (c.phone || c.mobile || c.email) && (
<span className="sep">·</span>
)}
{(c.phone || c.mobile) && <span>{c.phone || c.mobile}</span>}
{c.email && (
<>
<span className="sep">·</span>
<span>{c.email}</span>
</>
)}
{c._count.policies > 0 && (
<>
<span className="sep">·</span>
<span>
{c._count.policies}{" "}
{c._count.policies === 1 ? "póliza" : "pólizas"}
</span>
</>
)}
</div>
</div>
<span className="owner-cta">Ver expediente </span>
</Link>
{data.siblings.length > 0 && (
<div className="linked-props">
<div className="kv-label">
Otras propiedades de este cliente ({data.siblings.length})
</div>
{data.siblings.map((s) => (
<Link key={s.id} href={`/servicios/${s.id}`} className="linked-prop link">
{[s.addressLine1, s.addressLine2].filter(Boolean).join(", ") ||
"Propiedad"}
<span className="muted">
{s.zone ? ` · Zona ${s.zone}` : ""} · {s.serviceCount}{" "}
{s.serviceCount === 1 ? "servicio" : "servicios"}
</span>
</Link>
))}
</div>
)}
</div>
</section>
);
}
/* ------------------------------------------------------------- Servicios */
function ServiciosSection({ data }: { data: PropertyDetail }) {
return (
<section className="section">
<SectionHead
rule="servicios"
title="Servicios"
count={data.services.length}
/>
<div className="card">
{data.services.length === 0 ? (
<div className="empty-inline">
Esta propiedad no tiene servicios registrados.
</div>
) : (
<div className="svc-grid" style={{ padding: 16 }}>
{data.services.map((s) => (
<ServiceCard key={s.id} s={s} />
))}
</div>
)}
</div>
</section>
);
}
function ServiceCard({ s }: { s: Service }) {
const noteLabel = serviceNoteLabel(s.kind);
return (
<div className={`svc-item${s.active ? "" : " inactive"}`}>
<div className="svc-head">
<span className="svc-kind">
<span className="svc-glyph" aria-hidden>
{serviceKindGlyph(s.kind)}
</span>
{serviceKindLabel(s.kind)}
</span>
{!s.active && <span className="badge badge-neutral">Inactivo</span>}
</div>
<div className="svc-detail">
{s.accountNumber && (
<span>
Cuenta: <span className="mono">{s.accountNumber}</span>
</span>
)}
{s.meterNumber && (
<span>
Medidor: <span className="mono">{s.meterNumber}</span>
</span>
)}
{s.route && (
<span>
Ruta: <span className="mono">{s.route}</span>
</span>
)}
{s.dueDay && <span>Día de pago: {s.dueDay}</span>}
{s.notes && (
<span>
{noteLabel ? `${noteLabel}: ` : ""}
{s.notes}
</span>
)}
</div>
</div>
);
}
/* ---------------------------------------------------------- Fideicomiso */
function FideicomisoSection({ data }: { data: PropertyDetail }) {
const t = data.trustAccount;
const phrase = expiryPhrase(data.daysToTrustDue);
return (
<section className="section">
<SectionHead rule="cuenta" title="Fideicomiso" />
<div className="card">
{!t ? (
<div className="empty-inline">
Esta propiedad no tiene fideicomiso registrado.
</div>
) : (
<>
<div className="kv-grid">
<KV label="Banco" value={t.bankName} />
<KV label="Número de fideicomiso" value={t.trustNumber} />
<KV
label="Comisión anual"
value={formatMoney(t.bankFee, "MXN")}
/>
<KV label="Vigencia desde" value={formatDate(t.dueDate1)} />
<KV
label="Próximo vencimiento"
value={
t.dueDate2
? `${formatDate(t.dueDate2)}${phrase ? ` · ${phrase}` : ""}`
: null
}
/>
<KV
label="Estado"
value={trustStatusLabel(data.trustStatus)}
/>
</div>
<div className="section-note" style={{ padding: "0 22px 18px" }}>
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.
</div>
</>
)}
</div>
</section>
);
}
/* --------------------------------------------------------------- Póliza */
function PolizaSection({ data }: { data: PropertyDetail }) {
const p = data.policy!;
return (
<section className="section">
<SectionHead rule="seguros" title="Póliza vinculada" />
<div className="card">
<Link href={`/polizas/${p.id}`} className="owner-link">
<div>
<div className="owner-name mono">{p.policyNumber || "—"}</div>
<div className="cust-sub">
{p.policyType?.name && <span>{p.policyType.name}</span>}
{p.policyTo && (
<>
<span className="sep">·</span>
<span>Vence {formatDate(p.policyTo)}</span>
</>
)}
</div>
</div>
<span className="owner-cta">Ver póliza </span>
</Link>
</div>
</section>
);
}
/* ---------------------------------------------------------- Movimientos */
function MovimientosSection({ data }: { data: PropertyDetail }) {
return (
<section className="section">
<SectionHead
rule="cuenta"
title="Movimientos de servicios"
count={data.customerTransactions.length}
countSuffix="recientes"
/>
{data.customerLedger.length > 0 && (
<div className="summary-grid">
{data.customerLedger.map((row) => (
<div className="summary-card UTILITY" key={row.currency}>
<div className="summary-domain">
<span className="tx-dot UTILITY" />
Servicios · {row.currency}
</div>
<div className="summary-total">
{formatMoney(row.total, row.currency)}
</div>
<div className="summary-count">
{formatNumber(row.count)}{" "}
{row.count === 1 ? "movimiento" : "movimientos"}
</div>
</div>
))}
</div>
)}
<div className="card">
{data.customerTransactions.length === 0 ? (
<div className="empty-inline">Sin movimientos de servicios.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Periodo</th>
<th>Referencia</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{data.customerTransactions.map((t) => (
<TxRow key={t.id} t={t} />
))}
</tbody>
</table>
</div>
)}
<div className="section-note" style={{ padding: "0 16px 14px" }}>
Los movimientos pertenecen al cliente, no a esta propiedad: el
sistema anterior nunca ligó un pago a una propiedad concreta. Ver el{" "}
<Link href={`/clientes/${data.customerId}`} className="inline-link">
estado de cuenta completo
</Link>
.
</div>
</div>
</section>
);
}
function TxRow({ t }: { t: Transaction }) {
const num = t.amount != null ? Number(t.amount) : NaN;
const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos";
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(t.transactionDate)}
</td>
<td>{t.type?.nameEs || t.type?.nameEn || "—"}</td>
<td>{t.period || "—"}</td>
<td className="tx-ref">{t.reference || "—"}</td>
<td className="num">
<span className={`tx-amount ${sign}`}>
{formatMoney(t.amount, t.currency)}
</span>{" "}
<span className="tx-cur">{t.currency}</span>
</td>
</tr>
);
}
/* ----------------------------------------------------------- Documentos */
function DocumentosSection({ data }: { data: PropertyDetail }) {
return (
<section className="section">
<SectionHead rule="docs" title="Documentos" count={data.documents.length} />
<div className="card">
{data.documents.length === 0 ? (
<div className="empty-inline">
No hay documentos registrados para esta propiedad.
</div>
) : (
<>
<div className="doc-list">
{data.documents.map((d, i) => (
<div className="doc-item" key={d.id ?? i}>
<span className="doc-icon" aria-hidden>
</span>
<div style={{ minWidth: 0 }}>
<div className="doc-type">{d.documentType || "Documento"}</div>
<div className="doc-key">{d.storageKey || "—"}</div>
</div>
</div>
))}
</div>
<div className="section-note" style={{ padding: "0 22px 18px" }}>
Los archivos se almacenan en el object storage (storageKey); no se
descargan desde esta vista.
</div>
</>
)}
</div>
</section>
);
}
/* ------------------------------------------------------------ helpers */
function KV({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
return (
<div>
<div className="kv-label">{label}</div>
<div className="kv-value">{value || "—"}</div>
</div>
);
}
function SectionHead({
rule,
title,
count,
countSuffix,
}: {
rule: string;
title: string;
count?: number;
countSuffix?: string;
}) {
return (
<div className="section-head">
<span className={`section-rule ${rule}`} aria-hidden />
<h2 className="section-title">{title}</h2>
{count != null && (
<span className="section-count">
{count} {countSuffix ?? ""}
</span>
)}
</div>
);
}
function DetailSkeleton() {
return (
<div>
<div
className="skeleton"
style={{ height: 16, width: 160, marginBottom: 18 }}
/>
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
<div
className="skeleton"
style={{ height: 160, borderRadius: 16, marginTop: 34 }}
/>
<div
className="skeleton"
style={{ height: 240, borderRadius: 16, marginTop: 34 }}
/>
</div>
);
}
+578
View File
@@ -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 (AZ)" },
{ key: "address", label: "Dirección (AZ)" },
{ 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 (
<AppShell>
<ServiciosBrowser />
</AppShell>
);
}
function ServiciosBrowser() {
const [stats, setStats] = useState<PropertyStats | null>(null);
const [facets, setFacets] = useState<PropertyFacets | null>(null);
const [query, setQuery] = useState("");
const [focus, setFocus] = useState<Focus>("all");
const [serviceKind, setServiceKind] = useState<ServiceKind | "">("");
const [municipality, setMunicipality] = useState("");
const [bank, setBank] = useState("");
const [sort, setSort] = useState<PropertySort>("customer");
const [data, setData] = useState<PropertyListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
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 (
<>
<div className="page-head rise">
<p className="eyebrow">Administración de servicios</p>
<h1 className="page-title">Propiedades</h1>
<StatStrip stats={stats} focus={focus} onPickFocus={pickFocus} />
<ServiceMixStrip
stats={stats}
serviceKind={serviceKind}
onPickKind={(k) => setServiceKind(k === serviceKind ? "" : k)}
/>
</div>
<div className="toolbar">
<div className="search-box">
<span className="search-icon" aria-hidden>
</span>
<input
className="input search-input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar por dirección, cliente, cuenta, medidor, fideicomiso…"
aria-label="Buscar propiedades"
/>
</div>
<div className="seg" role="tablist" aria-label="Filtrar propiedades">
{FOCUS_FILTERS.map((f) => (
<button
key={f.key}
type="button"
role="tab"
aria-selected={focus === f.key}
className={`seg-btn ${focus === f.key ? "active" : ""}`}
onClick={() => pickFocus(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Servicio</span>
<select
className="input select"
value={serviceKind}
onChange={(e) => setServiceKind(e.target.value as ServiceKind | "")}
>
<option value="">Todos los servicios</option>
{facets?.kinds.map((k) => (
<option key={k.kind} value={k.kind}>
{serviceKindLabel(k.kind)} ({formatNumber(k.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Municipio</span>
<select
className="input select"
value={municipality}
onChange={(e) => setMunicipality(e.target.value)}
>
<option value="">Todos los municipios</option>
{facets?.municipalities.map((m) => (
<option key={m.name} value={m.name}>
{m.name} ({formatNumber(m.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Banco (fideicomiso)</span>
<select
className="input select"
value={bank}
onChange={(e) => setBank(e.target.value)}
>
<option value="">Todos los bancos</option>
{facets?.banks.map((b) => (
<option key={b.name} value={b.name}>
{b.name} ({formatNumber(b.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Ordenar por</span>
<select
className="input select"
value={sort}
onChange={(e) => setSort(e.target.value as PropertySort)}
>
{SORTS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</label>
{filtered && (
<button
type="button"
className="btn btn-ghost filter-clear"
onClick={() => {
setQuery("");
setFocus("all");
setServiceKind("");
setMunicipality("");
setBank("");
setSort("customer");
}}
>
Limpiar filtros
</button>
)}
</div>
{data && !loading && !error && (
<div className="result-meta" aria-live="polite">
{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"}
</div>
)}
{error ? (
<div className="state-error" role="alert">
{error}
</div>
) : loading ? (
<ListSkeleton />
) : data && data.items.length === 0 ? (
<EmptyState query={query} />
) : (
<>
<div className="cust-list">
{data?.items.map((p) => (
<PropertyRow key={p.id} p={p} />
))}
</div>
{data && data.pageCount > 1 && (
<Pager
page={data.page}
pageCount={data.pageCount}
onChange={goToPage}
/>
)}
</>
)}
</>
);
}
/** 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 (
<div className="stat-strip" aria-hidden>
{Array.from({ length: 6 }).map((_, i) => (
<div className="stat-cell" key={i}>
<div className="skeleton" style={{ height: 25, width: "60%" }} />
<div
className="skeleton"
style={{ height: 11, width: "80%", marginTop: 8 }}
/>
</div>
))}
</div>
);
}
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 (
<div className="stat-strip">
{cells.map((c) => (
<button
type="button"
key={c.label}
className={`stat-cell stat-cell-btn${c.accent ? " accent" : ""}${
focus === c.key ? " selected" : ""
}`}
onClick={() => onPickFocus(c.key)}
aria-pressed={focus === c.key}
>
<div className="stat-value">{formatNumber(c.value)}</div>
<div className="stat-label">{c.label}</div>
</button>
))}
<div className="stat-cell">
<div className="stat-value">{formatNumber(stats.services)}</div>
<div className="stat-label">Servicios · {formatNumber(stats.owners)} clientes</div>
</div>
</div>
);
}
/** 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 (
<div className="mix-strip">
<span className="premium-caption">Servicios administrados</span>
{stats.byKind.map((k) => (
<button
type="button"
key={k.kind}
className={`mix-chip${serviceKind === k.kind ? " selected" : ""}`}
onClick={() => onPickKind(k.kind)}
aria-pressed={serviceKind === k.kind}
>
<span className="mix-glyph" aria-hidden>
{serviceKindGlyph(k.kind)}
</span>
<span className="mix-body">
<strong>{formatNumber(k.count)}</strong>
<span className="mix-label">{serviceKindLabel(k.kind)}</span>
</span>
{k.active < k.count && (
<span className="mix-inactive">
{formatNumber(k.count - k.active)} inactivos
</span>
)}
</button>
))}
</div>
);
}
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 (
<Link href={`/servicios/${p.id}`} className="cust-row prop-row">
<div className="cust-main">
<div className="cust-name">
<span>{addr || "Propiedad sin dirección"}</span>
{p.municipality && (
<span className="badge badge-servicios">
<span className="dot" /> {p.municipality}
</span>
)}
{p.trust && (
<span className={`badge status-${p.trust.status}`}>
Fideicomiso · {trustStatusLabel(p.trust.status)}
</span>
)}
{p.serviceCount === 0 && (
<span className="badge badge-neutral">Sin servicios</span>
)}
</div>
<div className="cust-sub">
<span
className={
p.customerName === SIN_NOMBRE ? "cust-name-missing" : undefined
}
>
{p.customerName}
</span>
{location && (
<>
<span className="sep">·</span>
<span>{location}</span>
</>
)}
{p.zone && (
<>
<span className="sep">·</span>
<span>Zona {p.zone}</span>
</>
)}
{p.phones.length > 0 && (
<>
<span className="sep">·</span>
<span className="mono">{p.phones[0]}</span>
</>
)}
</div>
</div>
<div className="prop-side">
<div className="svc-chips">
{p.services.map((s) => (
<span
key={s.id}
className={`svc-chip${s.active ? "" : " inactive"}`}
title={`${serviceKindLabel(s.kind)}${s.active ? "" : " (inactivo)"}`}
>
<span aria-hidden>{serviceKindGlyph(s.kind)}</span>
<span className="sr-only">{serviceKindLabel(s.kind)}</span>
</span>
))}
{p.services.length === 0 && <span className="muted"></span>}
</div>
{p.trust?.dueDate2 && (
<>
<div className="pol-dates mono">
Vence {formatDate(p.trust.dueDate2)}
</div>
{phrase && (
<div className={`pol-phrase ${p.trust.status}`}>{phrase}</div>
)}
</>
)}
</div>
</Link>
);
}
function Pager({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (p: number) => void;
}) {
return (
<nav className="pager" aria-label="Paginación">
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page - 1)}
disabled={page <= 1}
>
Anterior
</button>
<span className="pager-info">
Página <strong>{page}</strong> de {pageCount}
</span>
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page + 1)}
disabled={page >= pageCount}
>
Siguiente
</button>
</nav>
);
}
function ListSkeleton() {
return (
<div className="cust-list" aria-hidden>
{Array.from({ length: 8 }).map((_, i) => (
<div className="skeleton skel-row" key={i} />
))}
</div>
);
}
function EmptyState({ query }: { query: string }) {
return (
<div className="state-box">
<div className="state-glyph" aria-hidden>
</div>
<h3>Sin resultados</h3>
<p>
{query
? `No encontramos propiedades para “${query}”.`
: "No hay propiedades que coincidan con los filtros."}
</p>
</div>
);
}
+1
View File
@@ -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" },
];
+58
View File
@@ -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}`);
}
+37 -1
View File
@@ -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> = {
+126
View File
@@ -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 {