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 { 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 } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
<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">
|
||||||
|
<div className="detail-actionbar">
|
||||||
<SectionHead
|
<SectionHead
|
||||||
rule="servicios"
|
rule="servicios"
|
||||||
title="Propiedades y servicios"
|
title="Propiedades y servicios"
|
||||||
count={properties.length}
|
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} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
<div className="detail-actionbar">
|
||||||
<BackLink />
|
<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">
|
||||||
|
|||||||
@@ -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 { 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}
|
||||||
|
|||||||
@@ -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,
|
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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user