(null);
+
+ useEffect(() => {
+ if (!allowed) return;
+ getProperty(id)
+ .then(setProperty)
+ .catch((e) => setError(e?.message ?? "No se pudo cargar la propiedad."));
+ }, [id, allowed]);
+
+ return (
+ <>
+
+ ← Propiedad
+
Editar propiedad
+
+ {!allowed ? (
+
+ No tiene permisos para editar propiedades.
+
+ ) : error ? (
+ {error}
+ ) : !property ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/app/servicios/[id]/page.tsx b/apps/web/src/app/servicios/[id]/page.tsx
index 7b422d2..5d241d3 100644
--- a/apps/web/src/app/servicios/[id]/page.tsx
+++ b/apps/web/src/app/servicios/[id]/page.tsx
@@ -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 (
-
+
@@ -86,10 +104,246 @@ function Detail({ id }: { id: string }) {
{data.policy &&
}
+
);
}
+/** 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 (
+
+ {archived && Archivada}
+ {canEdit && (
+
+ Editar
+
+ )}
+ {canDelete && (
+
+ )}
+
+ );
+}
+
+/** 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 (
+
+
+
+
Administrar propiedad
+
+
+ []}
+ 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(); }}
+ />
+
+
+
+ {data.documents.length > 0 && (
+
+
Documentos
+
+
+
+ | Tipo | Clave | Acción |
+
+
+ {data.documents.map((d) => (
+
+ | {d.documentType ?? "—"} |
+ {d.storageKey ?? "—"} |
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+ La carga de nuevos documentos requiere el almacenamiento de archivos
+ (pendiente); aquí solo se pueden eliminar los existentes.
+
+
+ )}
+
+ );
+}
+
+/** 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 (
+
+ );
+}
+
+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 (
diff --git a/apps/web/src/app/servicios/nuevo/page.tsx b/apps/web/src/app/servicios/nuevo/page.tsx
new file mode 100644
index 0000000..9ec3b3c
--- /dev/null
+++ b/apps/web/src/app/servicios/nuevo/page.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { Suspense } from "react";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { AppShell } from "@/components/AppShell";
+import { PropertyForm } from "@/components/PropertyForm";
+import { useCan } from "@/lib/abilities";
+
+export default function NuevaPropiedadPage() {
+ return (
+
+
+
+
+
+ );
+}
+
+function NuevaPropiedad() {
+ const allowed = useCan("property:create");
+ const params = useSearchParams();
+ const customerId = params.get("customerId") ?? undefined;
+ const customerName = params.get("customerName") ?? undefined;
+
+ return (
+ <>
+
+ ← Propiedades
+
Nueva propiedad
+
+ {allowed ? (
+
+ ) : (
+
+ No tiene permisos para crear propiedades.
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/app/servicios/page.tsx b/apps/web/src/app/servicios/page.tsx
index 7911e17..f47f08a 100644
--- a/apps/web/src/app/servicios/page.tsx
+++ b/apps/web/src/app/servicios/page.tsx
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
+import { useCan } from "@/lib/abilities";
import {
EXPIRY_WINDOW_DAYS,
getPropertyFacets,
@@ -81,6 +82,7 @@ export default function ServiciosPage() {
}
function ServiciosBrowser() {
+ const canCreate = useCan("property:create");
const [stats, setStats] = useState(null);
const [facets, setFacets] = useState(null);
@@ -164,7 +166,13 @@ function ServiciosBrowser() {
<>
Administración de servicios
-
Propiedades
+
+
Propiedades
+
+ {canCreate && (
+ + Nueva propiedad
+ )}
+
(() => 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(null);
+
+ function set(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 (
+
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index dcb43e6..1289175 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -34,9 +34,12 @@ import type {
LookupsResponse,
PropertyDetail,
PropertyFacets,
+ PropertyInput,
PropertyListResponse,
PropertySort,
PropertyStats,
+ ServiceInput,
+ TrustInput,
Role,
ServiceKind,
Statement,
@@ -338,6 +341,71 @@ export function getProperty(
return apiFetch(`/properties/${id}?days=${days}`);
}
+export function createProperty(input: PropertyInput): Promise {
+ return apiFetch("/properties", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+export function updateProperty(
+ id: string,
+ input: Partial,
+): Promise {
+ return apiFetch(`/properties/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(input),
+ });
+}
+export function archiveProperty(id: string): Promise {
+ return apiFetch(`/properties/${id}`, { method: "DELETE" });
+}
+export function restoreProperty(id: string): Promise {
+ return apiFetch(`/properties/${id}/restore`, { method: "POST" });
+}
+
+// Service child CRUD.
+export function addService(propertyId: string, input: ServiceInput): Promise {
+ return apiFetch(`/properties/${propertyId}/services`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+export function updateService(
+ propertyId: string,
+ serviceId: string,
+ input: Partial,
+): Promise {
+ return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
+ method: "PATCH",
+ body: JSON.stringify(input),
+ });
+}
+export function removeService(propertyId: string, serviceId: string): Promise {
+ return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
+ method: "DELETE",
+ });
+}
+
+// Trust account (1:1 upsert).
+export function upsertTrust(propertyId: string, input: TrustInput): Promise {
+ return apiFetch(`/properties/${propertyId}/trust`, {
+ method: "PUT",
+ body: JSON.stringify(input),
+ });
+}
+export function removeTrust(propertyId: string): Promise {
+ return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" });
+}
+
+export function removePropertyDocument(
+ propertyId: string,
+ documentId: string,
+): Promise {
+ return apiFetch(`/properties/${propertyId}/documents/${documentId}`, {
+ method: "DELETE",
+ });
+}
+
/* ------------------------------------------- Billing / statements module */
export interface MovementQuery {
diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts
index 3036a3d..a34efec 100644
--- a/apps/web/src/lib/types.ts
+++ b/apps/web/src/lib/types.ts
@@ -510,6 +510,35 @@ export interface PropertyListItem {
activeServiceCount: number;
documentCount: number;
trust: TrustSummary | null;
+ archived: boolean;
+}
+
+/** Editable property-header fields — shared by the form and the API. */
+export interface PropertyInput {
+ customerId: string;
+ policyId?: string;
+ addressLine1?: string;
+ addressLine2?: string;
+ phone1?: string;
+ phone2?: string;
+ phone3?: string;
+ zone?: string;
+}
+export interface ServiceInput {
+ kind: ServiceKind;
+ accountNumber?: string;
+ meterNumber?: string;
+ route?: string;
+ dueDay?: string;
+ active?: boolean;
+ notes?: string;
+}
+export interface TrustInput {
+ bankName?: string;
+ trustNumber?: string;
+ bankFee?: number;
+ dueDate1?: string;
+ dueDate2?: string;
}
export interface PropertyListResponse {
@@ -561,6 +590,7 @@ export interface PropertyDetail {
phone2: string | null;
phone3: string | null;
zone: string | null;
+ archivedAt: string | null;
legacySourceTable: string | null;
legacyId: string | null;
customer: PropertyOwnerRef;
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 35e8917..1f21c3b 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -321,6 +321,8 @@ model Property {
phone2 String?
phone3 String?
zone String?
+ // Soft-delete marker (see Customer.archivedAt).
+ archivedAt DateTime?
legacySourceTable String?
legacyId String?
createdAt DateTime @default(now())