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
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CustomerPicker } from "@/components/CustomerPicker";
import { createProperty, updateProperty } from "@/lib/api";
import type { PropertyDetail, PropertyInput } from "@/lib/types";
function s(v: string): string | undefined {
const t = v.trim();
return t === "" ? undefined : t;
}
type V = {
addressLine1: string;
addressLine2: string;
phone1: string;
phone2: string;
phone3: string;
zone: string;
};
function initial(p?: PropertyDetail): V {
return {
addressLine1: p?.addressLine1 ?? "",
addressLine2: p?.addressLine2 ?? "",
phone1: p?.phone1 ?? "",
phone2: p?.phone2 ?? "",
phone3: p?.phone3 ?? "",
zone: p?.zone ?? "",
};
}
export function PropertyForm({
property,
fixedCustomerId,
fixedCustomerName,
}: {
property?: PropertyDetail;
fixedCustomerId?: string;
fixedCustomerName?: string;
}) {
const router = useRouter();
const editing = !!property;
const [v, setV] = useState<V>(() => initial(property));
const [customerId, setCustomerId] = useState(
property?.customer.id ?? fixedCustomerId ?? "",
);
const [customerName, setCustomerName] = useState(
property?.customer.name ?? fixedCustomerName ?? "",
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function set<K extends keyof V>(k: K, val: V[K]) {
setV((p) => ({ ...p, [k]: val }));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!customerId) {
setError("Seleccione un cliente propietario.");
return;
}
setSaving(true);
setError(null);
const base = {
addressLine1: s(v.addressLine1),
addressLine2: s(v.addressLine2),
phone1: s(v.phone1),
phone2: s(v.phone2),
phone3: s(v.phone3),
zone: s(v.zone),
};
try {
const saved = editing
? await updateProperty(property!.id, base)
: await createProperty({ ...base, customerId } as PropertyInput);
router.push(`/servicios/${saved.id}`);
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar la propiedad.");
setSaving(false);
}
}
return (
<form onSubmit={submit}>
{error && <div className="state-box state-error">{error}</div>}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Propiedad</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Propietario *</span>
{editing ? (
<input className="input" value={customerName} disabled />
) : (
<CustomerPicker
value={customerId}
valueName={customerName}
onPick={(id, name) => {
setCustomerId(id);
setCustomerName(name);
}}
/>
)}
</label>
<label className="field">
<span className="field-label">Dirección 1</span>
<input className="input" value={v.addressLine1}
onChange={(e) => set("addressLine1", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Dirección 2</span>
<input className="input" value={v.addressLine2}
onChange={(e) => set("addressLine2", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Zona</span>
<input className="input" value={v.zone}
onChange={(e) => set("zone", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Teléfono 1</span>
<input className="input" value={v.phone1}
onChange={(e) => set("phone1", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Teléfono 2</span>
<input className="input" value={v.phone2}
onChange={(e) => set("phone2", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Teléfono 3</span>
<input className="input" value={v.phone3}
onChange={(e) => set("phone3", e.target.value)} />
</label>
</div>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Guardando…" : editing ? "Guardar cambios" : "Crear propiedad"}
</button>
<button type="button" className="btn btn-outline" onClick={() => router.back()}>
Cancelar
</button>
</div>
</form>
);
}