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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user