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:
2026-07-23 12:27:53 -07:00
co-authored by Claude Opus 4.8
parent 7a46c30d9b
commit 506f8ce684
12 changed files with 922 additions and 15 deletions
+30 -7
View File
@@ -97,7 +97,11 @@ function Detail({ id }: { id: string }) {
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
<DatosSection data={data} />
<PropiedadesSection properties={data.properties} />
<PropiedadesSection
properties={data.properties}
customerId={data.id}
customerName={data.name}
/>
<PolizasSection
policies={data.policies}
customerId={data.id}
@@ -337,14 +341,33 @@ function KV({
}
/* ----------------------------------------------- Propiedades y servicios */
function PropiedadesSection({ properties }: { properties: Property[] }) {
function PropiedadesSection({
properties,
customerId,
customerName,
}: {
properties: Property[];
customerId: string;
customerName: string;
}) {
const canCreate = useCan("property:create");
return (
<section className="section">
<SectionHead
rule="servicios"
title="Propiedades y servicios"
count={properties.length}
/>
<div className="detail-actionbar">
<SectionHead
rule="servicios"
title="Propiedades y servicios"
count={properties.length}
/>
{canCreate && (
<Link
href={`/servicios/nuevo?customerId=${customerId}&customerName=${encodeURIComponent(customerName)}`}
className="btn btn-outline"
>
+ Nueva propiedad
</Link>
)}
</div>
<div className="card">
{properties.length === 0 ? (
<div className="empty-inline">