The schema has carried `storageKey` pointers and the migration has written blobs to MinIO since day one, but the API had no S3 client — documents could only be deleted, never uploaded or retrieved. This adds the missing wiring. API - StorageModule/StorageService (@aws-sdk/client-s3, path-style for MinIO): put/getStream/delete, best-effort bucket ensure on boot, gracefully disabled when S3 env is absent (ServiceUnavailable on use). - Reads S3_ENDPOINT/S3_BUCKET + S3_ACCESS_KEY/S3_SECRET_KEY, falling back to MINIO_ROOT_USER/MINIO_ROOT_PASSWORD so one credential set drives both the migration and the API. - Property service documents: POST :id/documents (multipart), GET :id/documents/:childId/download (streamed), delete now also drops the blob. - Policy documents: same upload/download/delete (previously had none). - Keys stay under the service/<id>/… and policy/<id>/… prefixes the migration established. Web - api.ts: shared uploadFile() helper (uploadIngest refactored onto it), upload/download/remove helpers for property & policy documents. - Servicios, polizas, clientes detail pages: real Descargar links and an upload control (gated by policy:update / property:update) replacing the "storage pending" notes. Infra - docker-compose: minio service (9000/9001, healthcheck, named volume) + S3 env wired into the api service. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
890 lines
27 KiB
TypeScript
890 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import {
|
|
addService,
|
|
archiveProperty,
|
|
getProperty,
|
|
propertyDocumentDownloadUrl,
|
|
removePropertyDocument,
|
|
removeService,
|
|
removeTrust,
|
|
restoreProperty,
|
|
updateService,
|
|
uploadPropertyDocument,
|
|
upsertTrust,
|
|
} from "@/lib/api";
|
|
import { useCan } from "@/lib/abilities";
|
|
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
|
|
import {
|
|
expiryPhrase,
|
|
formatDate,
|
|
formatMoney,
|
|
formatNumber,
|
|
SERVICE_KIND_LABELS,
|
|
serviceKindGlyph,
|
|
serviceKindLabel,
|
|
serviceNoteLabel,
|
|
SIN_NOMBRE,
|
|
trustStatusLabel,
|
|
} from "@/lib/labels";
|
|
import type { PropertyDetail, Service, Transaction, TrustInput } 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;
|
|
|
|
const reload = () => getProperty(id).then(setData).catch(() => {});
|
|
|
|
return (
|
|
<div className="rise">
|
|
<div className="detail-actionbar">
|
|
<BackLink />
|
|
<PropertyActions data={data} onChange={reload} />
|
|
</div>
|
|
<Hero data={data} />
|
|
<ClienteSection data={data} />
|
|
<ServiciosSection data={data} />
|
|
<FideicomisoSection data={data} />
|
|
{data.policy && <PolizaSection data={data} />}
|
|
<MovimientosSection data={data} />
|
|
<DocumentosSection data={data} />
|
|
<PropertyEditor data={data} onChange={reload} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Edit / archive controls for the property header. */
|
|
function PropertyActions({
|
|
data,
|
|
onChange,
|
|
}: {
|
|
data: PropertyDetail;
|
|
onChange: () => void;
|
|
}) {
|
|
const canEdit = useCan("property:update");
|
|
const canDelete = useCan("property:delete");
|
|
const [busy, setBusy] = useState(false);
|
|
const archived = data.archivedAt != null;
|
|
|
|
async function toggle() {
|
|
const verb = archived ? "restaurar" : "archivar";
|
|
if (!window.confirm(`¿Seguro que desea ${verb} esta propiedad?`)) return;
|
|
setBusy(true);
|
|
try {
|
|
if (archived) await restoreProperty(data.id);
|
|
else await archiveProperty(data.id);
|
|
onChange();
|
|
} catch (e) {
|
|
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
if (!canEdit && !canDelete) return null;
|
|
return (
|
|
<div className="row-actions">
|
|
{archived && <span className="badge badge-negative">Archivada</span>}
|
|
{canEdit && (
|
|
<Link href={`/servicios/${data.id}/editar`} className="btn btn-outline">
|
|
Editar
|
|
</Link>
|
|
)}
|
|
{canDelete && (
|
|
<button type="button" className="btn btn-ghost" onClick={toggle} disabled={busy}>
|
|
{archived ? "Restaurar" : "Archivar"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Editable services + trust + documents — only for users who can edit. */
|
|
function PropertyEditor({
|
|
data,
|
|
onChange,
|
|
}: {
|
|
data: PropertyDetail;
|
|
onChange: () => void;
|
|
}) {
|
|
const canEdit = useCan("property:update");
|
|
if (!canEdit) return null;
|
|
|
|
const SERVICES: ChildConfig = {
|
|
apiKind: "services",
|
|
title: "Servicios",
|
|
fields: [
|
|
{
|
|
key: "kind",
|
|
label: "Tipo",
|
|
type: "select",
|
|
options: Object.entries(SERVICE_KIND_LABELS).map(([value, label]) => ({
|
|
value,
|
|
label,
|
|
})),
|
|
},
|
|
{ key: "accountNumber", label: "Cuenta" },
|
|
{ key: "meterNumber", label: "Medidor" },
|
|
{ key: "route", label: "Ruta" },
|
|
{ key: "dueDay", label: "Día pago" },
|
|
{ key: "notes", label: "Notas" },
|
|
{ key: "active", label: "Activo", type: "checkbox" },
|
|
],
|
|
};
|
|
|
|
return (
|
|
<section className="section">
|
|
<div className="section-head">
|
|
<span className="section-rule cuenta" aria-hidden />
|
|
<h2 className="section-title">Administrar propiedad</h2>
|
|
</div>
|
|
|
|
<ChildCollection
|
|
config={SERVICES}
|
|
rows={data.services as unknown as Record<string, unknown>[]}
|
|
canEdit={canEdit}
|
|
onAdd={async (p) => { await addService(data.id, p as never); onChange(); }}
|
|
onSave={async (sid, p) => { await updateService(data.id, sid, p as never); onChange(); }}
|
|
onRemove={async (sid) => { await removeService(data.id, sid); onChange(); }}
|
|
/>
|
|
|
|
<TrustEditor data={data} onChange={onChange} />
|
|
|
|
<DocumentsEditor data={data} onChange={onChange} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** Upload / download / delete document blobs stored in object storage (MinIO). */
|
|
function DocumentsEditor({
|
|
data,
|
|
onChange,
|
|
}: {
|
|
data: PropertyDetail;
|
|
onChange: () => void;
|
|
}) {
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [type, setType] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function upload() {
|
|
if (!file) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await uploadPropertyDocument(data.id, file, type.trim() || undefined);
|
|
setFile(null);
|
|
setType("");
|
|
onChange();
|
|
} catch (e) {
|
|
setError((e as Error)?.message ?? "No se pudo subir el archivo.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="card" style={{ padding: 16 }}>
|
|
<h3 className="section-title" style={{ marginTop: 0 }}>Documentos</h3>
|
|
{data.documents.length > 0 && (
|
|
<div className="tx-scroll">
|
|
<table className="tx-table">
|
|
<thead>
|
|
<tr><th>Tipo</th><th>Clave</th><th className="num">Acción</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.documents.map((d) => (
|
|
<tr key={d.id ?? d.storageKey}>
|
|
<td>{d.documentType ?? "—"}</td>
|
|
<td className="mono">{d.storageKey ?? "—"}</td>
|
|
<td>
|
|
<div className="row-actions">
|
|
{d.id && (
|
|
<a
|
|
className="btn btn-ghost"
|
|
href={propertyDocumentDownloadUrl(data.id, d.id)}
|
|
>
|
|
Descargar
|
|
</a>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={async () => {
|
|
if (!d.id) return;
|
|
if (!window.confirm("¿Eliminar este documento?")) return;
|
|
try {
|
|
await removePropertyDocument(data.id, d.id);
|
|
onChange();
|
|
} catch (e) {
|
|
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
|
}
|
|
}}
|
|
>
|
|
Eliminar
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
{error && <div className="state-box state-error" style={{ marginTop: 12 }}>{error}</div>}
|
|
<div className="inline-form" style={{ marginTop: 12 }}>
|
|
<input
|
|
className="input"
|
|
placeholder="Tipo (ej. RECIBO)"
|
|
value={type}
|
|
onChange={(e) => setType(e.target.value)}
|
|
/>
|
|
<input
|
|
type="file"
|
|
className="input"
|
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={!file || busy}
|
|
onClick={upload}
|
|
>
|
|
{busy ? "Subiendo…" : "Subir"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Trust is 1:1 — a small inline form that upserts or clears it. */
|
|
function TrustEditor({
|
|
data,
|
|
onChange,
|
|
}: {
|
|
data: PropertyDetail;
|
|
onChange: () => void;
|
|
}) {
|
|
const t = data.trustAccount;
|
|
const [bankName, setBankName] = useState(t?.bankName ?? "");
|
|
const [trustNumber, setTrustNumber] = useState(t?.trustNumber ?? "");
|
|
const [bankFee, setBankFee] = useState(t?.bankFee != null ? String(t.bankFee) : "");
|
|
const [dueDate1, setDueDate1] = useState(toDateInput(t?.dueDate1));
|
|
const [dueDate2, setDueDate2] = useState(toDateInput(t?.dueDate2));
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function save() {
|
|
setBusy(true);
|
|
const input: TrustInput = {
|
|
bankName: bankName.trim() || undefined,
|
|
trustNumber: trustNumber.trim() || undefined,
|
|
bankFee: bankFee.trim() === "" ? undefined : Number(bankFee),
|
|
dueDate1: dueDate1 || undefined,
|
|
dueDate2: dueDate2 || undefined,
|
|
};
|
|
try {
|
|
await upsertTrust(data.id, input);
|
|
onChange();
|
|
} catch (e) {
|
|
window.alert((e as Error)?.message ?? "No se pudo guardar el fideicomiso.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function clear() {
|
|
if (!window.confirm("¿Eliminar el fideicomiso de esta propiedad?")) return;
|
|
try {
|
|
await removeTrust(data.id);
|
|
onChange();
|
|
} catch (e) {
|
|
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<h3 className="section-title" style={{ marginTop: 0 }}>Fideicomiso</h3>
|
|
<div className="form-grid">
|
|
<label className="field">
|
|
<span className="field-label">Banco</span>
|
|
<input className="input" value={bankName} onChange={(e) => setBankName(e.target.value)} />
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">No. fideicomiso</span>
|
|
<input className="input" value={trustNumber} onChange={(e) => setTrustNumber(e.target.value)} />
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">Cuota banco</span>
|
|
<input className="input" type="number" step="0.01" value={bankFee} onChange={(e) => setBankFee(e.target.value)} />
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">Vence 1</span>
|
|
<input className="input" type="date" value={dueDate1} onChange={(e) => setDueDate1(e.target.value)} />
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">Vence 2 (próxima)</span>
|
|
<input className="input" type="date" value={dueDate2} onChange={(e) => setDueDate2(e.target.value)} />
|
|
</label>
|
|
</div>
|
|
<div className="form-actions">
|
|
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
|
|
{busy ? "Guardando…" : t ? "Guardar fideicomiso" : "Crear fideicomiso"}
|
|
</button>
|
|
{t && (
|
|
<button type="button" className="btn btn-ghost" onClick={clear}>
|
|
Eliminar
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function toDateInput(v: string | null | undefined): string {
|
|
if (!v) return "";
|
|
const d = new Date(v);
|
|
return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
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={`/estado-cuenta/${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, flex: 1 }}>
|
|
<div className="doc-type">{d.documentType || "Documento"}</div>
|
|
<div className="doc-key">{d.storageKey || "—"}</div>
|
|
</div>
|
|
{d.id && (
|
|
<a
|
|
className="btn btn-ghost"
|
|
href={propertyDocumentDownloadUrl(data.id, d.id)}
|
|
>
|
|
Descargar
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|