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:
@@ -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 { Request } from "express";
|
||||
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 {
|
||||
PropertiesService,
|
||||
type PropertySort,
|
||||
type TrustFilter,
|
||||
} from "./properties.service";
|
||||
import {
|
||||
CreatePropertyDto,
|
||||
ServiceDto,
|
||||
TrustDto,
|
||||
UpdatePropertyDto,
|
||||
UpdateServiceDto,
|
||||
} from "./property.dto";
|
||||
|
||||
const KINDS: ServiceKind[] = [
|
||||
"WATER",
|
||||
@@ -35,15 +58,21 @@ const SORTS: PropertySort[] = [
|
||||
"trust_due_desc",
|
||||
];
|
||||
|
||||
/** Clamped trust-renewal window; 30 days matches the policies module. */
|
||||
function parseDays(days?: string): number {
|
||||
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard)
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("properties")
|
||||
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")
|
||||
stats(@Query("days") days?: string) {
|
||||
@@ -67,6 +96,7 @@ export class PropertiesController {
|
||||
@Query("hasServices") hasServices?: string,
|
||||
@Query("customerId") customerId?: string,
|
||||
@Query("days") days?: string,
|
||||
@Query("includeArchived") includeArchived?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.properties.list({
|
||||
@@ -85,6 +115,7 @@ export class PropertiesController {
|
||||
hasServices === "true" ? true : hasServices === "false" ? false : undefined,
|
||||
customerId: customerId || undefined,
|
||||
days: parseDays(days),
|
||||
includeArchived: includeArchived === "true",
|
||||
sort: SORTS.includes(sort as PropertySort)
|
||||
? (sort as PropertySort)
|
||||
: "customer",
|
||||
@@ -95,4 +126,81 @@ export class PropertiesController {
|
||||
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||
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 { Prisma, ServiceKind } from "@jorgecuadros/database";
|
||||
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`
|
||||
@@ -37,6 +45,7 @@ export interface ListParams {
|
||||
customerId?: string;
|
||||
/** Window in days for the `expiring` trust bucket. */
|
||||
days: number;
|
||||
includeArchived?: boolean;
|
||||
sort: PropertySort;
|
||||
}
|
||||
|
||||
@@ -134,11 +143,14 @@ export class PropertiesService {
|
||||
hasServices,
|
||||
customerId,
|
||||
days,
|
||||
includeArchived,
|
||||
sort,
|
||||
} = params;
|
||||
|
||||
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
|
||||
// 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.
|
||||
@@ -191,6 +203,7 @@ export class PropertiesService {
|
||||
phone2: true,
|
||||
phone3: true,
|
||||
zone: true,
|
||||
archivedAt: true,
|
||||
customer: {
|
||||
select: { id: true, name: true, city: true, state: true },
|
||||
},
|
||||
@@ -221,6 +234,7 @@ export class PropertiesService {
|
||||
addressLine1: r.addressLine1,
|
||||
addressLine2: r.addressLine2,
|
||||
zone: r.zone,
|
||||
archived: r.archivedAt != null,
|
||||
phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[],
|
||||
customerId: r.customer.id,
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="rise">
|
||||
<BackLink />
|
||||
<div className="detail-actionbar">
|
||||
<BackLink />
|
||||
<PropertyActions data={data} onChange={reload} />
|
||||
</div>
|
||||
<Hero data={data} />
|
||||
<ClienteSection data={data} />
|
||||
<ServiciosSection data={data} />
|
||||
@@ -86,10 +104,246 @@ function Detail({ id }: { id: string }) {
|
||||
{data.policy && <PolizaSection data={data} />}
|
||||
<MovimientosSection data={data} />
|
||||
<DocumentosSection data={data} />
|
||||
<PropertyEditor data={data} onChange={reload} />
|
||||
</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() {
|
||||
return (
|
||||
<Link href="/servicios" className="back-link">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<PropertyStats | null>(null);
|
||||
const [facets, setFacets] = useState<PropertyFacets | null>(null);
|
||||
|
||||
@@ -164,7 +166,13 @@ function ServiciosBrowser() {
|
||||
<>
|
||||
<div className="page-head rise">
|
||||
<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} />
|
||||
<ServiceMixStrip
|
||||
stats={stats}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<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 */
|
||||
|
||||
export interface MovementQuery {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user