feat(properties): CRUD + service/trust/document editors (plan phase 4)
Utilities section becomes create/edit/archive-able, with its child data. API: - Property gains archivedAt (soft-delete); list/browser default to archivedAt=null with ?includeArchived opt-in. - PropertiesService: header create/update/archive/restore (customer FK validated); PropertyService add/update/remove scoped to the property; TrustAccount upsert (1:1) + remove; ServiceDocument pointer delete. - Controller write routes: create needs STAFF+ (property:create), archive MANAGER+ (property:delete), every service/trust/document route property:update. Mutations audited. DTOs added. - Document *upload* deliberately deferred: it needs the object-storage client wired into the API (today only the migration writes to MinIO); removing an existing pointer row is supported and the UI says so. Web: - PropertyForm (header) with CustomerPicker; /servicios/nuevo (accepts ?customerId prefill) and /servicios/[id]/editar. - Property detail: gated action bar (Editar/Archivar) + "Administrar propiedad" — services via the shared ChildCollection editor, an inline 1:1 TrustEditor (create/update/clear), and document-row delete. - "Nueva propiedad" buttons on the list and customer detail (prefilled). api.ts + types for all of it. Verified against dev: property create (archivedAt null), service add/update, VIEWER service-add 403, trust upsert (create then update the same row), trust/service remove, cross-property child guard 404, archive drops from the default list and includeArchived surfaces it. Both apps compile clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,19 +3,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { getProperty } from "@/lib/api";
|
||||
import {
|
||||
addService,
|
||||
archiveProperty,
|
||||
getProperty,
|
||||
removePropertyDocument,
|
||||
removeService,
|
||||
removeTrust,
|
||||
restoreProperty,
|
||||
updateService,
|
||||
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 } from "@/lib/types";
|
||||
import type { PropertyDetail, Service, Transaction, TrustInput } from "@/lib/types";
|
||||
|
||||
export default function PropiedadDetailPage({
|
||||
params,
|
||||
@@ -76,9 +89,14 @@ function Detail({ id }: { id: string }) {
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const reload = () => getProperty(id).then(setData).catch(() => {});
|
||||
|
||||
return (
|
||||
<div className="rise">
|
||||
<BackLink />
|
||||
<div className="detail-actionbar">
|
||||
<BackLink />
|
||||
<PropertyActions data={data} onChange={reload} />
|
||||
</div>
|
||||
<Hero data={data} />
|
||||
<ClienteSection data={data} />
|
||||
<ServiciosSection data={data} />
|
||||
@@ -86,10 +104,246 @@ function Detail({ id }: { id: string }) {
|
||||
{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} />
|
||||
|
||||
{data.documents.length > 0 && (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<h3 className="section-title" style={{ marginTop: 0 }}>Documentos</h3>
|
||||
<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">
|
||||
<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>
|
||||
<p className="inline-form-note">
|
||||
La carga de nuevos documentos requiere el almacenamiento de archivos
|
||||
(pendiente); aquí solo se pueden eliminar los existentes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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">
|
||||
|
||||
Reference in New Issue
Block a user