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