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
@@ -1,11 +1,34 @@
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { ServiceKind } from "@jorgecuadros/database"; import { ServiceKind } from "@jorgecuadros/database";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard"; import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import { import {
PropertiesService, PropertiesService,
type PropertySort, type PropertySort,
type TrustFilter, type TrustFilter,
} from "./properties.service"; } from "./properties.service";
import {
CreatePropertyDto,
ServiceDto,
TrustDto,
UpdatePropertyDto,
UpdateServiceDto,
} from "./property.dto";
const KINDS: ServiceKind[] = [ const KINDS: ServiceKind[] = [
"WATER", "WATER",
@@ -35,15 +58,21 @@ const SORTS: PropertySort[] = [
"trust_due_desc", "trust_due_desc",
]; ];
/** Clamped trust-renewal window; 30 days matches the policies module. */
function parseDays(days?: string): number { function parseDays(days?: string): number {
return Math.min(365, Math.max(1, Number(days) || 30)); return Math.min(365, Math.max(1, Number(days) || 30));
} }
@UseGuards(AuthenticatedGuard) @UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("properties") @Controller("properties")
export class PropertiesController { export class PropertiesController {
constructor(private readonly properties: PropertiesService) {} constructor(
private readonly properties: PropertiesService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("stats") @Get("stats")
stats(@Query("days") days?: string) { stats(@Query("days") days?: string) {
@@ -67,6 +96,7 @@ export class PropertiesController {
@Query("hasServices") hasServices?: string, @Query("hasServices") hasServices?: string,
@Query("customerId") customerId?: string, @Query("customerId") customerId?: string,
@Query("days") days?: string, @Query("days") days?: string,
@Query("includeArchived") includeArchived?: string,
@Query("sort") sort?: string, @Query("sort") sort?: string,
) { ) {
return this.properties.list({ return this.properties.list({
@@ -85,6 +115,7 @@ export class PropertiesController {
hasServices === "true" ? true : hasServices === "false" ? false : undefined, hasServices === "true" ? true : hasServices === "false" ? false : undefined,
customerId: customerId || undefined, customerId: customerId || undefined,
days: parseDays(days), days: parseDays(days),
includeArchived: includeArchived === "true",
sort: SORTS.includes(sort as PropertySort) sort: SORTS.includes(sort as PropertySort)
? (sort as PropertySort) ? (sort as PropertySort)
: "customer", : "customer",
@@ -95,4 +126,81 @@ export class PropertiesController {
detail(@Param("id") id: string, @Query("days") days?: string) { detail(@Param("id") id: string, @Query("days") days?: string) {
return this.properties.detail(id, parseDays(days)); return this.properties.detail(id, parseDays(days));
} }
// --- header writes --------------------------------------------------------
@Post()
@RequireAbility("property:create")
async create(@Body() dto: CreatePropertyDto, @Req() req: Request) {
const p = await this.properties.create(dto);
void this.audit.log(this.actingId(req), "property.create", { propertyId: p.id });
return p;
}
@Patch(":id")
@RequireAbility("property:update")
async update(@Param("id") id: string, @Body() dto: UpdatePropertyDto, @Req() req: Request) {
const p = await this.properties.update(id, dto);
void this.audit.log(this.actingId(req), "property.update", { propertyId: id });
return p;
}
@Delete(":id")
@RequireAbility("property:delete")
async archive(@Param("id") id: string, @Req() req: Request) {
const p = await this.properties.archive(id);
void this.audit.log(this.actingId(req), "property.archive", { propertyId: id });
return p;
}
@Post(":id/restore")
@RequireAbility("property:delete")
async restore(@Param("id") id: string, @Req() req: Request) {
const p = await this.properties.restore(id);
void this.audit.log(this.actingId(req), "property.restore", { propertyId: id });
return p;
}
// --- services (property:update) -------------------------------------------
@Post(":id/services")
@RequireAbility("property:update")
addService(@Param("id") id: string, @Body() dto: ServiceDto) {
return this.properties.addService(id, dto);
}
@Patch(":id/services/:childId")
@RequireAbility("property:update")
updateService(
@Param("id") id: string,
@Param("childId") childId: string,
@Body() dto: UpdateServiceDto,
) {
return this.properties.updateService(id, childId, dto);
}
@Delete(":id/services/:childId")
@RequireAbility("property:update")
removeService(@Param("id") id: string, @Param("childId") childId: string) {
return this.properties.removeService(id, childId);
}
// --- trust account (1:1) --------------------------------------------------
@Put(":id/trust")
@RequireAbility("property:update")
upsertTrust(@Param("id") id: string, @Body() dto: TrustDto) {
return this.properties.upsertTrust(id, dto);
}
@Delete(":id/trust")
@RequireAbility("property:update")
removeTrust(@Param("id") id: string) {
return this.properties.removeTrust(id);
}
// --- documents (remove pointer only) --------------------------------------
@Delete(":id/documents/:childId")
@RequireAbility("property:update")
removeDocument(@Param("id") id: string, @Param("childId") childId: string) {
return this.properties.removeDocument(id, childId);
}
} }
@@ -1,6 +1,14 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, ServiceKind } from "@jorgecuadros/database"; import { Prisma, ServiceKind } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { toDate } from "../common/coerce";
import {
CreatePropertyDto,
ServiceDto,
TrustDto,
UpdatePropertyDto,
UpdateServiceDto,
} from "./property.dto";
/** /**
* Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2` * Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2`
@@ -37,6 +45,7 @@ export interface ListParams {
customerId?: string; customerId?: string;
/** Window in days for the `expiring` trust bucket. */ /** Window in days for the `expiring` trust bucket. */
days: number; days: number;
includeArchived?: boolean;
sort: PropertySort; sort: PropertySort;
} }
@@ -134,11 +143,14 @@ export class PropertiesService {
hasServices, hasServices,
customerId, customerId,
days, days,
includeArchived,
sort, sort,
} = params; } = params;
const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)]; const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)];
if (!includeArchived) and.push({ archivedAt: null });
// Sorting by trust due date is only meaningful for properties that have a // Sorting by trust due date is only meaningful for properties that have a
// trust; MySQL would otherwise float the ~966 trust-less rows (NULL first // trust; MySQL would otherwise float the ~966 trust-less rows (NULL first
// on ASC) above every real due date. Scoping is explicit in the UI label. // on ASC) above every real due date. Scoping is explicit in the UI label.
@@ -191,6 +203,7 @@ export class PropertiesService {
phone2: true, phone2: true,
phone3: true, phone3: true,
zone: true, zone: true,
archivedAt: true,
customer: { customer: {
select: { id: true, name: true, city: true, state: true }, select: { id: true, name: true, city: true, state: true },
}, },
@@ -221,6 +234,7 @@ export class PropertiesService {
addressLine1: r.addressLine1, addressLine1: r.addressLine1,
addressLine2: r.addressLine2, addressLine2: r.addressLine2,
zone: r.zone, zone: r.zone,
archived: r.archivedAt != null,
phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[], phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[],
customerId: r.customer.id, customerId: r.customer.id,
customerName: r.customer.name, customerName: r.customer.name,
@@ -443,4 +457,102 @@ export class PropertiesService {
})), })),
}; };
} }
// --- property header writes -----------------------------------------------
async create(dto: CreatePropertyDto) {
const customer = await this.prisma.customer.findUnique({
where: { id: dto.customerId },
select: { id: true },
});
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
return this.prisma.property.create({ data: { ...dto } });
}
async update(id: string, dto: UpdatePropertyDto) {
await this.ensureProperty(id);
return this.prisma.property.update({ where: { id }, data: { ...dto } });
}
async archive(id: string) {
await this.ensureProperty(id);
return this.prisma.property.update({ where: { id }, data: { archivedAt: new Date() } });
}
async restore(id: string) {
await this.ensureProperty(id);
return this.prisma.property.update({ where: { id }, data: { archivedAt: null } });
}
private async ensureProperty(id: string) {
const found = await this.prisma.property.findUnique({
where: { id },
select: { id: true },
});
if (!found) throw new NotFoundException(`Property ${id} not found`);
}
private async ensureService(propertyId: string, serviceId: string) {
await this.ensureProperty(propertyId);
const row = await this.prisma.propertyService.findFirst({
where: { id: serviceId, propertyId },
select: { id: true },
});
if (!row) throw new NotFoundException(`Service ${serviceId} not found on property ${propertyId}`);
}
// --- services -------------------------------------------------------------
async addService(propertyId: string, dto: ServiceDto) {
await this.ensureProperty(propertyId);
return this.prisma.propertyService.create({ data: { propertyId, ...dto } });
}
async updateService(propertyId: string, id: string, dto: UpdateServiceDto) {
await this.ensureService(propertyId, id);
return this.prisma.propertyService.update({ where: { id }, data: { ...dto } });
}
async removeService(propertyId: string, id: string) {
await this.ensureService(propertyId, id);
return this.prisma.propertyService.delete({ where: { id } });
}
// --- trust account (1:1 upsert) -------------------------------------------
async upsertTrust(propertyId: string, dto: TrustDto) {
await this.ensureProperty(propertyId);
const data = {
bankName: dto.bankName,
trustNumber: dto.trustNumber,
bankFee: dto.bankFee,
...(dto.dueDate1 !== undefined && { dueDate1: toDate(dto.dueDate1) }),
...(dto.dueDate2 !== undefined && { dueDate2: toDate(dto.dueDate2) }),
};
return this.prisma.trustAccount.upsert({
where: { propertyId },
create: { propertyId, ...data },
update: data,
});
}
async removeTrust(propertyId: string) {
await this.ensureProperty(propertyId);
const existing = await this.prisma.trustAccount.findUnique({
where: { propertyId },
select: { id: true },
});
if (!existing) throw new NotFoundException(`No trust account on property ${propertyId}`);
return this.prisma.trustAccount.delete({ where: { propertyId } });
}
// --- documents ------------------------------------------------------------
// Removing a pointer row only; uploading files needs the object-storage
// client wired into the API (today only the migration writes to MinIO).
async removeDocument(propertyId: string, id: string) {
await this.ensureProperty(propertyId);
const row = await this.prisma.serviceDocument.findFirst({
where: { id, propertyId },
select: { id: true },
});
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
return this.prisma.serviceDocument.delete({ where: { id } });
}
} }
+58
View File
@@ -0,0 +1,58 @@
import {
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
MinLength,
} from "class-validator";
import { ServiceKind } from "@jorgecuadros/database";
export class CreatePropertyDto {
@IsString() @MinLength(1) customerId!: string;
@IsOptional() @IsString() policyId?: string;
@IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() phone1?: string;
@IsOptional() @IsString() phone2?: string;
@IsOptional() @IsString() phone3?: string;
@IsOptional() @IsString() zone?: string;
}
export class UpdatePropertyDto {
@IsOptional() @IsString() policyId?: string;
@IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() phone1?: string;
@IsOptional() @IsString() phone2?: string;
@IsOptional() @IsString() phone3?: string;
@IsOptional() @IsString() zone?: string;
}
export class ServiceDto {
@IsEnum(ServiceKind) kind!: ServiceKind;
@IsOptional() @IsString() accountNumber?: string;
@IsOptional() @IsString() meterNumber?: string;
@IsOptional() @IsString() route?: string;
@IsOptional() @IsString() dueDay?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() notes?: string;
}
export class UpdateServiceDto {
@IsOptional() @IsEnum(ServiceKind) kind?: ServiceKind;
@IsOptional() @IsString() accountNumber?: string;
@IsOptional() @IsString() meterNumber?: string;
@IsOptional() @IsString() route?: string;
@IsOptional() @IsString() dueDay?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() notes?: string;
}
/** Trust is 1:1 with a property — this both creates and updates it (upsert). */
export class TrustDto {
@IsOptional() @IsString() bankName?: string;
@IsOptional() @IsString() trustNumber?: string;
@IsOptional() @IsNumber() bankFee?: number;
@IsOptional() @IsString() dueDate1?: string;
@IsOptional() @IsString() dueDate2?: string;
}
+30 -7
View File
@@ -97,7 +97,11 @@ function Detail({ id }: { id: string }) {
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} /> <Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
<DatosSection data={data} /> <DatosSection data={data} />
<PropiedadesSection properties={data.properties} /> <PropiedadesSection
properties={data.properties}
customerId={data.id}
customerName={data.name}
/>
<PolizasSection <PolizasSection
policies={data.policies} policies={data.policies}
customerId={data.id} customerId={data.id}
@@ -337,14 +341,33 @@ function KV({
} }
/* ----------------------------------------------- Propiedades y servicios */ /* ----------------------------------------------- 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 ( return (
<section className="section"> <section className="section">
<SectionHead <div className="detail-actionbar">
rule="servicios" <SectionHead
title="Propiedades y servicios" rule="servicios"
count={properties.length} 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"> <div className="card">
{properties.length === 0 ? ( {properties.length === 0 ? (
<div className="empty-inline"> <div className="empty-inline">
@@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { PropertyForm } from "@/components/PropertyForm";
import { useCan } from "@/lib/abilities";
import { getProperty } from "@/lib/api";
import type { PropertyDetail } from "@/lib/types";
export default function EditarPropiedadPage({
params,
}: {
params: { id: string };
}) {
return (
<AppShell>
<EditarPropiedad id={params.id} />
</AppShell>
);
}
function EditarPropiedad({ id }: { id: string }) {
const allowed = useCan("property:update");
const [property, setProperty] = useState<PropertyDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!allowed) return;
getProperty(id)
.then(setProperty)
.catch((e) => setError(e?.message ?? "No se pudo cargar la propiedad."));
}, [id, allowed]);
return (
<>
<div className="page-head">
<Link href={`/servicios/${id}`} className="back-link"> Propiedad</Link>
<h1 className="page-title">Editar propiedad</h1>
</div>
{!allowed ? (
<div className="state-box state-error">
No tiene permisos para editar propiedades.
</div>
) : error ? (
<div className="state-box state-error">{error}</div>
) : !property ? (
<div className="empty-inline"><span className="spinner" aria-label="Cargando" /></div>
) : (
<PropertyForm property={property} />
)}
</>
);
}
+257 -3
View File
@@ -3,19 +3,32 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { AppShell } from "@/components/AppShell"; 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 { import {
expiryPhrase, expiryPhrase,
formatDate, formatDate,
formatMoney, formatMoney,
formatNumber, formatNumber,
SERVICE_KIND_LABELS,
serviceKindGlyph, serviceKindGlyph,
serviceKindLabel, serviceKindLabel,
serviceNoteLabel, serviceNoteLabel,
SIN_NOMBRE, SIN_NOMBRE,
trustStatusLabel, trustStatusLabel,
} from "@/lib/labels"; } from "@/lib/labels";
import type { PropertyDetail, Service, Transaction } from "@/lib/types"; import type { PropertyDetail, Service, Transaction, TrustInput } from "@/lib/types";
export default function PropiedadDetailPage({ export default function PropiedadDetailPage({
params, params,
@@ -76,9 +89,14 @@ function Detail({ id }: { id: string }) {
if (!data) return null; if (!data) return null;
const reload = () => getProperty(id).then(setData).catch(() => {});
return ( return (
<div className="rise"> <div className="rise">
<BackLink /> <div className="detail-actionbar">
<BackLink />
<PropertyActions data={data} onChange={reload} />
</div>
<Hero data={data} /> <Hero data={data} />
<ClienteSection data={data} /> <ClienteSection data={data} />
<ServiciosSection data={data} /> <ServiciosSection data={data} />
@@ -86,10 +104,246 @@ function Detail({ id }: { id: string }) {
{data.policy && <PolizaSection data={data} />} {data.policy && <PolizaSection data={data} />}
<MovimientosSection data={data} /> <MovimientosSection data={data} />
<DocumentosSection data={data} /> <DocumentosSection data={data} />
<PropertyEditor data={data} onChange={reload} />
</div> </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() { function BackLink() {
return ( return (
<Link href="/servicios" className="back-link"> <Link href="/servicios" className="back-link">
+41
View File
@@ -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 (
<AppShell>
<Suspense fallback={null}>
<NuevaPropiedad />
</Suspense>
</AppShell>
);
}
function NuevaPropiedad() {
const allowed = useCan("property:create");
const params = useSearchParams();
const customerId = params.get("customerId") ?? undefined;
const customerName = params.get("customerName") ?? undefined;
return (
<>
<div className="page-head">
<Link href="/servicios" className="back-link"> Propiedades</Link>
<h1 className="page-title">Nueva propiedad</h1>
</div>
{allowed ? (
<PropertyForm fixedCustomerId={customerId} fixedCustomerName={customerName} />
) : (
<div className="state-box state-error">
No tiene permisos para crear propiedades.
</div>
)}
</>
);
}
+9 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import { import {
EXPIRY_WINDOW_DAYS, EXPIRY_WINDOW_DAYS,
getPropertyFacets, getPropertyFacets,
@@ -81,6 +82,7 @@ export default function ServiciosPage() {
} }
function ServiciosBrowser() { function ServiciosBrowser() {
const canCreate = useCan("property:create");
const [stats, setStats] = useState<PropertyStats | null>(null); const [stats, setStats] = useState<PropertyStats | null>(null);
const [facets, setFacets] = useState<PropertyFacets | null>(null); const [facets, setFacets] = useState<PropertyFacets | null>(null);
@@ -164,7 +166,13 @@ function ServiciosBrowser() {
<> <>
<div className="page-head rise"> <div className="page-head rise">
<p className="eyebrow">Administración de servicios</p> <p className="eyebrow">Administración de servicios</p>
<h1 className="page-title">Propiedades</h1> <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<h1 className="page-title" style={{ margin: 0 }}>Propiedades</h1>
<span style={{ flex: 1 }} />
{canCreate && (
<Link href="/servicios/nuevo" className="btn btn-primary">+ Nueva propiedad</Link>
)}
</div>
<StatStrip stats={stats} focus={focus} onPickFocus={pickFocus} /> <StatStrip stats={stats} focus={focus} onPickFocus={pickFocus} />
<ServiceMixStrip <ServiceMixStrip
stats={stats} stats={stats}
+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>
);
}
+68
View File
@@ -34,9 +34,12 @@ import type {
LookupsResponse, LookupsResponse,
PropertyDetail, PropertyDetail,
PropertyFacets, PropertyFacets,
PropertyInput,
PropertyListResponse, PropertyListResponse,
PropertySort, PropertySort,
PropertyStats, PropertyStats,
ServiceInput,
TrustInput,
Role, Role,
ServiceKind, ServiceKind,
Statement, Statement,
@@ -338,6 +341,71 @@ export function getProperty(
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`); return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
} }
export function createProperty(input: PropertyInput): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>("/properties", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateProperty(
id: string,
input: Partial<PropertyInput>,
): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archiveProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, { method: "DELETE" });
}
export function restoreProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}/restore`, { method: "POST" });
}
// Service child CRUD.
export function addService(propertyId: string, input: ServiceInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateService(
propertyId: string,
serviceId: string,
input: Partial<ServiceInput>,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removeService(propertyId: string, serviceId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "DELETE",
});
}
// Trust account (1:1 upsert).
export function upsertTrust(propertyId: string, input: TrustInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function removeTrust(propertyId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" });
}
export function removePropertyDocument(
propertyId: string,
documentId: string,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/documents/${documentId}`, {
method: "DELETE",
});
}
/* ------------------------------------------- Billing / statements module */ /* ------------------------------------------- Billing / statements module */
export interface MovementQuery { export interface MovementQuery {
+30
View File
@@ -510,6 +510,35 @@ export interface PropertyListItem {
activeServiceCount: number; activeServiceCount: number;
documentCount: number; documentCount: number;
trust: TrustSummary | null; 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 { export interface PropertyListResponse {
@@ -561,6 +590,7 @@ export interface PropertyDetail {
phone2: string | null; phone2: string | null;
phone3: string | null; phone3: string | null;
zone: string | null; zone: string | null;
archivedAt: string | null;
legacySourceTable: string | null; legacySourceTable: string | null;
legacyId: string | null; legacyId: string | null;
customer: PropertyOwnerRef; customer: PropertyOwnerRef;
+2
View File
@@ -321,6 +321,8 @@ model Property {
phone2 String? phone2 String?
phone3 String? phone3 String?
zone String? zone String?
// Soft-delete marker (see Customer.archivedAt).
archivedAt DateTime?
legacySourceTable String? legacySourceTable String?
legacyId String? legacyId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())