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:
@@ -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 <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.
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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