diff --git a/apps/api/src/properties/properties.controller.ts b/apps/api/src/properties/properties.controller.ts index b09a3cf..b5abe5b 100644 --- a/apps/api/src/properties/properties.controller.ts +++ b/apps/api/src/properties/properties.controller.ts @@ -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); + } } diff --git a/apps/api/src/properties/properties.service.ts b/apps/api/src/properties/properties.service.ts index cf7a578..31c6326 100644 --- a/apps/api/src/properties/properties.service.ts +++ b/apps/api/src/properties/properties.service.ts @@ -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 } }); + } } diff --git a/apps/api/src/properties/property.dto.ts b/apps/api/src/properties/property.dto.ts new file mode 100644 index 0000000..f3da599 --- /dev/null +++ b/apps/api/src/properties/property.dto.ts @@ -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; +} diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index d1830a2..33b0f35 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -97,7 +97,11 @@ function Detail({ id }: { id: string }) { - + - +
+ + {canCreate && ( + + + Nueva propiedad + + )} +
{properties.length === 0 ? (
diff --git a/apps/web/src/app/servicios/[id]/editar/page.tsx b/apps/web/src/app/servicios/[id]/editar/page.tsx new file mode 100644 index 0000000..fa98084 --- /dev/null +++ b/apps/web/src/app/servicios/[id]/editar/page.tsx @@ -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 ( + + + + ); +} + +function EditarPropiedad({ id }: { id: string }) { + const allowed = useCan("property:update"); + const [property, setProperty] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!allowed) return; + getProperty(id) + .then(setProperty) + .catch((e) => setError(e?.message ?? "No se pudo cargar la propiedad.")); + }, [id, allowed]); + + return ( + <> +
+ ← Propiedad +

Editar propiedad

+
+ {!allowed ? ( +
+ No tiene permisos para editar propiedades. +
+ ) : error ? ( +
{error}
+ ) : !property ? ( +
+ ) : ( + + )} + + ); +} diff --git a/apps/web/src/app/servicios/[id]/page.tsx b/apps/web/src/app/servicios/[id]/page.tsx index 7b422d2..5d241d3 100644 --- a/apps/web/src/app/servicios/[id]/page.tsx +++ b/apps/web/src/app/servicios/[id]/page.tsx @@ -3,19 +3,32 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/AppShell"; -import { getProperty } from "@/lib/api"; +import { + addService, + archiveProperty, + getProperty, + removePropertyDocument, + removeService, + removeTrust, + restoreProperty, + updateService, + upsertTrust, +} from "@/lib/api"; +import { useCan } from "@/lib/abilities"; +import { ChildCollection, type ChildConfig } from "@/components/ChildCollection"; import { expiryPhrase, formatDate, formatMoney, formatNumber, + SERVICE_KIND_LABELS, serviceKindGlyph, serviceKindLabel, serviceNoteLabel, SIN_NOMBRE, trustStatusLabel, } from "@/lib/labels"; -import type { PropertyDetail, Service, Transaction } from "@/lib/types"; +import type { PropertyDetail, Service, Transaction, TrustInput } from "@/lib/types"; export default function PropiedadDetailPage({ params, @@ -76,9 +89,14 @@ function Detail({ id }: { id: string }) { if (!data) return null; + const reload = () => getProperty(id).then(setData).catch(() => {}); + return (
- +
+ + +
@@ -86,10 +104,246 @@ function Detail({ id }: { id: string }) { {data.policy && } +
); } +/** Edit / archive controls for the property header. */ +function PropertyActions({ + data, + onChange, +}: { + data: PropertyDetail; + onChange: () => void; +}) { + const canEdit = useCan("property:update"); + const canDelete = useCan("property:delete"); + const [busy, setBusy] = useState(false); + const archived = data.archivedAt != null; + + async function toggle() { + const verb = archived ? "restaurar" : "archivar"; + if (!window.confirm(`¿Seguro que desea ${verb} esta propiedad?`)) return; + setBusy(true); + try { + if (archived) await restoreProperty(data.id); + else await archiveProperty(data.id); + onChange(); + } catch (e) { + window.alert((e as Error)?.message ?? "No se pudo completar la acción."); + } finally { + setBusy(false); + } + } + + if (!canEdit && !canDelete) return null; + return ( +
+ {archived && Archivada} + {canEdit && ( + + Editar + + )} + {canDelete && ( + + )} +
+ ); +} + +/** Editable services + trust + documents — only for users who can edit. */ +function PropertyEditor({ + data, + onChange, +}: { + data: PropertyDetail; + onChange: () => void; +}) { + const canEdit = useCan("property:update"); + if (!canEdit) return null; + + const SERVICES: ChildConfig = { + apiKind: "services", + title: "Servicios", + fields: [ + { + key: "kind", + label: "Tipo", + type: "select", + options: Object.entries(SERVICE_KIND_LABELS).map(([value, label]) => ({ + value, + label, + })), + }, + { key: "accountNumber", label: "Cuenta" }, + { key: "meterNumber", label: "Medidor" }, + { key: "route", label: "Ruta" }, + { key: "dueDay", label: "Día pago" }, + { key: "notes", label: "Notas" }, + { key: "active", label: "Activo", type: "checkbox" }, + ], + }; + + return ( +
+
+ +

Administrar propiedad

+
+ + []} + canEdit={canEdit} + onAdd={async (p) => { await addService(data.id, p as never); onChange(); }} + onSave={async (sid, p) => { await updateService(data.id, sid, p as never); onChange(); }} + onRemove={async (sid) => { await removeService(data.id, sid); onChange(); }} + /> + + + + {data.documents.length > 0 && ( +
+

Documentos

+
+ + + + + + {data.documents.map((d) => ( + + + + + + ))} + +
TipoClaveAcción
{d.documentType ?? "—"}{d.storageKey ?? "—"} +
+ +
+
+
+

+ La carga de nuevos documentos requiere el almacenamiento de archivos + (pendiente); aquí solo se pueden eliminar los existentes. +

+
+ )} +
+ ); +} + +/** Trust is 1:1 — a small inline form that upserts or clears it. */ +function TrustEditor({ + data, + onChange, +}: { + data: PropertyDetail; + onChange: () => void; +}) { + const t = data.trustAccount; + const [bankName, setBankName] = useState(t?.bankName ?? ""); + const [trustNumber, setTrustNumber] = useState(t?.trustNumber ?? ""); + const [bankFee, setBankFee] = useState(t?.bankFee != null ? String(t.bankFee) : ""); + const [dueDate1, setDueDate1] = useState(toDateInput(t?.dueDate1)); + const [dueDate2, setDueDate2] = useState(toDateInput(t?.dueDate2)); + const [busy, setBusy] = useState(false); + + async function save() { + setBusy(true); + const input: TrustInput = { + bankName: bankName.trim() || undefined, + trustNumber: trustNumber.trim() || undefined, + bankFee: bankFee.trim() === "" ? undefined : Number(bankFee), + dueDate1: dueDate1 || undefined, + dueDate2: dueDate2 || undefined, + }; + try { + await upsertTrust(data.id, input); + onChange(); + } catch (e) { + window.alert((e as Error)?.message ?? "No se pudo guardar el fideicomiso."); + } finally { + setBusy(false); + } + } + + async function clear() { + if (!window.confirm("¿Eliminar el fideicomiso de esta propiedad?")) return; + try { + await removeTrust(data.id); + onChange(); + } catch (e) { + window.alert((e as Error)?.message ?? "No se pudo eliminar."); + } + } + + return ( +
+

Fideicomiso

+
+ + + + + +
+
+ + {t && ( + + )} +
+
+ ); +} + +function toDateInput(v: string | null | undefined): string { + if (!v) return ""; + const d = new Date(v); + return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10); +} + function BackLink() { return ( diff --git a/apps/web/src/app/servicios/nuevo/page.tsx b/apps/web/src/app/servicios/nuevo/page.tsx new file mode 100644 index 0000000..9ec3b3c --- /dev/null +++ b/apps/web/src/app/servicios/nuevo/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Suspense } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { AppShell } from "@/components/AppShell"; +import { PropertyForm } from "@/components/PropertyForm"; +import { useCan } from "@/lib/abilities"; + +export default function NuevaPropiedadPage() { + return ( + + + + + + ); +} + +function NuevaPropiedad() { + const allowed = useCan("property:create"); + const params = useSearchParams(); + const customerId = params.get("customerId") ?? undefined; + const customerName = params.get("customerName") ?? undefined; + + return ( + <> +
+ ← Propiedades +

Nueva propiedad

+
+ {allowed ? ( + + ) : ( +
+ No tiene permisos para crear propiedades. +
+ )} + + ); +} diff --git a/apps/web/src/app/servicios/page.tsx b/apps/web/src/app/servicios/page.tsx index 7911e17..f47f08a 100644 --- a/apps/web/src/app/servicios/page.tsx +++ b/apps/web/src/app/servicios/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/AppShell"; +import { useCan } from "@/lib/abilities"; import { EXPIRY_WINDOW_DAYS, getPropertyFacets, @@ -81,6 +82,7 @@ export default function ServiciosPage() { } function ServiciosBrowser() { + const canCreate = useCan("property:create"); const [stats, setStats] = useState(null); const [facets, setFacets] = useState(null); @@ -164,7 +166,13 @@ function ServiciosBrowser() { <>

Administración de servicios

-

Propiedades

+
+

Propiedades

+ + {canCreate && ( + + Nueva propiedad + )} +
(() => initial(property)); + const [customerId, setCustomerId] = useState( + property?.customer.id ?? fixedCustomerId ?? "", + ); + const [customerName, setCustomerName] = useState( + property?.customer.name ?? fixedCustomerName ?? "", + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + function set(k: K, val: V[K]) { + setV((p) => ({ ...p, [k]: val })); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!customerId) { + setError("Seleccione un cliente propietario."); + return; + } + setSaving(true); + setError(null); + const base = { + addressLine1: s(v.addressLine1), + addressLine2: s(v.addressLine2), + phone1: s(v.phone1), + phone2: s(v.phone2), + phone3: s(v.phone3), + zone: s(v.zone), + }; + try { + const saved = editing + ? await updateProperty(property!.id, base) + : await createProperty({ ...base, customerId } as PropertyInput); + router.push(`/servicios/${saved.id}`); + } catch (e2) { + setError((e2 as Error)?.message ?? "No se pudo guardar la propiedad."); + setSaving(false); + } + } + + return ( +
+ {error &&
{error}
} +
+

Propiedad

+
+ + + + + + + +
+
+
+ + +
+
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index dcb43e6..1289175 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -34,9 +34,12 @@ import type { LookupsResponse, PropertyDetail, PropertyFacets, + PropertyInput, PropertyListResponse, PropertySort, PropertyStats, + ServiceInput, + TrustInput, Role, ServiceKind, Statement, @@ -338,6 +341,71 @@ export function getProperty( return apiFetch(`/properties/${id}?days=${days}`); } +export function createProperty(input: PropertyInput): Promise { + return apiFetch("/properties", { + method: "POST", + body: JSON.stringify(input), + }); +} +export function updateProperty( + id: string, + input: Partial, +): Promise { + return apiFetch(`/properties/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} +export function archiveProperty(id: string): Promise { + return apiFetch(`/properties/${id}`, { method: "DELETE" }); +} +export function restoreProperty(id: string): Promise { + return apiFetch(`/properties/${id}/restore`, { method: "POST" }); +} + +// Service child CRUD. +export function addService(propertyId: string, input: ServiceInput): Promise { + return apiFetch(`/properties/${propertyId}/services`, { + method: "POST", + body: JSON.stringify(input), + }); +} +export function updateService( + propertyId: string, + serviceId: string, + input: Partial, +): Promise { + return apiFetch(`/properties/${propertyId}/services/${serviceId}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} +export function removeService(propertyId: string, serviceId: string): Promise { + return apiFetch(`/properties/${propertyId}/services/${serviceId}`, { + method: "DELETE", + }); +} + +// Trust account (1:1 upsert). +export function upsertTrust(propertyId: string, input: TrustInput): Promise { + return apiFetch(`/properties/${propertyId}/trust`, { + method: "PUT", + body: JSON.stringify(input), + }); +} +export function removeTrust(propertyId: string): Promise { + return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" }); +} + +export function removePropertyDocument( + propertyId: string, + documentId: string, +): Promise { + return apiFetch(`/properties/${propertyId}/documents/${documentId}`, { + method: "DELETE", + }); +} + /* ------------------------------------------- Billing / statements module */ export interface MovementQuery { diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 3036a3d..a34efec 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -510,6 +510,35 @@ export interface PropertyListItem { activeServiceCount: number; documentCount: number; trust: TrustSummary | null; + archived: boolean; +} + +/** Editable property-header fields — shared by the form and the API. */ +export interface PropertyInput { + customerId: string; + policyId?: string; + addressLine1?: string; + addressLine2?: string; + phone1?: string; + phone2?: string; + phone3?: string; + zone?: string; +} +export interface ServiceInput { + kind: ServiceKind; + accountNumber?: string; + meterNumber?: string; + route?: string; + dueDay?: string; + active?: boolean; + notes?: string; +} +export interface TrustInput { + bankName?: string; + trustNumber?: string; + bankFee?: number; + dueDate1?: string; + dueDate2?: string; } export interface PropertyListResponse { @@ -561,6 +590,7 @@ export interface PropertyDetail { phone2: string | null; phone3: string | null; zone: string | null; + archivedAt: string | null; legacySourceTable: string | null; legacyId: string | null; customer: PropertyOwnerRef; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 35e8917..1f21c3b 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -321,6 +321,8 @@ model Property { phone2 String? phone3 String? zone String? + // Soft-delete marker (see Customer.archivedAt). + archivedAt DateTime? legacySourceTable String? legacyId String? createdAt DateTime @default(now())