Compare commits
30
Commits
@@ -0,0 +1,16 @@
|
||||
# Keep the build context small + deterministic. node_modules, build output, and
|
||||
# the migration venv are all recreated inside the image, never copied from host.
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.next
|
||||
**/.turbo
|
||||
apps/web/.next
|
||||
packages/database/generated
|
||||
migration/.venv
|
||||
migration/**/__pycache__
|
||||
**/*.log
|
||||
.git
|
||||
.idea
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,91 @@
|
||||
# Build + push the API and web container images to the git.mancinas.io registry.
|
||||
#
|
||||
# Two images from this one repo:
|
||||
# git.mancinas.io/rmancinas/jorgecuadros-api
|
||||
# git.mancinas.io/rmancinas/jorgecuadros-web
|
||||
#
|
||||
# Comprehensive versioning (docker/metadata-action). Every build pushes a set
|
||||
# of tags so an image is addressable at several granularities:
|
||||
# - vX.Y.Z / vX.Y when the trigger is a git tag vX.Y.Z (releases)
|
||||
# - <branch> the branch that was pushed (e.g. master, feat-foo)
|
||||
# - sha-<short> immutable per-commit id, always present
|
||||
# - latest only on the default branch (master)
|
||||
# The same version string + commit + build date are baked into the image as
|
||||
# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running
|
||||
# container can report exactly what is deployed.
|
||||
#
|
||||
# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images.
|
||||
|
||||
name: Build and Push Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "apps/**"
|
||||
- "packages/**"
|
||||
- "docker/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".gitea/workflows/build.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.mancinas.io
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.image }}
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:27-dind
|
||||
options: --privileged
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image: jorgecuadros-api
|
||||
dockerfile: docker/api.Dockerfile
|
||||
- image: jorgecuadros-web
|
||||
dockerfile: docker/web.Dockerfile
|
||||
steps:
|
||||
- name: Install Node.js for actions
|
||||
run: apk add --no-cache nodejs npm
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=ref,event=branch
|
||||
type=sha,format=short,prefix=sha-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.meta.outputs.version }}
|
||||
GIT_SHA=${{ github.sha }}
|
||||
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
||||
@@ -8,7 +8,13 @@ build/
|
||||
*.log
|
||||
migration/output/
|
||||
migration/.venv/
|
||||
migration/ingest/
|
||||
migration/backups/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
packages/database/generated/
|
||||
.DS_Store
|
||||
.idea/
|
||||
*.tsbuildinfo
|
||||
.codegraph/
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ All tables get a surrogate `id` (uuid or serial) plus, where the row came from a
|
||||
- `service_documents` (extracted blobs), `trust_accounts` (from `TRUSTVENCE`).
|
||||
|
||||
**Shared financial ledger** (one office, one set of books — no reason to keep insurance and utility transactions in separate schemas):
|
||||
- `transactions` — unifies utilities' `EFECTIVO`/`EFECTIVO FM3`/`EFECTIVO_BACKUP`/`FEE ANUAL`/`datos2`/`fee15`/`billing`/`CHEQUE FM3`/`IVA 2015` and insurance's `EFECTIVO`, tagged by `domain` (utility/insurance/trust) and carrying the provenance columns so the de-duplication across those overlapping snapshot tables is traceable, not destructive.
|
||||
- `transactions` — unifies utilities' `EFECTIVO`/`EFECTIVO FM3`/`EFECTIVO_BACKUP`/`FEE ANUAL`/`datos2`/`fee15`/`billing`/`CHEQUE FM3`/`IVA 2015` and insurance's `EFECTIVO`, tagged by `domain` (utility/insurance/trust) and carrying the provenance columns so the de-duplication across those overlapping snapshot tables is traceable, not destructive. **`amount` is signed:** negative = charge (cargo), positive = credit (abono), so `SUM(amount)` per customer per currency *is* the balance — negative means the customer owes the office. The two currencies are never summed together (see the billing module note in Build sequencing step 6).
|
||||
- `exchange_rates` (from `TIPO HIST`), `type_transactions` (carry over ES/EN lookup as-is).
|
||||
- `bank_transactions` — the company's own operating bank register, from SCOTHIA's `DATOS E`/`DATOS I` unified into one signed-amount table (income positive, expense negative) with a `category` FK to `business_line_categories` (from `TABLA RAMODOS`) and a `cleared`/`operado` flag. This is deliberately **separate** from customer-facing `transactions` — it's the office's own bank reconciliation book, not money owed by/to a customer — but sharing the `business_line_categories` lookup lets you eventually answer "how much of our actual bank activity ties back to insurance vs. utilities vs. trust," which is a natural reporting win from unifying these three sources.
|
||||
- `business_line_categories` (from `TABLA RAMODOS`).
|
||||
@@ -108,8 +108,10 @@ All tables get a surrogate `id` (uuid or serial) plus, where the row came from a
|
||||
Given the amount of near-duplicate/overlapping data across snapshot tables (multiple `EFECTIVO*` variants, multiple year-stamped billing tables, `COBRO3` vs `DATGRAL`), doing a direct Access → normalized-MySQL transform in one pass is risky — a bug loses the ability to check itself against the source.
|
||||
|
||||
1. **Raw staging load**: dump every non-scratch Access table 1:1 into a MySQL `staging` (per-source schema/database, e.g. `stg_utilities`/`stg_seguros`/`stg_scothia`) — same columns, minimal type coercion — via a Python script across all four source files. Already built and run against real data as `migration/load_staging.py` in the new repo — see Status below. This is the audit trail — nothing is transformed yet. **Extraction toolchain note:** the original build used `pyodbc` + the Windows Access ODBC driver; the project has since moved to a macOS machine, so the extraction layer (`migration/extract.py`) is being reworked to use **mdbtools** (`mdb-tables`/`mdb-export`, installed via Homebrew) instead. mdbtools has been verified against the real files to read table data, accented-column tables (which broke pyodbc's UTF-16 path — e.g. `PROPANO`), and per-table exports cleanly. mdbtools does **not** extract Forms/Reports/Queries, but those were already captured on Windows via DAO/COM and are frozen in `migration/objects.json` + `docs/LEGACY_DATABASES_OBJECTS.md`, so nothing is lost. The only piece needing extra handling under mdbtools is `LONGBINARY` blob/document extraction (step 4), where mdbtools emits the OLE wrapper — addressed when step 4 runs, not a blocker for steps 1–3.
|
||||
2. **Reconciliation pass** — **DONE** (`migration/reconcile.py` → `migration/RECONCILIATION.md`, run against the staged data). For each set of overlapping tables, it probes a deliberate *business key* (not naive full-row match, which gives a misleading ~0 overlap everywhere) and reports what's actually duplicate vs. distinct. **Outcome overturned all three of the plan's original "duplicate" assumptions — the union/de-dup rules below are now decided by the data:**
|
||||
- **`EFECTIVO` vs `EFECTIVO_BACKUP`:** *not* a live/backup duplicate pair. `folio` is a per-table sequential number that **collides** (12,363 shared folio numbers, all carrying different transactions); on the real business key `(cl,fecha,monto,conepto)` only **2 rows** overlap. They are near-disjoint ledgers (BACKUP ≈ 2017–2022, EFECTIVO recent). **Rule: migrate both**, keyed internally by `(legacy_source_table, folio)` provenance; no folio de-dup, don't drop BACKUP. `EFECTIVO FM3`/`CHEQUE FM3` are a separate `fee/tax/multa` stream, migrated distinctly. (`monedas` needs currency normalization — `PESOS`/`Pesos`/`DOLLARS` variants.)
|
||||
2. **Reconciliation pass** — **DONE** (`migration/reconcile.py` → `migration/RECONCILIATION.md`, run against the staged data). For each set of overlapping tables, it probes a deliberate *business key* (not naive full-row match, which gives a misleading ~0 overlap everywhere) and reports what's actually duplicate vs. distinct. The union/de-dup rules below are decided by the data:
|
||||
- **`EFECTIVO` vs `EFECTIVO_BACKUP`: `EFECTIVO_BACKUP` is a stale backup copy — de-dup it. (Corrected 2026-07-22; see the box below.)** On the canonicalized business key `(cl,fecha,monto,conepto)`, **12,386 of BACKUP's 12,387 rows already exist verbatim in `EFECTIVO`** — same customer, same timestamp to the second, same amount, same concept text — leaving exactly **1** genuinely new row. `folio` is a per-table sequential number that **collides** (12,363 shared numbers, 12,204 of them on different payments), so it can never be the de-dup key. **Rule: load `EFECTIVO` in full; from `EFECTIVO_BACKUP` load only business-key-new rows.** `EFECTIVO FM3`/`CHEQUE FM3` are a separate `fee/tax/multa` stream, migrated distinctly. (`monedas` needs currency normalization — `PESOS`/`Pesos`/`DOLLARS` variants.)
|
||||
|
||||
> **Why this was wrong the first time.** The original pass reported only **2** overlapping rows and concluded the two tables were "near-disjoint ledgers, migrate both". That verdict came from a bug in `reconcile.py`, which compared business-key columns as raw strings on the premise that "every table went through the same mdb-export path, so identical source values serialize identically". They don't: `mdb-export` formats a numeric column from its *Access column type*, so the same amount is emitted as `5000` from one table and `27000.0000` from the other, and no two rows could ever match on `monto`. `reconcile.py` now canonicalizes numeric key columns before comparing. The bad rule had already been loaded: the ledger carried 45,861 rows with **12,386 duplicated payments**, roughly doubling every customer's historical receipt total — which would have made every balance and statement in step 6 wrong. Re-running `run_all.py` brings the ledger to **33,475** rows. Groups 2 and 3 below were re-checked under the fix and their verdicts are unchanged.
|
||||
- **`datos2` vs `FEE ANUAL` vs `fee15`:** *not* near-duplicate exports. They are **disjoint billing runs from different periods** (`datos2` ≈2025–26, `FEE ANUAL` 2018-01-03, `fee15` 2017-01-10 — each period-table `refer` is a single constant); zero real-identity overlap. **Rule: migrate all three, no de-dup**; keep `datos2.due_date` (null for the others).
|
||||
- **`DATGRAL` vs `COBRO3`:** `COBRO3` is *not* a filtered snapshot of the customer master — its `fee` is a **constant 75** for all 181 rows (a saved charge worklist / "cobro" = collection), and every `num_id` already exists in `DATGRAL`. **Rule: `DATGRAL` is the sole utilities customer master; COBRO3 contributes zero customers** — model its 181 rows as charge transactions if worth keeping, else exclude.
|
||||
3. **Transform + load**: SQL/TypeScript scripts (versioned in the new repo under `migration/`) that read `staging`, apply the customer-matching and unpivot logic described above, and upsert into the real Prisma-managed tables, writing `legacy_*` provenance on every row.
|
||||
@@ -123,10 +125,10 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
|
||||
3. Customer module (list/search/detail — the unified view is the core deliverable) backed by finished migration steps 3–5 for customers only.
|
||||
4. Insurance module (policies, vehicles, beneficiaries, claims) on top of the same customer records.
|
||||
5. Utilities module (properties, services, trust accounts) on top of the same customer records.
|
||||
6. Shared billing/statements module (the payoff: one statement per customer spanning both utility and insurance transactions).
|
||||
6. Shared billing/statements module (the payoff: one statement per customer spanning both utility and insurance transactions) — **DONE**. `apps/api/src/billing/` + web `/estado-cuenta` and `/estado-cuenta/[id]`. Two questions, two views: a per-customer **balances worklist** (who owes what) and a cross-customer **movement browser** (every charge and credit, filterable by line, concept, origin table and date range, with totals for the whole filtered set). The detail page is the actual statement: balance per currency, the same balance split by business line, charges broken out by concept, and the full movement list with a running balance. **Design constraint that shapes the whole module: balances are reported per currency and never collapsed into one number.** 912 of the 1,269 customers with a ledger move in both MXN and USD, the charge side is MXN-only while receipts arrive in both, and the legacy data never stored the exchange rate applied to a movement — so a single "total balance" would be a figure that never existed in the books.
|
||||
7. Bank register module (`bank_transactions`/`business_line_categories` from SCOTHIA) — small, self-contained, and has no customer FK, so it can slot in independently once the core migration pipeline exists; low risk, low priority relative to the customer-facing modules.
|
||||
8. VPS provisioning + Tailscale + MySQL replication setup. `utility_dbo`'s schema is now available (full dump on disk — 55 tables; see Status), so the exact replicated table/column set and inbox-table shape can be finalized against the real portal DB and the portal PHP code (`my-jorgecuadros-web`) that reads/writes it.
|
||||
9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set.
|
||||
9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. **The separate Phase B Access additive sync is implemented:** `migration/run_all.py --sync` and the admin `SYNC` job upsert legacy-owned rows without truncating the database or touching manual rows. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set.
|
||||
10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly.
|
||||
|
||||
## Status
|
||||
@@ -144,6 +146,9 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
||||
- **i18n:** **Spanish-first** UI — matches the source data and staff usage.
|
||||
- **CI/CD:** **Gitea Actions** on `git.mancinas.io` — build image, push to the git.mancinas.io container registry, deploy to Portainer (mirrors the `portainer-gitea-deploy` pattern already used on this LAN). Jenkins/`git.freakma.com` dropped.
|
||||
- **`utility_dbo`:** resolved — full dump + portal code available (see Status).
|
||||
- **Phase B Access additive sync:** implemented in `migration/run_all.py --sync` and the admin
|
||||
`SYNC` job. It preserves manual rows and stable legacy-owned primary keys; end-to-end database
|
||||
validation remains before production use.
|
||||
|
||||
## Open items (ops, not design)
|
||||
|
||||
@@ -154,5 +159,7 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
||||
|
||||
- Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check.
|
||||
- App: standard NestJS unit/integration tests per module (auth guards, Prisma queries), Playwright/Cypress e2e for the core "look up a customer, see their unified policies + services + statement" flow — the thing the whole project exists to deliver.
|
||||
- Sync: once the VPS replica and inbox tables exist, verify replication lag stays low (a few seconds to low minutes) and that a payment/propane submission on the portal reliably shows up in the internal app within one polling interval, before relying on it operationally.
|
||||
- **Sync:** Phase B Access additive sync now has automated CLI/admin wiring, but must be verified
|
||||
against a disposable DB with stable-PK, manual-row, update, and source-delete cases. The
|
||||
separate VPS/portal sync still requires VPS provisioning and inbox-table implementation.
|
||||
- Before cutover: run the new app against migrated data side-by-side with the live Access files for a period, comparing balances/statements for a sample of active customers to catch migration logic errors before the Access files are retired.
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Jorge Cuadros & Asociados — Platform
|
||||
|
||||
Internal platform for a Baja California insurance brokerage and property-services
|
||||
firm: a single expedient joining each client's **properties/services**,
|
||||
**insurance policies**, **account statement**, and the firm's **checkbook**.
|
||||
It replaces a legacy PHP/Access app (see `RESUME.md` and `PLAN.md` for the full
|
||||
history and rebuild rationale).
|
||||
|
||||
The UI is Spanish-first; the codebase and this document are in English.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tech | Port |
|
||||
| --------- | -------------------------------------------------------------- | ---- |
|
||||
| Web | Next.js 14 (App Router, React 18) | 3000 |
|
||||
| API | NestJS 10 · Passport local + `express-session` · Argon2 | 3001 |
|
||||
| Database | MySQL 8 via Prisma 5 (`@jorgecuadros/database` workspace pkg) | 3306 |
|
||||
| Migration | Python 3 pipeline (legacy Access → staging → transforms) | — |
|
||||
|
||||
Monorepo managed with **pnpm workspaces**. Node **>= 20**.
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
apps/
|
||||
web/ Next.js frontend (@jorgecuadros/web)
|
||||
api/ NestJS backend (@jorgecuadros/api)
|
||||
scripts/seed-user.mjs idempotent admin seeder
|
||||
packages/
|
||||
database/ Prisma schema + generated client (@jorgecuadros/database)
|
||||
prisma/schema.prisma
|
||||
migration/ One-off Python ETL from the legacy Access DB (run_all.py)
|
||||
docker/ Dockerfiles for api + web
|
||||
docker-compose.yml mysql + api + web
|
||||
.env.example copy to .env
|
||||
```
|
||||
|
||||
API feature modules: `auth`, `users`, `customers`, `policies`, `properties`,
|
||||
`billing`, `bank`. Web routes: `/clientes`, `/polizas`, `/servicios`,
|
||||
`/estado-cuenta`, `/banco` (chequera), `/catalogos`, `/usuarios`, `/login`.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 20 and **pnpm** (`npm i -g pnpm`)
|
||||
- Docker (for MySQL, or bring your own MySQL 8)
|
||||
- Python 3 — only if you run the legacy data migration
|
||||
|
||||
---
|
||||
|
||||
## Run it locally (development)
|
||||
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
pnpm blocks postinstall build scripts by default; the trusted ones
|
||||
(`argon2`, `prisma`, `@prisma/client`, `@prisma/engines`, `@nestjs/core`) are
|
||||
allowlisted in `pnpm-workspace.yaml`, so the native builds run automatically.
|
||||
|
||||
### 2. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Then edit `.env`. For the Docker MySQL below the defaults already line up;
|
||||
just set a real `SESSION_SECRET`:
|
||||
|
||||
```env
|
||||
DATABASE_URL=mysql://jorgecuadros:jorgecuadros@localhost:3306/jorgecuadros
|
||||
SESSION_SECRET=<any long random string>
|
||||
WEB_ORIGIN=http://localhost:3000
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
||||
```
|
||||
|
||||
The API loads `DATABASE_URL`, `SESSION_SECRET`, `WEB_ORIGIN`, and optional
|
||||
`PORT` (default `3001`). The web app only needs `NEXT_PUBLIC_API_ORIGIN`.
|
||||
|
||||
### 3. Start MySQL
|
||||
|
||||
```bash
|
||||
docker compose up -d mysql
|
||||
```
|
||||
|
||||
(Or point `DATABASE_URL` at an existing MySQL 8 instance.)
|
||||
|
||||
### 4. Create the schema + generate the Prisma client
|
||||
|
||||
The schema is managed with `prisma db push` (no migration history committed):
|
||||
|
||||
```bash
|
||||
pnpm --filter @jorgecuadros/database exec prisma db push
|
||||
pnpm --filter @jorgecuadros/database generate
|
||||
```
|
||||
|
||||
### 5. Seed a sign-in user
|
||||
|
||||
```bash
|
||||
node apps/api/scripts/seed-user.mjs
|
||||
```
|
||||
|
||||
Idempotent (upsert by email). Defaults — override with `SEED_EMAIL`,
|
||||
`SEED_PASSWORD`, `SEED_NAME`:
|
||||
|
||||
- email: `admin@jorgecuadros.local`
|
||||
- password: `ChangeMe!2026`
|
||||
- role: `ADMIN`
|
||||
|
||||
### 6. Run the apps (two terminals)
|
||||
|
||||
```bash
|
||||
# API → http://localhost:3001
|
||||
pnpm --filter @jorgecuadros/api start:dev
|
||||
|
||||
# Web → http://localhost:3000
|
||||
pnpm --filter @jorgecuadros/web dev
|
||||
```
|
||||
|
||||
Root shortcuts also exist: `pnpm dev:api`, `pnpm dev:web`.
|
||||
|
||||
### 7. Log in
|
||||
|
||||
Open http://localhost:3000, sign in with the seeded credentials.
|
||||
Sessions are cookie-based and last 8 hours.
|
||||
|
||||
---
|
||||
|
||||
## Run it with Docker (full stack)
|
||||
|
||||
Builds MySQL + API + web from `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
export SESSION_SECRET=$(openssl rand -hex 32)
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Web on http://localhost:3000, API on http://localhost:3001. `SESSION_SECRET`
|
||||
is required (compose fails without it). After first boot, push the schema and
|
||||
seed a user against the container DB:
|
||||
|
||||
```bash
|
||||
docker compose exec api node apps/api/scripts/seed-user.mjs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common commands
|
||||
|
||||
| Task | Command |
|
||||
| ------------------------ | -------------------------------------------------------------- |
|
||||
| Install | `pnpm install` |
|
||||
| Dev — API | `pnpm dev:api` |
|
||||
| Dev — Web | `pnpm dev:web` |
|
||||
| Build all | `pnpm build` |
|
||||
| Generate Prisma client | `pnpm prisma:generate` |
|
||||
| Push schema (dev) | `pnpm --filter @jorgecuadros/database exec prisma db push` |
|
||||
| Prisma Studio | `pnpm prisma:studio` |
|
||||
| API tests | `pnpm --filter @jorgecuadros/api test` |
|
||||
| Lint (web / api) | `pnpm --filter @jorgecuadros/web lint` · `... /api lint` |
|
||||
| Seed admin user | `node apps/api/scripts/seed-user.mjs` |
|
||||
|
||||
---
|
||||
|
||||
## Auth & roles
|
||||
|
||||
Session auth via Passport local strategy; passwords hashed with Argon2 (no
|
||||
plaintext, unlike the legacy app). Roles gate the UI and API — e.g. managing
|
||||
`/catalogos` and `/usuarios` requires the appropriate ability (`ADMIN` /
|
||||
`MANAGER`). New users are created by an admin in `/usuarios`; the first admin
|
||||
comes from the seed script above.
|
||||
|
||||
---
|
||||
|
||||
## Legacy data migration (optional)
|
||||
|
||||
`migration/` holds the one-off Python ETL that lifts data out of the old
|
||||
Microsoft Access database into MySQL.
|
||||
|
||||
```bash
|
||||
pip install -r migration/requirements.txt
|
||||
python migration/run_all.py
|
||||
```
|
||||
|
||||
> ⚠️ `run_all.py` truncates and reloads **all** downstream tables. Never run an
|
||||
> individual `transform_*.py` in isolation — it orphans dependent tables. See
|
||||
> `migration/RECONCILIATION.md` for details.
|
||||
|
||||
---
|
||||
|
||||
## Production notes
|
||||
|
||||
- Use `pnpm --filter @jorgecuadros/database exec prisma migrate deploy` if/when
|
||||
a committed migration history is adopted; today dev uses `db push`.
|
||||
- Set a strong `SESSION_SECRET` and a locked-down `DATABASE_URL`.
|
||||
- The API expects `WEB_ORIGIN` to match the browser origin for session cookies.
|
||||
- Documents are stored in MinIO in the deployed environment (see `RESUME.md`).
|
||||
@@ -4,11 +4,11 @@ Comprehensive state-of-the-world doc for picking this project back up. Read this
|
||||
before doing anything else in a fresh session — it front-loads everything that
|
||||
took multiple rounds of investigation to establish.
|
||||
|
||||
**Companion doc:** the full architecture/migration plan is copied into this
|
||||
repo as [`PLAN.md`](PLAN.md) (source of truth is
|
||||
`C:\Users\ricar\.claude\plans\logical-yawning-tome.md` — copy here if that
|
||||
one gets updated further). This file is the "what happened and what's next"
|
||||
companion to that plan, not a replacement for it. Read both.
|
||||
**Companion doc:** the full architecture/migration plan is [`PLAN.md`](PLAN.md) in
|
||||
this repo — **that is the source of truth for the design.** (It began as
|
||||
`~/.claude/plans/logical-yawning-tome.md` on the old Windows machine; that copy is
|
||||
gone and no longer authoritative.) This file is the "what happened and what's next"
|
||||
companion, not a replacement. Read both.
|
||||
|
||||
---
|
||||
|
||||
@@ -32,28 +32,37 @@ infrastructure decisions below.
|
||||
|
||||
## 2. Where everything lives (file paths)
|
||||
|
||||
**Source data (do not modify — read-only references):**
|
||||
- `C:\Users\ricar\Downloads\Jorge\UTILITIES.accdb` — utilities business, 52 tables, ~538MB
|
||||
- `C:\Users\ricar\Downloads\Jorge\SEGUROS 16.mdb` — insurance frontend shell, **empty**, all data is in `_be`
|
||||
- `C:\Users\ricar\Downloads\Jorge\SEGUROS 16_be.mdb` — insurance backend, 64 tables, ~882MB
|
||||
- `C:\Users\ricar\Downloads\Jorge\SCOTHIA.mdb` — office's own Scotiabank checking register ("chequera"), 7 tables, ~3MB
|
||||
> Paths below are the **current macOS machine**. The project moved Windows → macOS on
|
||||
> 2026-07-22; anything still written as `C:\Users\ricar\...` in older notes is stale.
|
||||
|
||||
**Source data (do not modify — read-only references), all in `~/Downloads/JorgeCuadros-Legacy/`:**
|
||||
- `UTILITIES.accdb` — utilities business, 52 tables, ~538MB
|
||||
- `SEGUROS 16.mdb` — insurance frontend shell, **no data tables**, but holds *all* of the
|
||||
insurance line's Reports/Forms/Queries
|
||||
- `SEGUROS 16_be.mdb` — insurance backend, 64 tables, ~882MB
|
||||
- `SCOTHIA.mdb` — office's own Scotiabank checking register ("chequera"), 7 tables, ~3MB
|
||||
- `utility_dbo.sql` — customer portal's live DB dump (1.3 GB, 55 tables)
|
||||
- `jorgecuadros.sql` — older/partial export (38 MB, 11 tables), **not** the portal live DB
|
||||
- **Full structural reference for all three, usable without Windows or the original files:** [`docs/LEGACY_DATABASES.md`](docs/LEGACY_DATABASES.md) — every table, every column with type/nullability, the cross-reference keys between the three databases, and every known data-quality quirk (the UTF-16 decode bug, the corrupted `MULT` row, near-duplicate snapshot tables, etc.), all generated from a live read of the real files via `migration/catalog_schema.py`. Regenerate it if the source files change; the raw JSON it's built from is checked in at `migration/catalog.json`.
|
||||
- **Queries/Forms/Reports reference:** [`docs/LEGACY_DATABASES_OBJECTS.md`](docs/LEGACY_DATABASES_OBJECTS.md) — none of this is visible via ODBC/`pyodbc`; it required DAO COM automation (`migration/catalog_objects.py`, needs `pywin32`) instead. Found 311 Reports, 271 Forms, and 1,274 Queries (751 "real," the rest Access-internal hidden subquery caches) across the three populated files — importantly, `SEGUROS 16.mdb` (which has zero data tables) turned out to hold *all* of the insurance line's Reports/Forms/Queries; `SEGUROS 16_be.mdb` is confirmed pure data storage. The real queries' full SQL text is the best available record of actual business logic (billing math, renewal batching) — worth reading before reimplementing any given feature from scratch. Raw JSON checked in at `migration/objects.json`.
|
||||
- `C:\Users\ricar\Downloads\jorgecuadros_app.sql` and `jorgecuadros_app (1).sql` — MySQL dumps of the customer-portal's **tracking/analytics** sidecar DB (`browse_tracking`, `devices` push-tokens, `task_tracking`) from `mysql.freakma.com`. **Not** the portal's real data DB — see open item #1 below.
|
||||
- `jorgecuadros_app.sql` / `jorgecuadros_app (1).sql` (on the old machine) — MySQL dumps of the portal's **tracking/analytics** sidecar DB (`browse_tracking`, `devices` push-tokens, `task_tracking`). **Not** the portal's real data DB; superseded by `utility_dbo.sql` above.
|
||||
|
||||
**Customer-facing portal (out of scope to rebuild, but the sync target):**
|
||||
- `~/PhpstormProjects/my-jorgecuadros-web` — PHP/`mysqli`, ~397 files, core in `scripts/functions.php`. Reads/writes `utility_dbo`.
|
||||
|
||||
**Old internal app (reference-only, not being built on):**
|
||||
- `C:\Users\ricar\Downloads\Jorge\jorgecuadros-intra-webapp` — PHP, MySQL (`webapp_jorgecuadros`). Schema at `db\webapp_jorgecuadros.sql` is a useful reference for field mappings/business logic. Code itself is not being reused — see §4.
|
||||
- `jorgecuadros-intra-webapp` (on the old machine) — PHP, MySQL (`webapp_jorgecuadros`). Its `db/webapp_jorgecuadros.sql` is a useful reference for field mappings/business logic. Code itself is not reused — see §4.
|
||||
|
||||
**New platform (the actual deliverable, in progress):**
|
||||
- `C:\Users\ricar\Downloads\Jorge\jorgecuadros-platform` — the new repo. Not yet a git repository (no commits made this session — user hasn't asked for any yet).
|
||||
**New platform (the actual deliverable):**
|
||||
- `~/WebstormProjects/jorgecuadros-platform` — the repo. **Is** a git repo, branch `master`, 21 commits, remote `git.mancinas.io/rmancinas/jorgecuadros-platform`.
|
||||
|
||||
**The plan document:**
|
||||
- `C:\Users\ricar\.claude\plans\logical-yawning-tome.md` — full architecture, source-data inventory per table, target data model, migration strategy, infrastructure/sync design, open decisions, build sequencing. **This is the source of truth for the design** — this RESUME.md summarizes it plus session/environment state that isn't in the plan itself.
|
||||
- [`PLAN.md`](PLAN.md) in this repo — full architecture, source-data inventory per table, target data model, migration strategy, infrastructure/sync design, locked decisions, build sequencing. **This is now the source of truth for the design** (the original `~/.claude/plans/logical-yawning-tome.md` lived on the old Windows machine). This RESUME.md is the "what happened / what's next" companion.
|
||||
|
||||
**Ephemeral / will NOT persist across sessions** (session-scoped temp directory):
|
||||
- `C:\Users\ricar\AppData\Local\Temp\claude\...\scratchpad\` — contained exploratory helper scripts (`dump_schema.py`, `summarize_schema.py`, `test_decode_fix.py`) and the schema JSON/txt dumps used during initial analysis, plus a Parquet staging output from one run of the migration script. **None of this needs to be recovered** — the real, permanent versions of the useful scripts are in `jorgecuadros-platform/migration/`, and the Parquet output can be regenerated in under 2 minutes by rerunning `load_staging.py` (see §6).
|
||||
**Staged data (gitignored, regenerable):**
|
||||
- `migration/output/stg_utilities|stg_seguros|stg_scothia/*.parquet` — regenerate in ~2 min with `load_staging.py --output-dir ./output`. Every transform step reads from here.
|
||||
|
||||
## 3. Key decisions made this session
|
||||
## 3. Key decisions (locked — see `PLAN.md` → "Decisions (locked)")
|
||||
|
||||
| Decision | Answer | Why |
|
||||
|---|---|---|
|
||||
@@ -65,9 +74,10 @@ infrastructure decisions below.
|
||||
| Infrastructure | Internal server (private) + new VPS (Tailscale-linked) running a MySQL replica | Internal server has no inbound internet exposure; shared hosting can't be a replication target; a VPS you control can be both a real replication node and internet-reachable for the portal |
|
||||
| Auth mechanism | Session-based (Passport + `express-session`), Argon2 password hashing | Implemented already — see §5 |
|
||||
|
||||
## 4. What was actually built and verified this session
|
||||
## 4. What is built and verified
|
||||
|
||||
Everything below was **run and confirmed working**, not just written:
|
||||
Everything below was **run and confirmed working**, not just written. §8 carries the
|
||||
per-module detail and the running status; this section is the structural tour.
|
||||
|
||||
### 4.1 Repo scaffold
|
||||
- `jorgecuadros-platform/` — npm workspaces (`apps/*`, `packages/*`)
|
||||
@@ -94,7 +104,7 @@ Regenerate the client any time with:
|
||||
cd jorgecuadros-platform
|
||||
DATABASE_URL="mysql://user:pass@localhost:3306/placeholder" npx prisma generate --schema=packages/database/prisma/schema.prisma
|
||||
```
|
||||
(A real `DATABASE_URL` isn't needed for `generate`/`validate`, just a syntactically valid one — no live DB was available in this session, see §7.)
|
||||
(A real `DATABASE_URL` isn't needed for `generate`/`validate`, just a syntactically valid one. A live dev DB *is* available now — see §7 — so `prisma db push` works too.)
|
||||
|
||||
### 4.3 Docker Compose / Dockerfiles
|
||||
- `docker-compose.yml` — `mysql:8.4` + `api` + `web` services, healthchecked.
|
||||
@@ -103,17 +113,20 @@ DATABASE_URL="mysql://user:pass@localhost:3306/placeholder" npx prisma generate
|
||||
- **Not run** — this environment has no Docker installed (`docker --version` fails). Untested beyond visual review; verify on a machine with Docker before relying on it.
|
||||
|
||||
### 4.4 Migration pipeline (`migration/`) — run end-to-end against real data
|
||||
- `config.py` — manifest of the 3 Access source files (paths + per-source exclude lists for confirmed-scratch tables, with reasoning in comments)
|
||||
- `extract.py` — connects via `pyodbc` + the Windows Access ODBC driver (`Microsoft Access Driver (*.mdb, *.accdb)`, 64-bit — confirmed installed on this machine). Two real bugs found and fixed here:
|
||||
1. `cursor.columns()` hits a UTF-16 decode bug on some tables (confirmed: `PROPANO`, `FALTANTES AGUA`, `TIT`) — fixed by reading column names from `cursor.description` after a `SELECT *` instead.
|
||||
2. `cursor.fetchall()` aborts an entire table on the first corrupted row — confirmed on `MULT` (Jet/ACE-level "Record is deleted" error, HY109). Fixed by fetching row-by-row in a try/except, skipping and logging just the bad row. Recovered 763 of 764 rows in `MULT`. **Verified the cursor advances correctly and doesn't infinite-loop on the bad row** before trusting this for a full run.
|
||||
- `load_staging.py` — dumps every non-excluded table into either Parquet (`--output-dir`, no DB needed) or MySQL (`--database-url`, one database per source: `stg_utilities`/`stg_seguros`/`stg_scothia`). **Actually run** in Parquet mode against all three Access files: **82 tables staged successfully, zero unhandled errors**, `MULT`'s corrupted row correctly skipped and logged.
|
||||
- `requirements.txt` — `pyodbc`, `pandas`, `pyarrow`, `sqlalchemy`, `pymysql`.
|
||||
- `config.py` — manifest of the Access source files (`SOURCE_ROOT` + per-source exclude lists for confirmed-scratch tables, with reasoning in comments)
|
||||
- `extract.py` — shells out to **mdbtools** (`mdb-tables` / `mdb-export`, Homebrew). Rewritten from the original `pyodbc` + Windows Access ODBC version during the macOS move; public interface (`connect`/`list_tables`/`read_table`) unchanged. mdbtools also sidesteps both bugs the pyodbc path needed workarounds for: it reads accented-column tables (`PROPANO`, `FALTANTES AGUA`, `TIT`) cleanly instead of hitting a UTF-16 decode error, and it doesn't abort a whole table on `MULT`'s corrupted row.
|
||||
- What mdbtools **cannot** do is read Forms/Reports/Queries. Those were already captured on Windows via DAO COM and are frozen in `migration/objects.json` + `docs/LEGACY_DATABASES_OBJECTS.md` — nothing is lost, but they can't be re-extracted on this machine.
|
||||
- `load_staging.py` — dumps every non-excluded table into either Parquet (`--output-dir`, no DB needed) or MySQL (`--database-url`, one database per source: `stg_utilities`/`stg_seguros`/`stg_scothia`). 82 tables staged, zero unhandled errors.
|
||||
- `reconcile.py` → `RECONCILIATION.md` — the duplicate/distinct pass (step 2). See §8 step 3.
|
||||
- `transform_*.py`, `prune_empty_customers.py`, `blob_extract.py` — steps 3–4, all idempotent (truncate + rebuild).
|
||||
- `run_all.py` — **the entry point.** Runs every step in dependency order. See the ⚠️ in §7 for why you should never run a single transform on its own.
|
||||
- `dbenv.py` — `--env <name>` reads `deploy/.env.<name>` for the target DB.
|
||||
- `requirements.txt` — `pandas`, `pyarrow`, `sqlalchemy`, `pymysql`, `boto3` (no `pyodbc` — that was the Windows path).
|
||||
|
||||
To rerun (from `jorgecuadros-platform/migration`, after `pip install -r requirements.txt`):
|
||||
To rerun (from `migration/`, venv at `migration/.venv`):
|
||||
```bash
|
||||
python load_staging.py --output-dir ./output # Parquet, no DB needed — always works
|
||||
python load_staging.py --database-url mysql+pymysql://user:pass@host:3306/ # loads into real MySQL once available
|
||||
./.venv/bin/python load_staging.py --output-dir ./output # re-extract from Access (needs mdbtools + the source files)
|
||||
./.venv/bin/python run_all.py --env dev # full transform+load; add --stage to re-extract first
|
||||
```
|
||||
|
||||
## 5. Infrastructure & sync architecture (designed, not yet built)
|
||||
@@ -124,22 +137,61 @@ python load_staging.py --database-url mysql+pymysql://user:pass@host:3306/ # l
|
||||
- **Internal → VPS:** one-way native MySQL replication (binlog/GTID) for the subset of data the portal needs to read (statements, balances, customer profile). Internal-only tables (staff notes, adjuster info, activity logs) are deliberately excluded from what replicates.
|
||||
- **VPS → Internal:** the portal also *writes* (payment submissions, propane orders) — one-way replication can't carry that back, and multi-master MySQL replication was deliberately ruled out as too fragile for this system's size. Instead: unreplicated "inbox" tables on the VPS (`payment_submissions`, `propane_order_requests`) that the portal writes to directly, polled every 1–5 min by a worker on the internal server (over Tailscale) that turns new rows into real records.
|
||||
|
||||
## 6. Open items — need input/access before certain next steps can proceed
|
||||
## 6. Open items
|
||||
|
||||
1. **`utility_dbo` schema is still unknown.** This is the customer portal's actual live data database (referenced in the old app via `getExternalDBConnection()` at `mysql.freakma.com`, used there only for `email_alert_log`, but the mobile app almost certainly reads/writes statements, payments, and propane orders directly against it). The two SQL dumps provided (`jorgecuadros_app.sql`, `jorgecuadros_app (1).sql`) turned out to be a **separate** analytics/tracking database, not this one. When asked, the user pointed back to `SEGUROS 16_be.mdb` — worth revisiting; it's possible the intent was "the source data ultimately comes from the Access files" rather than "here is utility_dbo's schema." **Without the real `utility_dbo` schema, the sync worker's exact target tables/columns for the inbox pattern can't be finalized.** Ask for an export or read-only credentials, same as how the Access files and tracking-DB dumps were provided.
|
||||
2. **VPS not yet provisioned** — provider (Hetzner vs DigitalOcean), size, and Tailscale/MySQL setup on it are pending. Ops task, not something done in this session.
|
||||
3. **CI/hosting** — keep Jenkins + `git.freakma.com`, or move to GitHub Actions if the new repo lives elsewhere?
|
||||
4. **i18n** — nearly all source data and, presumably, staff usage is in Spanish; old app's code/UI was English-labeled. Confirm Spanish-first / bilingual / English before frontend work goes deep.
|
||||
5. **Reconciliation pass not started** (migration plan step 2) — the near-duplicate snapshot tables (`EFECTIVO`/`EFECTIVO FM3`/`EFECTIVO_BACKUP`, `FEE ANUAL`/`datos2`/`fee15`/`billing`, `DATGRAL` vs `COBRO3`) need a diff/dedupe pass against the staged data before any transform-and-load into the real schema. This is explicitly *not* a "guess the rule up front" thing — the plan calls for writing SQL against the staged Parquet/MySQL data to see what's actually duplicate vs. distinct.
|
||||
**Resolved since this section was first written** (kept as a pointer, not a to-do):
|
||||
`utility_dbo` schema (full dump on disk), CI/CD (Gitea Actions), i18n (Spanish-first), and
|
||||
the reconciliation pass (done, then corrected) are all closed. See §3 and §8.
|
||||
|
||||
## 7. Environment notes (this machine, in case it matters for reproducing)
|
||||
**Still open:**
|
||||
1. **VPS not yet provisioned** — provider (Hetzner vs DigitalOcean), size, Tailscale + MySQL
|
||||
replica setup. Pure ops task; the design is settled (§5). This is the only genuinely
|
||||
blocking item left on the roadmap.
|
||||
2. **Sync worker not built** — unblocked now that `utility_dbo` and the portal code are on
|
||||
disk, but depends on the VPS existing. Portal write points to poll: `peticion_gas`,
|
||||
PayPal payments, `notifications_settings`, `verification_codes`.
|
||||
3. **Old external-DB credential** — the old repo's `dbConnection.php` has a hardcoded
|
||||
plaintext MySQL password committed to git history. Not carried into the new platform,
|
||||
but rotate it regardless; it is already exposed.
|
||||
4. **`bank_transactions.categoryId` is null on all 22354 rows — RESOLVED as won't-build**
|
||||
(plan step 7). The concept→ramo classifier was investigated and dropped: `concepto` is a
|
||||
payee name (0 of 22354 match a category), and TABLA RAMODOS is a property-management
|
||||
expense chart of accounts + owner names, not the insurance/servicios/fideicomiso split it
|
||||
was assumed to be — so a classifier would invent data rather than produce a business-line
|
||||
view. The `/banco` module intentionally has no category dimension. See §8 step 7(b).
|
||||
5. **`TRASPASOS PAYPAL` is a clearing account, not a customer** — carries -7.03M MXN over
|
||||
309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in
|
||||
code; needs a business decision on how to model it.
|
||||
6. **DB Operations — Phase B (additive sync) — IMPLEMENTED, verification pending.** Phase A provides
|
||||
the admin-only `/operaciones` page + `ops` API module (ability `db:manage`, ADMIN), ingest
|
||||
folder, backup, restore, and destructive re-import. Phase B now enables `SYNC`: `OpsService`
|
||||
creates a safety backup and runs `run_all.py --sync`; transforms upsert legacy-owned rows by
|
||||
provenance keys while preserving existing PKs and rows whose `legacyId IS NULL` (manual).
|
||||
Prisma now enforces provenance uniqueness for properties, policies, transactions, vehicles,
|
||||
and bank transactions. Sync intentionally skips prune/blob steps so manual customers and
|
||||
document pointers are not removed. Python compilation plus API/web production builds pass;
|
||||
still required before production use: push updated Prisma schema and run an end-to-end sync
|
||||
against a disposable/dev DB proving stable PKs, manual-row preservation, changed-row updates,
|
||||
and legacy-delete handling.
|
||||
|
||||
- Windows, PowerShell primary, Git Bash also available.
|
||||
- Node v25.2.0, npm 11.6.2 — no pnpm, yarn is present but npm workspaces were used throughout.
|
||||
- Python 3.9 (`C:\Python39`), `pip install`'d this session: `pyodbc`, `pandas`, `pyarrow`, `psycopg2` (installed but no longer used after the MySQL switch — harmless to leave or remove).
|
||||
- MS Access ODBC driver confirmed installed: **64-bit** `Microsoft Access Driver (*.mdb, *.accdb)` (matches 64-bit Python — this pairing matters, a 32/64-bit mismatch would break `pyodbc.connect`).
|
||||
- **No Docker, no local MySQL, no local Postgres** on this machine — `docker-compose.yml` and the MySQL-target mode of `load_staging.py` are written but unexecuted here. Test both on whatever machine ends up running this for real.
|
||||
- Old repo's `dbConnection.php` has a **hardcoded plaintext MySQL password** for the external DB connection, committed to git history. Not carried forward into the new platform, but worth rotating that credential regardless since it's already exposed in the old repo's history.
|
||||
## 7. Environment notes (current macOS machine)
|
||||
|
||||
- macOS (Darwin 25.5.0), zsh. Node v22.23.0. Python 3.14.6 in `migration/.venv`. Homebrew, Docker, MySQL/MariaDB client all present.
|
||||
- **mdbtools** installed via Homebrew — the extraction toolchain. No Access ODBC driver (and none needed).
|
||||
- **`npm` is pnpm-aliased**, and pnpm ignores the `workspaces` field. Consequences:
|
||||
- there is **no root `node_modules/.bin`**. Binaries live per-app: `apps/api/node_modules/.bin/nest`, `apps/web/node_modules/.bin/next`.
|
||||
- Prisma CLI is run as `npx prisma@5`.
|
||||
- **Dev servers** (both must be up to use the UI):
|
||||
- API `cd apps/api && ./node_modules/.bin/nest start --watch` → `:3001`
|
||||
- Web `cd apps/web && ./node_modules/.bin/next dev` → `:3000`
|
||||
- Dev login: `admin@jorgecuadros.local`, password from `apps/api/scripts/seed-user.mjs` (`SEED_PASSWORD` env overrides the default).
|
||||
- **Dev DB**: `192.168.4.212:3307` (cubex Swarm stack `jorgecuadros-dev-db`). Credentials in gitignored `deploy/.env.dev`. **MinIO** for documents: `192.168.4.212:9100`, bucket `jorgecuadros-documents`.
|
||||
|
||||
**Traps worth knowing before you lose an hour to one:**
|
||||
- ⚠️ **Never run a single `transform_*.py` on its own — use `run_all.py`.** Each step truncates what it owns, so a lone run orphans everything downstream. `prune_empty_customers.py` must re-run after any ledger change, and `blob_extract.py` must follow properties + policies or the uploaded MinIO objects end up with no rows pointing at them.
|
||||
- ⚠️ **Never run `next build` while `next dev` is running** — they share `.next` and the dev server starts serving blank white pages. Recovery: kill the dev server, `rm -rf apps/web/.next`, restart.
|
||||
- ⚠️ **`mdb-export` formats numerics per Access column type** (`5000` from one table, `27000.0000` from another). Never string-compare staged Parquet numerics across two tables — canonicalize first. This exact trap produced a wrong, *locked* migration decision that shipped 12386 duplicate rows into the ledger (§8 step 4).
|
||||
- Shell on this machine: `head` is aliased to an HTTP HEAD tool — use `/usr/bin/head`. `grep --include=*.md` trips zsh globbing — quote the pattern.
|
||||
|
||||
## 8. Plan locked — next actions
|
||||
|
||||
@@ -151,33 +203,197 @@ python load_staging.py --database-url mysql+pymysql://user:pass@host:3306/ # l
|
||||
- `utility_dbo`: **resolved** — full dump (`utility_dbo.sql`, 1.3 GB, 55 tables) and the
|
||||
portal codebase (`~/PhpstormProjects/my-jorgecuadros-web`) are both on disk.
|
||||
|
||||
**Environment: moved Windows → macOS.** Sources now at `~/Downloads/JorgeCuadros-Legacy/`
|
||||
(all four files). This machine has Docker, MySQL/MariaDB client, Node 22, Python 3.14,
|
||||
Homebrew. No Access ODBC driver, `node_modules` not installed, staging Parquet not present.
|
||||
**Environment: moved Windows → macOS** (2026-07-22). See §7 for the current machine.
|
||||
|
||||
**Execution queue (in order):**
|
||||
1. **Port the extraction layer to mdbtools.** Rewrite `migration/extract.py` to shell out to
|
||||
**Execution queue.** Steps 1–6 below are **done**; they are kept because each carries the
|
||||
data findings and corrections that came out of doing it. Skip to the ⏭ marker at the end
|
||||
for what's actually next.
|
||||
|
||||
1. ~~**Port the extraction layer to mdbtools.**~~ **DONE.** Rewrote `migration/extract.py` to shell out to
|
||||
`mdb-tables`/`mdb-export` instead of `pyodbc`. Keep the same public interface
|
||||
(`connect`/`list_tables`/`read_table`) so `load_staging.py` and `config.py` are unchanged
|
||||
beyond the already-fixed `SOURCE_ROOT`. Carry over the two hard-won fixes conceptually:
|
||||
accented-column tables (mdbtools reads `PROPANO` cleanly — verified) and the corrupted `MULT`
|
||||
row (mdb-export's `-b` / error handling; confirm the bad row is skipped, not fatal).
|
||||
2. **Re-run staging** (`python load_staging.py --output-dir ./output`) to regenerate the staged
|
||||
data on this machine, then load into a local MySQL (`docker compose up mysql`) for SQL reconciliation.
|
||||
3. **Reconciliation pass** (plan step 2) — **DONE** (`migration/reconcile.py` → `RECONCILIATION.md`).
|
||||
Overturned all three "duplicate" assumptions: EFECTIVO/BACKUP are near-disjoint ledgers
|
||||
(folio collides; migrate both), the billing tables are disjoint period runs (union all, no
|
||||
de-dup), and COBRO3 is a charge batch not a customer snapshot (DATGRAL is sole master). The
|
||||
decided union/de-dup rules are in `PLAN.md` migration step 2.
|
||||
4. **Transform + load** (plan step 3) — NEXT. Start with `Customer`/`CustomerLegacyRef` (every
|
||||
other module depends on it): DATGRAL (utilities) is the master; join insurance `DATGRAL`
|
||||
via its `NUM UTIL` cross-ref + name/address matching; COBRO3 excluded from customers. Then
|
||||
the ledger union per the reconciliation rules (both EFECTIVO tables, all three billing tables,
|
||||
provenance-keyed; normalize `monedas` currency variants).
|
||||
5. **Customer module** in `apps/api`/`apps/web` (list/search/detail) — first real feature,
|
||||
Spanish-first UI. Run `npm install` at repo root first (node_modules absent here).
|
||||
6. **Sync design finalization** — now unblocked: map the internal→VPS replicated subset and the
|
||||
VPS→internal inbox tables against the real `utility_dbo` schema and the portal's read/write
|
||||
points in `my-jorgecuadros-web` (`peticion_gas`, PayPal payments, `notifications_settings`).
|
||||
2. ~~**Re-run staging**~~ **DONE** — staged Parquet regenerated on this machine
|
||||
(`load_staging.py --output-dir ./output`), 82 tables.
|
||||
3. **Reconciliation pass** (plan step 2) — **DONE** (`migration/reconcile.py` → `RECONCILIATION.md`),
|
||||
**and corrected 2026-07-22.** Current verdicts:
|
||||
- `EFECTIVO_BACKUP` is a **stale backup copy of `EFECTIVO`** — 12386 of its 12387 rows are
|
||||
verbatim duplicates (customer + timestamp-to-the-second + amount + concept text), leaving
|
||||
1 new row. Load EFECTIVO in full, de-dup BACKUP on the business key. **Never de-dup on
|
||||
`folio`** — it is per-table sequential and collides (12363 shared numbers, 12204 of them
|
||||
on different payments).
|
||||
- The billing tables (`datos2`/`FEE ANUAL`/`fee15`) are disjoint period runs — union all,
|
||||
no de-dup.
|
||||
- `COBRO3` is a charge batch, not a customer snapshot — `DATGRAL` is the sole master.
|
||||
|
||||
Only genuinely-pending item is **VPS provisioning** (ops task — provider/size/Tailscale+MySQL).
|
||||
⚠️ **This file and `PLAN.md` previously said the opposite about EFECTIVO** ("near-disjoint
|
||||
ledgers, migrate both"). That was a bug, not a finding — see the Shared ledger entry in
|
||||
step 4 below for the root cause and the fix. If you read a doc, comment, or commit message
|
||||
from before 2026-07-22 that says "migrate both, no folio de-dup", it is stale.
|
||||
The authoritative rules live in `PLAN.md` migration step 2.
|
||||
4. **Transform + load** (plan step 3) — IN PROGRESS.
|
||||
- **Customers — DONE** (`migration/transform_customers.py`). Loaded into the dev DB: 1682
|
||||
customers (1172 utilities master + 510 insurance-only), 2242 legacy refs (all traceable),
|
||||
560 insurance rows linked via `num_util` with 0 broken refs, **542 merged identities**
|
||||
spanning both business lines; linked customers enriched with insurance-only ID-doc fields.
|
||||
COBRO3 excluded. Re-runnable (truncate+rebuild); needs staged Parquet present
|
||||
(`load_staging.py --output-dir ./output` first).
|
||||
- **Properties — DONE** (`migration/transform_properties.py`): 1519 properties (0 orphans),
|
||||
3486 services, 553 trust accounts from DATMEX/PROFILE; PROFILE flags matched 1519/1519.
|
||||
- **Policies — DONE** (`migration/transform_policies.py`): config-driven consolidation of all
|
||||
insurance lines into `policies` (2378: AUTO 1307 / MULT 760 / LICENCIAS 306 / M_EMPR 5;
|
||||
10 skipped for unresolved customer, 0 orphans) + 4678 installments, 1110 vehicles, 513
|
||||
insured_drivers, 126 beneficiaries, 1 claim, 5 policy_types, 15 insurance_providers, 17
|
||||
adjusters. Unmodeled coverage columns preserved verbatim in `coveragesJson`. Verified a
|
||||
unified customer (EARWOOD, DAVID) carrying both a utility property+services and 2 MULT
|
||||
policies — the cross-line customer view works at the data layer.
|
||||
- **Shared ledger — DONE** (`migration/transform_transactions.py`): **33475** transactions
|
||||
(UTILITY 33180 / INSURANCE 295, 0 orphans) unioning EFECTIVO (13695) **plus only the 1
|
||||
business-key-new row from EFECTIVO_BACKUP**, all three billing tables
|
||||
(datos2/FEE ANUAL/fee15), the FM3 fee stream (amount=fee+tax+multa), IVA 2015 (nominal
|
||||
date), and insurance EFECTIVO; plus 79 `type_transactions` and 2301 `exchange_rates`.
|
||||
Skipped 22 no-customer + 272 no-date + **12417 EFECTIVO_BACKUP duplicates**.
|
||||
**Corrected 2026-07-22 — this used to load 45861 rows.** `reconcile.py` had string-compared
|
||||
`monto`, which mdb-export serializes at a different precision per Access column type
|
||||
(`5000` vs `27000.0000`), so it saw 2 overlapping rows instead of 12386 and ruled
|
||||
EFECTIVO_BACKUP an independent ledger. It is a stale backup copy: 12386 of its 12387 rows
|
||||
match an EFECTIVO row on customer + timestamp-to-the-second + amount + concept text. The
|
||||
ledger was double-counting those payments, roughly doubling every customer's historical
|
||||
receipt total. Both `reconcile.py` (canonicalizes numeric key columns now) and
|
||||
`transform_transactions.py` (de-dups on the business key, never on `folio` — folio
|
||||
collides) are fixed, and `run_all.py --env dev` has been re-run end to end.
|
||||
**Lesson for any future reconciliation: never compare mdb-export output as raw strings
|
||||
across two tables — canonicalize numerics first.**
|
||||
- **Bank register — DONE** (`migration/transform_bank.py`): 22354 `bank_transactions` from
|
||||
SCOTHIA DATOS I/E as signed amounts (income +, expense -; net +899,375.77), 66
|
||||
`business_line_categories`. No customer FK; categoryId left null (concept->ramo classifier
|
||||
is a later enhancement).
|
||||
- **Documents — DONE** (`migration/blob_extract.py`, migration step 4): carves embedded files
|
||||
out of the Access OLE wrapper (magic-byte detection) and uploads to MinIO on cubex (stack
|
||||
`jorgecuadros-dev-minio`, S3 at 192.168.4.212:9100, bucket jorgecuadros-documents), writing
|
||||
service_documents/policy_documents pointer rows. Loaded 70 documents (3 service bills +
|
||||
67 policy foto/docs, 0 orphans, ~290 MB). **Data finding:** the LONGBINARY columns are
|
||||
almost entirely empty — DATMEX's real bill-scan columns are ILUZ/IAGUA/IPREDIAL/ITEL (not
|
||||
doc_1/doc_2), but only 3 cells populated across 1520 rows; the *_MENS tables are mail-merge
|
||||
templates (correctly excluded). The 538MB/882MB source files are mostly Access bloat, not
|
||||
documents. **Migration steps 1-4 COMPLETE.**
|
||||
- **Customer module (plan step 3) — DONE**: `apps/api/src/customers/` (list/search/detail/stats)
|
||||
+ `apps/web` `/clientes` and `/clientes/[id]`, Spanish-first, verified against real data.
|
||||
- **Insurance module (plan step 4) — DONE**: `apps/api/src/policies/` (`GET /policies` with
|
||||
search over policy number / customer / agent / plate / driver name, vigencia buckets
|
||||
active|expiring|expired|undated, ramo + aseguradora + liquidada filters, 5 sorts;
|
||||
`/policies/stats`, `/policies/facets`, `/policies/:id`) + web `/polizas` (renewals-first
|
||||
browser, clickable stat cells) and `/polizas/[id]`. Cross-links both ways with the customer
|
||||
view. **Data finding:** the `policies.total` column is dead — only 2 of 2378 rows are
|
||||
non-zero (1585 are literally 0, 791 null) and one of those two is *lower* than its own net
|
||||
premium, so every premium headline and the premium sort use `netPremium` (populated on
|
||||
2377/2378). This also fixed a live bug on the customer detail page, which was showing
|
||||
"$0.00 Total" for 1585 policies.
|
||||
- **Utilities module (plan step 5) — DONE**: `apps/api/src/properties/` (`GET /properties`
|
||||
with search over address / customer / service account number / meter / trust number /
|
||||
phones, filters for service kind, municipality, trust bank, trust bucket
|
||||
(with|without|active|expiring|expired|undated) and `hasServices`, 5 sorts;
|
||||
`/properties/stats`, `/properties/facets`, `/properties/:id`) + web `/servicios`
|
||||
(renewals-first property browser with clickable stat cells and a clickable service-mix
|
||||
strip) and `/servicios/[id]` (services, fideicomiso, linked policy, owner + sibling
|
||||
properties, owner-level utility ledger, documents). Cross-links both ways with the
|
||||
customer and policy views. **Data findings:** (a) the trust renewal date staff chase is
|
||||
`trust_accounts.dueDate2` — DATMEX's `vence2`, one year after `vence1` on 531 of 541
|
||||
dated trusts (18 due within 30 days, 119 already overdue); (b) `properties.zone` is
|
||||
effectively dead (1444 of 1519 null, the rest near-unique), so it is not a facet;
|
||||
(c) the municipality that bills a property lives in the *predial* service's `notes`
|
||||
(ROSARITO 566 / TIJUANA 221 / ENSENADA 152, 939/939 populated) — that is the real
|
||||
geographic filter. `PropertyService.notes` means something different per kind
|
||||
(municipality / CFE PAR-IMPAR cycle / gas supply type), so the UI labels it per kind.
|
||||
240 of 1519 properties have no service rows at all — surfaced as its own bucket.
|
||||
- **Billing / statements module (plan step 6) — DONE**: `apps/api/src/billing/`
|
||||
(`GET /billing` movement browser with search over customer / referencia / cheque /
|
||||
concepto / periodo, filters for línea, moneda, cargo-vs-abono, concepto (typeId), origin
|
||||
table and a from/to date range, 5 sorts, and **totals for the whole filtered set**;
|
||||
`GET /billing/balances` per-customer balances with owing/credit/settled buckets and 4
|
||||
sorts; `/billing/stats`, `/billing/facets`, `/billing/customers/:id`) + web
|
||||
`/estado-cuenta` (two tabs: "Saldos por cliente" worklist and "Movimientos" ledger) and
|
||||
`/estado-cuenta/[id]` (the statement: balance per currency, the same balance split by
|
||||
business line, cargos por concepto with proportional bars, and the full movement table
|
||||
with a running balance). Cross-links from the customer and property detail pages.
|
||||
**Data findings:**
|
||||
(a) `transactions.amount` is a *signed* ledger — every charge type is negative without
|
||||
exception (WATER 3115/3117, ELECTRIC 2191/2191, PROPERTY TAXES 926/926, TRUST FEE
|
||||
188/188) and every deposit type positive (CHECK/CASH DEPOSIT, PAYPAL, all of EFECTIVO),
|
||||
so `SUM(amount)` is the balance and negative = the customer owes.
|
||||
(b) **Currency is not summable.** 912 of the 1269 customers with a ledger move in both
|
||||
MXN and USD; the charge side (datos2/FEE ANUAL/fee15) is MXN-only while receipts arrive
|
||||
in both, and no per-movement exchange rate was ever stored. Every figure in the module is
|
||||
per currency; the balance filter/sort takes a currency argument rather than collapsing.
|
||||
(c) `type_transactions.nameEs` is **entirely null** — the legacy `TYPE OF TRX` table has
|
||||
an `ESPAÑOL` column but all 79 rows are empty, so the API can only return English names.
|
||||
`labels.ts:TX_TYPE_LABELS` supplies Spanish for the real service/payment categories; the
|
||||
rest of the 79 "types" are payee names (LORETO GONZALEZ, ALBERCAS VALLARTA…) that fall
|
||||
through untranslated, which is correct.
|
||||
(d) The biggest debtor by far is **"TRASPASOS PAYPAL"** (-7.03M MXN over 309 movements) —
|
||||
a house/clearing account, not a person. Left in rather than special-cased, but it will
|
||||
head the adeudo worklist until someone decides how to model it.
|
||||
- **Bank register module (plan step 7) — DONE**: `apps/api/src/bank/`
|
||||
(`GET /bank` register browser with search over concepto/beneficiario, cheque
|
||||
`reference`, `notes` and `amountInWords`; filters for direction
|
||||
(income|expense|void), cleared status and a from/to date range, 5 sorts, and
|
||||
**income/expense/net totals for the whole filtered set**; `/bank/stats`,
|
||||
`/bank/facets` (year list), `/bank/summary` year/month rollup with a running
|
||||
net figure) + web `/banco` (two tabs: "Movimientos" register and "Resumen por
|
||||
periodo" with clickable year→month drill-down). Added to the AppShell nav as
|
||||
"Chequera". Verified end-to-end in the browser: totals reconcile
|
||||
(6948 income + 14615 expense + 791 void = 22354; net +899,375.77 matches
|
||||
stats; 2013 months open at $0 and close at the year's net $794,295.78).
|
||||
**Design decisions / data findings:**
|
||||
(a) **Kept separate from `/estado-cuenta` on purpose** — this is the office's
|
||||
own chequera, not customer money; the two ledgers are never summed or shown
|
||||
together. Distinct nav entry, distinct page, distinct API module.
|
||||
(b) **No category/ramo dimension, and the concept→ramo classifier was NOT
|
||||
built** (closes open item §6.4): the data cannot support it. `DATOS E/I` have
|
||||
no ramo column to migrate; `concepto` is a *payee* name (PAYPAL, CFE, ~1,900
|
||||
individuals), and 0 of 22354 concepts match a `business_line_categories` name;
|
||||
and the 66 TABLA RAMODOS rows are a property-management expense chart of
|
||||
accounts (Payroll, Pool Labor, Gardening) + owner names, *not* the
|
||||
insurance/servicios/fideicomiso split the migration comment implied. A
|
||||
classifier would invent data, so `categoryId` stays null and the module does
|
||||
not filter on it. Register is browsable by date/payee/amount/cheque instead.
|
||||
(c) **Single currency (MXN).** `bank_transactions` has no currency column and
|
||||
every `amountInWords` is spelled out in PESOS — so, unlike the customer
|
||||
ledger, everything here is one currency and not split per-currency.
|
||||
(d) **The "acumulado" is net movement since the register opened, not a bank
|
||||
balance** — SCOTHIA carries no opening balance (its `ban` table holds only the
|
||||
bank's name), so the running total starts at 0 in 2013. Labelled as such in
|
||||
the UI so it is never read as a statement balance.
|
||||
(e) Sign convention (from `transform_bank.py`): positive = ingreso,
|
||||
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
|
||||
CANCELADO/VOID) — voids are excluded from both the income and expense sides.
|
||||
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers →
|
||||
properties → policies → transactions → prune → bank → blobs in order (all idempotent);
|
||||
add `--stage` to re-extract from the Access files first. Verified end-to-end against dev.
|
||||
|
||||
5. **Infra** — **DONE.** Dev MySQL deployed to the cubex Swarm via the Portainer API as stack
|
||||
`jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled
|
||||
`jorgecuadros_db=true`); Prisma schema pushed (26 tables). Stack file
|
||||
`deploy/jorgecuadros-db.stack.yml` deploys prod from the same file as
|
||||
`jorgecuadros-prod-db` on :3306. MinIO for documents deployed as `jorgecuadros-dev-minio`.
|
||||
|
||||
6. **Staff web UI** — **DONE** for all five modules (§8 step 4 + step 7: clientes, polizas,
|
||||
servicios, estado-cuenta, banco). Spanish-first, session-cookie auth against the API,
|
||||
verified against real migrated data. **All app-layer feature work is complete.**
|
||||
|
||||
---
|
||||
|
||||
- **Sync implementation — DONE, validation pending.** `run_all.py --sync` performs the
|
||||
non-destructive legacy upsert path for customers, properties, policies, transactions, and
|
||||
bank rows. It preserves manual rows and stable legacy-owned primary keys; the admin SYNC job
|
||||
automatically creates a pre-sync backup. Next validation: apply schema changes, then exercise
|
||||
sync against a disposable DB with added, changed, removed, and manually-created rows.
|
||||
- **Plan step 9: portal sync worker** remains separate and blocked on VPS provisioning. This
|
||||
Phase B feature synchronizes Access source files into the internal platform; it does not yet
|
||||
poll `utility_dbo` inbox tables or replicate portal-facing data to a VPS.
|
||||
- **Small / open:** (a) `TRASPASOS PAYPAL` clearing account still tops the adeudo worklist
|
||||
(§6.4d) — a business modelling call, not code. (b) Credential rotation on the old repo's
|
||||
exposed MySQL password. (c) The `/estado-cuenta` browser visual pass — `/banco` was verified
|
||||
in-browser this session; `/estado-cuenta` still worth a look.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jorgecuadros/database": "0.1.0",
|
||||
"@jorgecuadros/database": "workspace:*",
|
||||
"@nestjs/common": "^10.4.4",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.4",
|
||||
@@ -33,6 +33,7 @@
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Seed an initial staff sign-in user. Idempotent (upsert by email).
|
||||
// Run with the target DATABASE_URL in the environment, e.g.:
|
||||
// DATABASE_URL="mysql://..." node apps/api/scripts/seed-user.mjs
|
||||
// Optional: SEED_EMAIL, SEED_PASSWORD, SEED_NAME (dev defaults below).
|
||||
import { createRequire } from "node:module";
|
||||
const require = createRequire(import.meta.url);
|
||||
const argon2 = require("argon2");
|
||||
const { PrismaClient } = require("@jorgecuadros/database");
|
||||
|
||||
const email = process.env.SEED_EMAIL || "admin@jorgecuadros.local";
|
||||
const password = process.env.SEED_PASSWORD || "ChangeMe!2026";
|
||||
const name = process.env.SEED_NAME || "Administrador";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const passwordHash = await argon2.hash(password);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { passwordHash, active: true, role: "ADMIN", name },
|
||||
create: { email, passwordHash, active: true, role: "ADMIN", name },
|
||||
});
|
||||
console.log(`seeded user: ${user.email} (role ${user.role})`);
|
||||
console.log(` password: ${password}`);
|
||||
await prisma.$disconnect();
|
||||
@@ -1,16 +1,30 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { CommonModule } from "./common/common.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { CustomersModule } from "./customers/customers.module";
|
||||
import { PoliciesModule } from "./policies/policies.module";
|
||||
import { PropertiesModule } from "./properties/properties.module";
|
||||
import { BillingModule } from "./billing/billing.module";
|
||||
import { BankModule } from "./bank/bank.module";
|
||||
import { OpsModule } from "./ops/ops.module";
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
CommonModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
CustomersModule,
|
||||
PoliciesModule,
|
||||
PropertiesModule,
|
||||
BillingModule,
|
||||
BankModule,
|
||||
OpsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Server-authoritative permission matrix. Roles form an ordered rank
|
||||
// (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every
|
||||
// write action carries a minimum rank. VIEWER holds rank 0 and is the
|
||||
// read-only role. Reads are not listed here — they stay on AuthenticatedGuard
|
||||
// alone, so any logged-in user (including VIEWER) can read.
|
||||
//
|
||||
// This is the single source of truth: the API enforces it via AbilityGuard and
|
||||
// ships the resolved per-user map to the web through /auth/me, so the UI never
|
||||
// keeps its own copy of the rules.
|
||||
|
||||
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
||||
|
||||
export const ROLE_RANK: Record<Role, number> = {
|
||||
VIEWER: 0,
|
||||
STAFF: 1,
|
||||
MANAGER: 2,
|
||||
ADMIN: 3,
|
||||
};
|
||||
|
||||
export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
| "property:create"
|
||||
| "property:update"
|
||||
| "property:delete"
|
||||
| "ledger:create"
|
||||
| "ledger:void"
|
||||
| "bank:create"
|
||||
| "bank:void"
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
|
||||
/** Minimum role required for each ability. */
|
||||
export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"customer:create": "STAFF",
|
||||
"customer:update": "STAFF",
|
||||
"customer:delete": "ADMIN",
|
||||
"policy:create": "STAFF",
|
||||
"policy:update": "STAFF",
|
||||
"policy:delete": "MANAGER",
|
||||
"property:create": "STAFF",
|
||||
"property:update": "STAFF",
|
||||
"property:delete": "MANAGER",
|
||||
"ledger:create": "STAFF",
|
||||
"ledger:void": "MANAGER",
|
||||
"bank:create": "STAFF",
|
||||
"bank:void": "MANAGER",
|
||||
"lookup:manage": "MANAGER",
|
||||
"user:manage": "ADMIN",
|
||||
"db:manage": "ADMIN",
|
||||
};
|
||||
|
||||
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
||||
|
||||
export function can(role: Role, ability: Ability): boolean {
|
||||
return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]];
|
||||
}
|
||||
|
||||
/** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */
|
||||
export function abilitiesFor(role: Role): Record<Ability, boolean> {
|
||||
return Object.fromEntries(
|
||||
ALL_ABILITIES.map((a) => [a, can(role, a)]),
|
||||
) as Record<Ability, boolean>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import { ABILITY_KEY } from "./require-ability.decorator";
|
||||
import { Ability, Role, can } from "./abilities";
|
||||
|
||||
/**
|
||||
* Enforces the ability matrix (abilities.ts) against req.user.role. A route
|
||||
* with no @RequireAbility passes through untouched — this guard only gates the
|
||||
* routes that declare one. It does NOT check authentication; always list it
|
||||
* after AuthenticatedGuard so an unauthenticated request is rejected first.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AbilityGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const ability = this.reflector.getAllAndOverride<Ability | undefined>(
|
||||
ABILITY_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!ability) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const user = req.user as { role?: Role } | undefined;
|
||||
if (!user?.role || !can(user.role, ability)) {
|
||||
throw new ForbiddenException("No tiene permisos para esta acción");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,14 @@ import { Request, Response } from "express";
|
||||
import { LocalAuthGuard } from "./local-auth.guard";
|
||||
import { AuthenticatedGuard } from "./authenticated.guard";
|
||||
import { LoginDto } from "./login.dto";
|
||||
import { abilitiesFor, Role } from "./abilities";
|
||||
|
||||
/** Attach the resolved ability map so the web can gate its UI off one payload. */
|
||||
function withAbilities(user: unknown) {
|
||||
const u = user as { role?: Role } | undefined;
|
||||
if (!u?.role) return u;
|
||||
return { ...u, abilities: abilitiesFor(u.role) };
|
||||
}
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
@@ -13,13 +21,13 @@ export class AuthController {
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
|
||||
return req.user;
|
||||
return withAbilities(req.user);
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard)
|
||||
@Get("me")
|
||||
me(@Req() req: Request) {
|
||||
return req.user;
|
||||
return withAbilities(req.user);
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Validates credentials via LocalStrategy AND establishes the session:
|
||||
* super.logIn(request) calls passport's req.login, which serializes the user
|
||||
* into the session store so subsequent requests carry an authenticated
|
||||
* session cookie (otherwise login succeeds but no session is persisted).
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard("local") {}
|
||||
export class LocalAuthGuard extends AuthGuard("local") {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const result = (await super.canActivate(context)) as boolean;
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
await super.logIn(request);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
import type { Ability } from "./abilities";
|
||||
|
||||
export const ABILITY_KEY = "required_ability";
|
||||
|
||||
/**
|
||||
* Tags a write route with the ability it requires. Pair with
|
||||
* `@UseGuards(AuthenticatedGuard, AbilityGuard)` — AuthenticatedGuard proves
|
||||
* the session, AbilityGuard checks this ability against the user's role.
|
||||
*/
|
||||
export const RequireAbility = (ability: Ability) =>
|
||||
SetMetadata(ABILITY_KEY, ability);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* A new bank-register movement. `amount` is signed: positive = ingreso,
|
||||
* negative = egreso (the module's sign convention). Single currency (MXN).
|
||||
* Booked rows are never edited — a mistake is corrected by voiding + re-capture.
|
||||
*/
|
||||
export class CreateBankMovementDto {
|
||||
@IsNumber() amount!: number;
|
||||
@IsString() @MinLength(1) transactionDate!: string;
|
||||
|
||||
@IsOptional() @IsString() concept?: string;
|
||||
@IsOptional() @IsString() reference?: string;
|
||||
@IsOptional() @IsString() transactionType?: string;
|
||||
@IsOptional() @IsBoolean() cleared?: boolean;
|
||||
@IsOptional() @IsBoolean() transferred?: boolean;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@IsOptional() @IsString() amountInWords?: string;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
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 {
|
||||
BankCleared,
|
||||
BankDirection,
|
||||
BankService,
|
||||
BankSort,
|
||||
} from "./bank.service";
|
||||
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||
|
||||
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
||||
const SORTS: BankSort[] = [
|
||||
"date_desc",
|
||||
"date_asc",
|
||||
"amount_desc",
|
||||
"amount_asc",
|
||||
"reference",
|
||||
];
|
||||
|
||||
function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
||||
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||
}
|
||||
|
||||
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
||||
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
||||
if (!v) return undefined;
|
||||
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("bank")
|
||||
export class BankController {
|
||||
constructor(
|
||||
private readonly bank: BankService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
stats() {
|
||||
return this.bank.stats();
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.bank.facets();
|
||||
}
|
||||
|
||||
/** Year and month rollups with a running net-movement figure. */
|
||||
@Get("summary")
|
||||
summary(@Query("year") year?: string) {
|
||||
const y = Number(year);
|
||||
return this.bank.summary(
|
||||
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** The register browser. */
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("direction") direction?: string,
|
||||
@Query("cleared") cleared?: string,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.bank.list({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
direction: one(DIRECTIONS, direction),
|
||||
cleared: one(CLEARED, cleared),
|
||||
from: parseDate(from),
|
||||
to: parseDate(to, true),
|
||||
sort: one(SORTS, sort) ?? "date_desc",
|
||||
});
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
@Post()
|
||||
@RequireAbility("bank:create")
|
||||
async create(@Body() dto: CreateBankMovementDto, @Req() req: Request) {
|
||||
const row = await this.bank.createMovement(dto);
|
||||
void this.audit.log(this.actingId(req), "bank.create", {
|
||||
bankTransactionId: row.id,
|
||||
amount: dto.amount,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
@Post(":id/void")
|
||||
@RequireAbility("bank:void")
|
||||
async void(@Param("id") id: string, @Req() req: Request) {
|
||||
const row = await this.bank.voidMovement(id, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "bank.void", { bankTransactionId: id });
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BankController } from "./bank.controller";
|
||||
import { BankService } from "./bank.service";
|
||||
|
||||
@Module({
|
||||
controllers: [BankController],
|
||||
providers: [BankService],
|
||||
})
|
||||
export class BankModule {}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||
|
||||
/**
|
||||
* App-voided rows (voidedAt set) are reversed and must leave every
|
||||
* income/expense/net total. This is distinct from the legacy zero-amount
|
||||
* "void" cheques, which stay as amount-0 rows. List views still show voided
|
||||
* rows struck-through.
|
||||
*/
|
||||
const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null };
|
||||
|
||||
/**
|
||||
* Bank register (chequera) module — plan step 7.
|
||||
*
|
||||
* This is the office's OWN operating checking account, migrated from SCOTHIA's
|
||||
* `DATOS I` (ingresos) / `DATOS E` (egresos) into one signed-amount table. It
|
||||
* carries no customer FK and is deliberately NOT part of `/estado-cuenta`: that
|
||||
* ledger is what customers owe the office, this one is the office's own money.
|
||||
* The two must never be added together or shown in the same total.
|
||||
*
|
||||
* SIGN CONVENTION (set by migration/transform_bank.py):
|
||||
* - positive = ingreso (a deposit into the account)
|
||||
* - negative = egreso (a payment out of it)
|
||||
* - exactly zero = a cancelled/void cheque. 787 of the 791 zero rows say
|
||||
* CANCELADO or VOID in the concept; they are neither an income nor an
|
||||
* expense and are excluded from both sides, the way the ~193 zero rows are
|
||||
* in the customer ledger.
|
||||
*
|
||||
* SINGLE CURRENCY. Unlike the customer ledger there is no currency column here:
|
||||
* `bank_transactions` has none, and every `amountInWords` on the egreso side is
|
||||
* spelled out in PESOS. All figures in this module are MXN.
|
||||
*
|
||||
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
|
||||
* rows and this module does not filter or group by it, because the data cannot
|
||||
* support it:
|
||||
* - `DATOS E` / `DATOS I` have no ramo column at all — the only columns are
|
||||
* fecha, tipo, num, concepto, ingreso/egreso, operado, notas and (egresos)
|
||||
* cantidad en letra. There is no key to migrate.
|
||||
* - `concepto` is a *payee* name (PAYPAL, CFE, TELEFONOS DEL NOROESTE, and
|
||||
* ~1,900 individual people), not a classification. Zero of the 22,354
|
||||
* concepts match a `business_line_categories` name.
|
||||
* - the 66 categories in TABLA RAMODOS are a property-management expense
|
||||
* chart of accounts (Payroll, Pool (Labor), Gardening, Trash Coll) plus
|
||||
* owner names with property numbers — not the insurance/servicios/
|
||||
* fideicomiso split. Classifying concepts into them would not produce a
|
||||
* business-line breakdown even if it worked.
|
||||
* A concept->ramo classifier would therefore be invented data, so the register
|
||||
* is browsable by date, payee, amount and cheque number instead.
|
||||
*/
|
||||
|
||||
/** Which side of the register a movement is on. */
|
||||
export type BankDirection = "income" | "expense" | "void";
|
||||
|
||||
/** `operado` in the source: whether the bank has cleared the movement. */
|
||||
export type BankCleared = "cleared" | "pending";
|
||||
|
||||
export type BankSort =
|
||||
| "date_desc"
|
||||
| "date_asc"
|
||||
| "amount_desc"
|
||||
| "amount_asc"
|
||||
| "reference";
|
||||
|
||||
export interface BankListParams {
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
direction?: BankDirection;
|
||||
cleared?: BankCleared;
|
||||
/** Inclusive bounds on `transactionDate`. */
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
sort: BankSort;
|
||||
}
|
||||
|
||||
/** Raw shape of a year/month rollup row. */
|
||||
interface PeriodRow {
|
||||
period: number;
|
||||
count: bigint | number | string;
|
||||
income: Prisma.Decimal | null;
|
||||
expense: Prisma.Decimal | null;
|
||||
net: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
function num(v: bigint | number | string | null | undefined): number {
|
||||
if (v === null || v === undefined) return 0;
|
||||
return typeof v === "number" ? v : Number(v);
|
||||
}
|
||||
|
||||
function dec(v: Prisma.Decimal | null | undefined): string {
|
||||
return (v ?? new Prisma.Decimal(0)).toFixed(2);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BankService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
||||
const and: Prisma.BankTransactionWhereInput[] = [];
|
||||
|
||||
if (p.query && p.query.trim()) {
|
||||
const q = p.query.trim();
|
||||
and.push({
|
||||
OR: [
|
||||
{ concept: { contains: q } },
|
||||
{ reference: { contains: q } },
|
||||
{ notes: { contains: q } },
|
||||
{ amountInWords: { contains: q } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (p.direction === "income") and.push({ amount: { gt: 0 } });
|
||||
if (p.direction === "expense") and.push({ amount: { lt: 0 } });
|
||||
if (p.direction === "void") and.push({ amount: 0 });
|
||||
if (p.cleared) and.push({ cleared: p.cleared === "cleared" });
|
||||
if (p.from || p.to) {
|
||||
and.push({
|
||||
transactionDate: {
|
||||
...(p.from ? { gte: p.from } : {}),
|
||||
...(p.to ? { lte: p.to } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return and.length ? { AND: and } : {};
|
||||
}
|
||||
|
||||
private orderBy(
|
||||
sort: BankSort,
|
||||
): Prisma.BankTransactionOrderByWithRelationInput[] {
|
||||
switch (sort) {
|
||||
case "date_asc":
|
||||
return [{ transactionDate: "asc" }, { reference: "asc" }];
|
||||
case "amount_desc":
|
||||
return [{ amount: "desc" }];
|
||||
case "amount_asc":
|
||||
return [{ amount: "asc" }];
|
||||
case "reference":
|
||||
// `reference` is the cheque number on egresos and the deposit slip on
|
||||
// ingresos; it is a string column, so this is a lexical sort.
|
||||
return [{ reference: "asc" }];
|
||||
default:
|
||||
return [{ transactionDate: "desc" }, { reference: "desc" }];
|
||||
}
|
||||
}
|
||||
|
||||
/** The register itself: every deposit and payment, filterable. */
|
||||
async list(params: BankListParams) {
|
||||
const where = this.where(params);
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.bankTransaction.count({ where }),
|
||||
this.prisma.bankTransaction.findMany({
|
||||
where,
|
||||
skip: (params.page - 1) * params.pageSize,
|
||||
take: params.pageSize,
|
||||
orderBy: this.orderBy(params.sort),
|
||||
select: {
|
||||
id: true,
|
||||
transactionDate: true,
|
||||
transactionType: true,
|
||||
reference: true,
|
||||
concept: true,
|
||||
amount: true,
|
||||
cleared: true,
|
||||
transferred: true,
|
||||
notes: true,
|
||||
amountInWords: true,
|
||||
legacySourceTable: true,
|
||||
voidedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Totals cover the whole filtered set, not just the page — the figure staff
|
||||
// read off a filtered view ("what did we pay CFE in 2025") has to.
|
||||
const totals = await this.totalsFor(where);
|
||||
|
||||
return {
|
||||
items: rows.map((r) => ({
|
||||
id: r.id,
|
||||
transactionDate: r.transactionDate,
|
||||
transactionType: r.transactionType,
|
||||
reference: r.reference,
|
||||
concept: r.concept,
|
||||
amount: r.amount,
|
||||
direction: directionOf(r.amount),
|
||||
cleared: r.cleared,
|
||||
transferred: r.transferred,
|
||||
notes: r.notes,
|
||||
amountInWords: r.amountInWords,
|
||||
source: r.legacySourceTable,
|
||||
voided: r.voidedAt != null,
|
||||
})),
|
||||
total,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
pageCount: Math.ceil(total / params.pageSize),
|
||||
totals,
|
||||
};
|
||||
}
|
||||
|
||||
/** Income / expense / void split over an arbitrary filter. */
|
||||
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
|
||||
const [income, expense, voided] = await Promise.all([
|
||||
this.prisma.bankTransaction.aggregate({
|
||||
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.bankTransaction.aggregate({
|
||||
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.bankTransaction.count({
|
||||
where: { AND: [where, { amount: 0 }, NOT_VOIDED] },
|
||||
}),
|
||||
]);
|
||||
|
||||
const inSum = income._sum.amount ?? new Prisma.Decimal(0);
|
||||
const outSum = expense._sum.amount ?? new Prisma.Decimal(0);
|
||||
|
||||
return {
|
||||
income: inSum.toFixed(2),
|
||||
incomeCount: income._count._all,
|
||||
expense: outSum.toFixed(2),
|
||||
expenseCount: expense._count._all,
|
||||
net: inSum.plus(outSum).toFixed(2),
|
||||
voidCount: voided,
|
||||
};
|
||||
}
|
||||
|
||||
/** Top-line figures for the bank page header. */
|
||||
async stats() {
|
||||
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
||||
this.prisma.bankTransaction.count({ where: NOT_VOIDED }),
|
||||
this.prisma.bankTransaction.aggregate({
|
||||
where: NOT_VOIDED,
|
||||
_min: { transactionDate: true },
|
||||
_max: { transactionDate: true },
|
||||
}),
|
||||
this.prisma.bankTransaction.count({
|
||||
where: { AND: [{ cleared: false }, NOT_VOIDED] },
|
||||
}),
|
||||
this.prisma.bankTransaction.count({
|
||||
where: { AND: [{ transferred: true }, NOT_VOIDED] },
|
||||
}),
|
||||
this.totalsFor({}),
|
||||
]);
|
||||
|
||||
return {
|
||||
movements: count,
|
||||
firstMovement: bounds._min.transactionDate,
|
||||
lastMovement: bounds._max.transactionDate,
|
||||
pending,
|
||||
transferred,
|
||||
...totals,
|
||||
};
|
||||
}
|
||||
|
||||
/** Year list for the period filter, newest first. */
|
||||
async facets() {
|
||||
const years = await this.prisma.$queryRaw<
|
||||
{ year: number; count: bigint | number | string }[]
|
||||
>`
|
||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||
FROM bank_transactions
|
||||
WHERE voidedAt IS NULL
|
||||
GROUP BY year
|
||||
ORDER BY year DESC
|
||||
`;
|
||||
|
||||
return {
|
||||
years: years.map((y) => ({ year: Number(y.year), count: num(y.count) })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Period rollup for the "Resumen" view: one row per year, plus one row per
|
||||
* month when a year is selected.
|
||||
*
|
||||
* `cumulative` is the running sum of every movement from the start of the
|
||||
* register — NOT the bank balance. SCOTHIA carries no opening balance (its
|
||||
* `BAN` table holds only the bank's name), so the register starts at zero on
|
||||
* its first row in 2013 and the running figure is the net movement since
|
||||
* then. Labelled as such in the UI so it is never read as a statement balance.
|
||||
*/
|
||||
async summary(year?: number) {
|
||||
const years = await this.prisma.$queryRaw<PeriodRow[]>`
|
||||
SELECT
|
||||
YEAR(transactionDate) AS period,
|
||||
COUNT(*) AS count,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||
SUM(amount) AS net
|
||||
FROM bank_transactions
|
||||
WHERE voidedAt IS NULL
|
||||
GROUP BY period
|
||||
ORDER BY period ASC
|
||||
`;
|
||||
|
||||
const months = year
|
||||
? await this.prisma.$queryRaw<PeriodRow[]>`
|
||||
SELECT
|
||||
MONTH(transactionDate) AS period,
|
||||
COUNT(*) AS count,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||
SUM(amount) AS net
|
||||
FROM bank_transactions
|
||||
WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL
|
||||
GROUP BY period
|
||||
ORDER BY period ASC
|
||||
`
|
||||
: [];
|
||||
|
||||
// Cumulative across years runs from the first row of the register; the
|
||||
// monthly cumulative opens at the selected year's opening figure so the two
|
||||
// tables agree.
|
||||
let running = new Prisma.Decimal(0);
|
||||
const yearRows = years.map((r) => {
|
||||
const net = r.net ?? new Prisma.Decimal(0);
|
||||
const opening = running;
|
||||
running = running.plus(net);
|
||||
return {
|
||||
period: Number(r.period),
|
||||
count: num(r.count),
|
||||
income: dec(r.income),
|
||||
expense: dec(r.expense),
|
||||
net: net.toFixed(2),
|
||||
opening: opening.toFixed(2),
|
||||
cumulative: running.toFixed(2),
|
||||
};
|
||||
});
|
||||
|
||||
const opening =
|
||||
year === undefined
|
||||
? new Prisma.Decimal(0)
|
||||
: new Prisma.Decimal(
|
||||
yearRows.find((y) => y.period === year)?.opening ?? "0",
|
||||
);
|
||||
|
||||
let monthRunning = opening;
|
||||
const monthRows = months.map((r) => {
|
||||
const net = r.net ?? new Prisma.Decimal(0);
|
||||
monthRunning = monthRunning.plus(net);
|
||||
return {
|
||||
period: Number(r.period),
|
||||
count: num(r.count),
|
||||
income: dec(r.income),
|
||||
expense: dec(r.expense),
|
||||
net: net.toFixed(2),
|
||||
cumulative: monthRunning.toFixed(2),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
year: year ?? null,
|
||||
years: yearRows,
|
||||
months: monthRows,
|
||||
opening: opening.toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
// --- writes (append + void) -----------------------------------------------
|
||||
|
||||
async createMovement(dto: CreateBankMovementDto) {
|
||||
const date = new Date(dto.transactionDate);
|
||||
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
||||
return this.prisma.bankTransaction.create({
|
||||
data: {
|
||||
amount: dto.amount,
|
||||
transactionDate: date,
|
||||
concept: dto.concept,
|
||||
reference: dto.reference,
|
||||
transactionType: dto.transactionType,
|
||||
cleared: dto.cleared ?? false,
|
||||
transferred: dto.transferred ?? false,
|
||||
notes: dto.notes,
|
||||
amountInWords: dto.amountInWords,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async voidMovement(id: string, userId: string) {
|
||||
const row = await this.prisma.bankTransaction.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, voidedAt: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException(`Bank transaction ${id} not found`);
|
||||
if (row.voidedAt) throw new BadRequestException("El movimiento ya está anulado");
|
||||
return this.prisma.bankTransaction.update({
|
||||
where: { id },
|
||||
data: { voidedAt: new Date(), voidedById: userId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function directionOf(amount: Prisma.Decimal): BankDirection {
|
||||
if (amount.greaterThan(0)) return "income";
|
||||
return amount.lessThan(0) ? "expense" : "void";
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { TransactionDomain } 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 {
|
||||
BalanceFilter,
|
||||
BalanceSort,
|
||||
BillingService,
|
||||
LedgerCurrency,
|
||||
LedgerDirection,
|
||||
MovementSort,
|
||||
} from "./billing.service";
|
||||
import { CreateMovementDto } from "./movement.dto";
|
||||
|
||||
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
||||
const DIRECTIONS: LedgerDirection[] = ["charge", "credit"];
|
||||
const BALANCES: BalanceFilter[] = ["all", "owing", "credit", "settled"];
|
||||
const MOVEMENT_SORTS: MovementSort[] = [
|
||||
"date_desc",
|
||||
"date_asc",
|
||||
"amount_desc",
|
||||
"amount_asc",
|
||||
"customer",
|
||||
];
|
||||
const BALANCE_SORTS: BalanceSort[] = [
|
||||
"owing_desc",
|
||||
"credit_desc",
|
||||
"recent",
|
||||
"customer",
|
||||
];
|
||||
|
||||
function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
||||
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||
}
|
||||
|
||||
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
||||
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
||||
if (!v) return undefined;
|
||||
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("billing")
|
||||
export class BillingController {
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
stats() {
|
||||
return this.billing.stats();
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.billing.facets();
|
||||
}
|
||||
|
||||
/** Per-customer balances — the receivables worklist. */
|
||||
@Get("balances")
|
||||
balances(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("currency") currency?: string,
|
||||
@Query("balance") balance?: string,
|
||||
@Query("domain") domain?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.billing.balances({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
currency: one(CURRENCIES, currency) ?? "MXN",
|
||||
balance: one(BALANCES, balance) ?? "all",
|
||||
domain: one(DOMAINS, domain),
|
||||
sort: one(BALANCE_SORTS, sort) ?? "owing_desc",
|
||||
});
|
||||
}
|
||||
|
||||
/** One customer's full statement across both business lines. */
|
||||
@Get("customers/:id")
|
||||
statement(@Param("id") id: string) {
|
||||
return this.billing.statement(id);
|
||||
}
|
||||
|
||||
/** Cross-customer movement browser. */
|
||||
@Get()
|
||||
movements(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("domain") domain?: string,
|
||||
@Query("currency") currency?: string,
|
||||
@Query("direction") direction?: string,
|
||||
@Query("typeId") typeId?: string,
|
||||
@Query("source") source?: string,
|
||||
@Query("customerId") customerId?: string,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.billing.movements({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
domain: one(DOMAINS, domain),
|
||||
currency: one(CURRENCIES, currency),
|
||||
direction: one(DIRECTIONS, direction),
|
||||
typeId: typeId || undefined,
|
||||
source: source || undefined,
|
||||
customerId: customerId || undefined,
|
||||
from: parseDate(from),
|
||||
to: parseDate(to, true),
|
||||
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
|
||||
});
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
@Post()
|
||||
@RequireAbility("ledger:create")
|
||||
async create(@Body() dto: CreateMovementDto, @Req() req: Request) {
|
||||
const tx = await this.billing.createMovement(dto);
|
||||
void this.audit.log(this.actingId(req), "ledger.create", {
|
||||
transactionId: tx.id,
|
||||
customerId: dto.customerId,
|
||||
amount: dto.amount,
|
||||
currency: tx.currency,
|
||||
});
|
||||
return tx;
|
||||
}
|
||||
|
||||
@Post(":id/void")
|
||||
@RequireAbility("ledger:void")
|
||||
async void(@Param("id") id: string, @Req() req: Request) {
|
||||
const tx = await this.billing.voidMovement(id, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "ledger.void", { transactionId: id });
|
||||
return tx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BillingController } from "./billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
@Module({
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -0,0 +1,792 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateMovementDto } from "./movement.dto";
|
||||
|
||||
/**
|
||||
* Shared billing / statements module — plan step 6.
|
||||
*
|
||||
* SIGN CONVENTION (established from the migrated data, not assumed):
|
||||
* `transactions.amount` is a *signed* ledger amount.
|
||||
* - negative = cargo (a charge: a utility bill, predial, trust fee, HOA due,
|
||||
* insurance premium the office paid or billed on the customer's behalf).
|
||||
* Every legacy `type_of_trx` on the charge side is negative without
|
||||
* exception — WATER 3115/3117 negative, ELECTRIC 2191/2191, PROPERTY TAXES
|
||||
* 926/926, TRUST FEE 188/188.
|
||||
* - positive = abono (a credit: CHECK DEPOSIT, CASH DEPOSIT, PAYPAL, and
|
||||
* every `EFECTIVO` cash receipt).
|
||||
* So `SUM(amount)` is the balance: negative means the customer owes the office,
|
||||
* positive means the customer is in credit.
|
||||
*
|
||||
* CURRENCY IS NOT SUMMABLE. 912 of the 1269 customers with a ledger have
|
||||
* movements in both MXN and USD, and the charge side (`datos2`/`FEE ANUAL`/
|
||||
* `fee15`) is MXN-only while receipts arrive in both. Applying a historical
|
||||
* exchange rate to a 20-year ledger to produce one number would invent a figure
|
||||
* the source data never had, so every total in this module is reported *per
|
||||
* currency* and never collapsed. Balance filters and sorts therefore operate on
|
||||
* one caller-chosen currency at a time.
|
||||
*/
|
||||
|
||||
/** Which side of the ledger a movement is on. */
|
||||
export type LedgerDirection = "charge" | "credit";
|
||||
|
||||
/** Balance buckets for the receivables worklist, on the selected currency. */
|
||||
export type BalanceFilter = "all" | "owing" | "credit" | "settled";
|
||||
|
||||
export type MovementSort =
|
||||
| "date_desc"
|
||||
| "date_asc"
|
||||
| "amount_desc"
|
||||
| "amount_asc"
|
||||
| "customer";
|
||||
|
||||
export type BalanceSort = "owing_desc" | "credit_desc" | "recent" | "customer";
|
||||
|
||||
export type LedgerCurrency = "MXN" | "USD";
|
||||
|
||||
export interface MovementParams {
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
domain?: TransactionDomain;
|
||||
currency?: LedgerCurrency;
|
||||
direction?: LedgerDirection;
|
||||
typeId?: string;
|
||||
source?: string;
|
||||
customerId?: string;
|
||||
/** Inclusive ISO date bounds on `transactionDate`. */
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
sort: MovementSort;
|
||||
}
|
||||
|
||||
export interface BalanceParams {
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
currency: LedgerCurrency;
|
||||
balance: BalanceFilter;
|
||||
/** Restricts the whole balance to one business line. */
|
||||
domain?: TransactionDomain;
|
||||
sort: BalanceSort;
|
||||
}
|
||||
|
||||
/** Raw shape of the per-customer balance aggregate. */
|
||||
interface BalanceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
nameSource: string | null;
|
||||
nameMissing: number;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
movements: bigint | number | string;
|
||||
balanceMxn: Prisma.Decimal | null;
|
||||
balanceUsd: Prisma.Decimal | null;
|
||||
chargesMxn: Prisma.Decimal | null;
|
||||
creditsMxn: Prisma.Decimal | null;
|
||||
chargesUsd: Prisma.Decimal | null;
|
||||
creditsUsd: Prisma.Decimal | null;
|
||||
utilityMovements: bigint | number | string;
|
||||
insuranceMovements: bigint | number | string;
|
||||
lastMovement: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw-query counts come back in three shapes depending on the aggregate:
|
||||
* `COUNT(*)` as bigint, `SUM(bool)` as a decimal *string*, and plain numbers.
|
||||
* Normalize all of them before they reach the client as JSON.
|
||||
*/
|
||||
function num(v: bigint | number | string | null | undefined): number {
|
||||
if (v === null || v === undefined) return 0;
|
||||
return typeof v === "number" ? v : Number(v);
|
||||
}
|
||||
|
||||
function dec(v: Prisma.Decimal | null | undefined): string {
|
||||
return (v ?? new Prisma.Decimal(0)).toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every aggregate (SUM/count/groupBy and the raw balance SQL) must exclude
|
||||
* voided rows, or a reversed movement keeps affecting the books. List views
|
||||
* still show voided rows struck-through — only totals drop them.
|
||||
*/
|
||||
const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private movementWhere(p: MovementParams): Prisma.TransactionWhereInput {
|
||||
const and: Prisma.TransactionWhereInput[] = [];
|
||||
|
||||
if (p.query && p.query.trim()) {
|
||||
const q = p.query.trim();
|
||||
and.push({
|
||||
OR: [
|
||||
{ customer: { name: { contains: q } } },
|
||||
{ reference: { contains: q } },
|
||||
{ checkNumber: { contains: q } },
|
||||
{ message: { contains: q } },
|
||||
{ period: { contains: q } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (p.domain) and.push({ domain: p.domain });
|
||||
if (p.currency) and.push({ currency: p.currency });
|
||||
// A charge is strictly negative and a credit strictly positive; the ~193
|
||||
// zero-amount rows are neither and are excluded from both sides on purpose.
|
||||
if (p.direction === "charge") and.push({ amount: { lt: 0 } });
|
||||
if (p.direction === "credit") and.push({ amount: { gt: 0 } });
|
||||
if (p.typeId) and.push({ typeId: p.typeId });
|
||||
if (p.source) and.push({ legacySourceTable: p.source });
|
||||
if (p.customerId) and.push({ customerId: p.customerId });
|
||||
if (p.from || p.to) {
|
||||
and.push({
|
||||
transactionDate: {
|
||||
...(p.from ? { gte: p.from } : {}),
|
||||
...(p.to ? { lte: p.to } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return and.length ? { AND: and } : {};
|
||||
}
|
||||
|
||||
private movementOrderBy(
|
||||
sort: MovementSort,
|
||||
): Prisma.TransactionOrderByWithRelationInput[] {
|
||||
switch (sort) {
|
||||
case "date_asc":
|
||||
return [{ transactionDate: "asc" }];
|
||||
case "amount_desc":
|
||||
return [{ amount: "desc" }];
|
||||
case "amount_asc":
|
||||
return [{ amount: "asc" }];
|
||||
case "customer":
|
||||
return [
|
||||
{ customer: { nameMissing: "asc" } },
|
||||
{ customer: { name: "asc" } },
|
||||
{ transactionDate: "desc" },
|
||||
];
|
||||
default:
|
||||
return [{ transactionDate: "desc" }];
|
||||
}
|
||||
}
|
||||
|
||||
/** Cross-customer movement browser — every charge and credit, filterable. */
|
||||
async movements(params: MovementParams) {
|
||||
const where = this.movementWhere(params);
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.transaction.count({ where }),
|
||||
this.prisma.transaction.findMany({
|
||||
where,
|
||||
skip: (params.page - 1) * params.pageSize,
|
||||
take: params.pageSize,
|
||||
orderBy: this.movementOrderBy(params.sort),
|
||||
select: {
|
||||
id: true,
|
||||
transactionDate: true,
|
||||
domain: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
reference: true,
|
||||
period: true,
|
||||
checkNumber: true,
|
||||
message: true,
|
||||
legacySourceTable: true,
|
||||
voidedAt: true,
|
||||
type: { select: { nameEn: true, nameEs: true } },
|
||||
customer: {
|
||||
select: { id: true, name: true, nameSource: true, city: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Totals for the *filtered set*, not just the page — the number staff read
|
||||
// off a filtered view ("how much did we bill for water in April") has to
|
||||
// cover everything the filter matched.
|
||||
const totals = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { AND: [where, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const charges = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const credits = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const chargeMap = new Map(charges.map((c) => [c.currency, c]));
|
||||
const creditMap = new Map(credits.map((c) => [c.currency, c]));
|
||||
|
||||
return {
|
||||
items: rows.map((r) => ({
|
||||
id: r.id,
|
||||
transactionDate: r.transactionDate,
|
||||
domain: r.domain,
|
||||
amount: r.amount,
|
||||
currency: r.currency,
|
||||
direction: r.amount.lessThan(0) ? "charge" : "credit",
|
||||
reference: r.reference,
|
||||
period: r.period,
|
||||
checkNumber: r.checkNumber,
|
||||
message: r.message,
|
||||
source: r.legacySourceTable,
|
||||
type: r.type,
|
||||
voided: r.voidedAt != null,
|
||||
customerId: r.customer.id,
|
||||
customerName: r.customer.name,
|
||||
customerNameSource: r.customer.nameSource,
|
||||
customerCity: r.customer.city,
|
||||
})),
|
||||
total,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
pageCount: Math.ceil(total / params.pageSize),
|
||||
totals: totals.map((t) => ({
|
||||
currency: t.currency,
|
||||
net: t._sum.amount,
|
||||
count: t._count._all,
|
||||
charges: chargeMap.get(t.currency)?._sum.amount ?? null,
|
||||
chargeCount: chargeMap.get(t.currency)?._count._all ?? 0,
|
||||
credits: creditMap.get(t.currency)?._sum.amount ?? null,
|
||||
creditCount: creditMap.get(t.currency)?._count._all ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Receivables worklist: one row per customer with a ledger, carrying both
|
||||
* currency balances, filtered/sorted on the caller's chosen currency.
|
||||
*
|
||||
* Raw SQL rather than Prisma `groupBy` because this needs conditional sums
|
||||
* per currency *and* per direction in a single pass, plus ordering and
|
||||
* pagination on a computed balance — none of which groupBy expresses.
|
||||
*/
|
||||
async balances(params: BalanceParams) {
|
||||
const { query, page, pageSize, currency, balance, domain, sort } = params;
|
||||
|
||||
const filters: Prisma.Sql[] = [];
|
||||
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
||||
const txFilter = filters.length
|
||||
? Prisma.sql`AND ${Prisma.join(filters, " AND ")}`
|
||||
: Prisma.empty;
|
||||
|
||||
const nameFilter =
|
||||
query && query.trim()
|
||||
? Prisma.sql`AND (c.name LIKE ${`%${query.trim()}%`} OR c.city LIKE ${`%${query.trim()}%`})`
|
||||
: Prisma.empty;
|
||||
|
||||
// The balance column the filter and sort act on.
|
||||
const bal =
|
||||
currency === "USD"
|
||||
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
|
||||
: Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`;
|
||||
|
||||
// "Owing" is a *negative* balance (see the sign convention above). The
|
||||
// 0.005 threshold keeps rounding dust out of both worklists.
|
||||
let having = Prisma.empty;
|
||||
if (balance === "owing") having = Prisma.sql`HAVING ${bal} < -0.005`;
|
||||
else if (balance === "credit") having = Prisma.sql`HAVING ${bal} > 0.005`;
|
||||
else if (balance === "settled")
|
||||
having = Prisma.sql`HAVING ${bal} BETWEEN -0.005 AND 0.005`;
|
||||
|
||||
let orderBy: Prisma.Sql;
|
||||
switch (sort) {
|
||||
case "credit_desc":
|
||||
orderBy = Prisma.sql`ORDER BY ${bal} DESC`;
|
||||
break;
|
||||
case "recent":
|
||||
orderBy = Prisma.sql`ORDER BY MAX(t.transactionDate) DESC`;
|
||||
break;
|
||||
case "customer":
|
||||
orderBy = Prisma.sql`ORDER BY c.nameMissing ASC, c.name ASC`;
|
||||
break;
|
||||
default:
|
||||
// Deepest debt first — the point of the worklist.
|
||||
orderBy = Prisma.sql`ORDER BY ${bal} ASC`;
|
||||
}
|
||||
|
||||
const rows = await this.prisma.$queryRaw<BalanceRow[]>`
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.nameSource,
|
||||
c.nameMissing,
|
||||
c.city,
|
||||
c.state,
|
||||
COUNT(*) AS movements,
|
||||
SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END) AS balanceMxn,
|
||||
SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END) AS balanceUsd,
|
||||
SUM(CASE WHEN t.currency = 'MXN' AND t.amount < 0 THEN t.amount ELSE 0 END) AS chargesMxn,
|
||||
SUM(CASE WHEN t.currency = 'MXN' AND t.amount > 0 THEN t.amount ELSE 0 END) AS creditsMxn,
|
||||
SUM(CASE WHEN t.currency = 'USD' AND t.amount < 0 THEN t.amount ELSE 0 END) AS chargesUsd,
|
||||
SUM(CASE WHEN t.currency = 'USD' AND t.amount > 0 THEN t.amount ELSE 0 END) AS creditsUsd,
|
||||
SUM(t.domain = 'UTILITY') AS utilityMovements,
|
||||
SUM(t.domain = 'INSURANCE') AS insuranceMovements,
|
||||
MAX(t.transactionDate) AS lastMovement
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
WHERE t.voidedAt IS NULL ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||
${having}
|
||||
${orderBy}
|
||||
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
|
||||
`;
|
||||
|
||||
const counted = await this.prisma.$queryRaw<{ total: bigint | number | string }[]>`
|
||||
SELECT COUNT(*) AS total FROM (
|
||||
SELECT c.id
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
WHERE 1 = 1 ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id
|
||||
${having}
|
||||
) x
|
||||
`;
|
||||
const total = num(counted[0]?.total);
|
||||
|
||||
return {
|
||||
items: rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
nameSource: r.nameSource,
|
||||
city: r.city,
|
||||
state: r.state,
|
||||
movements: num(r.movements),
|
||||
utilityMovements: num(r.utilityMovements),
|
||||
insuranceMovements: num(r.insuranceMovements),
|
||||
lastMovement: r.lastMovement,
|
||||
balances: [
|
||||
{
|
||||
currency: "MXN",
|
||||
balance: dec(r.balanceMxn),
|
||||
charges: dec(r.chargesMxn),
|
||||
credits: dec(r.creditsMxn),
|
||||
},
|
||||
{
|
||||
currency: "USD",
|
||||
balance: dec(r.balanceUsd),
|
||||
charges: dec(r.chargesUsd),
|
||||
credits: dec(r.creditsUsd),
|
||||
},
|
||||
],
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
pageCount: Math.ceil(total / pageSize),
|
||||
currency,
|
||||
};
|
||||
}
|
||||
|
||||
/** Top-line figures for the billing page header. */
|
||||
async stats() {
|
||||
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
||||
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||
this.prisma.transaction
|
||||
.findMany({
|
||||
where: NOT_VOIDED,
|
||||
distinct: ["customerId"],
|
||||
select: { customerId: true },
|
||||
})
|
||||
.then((r) => r.length),
|
||||
this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: NOT_VOIDED,
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.transaction.groupBy({
|
||||
by: ["domain", "currency"],
|
||||
where: NOT_VOIDED,
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const charges = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const credits = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const chargeMap = new Map(charges.map((c) => [c.currency, c]));
|
||||
const creditMap = new Map(credits.map((c) => [c.currency, c]));
|
||||
|
||||
// How many customers sit on each side of the line, per currency — the
|
||||
// headline for a receivables view. Counted in SQL; a customer can be
|
||||
// "owing" in MXN and "in credit" in USD, and both are true at once.
|
||||
const sides = await this.prisma.$queryRaw<
|
||||
{
|
||||
currency: string;
|
||||
owing: bigint | number | string;
|
||||
inCredit: bigint | number | string;
|
||||
}[]
|
||||
>`
|
||||
SELECT currency,
|
||||
SUM(bal < -0.005) AS owing,
|
||||
SUM(bal > 0.005) AS inCredit
|
||||
FROM (
|
||||
SELECT customerId, currency, SUM(amount) AS bal
|
||||
FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency
|
||||
) x
|
||||
GROUP BY currency
|
||||
`;
|
||||
const sideMap = new Map(sides.map((s) => [s.currency, s]));
|
||||
|
||||
const [firstRow, lastRow] = await Promise.all([
|
||||
this.prisma.transaction.findFirst({
|
||||
where: NOT_VOIDED,
|
||||
orderBy: { transactionDate: "asc" },
|
||||
select: { transactionDate: true },
|
||||
}),
|
||||
this.prisma.transaction.findFirst({
|
||||
where: NOT_VOIDED,
|
||||
orderBy: { transactionDate: "desc" },
|
||||
select: { transactionDate: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Customers whose ledger spans both business lines — the whole reason this
|
||||
// module is one view instead of two.
|
||||
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
|
||||
SELECT COUNT(*) AS n FROM (
|
||||
SELECT customerId FROM transactions WHERE voidedAt IS NULL
|
||||
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
||||
) x
|
||||
`;
|
||||
|
||||
return {
|
||||
movements,
|
||||
ledgerCustomers,
|
||||
crossLineCustomers: num(crossLine[0]?.n),
|
||||
firstMovement: firstRow?.transactionDate ?? null,
|
||||
lastMovement: lastRow?.transactionDate ?? null,
|
||||
byCurrency: byCurrency.map((c) => ({
|
||||
currency: c.currency,
|
||||
net: c._sum.amount,
|
||||
count: c._count._all,
|
||||
charges: chargeMap.get(c.currency)?._sum.amount ?? null,
|
||||
chargeCount: chargeMap.get(c.currency)?._count._all ?? 0,
|
||||
credits: creditMap.get(c.currency)?._sum.amount ?? null,
|
||||
creditCount: creditMap.get(c.currency)?._count._all ?? 0,
|
||||
owing: num(sideMap.get(c.currency)?.owing),
|
||||
inCredit: num(sideMap.get(c.currency)?.inCredit),
|
||||
})),
|
||||
byDomain: byDomain.map((d) => ({
|
||||
domain: d.domain,
|
||||
currency: d.currency,
|
||||
net: d._sum.amount,
|
||||
count: d._count._all,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter dropdown options for the movement browser. */
|
||||
async facets() {
|
||||
const types = await this.prisma.transaction.groupBy({
|
||||
by: ["typeId"],
|
||||
where: { AND: [{ typeId: { not: null } }, NOT_VOIDED] },
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { typeId: "desc" } },
|
||||
});
|
||||
const typeRows = await this.prisma.typeTransaction.findMany({
|
||||
where: { id: { in: types.map((t) => t.typeId as string) } },
|
||||
select: { id: true, nameEn: true, nameEs: true },
|
||||
});
|
||||
const typeMap = new Map(typeRows.map((t) => [t.id, t]));
|
||||
|
||||
const sources = await this.prisma.transaction.groupBy({
|
||||
by: ["legacySourceTable"],
|
||||
where: NOT_VOIDED,
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { legacySourceTable: "desc" } },
|
||||
});
|
||||
|
||||
const years = await this.prisma.$queryRaw<
|
||||
{ year: number; count: bigint | number | string }[]
|
||||
>`
|
||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
|
||||
`;
|
||||
|
||||
return {
|
||||
types: types
|
||||
.map((t) => {
|
||||
const row = typeMap.get(t.typeId as string);
|
||||
return {
|
||||
id: t.typeId as string,
|
||||
name: row?.nameEs || row?.nameEn || "—",
|
||||
count: t._count._all,
|
||||
};
|
||||
})
|
||||
.filter((t) => t.name !== "—"),
|
||||
sources: sources.map((s) => ({
|
||||
name: s.legacySourceTable ?? "—",
|
||||
count: s._count._all,
|
||||
})),
|
||||
years: years.map((y) => ({ year: Number(y.year), count: num(y.count) })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One customer's statement across both business lines.
|
||||
*
|
||||
* Returns the *whole* ledger rather than a page of it: the heaviest customer
|
||||
* carries 365 movements (mean 26), and a running balance is meaningless if
|
||||
* the client only holds a slice. The running balance is accumulated per
|
||||
* currency in chronological order, then the list is handed back newest-first
|
||||
* with each row's balance-after already attached.
|
||||
*/
|
||||
async statement(customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameSource: true,
|
||||
addressLine1: true,
|
||||
city: true,
|
||||
state: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
email: true,
|
||||
customerSince: true,
|
||||
preferredCurrency: true,
|
||||
status: true,
|
||||
_count: { select: { properties: true, policies: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
}
|
||||
|
||||
const rows = await this.prisma.transaction.findMany({
|
||||
where: { customerId },
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
transactionDate: true,
|
||||
domain: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
reference: true,
|
||||
period: true,
|
||||
checkNumber: true,
|
||||
message: true,
|
||||
legacySourceTable: true,
|
||||
voidedAt: true,
|
||||
type: { select: { nameEn: true, nameEs: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const running = new Map<string, Prisma.Decimal>();
|
||||
const movements = rows.map((r) => {
|
||||
const voided = r.voidedAt != null;
|
||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||
// A voided row does not move the running balance — it shows struck-through
|
||||
// with the balance unchanged from the previous live movement.
|
||||
const next = voided ? prev : prev.plus(r.amount);
|
||||
running.set(r.currency, next);
|
||||
return {
|
||||
id: r.id,
|
||||
transactionDate: r.transactionDate,
|
||||
domain: r.domain,
|
||||
amount: r.amount,
|
||||
currency: r.currency,
|
||||
direction: r.amount.lessThan(0) ? "charge" : "credit",
|
||||
reference: r.reference,
|
||||
period: r.period,
|
||||
checkNumber: r.checkNumber,
|
||||
message: r.message,
|
||||
source: r.legacySourceTable,
|
||||
type: r.type,
|
||||
voided,
|
||||
/** Balance in this row's currency after applying it. */
|
||||
balanceAfter: next.toFixed(2),
|
||||
};
|
||||
});
|
||||
movements.reverse();
|
||||
|
||||
// Per-currency summary, and the same split by business line so the two
|
||||
// ledgers are visibly one statement without being illegally added up.
|
||||
const perCurrency = new Map<
|
||||
string,
|
||||
{
|
||||
currency: string;
|
||||
charges: Prisma.Decimal;
|
||||
credits: Prisma.Decimal;
|
||||
chargeCount: number;
|
||||
creditCount: number;
|
||||
count: number;
|
||||
first: Date | null;
|
||||
last: Date | null;
|
||||
}
|
||||
>();
|
||||
const perDomain = new Map<
|
||||
string,
|
||||
{
|
||||
domain: TransactionDomain;
|
||||
currency: string;
|
||||
charges: Prisma.Decimal;
|
||||
credits: Prisma.Decimal;
|
||||
count: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const r of rows) {
|
||||
if (r.voidedAt != null) continue; // voided rows never enter a total
|
||||
const c =
|
||||
perCurrency.get(r.currency) ??
|
||||
{
|
||||
currency: r.currency,
|
||||
charges: new Prisma.Decimal(0),
|
||||
credits: new Prisma.Decimal(0),
|
||||
chargeCount: 0,
|
||||
creditCount: 0,
|
||||
count: 0,
|
||||
first: null as Date | null,
|
||||
last: null as Date | null,
|
||||
};
|
||||
c.count += 1;
|
||||
if (r.amount.lessThan(0)) {
|
||||
c.charges = c.charges.plus(r.amount);
|
||||
c.chargeCount += 1;
|
||||
} else if (r.amount.greaterThan(0)) {
|
||||
c.credits = c.credits.plus(r.amount);
|
||||
c.creditCount += 1;
|
||||
}
|
||||
if (!c.first) c.first = r.transactionDate;
|
||||
c.last = r.transactionDate;
|
||||
perCurrency.set(r.currency, c);
|
||||
|
||||
const dk = `${r.domain}|${r.currency}`;
|
||||
const d =
|
||||
perDomain.get(dk) ??
|
||||
{
|
||||
domain: r.domain,
|
||||
currency: r.currency,
|
||||
charges: new Prisma.Decimal(0),
|
||||
credits: new Prisma.Decimal(0),
|
||||
count: 0,
|
||||
};
|
||||
d.count += 1;
|
||||
if (r.amount.lessThan(0)) d.charges = d.charges.plus(r.amount);
|
||||
else if (r.amount.greaterThan(0)) d.credits = d.credits.plus(r.amount);
|
||||
perDomain.set(dk, d);
|
||||
}
|
||||
|
||||
// Where the money goes, per charge type — the question a customer asks
|
||||
// when they query their balance.
|
||||
const byType = new Map<
|
||||
string,
|
||||
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
if (r.voidedAt != null) continue;
|
||||
if (!r.amount.lessThan(0)) continue;
|
||||
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
||||
const key = `${name}|${r.currency}`;
|
||||
const e =
|
||||
byType.get(key) ??
|
||||
{ name, currency: r.currency, total: new Prisma.Decimal(0), count: 0 };
|
||||
e.total = e.total.plus(r.amount);
|
||||
e.count += 1;
|
||||
byType.set(key, e);
|
||||
}
|
||||
|
||||
return {
|
||||
customer: {
|
||||
...customer,
|
||||
propertyCount: customer._count.properties,
|
||||
policyCount: customer._count.policies,
|
||||
},
|
||||
summary: [...perCurrency.values()].map((c) => ({
|
||||
currency: c.currency,
|
||||
charges: c.charges.toFixed(2),
|
||||
credits: c.credits.toFixed(2),
|
||||
balance: c.charges.plus(c.credits).toFixed(2),
|
||||
chargeCount: c.chargeCount,
|
||||
creditCount: c.creditCount,
|
||||
count: c.count,
|
||||
firstMovement: c.first,
|
||||
lastMovement: c.last,
|
||||
})),
|
||||
byDomain: [...perDomain.values()].map((d) => ({
|
||||
domain: d.domain,
|
||||
currency: d.currency,
|
||||
charges: d.charges.toFixed(2),
|
||||
credits: d.credits.toFixed(2),
|
||||
balance: d.charges.plus(d.credits).toFixed(2),
|
||||
count: d.count,
|
||||
})),
|
||||
byType: [...byType.values()]
|
||||
.map((t) => ({
|
||||
name: t.name,
|
||||
currency: t.currency,
|
||||
total: t.total.toFixed(2),
|
||||
count: t.count,
|
||||
}))
|
||||
.sort((a, b) => Number(a.total) - Number(b.total)),
|
||||
movements,
|
||||
};
|
||||
}
|
||||
|
||||
// --- writes (append + void; never edit or delete a booked row) ------------
|
||||
|
||||
async createMovement(dto: CreateMovementDto) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: dto.customerId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
|
||||
const date = new Date(dto.transactionDate);
|
||||
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
||||
|
||||
return this.prisma.transaction.create({
|
||||
data: {
|
||||
customerId: dto.customerId,
|
||||
domain: dto.domain,
|
||||
amount: dto.amount,
|
||||
transactionDate: date,
|
||||
currency: dto.currency,
|
||||
typeId: dto.typeId,
|
||||
period: dto.period,
|
||||
reference: dto.reference,
|
||||
checkNumber: dto.checkNumber,
|
||||
message: dto.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Reverse a movement by marking it voided; it stops counting toward totals. */
|
||||
async voidMovement(id: string, userId: string) {
|
||||
const tx = await this.prisma.transaction.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, voidedAt: true },
|
||||
});
|
||||
if (!tx) throw new NotFoundException(`Transaction ${id} not found`);
|
||||
if (tx.voidedAt) throw new BadRequestException("El movimiento ya está anulado");
|
||||
return this.prisma.transaction.update({
|
||||
where: { id },
|
||||
data: { voidedAt: new Date(), voidedById: userId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency, TransactionDomain } from "@jorgecuadros/database";
|
||||
|
||||
/**
|
||||
* A new ledger movement. `amount` is signed: negative = cargo (charge),
|
||||
* positive = abono (credit) — the module's sign convention. Booked movements
|
||||
* are never edited; a mistake is corrected by voiding and re-capturing.
|
||||
*/
|
||||
export class CreateMovementDto {
|
||||
@IsString() @MinLength(1) customerId!: string;
|
||||
@IsEnum(TransactionDomain) domain!: TransactionDomain;
|
||||
@IsNumber() amount!: number;
|
||||
@IsString() @MinLength(1) transactionDate!: string;
|
||||
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() typeId?: string;
|
||||
@IsOptional() @IsString() period?: string;
|
||||
@IsOptional() @IsString() reference?: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
@IsOptional() @IsString() message?: string;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Thin writer over the existing ActivityLog model. Every mutating route calls
|
||||
* this so who-did-what is recorded — the structural replacement for the old
|
||||
* PHP app's scattered Logger calls. Best-effort: a logging failure must never
|
||||
* fail the underlying write, so callers `void audit.log(...)` without awaiting.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async log(
|
||||
userId: string | null | undefined,
|
||||
event: string,
|
||||
message?: Record<string, unknown>,
|
||||
level = "info",
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.prisma.activityLog.create({
|
||||
data: {
|
||||
userId: userId ?? undefined,
|
||||
event,
|
||||
level,
|
||||
message: (message as Prisma.InputJsonValue) ?? undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* never let audit logging break a real write */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Small shared coercers for DTO fields that arrive as strings from JSON.
|
||||
// Distinguishing "field absent" (undefined -> leave unchanged) from
|
||||
// "field cleared" (null/"" -> set null) matters for PATCH semantics.
|
||||
|
||||
export function toDate(v?: string | null): Date | null | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === "" || v === null) return null;
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { AuditService } from "./audit.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
|
||||
/**
|
||||
* Editable customer fields. Internal/derived columns (nameSource, nameMissing,
|
||||
* legacy* provenance, archivedAt) are managed by the service, not the client.
|
||||
* `name` is the only required field; everything else is optional.
|
||||
*/
|
||||
export class CreateCustomerDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional() @IsString() addressLine1?: string;
|
||||
@IsOptional() @IsString() addressLine2?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsString() state?: string;
|
||||
@IsOptional() @IsString() zipCode?: string;
|
||||
@IsOptional() @IsString() country?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() mobile?: string;
|
||||
@IsOptional() @IsString() fax?: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@IsOptional() @IsString() identificationType?: string;
|
||||
@IsOptional() @IsString() identificationNumber?: string;
|
||||
/** ISO date string; coerced to Date by the service. */
|
||||
@IsOptional() @IsString() identificationExpiration?: string;
|
||||
@IsOptional() @IsString() customerSince?: string;
|
||||
@IsOptional() @IsBoolean() status?: boolean;
|
||||
@IsOptional() @IsNumber() minimumBalance?: number;
|
||||
@IsOptional() @IsNumber() feeAmount?: number;
|
||||
@IsOptional() @IsEnum(Currency) preferredCurrency?: Currency;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
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 { CustomersService } from "./customers.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("customers")
|
||||
export class CustomersController {
|
||||
constructor(
|
||||
private readonly customers: CustomersService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
stats() {
|
||||
return this.customers.stats();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("line") line?: "utility" | "insurance" | "both",
|
||||
@Query("includeArchived") includeArchived?: string,
|
||||
) {
|
||||
const p = Math.max(1, Number(page) || 1);
|
||||
const ps = Math.min(100, Math.max(1, Number(pageSize) || 25));
|
||||
return this.customers.list({
|
||||
query,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
line,
|
||||
includeArchived: includeArchived === "true",
|
||||
});
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
detail(@Param("id") id: string) {
|
||||
return this.customers.detail(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireAbility("customer:create")
|
||||
async create(@Body() dto: CreateCustomerDto, @Req() req: Request) {
|
||||
const c = await this.customers.create(dto);
|
||||
void this.audit.log(this.actingId(req), "customer.create", { customerId: c.id, name: c.name });
|
||||
return c;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequireAbility("customer:update")
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateCustomerDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const c = await this.customers.update(id, dto);
|
||||
void this.audit.log(this.actingId(req), "customer.update", { customerId: id });
|
||||
return c;
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequireAbility("customer:delete")
|
||||
async archive(@Param("id") id: string, @Req() req: Request) {
|
||||
const c = await this.customers.archive(id);
|
||||
void this.audit.log(this.actingId(req), "customer.archive", { customerId: id });
|
||||
return c;
|
||||
}
|
||||
|
||||
@Post(":id/restore")
|
||||
@RequireAbility("customer:delete")
|
||||
async restore(@Param("id") id: string, @Req() req: Request) {
|
||||
const c = await this.customers.restore(id);
|
||||
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CustomersController } from "./customers.controller";
|
||||
import { CustomersService } from "./customers.service";
|
||||
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
|
||||
export interface ListParams {
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
line?: "utility" | "insurance" | "both";
|
||||
includeArchived?: boolean;
|
||||
}
|
||||
|
||||
/** Parse an optional ISO date string to a Date (or null to clear it). */
|
||||
function toDate(v?: string): Date | null | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === "" || v === null) return null;
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Unified customer list with search + business-line filter, paginated. */
|
||||
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
||||
const where: Prisma.CustomerWhereInput = {};
|
||||
|
||||
if (!includeArchived) where.archivedAt = null;
|
||||
|
||||
if (query && query.trim()) {
|
||||
const q = query.trim();
|
||||
where.OR = [
|
||||
{ name: { contains: q } },
|
||||
{ email: { contains: q } },
|
||||
{ phone: { contains: q } },
|
||||
{ mobile: { contains: q } },
|
||||
{ city: { contains: q } },
|
||||
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
||||
];
|
||||
}
|
||||
if (line === "utility") where.properties = { some: {} };
|
||||
if (line === "insurance") where.policies = { some: {} };
|
||||
if (line === "both") {
|
||||
where.properties = { some: {} };
|
||||
where.policies = { some: {} };
|
||||
}
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.customer.count({ where }),
|
||||
this.prisma.customer.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
// Nameless records last: ordering by name alone floats every
|
||||
// "(SIN NOMBRE)" to the top, since "(" sorts before every letter.
|
||||
orderBy: [{ nameMissing: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameSource: true,
|
||||
city: true,
|
||||
state: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
status: true,
|
||||
archivedAt: true,
|
||||
_count: { select: { properties: true, policies: true, transactions: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
nameSource: r.nameSource,
|
||||
city: r.city,
|
||||
state: r.state,
|
||||
email: r.email,
|
||||
phone: r.phone,
|
||||
mobile: r.mobile,
|
||||
status: r.status,
|
||||
archived: r.archivedAt != null,
|
||||
propertyCount: r._count.properties,
|
||||
policyCount: r._count.policies,
|
||||
transactionCount: r._count.transactions,
|
||||
hasUtilities: r._count.properties > 0,
|
||||
hasInsurance: r._count.policies > 0,
|
||||
}));
|
||||
|
||||
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
/** Full unified customer view: identity + both business lines + ledger. */
|
||||
async detail(id: string) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
legacyRefs: true,
|
||||
properties: {
|
||||
include: { services: true, trustAccount: true, documents: true },
|
||||
},
|
||||
policies: {
|
||||
orderBy: { policyFrom: "desc" },
|
||||
include: {
|
||||
policyType: true,
|
||||
insuranceProvider: true,
|
||||
installments: { orderBy: { sequence: "asc" } },
|
||||
vehicles: true,
|
||||
insuredDrivers: true,
|
||||
beneficiaries: true,
|
||||
claims: true,
|
||||
documents: true,
|
||||
},
|
||||
},
|
||||
transactions: {
|
||||
orderBy: { transactionDate: "desc" },
|
||||
take: 100,
|
||||
include: { type: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer ${id} not found`);
|
||||
}
|
||||
|
||||
// Ledger totals per domain + currency (the "one statement across both
|
||||
// business lines" payoff), computed in the DB rather than in JS.
|
||||
const summary = await this.prisma.transaction.groupBy({
|
||||
by: ["domain", "currency"],
|
||||
// Exclude voided rows so the per-domain balance matches the statement.
|
||||
where: { customerId: id, voidedAt: null },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
return {
|
||||
...customer,
|
||||
transactionSummary: summary.map((s) => ({
|
||||
domain: s.domain,
|
||||
currency: s.currency,
|
||||
total: s._sum.amount,
|
||||
count: s._count._all,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
private toData(dto: CreateCustomerDto | UpdateCustomerDto) {
|
||||
// Whitelisted by the DTO already; map the date strings to Date objects.
|
||||
const { identificationExpiration, customerSince, ...rest } = dto;
|
||||
return {
|
||||
...rest,
|
||||
...(identificationExpiration !== undefined && {
|
||||
identificationExpiration: toDate(identificationExpiration),
|
||||
}),
|
||||
...(customerSince !== undefined && { customerSince: toDate(customerSince) }),
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: CreateCustomerDto) {
|
||||
return this.prisma.customer.create({
|
||||
// App-created rows: nameMissing false (name is required), no legacy
|
||||
// provenance — those columns stay null, marking a native record.
|
||||
data: { ...this.toData(dto), name: dto.name, nameMissing: false },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCustomerDto) {
|
||||
await this.ensureExists(id);
|
||||
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
|
||||
}
|
||||
|
||||
/** Soft-delete: hide from default lists, keep the row + provenance. */
|
||||
async archive(id: string) {
|
||||
await this.ensureExists(id);
|
||||
return this.prisma.customer.update({
|
||||
where: { id },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async restore(id: string) {
|
||||
await this.ensureExists(id);
|
||||
return this.prisma.customer.update({
|
||||
where: { id },
|
||||
data: { archivedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureExists(id: string) {
|
||||
const found = await this.prisma.customer.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException(`Customer ${id} not found`);
|
||||
}
|
||||
|
||||
/** Top-line counts for a dashboard header. */
|
||||
async stats() {
|
||||
const [customers, withUtilities, withInsurance, policies, properties, transactions] =
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.customer.count(),
|
||||
this.prisma.customer.count({ where: { properties: { some: {} } } }),
|
||||
this.prisma.customer.count({ where: { policies: { some: {} } } }),
|
||||
this.prisma.policy.count(),
|
||||
this.prisma.property.count(),
|
||||
this.prisma.transaction.count(),
|
||||
]);
|
||||
const bothLines = await this.prisma.customer.count({
|
||||
where: { properties: { some: {} }, policies: { some: {} } },
|
||||
});
|
||||
return { customers, withUtilities, withInsurance, bothLines, policies, properties, transactions };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
|
||||
/** Same editable fields as create, all optional. */
|
||||
export class UpdateCustomerDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsString() addressLine1?: string;
|
||||
@IsOptional() @IsString() addressLine2?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsString() state?: string;
|
||||
@IsOptional() @IsString() zipCode?: string;
|
||||
@IsOptional() @IsString() country?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() mobile?: string;
|
||||
@IsOptional() @IsString() fax?: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@IsOptional() @IsString() identificationType?: string;
|
||||
@IsOptional() @IsString() identificationNumber?: string;
|
||||
@IsOptional() @IsString() identificationExpiration?: string;
|
||||
@IsOptional() @IsString() customerSince?: string;
|
||||
@IsOptional() @IsBoolean() status?: boolean;
|
||||
@IsOptional() @IsNumber() minimumBalance?: number;
|
||||
@IsOptional() @IsNumber() feeAmount?: number;
|
||||
@IsOptional() @IsEnum(Currency) preferredCurrency?: Currency;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { Request, Response } 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 { OpsService } from "./ops.service";
|
||||
import { StartJobDto } from "./start-job.dto";
|
||||
|
||||
/** Every route is ADMIN-only (ability "db:manage"). */
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@RequireAbility("db:manage")
|
||||
@Controller("ops")
|
||||
export class OpsController {
|
||||
constructor(
|
||||
private readonly ops: OpsService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- ingest */
|
||||
|
||||
@Get("ingest")
|
||||
listIngest() {
|
||||
return this.ops.listIngest();
|
||||
}
|
||||
|
||||
@Post("ingest/:name")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", { limits: { fileSize: 500 * 1024 * 1024 } }),
|
||||
)
|
||||
async uploadIngest(
|
||||
@Param("name") name: string,
|
||||
@UploadedFile() file: { buffer: Buffer; size: number } | undefined,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
if (!file) throw new Error("No se recibió ningún archivo.");
|
||||
await this.ops.saveIngest(name, file.buffer);
|
||||
void this.audit.log(this.actingId(req), "ops.ingest.upload", {
|
||||
name,
|
||||
size: file.size,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Delete("ingest/:name")
|
||||
async deleteIngest(@Param("name") name: string, @Req() req: Request) {
|
||||
await this.ops.deleteIngest(name);
|
||||
void this.audit.log(this.actingId(req), "ops.ingest.delete", { name });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ backups */
|
||||
|
||||
@Get("backups")
|
||||
listBackups() {
|
||||
return this.ops.listBackups();
|
||||
}
|
||||
|
||||
@Get("backups/:name/download")
|
||||
download(
|
||||
@Param("name") name: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): StreamableFile {
|
||||
const { stream, name: safe } = this.ops.backupStream(name);
|
||||
res.set({
|
||||
"Content-Type": "application/gzip",
|
||||
"Content-Disposition": `attachment; filename="${safe}"`,
|
||||
});
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
@Delete("backups/:name")
|
||||
async deleteBackup(@Param("name") name: string, @Req() req: Request) {
|
||||
await this.ops.deleteBackup(name);
|
||||
void this.audit.log(this.actingId(req), "ops.backup.delete", { name });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- jobs */
|
||||
|
||||
@Get("jobs")
|
||||
listJobs() {
|
||||
return this.ops.listJobs();
|
||||
}
|
||||
|
||||
@Get("jobs/:id")
|
||||
getJob(@Param("id") id: string) {
|
||||
return this.ops.getJob(id);
|
||||
}
|
||||
|
||||
@Post("jobs")
|
||||
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
||||
const userId = this.actingId(req);
|
||||
const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId);
|
||||
void this.audit.log(userId, "ops.job.start", {
|
||||
jobId: job.id,
|
||||
kind: dto.kind,
|
||||
file: dto.file,
|
||||
});
|
||||
return job;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OpsController } from "./ops.controller";
|
||||
import { OpsService } from "./ops.service";
|
||||
|
||||
@Module({
|
||||
controllers: [OpsController],
|
||||
providers: [OpsService],
|
||||
})
|
||||
export class OpsModule {}
|
||||
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createReadStream, promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { OpsJobKind } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Admin database operations. Everything long-running (mysqldump, mysql restore,
|
||||
* the Python migration) runs as a detached child process recorded as one OpsJob
|
||||
* row whose `log` is appended as the process talks; the web polls that row.
|
||||
*
|
||||
* Only ONE mutating job runs at a time (a RUNNING row blocks a new start) — a
|
||||
* restore or re-import racing a migration would corrupt the database.
|
||||
*/
|
||||
|
||||
/** The four legacy Access files. Uploads are allowlisted to exactly these
|
||||
* names so an ingest write can never land at an arbitrary path. */
|
||||
export const INGEST_FILES = [
|
||||
"UTILITIES.accdb",
|
||||
"SEGUROS 16.mdb",
|
||||
"SEGUROS 16_be.mdb",
|
||||
"SCOTHIA.mdb",
|
||||
] as const;
|
||||
export type IngestName = (typeof INGEST_FILES)[number];
|
||||
|
||||
interface MysqlConn {
|
||||
host: string;
|
||||
port: string;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(OpsService.name);
|
||||
|
||||
private readonly migrationDir =
|
||||
process.env.MIGRATION_DIR ?? path.resolve(process.cwd(), "migration");
|
||||
private readonly ingestDir =
|
||||
process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest");
|
||||
private readonly backupDir =
|
||||
process.env.BACKUP_DIR ?? path.join(this.migrationDir, "backups");
|
||||
private readonly migrationEnv = process.env.MIGRATION_ENV ?? "dev";
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await fs.mkdir(this.ingestDir, { recursive: true });
|
||||
await fs.mkdir(this.backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- ingest */
|
||||
|
||||
private assertIngestName(name: string): IngestName {
|
||||
if (!INGEST_FILES.includes(name as IngestName)) {
|
||||
throw new BadRequestException(
|
||||
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return name as IngestName;
|
||||
}
|
||||
|
||||
async listIngest(): Promise<
|
||||
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
|
||||
> {
|
||||
return Promise.all(
|
||||
INGEST_FILES.map(async (name) => {
|
||||
try {
|
||||
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||
return {
|
||||
name,
|
||||
present: true,
|
||||
size: st.size,
|
||||
modifiedAt: st.mtime.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return { name, present: false, size: null, modifiedAt: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async saveIngest(name: string, data: Buffer): Promise<void> {
|
||||
const safe = this.assertIngestName(name);
|
||||
await fs.writeFile(path.join(this.ingestDir, safe), data);
|
||||
}
|
||||
|
||||
async deleteIngest(name: string): Promise<void> {
|
||||
const safe = this.assertIngestName(name);
|
||||
await fs.rm(path.join(this.ingestDir, safe), { force: true });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- backups */
|
||||
|
||||
private assertBackupName(name: string): string {
|
||||
// No path separators, must be a produced backup file.
|
||||
if (!/^[A-Za-z0-9._-]+\.sql\.gz$/.test(name)) {
|
||||
throw new BadRequestException("Nombre de respaldo inválido.");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
async listBackups(): Promise<
|
||||
{ name: string; size: number; createdAt: string }[]
|
||||
> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(this.backupDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const rows = await Promise.all(
|
||||
names
|
||||
.filter((n) => n.endsWith(".sql.gz"))
|
||||
.map(async (name) => {
|
||||
const st = await fs.stat(path.join(this.backupDir, name));
|
||||
return { name, size: st.size, createdAt: st.mtime.toISOString() };
|
||||
}),
|
||||
);
|
||||
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
backupStream(name: string) {
|
||||
const safe = this.assertBackupName(name);
|
||||
const full = path.join(this.backupDir, safe);
|
||||
return { stream: createReadStream(full), name: safe };
|
||||
}
|
||||
|
||||
async deleteBackup(name: string): Promise<void> {
|
||||
const safe = this.assertBackupName(name);
|
||||
await fs.rm(path.join(this.backupDir, safe), { force: true });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- jobs */
|
||||
|
||||
listJobs(limit = 20) {
|
||||
return this.prisma.opsJob.findMany({
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async getJob(id: string) {
|
||||
const job = await this.prisma.opsJob.findUnique({ where: { id } });
|
||||
if (!job) throw new NotFoundException("Trabajo no encontrado.");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a mutating op. Refuses if another job is already RUNNING. Returns the
|
||||
* new job row immediately; the process runs on in the background and appends
|
||||
* to `log` until it exits.
|
||||
*/
|
||||
async startJob(
|
||||
kind: OpsJobKind,
|
||||
params: Record<string, unknown>,
|
||||
userId: string | undefined,
|
||||
) {
|
||||
const running = await this.prisma.opsJob.count({ where: { status: "RUNNING" } });
|
||||
if (running > 0) {
|
||||
throw new ConflictException(
|
||||
"Ya hay una operación en curso. Espere a que termine.",
|
||||
);
|
||||
}
|
||||
|
||||
const conn = this.parseDbUrl();
|
||||
const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
|
||||
|
||||
const job = await this.prisma.opsJob.create({
|
||||
data: {
|
||||
kind,
|
||||
status: "RUNNING",
|
||||
log: "",
|
||||
params: resolvedParams as object,
|
||||
createdById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
this.run(job.id, cmd, conn.password);
|
||||
return job;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- internals */
|
||||
|
||||
private parseDbUrl(): MysqlConn {
|
||||
const raw = process.env.DATABASE_URL;
|
||||
if (!raw) throw new BadRequestException("DATABASE_URL no está configurada.");
|
||||
const u = new URL(raw);
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: u.port || "3306",
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in
|
||||
* the child env, never on the command line (which would leak via `ps`). */
|
||||
private connFlags(c: MysqlConn): string {
|
||||
return `--host=${c.host} --port=${c.port} --user=${shq(c.user)}`;
|
||||
}
|
||||
|
||||
private timestamp(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
||||
}
|
||||
|
||||
private async buildCommand(
|
||||
kind: OpsJobKind,
|
||||
params: Record<string, unknown>,
|
||||
conn: MysqlConn,
|
||||
): Promise<{ cmd: string; resolvedParams: Record<string, unknown> }> {
|
||||
const flags = this.connFlags(conn);
|
||||
const db = shq(conn.database);
|
||||
|
||||
if (kind === "BACKUP") {
|
||||
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||
const out = shq(path.join(this.backupDir, file));
|
||||
return {
|
||||
cmd: `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out}`,
|
||||
resolvedParams: { file },
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "RESTORE") {
|
||||
const name = this.assertBackupName(String(params.file ?? ""));
|
||||
const full = path.join(this.backupDir, name);
|
||||
await fs.access(full).catch(() => {
|
||||
throw new NotFoundException(`Respaldo no encontrado: ${name}`);
|
||||
});
|
||||
return {
|
||||
cmd: `gunzip -c ${shq(full)} | mysql ${flags} ${db}`,
|
||||
resolvedParams: { file: name },
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "SYNC") {
|
||||
const file = `pre-sync-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||
const out = shq(path.join(this.backupDir, file));
|
||||
const py = await this.pythonBin();
|
||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||
const cmd =
|
||||
`echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
||||
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
|
||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||
}
|
||||
|
||||
if (kind === "REIMPORT") {
|
||||
// Safety backup first, then a full truncate+rebuild from the ingest files.
|
||||
const file = `pre-reimport-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||
const out = shq(path.join(this.backupDir, file));
|
||||
const py = await this.pythonBin();
|
||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||
const cmd =
|
||||
`echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
||||
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||
}
|
||||
|
||||
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
||||
}
|
||||
|
||||
/** Prefer the migration venv python if it exists (local dev), else system. */
|
||||
private async pythonBin(): Promise<string> {
|
||||
const venv = path.join(this.migrationDir, ".venv", "bin", "python");
|
||||
try {
|
||||
await fs.access(venv);
|
||||
return venv;
|
||||
} catch {
|
||||
return process.env.PYTHON_BIN ?? "python3";
|
||||
}
|
||||
}
|
||||
|
||||
private run(jobId: string, cmd: string, password: string): void {
|
||||
const child = spawn("sh", ["-c", cmd], {
|
||||
cwd: this.migrationDir,
|
||||
env: {
|
||||
...process.env,
|
||||
MYSQL_PWD: password,
|
||||
INGEST_DIR: this.ingestDir,
|
||||
BACKUP_DIR: this.backupDir,
|
||||
},
|
||||
});
|
||||
|
||||
let buffer = "";
|
||||
let pending = "";
|
||||
let flushing = false;
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const flush = async () => {
|
||||
if (flushing || !pending) return;
|
||||
flushing = true;
|
||||
const chunk = pending;
|
||||
pending = "";
|
||||
try {
|
||||
await this.prisma.opsJob.update({
|
||||
where: { id: jobId },
|
||||
data: { log: { set: buffer } },
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(`ops job ${jobId} log flush failed: ${String(e)}`);
|
||||
} finally {
|
||||
flushing = false;
|
||||
void chunk;
|
||||
}
|
||||
};
|
||||
|
||||
const onData = (d: Buffer) => {
|
||||
const text = d.toString();
|
||||
buffer += text;
|
||||
pending += text;
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flush();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", onData);
|
||||
child.stderr.on("data", onData);
|
||||
|
||||
const finalize = async (status: "SUCCESS" | "FAILED", tail: string) => {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
buffer += tail;
|
||||
await this.prisma.opsJob
|
||||
.update({
|
||||
where: { id: jobId },
|
||||
data: { status, log: { set: buffer }, finishedAt: new Date() },
|
||||
})
|
||||
.catch((e) => this.logger.error(`ops job ${jobId} finalize failed: ${String(e)}`));
|
||||
};
|
||||
|
||||
child.on("error", (err) => {
|
||||
void finalize("FAILED", `\n[proceso no pudo iniciar] ${err.message}\n`);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) void finalize("SUCCESS", `\n[completado con éxito]\n`);
|
||||
else void finalize("FAILED", `\n[terminó con código ${code}]\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-quote a value for a POSIX shell command. */
|
||||
function shq(v: string): string {
|
||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsEnum, IsOptional, IsString } from "class-validator";
|
||||
import { OpsJobKind } from "@jorgecuadros/database";
|
||||
|
||||
export class StartJobDto {
|
||||
@IsEnum(OpsJobKind)
|
||||
kind!: OpsJobKind;
|
||||
|
||||
/** Target backup filename — required for RESTORE, ignored otherwise. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
file?: string;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from "class-validator";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
|
||||
// Each child DTO covers create; updates reuse the same shape with all fields
|
||||
// optional via the corresponding Update class. Route supplies the policyId.
|
||||
|
||||
export class InstallmentDto {
|
||||
@IsInt() sequence!: number;
|
||||
@IsOptional() @IsNumber() amount?: number;
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() dueDate?: string;
|
||||
@IsOptional() @IsString() paidDate?: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||
}
|
||||
export class UpdateInstallmentDto {
|
||||
@IsOptional() @IsInt() sequence?: number;
|
||||
@IsOptional() @IsNumber() amount?: number;
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() dueDate?: string;
|
||||
@IsOptional() @IsString() paidDate?: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||
}
|
||||
|
||||
export class VehicleDto {
|
||||
@IsOptional() @IsString() make?: string;
|
||||
@IsOptional() @IsString() model?: string;
|
||||
@IsOptional() @IsString() modelYear?: string;
|
||||
@IsOptional() @IsString() bodyType?: string;
|
||||
@IsOptional() @IsString() engineNumber?: string;
|
||||
@IsOptional() @IsString() licensePlate?: string;
|
||||
@IsOptional() @IsString() vinNumber?: string;
|
||||
@IsOptional() @IsString() stateCode?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
export class UpdateVehicleDto extends VehicleDto {}
|
||||
|
||||
export class DriverDto {
|
||||
@IsOptional() @IsString() fullName?: string;
|
||||
@IsOptional() @IsString() birthDate?: string;
|
||||
@IsOptional() @IsString() sex?: string;
|
||||
@IsOptional() @IsString() occupation?: string;
|
||||
@IsOptional() @IsString() licenseNumber?: string;
|
||||
@IsOptional() @IsString() licenseState?: string;
|
||||
}
|
||||
export class UpdateDriverDto extends DriverDto {}
|
||||
|
||||
export class BeneficiaryDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() address?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
}
|
||||
export class UpdateBeneficiaryDto extends BeneficiaryDto {}
|
||||
|
||||
export class ClaimDto {
|
||||
@IsOptional() @IsString() claimType?: string;
|
||||
@IsOptional() @IsString() incidentDate?: string;
|
||||
@IsOptional() @IsString() reportedDate?: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsString() adjusterId?: string;
|
||||
@IsOptional() @IsNumber() claimedAmount?: number;
|
||||
@IsOptional() @IsNumber() settledAmount?: number;
|
||||
@IsOptional() @IsString() settlementDate?: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
@IsOptional() @IsBoolean() resolved?: boolean;
|
||||
@IsOptional() @IsString() resolutionNotes?: string;
|
||||
}
|
||||
export class UpdateClaimDto extends ClaimDto {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class ProviderDto {
|
||||
@IsString() @MinLength(1) name!: string;
|
||||
}
|
||||
export class UpdateProviderDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
}
|
||||
|
||||
export class PolicyTypeDto {
|
||||
@IsString() @MinLength(1) name!: string;
|
||||
@IsOptional() @IsString() shortDescription?: string;
|
||||
}
|
||||
export class UpdatePolicyTypeDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsString() shortDescription?: string;
|
||||
}
|
||||
|
||||
export class AdjusterDto {
|
||||
@IsOptional() @IsString() company?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() beeper?: string;
|
||||
}
|
||||
export class UpdateAdjusterDto extends AdjusterDto {}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
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 { PoliciesService } from "./policies.service";
|
||||
import {
|
||||
AdjusterDto,
|
||||
PolicyTypeDto,
|
||||
ProviderDto,
|
||||
UpdateAdjusterDto,
|
||||
UpdatePolicyTypeDto,
|
||||
UpdateProviderDto,
|
||||
} from "./lookup.dto";
|
||||
|
||||
/**
|
||||
* Insurance reference data: providers, policy types, adjusters. Reading is open
|
||||
* to any authenticated user (the policy form needs the options); mutating needs
|
||||
* "lookup:manage" (MANAGER+).
|
||||
*/
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("lookups")
|
||||
export class LookupsController {
|
||||
constructor(
|
||||
private readonly policies: PoliciesService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.policies.listLookups();
|
||||
}
|
||||
|
||||
@Post("providers")
|
||||
@RequireAbility("lookup:manage")
|
||||
async createProvider(@Body() dto: ProviderDto, @Req() req: Request) {
|
||||
const row = await this.policies.createProvider(dto);
|
||||
void this.audit.log(this.actingId(req), "lookup.provider.create", {
|
||||
providerId: row.id,
|
||||
name: row.name,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
@Patch("providers/:id")
|
||||
@RequireAbility("lookup:manage")
|
||||
async updateProvider(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateProviderDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const row = await this.policies.updateProvider(id, dto);
|
||||
void this.audit.log(this.actingId(req), "lookup.provider.update", {
|
||||
providerId: id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
@Delete("providers/:id")
|
||||
@RequireAbility("lookup:manage")
|
||||
async removeProvider(@Param("id") id: string, @Req() req: Request) {
|
||||
const row = await this.policies.removeProvider(id);
|
||||
void this.audit.log(this.actingId(req), "lookup.provider.delete", {
|
||||
providerId: id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
@Post("policy-types")
|
||||
@RequireAbility("lookup:manage")
|
||||
async createType(@Body() dto: PolicyTypeDto, @Req() req: Request) {
|
||||
const row = await this.policies.createPolicyType(dto);
|
||||
void this.audit.log(this.actingId(req), "lookup.policyType.create", {
|
||||
policyTypeId: row.id,
|
||||
name: row.name,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
@Patch("policy-types/:id")
|
||||
@RequireAbility("lookup:manage")
|
||||
async updateType(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdatePolicyTypeDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const row = await this.policies.updatePolicyType(id, dto);
|
||||
void this.audit.log(this.actingId(req), "lookup.policyType.update", {
|
||||
policyTypeId: id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
@Delete("policy-types/:id")
|
||||
@RequireAbility("lookup:manage")
|
||||
async removeType(@Param("id") id: string, @Req() req: Request) {
|
||||
const row = await this.policies.removePolicyType(id);
|
||||
void this.audit.log(this.actingId(req), "lookup.policyType.delete", {
|
||||
policyTypeId: id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
@Post("adjusters")
|
||||
@RequireAbility("lookup:manage")
|
||||
async createAdjuster(@Body() dto: AdjusterDto, @Req() req: Request) {
|
||||
const row = await this.policies.createAdjuster(dto);
|
||||
void this.audit.log(this.actingId(req), "lookup.adjuster.create", {
|
||||
adjusterId: row.id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
@Patch("adjusters/:id")
|
||||
@RequireAbility("lookup:manage")
|
||||
async updateAdjuster(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateAdjusterDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const row = await this.policies.updateAdjuster(id, dto);
|
||||
void this.audit.log(this.actingId(req), "lookup.adjuster.update", {
|
||||
adjusterId: id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
@Delete("adjusters/:id")
|
||||
@RequireAbility("lookup:manage")
|
||||
async removeAdjuster(@Param("id") id: string, @Req() req: Request) {
|
||||
const row = await this.policies.removeAdjuster(id);
|
||||
void this.audit.log(this.actingId(req), "lookup.adjuster.delete", {
|
||||
adjusterId: id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
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 {
|
||||
PoliciesService,
|
||||
type PolicySort,
|
||||
type PolicyStatus,
|
||||
} from "./policies.service";
|
||||
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
|
||||
import {
|
||||
BeneficiaryDto,
|
||||
ClaimDto,
|
||||
DriverDto,
|
||||
InstallmentDto,
|
||||
UpdateBeneficiaryDto,
|
||||
UpdateClaimDto,
|
||||
UpdateDriverDto,
|
||||
UpdateInstallmentDto,
|
||||
VehicleDto,
|
||||
} from "./children.dto";
|
||||
|
||||
const STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"];
|
||||
const SORTS: PolicySort[] = [
|
||||
"expiry_desc",
|
||||
"expiry_asc",
|
||||
"customer",
|
||||
"number",
|
||||
"premium_desc",
|
||||
];
|
||||
|
||||
function parseDays(days?: string): number {
|
||||
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("policies")
|
||||
export class PoliciesController {
|
||||
constructor(
|
||||
private readonly policies: PoliciesService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
stats(@Query("days") days?: string) {
|
||||
return this.policies.stats(parseDays(days));
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.policies.facets();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("days") days?: string,
|
||||
@Query("typeId") typeId?: string,
|
||||
@Query("providerId") providerId?: string,
|
||||
@Query("liquidated") liquidated?: string,
|
||||
@Query("includeArchived") includeArchived?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.policies.list({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
status: STATUSES.includes(status as PolicyStatus)
|
||||
? (status as PolicyStatus)
|
||||
: undefined,
|
||||
days: parseDays(days),
|
||||
typeId: typeId || undefined,
|
||||
providerId: providerId || undefined,
|
||||
liquidated:
|
||||
liquidated === "true" ? true : liquidated === "false" ? false : undefined,
|
||||
includeArchived: includeArchived === "true",
|
||||
sort: SORTS.includes(sort as PolicySort)
|
||||
? (sort as PolicySort)
|
||||
: "expiry_desc",
|
||||
});
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||
return this.policies.detail(id, parseDays(days));
|
||||
}
|
||||
|
||||
// --- header writes --------------------------------------------------------
|
||||
|
||||
@Post()
|
||||
@RequireAbility("policy:create")
|
||||
async create(@Body() dto: CreatePolicyDto, @Req() req: Request) {
|
||||
const p = await this.policies.create(dto);
|
||||
void this.audit.log(this.actingId(req), "policy.create", { policyId: p.id });
|
||||
return p;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequireAbility("policy:update")
|
||||
async update(@Param("id") id: string, @Body() dto: UpdatePolicyDto, @Req() req: Request) {
|
||||
const p = await this.policies.update(id, dto);
|
||||
void this.audit.log(this.actingId(req), "policy.update", { policyId: id });
|
||||
return p;
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequireAbility("policy:delete")
|
||||
async archive(@Param("id") id: string, @Req() req: Request) {
|
||||
const p = await this.policies.archive(id);
|
||||
void this.audit.log(this.actingId(req), "policy.archive", { policyId: id });
|
||||
return p;
|
||||
}
|
||||
|
||||
@Post(":id/restore")
|
||||
@RequireAbility("policy:delete")
|
||||
async restore(@Param("id") id: string, @Req() req: Request) {
|
||||
const p = await this.policies.restore(id);
|
||||
void this.audit.log(this.actingId(req), "policy.restore", { policyId: id });
|
||||
return p;
|
||||
}
|
||||
|
||||
// --- children (all editing a policy => policy:update) ---------------------
|
||||
|
||||
@Post(":id/installments")
|
||||
@RequireAbility("policy:update")
|
||||
addInstallment(@Param("id") id: string, @Body() dto: InstallmentDto) {
|
||||
return this.policies.addInstallment(id, dto);
|
||||
}
|
||||
@Patch(":id/installments/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
updateInstallment(
|
||||
@Param("id") id: string,
|
||||
@Param("childId") childId: string,
|
||||
@Body() dto: UpdateInstallmentDto,
|
||||
) {
|
||||
return this.policies.updateInstallment(id, childId, dto);
|
||||
}
|
||||
@Delete(":id/installments/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
removeInstallment(@Param("id") id: string, @Param("childId") childId: string) {
|
||||
return this.policies.removeInstallment(id, childId);
|
||||
}
|
||||
|
||||
@Post(":id/vehicles")
|
||||
@RequireAbility("policy:update")
|
||||
addVehicle(@Param("id") id: string, @Body() dto: VehicleDto) {
|
||||
return this.policies.addVehicle(id, dto);
|
||||
}
|
||||
@Patch(":id/vehicles/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
updateVehicle(
|
||||
@Param("id") id: string,
|
||||
@Param("childId") childId: string,
|
||||
@Body() dto: VehicleDto,
|
||||
) {
|
||||
return this.policies.updateVehicle(id, childId, dto);
|
||||
}
|
||||
@Delete(":id/vehicles/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
removeVehicle(@Param("id") id: string, @Param("childId") childId: string) {
|
||||
return this.policies.removeVehicle(id, childId);
|
||||
}
|
||||
|
||||
@Post(":id/drivers")
|
||||
@RequireAbility("policy:update")
|
||||
addDriver(@Param("id") id: string, @Body() dto: DriverDto) {
|
||||
return this.policies.addDriver(id, dto);
|
||||
}
|
||||
@Patch(":id/drivers/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
updateDriver(
|
||||
@Param("id") id: string,
|
||||
@Param("childId") childId: string,
|
||||
@Body() dto: UpdateDriverDto,
|
||||
) {
|
||||
return this.policies.updateDriver(id, childId, dto);
|
||||
}
|
||||
@Delete(":id/drivers/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
removeDriver(@Param("id") id: string, @Param("childId") childId: string) {
|
||||
return this.policies.removeDriver(id, childId);
|
||||
}
|
||||
|
||||
@Post(":id/beneficiaries")
|
||||
@RequireAbility("policy:update")
|
||||
addBeneficiary(@Param("id") id: string, @Body() dto: BeneficiaryDto) {
|
||||
return this.policies.addBeneficiary(id, dto);
|
||||
}
|
||||
@Patch(":id/beneficiaries/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
updateBeneficiary(
|
||||
@Param("id") id: string,
|
||||
@Param("childId") childId: string,
|
||||
@Body() dto: UpdateBeneficiaryDto,
|
||||
) {
|
||||
return this.policies.updateBeneficiary(id, childId, dto);
|
||||
}
|
||||
@Delete(":id/beneficiaries/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
removeBeneficiary(@Param("id") id: string, @Param("childId") childId: string) {
|
||||
return this.policies.removeBeneficiary(id, childId);
|
||||
}
|
||||
|
||||
@Post(":id/claims")
|
||||
@RequireAbility("policy:update")
|
||||
addClaim(@Param("id") id: string, @Body() dto: ClaimDto) {
|
||||
return this.policies.addClaim(id, dto);
|
||||
}
|
||||
@Patch(":id/claims/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
updateClaim(
|
||||
@Param("id") id: string,
|
||||
@Param("childId") childId: string,
|
||||
@Body() dto: UpdateClaimDto,
|
||||
) {
|
||||
return this.policies.updateClaim(id, childId, dto);
|
||||
}
|
||||
@Delete(":id/claims/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
removeClaim(@Param("id") id: string, @Param("childId") childId: string) {
|
||||
return this.policies.removeClaim(id, childId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PoliciesController } from "./policies.controller";
|
||||
import { LookupsController } from "./lookups.controller";
|
||||
import { PoliciesService } from "./policies.service";
|
||||
|
||||
@Module({
|
||||
controllers: [PoliciesController, LookupsController],
|
||||
providers: [PoliciesService],
|
||||
})
|
||||
export class PoliciesModule {}
|
||||
@@ -0,0 +1,532 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { toDate } from "../common/coerce";
|
||||
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
|
||||
import {
|
||||
BeneficiaryDto,
|
||||
ClaimDto,
|
||||
DriverDto,
|
||||
InstallmentDto,
|
||||
UpdateBeneficiaryDto,
|
||||
UpdateClaimDto,
|
||||
UpdateDriverDto,
|
||||
UpdateInstallmentDto,
|
||||
VehicleDto,
|
||||
} from "./children.dto";
|
||||
import {
|
||||
AdjusterDto,
|
||||
PolicyTypeDto,
|
||||
ProviderDto,
|
||||
UpdateAdjusterDto,
|
||||
UpdatePolicyTypeDto,
|
||||
UpdateProviderDto,
|
||||
} from "./lookup.dto";
|
||||
|
||||
/**
|
||||
* Vigencia buckets, derived from `policyTo` against today. `undated` is a real
|
||||
* bucket rather than an error case: 528 of the migrated policies carry no end
|
||||
* date at all (the legacy Access tables left it blank), so they can neither be
|
||||
* called current nor expired.
|
||||
*/
|
||||
export type PolicyStatus = "active" | "expiring" | "expired" | "undated";
|
||||
|
||||
export type PolicySort =
|
||||
| "expiry_desc"
|
||||
| "expiry_asc"
|
||||
| "customer"
|
||||
| "number"
|
||||
| "premium_desc";
|
||||
|
||||
export interface ListParams {
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: PolicyStatus;
|
||||
/** Window in days for the `expiring` bucket. */
|
||||
days: number;
|
||||
typeId?: string;
|
||||
providerId?: string;
|
||||
liquidated?: boolean;
|
||||
includeArchived?: boolean;
|
||||
sort: PolicySort;
|
||||
}
|
||||
|
||||
/** Midnight today, UTC — policy dates are stored date-only at 00:00 UTC. */
|
||||
function today(): Date {
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||
);
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
return new Date(d.getTime() + days * 86400000);
|
||||
}
|
||||
|
||||
function statusOf(policyTo: Date | null, from: Date, soon: Date): PolicyStatus {
|
||||
if (!policyTo) return "undated";
|
||||
if (policyTo < from) return "expired";
|
||||
return policyTo <= soon ? "expiring" : "active";
|
||||
}
|
||||
|
||||
function daysUntil(policyTo: Date | null, from: Date): number | null {
|
||||
if (!policyTo) return null;
|
||||
return Math.round((policyTo.getTime() - from.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PoliciesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private statusWhere(
|
||||
status: PolicyStatus | undefined,
|
||||
days: number,
|
||||
): Prisma.PolicyWhereInput {
|
||||
const from = today();
|
||||
switch (status) {
|
||||
case "active":
|
||||
return { policyTo: { gte: from } };
|
||||
case "expiring":
|
||||
return { policyTo: { gte: from, lte: addDays(from, days) } };
|
||||
case "expired":
|
||||
return { policyTo: { lt: from } };
|
||||
case "undated":
|
||||
return { policyTo: null };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private orderBy(sort: PolicySort): Prisma.PolicyOrderByWithRelationInput[] {
|
||||
switch (sort) {
|
||||
case "expiry_asc":
|
||||
return [{ policyTo: "asc" }];
|
||||
case "customer":
|
||||
return [{ customer: { name: "asc" } }, { policyTo: "desc" }];
|
||||
case "number":
|
||||
return [{ policyNumber: "asc" }];
|
||||
case "premium_desc":
|
||||
// Sorts on netPremium, not total: `total` is 0 or null on all but 2 of
|
||||
// the 2378 migrated policies, so ordering by it is meaningless.
|
||||
return [{ netPremium: "desc" }];
|
||||
default:
|
||||
// MySQL sorts NULLs last on DESC, which puts the 528 undated policies
|
||||
// at the end instead of the top — the behaviour we want by default.
|
||||
return [{ policyTo: "desc" }];
|
||||
}
|
||||
}
|
||||
|
||||
/** Policy list with search, vigencia/type/provider filters, paginated. */
|
||||
async list(params: ListParams) {
|
||||
const { query, page, pageSize, status, days, typeId, providerId, liquidated,
|
||||
includeArchived, sort } = params;
|
||||
|
||||
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
|
||||
|
||||
if (!includeArchived) where.archivedAt = null;
|
||||
|
||||
if (query && query.trim()) {
|
||||
const q = query.trim();
|
||||
where.OR = [
|
||||
{ policyNumber: { contains: q } },
|
||||
{ customer: { name: { contains: q } } },
|
||||
{ agentName: { contains: q } },
|
||||
{ vehicles: { some: { licensePlate: { contains: q } } } },
|
||||
{ insuredDrivers: { some: { fullName: { contains: q } } } },
|
||||
{ legacyId: { contains: q } },
|
||||
];
|
||||
}
|
||||
if (typeId) where.policyTypeId = typeId;
|
||||
if (providerId) where.insuranceProviderId = providerId;
|
||||
if (liquidated !== undefined) where.liquidated = liquidated;
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.policy.count({ where }),
|
||||
this.prisma.policy.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: this.orderBy(sort),
|
||||
select: {
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
agentName: true,
|
||||
policyFrom: true,
|
||||
policyTo: true,
|
||||
netPremium: true,
|
||||
total: true,
|
||||
currency: true,
|
||||
liquidated: true,
|
||||
archivedAt: true,
|
||||
customer: { select: { id: true, name: true, city: true } },
|
||||
policyType: { select: { id: true, name: true } },
|
||||
insuranceProvider: { select: { id: true, name: true } },
|
||||
_count: { select: { vehicles: true, installments: true, documents: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const from = today();
|
||||
const soon = addDays(from, days);
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
policyNumber: r.policyNumber,
|
||||
agentName: r.agentName,
|
||||
policyFrom: r.policyFrom,
|
||||
policyTo: r.policyTo,
|
||||
netPremium: r.netPremium,
|
||||
total: r.total,
|
||||
currency: r.currency,
|
||||
liquidated: r.liquidated,
|
||||
archived: r.archivedAt != null,
|
||||
customerId: r.customer.id,
|
||||
customerName: r.customer.name,
|
||||
customerCity: r.customer.city,
|
||||
policyType: r.policyType,
|
||||
insuranceProvider: r.insuranceProvider,
|
||||
status: statusOf(r.policyTo, from, soon),
|
||||
daysToExpiry: daysUntil(r.policyTo, from),
|
||||
vehicleCount: r._count.vehicles,
|
||||
installmentCount: r._count.installments,
|
||||
documentCount: r._count.documents,
|
||||
}));
|
||||
|
||||
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
/** Top-line counts for the policies page header. */
|
||||
async stats(days: number) {
|
||||
const from = today();
|
||||
const soon = addDays(from, days);
|
||||
|
||||
const [total, active, expiring, expired, undated, liquidated] =
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.policy.count(),
|
||||
this.prisma.policy.count({ where: { policyTo: { gte: from } } }),
|
||||
this.prisma.policy.count({
|
||||
where: { policyTo: { gte: from, lte: soon } },
|
||||
}),
|
||||
this.prisma.policy.count({ where: { policyTo: { lt: from } } }),
|
||||
this.prisma.policy.count({ where: { policyTo: null } }),
|
||||
this.prisma.policy.count({ where: { liquidated: true } }),
|
||||
]);
|
||||
|
||||
// Premium in force, per currency — the two currencies can't be summed.
|
||||
const inForce = await this.prisma.policy.groupBy({
|
||||
by: ["currency"],
|
||||
where: { policyTo: { gte: from } },
|
||||
_sum: { total: true, netPremium: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
return {
|
||||
total,
|
||||
active,
|
||||
expiring,
|
||||
expired,
|
||||
undated,
|
||||
liquidated,
|
||||
pending: total - liquidated,
|
||||
days,
|
||||
premiumInForce: inForce.map((r) => ({
|
||||
currency: r.currency,
|
||||
total: r._sum.total,
|
||||
netPremium: r._sum.netPremium,
|
||||
count: r._count._all,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter dropdown options, with counts so empty choices are visible. */
|
||||
async facets() {
|
||||
const [types, providers] = await this.prisma.$transaction([
|
||||
this.prisma.policyType.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||
}),
|
||||
this.prisma.insuranceProvider.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
|
||||
providers: providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
count: p._count.policies,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Full policy view, including the owning customer. */
|
||||
async detail(id: string, days: number) {
|
||||
const policy = await this.prisma.policy.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameSource: true,
|
||||
city: true,
|
||||
state: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
policyType: true,
|
||||
insuranceProvider: true,
|
||||
installments: { orderBy: { sequence: "asc" } },
|
||||
vehicles: true,
|
||||
insuredDrivers: true,
|
||||
beneficiaries: true,
|
||||
claims: { include: { adjuster: true } },
|
||||
documents: true,
|
||||
properties: {
|
||||
select: { id: true, addressLine1: true, addressLine2: true, zone: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!policy) {
|
||||
throw new NotFoundException(`Policy ${id} not found`);
|
||||
}
|
||||
|
||||
const from = today();
|
||||
return {
|
||||
...policy,
|
||||
status: statusOf(policy.policyTo, from, addDays(from, days)),
|
||||
daysToExpiry: daysUntil(policy.policyTo, from),
|
||||
};
|
||||
}
|
||||
|
||||
// --- policy header writes -------------------------------------------------
|
||||
|
||||
private headerData(dto: CreatePolicyDto | UpdatePolicyDto) {
|
||||
const { policyDate, policyFrom, policyTo, liquidationDate, ...rest } =
|
||||
dto as CreatePolicyDto;
|
||||
return {
|
||||
...rest,
|
||||
...(policyDate !== undefined && { policyDate: toDate(policyDate) }),
|
||||
...(policyFrom !== undefined && { policyFrom: toDate(policyFrom) }),
|
||||
...(policyTo !== undefined && { policyTo: toDate(policyTo) }),
|
||||
...(liquidationDate !== undefined && { liquidationDate: toDate(liquidationDate) }),
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: CreatePolicyDto) {
|
||||
// Validate the customer FK up front for a clean 404 instead of a raw
|
||||
// Prisma constraint error.
|
||||
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.policy.create({
|
||||
data: {
|
||||
...this.headerData(dto),
|
||||
policyNumber: dto.policyNumber,
|
||||
customerId: dto.customerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePolicyDto) {
|
||||
await this.ensurePolicy(id);
|
||||
return this.prisma.policy.update({ where: { id }, data: this.headerData(dto) });
|
||||
}
|
||||
|
||||
async archive(id: string) {
|
||||
await this.ensurePolicy(id);
|
||||
return this.prisma.policy.update({ where: { id }, data: { archivedAt: new Date() } });
|
||||
}
|
||||
|
||||
async restore(id: string) {
|
||||
await this.ensurePolicy(id);
|
||||
return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } });
|
||||
}
|
||||
|
||||
private async ensurePolicy(id: string) {
|
||||
const found = await this.prisma.policy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException(`Policy ${id} not found`);
|
||||
}
|
||||
|
||||
// --- child rows -----------------------------------------------------------
|
||||
// Each child is created under a policy and edited/removed by its own id,
|
||||
// scoped to that policy so one policy's id can't touch another's rows.
|
||||
|
||||
private async ensureChild(
|
||||
model: "policyPaymentInstallment" | "vehicle" | "insuredDriver" | "policyBeneficiary" | "claim",
|
||||
policyId: string,
|
||||
childId: string,
|
||||
) {
|
||||
await this.ensurePolicy(policyId);
|
||||
// @ts-expect-error dynamic delegate access is safe for these known models
|
||||
const row = await this.prisma[model].findFirst({
|
||||
where: { id: childId, policyId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException(`Child ${childId} not found on policy ${policyId}`);
|
||||
}
|
||||
|
||||
async addInstallment(policyId: string, dto: InstallmentDto) {
|
||||
await this.ensurePolicy(policyId);
|
||||
return this.prisma.policyPaymentInstallment.create({
|
||||
data: {
|
||||
policyId,
|
||||
sequence: dto.sequence,
|
||||
amount: dto.amount,
|
||||
currency: dto.currency,
|
||||
dueDate: toDate(dto.dueDate) ?? undefined,
|
||||
paidDate: toDate(dto.paidDate) ?? undefined,
|
||||
checkNumber: dto.checkNumber,
|
||||
isCash: dto.isCash,
|
||||
},
|
||||
});
|
||||
}
|
||||
async updateInstallment(policyId: string, id: string, dto: UpdateInstallmentDto) {
|
||||
await this.ensureChild("policyPaymentInstallment", policyId, id);
|
||||
return this.prisma.policyPaymentInstallment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
sequence: dto.sequence,
|
||||
amount: dto.amount,
|
||||
currency: dto.currency,
|
||||
...(dto.dueDate !== undefined && { dueDate: toDate(dto.dueDate) }),
|
||||
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
|
||||
checkNumber: dto.checkNumber,
|
||||
isCash: dto.isCash,
|
||||
},
|
||||
});
|
||||
}
|
||||
async removeInstallment(policyId: string, id: string) {
|
||||
await this.ensureChild("policyPaymentInstallment", policyId, id);
|
||||
return this.prisma.policyPaymentInstallment.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async addVehicle(policyId: string, dto: VehicleDto) {
|
||||
await this.ensurePolicy(policyId);
|
||||
return this.prisma.vehicle.create({ data: { policyId, ...dto } });
|
||||
}
|
||||
async updateVehicle(policyId: string, id: string, dto: VehicleDto) {
|
||||
await this.ensureChild("vehicle", policyId, id);
|
||||
return this.prisma.vehicle.update({ where: { id }, data: { ...dto } });
|
||||
}
|
||||
async removeVehicle(policyId: string, id: string) {
|
||||
await this.ensureChild("vehicle", policyId, id);
|
||||
return this.prisma.vehicle.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async addDriver(policyId: string, dto: DriverDto) {
|
||||
await this.ensurePolicy(policyId);
|
||||
return this.prisma.insuredDriver.create({
|
||||
data: { policyId, ...dto, birthDate: toDate(dto.birthDate) ?? undefined },
|
||||
});
|
||||
}
|
||||
async updateDriver(policyId: string, id: string, dto: UpdateDriverDto) {
|
||||
await this.ensureChild("insuredDriver", policyId, id);
|
||||
return this.prisma.insuredDriver.update({
|
||||
where: { id },
|
||||
data: { ...dto, ...(dto.birthDate !== undefined && { birthDate: toDate(dto.birthDate) }) },
|
||||
});
|
||||
}
|
||||
async removeDriver(policyId: string, id: string) {
|
||||
await this.ensureChild("insuredDriver", policyId, id);
|
||||
return this.prisma.insuredDriver.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async addBeneficiary(policyId: string, dto: BeneficiaryDto) {
|
||||
await this.ensurePolicy(policyId);
|
||||
return this.prisma.policyBeneficiary.create({ data: { policyId, ...dto } });
|
||||
}
|
||||
async updateBeneficiary(policyId: string, id: string, dto: UpdateBeneficiaryDto) {
|
||||
await this.ensureChild("policyBeneficiary", policyId, id);
|
||||
return this.prisma.policyBeneficiary.update({ where: { id }, data: { ...dto } });
|
||||
}
|
||||
async removeBeneficiary(policyId: string, id: string) {
|
||||
await this.ensureChild("policyBeneficiary", policyId, id);
|
||||
return this.prisma.policyBeneficiary.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async addClaim(policyId: string, dto: ClaimDto) {
|
||||
await this.ensurePolicy(policyId);
|
||||
return this.prisma.claim.create({ data: { policyId, ...this.claimData(dto) } });
|
||||
}
|
||||
async updateClaim(policyId: string, id: string, dto: UpdateClaimDto) {
|
||||
await this.ensureChild("claim", policyId, id);
|
||||
return this.prisma.claim.update({ where: { id }, data: this.claimData(dto) });
|
||||
}
|
||||
async removeClaim(policyId: string, id: string) {
|
||||
await this.ensureChild("claim", policyId, id);
|
||||
return this.prisma.claim.delete({ where: { id } });
|
||||
}
|
||||
private claimData(dto: ClaimDto) {
|
||||
const { incidentDate, reportedDate, settlementDate, ...rest } = dto;
|
||||
return {
|
||||
...rest,
|
||||
...(incidentDate !== undefined && { incidentDate: toDate(incidentDate) }),
|
||||
...(reportedDate !== undefined && { reportedDate: toDate(reportedDate) }),
|
||||
...(settlementDate !== undefined && { settlementDate: toDate(settlementDate) }),
|
||||
};
|
||||
}
|
||||
|
||||
// --- lookups (providers / policy types / adjusters) -----------------------
|
||||
|
||||
listLookups() {
|
||||
return this.prisma.$transaction([
|
||||
this.prisma.insuranceProvider.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||
}),
|
||||
this.prisma.policyType.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortDescription: true,
|
||||
_count: { select: { policies: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.adjuster.findMany({ orderBy: { name: "asc" } }),
|
||||
]).then(([providers, types, adjusters]) => ({ providers, types, adjusters }));
|
||||
}
|
||||
|
||||
createProvider(dto: ProviderDto) {
|
||||
return this.prisma.insuranceProvider.create({ data: dto });
|
||||
}
|
||||
updateProvider(id: string, dto: UpdateProviderDto) {
|
||||
return this.prisma.insuranceProvider.update({ where: { id }, data: dto });
|
||||
}
|
||||
removeProvider(id: string) {
|
||||
return this.prisma.insuranceProvider.delete({ where: { id } });
|
||||
}
|
||||
|
||||
createPolicyType(dto: PolicyTypeDto) {
|
||||
return this.prisma.policyType.create({ data: dto });
|
||||
}
|
||||
updatePolicyType(id: string, dto: UpdatePolicyTypeDto) {
|
||||
return this.prisma.policyType.update({ where: { id }, data: dto });
|
||||
}
|
||||
removePolicyType(id: string) {
|
||||
return this.prisma.policyType.delete({ where: { id } });
|
||||
}
|
||||
|
||||
createAdjuster(dto: AdjusterDto) {
|
||||
return this.prisma.adjuster.create({ data: dto });
|
||||
}
|
||||
updateAdjuster(id: string, dto: UpdateAdjusterDto) {
|
||||
return this.prisma.adjuster.update({ where: { id }, data: dto });
|
||||
}
|
||||
removeAdjuster(id: string) {
|
||||
return this.prisma.adjuster.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
|
||||
* exposed for editing. Dates arrive as ISO strings and are coerced by the
|
||||
* service. `total` is legacy-dead data — the UI uses netPremium. */
|
||||
export class CreatePolicyDto {
|
||||
@IsString() @MinLength(1) policyNumber!: string;
|
||||
@IsString() @MinLength(1) customerId!: string;
|
||||
|
||||
@IsOptional() @IsString() policyTypeId?: string;
|
||||
@IsOptional() @IsString() insuranceProviderId?: string;
|
||||
@IsOptional() @IsString() agentName?: string;
|
||||
@IsOptional() @IsString() policyDate?: string;
|
||||
@IsOptional() @IsString() policyFrom?: string;
|
||||
@IsOptional() @IsString() policyTo?: string;
|
||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() observations?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@IsOptional() @IsBoolean() endorsement?: boolean;
|
||||
@IsOptional() @IsBoolean() liquidated?: boolean;
|
||||
@IsOptional() @IsString() liquidationNumber?: string;
|
||||
@IsOptional() @IsString() liquidationDate?: string;
|
||||
}
|
||||
|
||||
/** All header fields optional (customerId is not re-assignable on update). */
|
||||
export class UpdatePolicyDto {
|
||||
@IsOptional() @IsString() @MinLength(1) policyNumber?: string;
|
||||
@IsOptional() @IsString() policyTypeId?: string;
|
||||
@IsOptional() @IsString() insuranceProviderId?: string;
|
||||
@IsOptional() @IsString() agentName?: string;
|
||||
@IsOptional() @IsString() policyDate?: string;
|
||||
@IsOptional() @IsString() policyFrom?: string;
|
||||
@IsOptional() @IsString() policyTo?: string;
|
||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() observations?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@IsOptional() @IsBoolean() endorsement?: boolean;
|
||||
@IsOptional() @IsBoolean() liquidated?: boolean;
|
||||
@IsOptional() @IsString() liquidationNumber?: string;
|
||||
@IsOptional() @IsString() liquidationDate?: string;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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",
|
||||
"ELECTRIC",
|
||||
"GAS",
|
||||
"CABLE",
|
||||
"PROPERTY_TAX",
|
||||
"FEDERAL_ZONE",
|
||||
"ALARM",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
const TRUST_FILTERS: TrustFilter[] = [
|
||||
"with",
|
||||
"without",
|
||||
"active",
|
||||
"expiring",
|
||||
"expired",
|
||||
"undated",
|
||||
];
|
||||
|
||||
const SORTS: PropertySort[] = [
|
||||
"customer",
|
||||
"address",
|
||||
"services_desc",
|
||||
"trust_due_asc",
|
||||
"trust_due_desc",
|
||||
];
|
||||
|
||||
function parseDays(days?: string): number {
|
||||
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("properties")
|
||||
export class PropertiesController {
|
||||
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) {
|
||||
return this.properties.stats(parseDays(days));
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.properties.facets();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("serviceKind") serviceKind?: string,
|
||||
@Query("municipality") municipality?: string,
|
||||
@Query("bank") bank?: string,
|
||||
@Query("trust") trust?: string,
|
||||
@Query("hasServices") hasServices?: string,
|
||||
@Query("customerId") customerId?: string,
|
||||
@Query("days") days?: string,
|
||||
@Query("includeArchived") includeArchived?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.properties.list({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
serviceKind: KINDS.includes(serviceKind as ServiceKind)
|
||||
? (serviceKind as ServiceKind)
|
||||
: undefined,
|
||||
municipality: municipality || undefined,
|
||||
bank: bank || undefined,
|
||||
trust: TRUST_FILTERS.includes(trust as TrustFilter)
|
||||
? (trust as TrustFilter)
|
||||
: undefined,
|
||||
hasServices:
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PropertiesController } from "./properties.controller";
|
||||
import { PropertiesService } from "./properties.service";
|
||||
|
||||
@Module({
|
||||
controllers: [PropertiesController],
|
||||
providers: [PropertiesService],
|
||||
})
|
||||
export class PropertiesModule {}
|
||||
@@ -0,0 +1,558 @@
|
||||
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`
|
||||
* against today. The migration loaded DATMEX's `vence1`/`vence2` pair as
|
||||
* `dueDate1`/`dueDate2`; on 531 of the 541 dated trusts `dueDate2` is exactly
|
||||
* one year after `dueDate1`, so `dueDate2` is the *next* annual due date — the
|
||||
* one staff chase — and `dueDate1` is the period it renewed from.
|
||||
*
|
||||
* `undated` is a real bucket, not an error: 12 trusts carry no dates at all.
|
||||
*/
|
||||
export type TrustStatus = "active" | "expiring" | "expired" | "undated";
|
||||
|
||||
/** `with`/`without` filter on the whole property set; the rest are trust buckets. */
|
||||
export type TrustFilter = "with" | "without" | TrustStatus;
|
||||
|
||||
export type PropertySort =
|
||||
| "customer"
|
||||
| "address"
|
||||
| "services_desc"
|
||||
| "trust_due_asc"
|
||||
| "trust_due_desc";
|
||||
|
||||
export interface ListParams {
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
serviceKind?: ServiceKind;
|
||||
/** Municipality from the predial service's notes — see `facets()`. */
|
||||
municipality?: string;
|
||||
bank?: string;
|
||||
trust?: TrustFilter;
|
||||
/** false = properties with no service rows at all (240 of 1519). */
|
||||
hasServices?: boolean;
|
||||
customerId?: string;
|
||||
/** Window in days for the `expiring` trust bucket. */
|
||||
days: number;
|
||||
includeArchived?: boolean;
|
||||
sort: PropertySort;
|
||||
}
|
||||
|
||||
/** Municipality lives in the predial service's `notes` (939/939 populated,
|
||||
* exactly three values). FEDERAL_ZONE's notes hold the same idea but also
|
||||
* carry non-municipality values like "SUSPENDIDO", so predial is the source. */
|
||||
const MUNICIPALITY_KIND: ServiceKind = "PROPERTY_TAX";
|
||||
|
||||
/** Midnight today, UTC — trust dates are stored date-only at 00:00 UTC. */
|
||||
function today(): Date {
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||
);
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
return new Date(d.getTime() + days * 86400000);
|
||||
}
|
||||
|
||||
function trustStatusOf(
|
||||
dueDate: Date | null | undefined,
|
||||
from: Date,
|
||||
soon: Date,
|
||||
): TrustStatus {
|
||||
if (!dueDate) return "undated";
|
||||
if (dueDate < from) return "expired";
|
||||
return dueDate <= soon ? "expiring" : "active";
|
||||
}
|
||||
|
||||
function daysUntil(dueDate: Date | null | undefined, from: Date): number | null {
|
||||
if (!dueDate) return null;
|
||||
return Math.round((dueDate.getTime() - from.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PropertiesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private trustWhere(
|
||||
trust: TrustFilter | undefined,
|
||||
days: number,
|
||||
): Prisma.PropertyWhereInput {
|
||||
const from = today();
|
||||
switch (trust) {
|
||||
case "with":
|
||||
return { trustAccount: { isNot: null } };
|
||||
case "without":
|
||||
return { trustAccount: { is: null } };
|
||||
case "active":
|
||||
return { trustAccount: { dueDate2: { gte: from } } };
|
||||
case "expiring":
|
||||
return {
|
||||
trustAccount: { dueDate2: { gte: from, lte: addDays(from, days) } },
|
||||
};
|
||||
case "expired":
|
||||
return { trustAccount: { dueDate2: { lt: from } } };
|
||||
case "undated":
|
||||
return { trustAccount: { is: { dueDate2: null } } };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private orderBy(sort: PropertySort): Prisma.PropertyOrderByWithRelationInput[] {
|
||||
switch (sort) {
|
||||
case "address":
|
||||
return [{ addressLine1: "asc" }, { addressLine2: "asc" }];
|
||||
case "services_desc":
|
||||
return [{ services: { _count: "desc" } }, { customer: { name: "asc" } }];
|
||||
case "trust_due_asc":
|
||||
return [{ trustAccount: { dueDate2: "asc" } }];
|
||||
case "trust_due_desc":
|
||||
return [{ trustAccount: { dueDate2: "desc" } }];
|
||||
default:
|
||||
// Nameless customers last, same rule the customer list uses.
|
||||
return [
|
||||
{ customer: { nameMissing: "asc" } },
|
||||
{ customer: { name: "asc" } },
|
||||
{ addressLine1: "asc" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Property list with search, service/trust/municipality filters, paginated. */
|
||||
async list(params: ListParams) {
|
||||
const {
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
serviceKind,
|
||||
municipality,
|
||||
bank,
|
||||
trust,
|
||||
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.
|
||||
if (sort === "trust_due_asc" || sort === "trust_due_desc") {
|
||||
and.push({ trustAccount: { isNot: null } });
|
||||
}
|
||||
|
||||
if (query && query.trim()) {
|
||||
const q = query.trim();
|
||||
and.push({
|
||||
OR: [
|
||||
{ addressLine1: { contains: q } },
|
||||
{ addressLine2: { contains: q } },
|
||||
{ phone1: { contains: q } },
|
||||
{ phone2: { contains: q } },
|
||||
{ phone3: { contains: q } },
|
||||
{ zone: { contains: q } },
|
||||
{ legacyId: { contains: q } },
|
||||
{ customer: { name: { contains: q } } },
|
||||
{ services: { some: { accountNumber: { contains: q } } } },
|
||||
{ services: { some: { meterNumber: { contains: q } } } },
|
||||
{ trustAccount: { trustNumber: { contains: q } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (serviceKind) and.push({ services: { some: { kind: serviceKind } } });
|
||||
if (municipality)
|
||||
and.push({
|
||||
services: { some: { kind: MUNICIPALITY_KIND, notes: municipality } },
|
||||
});
|
||||
if (bank) and.push({ trustAccount: { bankName: bank } });
|
||||
if (hasServices !== undefined)
|
||||
and.push(hasServices ? { services: { some: {} } } : { services: { none: {} } });
|
||||
if (customerId) and.push({ customerId });
|
||||
|
||||
const where: Prisma.PropertyWhereInput = { AND: and };
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.property.count({ where }),
|
||||
this.prisma.property.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: this.orderBy(sort),
|
||||
select: {
|
||||
id: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
phone1: true,
|
||||
phone2: true,
|
||||
phone3: true,
|
||||
zone: true,
|
||||
archivedAt: true,
|
||||
customer: {
|
||||
select: { id: true, name: true, city: true, state: true },
|
||||
},
|
||||
services: {
|
||||
select: { id: true, kind: true, active: true, notes: true },
|
||||
},
|
||||
trustAccount: {
|
||||
select: {
|
||||
bankName: true,
|
||||
trustNumber: true,
|
||||
bankFee: true,
|
||||
dueDate1: true,
|
||||
dueDate2: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { services: true, documents: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const from = today();
|
||||
const soon = addDays(from, days);
|
||||
|
||||
const items = rows.map((r) => {
|
||||
const predial = r.services.find((s) => s.kind === MUNICIPALITY_KIND);
|
||||
return {
|
||||
id: r.id,
|
||||
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,
|
||||
customerCity: r.customer.city,
|
||||
customerState: r.customer.state,
|
||||
municipality: predial?.notes ?? null,
|
||||
services: r.services.map((s) => ({
|
||||
id: s.id,
|
||||
kind: s.kind,
|
||||
active: s.active,
|
||||
})),
|
||||
serviceCount: r._count.services,
|
||||
activeServiceCount: r.services.filter((s) => s.active).length,
|
||||
documentCount: r._count.documents,
|
||||
trust: r.trustAccount
|
||||
? {
|
||||
bankName: r.trustAccount.bankName,
|
||||
trustNumber: r.trustAccount.trustNumber,
|
||||
bankFee: r.trustAccount.bankFee,
|
||||
dueDate1: r.trustAccount.dueDate1,
|
||||
dueDate2: r.trustAccount.dueDate2,
|
||||
status: trustStatusOf(r.trustAccount.dueDate2, from, soon),
|
||||
daysToDue: daysUntil(r.trustAccount.dueDate2, from),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
/** Top-line counts for the utilities page header. */
|
||||
async stats(days: number) {
|
||||
const from = today();
|
||||
const soon = addDays(from, days);
|
||||
|
||||
const [
|
||||
properties,
|
||||
owners,
|
||||
services,
|
||||
withoutServices,
|
||||
trusts,
|
||||
trustExpiring,
|
||||
trustExpired,
|
||||
documents,
|
||||
] = await this.prisma.$transaction([
|
||||
this.prisma.property.count(),
|
||||
this.prisma.customer.count({ where: { properties: { some: {} } } }),
|
||||
this.prisma.propertyService.count(),
|
||||
this.prisma.property.count({ where: { services: { none: {} } } }),
|
||||
this.prisma.property.count({ where: { trustAccount: { isNot: null } } }),
|
||||
this.prisma.property.count({
|
||||
where: { trustAccount: { dueDate2: { gte: from, lte: soon } } },
|
||||
}),
|
||||
this.prisma.property.count({
|
||||
where: { trustAccount: { dueDate2: { lt: from } } },
|
||||
}),
|
||||
this.prisma.serviceDocument.count(),
|
||||
]);
|
||||
|
||||
// Service mix, per kind — the operational headline for this line of
|
||||
// business (how many bills of each type the office pays every month).
|
||||
const byKind = await this.prisma.propertyService.groupBy({
|
||||
by: ["kind"],
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { kind: "desc" } },
|
||||
});
|
||||
|
||||
const activeByKind = await this.prisma.propertyService.groupBy({
|
||||
by: ["kind"],
|
||||
where: { active: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const activeMap = new Map(activeByKind.map((r) => [r.kind, r._count._all]));
|
||||
|
||||
return {
|
||||
properties,
|
||||
owners,
|
||||
services,
|
||||
withoutServices,
|
||||
trusts,
|
||||
trustExpiring,
|
||||
trustExpired,
|
||||
documents,
|
||||
days,
|
||||
byKind: byKind.map((r) => ({
|
||||
kind: r.kind,
|
||||
count: r._count._all,
|
||||
active: activeMap.get(r.kind) ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter dropdown options, with counts so empty choices are visible. */
|
||||
async facets() {
|
||||
// Kept as separate awaits rather than one $transaction: Prisma's groupBy
|
||||
// result type is lost when the calls are widened into a promise array.
|
||||
const kinds = await this.prisma.propertyService.groupBy({
|
||||
by: ["kind"],
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { kind: "desc" } },
|
||||
});
|
||||
const municipalities = await this.prisma.propertyService.groupBy({
|
||||
by: ["notes"],
|
||||
where: { kind: MUNICIPALITY_KIND, notes: { not: null } },
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { notes: "desc" } },
|
||||
});
|
||||
const banks = await this.prisma.trustAccount.groupBy({
|
||||
by: ["bankName"],
|
||||
where: { bankName: { not: null } },
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { bankName: "desc" } },
|
||||
});
|
||||
|
||||
return {
|
||||
kinds: kinds.map((k) => ({ kind: k.kind, count: k._count._all })),
|
||||
municipalities: municipalities.map((m) => ({
|
||||
name: m.notes as string,
|
||||
count: m._count._all,
|
||||
})),
|
||||
banks: banks.map((b) => ({
|
||||
name: b.bankName as string,
|
||||
count: b._count._all,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Full property view: services, trust, documents, owner and siblings. */
|
||||
async detail(id: string, days: number) {
|
||||
const property = await this.prisma.property.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameSource: true,
|
||||
addressLine1: true,
|
||||
city: true,
|
||||
state: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
email: true,
|
||||
_count: { select: { properties: true, policies: true } },
|
||||
},
|
||||
},
|
||||
services: { orderBy: { kind: "asc" } },
|
||||
trustAccount: true,
|
||||
documents: true,
|
||||
policy: {
|
||||
select: {
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
policyTo: true,
|
||||
policyType: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!property) {
|
||||
throw new NotFoundException(`Property ${id} not found`);
|
||||
}
|
||||
|
||||
// Other properties of the same owner, so staff can hop between them
|
||||
// without going back through the customer file.
|
||||
const siblings = await this.prisma.property.findMany({
|
||||
where: { customerId: property.customerId, id: { not: id } },
|
||||
orderBy: [{ addressLine1: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
zone: true,
|
||||
_count: { select: { services: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Utility-domain ledger for the OWNER, not for this property: the legacy
|
||||
// data ties payments to the customer, never to a specific property, so
|
||||
// these are shown as the customer's service movements.
|
||||
const transactions = await this.prisma.transaction.findMany({
|
||||
where: { customerId: property.customerId, domain: "UTILITY" },
|
||||
orderBy: { transactionDate: "desc" },
|
||||
take: 12,
|
||||
include: { type: true },
|
||||
});
|
||||
const ledger = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { customerId: property.customerId, domain: "UTILITY", voidedAt: null },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const from = today();
|
||||
const predial = property.services.find((s) => s.kind === MUNICIPALITY_KIND);
|
||||
|
||||
return {
|
||||
...property,
|
||||
municipality: predial?.notes ?? null,
|
||||
trustStatus: trustStatusOf(
|
||||
property.trustAccount?.dueDate2,
|
||||
from,
|
||||
addDays(from, days),
|
||||
),
|
||||
daysToTrustDue: daysUntil(property.trustAccount?.dueDate2, from),
|
||||
siblings: siblings.map((s) => ({
|
||||
id: s.id,
|
||||
addressLine1: s.addressLine1,
|
||||
addressLine2: s.addressLine2,
|
||||
zone: s.zone,
|
||||
serviceCount: s._count.services,
|
||||
})),
|
||||
customerTransactions: transactions,
|
||||
customerLedger: ledger.map((l) => ({
|
||||
currency: l.currency,
|
||||
total: l._sum.amount,
|
||||
count: l._count._all,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { UserRole } from "@jorgecuadros/database";
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsEnum(UserRole)
|
||||
role!: UserRole;
|
||||
|
||||
@IsOptional()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { UserRole } from "@jorgecuadros/database";
|
||||
|
||||
/** Password changes go through the dedicated reset-password route, not here. */
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
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 { UsersService } from "./users.service";
|
||||
import { CreateUserDto } from "./create-user.dto";
|
||||
import { UpdateUserDto } from "./update-user.dto";
|
||||
import { ResetPasswordDto } from "./reset-password.dto";
|
||||
|
||||
/** Every route here is ADMIN-only (ability "user:manage"). */
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@RequireAbility("user:manage")
|
||||
@Controller("users")
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly users: UsersService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.users.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateUserDto, @Req() req: Request) {
|
||||
const user = await this.users.create(dto);
|
||||
void this.audit.log(this.actingId(req), "user.create", {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateUserDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const user = await this.users.update(id, dto, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "user.update", { userId: id, changes: dto });
|
||||
return user;
|
||||
}
|
||||
|
||||
@Post(":id/reset-password")
|
||||
async resetPassword(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ResetPasswordDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const user = await this.users.resetPassword(id, dto.password);
|
||||
void this.audit.log(this.actingId(req), "user.reset_password", { userId: id });
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { UsersController } from "./users.controller";
|
||||
|
||||
@Module({
|
||||
providers: [UsersService],
|
||||
controllers: [UsersController],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import * as argon2 from "argon2";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import type { User } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateUserDto } from "./create-user.dto";
|
||||
import { UpdateUserDto } from "./update-user.dto";
|
||||
|
||||
/** Shape returned to the UI — never carries passwordHash. */
|
||||
const safeSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
export type SafeUserRow = Prisma.UserGetPayload<{ select: typeof safeSelect }>;
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// --- used by auth (need the hash / full row) -----------------------------
|
||||
|
||||
findByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
@@ -13,4 +37,89 @@ export class UsersService {
|
||||
findById(id: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
// --- admin CRUD (safe rows only) -----------------------------------------
|
||||
|
||||
list(): Promise<SafeUserRow[]> {
|
||||
return this.prisma.user.findMany({
|
||||
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||
select: safeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateUserDto): Promise<SafeUserRow> {
|
||||
const passwordHash = await argon2.hash(dto.password);
|
||||
try {
|
||||
return await this.prisma.user.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
role: dto.role,
|
||||
active: dto.active ?? true,
|
||||
},
|
||||
select: safeSelect,
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `actingUserId` is the admin making the change — used to stop an admin from
|
||||
* locking themselves out (deactivating or demoting their own account).
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateUserDto,
|
||||
actingUserId: string,
|
||||
): Promise<SafeUserRow> {
|
||||
await this.ensureExists(id);
|
||||
|
||||
if (id === actingUserId) {
|
||||
if (dto.active === false) {
|
||||
throw new BadRequestException("No puede desactivar su propia cuenta");
|
||||
}
|
||||
if (dto.role && dto.role !== "ADMIN") {
|
||||
throw new BadRequestException("No puede quitarse su propio rol de administrador");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
role: dto.role,
|
||||
active: dto.active,
|
||||
},
|
||||
select: safeSelect,
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async resetPassword(id: string, password: string): Promise<SafeUserRow> {
|
||||
await this.ensureExists(id);
|
||||
const passwordHash = await argon2.hash(password);
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash },
|
||||
select: safeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureExists(id: string): Promise<void> {
|
||||
const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`);
|
||||
}
|
||||
|
||||
private mapError(e: unknown): Error {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
return new ConflictException("Ya existe un usuario con ese correo");
|
||||
}
|
||||
return e as Error;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { createLookup, getLookups, removeLookup, updateLookup } from "@/lib/api";
|
||||
import type { LookupsResponse } from "@/lib/types";
|
||||
|
||||
const PROVIDER: ChildConfig = {
|
||||
apiKind: "providers",
|
||||
title: "Aseguradoras",
|
||||
fields: [{ key: "name", label: "Nombre" }],
|
||||
};
|
||||
const TYPE: ChildConfig = {
|
||||
apiKind: "policy-types",
|
||||
title: "Tipos de póliza",
|
||||
fields: [
|
||||
{ key: "name", label: "Nombre" },
|
||||
{ key: "shortDescription", label: "Descripción" },
|
||||
],
|
||||
};
|
||||
const ADJUSTER: ChildConfig = {
|
||||
apiKind: "adjusters",
|
||||
title: "Ajustadores",
|
||||
fields: [
|
||||
{ key: "company", label: "Empresa" },
|
||||
{ key: "name", label: "Nombre" },
|
||||
{ key: "city", label: "Ciudad" },
|
||||
{ key: "phone", label: "Teléfono" },
|
||||
{ key: "beeper", label: "Beeper" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function CatalogosPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Catalogos />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Catalogos() {
|
||||
const canEdit = useCan("lookup:manage");
|
||||
const [data, setData] = useState<LookupsResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reload() {
|
||||
getLookups().then(setData).catch((e) => setError(e?.message ?? "Error al cargar."));
|
||||
}
|
||||
useEffect(reload, []);
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><h1 className="page-title">Catálogos</h1></div>
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para administrar catálogos.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const section = (config: ChildConfig, rows: Record<string, unknown>[]) => (
|
||||
<ChildCollection
|
||||
config={config}
|
||||
rows={rows}
|
||||
canEdit={canEdit}
|
||||
onAdd={async (p) => {
|
||||
await createLookup(config.apiKind, p);
|
||||
reload();
|
||||
}}
|
||||
onSave={async (id, p) => {
|
||||
await updateLookup(config.apiKind, id, p);
|
||||
reload();
|
||||
}}
|
||||
onRemove={async (id) => {
|
||||
await removeLookup(config.apiKind, id);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<p className="eyebrow">Datos de referencia de seguros</p>
|
||||
<h1 className="page-title">Catálogos</h1>
|
||||
</div>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
{!data ? (
|
||||
<div className="empty-inline"><span className="spinner" aria-label="Cargando" /></div>
|
||||
) : (
|
||||
<>
|
||||
{section(PROVIDER, data.providers as unknown as Record<string, unknown>[])}
|
||||
{section(TYPE, data.types as unknown as Record<string, unknown>[])}
|
||||
{section(ADJUSTER, data.adjusters as unknown as Record<string, unknown>[])}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CustomerForm } from "@/components/CustomerForm";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { getCustomer } from "@/lib/api";
|
||||
import type { CustomerDetail } from "@/lib/types";
|
||||
|
||||
export default function EditarClientePage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
return (
|
||||
<AppShell>
|
||||
<EditarCliente id={params.id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EditarCliente({ id }: { id: string }) {
|
||||
const allowed = useCan("customer:update");
|
||||
const [customer, setCustomer] = useState<CustomerDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed) return;
|
||||
getCustomer(id)
|
||||
.then(setCustomer)
|
||||
.catch((e) => setError(e?.message ?? "No se pudo cargar el cliente."));
|
||||
}, [id, allowed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<Link href={`/clientes/${id}`} className="back-link">
|
||||
← Cliente
|
||||
</Link>
|
||||
<h1 className="page-title">Editar cliente</h1>
|
||||
</div>
|
||||
{!allowed ? (
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para editar clientes.
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="state-box state-error">{error}</div>
|
||||
) : !customer ? (
|
||||
<div className="empty-inline">
|
||||
<span className="spinner" aria-label="Cargando" />
|
||||
</div>
|
||||
) : (
|
||||
<CustomerForm customer={customer} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { archiveCustomer, getCustomer, restoreCustomer } from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
domainLabel,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
premiumHeadline,
|
||||
serviceKindGlyph,
|
||||
serviceKindLabel,
|
||||
SIN_NOMBRE,
|
||||
sourceSystemLabel,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
CustomerDetail,
|
||||
Installment,
|
||||
Policy,
|
||||
Property,
|
||||
Transaction,
|
||||
TransactionSummaryRow,
|
||||
} from "@/lib/types";
|
||||
|
||||
export default function ClienteDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const { id } = params;
|
||||
return (
|
||||
<AppShell>
|
||||
<Detail id={id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ id }: { id: string }) {
|
||||
const [data, setData] = useState<CustomerDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getCustomer(id)
|
||||
.then((d) => {
|
||||
if (alive) {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
setError(
|
||||
e?.status === 404
|
||||
? "No encontramos este cliente."
|
||||
: e?.message ?? "No se pudo cargar el cliente.",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <DetailSkeleton />;
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<>
|
||||
<BackLink />
|
||||
<div className="state-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const hasUtilities = data.properties.length > 0;
|
||||
const hasInsurance = data.policies.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rise">
|
||||
<div className="detail-actionbar">
|
||||
<BackLink />
|
||||
<CustomerActions
|
||||
customer={data}
|
||||
onChange={() => getCustomer(id).then(setData).catch(() => {})}
|
||||
/>
|
||||
</div>
|
||||
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
|
||||
|
||||
<DatosSection data={data} />
|
||||
<PropiedadesSection
|
||||
properties={data.properties}
|
||||
customerId={data.id}
|
||||
customerName={data.name}
|
||||
/>
|
||||
<PolizasSection
|
||||
policies={data.policies}
|
||||
customerId={data.id}
|
||||
customerName={data.name}
|
||||
/>
|
||||
<EstadoCuentaSection
|
||||
customerId={data.id}
|
||||
summary={data.transactionSummary}
|
||||
transactions={data.transactions}
|
||||
/>
|
||||
<DocumentosSection data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link href="/clientes" className="back-link">
|
||||
← Volver a Clientes
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Edit / archive controls, each gated by the matching ability. */
|
||||
function CustomerActions({
|
||||
customer,
|
||||
onChange,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const canEdit = useCan("customer:update");
|
||||
const canDelete = useCan("customer:delete");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const archived = customer.archivedAt != null;
|
||||
|
||||
async function toggleArchive() {
|
||||
const verb = archived ? "restaurar" : "archivar";
|
||||
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (archived) await restoreCustomer(customer.id);
|
||||
else await archiveCustomer(customer.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">Archivado</span>}
|
||||
{canEdit && (
|
||||
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
||||
Editar
|
||||
</Link>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={toggleArchive}
|
||||
disabled={busy}
|
||||
>
|
||||
{archived ? "Restaurar" : "Archivar"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Hero */
|
||||
function Hero({
|
||||
data,
|
||||
hasUtilities,
|
||||
hasInsurance,
|
||||
}: {
|
||||
data: CustomerDetail;
|
||||
hasUtilities: boolean;
|
||||
hasInsurance: boolean;
|
||||
}) {
|
||||
const provenance = data.legacyRefs
|
||||
.map((r) => `${sourceSystemLabel(r.sourceSystem)} #${r.legacyId}`)
|
||||
.join(" · ");
|
||||
|
||||
const facts: { label: string; value: string }[] = [
|
||||
{ label: "Cliente desde", value: formatDate(data.customerSince) },
|
||||
{
|
||||
label: "Cuota",
|
||||
value:
|
||||
data.feeAmount != null && data.feeAmount !== ""
|
||||
? formatMoney(data.feeAmount, data.preferredCurrency)
|
||||
: "—",
|
||||
},
|
||||
{ label: "Propiedades", value: String(data.properties.length) },
|
||||
{ label: "Pólizas", value: String(data.policies.length) },
|
||||
{ label: "Moneda", value: data.preferredCurrency ?? "—" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="detail-hero">
|
||||
<div className="hero-top">
|
||||
<div>
|
||||
<h1
|
||||
className={`hero-name${
|
||||
data.name === SIN_NOMBRE ? " hero-name-missing" : ""
|
||||
}`}
|
||||
>
|
||||
{data.name}
|
||||
</h1>
|
||||
{data.nameSource && (
|
||||
<div className="hero-provenance">
|
||||
Nombre recuperado de {data.nameSource} — el registro original no
|
||||
tenía nombre.
|
||||
</div>
|
||||
)}
|
||||
{provenance && (
|
||||
<div className="hero-provenance">Origen: {provenance}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hero-badges">
|
||||
{hasUtilities && (
|
||||
<span className="badge badge-servicios">
|
||||
<span className="dot" /> Servicios
|
||||
</span>
|
||||
)}
|
||||
{hasInsurance && (
|
||||
<span className="badge badge-seguros">
|
||||
<span className="dot" /> Seguros
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`badge ${
|
||||
data.status ? "badge-on-dark" : "badge-negative"
|
||||
}`}
|
||||
>
|
||||
{data.status ? "Activo" : "Inactivo"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-facts">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label}>
|
||||
<div className="hero-fact-label">{f.label}</div>
|
||||
<div className="hero-fact-value">{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Datos del cliente */
|
||||
function DatosSection({ data }: { data: CustomerDetail }) {
|
||||
const mxAddress = [data.addressLine1, data.addressLine2]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const cityLine = [
|
||||
data.city?.replace(/,\s*$/, ""),
|
||||
data.state,
|
||||
data.zipCode,
|
||||
data.country,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
const idLine =
|
||||
data.identificationNumber || data.identificationType
|
||||
? [
|
||||
data.identificationType,
|
||||
data.identificationNumber,
|
||||
data.identificationExpiration
|
||||
? `vence ${formatDate(data.identificationExpiration)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="datos" title="Datos del cliente" />
|
||||
<div className="card">
|
||||
<div className="kv-grid">
|
||||
<KV label="Teléfono" value={data.phone} mono />
|
||||
<KV label="Móvil" value={data.mobile} mono />
|
||||
<KV label="Fax" value={data.fax} mono />
|
||||
<KV label="Correo electrónico" value={data.email} />
|
||||
<KV label="Documento de identidad" value={idLine} />
|
||||
<KV
|
||||
label="Estado"
|
||||
value={data.status ? "Activo" : "Inactivo"}
|
||||
/>
|
||||
{(mxAddress || cityLine) && (
|
||||
<div className="kv-block">
|
||||
<div className="kv-label">Domicilio</div>
|
||||
<div className="kv-value">
|
||||
{mxAddress && <div>{mxAddress}</div>}
|
||||
{cityLine && <div>{cityLine}</div>}
|
||||
{!mxAddress && !cityLine && "—"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.notes && (
|
||||
<div className="kv-block">
|
||||
<div className="kv-label">Notas</div>
|
||||
<div className="kv-value">{data.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function KV({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="kv-label">{label}</div>
|
||||
<div className={`kv-value${mono && value ? " mono" : ""}`}>
|
||||
{value || "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------- Propiedades y servicios */
|
||||
function PropiedadesSection({
|
||||
properties,
|
||||
customerId,
|
||||
customerName,
|
||||
}: {
|
||||
properties: Property[];
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
}) {
|
||||
const canCreate = useCan("property:create");
|
||||
return (
|
||||
<section className="section">
|
||||
<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">
|
||||
Este cliente no tiene propiedades registradas.
|
||||
</div>
|
||||
) : (
|
||||
properties.map((p) => <PropertyCard key={p.id} p={p} />)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyCard({ p }: { p: Property }) {
|
||||
const addr = [p.addressLine1, p.addressLine2].filter(Boolean).join(", ");
|
||||
const phones = [p.phone1, p.phone2, p.phone3].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="prop-card">
|
||||
<div className="prop-addr">
|
||||
<Link href={`/servicios/${p.id}`} className="policy-num-link">
|
||||
{addr || "Propiedad"}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="prop-meta">
|
||||
{p.zone && <span>Zona: {p.zone}</span>}
|
||||
{phones.length > 0 && (
|
||||
<span className="mono">{phones.join(" · ")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{p.services.length > 0 && (
|
||||
<div className="svc-grid">
|
||||
{p.services.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`svc-item${s.active ? "" : " inactive"}`}
|
||||
>
|
||||
<div className="svc-head">
|
||||
<span className="svc-kind">
|
||||
<span className="svc-glyph" aria-hidden>
|
||||
{serviceKindGlyph(s.kind)}
|
||||
</span>
|
||||
{serviceKindLabel(s.kind)}
|
||||
</span>
|
||||
{!s.active && (
|
||||
<span className="badge badge-neutral">Inactivo</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="svc-detail">
|
||||
{s.accountNumber && (
|
||||
<span>
|
||||
Cuenta: <span className="mono">{s.accountNumber}</span>
|
||||
</span>
|
||||
)}
|
||||
{s.meterNumber && (
|
||||
<span>
|
||||
Medidor: <span className="mono">{s.meterNumber}</span>
|
||||
</span>
|
||||
)}
|
||||
{s.route && (
|
||||
<span>
|
||||
Ruta: <span className="mono">{s.route}</span>
|
||||
</span>
|
||||
)}
|
||||
{s.dueDay && <span>Día de pago: {s.dueDay}</span>}
|
||||
{s.notes && <span>{s.notes}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.trustAccount && (
|
||||
<div className="trust-box">
|
||||
<div className="trust-title">Fideicomiso</div>
|
||||
<div className="trust-facts">
|
||||
{p.trustAccount.bankName && (
|
||||
<span>
|
||||
<strong>Banco:</strong> {p.trustAccount.bankName}
|
||||
</span>
|
||||
)}
|
||||
{p.trustAccount.trustNumber && (
|
||||
<span>
|
||||
<strong>No.:</strong>{" "}
|
||||
<span className="mono">{p.trustAccount.trustNumber}</span>
|
||||
</span>
|
||||
)}
|
||||
{p.trustAccount.bankFee && (
|
||||
<span>
|
||||
<strong>Comisión:</strong>{" "}
|
||||
{formatMoney(p.trustAccount.bankFee, "MXN")}
|
||||
</span>
|
||||
)}
|
||||
{p.trustAccount.dueDate1 && (
|
||||
<span>
|
||||
<strong>Vigencia:</strong>{" "}
|
||||
{formatDate(p.trustAccount.dueDate1)}
|
||||
{p.trustAccount.dueDate2
|
||||
? ` – ${formatDate(p.trustAccount.dueDate2)}`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Pólizas de seguro */
|
||||
function PolizasSection({
|
||||
policies,
|
||||
customerId,
|
||||
customerName,
|
||||
}: {
|
||||
policies: Policy[];
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
}) {
|
||||
const canCreate = useCan("policy:create");
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="detail-actionbar">
|
||||
<SectionHead
|
||||
rule="seguros"
|
||||
title="Pólizas de seguro"
|
||||
count={policies.length}
|
||||
/>
|
||||
{canCreate && (
|
||||
<Link
|
||||
href={`/polizas/nuevo?customerId=${customerId}&customerName=${encodeURIComponent(customerName)}`}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
+ Nueva póliza
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
{policies.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
Este cliente no tiene pólizas registradas.
|
||||
</div>
|
||||
) : (
|
||||
policies.map((p) => <PolicyCard key={p.id} p={p} />)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyCard({ p }: { p: Policy }) {
|
||||
const { value: headline, label: headlineLabel } = premiumHeadline(p);
|
||||
|
||||
return (
|
||||
<div className="policy-card">
|
||||
<div className="policy-head">
|
||||
<div>
|
||||
<Link href={`/polizas/${p.id}`} className="policy-num policy-num-link">
|
||||
{p.policyNumber || "—"} →
|
||||
</Link>
|
||||
<div className="policy-type-row">
|
||||
{p.policyType?.name && (
|
||||
<span className="badge badge-seguros">
|
||||
<span className="dot" /> {p.policyType.name}
|
||||
</span>
|
||||
)}
|
||||
{p.insuranceProvider?.name && (
|
||||
<span>{p.insuranceProvider.name}</span>
|
||||
)}
|
||||
{p.agentName && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>Agente: {p.agentName}</span>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={`badge ${
|
||||
p.liquidated ? "badge-positive" : "badge-neutral"
|
||||
}`}
|
||||
>
|
||||
{p.liquidated ? "Liquidada" : "Pendiente"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="policy-type-row">
|
||||
<span className="kv-label" style={{ margin: 0 }}>
|
||||
Vigencia:
|
||||
</span>
|
||||
<span className="mono">
|
||||
{formatDate(p.policyFrom)} – {formatDate(p.policyTo)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="policy-figures">
|
||||
<div className="policy-total">
|
||||
{formatMoney(headline, p.currency)}
|
||||
</div>
|
||||
<div className="policy-total-label">{headlineLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="policy-body">
|
||||
{p.installments.length > 0 && (
|
||||
<div className="subpanel">
|
||||
<div className="subpanel-title">
|
||||
<span>Pagos</span>
|
||||
<span>{p.installments.length}</span>
|
||||
</div>
|
||||
{p.installments.map((inst) => (
|
||||
<InstallmentRow key={inst.id} inst={inst} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.vehicles.length > 0 && (
|
||||
<div className="subpanel">
|
||||
<div className="subpanel-title">
|
||||
<span>Vehículos</span>
|
||||
<span>{p.vehicles.length}</span>
|
||||
</div>
|
||||
<div className="mini-list">
|
||||
{p.vehicles.map((v) => (
|
||||
<div key={v.id}>
|
||||
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
|
||||
"Vehículo"}
|
||||
<div className="mini-sub">
|
||||
{[
|
||||
v.bodyType,
|
||||
v.licensePlate ? `Placa ${v.licensePlate}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.insuredDrivers.length > 0 && (
|
||||
<div className="subpanel">
|
||||
<div className="subpanel-title">
|
||||
<span>Asegurados</span>
|
||||
<span>{p.insuredDrivers.length}</span>
|
||||
</div>
|
||||
<div className="mini-list">
|
||||
{p.insuredDrivers.map((d) => (
|
||||
<div key={d.id}>
|
||||
{d.fullName || "—"}
|
||||
{d.licenseNumber && (
|
||||
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.beneficiaries.length > 0 && (
|
||||
<div className="subpanel">
|
||||
<div className="subpanel-title">
|
||||
<span>Beneficiarios</span>
|
||||
<span>{p.beneficiaries.length}</span>
|
||||
</div>
|
||||
<div className="mini-list">
|
||||
{p.beneficiaries.map((b) => (
|
||||
<div key={b.id}>
|
||||
{b.name || "—"}
|
||||
{(b.phone || b.email) && (
|
||||
<div className="mini-sub">
|
||||
{[b.phone, b.email].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallmentRow({ inst }: { inst: Installment }) {
|
||||
const method = inst.isCash
|
||||
? "Efectivo"
|
||||
: inst.checkNumber
|
||||
? `Ref. ${inst.checkNumber}`
|
||||
: null;
|
||||
return (
|
||||
<div className="pay-row">
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<span className="pay-seq">{inst.sequence}</span>
|
||||
<span>
|
||||
{inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"}
|
||||
{method && (
|
||||
<span className="muted" style={{ fontSize: 11 }}>
|
||||
{" "}
|
||||
· {method}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mono" style={{ fontWeight: 600 }}>
|
||||
{formatMoney(inst.amount, inst.currency)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Estado de cuenta */
|
||||
function EstadoCuentaSection({
|
||||
customerId,
|
||||
summary,
|
||||
transactions,
|
||||
}: {
|
||||
customerId: string;
|
||||
summary: TransactionSummaryRow[];
|
||||
transactions: Transaction[];
|
||||
}) {
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="cuenta"
|
||||
title="Estado de cuenta"
|
||||
count={transactions.length}
|
||||
countSuffix="movimientos"
|
||||
/>
|
||||
|
||||
{summary.length > 0 && (
|
||||
<div className="summary-grid">
|
||||
{summary.map((row, i) => (
|
||||
<div className={`summary-card ${row.domain}`} key={i}>
|
||||
<div className="summary-domain">
|
||||
<span className={`tx-dot ${row.domain}`} />
|
||||
{domainLabel(row.domain)} · {row.currency}
|
||||
</div>
|
||||
<div className="summary-total">
|
||||
{formatMoney(row.total, row.currency)}
|
||||
</div>
|
||||
<div className="summary-count">
|
||||
{row.count}{" "}
|
||||
{row.count === 1 ? "movimiento" : "movimientos"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
{transactions.length === 0 ? (
|
||||
<div className="empty-inline">Sin movimientos registrados.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Línea</th>
|
||||
<th>Tipo</th>
|
||||
<th>Referencia</th>
|
||||
<th>Concepto</th>
|
||||
<th className="num">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((t) => (
|
||||
<TxRow key={t.id} t={t} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{transactions.length >= 100 && (
|
||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||
Mostrando los 100 movimientos más recientes.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{transactions.length > 0 && (
|
||||
<p className="section-note">
|
||||
<Link href={`/estado-cuenta/${customerId}`} className="inline-link">
|
||||
Ver estado de cuenta completo →
|
||||
</Link>{" "}
|
||||
con saldo, saldo corrido y desglose por línea de negocio.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TxRow({ t }: { t: Transaction }) {
|
||||
const num = t.amount != null ? Number(t.amount) : NaN;
|
||||
const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos";
|
||||
const tipo =
|
||||
t.type?.nameEs || t.type?.nameEn || "—";
|
||||
const concept = t.message || t.period || "—";
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(t.transactionDate)}
|
||||
</td>
|
||||
<td className="tx-domain-cell">
|
||||
<span className={`tx-dot ${t.domain}`} />
|
||||
{domainLabel(t.domain)}
|
||||
</td>
|
||||
<td>{tipo}</td>
|
||||
<td className="tx-ref">{t.reference || "—"}</td>
|
||||
<td className="tx-concept">{concept}</td>
|
||||
<td className="num">
|
||||
<span className={`tx-amount ${sign}`}>
|
||||
{formatMoney(t.amount, t.currency)}
|
||||
</span>{" "}
|
||||
<span className="tx-cur">{t.currency}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- Documentos */
|
||||
function DocumentosSection({ data }: { data: CustomerDetail }) {
|
||||
type Doc = { type: string; key: string | null; scope: string };
|
||||
const docs: Doc[] = [];
|
||||
data.properties.forEach((p) => {
|
||||
const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad";
|
||||
p.documents.forEach((d) =>
|
||||
docs.push({
|
||||
type: d.documentType || "Documento",
|
||||
key: d.storageKey,
|
||||
scope: label,
|
||||
}),
|
||||
);
|
||||
});
|
||||
data.policies.forEach((p) => {
|
||||
p.documents.forEach((d) =>
|
||||
docs.push({
|
||||
type: d.documentType || "Documento",
|
||||
key: d.storageKey,
|
||||
scope: `Póliza ${p.policyNumber ?? ""}`.trim(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="docs" title="Documentos" count={docs.length} />
|
||||
<div className="card">
|
||||
{docs.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
No hay documentos registrados para este cliente.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="doc-list">
|
||||
{docs.map((d, i) => (
|
||||
<div className="doc-item" key={i}>
|
||||
<span className="doc-icon" aria-hidden>
|
||||
▤
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="doc-type">{d.type}</div>
|
||||
<div className="doc-key">{d.scope}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="section-note"
|
||||
style={{ padding: "0 22px 18px" }}
|
||||
>
|
||||
Los archivos se almacenan en el object storage
|
||||
(storageKey); no se descargan desde esta vista.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- helpers */
|
||||
function SectionHead({
|
||||
rule,
|
||||
title,
|
||||
count,
|
||||
countSuffix,
|
||||
}: {
|
||||
rule: string;
|
||||
title: string;
|
||||
count?: number;
|
||||
countSuffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-head">
|
||||
<span className={`section-rule ${rule}`} aria-hidden />
|
||||
<h2 className="section-title">{title}</h2>
|
||||
{count != null && (
|
||||
<span className="section-count">
|
||||
{count} {countSuffix ?? ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 16, width: 140, marginBottom: 18 }}
|
||||
/>
|
||||
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 200, borderRadius: 16, marginTop: 34 }}
|
||||
/>
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 260, borderRadius: 16, marginTop: 34 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CustomerForm } from "@/components/CustomerForm";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
|
||||
export default function NuevoClientePage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<NuevoCliente />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function NuevoCliente() {
|
||||
const allowed = useCan("customer:create");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<Link href="/clientes" className="back-link">
|
||||
← Clientes
|
||||
</Link>
|
||||
<h1 className="page-title">Nuevo cliente</h1>
|
||||
</div>
|
||||
{allowed ? (
|
||||
<CustomerForm />
|
||||
) : (
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para crear clientes.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { getStats, listCustomers } from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatNumber, SIN_NOMBRE } from "@/lib/labels";
|
||||
import type {
|
||||
BusinessLine,
|
||||
CustomerListItem,
|
||||
CustomerListResponse,
|
||||
CustomerStats,
|
||||
} from "@/lib/types";
|
||||
|
||||
type Filter = "all" | BusinessLine;
|
||||
|
||||
const FILTERS: { key: Filter; label: string }[] = [
|
||||
{ key: "all", label: "Todos" },
|
||||
{ key: "utility", label: "Servicios" },
|
||||
{ key: "insurance", label: "Seguros" },
|
||||
{ key: "both", label: "Ambos" },
|
||||
];
|
||||
|
||||
export default function ClientesPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<ClientesBrowser />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientesBrowser() {
|
||||
const [stats, setStats] = useState<CustomerStats | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [data, setData] = useState<CustomerListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canCreate = useCan("customer:create");
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
getStats().then(setStats).catch(() => setStats(null));
|
||||
}, []);
|
||||
|
||||
const runSearch = useCallback(
|
||||
(q: string, f: Filter, p: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
listCustomers({
|
||||
query: q || undefined,
|
||||
line: f === "all" ? undefined : f,
|
||||
page: p,
|
||||
pageSize: 25,
|
||||
})
|
||||
.then((res) => {
|
||||
setData(res);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(
|
||||
e?.message ?? "No se pudieron cargar los clientes.",
|
||||
);
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Debounced search on query/filter change; resets to page 1.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setPage(1);
|
||||
runSearch(query, filter, 1);
|
||||
}, 280);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, filter]);
|
||||
|
||||
function goToPage(p: number) {
|
||||
setPage(p);
|
||||
runSearch(query, filter, p);
|
||||
if (typeof window !== "undefined")
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head rise">
|
||||
<p className="eyebrow">Directorio unificado</p>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<h1 className="page-title" style={{ margin: 0 }}>Clientes</h1>
|
||||
<span style={{ flex: 1 }} />
|
||||
{canCreate && (
|
||||
<Link href="/clientes/nuevo" className="btn btn-primary">
|
||||
+ Nuevo cliente
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<StatStrip stats={stats} />
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="search-box">
|
||||
<span className="search-icon" aria-hidden>
|
||||
⌕
|
||||
</span>
|
||||
<input
|
||||
className="input search-input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar por nombre, ciudad, teléfono…"
|
||||
aria-label="Buscar clientes"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="seg"
|
||||
role="tablist"
|
||||
aria-label="Filtrar por línea de negocio"
|
||||
>
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={filter === f.key}
|
||||
className={`seg-btn ${filter === f.key ? "active" : ""}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && !loading && !error && (
|
||||
<div className="result-meta" aria-live="polite">
|
||||
{data.total === 0
|
||||
? "Sin resultados"
|
||||
: `${formatNumber(data.total)} ${
|
||||
data.total === 1 ? "cliente" : "clientes"
|
||||
}`}
|
||||
{query ? ` para “${query}”` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="state-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<ListSkeleton />
|
||||
) : data && data.items.length === 0 ? (
|
||||
<EmptyState query={query} />
|
||||
) : (
|
||||
<>
|
||||
<div className="cust-list">
|
||||
{data?.items.map((c) => (
|
||||
<CustomerRow key={c.id} c={c} />
|
||||
))}
|
||||
</div>
|
||||
{data && data.pageCount > 1 && (
|
||||
<Pager
|
||||
page={data.page}
|
||||
pageCount={data.pageCount}
|
||||
onChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatStrip({ stats }: { stats: CustomerStats | null }) {
|
||||
const cells: {
|
||||
value: string;
|
||||
label: string;
|
||||
accent?: boolean;
|
||||
}[] = stats
|
||||
? [
|
||||
{ value: formatNumber(stats.customers), label: "Clientes", accent: true },
|
||||
{ value: formatNumber(stats.withUtilities), label: "Con servicios" },
|
||||
{ value: formatNumber(stats.withInsurance), label: "Con seguros" },
|
||||
{ value: formatNumber(stats.bothLines), label: "Ambas líneas", accent: true },
|
||||
{ value: formatNumber(stats.policies), label: "Pólizas" },
|
||||
{ value: formatNumber(stats.properties), label: "Propiedades" },
|
||||
]
|
||||
: [];
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="stat-strip" aria-hidden>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div className="stat-cell" key={i}>
|
||||
<div className="skeleton" style={{ height: 25, width: "60%" }} />
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 11, width: "80%", marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stat-strip">
|
||||
{cells.map((c) => (
|
||||
<div
|
||||
className={`stat-cell${c.accent ? " accent" : ""}`}
|
||||
key={c.label}
|
||||
>
|
||||
<div className="stat-value">{c.value}</div>
|
||||
<div className="stat-label">{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerRow({ c }: { c: CustomerListItem }) {
|
||||
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const contact = c.phone || c.mobile || c.email;
|
||||
|
||||
return (
|
||||
<Link href={`/clientes/${c.id}`} className="cust-row">
|
||||
<div className="cust-main">
|
||||
<div className="cust-name">
|
||||
{!c.status && (
|
||||
<span className="inactive-dot" title="Inactivo" aria-hidden />
|
||||
)}
|
||||
<span className={c.name === SIN_NOMBRE ? "cust-name-missing" : undefined}>
|
||||
{c.name}
|
||||
</span>
|
||||
{c.nameSource && (
|
||||
<span
|
||||
className="name-source"
|
||||
title={`El registro original no tenía nombre. Recuperado de ${c.nameSource}.`}
|
||||
>
|
||||
nombre recuperado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cust-sub">
|
||||
{location && <span>{location}</span>}
|
||||
{location && contact && <span className="sep">·</span>}
|
||||
{contact && <span>{contact}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cust-side">
|
||||
{c.hasUtilities && (
|
||||
<span className="badge badge-servicios">
|
||||
<span className="dot" /> Servicios
|
||||
{c.propertyCount > 0 && (
|
||||
<span className="badge-count">· {c.propertyCount}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{c.hasInsurance && (
|
||||
<span className="badge badge-seguros">
|
||||
<span className="dot" /> Seguros
|
||||
{c.policyCount > 0 && (
|
||||
<span className="badge-count">· {c.policyCount}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({
|
||||
page,
|
||||
pageCount,
|
||||
onChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="pager" aria-label="Paginación">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
Página <strong>{page}</strong> de {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={page >= pageCount}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className="cust-list" aria-hidden>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div className="skeleton skel-row" key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ query }: { query: string }) {
|
||||
return (
|
||||
<div className="state-box">
|
||||
<div className="state-glyph" aria-hidden>
|
||||
⌕
|
||||
</div>
|
||||
<h3>Sin resultados</h3>
|
||||
<p>
|
||||
{query
|
||||
? `No encontramos clientes para “${query}”.`
|
||||
: "No hay clientes que coincidan con el filtro."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { MovementForm } from "@/components/MovementForm";
|
||||
import {
|
||||
getBillingFacets,
|
||||
getStatement,
|
||||
voidMovement,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
balancePhrase,
|
||||
balanceTone,
|
||||
directionLabel,
|
||||
domainLabel,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
ledgerSourceLabel,
|
||||
SIN_NOMBRE,
|
||||
txTypeLabel,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
BillingFacets,
|
||||
LedgerCurrency,
|
||||
Statement,
|
||||
StatementMovement,
|
||||
TransactionDomain,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* One customer's statement across both business lines — the payoff of plan
|
||||
* step 6 and, ultimately, of the whole unified-customer project: a utility
|
||||
* charge and an insurance payment finally sit on the same page, under the same
|
||||
* person, with a running balance.
|
||||
*
|
||||
* The running balance is per currency (the API accumulates it chronologically
|
||||
* before handing the list back newest-first), so the movement table is scoped
|
||||
* to one currency at a time — a column that alternated between pesos and
|
||||
* dollars would be a meaningless number.
|
||||
*/
|
||||
export default function EstadoCuentaDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const { id } = params;
|
||||
return (
|
||||
<AppShell>
|
||||
<StatementView id={id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function StatementView({ id }: { id: string }) {
|
||||
const canCapture = useCan("ledger:create");
|
||||
const canVoid = useCan("ledger:void");
|
||||
const [data, setData] = useState<Statement | null>(null);
|
||||
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
||||
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
||||
|
||||
function reload() {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getStatement(id)
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
setData(d);
|
||||
// Default to the currency the customer actually moves the most in;
|
||||
// preserve a previously-chosen currency across reloads.
|
||||
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
||||
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(
|
||||
e?.status === 404
|
||||
? "No encontramos este cliente."
|
||||
: e?.message ?? "No se pudo cargar el estado de cuenta.",
|
||||
);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = reload();
|
||||
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||
return cleanup;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const movements = useMemo(() => {
|
||||
if (!data || !currency) return [];
|
||||
return data.movements.filter(
|
||||
(m) => m.currency === currency && (!domain || m.domain === domain),
|
||||
);
|
||||
}, [data, currency, domain]);
|
||||
|
||||
if (loading) return <StatementSkeleton />;
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<>
|
||||
<BackLink />
|
||||
<div className="state-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!data || !currency) return null;
|
||||
|
||||
const active = data.summary.find((s) => s.currency === currency);
|
||||
|
||||
return (
|
||||
<div className="rise">
|
||||
<BackLink />
|
||||
<Hero data={data} />
|
||||
|
||||
<section className="section">
|
||||
<SectionHead rule="cuenta" title="Saldo por moneda" />
|
||||
{data.summary.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-inline">
|
||||
Este cliente no tiene movimientos registrados.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="summary-grid">
|
||||
{data.summary.map((s) => {
|
||||
const tone = balanceTone(s.balance);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={s.currency}
|
||||
className={`summary-card bal-card ${tone}${
|
||||
currency === s.currency ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => setCurrency(s.currency)}
|
||||
aria-pressed={currency === s.currency}
|
||||
>
|
||||
<div className="summary-domain">
|
||||
Saldo en {s.currency} · {balancePhrase(s.balance)}
|
||||
</div>
|
||||
<div className={`summary-total bal-amount ${tone}`}>
|
||||
{formatMoney(s.balance, s.currency)}
|
||||
</div>
|
||||
<div className="bal-breakdown">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(s.charges, s.currency)}
|
||||
</span>
|
||||
<span className="bal-breakdown-label">
|
||||
{formatNumber(s.chargeCount)} cargos
|
||||
</span>
|
||||
<span className="tx-amount pos">
|
||||
{formatMoney(s.credits, s.currency)}
|
||||
</span>
|
||||
<span className="bal-breakdown-label">
|
||||
{formatNumber(s.creditCount)} abonos
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-count">
|
||||
{formatDate(s.firstMovement)} a {formatDate(s.lastMovement)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="section-note">
|
||||
Los saldos se muestran por separado en cada moneda. La contabilidad
|
||||
heredada registró los cargos únicamente en pesos y los recibos en
|
||||
ambas monedas, sin guardar el tipo de cambio aplicado a cada
|
||||
movimiento, por lo que sumarlas produciría una cifra que nunca existió
|
||||
en los libros.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<PorLineaSection data={data} currency={currency} />
|
||||
<ConceptosSection data={data} currency={currency} />
|
||||
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="cuenta"
|
||||
title="Movimientos"
|
||||
count={movements.length}
|
||||
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
||||
right={
|
||||
canCapture ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setCaptureOpen((v) => !v)}
|
||||
>
|
||||
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{captureOpen && (
|
||||
<MovementForm
|
||||
concepts={facets?.types ?? []}
|
||||
defaultCurrency={currency ?? "MXN"}
|
||||
defaultCustomer={{
|
||||
id: data.customer.id,
|
||||
name: data.customer.name,
|
||||
}}
|
||||
onSaved={() => {
|
||||
setCaptureOpen(false);
|
||||
reload();
|
||||
}}
|
||||
onCancel={() => setCaptureOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="filter-row">
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Moneda</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
||||
>
|
||||
{data.summary.map((s) => (
|
||||
<option key={s.currency} value={s.currency}>
|
||||
{s.currency} ({formatNumber(s.count)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Línea de negocio</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={domain}
|
||||
onChange={(e) =>
|
||||
setDomain(e.target.value as TransactionDomain | "")
|
||||
}
|
||||
>
|
||||
<option value="">Ambas líneas</option>
|
||||
<option value="UTILITY">Servicios</option>
|
||||
<option value="INSURANCE">Seguros</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{movements.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
Sin movimientos en {currency}
|
||||
{domain ? ` para ${domainLabel(domain)}` : ""}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Línea</th>
|
||||
<th>Concepto</th>
|
||||
<th>Referencia</th>
|
||||
<th className="num">Cargo / Abono</th>
|
||||
<th className="num">Saldo</th>
|
||||
{canVoid && (
|
||||
<th style={{ width: 1, whiteSpace: "nowrap" }}>
|
||||
Acciones
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m) => (
|
||||
<StatementRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
canVoid={canVoid}
|
||||
onVoided={reload}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{domain && movements.length > 0 && (
|
||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||
La columna de saldo es el saldo acumulado del cliente en{" "}
|
||||
{currency} sobre <strong>todas</strong> sus líneas — filtrar por
|
||||
línea oculta filas, no las descuenta.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{active && (
|
||||
<p className="section-note">
|
||||
Saldo final en {currency}:{" "}
|
||||
<strong>{formatMoney(active.balance, currency)}</strong> (
|
||||
{balancePhrase(active.balance).toLowerCase()}).
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link href="/estado-cuenta" className="back-link">
|
||||
← Volver a Estado de cuenta
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero({ data }: { data: Statement }) {
|
||||
const c = data.customer;
|
||||
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
const facts: { label: string; value: string }[] = [
|
||||
{ label: "Cliente desde", value: formatDate(c.customerSince) },
|
||||
{ label: "Propiedades", value: String(c.propertyCount) },
|
||||
{ label: "Pólizas", value: String(c.policyCount) },
|
||||
{ label: "Teléfono", value: c.phone || c.mobile || "—" },
|
||||
{ label: "Correo", value: c.email || "—" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="detail-hero">
|
||||
<div className="hero-top">
|
||||
<div>
|
||||
<h1
|
||||
className={`hero-name${
|
||||
c.name === SIN_NOMBRE ? " hero-name-missing" : ""
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</h1>
|
||||
{location && <div className="hero-provenance">{location}</div>}
|
||||
{c.nameSource && (
|
||||
<div className="hero-provenance">
|
||||
Nombre recuperado de {c.nameSource} — el registro original no
|
||||
tenía nombre.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hero-badges">
|
||||
{c.propertyCount > 0 && (
|
||||
<span className="badge badge-servicios">
|
||||
<span className="dot" /> Servicios
|
||||
</span>
|
||||
)}
|
||||
{c.policyCount > 0 && (
|
||||
<span className="badge badge-seguros">
|
||||
<span className="dot" /> Seguros
|
||||
</span>
|
||||
)}
|
||||
<span className={`badge ${c.status ? "badge-on-dark" : "badge-negative"}`}>
|
||||
{c.status ? "Activo" : "Inactivo"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-facts">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label}>
|
||||
<div className="hero-fact-label">{f.label}</div>
|
||||
<div className="hero-fact-value">{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hero-links">
|
||||
<Link href={`/clientes/${c.id}`} className="btn btn-outline">
|
||||
Ver ficha del cliente
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The cross-line split — the same balance, broken out by business line. */
|
||||
function PorLineaSection({
|
||||
data,
|
||||
currency,
|
||||
}: {
|
||||
data: Statement;
|
||||
currency: LedgerCurrency;
|
||||
}) {
|
||||
const rows = data.byDomain.filter((d) => d.currency === currency);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="cuenta" title={`Por línea de negocio · ${currency}`} />
|
||||
<div className="summary-grid">
|
||||
{rows.map((r) => (
|
||||
<div className={`summary-card ${r.domain}`} key={r.domain}>
|
||||
<div className="summary-domain">
|
||||
<span className={`tx-dot ${r.domain}`} />
|
||||
{domainLabel(r.domain)}
|
||||
</div>
|
||||
<div className={`summary-total bal-amount ${balanceTone(r.balance)}`}>
|
||||
{formatMoney(r.balance, currency)}
|
||||
</div>
|
||||
<div className="bal-breakdown">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(r.charges, currency)}
|
||||
</span>
|
||||
<span className="bal-breakdown-label">en cargos</span>
|
||||
<span className="tx-amount pos">
|
||||
{formatMoney(r.credits, currency)}
|
||||
</span>
|
||||
<span className="bal-breakdown-label">en abonos</span>
|
||||
</div>
|
||||
<div className="summary-count">
|
||||
{formatNumber(r.count)}{" "}
|
||||
{r.count === 1 ? "movimiento" : "movimientos"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the charges went — the question a customer asks about their balance. */
|
||||
function ConceptosSection({
|
||||
data,
|
||||
currency,
|
||||
}: {
|
||||
data: Statement;
|
||||
currency: LedgerCurrency;
|
||||
}) {
|
||||
const rows = data.byType.filter((t) => t.currency === currency).slice(0, 12);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const largest = Math.abs(Number(rows[0]?.total ?? 0)) || 1;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="servicios" title={`Cargos por concepto · ${currency}`} />
|
||||
<div className="card">
|
||||
<div className="concept-list">
|
||||
{rows.map((t) => (
|
||||
<div className="concept-row" key={`${t.name}-${t.currency}`}>
|
||||
<div className="concept-name">
|
||||
{txTypeLabel({ nameEn: t.name })}
|
||||
<span className="concept-count">
|
||||
{formatNumber(t.count)}{" "}
|
||||
{t.count === 1 ? "cargo" : "cargos"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="concept-bar" aria-hidden>
|
||||
<span
|
||||
style={{
|
||||
width: `${Math.max(
|
||||
2,
|
||||
(Math.abs(Number(t.total)) / largest) * 100,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="concept-total tx-amount neg">
|
||||
{formatMoney(t.total, currency)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatementRow({
|
||||
m,
|
||||
canVoid,
|
||||
onVoided,
|
||||
}: {
|
||||
m: StatementMovement;
|
||||
canVoid: boolean;
|
||||
onVoided: () => void;
|
||||
}) {
|
||||
const concept = m.message || m.period || null;
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function doVoid() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"¿Anular este movimiento? Quedará tachado y no contará en los totales.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await voidMovement(m.id);
|
||||
onVoided();
|
||||
} catch (e) {
|
||||
window.alert(
|
||||
(e as Error)?.message ?? "No se pudo anular el movimiento.",
|
||||
);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
|
||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(m.transactionDate)}
|
||||
</td>
|
||||
<td className="tx-domain-cell">
|
||||
<span className={`tx-dot ${m.domain}`} />
|
||||
{domainLabel(m.domain)}
|
||||
</td>
|
||||
<td>
|
||||
{txTypeLabel(m.type)}
|
||||
{concept && <div className="tx-concept">{concept}</div>}
|
||||
</td>
|
||||
<td className="tx-ref">
|
||||
{m.reference || m.checkNumber || "—"}
|
||||
<div className="tx-concept">{ledgerSourceLabel(m.source)}</div>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className={`tx-amount ${m.direction === "charge" ? "neg" : "pos"}`}>
|
||||
{formatMoney(m.amount, m.currency)}
|
||||
</span>
|
||||
<div className="tx-cur">{directionLabel(m.direction)}</div>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className={`bal-running ${balanceTone(m.balanceAfter)}`}>
|
||||
{formatMoney(m.balanceAfter, m.currency)}
|
||||
</span>
|
||||
</td>
|
||||
{canVoid && (
|
||||
<td style={{ whiteSpace: "nowrap" }}>
|
||||
{!m.voided && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 10px", fontSize: 12 }}
|
||||
onClick={doVoid}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Anulando…" : "Anular"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({
|
||||
rule,
|
||||
title,
|
||||
count,
|
||||
countSuffix,
|
||||
right,
|
||||
}: {
|
||||
rule: string;
|
||||
title: string;
|
||||
count?: number;
|
||||
countSuffix?: string;
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="section-head"
|
||||
style={
|
||||
right
|
||||
? { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className={`section-rule ${rule}`} aria-hidden />
|
||||
<h2 className="section-title">{title}</h2>
|
||||
{count != null && (
|
||||
<span className="section-count">
|
||||
{formatNumber(count)} {countSuffix ?? ""}
|
||||
</span>
|
||||
)}
|
||||
{right && (
|
||||
<div style={{ marginLeft: "auto" }}>{right}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatementSkeleton() {
|
||||
return (
|
||||
<div aria-hidden>
|
||||
<div className="skeleton" style={{ height: 18, width: 180 }} />
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 150, marginTop: 16, borderRadius: 16 }}
|
||||
/>
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 320, marginTop: 24, borderRadius: 16 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { MovementForm } from "@/components/MovementForm";
|
||||
import {
|
||||
getBillingFacets,
|
||||
getBillingStats,
|
||||
listBalances,
|
||||
listMovements,
|
||||
voidMovement,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
balancePhrase,
|
||||
balanceTone,
|
||||
directionLabel,
|
||||
domainLabel,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
ledgerSourceLabel,
|
||||
SIN_NOMBRE,
|
||||
txTypeLabel,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
BalanceFilter,
|
||||
BalanceListItem,
|
||||
BalanceListResponse,
|
||||
BalanceSort,
|
||||
BillingFacets,
|
||||
BillingStats,
|
||||
LedgerCurrency,
|
||||
LedgerDirection,
|
||||
MovementListItem,
|
||||
MovementListResponse,
|
||||
MovementSort,
|
||||
TransactionDomain,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Shared billing / statements browser — plan step 6.
|
||||
*
|
||||
* Two views over the same ledger, because staff ask two different questions:
|
||||
* - "Saldos": who owes what, one row per customer. The receivables worklist.
|
||||
* - "Movimientos": every individual charge and credit, filterable — the
|
||||
* answer to "what did we bill for water in April".
|
||||
*
|
||||
* Both are cross-line: a customer's utility charges and insurance movements sit
|
||||
* in the same ledger, which is the point of the unified customer record.
|
||||
*
|
||||
* Balances are always shown *per currency* and never added together — see the
|
||||
* currency note in `billing.service.ts`.
|
||||
*/
|
||||
type View = "saldos" | "movimientos";
|
||||
|
||||
const BALANCE_FILTERS: { key: BalanceFilter; label: string }[] = [
|
||||
{ key: "owing", label: "Con adeudo" },
|
||||
{ key: "credit", label: "Con saldo a favor" },
|
||||
{ key: "settled", label: "En ceros" },
|
||||
{ key: "all", label: "Todos" },
|
||||
];
|
||||
|
||||
const BALANCE_SORTS: { key: BalanceSort; label: string }[] = [
|
||||
{ key: "owing_desc", label: "Mayor adeudo primero" },
|
||||
{ key: "credit_desc", label: "Mayor saldo a favor primero" },
|
||||
{ key: "recent", label: "Movimiento más reciente" },
|
||||
{ key: "customer", label: "Cliente (A–Z)" },
|
||||
];
|
||||
|
||||
const MOVEMENT_SORTS: { key: MovementSort; label: string }[] = [
|
||||
{ key: "date_desc", label: "Fecha (más reciente)" },
|
||||
{ key: "date_asc", label: "Fecha (más antigua)" },
|
||||
{ key: "amount_asc", label: "Cargo más grande" },
|
||||
{ key: "amount_desc", label: "Abono más grande" },
|
||||
{ key: "customer", label: "Cliente (A–Z)" },
|
||||
];
|
||||
|
||||
const DIRECTIONS: { key: LedgerDirection | ""; label: string }[] = [
|
||||
{ key: "", label: "Cargos y abonos" },
|
||||
{ key: "charge", label: "Sólo cargos" },
|
||||
{ key: "credit", label: "Sólo abonos" },
|
||||
];
|
||||
|
||||
const DOMAINS: { key: TransactionDomain | ""; label: string }[] = [
|
||||
{ key: "", label: "Ambas líneas" },
|
||||
{ key: "UTILITY", label: "Servicios" },
|
||||
{ key: "INSURANCE", label: "Seguros" },
|
||||
];
|
||||
|
||||
export default function EstadoCuentaPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<BillingBrowser />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingBrowser() {
|
||||
const canCapture = useCan("ledger:create");
|
||||
const canVoid = useCan("ledger:void");
|
||||
const [stats, setStats] = useState<BillingStats | null>(null);
|
||||
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
||||
const [view, setView] = useState<View>("saldos");
|
||||
|
||||
// The currency every balance figure is filtered and sorted on. MXN is the
|
||||
// default because the charge side of the ledger is MXN-only.
|
||||
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
|
||||
const [query, setQuery] = useState("");
|
||||
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
||||
|
||||
const [balanceFilter, setBalanceFilter] = useState<BalanceFilter>("owing");
|
||||
const [balanceSort, setBalanceSort] = useState<BalanceSort>("owing_desc");
|
||||
|
||||
const [direction, setDirection] = useState<LedgerDirection | "">("");
|
||||
const [typeId, setTypeId] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [movementSort, setMovementSort] = useState<MovementSort>("date_desc");
|
||||
|
||||
const [balances, setBalances] = useState<BalanceListResponse | null>(null);
|
||||
const [movements, setMovements] = useState<MovementListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
getBillingStats().then(setStats).catch(() => setStats(null));
|
||||
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||
}, []);
|
||||
|
||||
const runSearch = useCallback(
|
||||
(p: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const done = (fn: () => void) => {
|
||||
fn();
|
||||
setLoading(false);
|
||||
};
|
||||
if (view === "saldos") {
|
||||
listBalances({
|
||||
query: query || undefined,
|
||||
currency,
|
||||
balance: balanceFilter,
|
||||
domain: domain || undefined,
|
||||
sort: balanceSort,
|
||||
page: p,
|
||||
pageSize: 25,
|
||||
})
|
||||
.then((res) => done(() => setBalances(res)))
|
||||
.catch((e) => {
|
||||
setError(e?.message ?? "No se pudieron cargar los saldos.");
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
listMovements({
|
||||
query: query || undefined,
|
||||
currency,
|
||||
domain: domain || undefined,
|
||||
direction: direction || undefined,
|
||||
typeId: typeId || undefined,
|
||||
source: source || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
sort: movementSort,
|
||||
page: p,
|
||||
pageSize: 25,
|
||||
})
|
||||
.then((res) => done(() => setMovements(res)))
|
||||
.catch((e) => {
|
||||
setError(e?.message ?? "No se pudieron cargar los movimientos.");
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
view,
|
||||
query,
|
||||
currency,
|
||||
domain,
|
||||
balanceFilter,
|
||||
balanceSort,
|
||||
direction,
|
||||
typeId,
|
||||
source,
|
||||
from,
|
||||
to,
|
||||
movementSort,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [runSearch]);
|
||||
|
||||
function goToPage(p: number) {
|
||||
runSearch(p);
|
||||
if (typeof window !== "undefined")
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
/** Jumping in from a headline count should land on the matching worklist. */
|
||||
function pickBalance(f: BalanceFilter, cur?: LedgerCurrency) {
|
||||
setView("saldos");
|
||||
setBalanceFilter(f);
|
||||
if (cur) setCurrency(cur);
|
||||
setBalanceSort(f === "credit" ? "credit_desc" : "owing_desc");
|
||||
}
|
||||
|
||||
const data = view === "saldos" ? balances : movements;
|
||||
const filtered =
|
||||
query !== "" ||
|
||||
domain !== "" ||
|
||||
(view === "saldos"
|
||||
? balanceFilter !== "owing" || balanceSort !== "owing_desc"
|
||||
: direction !== "" ||
|
||||
typeId !== "" ||
|
||||
source !== "" ||
|
||||
from !== "" ||
|
||||
to !== "" ||
|
||||
movementSort !== "date_desc");
|
||||
|
||||
function clearFilters() {
|
||||
setQuery("");
|
||||
setDomain("");
|
||||
setCurrency("MXN");
|
||||
setBalanceFilter("owing");
|
||||
setBalanceSort("owing_desc");
|
||||
setDirection("");
|
||||
setTypeId("");
|
||||
setSource("");
|
||||
setFrom("");
|
||||
setTo("");
|
||||
setMovementSort("date_desc");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head rise">
|
||||
<p className="eyebrow">Cobranza y facturación</p>
|
||||
<h1 className="page-title">Estado de cuenta</h1>
|
||||
<BillingStatStrip
|
||||
stats={stats}
|
||||
currency={currency}
|
||||
balanceFilter={view === "saldos" ? balanceFilter : null}
|
||||
onPickBalance={pickBalance}
|
||||
/>
|
||||
<LedgerTotalsStrip stats={stats} />
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="search-box">
|
||||
<span className="search-icon" aria-hidden>
|
||||
⌕
|
||||
</span>
|
||||
<input
|
||||
className="input search-input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={
|
||||
view === "saldos"
|
||||
? "Buscar por cliente o ciudad…"
|
||||
: "Buscar por cliente, referencia, cheque, concepto…"
|
||||
}
|
||||
aria-label="Buscar en el estado de cuenta"
|
||||
/>
|
||||
</div>
|
||||
<div className="seg" role="tablist" aria-label="Vista">
|
||||
{(
|
||||
[
|
||||
{ key: "saldos" as View, label: "Saldos por cliente" },
|
||||
{ key: "movimientos" as View, label: "Movimientos" },
|
||||
]
|
||||
).map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === v.key}
|
||||
className={`seg-btn ${view === v.key ? "active" : ""}`}
|
||||
onClick={() => setView(v.key)}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === "movimientos" && canCapture && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setCaptureOpen((v) => !v)}
|
||||
>
|
||||
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === "movimientos" && captureOpen && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<span className="section-rule cuenta" aria-hidden />
|
||||
<h2 className="section-title">Capturar movimiento</h2>
|
||||
</div>
|
||||
<MovementForm
|
||||
concepts={facets?.types ?? []}
|
||||
defaultCurrency={currency}
|
||||
onSaved={() => {
|
||||
setCaptureOpen(false);
|
||||
runSearch(movements?.page ?? 1);
|
||||
getBillingStats()
|
||||
.then(setStats)
|
||||
.catch(() => setStats(null));
|
||||
}}
|
||||
onCancel={() => setCaptureOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="filter-row">
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Moneda</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
||||
>
|
||||
<option value="MXN">Pesos (MXN)</option>
|
||||
<option value="USD">Dólares (USD)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Línea de negocio</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value as TransactionDomain | "")}
|
||||
>
|
||||
{DOMAINS.map((d) => (
|
||||
<option key={d.key} value={d.key}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{view === "saldos" ? (
|
||||
<>
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Saldo</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={balanceFilter}
|
||||
onChange={(e) =>
|
||||
setBalanceFilter(e.target.value as BalanceFilter)
|
||||
}
|
||||
>
|
||||
{BALANCE_FILTERS.map((b) => (
|
||||
<option key={b.key} value={b.key}>
|
||||
{b.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Ordenar por</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={balanceSort}
|
||||
onChange={(e) => setBalanceSort(e.target.value as BalanceSort)}
|
||||
>
|
||||
{BALANCE_SORTS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Movimiento</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={direction}
|
||||
onChange={(e) =>
|
||||
setDirection(e.target.value as LedgerDirection | "")
|
||||
}
|
||||
>
|
||||
{DIRECTIONS.map((d) => (
|
||||
<option key={d.key} value={d.key}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Concepto</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={typeId}
|
||||
onChange={(e) => setTypeId(e.target.value)}
|
||||
>
|
||||
<option value="">Todos los conceptos</option>
|
||||
{facets?.types.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{txTypeLabel({ nameEn: t.name })} ({formatNumber(t.count)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Origen</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
>
|
||||
<option value="">Todos los orígenes</option>
|
||||
{facets?.sources.map((s) => (
|
||||
<option key={s.name} value={s.name}>
|
||||
{ledgerSourceLabel(s.name)} ({formatNumber(s.count)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Desde</span>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Hasta</span>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Ordenar por</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={movementSort}
|
||||
onChange={(e) =>
|
||||
setMovementSort(e.target.value as MovementSort)
|
||||
}
|
||||
>
|
||||
{MOVEMENT_SORTS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filtered && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost filter-clear"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && !loading && !error && (
|
||||
<div className="result-meta" aria-live="polite">
|
||||
{data.total === 0
|
||||
? "Sin resultados"
|
||||
: view === "saldos"
|
||||
? `${formatNumber(data.total)} ${
|
||||
data.total === 1 ? "cliente" : "clientes"
|
||||
} · saldo en ${currency}`
|
||||
: `${formatNumber(data.total)} ${
|
||||
data.total === 1 ? "movimiento" : "movimientos"
|
||||
}`}
|
||||
{query ? ` para “${query}”` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "movimientos" && movements && !loading && (
|
||||
<FilteredTotals totals={movements.totals} />
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="state-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<ListSkeleton />
|
||||
) : data && data.total === 0 ? (
|
||||
<EmptyState query={query} view={view} />
|
||||
) : view === "saldos" ? (
|
||||
<>
|
||||
<div className="cust-list">
|
||||
{balances?.items.map((b) => (
|
||||
<BalanceRow key={b.id} b={b} currency={currency} />
|
||||
))}
|
||||
</div>
|
||||
{balances && balances.pageCount > 1 && (
|
||||
<Pager
|
||||
page={balances.page}
|
||||
pageCount={balances.pageCount}
|
||||
onChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Cliente</th>
|
||||
<th>Línea</th>
|
||||
<th>Concepto</th>
|
||||
<th>Referencia</th>
|
||||
<th className="num">Monto</th>
|
||||
{canVoid && <th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements?.items.map((m) => (
|
||||
<MovementRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
canVoid={canVoid}
|
||||
onVoided={() => {
|
||||
runSearch(movements?.page ?? 1);
|
||||
getBillingStats()
|
||||
.then(setStats)
|
||||
.catch(() => setStats(null));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{movements && movements.pageCount > 1 && (
|
||||
<Pager
|
||||
page={movements.page}
|
||||
pageCount={movements.pageCount}
|
||||
onChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Headline counts; the owing/credit cells double as worklist shortcuts. */
|
||||
function BillingStatStrip({
|
||||
stats,
|
||||
currency,
|
||||
balanceFilter,
|
||||
onPickBalance,
|
||||
}: {
|
||||
stats: BillingStats | null;
|
||||
currency: LedgerCurrency;
|
||||
balanceFilter: BalanceFilter | null;
|
||||
onPickBalance: (f: BalanceFilter, cur?: LedgerCurrency) => void;
|
||||
}) {
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="stat-strip" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div className="stat-cell" key={i}>
|
||||
<div className="skeleton" style={{ height: 25, width: "60%" }} />
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 11, width: "80%", marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cur = stats.byCurrency.find((c) => c.currency === currency);
|
||||
|
||||
return (
|
||||
<div className="stat-strip">
|
||||
<button
|
||||
type="button"
|
||||
className={`stat-cell stat-cell-btn accent${
|
||||
balanceFilter === "owing" ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => onPickBalance("owing")}
|
||||
aria-pressed={balanceFilter === "owing"}
|
||||
>
|
||||
<div className="stat-value">{formatNumber(cur?.owing ?? 0)}</div>
|
||||
<div className="stat-label">Clientes con adeudo ({currency})</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`stat-cell stat-cell-btn${
|
||||
balanceFilter === "credit" ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => onPickBalance("credit")}
|
||||
aria-pressed={balanceFilter === "credit"}
|
||||
>
|
||||
<div className="stat-value">{formatNumber(cur?.inCredit ?? 0)}</div>
|
||||
<div className="stat-label">Con saldo a favor ({currency})</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`stat-cell stat-cell-btn${
|
||||
balanceFilter === "all" ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => onPickBalance("all")}
|
||||
aria-pressed={balanceFilter === "all"}
|
||||
>
|
||||
<div className="stat-value">{formatNumber(stats.ledgerCustomers)}</div>
|
||||
<div className="stat-label">Clientes con movimientos</div>
|
||||
</button>
|
||||
<div className="stat-cell">
|
||||
<div className="stat-value">{formatNumber(stats.movements)}</div>
|
||||
<div className="stat-label">
|
||||
Movimientos · {formatDate(stats.firstMovement)} a{" "}
|
||||
{formatDate(stats.lastMovement)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-cell">
|
||||
<div className="stat-value">
|
||||
{formatNumber(stats.crossLineCustomers)}
|
||||
</div>
|
||||
<div className="stat-label">Con movimientos en ambas líneas</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charges vs credits for the whole ledger, per currency. Kept as two separate
|
||||
* chips rather than one figure: the two currencies are never added together.
|
||||
*/
|
||||
function LedgerTotalsStrip({ stats }: { stats: BillingStats | null }) {
|
||||
if (!stats || stats.byCurrency.length === 0) return null;
|
||||
return (
|
||||
<div className="mix-strip">
|
||||
<span className="premium-caption">Movimiento histórico</span>
|
||||
{stats.byCurrency.map((c) => (
|
||||
<div className="ledger-chip" key={c.currency}>
|
||||
<span className="ledger-chip-cur">{c.currency}</span>
|
||||
<span className="ledger-chip-figs">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(c.charges, c.currency)}
|
||||
</span>
|
||||
<span className="ledger-chip-label">
|
||||
en cargos · {formatNumber(c.chargeCount)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ledger-chip-figs">
|
||||
<span className="tx-amount pos">
|
||||
{formatMoney(c.credits, c.currency)}
|
||||
</span>
|
||||
<span className="ledger-chip-label">
|
||||
en abonos · {formatNumber(c.creditCount)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Totals for everything the current movement filter matched, not just the page. */
|
||||
function FilteredTotals({
|
||||
totals,
|
||||
}: {
|
||||
totals: MovementListResponse["totals"];
|
||||
}) {
|
||||
if (totals.length === 0) return null;
|
||||
return (
|
||||
<div className="filtered-totals">
|
||||
{totals.map((t) => (
|
||||
<div className="filtered-total" key={t.currency}>
|
||||
<span className="filtered-total-cur">{t.currency}</span>
|
||||
<span>
|
||||
<strong className="tx-amount neg">
|
||||
{formatMoney(t.charges, t.currency)}
|
||||
</strong>{" "}
|
||||
cargos
|
||||
</span>
|
||||
<span>
|
||||
<strong className="tx-amount pos">
|
||||
{formatMoney(t.credits, t.currency)}
|
||||
</strong>{" "}
|
||||
abonos
|
||||
</span>
|
||||
<span className="filtered-total-net">
|
||||
Neto <strong>{formatMoney(t.net, t.currency)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BalanceRow({
|
||||
b,
|
||||
currency,
|
||||
}: {
|
||||
b: BalanceListItem;
|
||||
currency: LedgerCurrency;
|
||||
}) {
|
||||
const selected =
|
||||
b.balances.find((x) => x.currency === currency) ?? b.balances[0];
|
||||
const other = b.balances.find((x) => x.currency !== currency);
|
||||
const tone = balanceTone(selected.balance);
|
||||
const location = [b.city?.replace(/,\s*$/, ""), b.state]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<Link href={`/estado-cuenta/${b.id}`} className="cust-row bal-row">
|
||||
<div className="cust-main">
|
||||
<div className="cust-name">
|
||||
<span className={b.name === SIN_NOMBRE ? "cust-name-missing" : undefined}>
|
||||
{b.name}
|
||||
</span>
|
||||
{b.utilityMovements > 0 && (
|
||||
<span className="badge badge-servicios">
|
||||
<span className="dot" /> Servicios
|
||||
</span>
|
||||
)}
|
||||
{b.insuranceMovements > 0 && (
|
||||
<span className="badge badge-seguros">
|
||||
<span className="dot" /> Seguros
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cust-sub">
|
||||
{location && <span>{location}</span>}
|
||||
{location && <span className="sep">·</span>}
|
||||
<span>
|
||||
{formatNumber(b.movements)}{" "}
|
||||
{b.movements === 1 ? "movimiento" : "movimientos"}
|
||||
</span>
|
||||
<span className="sep">·</span>
|
||||
<span>último {formatDate(b.lastMovement)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bal-side">
|
||||
<div className={`bal-amount ${tone}`}>
|
||||
{formatMoney(selected.balance, selected.currency)}
|
||||
</div>
|
||||
<div className={`bal-phrase ${tone}`}>
|
||||
{balancePhrase(selected.balance)} · {selected.currency}
|
||||
</div>
|
||||
{other && Math.abs(Number(other.balance)) >= 0.005 && (
|
||||
<div className="bal-other">
|
||||
{formatMoney(other.balance, other.currency)} en {other.currency}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MovementRow({
|
||||
m,
|
||||
canVoid,
|
||||
onVoided,
|
||||
}: {
|
||||
m: MovementListItem;
|
||||
canVoid: boolean;
|
||||
onVoided: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function doVoid() {
|
||||
if (!window.confirm("¿Anular este movimiento? Quedará tachado y no contará en los totales."))
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await voidMovement(m.id);
|
||||
onVoided();
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo anular el movimiento.");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
|
||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(m.transactionDate)}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/estado-cuenta/${m.customerId}`} className="inline-link">
|
||||
<span
|
||||
className={
|
||||
m.customerName === SIN_NOMBRE ? "cust-name-missing" : undefined
|
||||
}
|
||||
>
|
||||
{m.customerName}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="tx-domain-cell">
|
||||
<span className={`tx-dot ${m.domain}`} />
|
||||
{domainLabel(m.domain)}
|
||||
</td>
|
||||
<td>
|
||||
{txTypeLabel(m.type)}
|
||||
{m.message && <div className="tx-concept">{m.message}</div>}
|
||||
</td>
|
||||
<td className="tx-ref">
|
||||
{m.reference || m.checkNumber || "—"}
|
||||
<div className="tx-concept">{ledgerSourceLabel(m.source)}</div>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className={`tx-amount ${m.direction === "charge" ? "neg" : "pos"}`}>
|
||||
{formatMoney(m.amount, m.currency)}
|
||||
</span>
|
||||
<div className="tx-cur">
|
||||
{m.currency} · {directionLabel(m.direction)}
|
||||
</div>
|
||||
</td>
|
||||
{canVoid && (
|
||||
<td style={{ whiteSpace: "nowrap" }}>
|
||||
{!m.voided && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 10px", fontSize: 12 }}
|
||||
onClick={doVoid}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Anulando…" : "Anular"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({
|
||||
page,
|
||||
pageCount,
|
||||
onChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="pager" aria-label="Paginación">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
Página <strong>{page}</strong> de {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={page >= pageCount}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className="cust-list" aria-hidden>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div className="skeleton skel-row" key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ query, view }: { query: string; view: View }) {
|
||||
return (
|
||||
<div className="state-box">
|
||||
<div className="state-glyph" aria-hidden>
|
||||
⌕
|
||||
</div>
|
||||
<h3>Sin resultados</h3>
|
||||
<p>
|
||||
{query
|
||||
? `No encontramos ${
|
||||
view === "saldos" ? "clientes" : "movimientos"
|
||||
} para “${query}”.`
|
||||
: `No hay ${
|
||||
view === "saldos" ? "saldos" : "movimientos"
|
||||
} que coincidan con los filtros.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "Jorge Cuadros & Assoc.",
|
||||
description: "Unified customer, insurance, and utilities platform",
|
||||
title: "Jorge Cuadros & Asociados — Plataforma",
|
||||
description:
|
||||
"Plataforma interna unificada de clientes, servicios y seguros.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="es">
|
||||
<head>
|
||||
{/* Google Fonts via <link> so an offline build still runs with the
|
||||
system fallback stacks defined in globals.css. */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,560;9..144,600&family=Work+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ApiError, login, me } from "@/lib/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bootChecking, setBootChecking] = useState(true);
|
||||
|
||||
// If already signed in, skip straight to the customer browser.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
me()
|
||||
.then(() => router.replace("/clientes"))
|
||||
.catch(() => {
|
||||
if (alive) setBootChecking(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
router.replace("/clientes");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setError("Correo o contraseña incorrectos");
|
||||
} else if (err instanceof ApiError && err.status === 0) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("No se pudo iniciar sesión. Inténtalo de nuevo.");
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (bootChecking) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "var(--brand-700)",
|
||||
}}
|
||||
>
|
||||
<span className="spinner" aria-label="Cargando" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
{/* Brand / narrative panel */}
|
||||
<aside className="login-aside" aria-hidden="false">
|
||||
<div className="login-aside-top">
|
||||
<div className="login-brand">
|
||||
<span className="brand-mark" aria-hidden>
|
||||
JC
|
||||
</span>
|
||||
<div className="brand-text">
|
||||
<span className="brand-name" style={{ color: "#f6f3ec" }}>
|
||||
Jorge Cuadros
|
||||
</span>
|
||||
<span className="brand-sub">& Asociados</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-aside-mid">
|
||||
<p className="eyebrow" style={{ color: "rgba(242,239,231,0.6)" }}>
|
||||
Plataforma interna
|
||||
</p>
|
||||
<h1 className="login-headline">
|
||||
Un solo expediente para <em>Servicios</em> y <em>Seguros</em>.
|
||||
</h1>
|
||||
<p className="login-lede">
|
||||
Consulta en un mismo lugar las propiedades, pólizas y el estado de
|
||||
cuenta de cada cliente en Baja California.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="login-aside-foot">
|
||||
<div className="login-lob">
|
||||
<span className="badge badge-servicios">
|
||||
<span className="dot" /> Servicios
|
||||
</span>
|
||||
<span className="badge badge-seguros">
|
||||
<span className="dot" /> Seguros
|
||||
</span>
|
||||
</div>
|
||||
<span className="login-foot-note">
|
||||
Gestión de propiedades y correduría de seguros
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Form panel */}
|
||||
<section className="login-form-panel">
|
||||
<div className="login-form-inner rise">
|
||||
<p className="eyebrow">Acceso del personal</p>
|
||||
<h2 className="login-form-title">Iniciar sesión</h2>
|
||||
<p className="muted" style={{ marginTop: 6, marginBottom: 28 }}>
|
||||
Ingresa con tu cuenta para continuar.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<label className="field">
|
||||
<span className="field-label">Correo electrónico</span>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="nombre@jorgecuadros.local"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="field-label">Contraseña</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="login-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary login-submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 15, height: 15 }} />
|
||||
Entrando…
|
||||
</>
|
||||
) : (
|
||||
"Entrar"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
OPS_KIND_LABELS,
|
||||
OPS_STATUS_LABELS,
|
||||
formatBytes,
|
||||
formatDateTime,
|
||||
} from "@/lib/labels";
|
||||
import {
|
||||
backupDownloadUrl,
|
||||
deleteBackup,
|
||||
deleteIngest,
|
||||
getOpsJob,
|
||||
listBackups,
|
||||
listIngest,
|
||||
listOpsJobs,
|
||||
startOpsJob,
|
||||
uploadIngest,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
BackupFile,
|
||||
IngestFile,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
} from "@/lib/types";
|
||||
|
||||
export default function OperacionesPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Operaciones />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
type ConfirmState =
|
||||
| { kind: "REIMPORT" }
|
||||
| { kind: "RESTORE"; file: string }
|
||||
| null;
|
||||
|
||||
function Operaciones() {
|
||||
const allowed = useCan("db:manage");
|
||||
|
||||
const [ingest, setIngest] = useState<IngestFile[] | null>(null);
|
||||
const [backups, setBackups] = useState<BackupFile[] | null>(null);
|
||||
const [jobs, setJobs] = useState<OpsJob[] | null>(null);
|
||||
const [activeJob, setActiveJob] = useState<OpsJob | null>(null);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
const refreshLists = useCallback(() => {
|
||||
listIngest().then(setIngest).catch(() => setIngest([]));
|
||||
listBackups().then(setBackups).catch(() => setBackups([]));
|
||||
listOpsJobs()
|
||||
.then((rows) => {
|
||||
setJobs(rows);
|
||||
const running = rows.find((j) => j.status === "RUNNING");
|
||||
if (running) setActiveJob(running);
|
||||
})
|
||||
.catch(() => setJobs([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowed) refreshLists();
|
||||
}, [allowed, refreshLists]);
|
||||
|
||||
// Poll the active job while it runs; refresh everything when it finishes.
|
||||
useEffect(() => {
|
||||
if (!activeJob || activeJob.status !== "RUNNING") return;
|
||||
const id = activeJob.id;
|
||||
const timer = setInterval(() => {
|
||||
getOpsJob(id)
|
||||
.then((job) => {
|
||||
setActiveJob(job);
|
||||
if (job.status !== "RUNNING") {
|
||||
clearInterval(timer);
|
||||
refreshLists();
|
||||
setNotice(
|
||||
job.status === "SUCCESS"
|
||||
? `${OPS_KIND_LABELS[job.kind]} completada.`
|
||||
: `${OPS_KIND_LABELS[job.kind]} terminó con error. Revise el registro.`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* transient — keep polling */
|
||||
});
|
||||
}, 1500);
|
||||
return () => clearInterval(timer);
|
||||
}, [activeJob, refreshLists]);
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="page-head">
|
||||
<h1 className="page-title">Operaciones</h1>
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para administrar la base de datos.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const jobRunning = activeJob?.status === "RUNNING";
|
||||
|
||||
async function handleUpload(name: string, file: File | undefined) {
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setUploading(name);
|
||||
try {
|
||||
await uploadIngest(name, file);
|
||||
setNotice(`${name} cargado.`);
|
||||
refreshLists();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo cargar el archivo.");
|
||||
} finally {
|
||||
setUploading(null);
|
||||
const input = fileInputs.current[name];
|
||||
if (input) input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteIngest(name: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await deleteIngest(name);
|
||||
refreshLists();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo eliminar.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBackup(name: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await deleteBackup(name);
|
||||
refreshLists();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo eliminar el respaldo.");
|
||||
}
|
||||
}
|
||||
|
||||
async function start(kind: OpsJobKind, file?: string) {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setStarting(true);
|
||||
try {
|
||||
const job = await startOpsJob(kind, file);
|
||||
setActiveJob(job);
|
||||
setJobs((prev) => (prev ? [job, ...prev] : [job]));
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo iniciar la operación.");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function askConfirm(state: ConfirmState) {
|
||||
setConfirm(state);
|
||||
setConfirmText("");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
async function runConfirmed() {
|
||||
if (!confirm) return;
|
||||
const c = confirm;
|
||||
setConfirm(null);
|
||||
if (c.kind === "REIMPORT") await start("REIMPORT");
|
||||
else await start("RESTORE", c.file);
|
||||
}
|
||||
|
||||
const ingestReady = (ingest ?? []).every((f) => f.present);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<h1 className="page-title">Operaciones de base de datos</h1>
|
||||
</div>
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
{notice && <div className="state-box">{notice}</div>}
|
||||
|
||||
{/* Active / running job with live log */}
|
||||
{activeJob && (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
{OPS_KIND_LABELS[activeJob.kind]}{" "}
|
||||
<span
|
||||
className={`badge ${
|
||||
activeJob.status === "SUCCESS"
|
||||
? "badge-positive"
|
||||
: activeJob.status === "FAILED"
|
||||
? "badge-negative"
|
||||
: "badge-neutral"
|
||||
}`}
|
||||
>
|
||||
{jobRunning && <span className="spinner" aria-hidden style={{ marginRight: 6 }} />}
|
||||
{OPS_STATUS_LABELS[activeJob.status]}
|
||||
</span>
|
||||
</h2>
|
||||
{!jobRunning && (
|
||||
<button className="btn btn-ghost" type="button" onClick={() => setActiveJob(null)}>
|
||||
Ocultar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="ops-log">{activeJob.log || "Iniciando…"}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingest folder */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Carpeta de ingesta</h2>
|
||||
<p className="inline-form-note">
|
||||
Los cuatro archivos originales de Access. La reimportación y la
|
||||
sincronización leen de aquí.
|
||||
</p>
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Archivo</th>
|
||||
<th>Estado</th>
|
||||
<th className="num">Tamaño</th>
|
||||
<th>Modificado</th>
|
||||
<th className="num">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(ingest ?? []).map((f) => (
|
||||
<tr key={f.name}>
|
||||
<td className="mono">{f.name}</td>
|
||||
<td>
|
||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||
{f.present ? "Presente" : "Falta"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{formatBytes(f.size)}</td>
|
||||
<td>{formatDateTime(f.modifiedAt)}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputs.current[f.name] = el;
|
||||
}}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => fileInputs.current[f.name]?.click()}
|
||||
>
|
||||
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
||||
</button>
|
||||
{f.present && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={() => handleDeleteIngest(f.name)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operations */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Operaciones</h2>
|
||||
<div className="form-grid">
|
||||
<OpTile
|
||||
title="Respaldo"
|
||||
desc="Genera un volcado comprimido de la base de datos actual."
|
||||
action="Crear respaldo"
|
||||
tone="primary"
|
||||
disabled={jobRunning || starting}
|
||||
onClick={() => start("BACKUP")}
|
||||
/>
|
||||
<OpTile
|
||||
title="Reimportar (purga)"
|
||||
desc="Respalda, borra TODO y reconstruye desde los archivos de ingesta. Se pierden los datos capturados manualmente."
|
||||
action="Reimportar"
|
||||
tone="danger"
|
||||
disabled={jobRunning || starting || !ingestReady}
|
||||
onClick={() => askConfirm({ kind: "REIMPORT" })}
|
||||
/>
|
||||
<OpTile
|
||||
title="Sincronizar"
|
||||
desc="Conserva los datos actuales e importa solo lo nuevo del legado. Disponible en la Fase B."
|
||||
action="Próximamente"
|
||||
tone="muted"
|
||||
disabled
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{!ingestReady && (
|
||||
<p className="inline-form-note" style={{ marginTop: 12 }}>
|
||||
La reimportación requiere que los cuatro archivos estén presentes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backups */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Respaldos</h2>
|
||||
<p className="inline-form-note">
|
||||
Restaurar sobreescribe la base de datos completa con el respaldo elegido.
|
||||
</p>
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Archivo</th>
|
||||
<th className="num">Tamaño</th>
|
||||
<th>Creado</th>
|
||||
<th className="num">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backups === null ? (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<span className="spinner" aria-label="Cargando" />
|
||||
</td>
|
||||
</tr>
|
||||
) : backups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="muted">
|
||||
Sin respaldos.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
backups.map((b) => (
|
||||
<tr key={b.name}>
|
||||
<td className="mono">{b.name}</td>
|
||||
<td className="num">{formatBytes(b.size)}</td>
|
||||
<td>{formatDateTime(b.createdAt)}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<a className="btn btn-outline" href={backupDownloadUrl(b.name)}>
|
||||
Descargar
|
||||
</a>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
disabled={jobRunning || starting}
|
||||
onClick={() => askConfirm({ kind: "RESTORE", file: b.name })}
|
||||
>
|
||||
Restaurar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={() => handleDeleteBackup(b.name)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent jobs */}
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Historial</h2>
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Operación</th>
|
||||
<th>Estado</th>
|
||||
<th>Inicio</th>
|
||||
<th>Fin</th>
|
||||
<th className="num"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(jobs ?? []).map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td>{OPS_KIND_LABELS[j.kind]}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${
|
||||
j.status === "SUCCESS"
|
||||
? "badge-positive"
|
||||
: j.status === "FAILED"
|
||||
? "badge-negative"
|
||||
: "badge-neutral"
|
||||
}`}
|
||||
>
|
||||
{OPS_STATUS_LABELS[j.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatDateTime(j.startedAt)}</td>
|
||||
<td>{formatDateTime(j.finishedAt)}</td>
|
||||
<td className="num">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={() => setActiveJob(j)}
|
||||
>
|
||||
Ver registro
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs && jobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted">
|
||||
Sin operaciones registradas.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destructive-op confirm */}
|
||||
{confirm && (
|
||||
<div className="modal-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="card" style={{ padding: 24, maxWidth: 480 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
{confirm.kind === "REIMPORT" ? "Confirmar reimportación" : "Confirmar restauración"}
|
||||
</h2>
|
||||
<p className="inline-form-note">
|
||||
{confirm.kind === "REIMPORT"
|
||||
? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático."
|
||||
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
|
||||
</p>
|
||||
<label className="field">
|
||||
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
||||
<input
|
||||
className="input"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
type="button"
|
||||
disabled={confirmText !== "CONFIRMAR" || starting}
|
||||
onClick={runConfirmed}
|
||||
>
|
||||
{confirm.kind === "REIMPORT" ? "Reimportar" : "Restaurar"}
|
||||
</button>
|
||||
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OpTile({
|
||||
title,
|
||||
desc,
|
||||
action,
|
||||
tone,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
action: string;
|
||||
tone: "primary" | "danger" | "muted";
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const btnClass =
|
||||
tone === "danger" ? "btn btn-danger" : tone === "muted" ? "btn btn-outline" : "btn btn-primary";
|
||||
return (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<h3 className="section-title" style={{ fontSize: 15, margin: "0 0 4px" }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="inline-form-note" style={{ minHeight: 48 }}>
|
||||
{desc}
|
||||
</p>
|
||||
<button className={btnClass} type="button" disabled={disabled} onClick={onClick}>
|
||||
{action}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Jorge Cuadros & Assoc.</h1>
|
||||
<p>Unified customer platform — scaffold in progress.</p>
|
||||
</main>
|
||||
);
|
||||
redirect("/clientes");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PolicyForm } from "@/components/PolicyForm";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { getPolicy } from "@/lib/api";
|
||||
import type { PolicyDetail } from "@/lib/types";
|
||||
|
||||
export default function EditarPolizaPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
return (
|
||||
<AppShell>
|
||||
<EditarPoliza id={params.id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EditarPoliza({ id }: { id: string }) {
|
||||
const allowed = useCan("policy:update");
|
||||
const [policy, setPolicy] = useState<PolicyDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed) return;
|
||||
getPolicy(id)
|
||||
.then(setPolicy)
|
||||
.catch((e) => setError(e?.message ?? "No se pudo cargar la póliza."));
|
||||
}, [id, allowed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<Link href={`/polizas/${id}`} className="back-link">← Póliza</Link>
|
||||
<h1 className="page-title">Editar póliza</h1>
|
||||
</div>
|
||||
{!allowed ? (
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para editar pólizas.
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="state-box state-error">{error}</div>
|
||||
) : !policy ? (
|
||||
<div className="empty-inline"><span className="spinner" aria-label="Cargando" /></div>
|
||||
) : (
|
||||
<PolicyForm policy={policy} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import {
|
||||
addPolicyChild,
|
||||
archivePolicy,
|
||||
getLookups,
|
||||
getPolicy,
|
||||
removePolicyChild,
|
||||
restorePolicy,
|
||||
updatePolicyChild,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
|
||||
import {
|
||||
expiryPhrase,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
policyStatusLabel,
|
||||
premiumHeadline,
|
||||
SIN_NOMBRE,
|
||||
} from "@/lib/labels";
|
||||
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
||||
|
||||
export default function PolizaDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
// Next 14 passes `params` as a plain object here — no `use()` unwrapping.
|
||||
const { id } = params;
|
||||
return (
|
||||
<AppShell>
|
||||
<Detail id={id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ id }: { id: string }) {
|
||||
const [data, setData] = useState<PolicyDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getPolicy(id)
|
||||
.then((d) => {
|
||||
if (alive) {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
setError(
|
||||
e?.status === 404
|
||||
? "No encontramos esta póliza."
|
||||
: e?.message ?? "No se pudo cargar la póliza.",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <DetailSkeleton />;
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<>
|
||||
<BackLink />
|
||||
<div className="state-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const reload = () => getPolicy(id).then(setData).catch(() => {});
|
||||
|
||||
return (
|
||||
<div className="rise">
|
||||
<div className="detail-actionbar">
|
||||
<BackLink />
|
||||
<PolicyActions data={data} onChange={reload} />
|
||||
</div>
|
||||
<Hero data={data} />
|
||||
<ClienteSection data={data} />
|
||||
<CondicionesSection data={data} />
|
||||
{data.installments.length > 0 && <PagosSection data={data} />}
|
||||
{data.vehicles.length > 0 && <VehiculosSection data={data} />}
|
||||
{(data.insuredDrivers.length > 0 || data.beneficiaries.length > 0) && (
|
||||
<PersonasSection data={data} />
|
||||
)}
|
||||
{data.claims.length > 0 && <SiniestrosSection data={data} />}
|
||||
<CoberturasSection data={data} />
|
||||
<DocumentosSection data={data} />
|
||||
<ChildrenEditor data={data} onChange={reload} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Edit / archive controls for the policy header. */
|
||||
function PolicyActions({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: PolicyDetail;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const canEdit = useCan("policy:update");
|
||||
const canDelete = useCan("policy: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 póliza?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (archived) await restorePolicy(data.id);
|
||||
else await archivePolicy(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={`/polizas/${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 child collections — only shown to users who can edit the policy. */
|
||||
function ChildrenEditor({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: PolicyDetail;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const canEdit = useCan("policy:update");
|
||||
const [adjusters, setAdjusters] = useState<AdjusterRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canEdit) getLookups().then((l) => setAdjusters(l.adjusters)).catch(() => {});
|
||||
}, [canEdit]);
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
const INSTALLMENTS: ChildConfig = {
|
||||
apiKind: "installments",
|
||||
title: "Pagos",
|
||||
fields: [
|
||||
{ key: "sequence", label: "Sec.", type: "number" },
|
||||
{ key: "amount", label: "Monto", type: "number" },
|
||||
{ key: "currency", label: "Moneda", type: "select",
|
||||
options: [{ value: "MXN", label: "MXN" }, { value: "USD", label: "USD" }] },
|
||||
{ key: "dueDate", label: "Vence", type: "date" },
|
||||
{ key: "paidDate", label: "Pagado", type: "date" },
|
||||
{ key: "checkNumber", label: "Cheque" },
|
||||
{ key: "isCash", label: "Efectivo", type: "checkbox" },
|
||||
],
|
||||
};
|
||||
const VEHICLES: ChildConfig = {
|
||||
apiKind: "vehicles",
|
||||
title: "Vehículos",
|
||||
fields: [
|
||||
{ key: "make", label: "Marca" },
|
||||
{ key: "model", label: "Modelo" },
|
||||
{ key: "modelYear", label: "Año" },
|
||||
{ key: "licensePlate", label: "Placa" },
|
||||
{ key: "vinNumber", label: "VIN" },
|
||||
{ key: "stateCode", label: "Estado" },
|
||||
],
|
||||
};
|
||||
const DRIVERS: ChildConfig = {
|
||||
apiKind: "drivers",
|
||||
title: "Conductores",
|
||||
fields: [
|
||||
{ key: "fullName", label: "Nombre" },
|
||||
{ key: "birthDate", label: "Nacimiento", type: "date" },
|
||||
{ key: "sex", label: "Sexo" },
|
||||
{ key: "occupation", label: "Ocupación" },
|
||||
{ key: "licenseNumber", label: "Licencia" },
|
||||
{ key: "licenseState", label: "Estado" },
|
||||
],
|
||||
};
|
||||
const BENEFICIARIES: ChildConfig = {
|
||||
apiKind: "beneficiaries",
|
||||
title: "Beneficiarios",
|
||||
fields: [
|
||||
{ key: "name", label: "Nombre" },
|
||||
{ key: "phone", label: "Teléfono" },
|
||||
{ key: "email", label: "Correo" },
|
||||
{ key: "address", label: "Dirección" },
|
||||
],
|
||||
};
|
||||
const CLAIMS: ChildConfig = {
|
||||
apiKind: "claims",
|
||||
title: "Siniestros",
|
||||
fields: [
|
||||
{ key: "claimType", label: "Tipo" },
|
||||
{ key: "incidentDate", label: "Fecha", type: "date" },
|
||||
{ key: "description", label: "Descripción" },
|
||||
{ key: "adjusterId", label: "Ajustador", type: "select",
|
||||
options: adjusters.map((a) => ({ value: a.id, label: a.name ?? a.company ?? a.id })) },
|
||||
{ key: "claimedAmount", label: "Reclamado", type: "number" },
|
||||
{ key: "settledAmount", label: "Pagado", type: "number" },
|
||||
{ key: "resolved", label: "Resuelto", type: "checkbox" },
|
||||
],
|
||||
};
|
||||
|
||||
const bind = (cfg: ChildConfig, rows: Record<string, unknown>[]) => (
|
||||
<ChildCollection
|
||||
config={cfg}
|
||||
rows={rows}
|
||||
canEdit={canEdit}
|
||||
onAdd={async (p) => { await addPolicyChild(data.id, cfg.apiKind, p); onChange(); }}
|
||||
onSave={async (cid, p) => { await updatePolicyChild(data.id, cfg.apiKind, cid, p); onChange(); }}
|
||||
onRemove={async (cid) => { await removePolicyChild(data.id, cfg.apiKind, cid); onChange(); }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<span className="section-rule cuenta" aria-hidden />
|
||||
<h2 className="section-title">Administrar detalles</h2>
|
||||
</div>
|
||||
{bind(INSTALLMENTS, data.installments as unknown as Record<string, unknown>[])}
|
||||
{bind(VEHICLES, data.vehicles as unknown as Record<string, unknown>[])}
|
||||
{bind(DRIVERS, data.insuredDrivers as unknown as Record<string, unknown>[])}
|
||||
{bind(BENEFICIARIES, data.beneficiaries as unknown as Record<string, unknown>[])}
|
||||
{bind(CLAIMS, data.claims as unknown as Record<string, unknown>[])}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link href="/polizas" className="back-link">
|
||||
← Volver a Pólizas
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Hero */
|
||||
function Hero({ data }: { data: PolicyDetail }) {
|
||||
const premium = premiumHeadline(data);
|
||||
const phrase = expiryPhrase(data.daysToExpiry);
|
||||
const provenance = [data.legacySourceTable, data.legacyId]
|
||||
.filter(Boolean)
|
||||
.join(" #");
|
||||
|
||||
const facts: { label: string; value: string }[] = [
|
||||
{ label: "Vigencia desde", value: formatDate(data.policyFrom) },
|
||||
{ label: "Vigencia hasta", value: formatDate(data.policyTo) },
|
||||
{ label: premium.label, value: formatMoney(premium.value, data.currency) },
|
||||
{ label: "Moneda", value: data.currency ?? "—" },
|
||||
{ label: "Agente", value: data.agentName || "—" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="detail-hero">
|
||||
<div className="hero-top">
|
||||
<div>
|
||||
<h1 className="hero-name mono">{data.policyNumber || "—"}</h1>
|
||||
<div className="hero-provenance">
|
||||
{[data.policyType?.name, data.insuranceProvider?.name]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "Sin ramo ni aseguradora registrados"}
|
||||
</div>
|
||||
{provenance && (
|
||||
<div className="hero-provenance">Origen: {provenance}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hero-badges">
|
||||
<span className={`badge status-${data.status}`}>
|
||||
{policyStatusLabel(data.status)}
|
||||
{phrase && data.status !== "expired" ? ` · ${phrase}` : ""}
|
||||
</span>
|
||||
<span
|
||||
className={`badge ${
|
||||
data.liquidated ? "badge-positive" : "badge-negative"
|
||||
}`}
|
||||
>
|
||||
{data.liquidated ? "Liquidada" : "Sin liquidar"}
|
||||
</span>
|
||||
{data.endorsement && (
|
||||
<span className="badge badge-on-dark">Endoso</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-facts">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label}>
|
||||
<div className="hero-fact-label">{f.label}</div>
|
||||
<div className="hero-fact-value">{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Cliente */
|
||||
function ClienteSection({ data }: { data: PolicyDetail }) {
|
||||
const c = data.customer;
|
||||
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="datos" title="Cliente" />
|
||||
<div className="card">
|
||||
<Link href={`/clientes/${c.id}`} className="owner-link">
|
||||
<div>
|
||||
<div
|
||||
className={`owner-name${
|
||||
c.name === SIN_NOMBRE ? " cust-name-missing" : ""
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</div>
|
||||
<div className="cust-sub">
|
||||
{location && <span>{location}</span>}
|
||||
{location && (c.phone || c.email) && (
|
||||
<span className="sep">·</span>
|
||||
)}
|
||||
{(c.phone || c.mobile) && <span>{c.phone || c.mobile}</span>}
|
||||
{c.email && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>{c.email}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="owner-cta">Ver expediente →</span>
|
||||
</Link>
|
||||
|
||||
{data.properties.length > 0 && (
|
||||
<div className="linked-props">
|
||||
<div className="kv-label">Propiedades cubiertas</div>
|
||||
{data.properties.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/servicios/${p.id}`}
|
||||
className="linked-prop link"
|
||||
>
|
||||
{[p.addressLine1, p.addressLine2].filter(Boolean).join(", ") ||
|
||||
"Propiedad"}
|
||||
{p.zone && <span className="muted"> · Zona {p.zone}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Condiciones */
|
||||
function CondicionesSection({ data }: { data: PolicyDetail }) {
|
||||
const cur = data.currency;
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="seguros" title="Condiciones y primas" />
|
||||
<div className="card">
|
||||
<div className="kv-grid">
|
||||
<KV label="Fecha de emisión" value={formatDate(data.policyDate)} />
|
||||
<KV
|
||||
label="Periodo de cobertura"
|
||||
value={
|
||||
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
||||
}
|
||||
/>
|
||||
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
||||
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
||||
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
||||
only show it when it actually carries a figure. */}
|
||||
{data.total != null && Number(data.total) > 0 && (
|
||||
<KV label="Total" value={formatMoney(data.total, cur)} />
|
||||
)}
|
||||
<KV
|
||||
label="Liquidación"
|
||||
value={
|
||||
data.liquidated
|
||||
? [
|
||||
data.liquidationNumber
|
||||
? `No. ${data.liquidationNumber}`
|
||||
: null,
|
||||
data.liquidationDate
|
||||
? formatDate(data.liquidationDate)
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "Liquidada"
|
||||
: "Pendiente"
|
||||
}
|
||||
/>
|
||||
{data.observations && (
|
||||
<div className="kv-block">
|
||||
<div className="kv-label">Observaciones</div>
|
||||
<div className="kv-value">{data.observations}</div>
|
||||
</div>
|
||||
)}
|
||||
{data.notes && (
|
||||
<div className="kv-block">
|
||||
<div className="kv-label">Notas</div>
|
||||
<div className="kv-value">{data.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Pagos */
|
||||
function PagosSection({ data }: { data: PolicyDetail }) {
|
||||
const paid = data.installments.filter((i) => i.paidDate).length;
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="cuenta"
|
||||
title="Pagos"
|
||||
count={data.installments.length}
|
||||
countSuffix={`· ${paid} pagados`}
|
||||
/>
|
||||
<div className="card">
|
||||
<div className="subpanel" style={{ margin: 16 }}>
|
||||
{data.installments.map((inst) => (
|
||||
<InstallmentRow key={inst.id} inst={inst} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallmentRow({ inst }: { inst: Installment }) {
|
||||
const method = inst.isCash
|
||||
? "Efectivo"
|
||||
: inst.checkNumber
|
||||
? `Ref. ${inst.checkNumber}`
|
||||
: null;
|
||||
return (
|
||||
<div className="pay-row">
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<span className="pay-seq">{inst.sequence}</span>
|
||||
<span>
|
||||
{inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"}
|
||||
{inst.dueDate && !inst.paidDate && (
|
||||
<span className="muted" style={{ fontSize: 11 }}>
|
||||
{" "}
|
||||
· vence {formatDate(inst.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
{method && (
|
||||
<span className="muted" style={{ fontSize: 11 }}>
|
||||
{" "}
|
||||
· {method}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mono" style={{ fontWeight: 600 }}>
|
||||
{formatMoney(inst.amount, inst.currency)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- Vehículos */
|
||||
function VehiculosSection({ data }: { data: PolicyDetail }) {
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="servicios"
|
||||
title="Vehículos asegurados"
|
||||
count={data.vehicles.length}
|
||||
/>
|
||||
<div className="card">
|
||||
<div className="veh-grid">
|
||||
{data.vehicles.map((v) => (
|
||||
<div className="veh-card" key={v.id}>
|
||||
<div className="veh-title">
|
||||
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
|
||||
"Vehículo"}
|
||||
</div>
|
||||
<div className="veh-facts">
|
||||
{v.bodyType && <span>{v.bodyType}</span>}
|
||||
{v.licensePlate && (
|
||||
<span>
|
||||
Placa: <span className="mono">{v.licensePlate}</span>
|
||||
</span>
|
||||
)}
|
||||
{v.vinNumber && (
|
||||
<span>
|
||||
Serie: <span className="mono">{v.vinNumber}</span>
|
||||
</span>
|
||||
)}
|
||||
{v.engineNumber && (
|
||||
<span>
|
||||
Motor: <span className="mono">{v.engineNumber}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Asegurados y beneficiarios */
|
||||
function PersonasSection({ data }: { data: PolicyDetail }) {
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="datos" title="Asegurados y beneficiarios" />
|
||||
<div className="card">
|
||||
<div className="policy-body">
|
||||
{data.insuredDrivers.length > 0 && (
|
||||
<div className="subpanel">
|
||||
<div className="subpanel-title">
|
||||
<span>Asegurados</span>
|
||||
<span>{data.insuredDrivers.length}</span>
|
||||
</div>
|
||||
<div className="mini-list">
|
||||
{data.insuredDrivers.map((d) => (
|
||||
<div key={d.id}>
|
||||
{d.fullName || "—"}
|
||||
{d.licenseNumber && (
|
||||
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.beneficiaries.length > 0 && (
|
||||
<div className="subpanel">
|
||||
<div className="subpanel-title">
|
||||
<span>Beneficiarios</span>
|
||||
<span>{data.beneficiaries.length}</span>
|
||||
</div>
|
||||
<div className="mini-list">
|
||||
{data.beneficiaries.map((b) => (
|
||||
<div key={b.id}>
|
||||
{b.name || "—"}
|
||||
{(b.phone || b.email) && (
|
||||
<div className="mini-sub">
|
||||
{[b.phone, b.email].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Siniestros */
|
||||
function SiniestrosSection({ data }: { data: PolicyDetail }) {
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="cuenta" title="Siniestros" count={data.claims.length} />
|
||||
<div className="card">
|
||||
{data.claims.map((c) => (
|
||||
<div className="prop-card" key={c.id}>
|
||||
<div className="prop-addr">{c.claimType || "Siniestro"}</div>
|
||||
<div className="prop-meta">
|
||||
{c.incidentDate && (
|
||||
<span>Ocurrido: {formatDate(c.incidentDate)}</span>
|
||||
)}
|
||||
{c.reportedDate && (
|
||||
<span>Reportado: {formatDate(c.reportedDate)}</span>
|
||||
)}
|
||||
{c.adjuster?.name && <span>Ajustador: {c.adjuster.name}</span>}
|
||||
</div>
|
||||
<div className="kv-grid" style={{ marginTop: 12 }}>
|
||||
<KV
|
||||
label="Monto reclamado"
|
||||
value={formatMoney(c.claimedAmount, data.currency)}
|
||||
/>
|
||||
<KV
|
||||
label="Monto liquidado"
|
||||
value={formatMoney(c.settledAmount, data.currency)}
|
||||
/>
|
||||
<KV label="Fecha de finiquito" value={formatDate(c.settlementDate)} />
|
||||
{c.description && (
|
||||
<div className="kv-block">
|
||||
<div className="kv-label">Descripción</div>
|
||||
<div className="kv-value">{c.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Coberturas */
|
||||
/** The legacy tables carry per-line coverage columns the target schema does
|
||||
* not model; the migration preserved them verbatim in `coveragesJson`. */
|
||||
function CoberturasSection({ data }: { data: PolicyDetail }) {
|
||||
const entries = Object.entries(data.coveragesJson ?? {}).filter(
|
||||
([, v]) => v !== null && v !== "" && v !== 0,
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="seguros" title="Coberturas" count={entries.length} />
|
||||
<div className="card">
|
||||
<div className="kv-grid">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<div className="kv-label">{k}</div>
|
||||
<div className="kv-value">{String(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||
Campos de cobertura conservados tal cual desde el sistema anterior.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Documentos */
|
||||
function DocumentosSection({ data }: { data: PolicyDetail }) {
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="docs" title="Documentos" count={data.documents.length} />
|
||||
<div className="card">
|
||||
{data.documents.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
No hay documentos registrados para esta póliza.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="doc-list">
|
||||
{data.documents.map((d, i) => (
|
||||
<div className="doc-item" key={d.id ?? i}>
|
||||
<span className="doc-icon" aria-hidden>
|
||||
▤
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="doc-type">{d.documentType || "Documento"}</div>
|
||||
<div className="doc-key">{d.storageKey || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||
Los archivos se almacenan en el object storage (storageKey); no se
|
||||
descargan desde esta vista.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ helpers */
|
||||
function KV({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="kv-label">{label}</div>
|
||||
<div className="kv-value">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({
|
||||
rule,
|
||||
title,
|
||||
count,
|
||||
countSuffix,
|
||||
}: {
|
||||
rule: string;
|
||||
title: string;
|
||||
count?: number;
|
||||
countSuffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-head">
|
||||
<span className={`section-rule ${rule}`} aria-hidden />
|
||||
<h2 className="section-title">{title}</h2>
|
||||
{count != null && (
|
||||
<span className="section-count">
|
||||
{count} {countSuffix ?? ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 16, width: 140, marginBottom: 18 }}
|
||||
/>
|
||||
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 200, borderRadius: 16, marginTop: 34 }}
|
||||
/>
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 260, borderRadius: 16, marginTop: 34 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 { PolicyForm } from "@/components/PolicyForm";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
|
||||
export default function NuevaPolizaPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Suspense fallback={null}>
|
||||
<NuevaPoliza />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function NuevaPoliza() {
|
||||
const allowed = useCan("policy:create");
|
||||
const params = useSearchParams();
|
||||
const customerId = params.get("customerId") ?? undefined;
|
||||
const customerName = params.get("customerName") ?? undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<Link href="/polizas" className="back-link">← Pólizas</Link>
|
||||
<h1 className="page-title">Nueva póliza</h1>
|
||||
</div>
|
||||
{allowed ? (
|
||||
<PolicyForm fixedCustomerId={customerId} fixedCustomerName={customerName} />
|
||||
) : (
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para crear pólizas.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
getPolicyFacets,
|
||||
getPolicyStats,
|
||||
listPolicies,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
expiryPhrase,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
policyStatusLabel,
|
||||
premiumHeadline,
|
||||
SIN_NOMBRE,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
PolicyFacets,
|
||||
PolicyListItem,
|
||||
PolicyListResponse,
|
||||
PolicySort,
|
||||
PolicyStats,
|
||||
PolicyStatus,
|
||||
} from "@/lib/types";
|
||||
|
||||
type StatusFilter = "all" | PolicyStatus;
|
||||
|
||||
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: "all", label: "Todas" },
|
||||
{ key: "expiring", label: "Por vencer" },
|
||||
{ key: "active", label: "Vigentes" },
|
||||
{ key: "expired", label: "Vencidas" },
|
||||
{ key: "undated", label: "Sin vigencia" },
|
||||
];
|
||||
|
||||
const SORTS: { key: PolicySort; label: string }[] = [
|
||||
{ key: "expiry_desc", label: "Vencimiento (más reciente)" },
|
||||
{ key: "expiry_asc", label: "Vencimiento (más próximo)" },
|
||||
{ key: "customer", label: "Cliente (A–Z)" },
|
||||
{ key: "number", label: "Número de póliza" },
|
||||
{ key: "premium_desc", label: "Prima (mayor a menor)" },
|
||||
];
|
||||
|
||||
export default function PolizasPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PolizasBrowser />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PolizasBrowser() {
|
||||
const canCreate = useCan("policy:create");
|
||||
const [stats, setStats] = useState<PolicyStats | null>(null);
|
||||
const [facets, setFacets] = useState<PolicyFacets | null>(null);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [typeId, setTypeId] = useState("");
|
||||
const [providerId, setProviderId] = useState("");
|
||||
const [sort, setSort] = useState<PolicySort>("expiry_desc");
|
||||
|
||||
const [data, setData] = useState<PolicyListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
getPolicyStats().then(setStats).catch(() => setStats(null));
|
||||
getPolicyFacets().then(setFacets).catch(() => setFacets(null));
|
||||
}, []);
|
||||
|
||||
const runSearch = useCallback(
|
||||
(p: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
listPolicies({
|
||||
query: query || undefined,
|
||||
status: status === "all" ? undefined : status,
|
||||
typeId: typeId || undefined,
|
||||
providerId: providerId || undefined,
|
||||
sort,
|
||||
days: EXPIRY_WINDOW_DAYS,
|
||||
page: p,
|
||||
pageSize: 25,
|
||||
})
|
||||
.then((res) => {
|
||||
setData(res);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e?.message ?? "No se pudieron cargar las pólizas.");
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[query, status, typeId, providerId, sort],
|
||||
);
|
||||
|
||||
// Debounced re-query whenever any filter changes; always back to page 1.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [runSearch]);
|
||||
|
||||
function goToPage(p: number) {
|
||||
runSearch(p);
|
||||
if (typeof window !== "undefined")
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const filtered =
|
||||
query !== "" || status !== "all" || typeId !== "" || providerId !== "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head rise">
|
||||
<p className="eyebrow">Cartera de seguros</p>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<h1 className="page-title" style={{ margin: 0 }}>Pólizas</h1>
|
||||
<span style={{ flex: 1 }} />
|
||||
{canCreate && (
|
||||
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
|
||||
)}
|
||||
</div>
|
||||
<StatStrip
|
||||
stats={stats}
|
||||
status={status}
|
||||
onPickStatus={(s) => setStatus(s)}
|
||||
/>
|
||||
<PremiumStrip stats={stats} />
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="search-box">
|
||||
<span className="search-icon" aria-hidden>
|
||||
⌕
|
||||
</span>
|
||||
<input
|
||||
className="input search-input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar por póliza, cliente, placa, agente…"
|
||||
aria-label="Buscar pólizas"
|
||||
/>
|
||||
</div>
|
||||
<div className="seg" role="tablist" aria-label="Filtrar por vigencia">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === f.key}
|
||||
className={`seg-btn ${status === f.key ? "active" : ""}`}
|
||||
onClick={() => setStatus(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Ramo</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={typeId}
|
||||
onChange={(e) => setTypeId(e.target.value)}
|
||||
>
|
||||
<option value="">Todos los ramos</option>
|
||||
{facets?.types
|
||||
.filter((t) => t.count > 0)
|
||||
.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({formatNumber(t.count)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Aseguradora</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={providerId}
|
||||
onChange={(e) => setProviderId(e.target.value)}
|
||||
>
|
||||
<option value="">Todas las aseguradoras</option>
|
||||
{facets?.providers
|
||||
.filter((p) => p.count > 0)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({formatNumber(p.count)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Ordenar por</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as PolicySort)}
|
||||
>
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{filtered && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost filter-clear"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setStatus("all");
|
||||
setTypeId("");
|
||||
setProviderId("");
|
||||
}}
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && !loading && !error && (
|
||||
<div className="result-meta" aria-live="polite">
|
||||
{data.total === 0
|
||||
? "Sin resultados"
|
||||
: `${formatNumber(data.total)} ${
|
||||
data.total === 1 ? "póliza" : "pólizas"
|
||||
}`}
|
||||
{query ? ` para “${query}”` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="state-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<ListSkeleton />
|
||||
) : data && data.items.length === 0 ? (
|
||||
<EmptyState query={query} />
|
||||
) : (
|
||||
<>
|
||||
<div className="cust-list">
|
||||
{data?.items.map((p) => (
|
||||
<PolicyRow key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
{data && data.pageCount > 1 && (
|
||||
<Pager
|
||||
page={data.page}
|
||||
pageCount={data.pageCount}
|
||||
onChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Counts double as filter shortcuts — clicking a cell applies that bucket. */
|
||||
function StatStrip({
|
||||
stats,
|
||||
status,
|
||||
onPickStatus,
|
||||
}: {
|
||||
stats: PolicyStats | null;
|
||||
status: StatusFilter;
|
||||
onPickStatus: (s: StatusFilter) => void;
|
||||
}) {
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="stat-strip" aria-hidden>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div className="stat-cell" key={i}>
|
||||
<div className="skeleton" style={{ height: 25, width: "60%" }} />
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 11, width: "80%", marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cells: {
|
||||
key: StatusFilter;
|
||||
value: number;
|
||||
label: string;
|
||||
accent?: boolean;
|
||||
}[] = [
|
||||
{ key: "all", value: stats.total, label: "Pólizas", accent: true },
|
||||
{
|
||||
key: "expiring",
|
||||
value: stats.expiring,
|
||||
label: `Vencen en ${stats.days} días`,
|
||||
accent: true,
|
||||
},
|
||||
{ key: "active", value: stats.active, label: "Vigentes" },
|
||||
{ key: "expired", value: stats.expired, label: "Vencidas" },
|
||||
{ key: "undated", value: stats.undated, label: "Sin vigencia" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="stat-strip">
|
||||
{cells.map((c) => (
|
||||
<button
|
||||
type="button"
|
||||
key={c.label}
|
||||
className={`stat-cell stat-cell-btn${c.accent ? " accent" : ""}${
|
||||
status === c.key ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => onPickStatus(c.key)}
|
||||
aria-pressed={status === c.key}
|
||||
>
|
||||
<div className="stat-value">{formatNumber(c.value)}</div>
|
||||
<div className="stat-label">{c.label}</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="stat-cell">
|
||||
<div className="stat-value">{formatNumber(stats.pending)}</div>
|
||||
<div className="stat-label">Sin liquidar</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Premium in force, split by currency — MXN and USD can't be summed. */
|
||||
function PremiumStrip({ stats }: { stats: PolicyStats | null }) {
|
||||
if (!stats || stats.premiumInForce.length === 0) return null;
|
||||
return (
|
||||
<div className="premium-strip">
|
||||
<span className="premium-caption">Prima neta vigente</span>
|
||||
{stats.premiumInForce.map((row) => (
|
||||
<span className="premium-chip" key={row.currency}>
|
||||
<strong>{formatMoney(row.netPremium, row.currency)}</strong>
|
||||
<span className="premium-chip-sub">
|
||||
{row.currency} · {formatNumber(row.count)}{" "}
|
||||
{row.count === 1 ? "póliza" : "pólizas"}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyRow({ p }: { p: PolicyListItem }) {
|
||||
const premium = premiumHeadline(p);
|
||||
const phrase = expiryPhrase(p.daysToExpiry);
|
||||
const showPhrase = p.status === "expiring" || p.status === "active";
|
||||
|
||||
return (
|
||||
<Link href={`/polizas/${p.id}`} className="cust-row pol-row">
|
||||
<div className="cust-main">
|
||||
<div className="cust-name">
|
||||
<span className="mono pol-number">{p.policyNumber || "—"}</span>
|
||||
{p.policyType?.name && (
|
||||
<span className="badge badge-seguros">
|
||||
<span className="dot" /> {p.policyType.name}
|
||||
</span>
|
||||
)}
|
||||
<span className={`badge status-${p.status}`}>
|
||||
{policyStatusLabel(p.status)}
|
||||
</span>
|
||||
{!p.liquidated && (
|
||||
<span className="badge badge-neutral">Sin liquidar</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cust-sub">
|
||||
<span
|
||||
className={
|
||||
p.customerName === SIN_NOMBRE ? "cust-name-missing" : undefined
|
||||
}
|
||||
>
|
||||
{p.customerName}
|
||||
</span>
|
||||
{p.insuranceProvider?.name && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>{p.insuranceProvider.name}</span>
|
||||
</>
|
||||
)}
|
||||
{p.vehicleCount > 0 && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>
|
||||
{p.vehicleCount}{" "}
|
||||
{p.vehicleCount === 1 ? "vehículo" : "vehículos"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pol-side">
|
||||
<div className="pol-premium">
|
||||
{formatMoney(premium.value, p.currency)}
|
||||
</div>
|
||||
<div className="pol-dates mono">
|
||||
{formatDate(p.policyFrom)} – {formatDate(p.policyTo)}
|
||||
</div>
|
||||
{showPhrase && phrase && (
|
||||
<div className={`pol-phrase ${p.status}`}>{phrase}</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({
|
||||
page,
|
||||
pageCount,
|
||||
onChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="pager" aria-label="Paginación">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
Página <strong>{page}</strong> de {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={page >= pageCount}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className="cust-list" aria-hidden>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div className="skeleton skel-row" key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ query }: { query: string }) {
|
||||
return (
|
||||
<div className="state-box">
|
||||
<div className="state-glyph" aria-hidden>
|
||||
⌕
|
||||
</div>
|
||||
<h3>Sin resultados</h3>
|
||||
<p>
|
||||
{query
|
||||
? `No encontramos pólizas para “${query}”.`
|
||||
: "No hay pólizas que coincidan con los filtros."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
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, TrustInput } from "@/lib/types";
|
||||
|
||||
export default function PropiedadDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
// Next 14 passes `params` as a plain object here — no `use()` unwrapping.
|
||||
const { id } = params;
|
||||
return (
|
||||
<AppShell>
|
||||
<Detail id={id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ id }: { id: string }) {
|
||||
const [data, setData] = useState<PropertyDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getProperty(id)
|
||||
.then((d) => {
|
||||
if (alive) {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
setError(
|
||||
e?.status === 404
|
||||
? "No encontramos esta propiedad."
|
||||
: e?.message ?? "No se pudo cargar la propiedad.",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <DetailSkeleton />;
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<>
|
||||
<BackLink />
|
||||
<div className="state-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const reload = () => getProperty(id).then(setData).catch(() => {});
|
||||
|
||||
return (
|
||||
<div className="rise">
|
||||
<div className="detail-actionbar">
|
||||
<BackLink />
|
||||
<PropertyActions data={data} onChange={reload} />
|
||||
</div>
|
||||
<Hero data={data} />
|
||||
<ClienteSection data={data} />
|
||||
<ServiciosSection data={data} />
|
||||
<FideicomisoSection data={data} />
|
||||
{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">
|
||||
← Volver a Propiedades
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Hero */
|
||||
function Hero({ data }: { data: PropertyDetail }) {
|
||||
const addr = [data.addressLine1, data.addressLine2].filter(Boolean).join(", ");
|
||||
const phones = [data.phone1, data.phone2, data.phone3].filter(Boolean);
|
||||
const provenance = [data.legacySourceTable, data.legacyId]
|
||||
.filter(Boolean)
|
||||
.join(" #");
|
||||
const activos = data.services.filter((s) => s.active).length;
|
||||
const phrase = expiryPhrase(data.daysToTrustDue);
|
||||
|
||||
const facts: { label: string; value: string }[] = [
|
||||
{ label: "Cliente", value: data.customer.name },
|
||||
{
|
||||
label: "Servicios",
|
||||
value:
|
||||
data.services.length === 0
|
||||
? "Ninguno"
|
||||
: `${activos} activos de ${data.services.length}`,
|
||||
},
|
||||
{ label: "Municipio", value: data.municipality || "—" },
|
||||
{
|
||||
label: "Fideicomiso",
|
||||
value: data.trustAccount
|
||||
? formatDate(data.trustAccount.dueDate2)
|
||||
: "Sin fideicomiso",
|
||||
},
|
||||
{ label: "Teléfonos", value: phones.join(" · ") || "—" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="detail-hero">
|
||||
<div className="hero-top">
|
||||
<div>
|
||||
<h1 className="hero-name">{addr || "Propiedad sin dirección"}</h1>
|
||||
<div className="hero-provenance">
|
||||
{data.zone ? `Zona ${data.zone} · ` : ""}
|
||||
{data.customer.name}
|
||||
</div>
|
||||
{provenance && (
|
||||
<div className="hero-provenance">Origen: {provenance}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hero-badges">
|
||||
{data.municipality && (
|
||||
<span className="badge badge-servicios">
|
||||
<span className="dot" /> {data.municipality}
|
||||
</span>
|
||||
)}
|
||||
{data.trustAccount ? (
|
||||
<span className={`badge status-${data.trustStatus}`}>
|
||||
Fideicomiso · {trustStatusLabel(data.trustStatus)}
|
||||
{phrase && data.trustStatus !== "expired" ? ` · ${phrase}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-on-dark">Sin fideicomiso</span>
|
||||
)}
|
||||
{data.services.length === 0 && (
|
||||
<span className="badge badge-neutral">Sin servicios</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-facts">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label}>
|
||||
<div className="hero-fact-label">{f.label}</div>
|
||||
<div className="hero-fact-value">{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Cliente */
|
||||
function ClienteSection({ data }: { data: PropertyDetail }) {
|
||||
const c = data.customer;
|
||||
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="datos" title="Cliente" />
|
||||
<div className="card">
|
||||
<Link href={`/clientes/${c.id}`} className="owner-link">
|
||||
<div>
|
||||
<div
|
||||
className={`owner-name${
|
||||
c.name === SIN_NOMBRE ? " cust-name-missing" : ""
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</div>
|
||||
<div className="cust-sub">
|
||||
{location && <span>{location}</span>}
|
||||
{location && (c.phone || c.mobile || c.email) && (
|
||||
<span className="sep">·</span>
|
||||
)}
|
||||
{(c.phone || c.mobile) && <span>{c.phone || c.mobile}</span>}
|
||||
{c.email && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>{c.email}</span>
|
||||
</>
|
||||
)}
|
||||
{c._count.policies > 0 && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>
|
||||
{c._count.policies}{" "}
|
||||
{c._count.policies === 1 ? "póliza" : "pólizas"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="owner-cta">Ver expediente →</span>
|
||||
</Link>
|
||||
|
||||
{data.siblings.length > 0 && (
|
||||
<div className="linked-props">
|
||||
<div className="kv-label">
|
||||
Otras propiedades de este cliente ({data.siblings.length})
|
||||
</div>
|
||||
{data.siblings.map((s) => (
|
||||
<Link key={s.id} href={`/servicios/${s.id}`} className="linked-prop link">
|
||||
{[s.addressLine1, s.addressLine2].filter(Boolean).join(", ") ||
|
||||
"Propiedad"}
|
||||
<span className="muted">
|
||||
{s.zone ? ` · Zona ${s.zone}` : ""} · {s.serviceCount}{" "}
|
||||
{s.serviceCount === 1 ? "servicio" : "servicios"}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Servicios */
|
||||
function ServiciosSection({ data }: { data: PropertyDetail }) {
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="servicios"
|
||||
title="Servicios"
|
||||
count={data.services.length}
|
||||
/>
|
||||
<div className="card">
|
||||
{data.services.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
Esta propiedad no tiene servicios registrados.
|
||||
</div>
|
||||
) : (
|
||||
<div className="svc-grid" style={{ padding: 16 }}>
|
||||
{data.services.map((s) => (
|
||||
<ServiceCard key={s.id} s={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ s }: { s: Service }) {
|
||||
const noteLabel = serviceNoteLabel(s.kind);
|
||||
return (
|
||||
<div className={`svc-item${s.active ? "" : " inactive"}`}>
|
||||
<div className="svc-head">
|
||||
<span className="svc-kind">
|
||||
<span className="svc-glyph" aria-hidden>
|
||||
{serviceKindGlyph(s.kind)}
|
||||
</span>
|
||||
{serviceKindLabel(s.kind)}
|
||||
</span>
|
||||
{!s.active && <span className="badge badge-neutral">Inactivo</span>}
|
||||
</div>
|
||||
<div className="svc-detail">
|
||||
{s.accountNumber && (
|
||||
<span>
|
||||
Cuenta: <span className="mono">{s.accountNumber}</span>
|
||||
</span>
|
||||
)}
|
||||
{s.meterNumber && (
|
||||
<span>
|
||||
Medidor: <span className="mono">{s.meterNumber}</span>
|
||||
</span>
|
||||
)}
|
||||
{s.route && (
|
||||
<span>
|
||||
Ruta: <span className="mono">{s.route}</span>
|
||||
</span>
|
||||
)}
|
||||
{s.dueDay && <span>Día de pago: {s.dueDay}</span>}
|
||||
{s.notes && (
|
||||
<span>
|
||||
{noteLabel ? `${noteLabel}: ` : ""}
|
||||
{s.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Fideicomiso */
|
||||
function FideicomisoSection({ data }: { data: PropertyDetail }) {
|
||||
const t = data.trustAccount;
|
||||
const phrase = expiryPhrase(data.daysToTrustDue);
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="cuenta" title="Fideicomiso" />
|
||||
<div className="card">
|
||||
{!t ? (
|
||||
<div className="empty-inline">
|
||||
Esta propiedad no tiene fideicomiso registrado.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="kv-grid">
|
||||
<KV label="Banco" value={t.bankName} />
|
||||
<KV label="Número de fideicomiso" value={t.trustNumber} />
|
||||
<KV
|
||||
label="Comisión anual"
|
||||
value={formatMoney(t.bankFee, "MXN")}
|
||||
/>
|
||||
<KV label="Vigencia desde" value={formatDate(t.dueDate1)} />
|
||||
<KV
|
||||
label="Próximo vencimiento"
|
||||
value={
|
||||
t.dueDate2
|
||||
? `${formatDate(t.dueDate2)}${phrase ? ` · ${phrase}` : ""}`
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<KV
|
||||
label="Estado"
|
||||
value={trustStatusLabel(data.trustStatus)}
|
||||
/>
|
||||
</div>
|
||||
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||
La comisión bancaria se cobra cada año en el próximo
|
||||
vencimiento; el sistema anterior guardaba el par de fechas
|
||||
(vence1 / vence2) del periodo en curso y del siguiente.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Póliza */
|
||||
function PolizaSection({ data }: { data: PropertyDetail }) {
|
||||
const p = data.policy!;
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="seguros" title="Póliza vinculada" />
|
||||
<div className="card">
|
||||
<Link href={`/polizas/${p.id}`} className="owner-link">
|
||||
<div>
|
||||
<div className="owner-name mono">{p.policyNumber || "—"}</div>
|
||||
<div className="cust-sub">
|
||||
{p.policyType?.name && <span>{p.policyType.name}</span>}
|
||||
{p.policyTo && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>Vence {formatDate(p.policyTo)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="owner-cta">Ver póliza →</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Movimientos */
|
||||
function MovimientosSection({ data }: { data: PropertyDetail }) {
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="cuenta"
|
||||
title="Movimientos de servicios"
|
||||
count={data.customerTransactions.length}
|
||||
countSuffix="recientes"
|
||||
/>
|
||||
|
||||
{data.customerLedger.length > 0 && (
|
||||
<div className="summary-grid">
|
||||
{data.customerLedger.map((row) => (
|
||||
<div className="summary-card UTILITY" key={row.currency}>
|
||||
<div className="summary-domain">
|
||||
<span className="tx-dot UTILITY" />
|
||||
Servicios · {row.currency}
|
||||
</div>
|
||||
<div className="summary-total">
|
||||
{formatMoney(row.total, row.currency)}
|
||||
</div>
|
||||
<div className="summary-count">
|
||||
{formatNumber(row.count)}{" "}
|
||||
{row.count === 1 ? "movimiento" : "movimientos"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
{data.customerTransactions.length === 0 ? (
|
||||
<div className="empty-inline">Sin movimientos de servicios.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Tipo</th>
|
||||
<th>Periodo</th>
|
||||
<th>Referencia</th>
|
||||
<th className="num">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.customerTransactions.map((t) => (
|
||||
<TxRow key={t.id} t={t} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||
Los movimientos pertenecen al cliente, no a esta propiedad: el
|
||||
sistema anterior nunca ligó un pago a una propiedad concreta. Ver el{" "}
|
||||
<Link
|
||||
href={`/estado-cuenta/${data.customerId}`}
|
||||
className="inline-link"
|
||||
>
|
||||
estado de cuenta completo
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TxRow({ t }: { t: Transaction }) {
|
||||
const num = t.amount != null ? Number(t.amount) : NaN;
|
||||
const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos";
|
||||
return (
|
||||
<tr>
|
||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(t.transactionDate)}
|
||||
</td>
|
||||
<td>{t.type?.nameEs || t.type?.nameEn || "—"}</td>
|
||||
<td>{t.period || "—"}</td>
|
||||
<td className="tx-ref">{t.reference || "—"}</td>
|
||||
<td className="num">
|
||||
<span className={`tx-amount ${sign}`}>
|
||||
{formatMoney(t.amount, t.currency)}
|
||||
</span>{" "}
|
||||
<span className="tx-cur">{t.currency}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- Documentos */
|
||||
function DocumentosSection({ data }: { data: PropertyDetail }) {
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="docs" title="Documentos" count={data.documents.length} />
|
||||
<div className="card">
|
||||
{data.documents.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
No hay documentos registrados para esta propiedad.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="doc-list">
|
||||
{data.documents.map((d, i) => (
|
||||
<div className="doc-item" key={d.id ?? i}>
|
||||
<span className="doc-icon" aria-hidden>
|
||||
▤
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="doc-type">{d.documentType || "Documento"}</div>
|
||||
<div className="doc-key">{d.storageKey || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||
Los archivos se almacenan en el object storage (storageKey); no se
|
||||
descargan desde esta vista.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ helpers */
|
||||
function KV({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="kv-label">{label}</div>
|
||||
<div className="kv-value">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({
|
||||
rule,
|
||||
title,
|
||||
count,
|
||||
countSuffix,
|
||||
}: {
|
||||
rule: string;
|
||||
title: string;
|
||||
count?: number;
|
||||
countSuffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-head">
|
||||
<span className={`section-rule ${rule}`} aria-hidden />
|
||||
<h2 className="section-title">{title}</h2>
|
||||
{count != null && (
|
||||
<span className="section-count">
|
||||
{count} {countSuffix ?? ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 16, width: 160, marginBottom: 18 }}
|
||||
/>
|
||||
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 160, borderRadius: 16, marginTop: 34 }}
|
||||
/>
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 240, borderRadius: 16, marginTop: 34 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
getPropertyStats,
|
||||
listProperties,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
expiryPhrase,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
serviceKindGlyph,
|
||||
serviceKindLabel,
|
||||
SIN_NOMBRE,
|
||||
trustStatusLabel,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
PropertyFacets,
|
||||
PropertyListItem,
|
||||
PropertyListResponse,
|
||||
PropertySort,
|
||||
PropertyStats,
|
||||
ServiceKind,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The buckets staff actually work from. Trust (fideicomiso) renewals are the
|
||||
* recurring deadline in this line of business, so they get first-class filters
|
||||
* next to the "nothing enrolled yet" bucket that flags incomplete records.
|
||||
*/
|
||||
type Focus = "all" | "trust" | "expiring" | "expired" | "no_services";
|
||||
|
||||
const FOCUS_FILTERS: { key: Focus; label: string }[] = [
|
||||
{ key: "all", label: "Todas" },
|
||||
{ key: "expiring", label: "Fideicomiso por vencer" },
|
||||
{ key: "expired", label: "Fideicomiso vencido" },
|
||||
{ key: "trust", label: "Con fideicomiso" },
|
||||
{ key: "no_services", label: "Sin servicios" },
|
||||
];
|
||||
|
||||
const SORTS: { key: PropertySort; label: string }[] = [
|
||||
{ key: "customer", label: "Cliente (A–Z)" },
|
||||
{ key: "address", label: "Dirección (A–Z)" },
|
||||
{ key: "services_desc", label: "Más servicios" },
|
||||
{
|
||||
key: "trust_due_asc",
|
||||
label: "Vencimiento de fideicomiso (solo con fideicomiso)",
|
||||
},
|
||||
{
|
||||
key: "trust_due_desc",
|
||||
label: "Vencimiento más lejano (solo con fideicomiso)",
|
||||
},
|
||||
];
|
||||
|
||||
/** Focus bucket → the query the API understands. */
|
||||
function focusQuery(focus: Focus) {
|
||||
switch (focus) {
|
||||
case "trust":
|
||||
return { trust: "with" as const };
|
||||
case "expiring":
|
||||
return { trust: "expiring" as const };
|
||||
case "expired":
|
||||
return { trust: "expired" as const };
|
||||
case "no_services":
|
||||
return { hasServices: false };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export default function ServiciosPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<ServiciosBrowser />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiciosBrowser() {
|
||||
const canCreate = useCan("property:create");
|
||||
const [stats, setStats] = useState<PropertyStats | null>(null);
|
||||
const [facets, setFacets] = useState<PropertyFacets | null>(null);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [focus, setFocus] = useState<Focus>("all");
|
||||
const [serviceKind, setServiceKind] = useState<ServiceKind | "">("");
|
||||
const [municipality, setMunicipality] = useState("");
|
||||
const [bank, setBank] = useState("");
|
||||
const [sort, setSort] = useState<PropertySort>("customer");
|
||||
|
||||
const [data, setData] = useState<PropertyListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
getPropertyStats().then(setStats).catch(() => setStats(null));
|
||||
getPropertyFacets().then(setFacets).catch(() => setFacets(null));
|
||||
}, []);
|
||||
|
||||
const runSearch = useCallback(
|
||||
(p: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
listProperties({
|
||||
query: query || undefined,
|
||||
serviceKind: serviceKind || undefined,
|
||||
municipality: municipality || undefined,
|
||||
bank: bank || undefined,
|
||||
sort,
|
||||
days: EXPIRY_WINDOW_DAYS,
|
||||
page: p,
|
||||
pageSize: 25,
|
||||
...focusQuery(focus),
|
||||
})
|
||||
.then((res) => {
|
||||
setData(res);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e?.message ?? "No se pudieron cargar las propiedades.");
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[query, focus, serviceKind, municipality, bank, sort],
|
||||
);
|
||||
|
||||
// Debounced re-query whenever any filter changes; always back to page 1.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [runSearch]);
|
||||
|
||||
/** Picking a renewal bucket also switches the sort to due-date order —
|
||||
* a list of renewals sorted by customer name isn't a worklist. */
|
||||
function pickFocus(f: Focus) {
|
||||
setFocus(f);
|
||||
if (f === "expiring" || f === "expired") setSort("trust_due_asc");
|
||||
else if (sort === "trust_due_asc" || sort === "trust_due_desc")
|
||||
setSort("customer");
|
||||
}
|
||||
|
||||
function goToPage(p: number) {
|
||||
runSearch(p);
|
||||
if (typeof window !== "undefined")
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const filtered =
|
||||
query !== "" ||
|
||||
focus !== "all" ||
|
||||
serviceKind !== "" ||
|
||||
municipality !== "" ||
|
||||
bank !== "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head rise">
|
||||
<p className="eyebrow">Administración de servicios</p>
|
||||
<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}
|
||||
serviceKind={serviceKind}
|
||||
onPickKind={(k) => setServiceKind(k === serviceKind ? "" : k)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="search-box">
|
||||
<span className="search-icon" aria-hidden>
|
||||
⌕
|
||||
</span>
|
||||
<input
|
||||
className="input search-input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar por dirección, cliente, cuenta, medidor, fideicomiso…"
|
||||
aria-label="Buscar propiedades"
|
||||
/>
|
||||
</div>
|
||||
<div className="seg" role="tablist" aria-label="Filtrar propiedades">
|
||||
{FOCUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={focus === f.key}
|
||||
className={`seg-btn ${focus === f.key ? "active" : ""}`}
|
||||
onClick={() => pickFocus(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Servicio</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={serviceKind}
|
||||
onChange={(e) => setServiceKind(e.target.value as ServiceKind | "")}
|
||||
>
|
||||
<option value="">Todos los servicios</option>
|
||||
{facets?.kinds.map((k) => (
|
||||
<option key={k.kind} value={k.kind}>
|
||||
{serviceKindLabel(k.kind)} ({formatNumber(k.count)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Municipio</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={municipality}
|
||||
onChange={(e) => setMunicipality(e.target.value)}
|
||||
>
|
||||
<option value="">Todos los municipios</option>
|
||||
{facets?.municipalities.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.name} ({formatNumber(m.count)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Banco (fideicomiso)</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={bank}
|
||||
onChange={(e) => setBank(e.target.value)}
|
||||
>
|
||||
<option value="">Todos los bancos</option>
|
||||
{facets?.banks.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} ({formatNumber(b.count)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Ordenar por</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as PropertySort)}
|
||||
>
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{filtered && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost filter-clear"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setFocus("all");
|
||||
setServiceKind("");
|
||||
setMunicipality("");
|
||||
setBank("");
|
||||
setSort("customer");
|
||||
}}
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && !loading && !error && (
|
||||
<div className="result-meta" aria-live="polite">
|
||||
{data.total === 0
|
||||
? "Sin resultados"
|
||||
: `${formatNumber(data.total)} ${
|
||||
data.total === 1 ? "propiedad" : "propiedades"
|
||||
}`}
|
||||
{query ? ` para “${query}”` : ""}
|
||||
{(sort === "trust_due_asc" || sort === "trust_due_desc") &&
|
||||
" · solo propiedades con fideicomiso"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="state-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<ListSkeleton />
|
||||
) : data && data.items.length === 0 ? (
|
||||
<EmptyState query={query} />
|
||||
) : (
|
||||
<>
|
||||
<div className="cust-list">
|
||||
{data?.items.map((p) => (
|
||||
<PropertyRow key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
{data && data.pageCount > 1 && (
|
||||
<Pager
|
||||
page={data.page}
|
||||
pageCount={data.pageCount}
|
||||
onChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Counts double as filter shortcuts — clicking a cell applies that bucket. */
|
||||
function StatStrip({
|
||||
stats,
|
||||
focus,
|
||||
onPickFocus,
|
||||
}: {
|
||||
stats: PropertyStats | null;
|
||||
focus: Focus;
|
||||
onPickFocus: (f: Focus) => void;
|
||||
}) {
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="stat-strip" aria-hidden>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div className="stat-cell" key={i}>
|
||||
<div className="skeleton" style={{ height: 25, width: "60%" }} />
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 11, width: "80%", marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cells: {
|
||||
key: Focus;
|
||||
value: number;
|
||||
label: string;
|
||||
accent?: boolean;
|
||||
}[] = [
|
||||
{ key: "all", value: stats.properties, label: "Propiedades", accent: true },
|
||||
{
|
||||
key: "expiring",
|
||||
value: stats.trustExpiring,
|
||||
label: `Fideicomisos en ${stats.days} días`,
|
||||
accent: true,
|
||||
},
|
||||
{ key: "expired", value: stats.trustExpired, label: "Fideicomisos vencidos" },
|
||||
{ key: "trust", value: stats.trusts, label: "Con fideicomiso" },
|
||||
{ key: "no_services", value: stats.withoutServices, label: "Sin servicios" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="stat-strip">
|
||||
{cells.map((c) => (
|
||||
<button
|
||||
type="button"
|
||||
key={c.label}
|
||||
className={`stat-cell stat-cell-btn${c.accent ? " accent" : ""}${
|
||||
focus === c.key ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => onPickFocus(c.key)}
|
||||
aria-pressed={focus === c.key}
|
||||
>
|
||||
<div className="stat-value">{formatNumber(c.value)}</div>
|
||||
<div className="stat-label">{c.label}</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="stat-cell">
|
||||
<div className="stat-value">{formatNumber(stats.services)}</div>
|
||||
<div className="stat-label">Servicios · {formatNumber(stats.owners)} clientes</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The monthly workload, per service type — also the fastest kind filter. */
|
||||
function ServiceMixStrip({
|
||||
stats,
|
||||
serviceKind,
|
||||
onPickKind,
|
||||
}: {
|
||||
stats: PropertyStats | null;
|
||||
serviceKind: ServiceKind | "";
|
||||
onPickKind: (k: ServiceKind) => void;
|
||||
}) {
|
||||
if (!stats || stats.byKind.length === 0) return null;
|
||||
return (
|
||||
<div className="mix-strip">
|
||||
<span className="premium-caption">Servicios administrados</span>
|
||||
{stats.byKind.map((k) => (
|
||||
<button
|
||||
type="button"
|
||||
key={k.kind}
|
||||
className={`mix-chip${serviceKind === k.kind ? " selected" : ""}`}
|
||||
onClick={() => onPickKind(k.kind)}
|
||||
aria-pressed={serviceKind === k.kind}
|
||||
>
|
||||
<span className="mix-glyph" aria-hidden>
|
||||
{serviceKindGlyph(k.kind)}
|
||||
</span>
|
||||
<span className="mix-body">
|
||||
<strong>{formatNumber(k.count)}</strong>
|
||||
<span className="mix-label">{serviceKindLabel(k.kind)}</span>
|
||||
</span>
|
||||
{k.active < k.count && (
|
||||
<span className="mix-inactive">
|
||||
{formatNumber(k.count - k.active)} inactivos
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyRow({ p }: { p: PropertyListItem }) {
|
||||
const addr = [p.addressLine1, p.addressLine2].filter(Boolean).join(", ");
|
||||
const location = [p.customerCity?.replace(/,\s*$/, ""), p.customerState]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const phrase = p.trust ? expiryPhrase(p.trust.daysToDue) : null;
|
||||
|
||||
return (
|
||||
<Link href={`/servicios/${p.id}`} className="cust-row prop-row">
|
||||
<div className="cust-main">
|
||||
<div className="cust-name">
|
||||
<span>{addr || "Propiedad sin dirección"}</span>
|
||||
{p.municipality && (
|
||||
<span className="badge badge-servicios">
|
||||
<span className="dot" /> {p.municipality}
|
||||
</span>
|
||||
)}
|
||||
{p.trust && (
|
||||
<span className={`badge status-${p.trust.status}`}>
|
||||
Fideicomiso · {trustStatusLabel(p.trust.status)}
|
||||
</span>
|
||||
)}
|
||||
{p.serviceCount === 0 && (
|
||||
<span className="badge badge-neutral">Sin servicios</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cust-sub">
|
||||
<span
|
||||
className={
|
||||
p.customerName === SIN_NOMBRE ? "cust-name-missing" : undefined
|
||||
}
|
||||
>
|
||||
{p.customerName}
|
||||
</span>
|
||||
{location && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>{location}</span>
|
||||
</>
|
||||
)}
|
||||
{p.zone && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>Zona {p.zone}</span>
|
||||
</>
|
||||
)}
|
||||
{p.phones.length > 0 && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span className="mono">{p.phones[0]}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="prop-side">
|
||||
<div className="svc-chips">
|
||||
{p.services.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={`svc-chip${s.active ? "" : " inactive"}`}
|
||||
title={`${serviceKindLabel(s.kind)}${s.active ? "" : " (inactivo)"}`}
|
||||
>
|
||||
<span aria-hidden>{serviceKindGlyph(s.kind)}</span>
|
||||
<span className="sr-only">{serviceKindLabel(s.kind)}</span>
|
||||
</span>
|
||||
))}
|
||||
{p.services.length === 0 && <span className="muted">—</span>}
|
||||
</div>
|
||||
{p.trust?.dueDate2 && (
|
||||
<>
|
||||
<div className="pol-dates mono">
|
||||
Vence {formatDate(p.trust.dueDate2)}
|
||||
</div>
|
||||
{phrase && (
|
||||
<div className={`pol-phrase ${p.trust.status}`}>{phrase}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({
|
||||
page,
|
||||
pageCount,
|
||||
onChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="pager" aria-label="Paginación">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
Página <strong>{page}</strong> de {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={page >= pageCount}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className="cust-list" aria-hidden>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div className="skeleton skel-row" key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ query }: { query: string }) {
|
||||
return (
|
||||
<div className="state-box">
|
||||
<div className="state-glyph" aria-hidden>
|
||||
⌕
|
||||
</div>
|
||||
<h3>Sin resultados</h3>
|
||||
<p>
|
||||
{query
|
||||
? `No encontramos propiedades para “${query}”.`
|
||||
: "No hay propiedades que coincidan con los filtros."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useAuth, useCan } from "@/lib/abilities";
|
||||
import { ROLE_LABEL, ROLES_DESC } from "@/lib/labels";
|
||||
import {
|
||||
createUser,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
} from "@/lib/api";
|
||||
import type { Role, UserRow } from "@/lib/types";
|
||||
|
||||
export default function UsuariosPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<UsuariosAdmin />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "STAFF",
|
||||
active: true,
|
||||
};
|
||||
|
||||
function UsuariosAdmin() {
|
||||
const me = useAuth();
|
||||
const allowed = useCan("user:manage");
|
||||
|
||||
const [users, setUsers] = useState<UserRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
// null = create mode; a user id = editing that row.
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Inline "reset password" target + value.
|
||||
const [pwTarget, setPwTarget] = useState<string | null>(null);
|
||||
const [pwValue, setPwValue] = useState("");
|
||||
|
||||
function refresh() {
|
||||
listUsers()
|
||||
.then(setUsers)
|
||||
.catch((e) => setError(e?.message ?? "No se pudieron cargar los usuarios."));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (allowed) refresh();
|
||||
}, [allowed]);
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="page-head">
|
||||
<h1 className="page-title">Usuarios</h1>
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para administrar usuarios.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function startEdit(u: UserRow) {
|
||||
setEditingId(u.id);
|
||||
setForm({ name: u.name, email: u.email, password: "", role: u.role, active: u.active });
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (editingId) {
|
||||
await updateUser(editingId, {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
});
|
||||
setNotice("Usuario actualizado.");
|
||||
} else {
|
||||
await createUser({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
});
|
||||
setNotice("Usuario creado.");
|
||||
}
|
||||
startCreate();
|
||||
refresh();
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo guardar el usuario.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPassword(id: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await resetUserPassword(id, pwValue);
|
||||
setPwTarget(null);
|
||||
setPwValue("");
|
||||
setNotice("Contraseña restablecida.");
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo restablecer la contraseña.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<h1 className="page-title">Usuarios</h1>
|
||||
</div>
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
{notice && <div className="state-box">{notice}</div>}
|
||||
|
||||
{/* Create / edit form */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 4 }}>
|
||||
{editingId ? "Editar usuario" : "Nuevo usuario"}
|
||||
</h2>
|
||||
<p className="inline-form-note">
|
||||
El rol define el acceso: Solo lectura no puede escribir; Personal y
|
||||
superior sí. Administrador gestiona usuarios.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Nombre</span>
|
||||
<input
|
||||
className="input"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Correo</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{!editingId && (
|
||||
<label className="field">
|
||||
<span className="field-label">Contraseña (mín. 8)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
<span className="field-label">Rol</span>
|
||||
<select
|
||||
className="select"
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as Role })}
|
||||
>
|
||||
{ROLES_DESC.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{ROLE_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field" style={{ justifyContent: "flex-end" }}>
|
||||
<span className="field-label">Activo</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
disabled={editingId === me?.id}
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? "Guardando…" : editingId ? "Guardar cambios" : "Crear usuario"}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" className="btn btn-outline" onClick={startCreate}>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="card">
|
||||
{users === null ? (
|
||||
<div className="empty-inline">
|
||||
<span className="spinner" aria-label="Cargando" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-inline">Sin usuarios.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Correo</th>
|
||||
<th>Rol</th>
|
||||
<th>Estado</th>
|
||||
<th className="num">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
{u.name}
|
||||
{u.id === me?.id && (
|
||||
<span className="muted"> (usted)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{u.email}</td>
|
||||
<td>
|
||||
<span className="badge badge-neutral">{ROLE_LABEL[u.role]}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${u.active ? "badge-positive" : "badge-negative"}`}
|
||||
>
|
||||
{u.active ? "Activo" : "Inactivo"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{pwTarget === u.id ? (
|
||||
<div className="row-actions">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Nueva contraseña"
|
||||
minLength={8}
|
||||
value={pwValue}
|
||||
onChange={(e) => setPwValue(e.target.value)}
|
||||
style={{ maxWidth: 180 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
disabled={pwValue.length < 8}
|
||||
onClick={() => submitPassword(u.id)}
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPwTarget(null);
|
||||
setPwValue("");
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
onClick={() => startEdit(u)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPwTarget(u.id);
|
||||
setPwValue("");
|
||||
}}
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { logout, me } from "@/lib/api";
|
||||
import { AuthContext, can } from "@/lib/abilities";
|
||||
import { ROLE_LABEL } from "@/lib/labels";
|
||||
import type { AuthUser, Ability } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Authenticated shell: gates on /auth/me, redirects to /login when the
|
||||
* session is missing, renders the brand header + logout, and wraps page
|
||||
* content. Provides the AuthContext so any page can read the user's
|
||||
* abilities. Used by every authenticated page.
|
||||
*/
|
||||
const NAV: { href: string; label: string; ability?: Ability }[] = [
|
||||
{ href: "/clientes", label: "Clientes" },
|
||||
{ href: "/servicios", label: "Propiedades" },
|
||||
{ href: "/polizas", label: "Pólizas" },
|
||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||
];
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
me()
|
||||
.then((u) => {
|
||||
if (alive) {
|
||||
setUser(u);
|
||||
setChecking(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
router.replace("/login");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
async function handleLogout() {
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
/* ignore — we redirect regardless */
|
||||
}
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "var(--brand-700)",
|
||||
}}
|
||||
>
|
||||
<span className="spinner" aria-label="Cargando" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={user}>
|
||||
<header className="appbar">
|
||||
<div className="appbar-inner">
|
||||
<Link href="/clientes" className="brand">
|
||||
<span className="brand-mark" aria-hidden>
|
||||
JC
|
||||
</span>
|
||||
<span className="brand-text">
|
||||
<span className="brand-name">Jorge Cuadros</span>
|
||||
<span className="brand-sub">& Asociados</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="appbar-nav" aria-label="Principal">
|
||||
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
||||
(item) => {
|
||||
const active = pathname?.startsWith(item.href) ?? false;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`appbar-link${active ? " active" : ""}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</nav>
|
||||
<span className="appbar-spacer" />
|
||||
<div className="appbar-user">
|
||||
{user && (
|
||||
<span className="appbar-user-name">
|
||||
{user.name}
|
||||
<span className="appbar-user-role">{ROLE_LABEL[user.role]}</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
>
|
||||
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="shell-main">{children}</main>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/** A single editable field in a child row. */
|
||||
export type FieldDef = {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: "text" | "number" | "date" | "checkbox" | "select";
|
||||
options?: { value: string; label: string }[];
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export type ChildConfig = {
|
||||
/** URL segment: installments | vehicles | drivers | beneficiaries | claims */
|
||||
apiKind: string;
|
||||
title: string;
|
||||
fields: FieldDef[];
|
||||
};
|
||||
|
||||
type RowValues = Record<string, string | boolean>;
|
||||
|
||||
function toDateInput(v: unknown): string {
|
||||
if (!v || typeof v !== "string") return "";
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Build editable values for a field from an existing row (or blank). */
|
||||
function rowToValues(fields: FieldDef[], row?: Record<string, unknown>): RowValues {
|
||||
const v: RowValues = {};
|
||||
for (const f of fields) {
|
||||
const raw = row?.[f.key];
|
||||
if (f.type === "checkbox") v[f.key] = !!raw;
|
||||
else if (f.type === "date") v[f.key] = toDateInput(raw);
|
||||
else v[f.key] = raw == null ? "" : String(raw);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Coerce editable values into an API payload (numbers/blanks handled). */
|
||||
function valuesToPayload(fields: FieldDef[], v: RowValues): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
const val = v[f.key];
|
||||
if (f.type === "checkbox") out[f.key] = !!val;
|
||||
else if (f.type === "number") {
|
||||
const s = String(val).trim();
|
||||
out[f.key] = s === "" ? undefined : Number(s);
|
||||
} else {
|
||||
const s = String(val).trim();
|
||||
out[f.key] = s === "" ? undefined : s;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic add/edit/remove editor for a policy's child collection. The parent
|
||||
* owns the API calls (so it can reload the policy afterward); this component is
|
||||
* pure UI over `rows` plus add/save/remove callbacks.
|
||||
*/
|
||||
export function ChildCollection({
|
||||
config,
|
||||
rows,
|
||||
canEdit,
|
||||
onAdd,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
config: ChildConfig;
|
||||
rows: Record<string, unknown>[];
|
||||
canEdit: boolean;
|
||||
onAdd: (payload: Record<string, unknown>) => Promise<void>;
|
||||
onSave: (id: string, payload: Record<string, unknown>) => Promise<void>;
|
||||
onRemove: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [values, setValues] = useState<RowValues>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function startAdd() {
|
||||
setEditingId(null);
|
||||
setAdding(true);
|
||||
setValues(rowToValues(config.fields));
|
||||
}
|
||||
function startEdit(row: Record<string, unknown>) {
|
||||
setAdding(false);
|
||||
setEditingId(String(row.id));
|
||||
setValues(rowToValues(config.fields, row));
|
||||
}
|
||||
function cancel() {
|
||||
setAdding(false);
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = valuesToPayload(config.fields, values);
|
||||
if (editingId) await onSave(editingId, payload);
|
||||
else await onAdd(payload);
|
||||
cancel();
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo guardar.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!window.confirm("¿Eliminar este registro?")) return;
|
||||
try {
|
||||
await onRemove(id);
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
||||
}
|
||||
}
|
||||
|
||||
const colCount = config.fields.length + (canEdit ? 1 : 0);
|
||||
|
||||
function editorRow() {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colCount}>{editor()}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function editor() {
|
||||
return (
|
||||
<div className="child-editor">
|
||||
<div className="form-grid">
|
||||
{config.fields.map((f) => (
|
||||
<label className="field" key={f.key}>
|
||||
<span className="field-label">{f.label}</span>
|
||||
{f.type === "checkbox" ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!values[f.key]}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.checked })}
|
||||
/>
|
||||
) : f.type === "select" ? (
|
||||
<select
|
||||
className="select"
|
||||
value={String(values[f.key] ?? "")}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{f.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
|
||||
step={f.type === "number" ? "0.01" : undefined}
|
||||
value={String(values[f.key] ?? "")}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={busy}>
|
||||
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="child-head">
|
||||
<h3 className="section-title" style={{ margin: 0 }}>
|
||||
{config.title}
|
||||
<span className="section-count"> {rows.length}</span>
|
||||
</h3>
|
||||
{canEdit && !adding && editingId === null && (
|
||||
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||
+ Agregar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 && !adding ? (
|
||||
<div className="empty-inline">Sin registros.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{config.fields.map((f) => (
|
||||
<th key={f.key}>{f.label}</th>
|
||||
))}
|
||||
{canEdit && <th className="num">Acciones</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adding && editorRow()}
|
||||
{rows.map((row) =>
|
||||
editingId === String(row.id) ? (
|
||||
<tr key={String(row.id)}>
|
||||
<td colSpan={colCount}>{editor()}</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={String(row.id)}>
|
||||
{config.fields.map((f) => (
|
||||
<td key={f.key}>{cellText(f, row[f.key])}</td>
|
||||
))}
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => startEdit(row)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => remove(String(row.id))}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cellText(f: FieldDef, raw: unknown): string {
|
||||
if (f.type === "checkbox") return raw ? "Sí" : "No";
|
||||
if (f.type === "date") return toDateInput(raw) || "—";
|
||||
if (f.type === "select") {
|
||||
const opt = f.options?.find((o) => o.value === String(raw));
|
||||
return opt ? opt.label : "—";
|
||||
}
|
||||
return raw == null || raw === "" ? "—" : String(raw);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types";
|
||||
import { createCustomer, updateCustomer } from "@/lib/api";
|
||||
|
||||
/** ISO date (yyyy-mm-dd) for a date input, from an API date string. */
|
||||
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 numOrUndef(v: string | number | null | undefined): number | undefined {
|
||||
if (v === null || v === undefined || v === "") return undefined;
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
type Values = {
|
||||
name: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
mobile: string;
|
||||
fax: string;
|
||||
email: string;
|
||||
identificationType: string;
|
||||
identificationNumber: string;
|
||||
identificationExpiration: string;
|
||||
customerSince: string;
|
||||
preferredCurrency: Currency;
|
||||
minimumBalance: string;
|
||||
feeAmount: string;
|
||||
status: boolean;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
function initial(c?: CustomerDetail): Values {
|
||||
return {
|
||||
name: c?.name ?? "",
|
||||
addressLine1: c?.addressLine1 ?? "",
|
||||
addressLine2: c?.addressLine2 ?? "",
|
||||
city: c?.city ?? "",
|
||||
state: c?.state ?? "",
|
||||
zipCode: c?.zipCode ?? "",
|
||||
country: c?.country ?? "",
|
||||
phone: c?.phone ?? "",
|
||||
mobile: c?.mobile ?? "",
|
||||
fax: c?.fax ?? "",
|
||||
email: c?.email ?? "",
|
||||
identificationType: c?.identificationType ?? "",
|
||||
identificationNumber: c?.identificationNumber ?? "",
|
||||
identificationExpiration: toDateInput(c?.identificationExpiration),
|
||||
customerSince: toDateInput(c?.customerSince),
|
||||
preferredCurrency: (c?.preferredCurrency as Currency) ?? "USD",
|
||||
minimumBalance: c?.minimumBalance != null ? String(c.minimumBalance) : "",
|
||||
feeAmount: c?.feeAmount != null ? String(c.feeAmount) : "",
|
||||
status: c?.status ?? true,
|
||||
notes: c?.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Empty string -> undefined so we don't send blanks as real values. */
|
||||
function s(v: string): string | undefined {
|
||||
const t = v.trim();
|
||||
return t === "" ? undefined : t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared create/edit form. When `customer` is given it edits (PATCH), otherwise
|
||||
* it creates (POST). Redirects to the customer's detail page on success.
|
||||
*/
|
||||
export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
||||
const router = useRouter();
|
||||
const editing = !!customer;
|
||||
const [v, setV] = useState<Values>(() => initial(customer));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function set<K extends keyof Values>(key: K, val: Values[K]) {
|
||||
setV((prev) => ({ ...prev, [key]: val }));
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const payload: CustomerInput = {
|
||||
name: v.name.trim(),
|
||||
addressLine1: s(v.addressLine1),
|
||||
addressLine2: s(v.addressLine2),
|
||||
city: s(v.city),
|
||||
state: s(v.state),
|
||||
zipCode: s(v.zipCode),
|
||||
country: s(v.country),
|
||||
phone: s(v.phone),
|
||||
mobile: s(v.mobile),
|
||||
fax: s(v.fax),
|
||||
email: s(v.email),
|
||||
identificationType: s(v.identificationType),
|
||||
identificationNumber: s(v.identificationNumber),
|
||||
identificationExpiration: s(v.identificationExpiration),
|
||||
customerSince: s(v.customerSince),
|
||||
preferredCurrency: v.preferredCurrency,
|
||||
minimumBalance: numOrUndef(v.minimumBalance),
|
||||
feeAmount: numOrUndef(v.feeAmount),
|
||||
status: v.status,
|
||||
notes: s(v.notes),
|
||||
};
|
||||
try {
|
||||
const saved = editing
|
||||
? await updateCustomer(customer!.id, payload)
|
||||
: await createCustomer(payload);
|
||||
router.push(`/clientes/${saved.id}`);
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo guardar el cliente.");
|
||||
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 }}>Identidad</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Nombre" required>
|
||||
<input className="input" required value={v.name}
|
||||
onChange={(e) => set("name", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Correo">
|
||||
<input className="input" type="email" value={v.email}
|
||||
onChange={(e) => set("email", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Teléfono">
|
||||
<input className="input" value={v.phone}
|
||||
onChange={(e) => set("phone", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Celular">
|
||||
<input className="input" value={v.mobile}
|
||||
onChange={(e) => set("mobile", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Fax">
|
||||
<input className="input" value={v.fax}
|
||||
onChange={(e) => set("fax", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Cliente desde">
|
||||
<input className="input" type="date" value={v.customerSince}
|
||||
onChange={(e) => set("customerSince", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Domicilio</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Dirección 1">
|
||||
<input className="input" value={v.addressLine1}
|
||||
onChange={(e) => set("addressLine1", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Dirección 2">
|
||||
<input className="input" value={v.addressLine2}
|
||||
onChange={(e) => set("addressLine2", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Ciudad">
|
||||
<input className="input" value={v.city}
|
||||
onChange={(e) => set("city", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Estado">
|
||||
<input className="input" value={v.state}
|
||||
onChange={(e) => set("state", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Código postal">
|
||||
<input className="input" value={v.zipCode}
|
||||
onChange={(e) => set("zipCode", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="País">
|
||||
<input className="input" value={v.country}
|
||||
onChange={(e) => set("country", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
||||
Identificación y cuenta
|
||||
</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Tipo de identificación">
|
||||
<input className="input" value={v.identificationType}
|
||||
onChange={(e) => set("identificationType", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Número de identificación">
|
||||
<input className="input" value={v.identificationNumber}
|
||||
onChange={(e) => set("identificationNumber", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Vence identificación">
|
||||
<input className="input" type="date" value={v.identificationExpiration}
|
||||
onChange={(e) => set("identificationExpiration", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Moneda preferida">
|
||||
<select className="select" value={v.preferredCurrency}
|
||||
onChange={(e) => set("preferredCurrency", e.target.value as Currency)}>
|
||||
<option value="USD">USD</option>
|
||||
<option value="MXN">MXN</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Saldo mínimo">
|
||||
<input className="input" type="number" step="0.01" value={v.minimumBalance}
|
||||
onChange={(e) => set("minimumBalance", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Cuota">
|
||||
<input className="input" type="number" step="0.01" value={v.feeAmount}
|
||||
onChange={(e) => set("feeAmount", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Activo">
|
||||
<input type="checkbox" checked={v.status}
|
||||
onChange={(e) => set("status", e.target.checked)} />
|
||||
</Field>
|
||||
</div>
|
||||
<label className="field" style={{ marginTop: 16 }}>
|
||||
<span className="field-label">Notas</span>
|
||||
<textarea className="input" rows={3} value={v.notes}
|
||||
onChange={(e) => set("notes", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? "Guardando…" : editing ? "Guardar cambios" : "Crear cliente"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => router.back()}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
{label}
|
||||
{required && <span aria-hidden> *</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { listCustomers } from "@/lib/api";
|
||||
import type { CustomerListItem } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Debounced customer search + select. Reports the chosen customer's id and name
|
||||
* upward. Used when creating a policy that isn't started from a customer page.
|
||||
*/
|
||||
export function CustomerPicker({
|
||||
value,
|
||||
valueName,
|
||||
onPick,
|
||||
}: {
|
||||
value: string;
|
||||
valueName?: string;
|
||||
onPick: (id: string, name: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<CustomerListItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const debounce = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (debounce.current) clearTimeout(debounce.current);
|
||||
if (query.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
debounce.current = setTimeout(() => {
|
||||
listCustomers({ query, pageSize: 8 })
|
||||
.then((r) => {
|
||||
setResults(r.items);
|
||||
setOpen(true);
|
||||
})
|
||||
.catch(() => setResults([]));
|
||||
}, 260);
|
||||
return () => {
|
||||
if (debounce.current) clearTimeout(debounce.current);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="picker">
|
||||
{value ? (
|
||||
<div className="picker-selected">
|
||||
<span>{valueName ?? "Cliente seleccionado"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => onPick("", "")}
|
||||
>
|
||||
Cambiar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Buscar cliente por nombre…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => results.length && setOpen(true)}
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<ul className="picker-list">
|
||||
{results.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="picker-item"
|
||||
onClick={() => {
|
||||
onPick(c.id, c.name);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
{c.city && <span className="muted"> · {c.city}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { createMovement } from "@/lib/api";
|
||||
import type {
|
||||
CreateMovementInput,
|
||||
Currency,
|
||||
Facet,
|
||||
LedgerCurrency,
|
||||
TransactionDomain,
|
||||
} from "@/lib/types";
|
||||
|
||||
const DOMAINS: { key: TransactionDomain; label: string }[] = [
|
||||
{ key: "UTILITY", label: "Servicios" },
|
||||
{ key: "INSURANCE", label: "Seguros" },
|
||||
{ key: "TRUST", label: "Fideicomiso" },
|
||||
];
|
||||
|
||||
type Direction = "charge" | "credit";
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function s(v: string): string | undefined {
|
||||
const t = v.trim();
|
||||
return t === "" ? undefined : t;
|
||||
}
|
||||
|
||||
/** Capture form for one ledger movement. `defaultCustomer` pre-fills the picker
|
||||
* when opening from a customer's statement. `concepts` is the list of
|
||||
* transaction-type facets from the billing module. */
|
||||
export function MovementForm({
|
||||
concepts,
|
||||
defaultCurrency,
|
||||
defaultCustomer,
|
||||
defaultDomain,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
concepts: Facet[];
|
||||
defaultCurrency?: LedgerCurrency;
|
||||
defaultCustomer?: { id: string; name: string };
|
||||
defaultDomain?: TransactionDomain;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [customerId, setCustomerId] = useState(defaultCustomer?.id ?? "");
|
||||
const [customerName, setCustomerName] = useState(defaultCustomer?.name ?? "");
|
||||
const [domain, setDomain] = useState<TransactionDomain>(
|
||||
defaultDomain ?? "UTILITY",
|
||||
);
|
||||
const [direction, setDirection] = useState<Direction>("charge");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [transactionDate, setTransactionDate] = useState(today());
|
||||
const [currency, setCurrency] = useState<LedgerCurrency>(
|
||||
defaultCurrency ?? "MXN",
|
||||
);
|
||||
const [typeId, setTypeId] = useState("");
|
||||
const [period, setPeriod] = useState("");
|
||||
const [reference, setReference] = useState("");
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!customerId) {
|
||||
setError("Selecciona un cliente.");
|
||||
return;
|
||||
}
|
||||
const abs = Number(amount);
|
||||
if (!Number.isFinite(abs) || abs === 0) {
|
||||
setError("El monto debe ser un número distinto de cero.");
|
||||
return;
|
||||
}
|
||||
const signed = direction === "charge" ? -Math.abs(abs) : Math.abs(abs);
|
||||
const payload: CreateMovementInput = {
|
||||
customerId,
|
||||
domain,
|
||||
amount: signed,
|
||||
transactionDate,
|
||||
currency: currency as Currency,
|
||||
typeId: s(typeId),
|
||||
period: s(period),
|
||||
reference: s(reference),
|
||||
checkNumber: s(checkNumber),
|
||||
message: s(message),
|
||||
};
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createMovement(payload);
|
||||
onSaved();
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo guardar el movimiento.");
|
||||
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 }}>Cliente</h2>
|
||||
<CustomerPicker
|
||||
value={customerId}
|
||||
valueName={customerId ? customerName : undefined}
|
||||
onPick={(id, name) => {
|
||||
setCustomerId(id);
|
||||
setCustomerName(name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Movimiento</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Línea de negocio" required>
|
||||
<select
|
||||
className="select"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
|
||||
>
|
||||
{DOMAINS.map((d) => (
|
||||
<option key={d.key} value={d.key}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Fecha" required>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
required
|
||||
value={transactionDate}
|
||||
onChange={(e) => setTransactionDate(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Tipo" required>
|
||||
<select
|
||||
className="select"
|
||||
value={direction}
|
||||
onChange={(e) => setDirection(e.target.value as Direction)}
|
||||
>
|
||||
<option value="charge">Cargo</option>
|
||||
<option value="credit">Abono</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Monto" required>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
min="0"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Moneda" required>
|
||||
<select
|
||||
className="select"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
||||
>
|
||||
<option value="MXN">Pesos (MXN)</option>
|
||||
<option value="USD">Dólares (USD)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Concepto">
|
||||
<select
|
||||
className="select"
|
||||
value={typeId}
|
||||
onChange={(e) => setTypeId(e.target.value)}
|
||||
>
|
||||
<option value="">(sin concepto)</option>
|
||||
{concepts.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Detalles</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Periodo">
|
||||
<input
|
||||
className="input"
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
placeholder="Ej. 2025-01"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Referencia">
|
||||
<input
|
||||
className="input"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Número de cheque">
|
||||
<input
|
||||
className="input"
|
||||
value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<label className="field" style={{ marginTop: 16 }}>
|
||||
<span className="field-label">Mensaje</span>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? "Guardando…" : "Capturar movimiento"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
{label}
|
||||
{required && <span aria-hidden> *</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { createPolicy, getLookups, updatePolicy } from "@/lib/api";
|
||||
import type {
|
||||
Currency,
|
||||
LookupsResponse,
|
||||
PolicyDetail,
|
||||
PolicyInput,
|
||||
} from "@/lib/types";
|
||||
|
||||
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 numOrUndef(v: string): number | undefined {
|
||||
const t = v.trim();
|
||||
if (t === "") return undefined;
|
||||
const n = Number(t);
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
function s(v: string): string | undefined {
|
||||
const t = v.trim();
|
||||
return t === "" ? undefined : t;
|
||||
}
|
||||
|
||||
type V = {
|
||||
policyNumber: string;
|
||||
policyTypeId: string;
|
||||
insuranceProviderId: string;
|
||||
agentName: string;
|
||||
policyDate: string;
|
||||
policyFrom: string;
|
||||
policyTo: string;
|
||||
netPremium: string;
|
||||
policyFee: string;
|
||||
brokerFee: string;
|
||||
commission: string;
|
||||
currency: Currency;
|
||||
liquidated: boolean;
|
||||
liquidationNumber: string;
|
||||
liquidationDate: string;
|
||||
endorsement: boolean;
|
||||
observations: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
function initial(p?: PolicyDetail): V {
|
||||
return {
|
||||
policyNumber: p?.policyNumber ?? "",
|
||||
policyTypeId: p?.policyType?.id ?? "",
|
||||
insuranceProviderId: p?.insuranceProvider?.id ?? "",
|
||||
agentName: p?.agentName ?? "",
|
||||
policyDate: toDateInput(p?.policyDate),
|
||||
policyFrom: toDateInput(p?.policyFrom),
|
||||
policyTo: toDateInput(p?.policyTo),
|
||||
netPremium: p?.netPremium != null ? String(p.netPremium) : "",
|
||||
policyFee: p?.policyFee != null ? String(p.policyFee) : "",
|
||||
brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "",
|
||||
commission: p?.commission != null ? String(p.commission) : "",
|
||||
currency: (p?.currency as Currency) ?? "MXN",
|
||||
liquidated: p?.liquidated ?? false,
|
||||
liquidationNumber: p?.liquidationNumber ?? "",
|
||||
liquidationDate: toDateInput(p?.liquidationDate),
|
||||
endorsement: p?.endorsement ?? false,
|
||||
observations: p?.observations ?? "",
|
||||
notes: p?.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function PolicyForm({
|
||||
policy,
|
||||
fixedCustomerId,
|
||||
fixedCustomerName,
|
||||
}: {
|
||||
policy?: PolicyDetail;
|
||||
fixedCustomerId?: string;
|
||||
fixedCustomerName?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const editing = !!policy;
|
||||
const [v, setV] = useState<V>(() => initial(policy));
|
||||
const [lookups, setLookups] = useState<LookupsResponse | null>(null);
|
||||
const [customerId, setCustomerId] = useState(
|
||||
policy?.customer.id ?? fixedCustomerId ?? "",
|
||||
);
|
||||
const [customerName, setCustomerName] = useState(
|
||||
policy?.customer.name ?? fixedCustomerName ?? "",
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getLookups().then(setLookups).catch(() => setLookups(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.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const base = {
|
||||
policyNumber: v.policyNumber.trim(),
|
||||
policyTypeId: s(v.policyTypeId),
|
||||
insuranceProviderId: s(v.insuranceProviderId),
|
||||
agentName: s(v.agentName),
|
||||
policyDate: s(v.policyDate),
|
||||
policyFrom: s(v.policyFrom),
|
||||
policyTo: s(v.policyTo),
|
||||
netPremium: numOrUndef(v.netPremium),
|
||||
policyFee: numOrUndef(v.policyFee),
|
||||
brokerFee: numOrUndef(v.brokerFee),
|
||||
commission: numOrUndef(v.commission),
|
||||
currency: v.currency,
|
||||
liquidated: v.liquidated,
|
||||
liquidationNumber: s(v.liquidationNumber),
|
||||
liquidationDate: s(v.liquidationDate),
|
||||
endorsement: v.endorsement,
|
||||
observations: s(v.observations),
|
||||
notes: s(v.notes),
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
const saved = await updatePolicy(policy!.id, base);
|
||||
router.push(`/polizas/${saved.id}`);
|
||||
} else {
|
||||
const payload: PolicyInput = { ...base, customerId };
|
||||
const saved = await createPolicy(payload);
|
||||
router.push(`/polizas/${saved.id}`);
|
||||
}
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo guardar la póliza.");
|
||||
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 }}>Datos de la póliza</h2>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Cliente *</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">Número de póliza *</span>
|
||||
<input className="input" required value={v.policyNumber}
|
||||
onChange={(e) => set("policyNumber", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Tipo</span>
|
||||
<select className="select" value={v.policyTypeId}
|
||||
onChange={(e) => set("policyTypeId", e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{lookups?.types.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Aseguradora</span>
|
||||
<select className="select" value={v.insuranceProviderId}
|
||||
onChange={(e) => set("insuranceProviderId", e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{lookups?.providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Agente</span>
|
||||
<input className="input" value={v.agentName}
|
||||
onChange={(e) => set("agentName", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Moneda</span>
|
||||
<select className="select" value={v.currency}
|
||||
onChange={(e) => set("currency", e.target.value as Currency)}>
|
||||
<option value="MXN">MXN</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia y prima</h2>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Emisión</span>
|
||||
<input className="input" type="date" value={v.policyDate}
|
||||
onChange={(e) => set("policyDate", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Desde</span>
|
||||
<input className="input" type="date" value={v.policyFrom}
|
||||
onChange={(e) => set("policyFrom", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Hasta</span>
|
||||
<input className="input" type="date" value={v.policyTo}
|
||||
onChange={(e) => set("policyTo", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Prima neta</span>
|
||||
<input className="input" type="number" step="0.01" value={v.netPremium}
|
||||
onChange={(e) => set("netPremium", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Derecho de póliza</span>
|
||||
<input className="input" type="number" step="0.01" value={v.policyFee}
|
||||
onChange={(e) => set("policyFee", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Comisión</span>
|
||||
<input className="input" type="number" step="0.01" value={v.commission}
|
||||
onChange={(e) => set("commission", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Liquidación</h2>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Liquidada</span>
|
||||
<input type="checkbox" checked={v.liquidated}
|
||||
onChange={(e) => set("liquidated", e.target.checked)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Endoso</span>
|
||||
<input type="checkbox" checked={v.endorsement}
|
||||
onChange={(e) => set("endorsement", e.target.checked)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">No. liquidación</span>
|
||||
<input className="input" value={v.liquidationNumber}
|
||||
onChange={(e) => set("liquidationNumber", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Fecha liquidación</span>
|
||||
<input className="input" type="date" value={v.liquidationDate}
|
||||
onChange={(e) => set("liquidationDate", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field" style={{ marginTop: 14 }}>
|
||||
<span className="field-label">Observaciones</span>
|
||||
<textarea className="input" rows={2} value={v.observations}
|
||||
onChange={(e) => set("observations", e.target.value)} />
|
||||
</label>
|
||||
<label className="field" style={{ marginTop: 12 }}>
|
||||
<span className="field-label">Notas</span>
|
||||
<textarea className="input" rows={2} value={v.notes}
|
||||
onChange={(e) => set("notes", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? "Guardando…" : editing ? "Guardar cambios" : "Crear póliza"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => router.back()}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// UI-side permission helpers. The rules themselves live server-side
|
||||
// (apps/api/src/auth/abilities.ts) and arrive resolved on `user.abilities` via
|
||||
// /auth/me — this module just reads that map. Gating here is cosmetic (show or
|
||||
// hide a control); the API enforces every write regardless.
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Ability, AuthUser } from "./types";
|
||||
|
||||
export const AuthContext = createContext<AuthUser | null>(null);
|
||||
|
||||
/** The signed-in user (or null while loading). */
|
||||
export function useAuth(): AuthUser | null {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
/** Whether the current user may perform `ability`. False when not loaded. */
|
||||
export function useCan(ability: Ability): boolean {
|
||||
const user = useAuth();
|
||||
return user?.abilities?.[ability] ?? false;
|
||||
}
|
||||
|
||||
export function can(user: AuthUser | null, ability: Ability): boolean {
|
||||
return user?.abilities?.[ability] ?? false;
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
// Cross-origin API client. All requests send the session cookie (connect.sid)
|
||||
// via credentials: "include". The API origin comes from NEXT_PUBLIC_API_ORIGIN.
|
||||
|
||||
import type {
|
||||
AuthUser,
|
||||
BalanceFilter,
|
||||
BalanceListResponse,
|
||||
BalanceSort,
|
||||
BankCleared,
|
||||
BankDirection,
|
||||
BankFacets,
|
||||
BankListResponse,
|
||||
BankSort,
|
||||
BankStats,
|
||||
BankSummary,
|
||||
BillingFacets,
|
||||
BillingStats,
|
||||
BusinessLine,
|
||||
CreateBankMovementInput,
|
||||
CreateMovementInput,
|
||||
CustomerDetail,
|
||||
CustomerInput,
|
||||
CustomerListResponse,
|
||||
CustomerStats,
|
||||
LedgerCurrency,
|
||||
LedgerDirection,
|
||||
MovementListResponse,
|
||||
MovementSort,
|
||||
PolicyDetail,
|
||||
PolicyFacets,
|
||||
PolicyInput,
|
||||
PolicyListResponse,
|
||||
PolicySort,
|
||||
PolicyStats,
|
||||
PolicyStatus,
|
||||
LookupsResponse,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
IngestFile,
|
||||
BackupFile,
|
||||
PropertyDetail,
|
||||
PropertyFacets,
|
||||
PropertyInput,
|
||||
PropertyListResponse,
|
||||
PropertySort,
|
||||
PropertyStats,
|
||||
ServiceInput,
|
||||
TrustInput,
|
||||
Role,
|
||||
ServiceKind,
|
||||
Statement,
|
||||
Transaction,
|
||||
TransactionDomain,
|
||||
TrustFilter,
|
||||
UserRow,
|
||||
} from "./types";
|
||||
|
||||
export const API_ORIGIN =
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:3001";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_ORIGIN}${path}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new ApiError(
|
||||
0,
|
||||
"No se pudo conectar con el servidor. Verifica tu conexión.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let message = `Error ${res.status}`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.message) message = body.message;
|
||||
} catch {
|
||||
/* ignore non-JSON error bodies */
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function login(email: string, password: string): Promise<AuthUser> {
|
||||
return apiFetch<AuthUser>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function me(): Promise<AuthUser> {
|
||||
return apiFetch<AuthUser>("/auth/me");
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ success: boolean }> {
|
||||
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
export function getStats(): Promise<CustomerStats> {
|
||||
return apiFetch<CustomerStats>("/customers/stats");
|
||||
}
|
||||
|
||||
export interface CustomerQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
line?: BusinessLine;
|
||||
}
|
||||
|
||||
export function listCustomers(
|
||||
q: CustomerQuery,
|
||||
): Promise<CustomerListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.query) params.set("query", q.query);
|
||||
if (q.page) params.set("page", String(q.page));
|
||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||
if (q.line) params.set("line", q.line);
|
||||
const qs = params.toString();
|
||||
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getCustomer(id: string): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
||||
}
|
||||
|
||||
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>("/customers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCustomer(
|
||||
id: string,
|
||||
input: Partial<CustomerInput>,
|
||||
): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>(`/customers/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function archiveCustomer(id: string): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>(`/customers/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function restoreCustomer(id: string): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Policies module */
|
||||
|
||||
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
||||
* "por vencer" bucket means the same thing everywhere. */
|
||||
export const EXPIRY_WINDOW_DAYS = 30;
|
||||
|
||||
export interface PolicyQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: PolicyStatus;
|
||||
days?: number;
|
||||
typeId?: string;
|
||||
providerId?: string;
|
||||
liquidated?: boolean;
|
||||
sort?: PolicySort;
|
||||
}
|
||||
|
||||
export function listPolicies(q: PolicyQuery): Promise<PolicyListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.query) params.set("query", q.query);
|
||||
if (q.page) params.set("page", String(q.page));
|
||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||
if (q.status) params.set("status", q.status);
|
||||
if (q.days) params.set("days", String(q.days));
|
||||
if (q.typeId) params.set("typeId", q.typeId);
|
||||
if (q.providerId) params.set("providerId", q.providerId);
|
||||
if (q.liquidated !== undefined) params.set("liquidated", String(q.liquidated));
|
||||
if (q.sort) params.set("sort", q.sort);
|
||||
const qs = params.toString();
|
||||
return apiFetch<PolicyListResponse>(`/policies${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getPolicyStats(
|
||||
days: number = EXPIRY_WINDOW_DAYS,
|
||||
): Promise<PolicyStats> {
|
||||
return apiFetch<PolicyStats>(`/policies/stats?days=${days}`);
|
||||
}
|
||||
|
||||
export function getPolicyFacets(): Promise<PolicyFacets> {
|
||||
return apiFetch<PolicyFacets>("/policies/facets");
|
||||
}
|
||||
|
||||
export function getPolicy(
|
||||
id: string,
|
||||
days: number = EXPIRY_WINDOW_DAYS,
|
||||
): Promise<PolicyDetail> {
|
||||
return apiFetch<PolicyDetail>(`/policies/${id}?days=${days}`);
|
||||
}
|
||||
|
||||
export function createPolicy(input: PolicyInput): Promise<PolicyDetail> {
|
||||
return apiFetch<PolicyDetail>("/policies", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
export function updatePolicy(
|
||||
id: string,
|
||||
input: Partial<PolicyInput>,
|
||||
): Promise<PolicyDetail> {
|
||||
return apiFetch<PolicyDetail>(`/policies/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
export function archivePolicy(id: string): Promise<PolicyDetail> {
|
||||
return apiFetch<PolicyDetail>(`/policies/${id}`, { method: "DELETE" });
|
||||
}
|
||||
export function restorePolicy(id: string): Promise<PolicyDetail> {
|
||||
return apiFetch<PolicyDetail>(`/policies/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
// Generic policy-child CRUD. `kind` is the URL segment
|
||||
// (installments|vehicles|drivers|beneficiaries|claims).
|
||||
export function addPolicyChild<T>(
|
||||
policyId: string,
|
||||
kind: string,
|
||||
input: T,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/policies/${policyId}/${kind}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
export function updatePolicyChild<T>(
|
||||
policyId: string,
|
||||
kind: string,
|
||||
childId: string,
|
||||
input: T,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
export function removePolicyChild(
|
||||
policyId: string,
|
||||
kind: string,
|
||||
childId: string,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Lookups (insurance ref) */
|
||||
|
||||
export function getLookups(): Promise<LookupsResponse> {
|
||||
return apiFetch<LookupsResponse>("/lookups");
|
||||
}
|
||||
export function createLookup(kind: string, input: unknown): Promise<unknown> {
|
||||
return apiFetch(`/lookups/${kind}`, { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
export function updateLookup(
|
||||
kind: string,
|
||||
id: string,
|
||||
input: unknown,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/lookups/${kind}/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
export function removeLookup(kind: string, id: string): Promise<unknown> {
|
||||
return apiFetch(`/lookups/${kind}/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- Utilities module */
|
||||
|
||||
export interface PropertyQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
serviceKind?: ServiceKind;
|
||||
municipality?: string;
|
||||
bank?: string;
|
||||
trust?: TrustFilter;
|
||||
hasServices?: boolean;
|
||||
customerId?: string;
|
||||
days?: number;
|
||||
sort?: PropertySort;
|
||||
}
|
||||
|
||||
export function listProperties(q: PropertyQuery): Promise<PropertyListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.query) params.set("query", q.query);
|
||||
if (q.page) params.set("page", String(q.page));
|
||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||
if (q.serviceKind) params.set("serviceKind", q.serviceKind);
|
||||
if (q.municipality) params.set("municipality", q.municipality);
|
||||
if (q.bank) params.set("bank", q.bank);
|
||||
if (q.trust) params.set("trust", q.trust);
|
||||
if (q.hasServices !== undefined)
|
||||
params.set("hasServices", String(q.hasServices));
|
||||
if (q.customerId) params.set("customerId", q.customerId);
|
||||
if (q.days) params.set("days", String(q.days));
|
||||
if (q.sort) params.set("sort", q.sort);
|
||||
const qs = params.toString();
|
||||
return apiFetch<PropertyListResponse>(`/properties${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getPropertyStats(
|
||||
days: number = EXPIRY_WINDOW_DAYS,
|
||||
): Promise<PropertyStats> {
|
||||
return apiFetch<PropertyStats>(`/properties/stats?days=${days}`);
|
||||
}
|
||||
|
||||
export function getPropertyFacets(): Promise<PropertyFacets> {
|
||||
return apiFetch<PropertyFacets>("/properties/facets");
|
||||
}
|
||||
|
||||
export function getProperty(
|
||||
id: string,
|
||||
days: number = EXPIRY_WINDOW_DAYS,
|
||||
): Promise<PropertyDetail> {
|
||||
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 {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
domain?: TransactionDomain;
|
||||
currency?: LedgerCurrency;
|
||||
direction?: LedgerDirection;
|
||||
typeId?: string;
|
||||
source?: string;
|
||||
customerId?: string;
|
||||
/** `YYYY-MM-DD`, inclusive on both ends. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort?: MovementSort;
|
||||
}
|
||||
|
||||
export function listMovements(q: MovementQuery): Promise<MovementListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.query) params.set("query", q.query);
|
||||
if (q.page) params.set("page", String(q.page));
|
||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||
if (q.domain) params.set("domain", q.domain);
|
||||
if (q.currency) params.set("currency", q.currency);
|
||||
if (q.direction) params.set("direction", q.direction);
|
||||
if (q.typeId) params.set("typeId", q.typeId);
|
||||
if (q.source) params.set("source", q.source);
|
||||
if (q.customerId) params.set("customerId", q.customerId);
|
||||
if (q.from) params.set("from", q.from);
|
||||
if (q.to) params.set("to", q.to);
|
||||
if (q.sort) params.set("sort", q.sort);
|
||||
const qs = params.toString();
|
||||
return apiFetch<MovementListResponse>(`/billing${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export interface BalanceQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
currency?: LedgerCurrency;
|
||||
balance?: BalanceFilter;
|
||||
domain?: TransactionDomain;
|
||||
sort?: BalanceSort;
|
||||
}
|
||||
|
||||
export function listBalances(q: BalanceQuery): Promise<BalanceListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.query) params.set("query", q.query);
|
||||
if (q.page) params.set("page", String(q.page));
|
||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||
if (q.currency) params.set("currency", q.currency);
|
||||
if (q.balance) params.set("balance", q.balance);
|
||||
if (q.domain) params.set("domain", q.domain);
|
||||
if (q.sort) params.set("sort", q.sort);
|
||||
const qs = params.toString();
|
||||
return apiFetch<BalanceListResponse>(`/billing/balances${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getBillingStats(): Promise<BillingStats> {
|
||||
return apiFetch<BillingStats>("/billing/stats");
|
||||
}
|
||||
|
||||
export function getBillingFacets(): Promise<BillingFacets> {
|
||||
return apiFetch<BillingFacets>("/billing/facets");
|
||||
}
|
||||
|
||||
export function getStatement(customerId: string): Promise<Statement> {
|
||||
return apiFetch<Statement>(`/billing/customers/${customerId}`);
|
||||
}
|
||||
|
||||
/** Append a new ledger movement. Booked movements are never edited — fix
|
||||
* mistakes with voidMovement + a fresh capture. */
|
||||
export function createMovement(
|
||||
input: CreateMovementInput,
|
||||
): Promise<Transaction> {
|
||||
return apiFetch<Transaction>("/billing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Reverse a movement by marking it voided; totals drop it. 400 if already void. */
|
||||
export function voidMovement(id: string): Promise<Transaction> {
|
||||
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Bank register (chequera) */
|
||||
|
||||
export interface BankQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
direction?: BankDirection;
|
||||
cleared?: BankCleared;
|
||||
/** `YYYY-MM-DD`, inclusive on both ends. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort?: BankSort;
|
||||
}
|
||||
|
||||
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.query) params.set("query", q.query);
|
||||
if (q.page) params.set("page", String(q.page));
|
||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||
if (q.direction) params.set("direction", q.direction);
|
||||
if (q.cleared) params.set("cleared", q.cleared);
|
||||
if (q.from) params.set("from", q.from);
|
||||
if (q.to) params.set("to", q.to);
|
||||
if (q.sort) params.set("sort", q.sort);
|
||||
const qs = params.toString();
|
||||
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getBankStats(): Promise<BankStats> {
|
||||
return apiFetch<BankStats>("/bank/stats");
|
||||
}
|
||||
|
||||
export function getBankFacets(): Promise<BankFacets> {
|
||||
return apiFetch<BankFacets>("/bank/facets");
|
||||
}
|
||||
|
||||
export function getBankSummary(year?: number): Promise<BankSummary> {
|
||||
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
|
||||
}
|
||||
|
||||
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
|
||||
* with voidBankMovement + a fresh capture. */
|
||||
export function createBankMovement(
|
||||
input: CreateBankMovementInput,
|
||||
): Promise<unknown> {
|
||||
return apiFetch("/bank", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Reverse a chequera movement by marking it voided; totals drop it. */
|
||||
export function voidBankMovement(id: string): Promise<unknown> {
|
||||
return apiFetch(`/bank/${id}/void`, { method: "POST" });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Users / administration */
|
||||
|
||||
export function listUsers(): Promise<UserRow[]> {
|
||||
return apiFetch<UserRow[]>("/users");
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function createUser(input: CreateUserInput): Promise<UserRow> {
|
||||
return apiFetch<UserRow>("/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
name?: string;
|
||||
email?: string;
|
||||
role?: Role;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function updateUser(id: string, input: UpdateUserInput): Promise<UserRow> {
|
||||
return apiFetch<UserRow>(`/users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function resetUserPassword(id: string, password: string): Promise<UserRow> {
|
||||
return apiFetch<UserRow>(`/users/${id}/reset-password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------- DB operations (admin only) */
|
||||
|
||||
export function listIngest(): Promise<IngestFile[]> {
|
||||
return apiFetch<IngestFile[]>("/ops/ingest");
|
||||
}
|
||||
|
||||
/** Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. */
|
||||
export async function uploadIngest(name: string, file: File): Promise<void> {
|
||||
const body = new FormData();
|
||||
body.append("file", file, name);
|
||||
const res = await fetch(`${API_ORIGIN}/ops/ingest/${encodeURIComponent(name)}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = `Error ${res.status}`;
|
||||
try {
|
||||
const b = await res.json();
|
||||
if (b?.message) message = b.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteIngest(name: string): Promise<unknown> {
|
||||
return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function listBackups(): Promise<BackupFile[]> {
|
||||
return apiFetch<BackupFile[]>("/ops/backups");
|
||||
}
|
||||
|
||||
export function backupDownloadUrl(name: string): string {
|
||||
return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`;
|
||||
}
|
||||
|
||||
export function deleteBackup(name: string): Promise<unknown> {
|
||||
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function listOpsJobs(): Promise<OpsJob[]> {
|
||||
return apiFetch<OpsJob[]>("/ops/jobs");
|
||||
}
|
||||
|
||||
export function getOpsJob(id: string): Promise<OpsJob> {
|
||||
return apiFetch<OpsJob>(`/ops/jobs/${id}`);
|
||||
}
|
||||
|
||||
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
|
||||
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
|
||||
return apiFetch<OpsJob>("/ops/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, file }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Spanish label maps + formatting helpers. Single source of truth for i18n.
|
||||
|
||||
import type {
|
||||
BankDirection,
|
||||
LedgerDirection,
|
||||
PolicyStatus,
|
||||
Role,
|
||||
ServiceKind,
|
||||
TransactionDomain,
|
||||
TrustStatus,
|
||||
} from "./types";
|
||||
|
||||
/** Access tiers, high → low. VIEWER is read-only; STAFF+ can write. */
|
||||
export const ROLE_LABEL: Record<Role, string> = {
|
||||
ADMIN: "Administrador",
|
||||
MANAGER: "Gerente",
|
||||
STAFF: "Personal",
|
||||
VIEWER: "Solo lectura",
|
||||
};
|
||||
|
||||
/** Roles in descending rank — for populating role <select>s. */
|
||||
export const ROLES_DESC: Role[] = ["ADMIN", "MANAGER", "STAFF", "VIEWER"];
|
||||
|
||||
/**
|
||||
* Placeholder the migration writes when a legacy record had no name and none
|
||||
* could be recovered from a secondary table (migration/transform_customers.py).
|
||||
*/
|
||||
export const SIN_NOMBRE = "(SIN NOMBRE)";
|
||||
|
||||
export const DOMAIN_LABELS: Record<string, string> = {
|
||||
UTILITY: "Servicios",
|
||||
INSURANCE: "Seguros",
|
||||
TRUST: "Fideicomiso",
|
||||
};
|
||||
|
||||
export function domainLabel(domain: TransactionDomain): string {
|
||||
return DOMAIN_LABELS[domain] ?? domain;
|
||||
}
|
||||
|
||||
export const SERVICE_KIND_LABELS: Record<string, string> = {
|
||||
WATER: "Agua",
|
||||
ELECTRIC: "Electricidad",
|
||||
GAS: "Gas",
|
||||
CABLE: "Cable/TV",
|
||||
PROPERTY_TAX: "Predial",
|
||||
FEDERAL_ZONE: "Zona Federal",
|
||||
ALARM: "Alarma",
|
||||
OTHER: "Otro",
|
||||
};
|
||||
|
||||
export function serviceKindLabel(kind: ServiceKind): string {
|
||||
return SERVICE_KIND_LABELS[kind] ?? kind;
|
||||
}
|
||||
|
||||
// A short glyph per service kind, drawn with unicode so no icon dependency.
|
||||
export const SERVICE_KIND_GLYPH: Record<string, string> = {
|
||||
WATER: "≈",
|
||||
ELECTRIC: "⚡",
|
||||
GAS: "◐",
|
||||
CABLE: "▤",
|
||||
PROPERTY_TAX: "⌂",
|
||||
FEDERAL_ZONE: "⇲",
|
||||
ALARM: "◈",
|
||||
OTHER: "•",
|
||||
};
|
||||
|
||||
export function serviceKindGlyph(kind: ServiceKind): string {
|
||||
return SERVICE_KIND_GLYPH[kind] ?? "•";
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-text detail the migration parked in `PropertyService.notes`, which
|
||||
* means something different per service kind: the municipality that bills the
|
||||
* predial / zona federal, the CFE billing cycle (PAR/IMPAR), and the gas
|
||||
* supply type. Used to label the note instead of dumping a bare string.
|
||||
*/
|
||||
export const SERVICE_NOTE_LABELS: Record<string, string> = {
|
||||
PROPERTY_TAX: "Municipio",
|
||||
FEDERAL_ZONE: "Municipio",
|
||||
ELECTRIC: "Ciclo",
|
||||
GAS: "Suministro",
|
||||
CABLE: "Proveedor",
|
||||
};
|
||||
|
||||
export function serviceNoteLabel(kind: ServiceKind): string | null {
|
||||
return SERVICE_NOTE_LABELS[kind] ?? null;
|
||||
}
|
||||
|
||||
// ----- fideicomisos (trusts) -----
|
||||
|
||||
export const TRUST_STATUS_LABELS: Record<TrustStatus, string> = {
|
||||
active: "Vigente",
|
||||
expiring: "Por vencer",
|
||||
expired: "Vencido",
|
||||
undated: "Sin fecha",
|
||||
};
|
||||
|
||||
export function trustStatusLabel(status: TrustStatus): string {
|
||||
return TRUST_STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
// ----- policies -----
|
||||
|
||||
export const POLICY_STATUS_LABELS: Record<PolicyStatus, string> = {
|
||||
active: "Vigente",
|
||||
expiring: "Por vencer",
|
||||
expired: "Vencida",
|
||||
undated: "Sin vigencia",
|
||||
};
|
||||
|
||||
export function policyStatusLabel(status: PolicyStatus): string {
|
||||
return POLICY_STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headline premium for a policy: always `netPremium`.
|
||||
*
|
||||
* The legacy `total` column did not survive the migration as a usable figure —
|
||||
* of 2378 policies only 2 carry a non-zero total (1585 are literally 0, 791
|
||||
* null), and one of those two is *lower* than its own net premium. `netPremium`
|
||||
* is populated on 2377 of 2378. `total` is still shown verbatim in the policy
|
||||
* detail's condiciones grid, where it reads as source data rather than as the
|
||||
* amount the customer owes.
|
||||
*/
|
||||
export function premiumHeadline(p: {
|
||||
netPremium?: string | null;
|
||||
}): { value: string | null; label: string } {
|
||||
return { value: p.netPremium ?? null, label: "Prima neta" };
|
||||
}
|
||||
|
||||
/** "vence en 12 días" / "venció hace 3 días" — null when the policy is undated. */
|
||||
export function expiryPhrase(days: number | null): string | null {
|
||||
if (days === null) return null;
|
||||
if (days === 0) return "vence hoy";
|
||||
if (days > 0) return `vence en ${days} ${days === 1 ? "día" : "días"}`;
|
||||
const past = Math.abs(days);
|
||||
return `venció hace ${past} ${past === 1 ? "día" : "días"}`;
|
||||
}
|
||||
|
||||
// ----- ledger / estado de cuenta -----
|
||||
|
||||
/**
|
||||
* A charge is negative and a credit positive (see `billing.service.ts`), so the
|
||||
* balance is the plain sum. These are the two words the office uses.
|
||||
*/
|
||||
export const DIRECTION_LABELS: Record<LedgerDirection, string> = {
|
||||
charge: "Cargo",
|
||||
credit: "Abono",
|
||||
};
|
||||
|
||||
export function directionLabel(d: LedgerDirection): string {
|
||||
return DIRECTION_LABELS[d] ?? d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spanish names for the legacy `TYPE OF TRX` lookup.
|
||||
*
|
||||
* The lookup ships an `ESPAÑOL` column, but it is **empty in the source** — all
|
||||
* 79 rows are null — so the API can only return the English name. This map
|
||||
* covers the entries that are real service/payment categories; the rest of the
|
||||
* 79 are payee names (LORETO GONZALEZ, ALBERCAS VALLARTA…) that shouldn't be
|
||||
* translated anyway, and fall through to the raw value.
|
||||
*/
|
||||
export const TX_TYPE_LABELS: Record<string, string> = {
|
||||
WATER: "Agua",
|
||||
ELECTRIC: "Electricidad",
|
||||
TELEPHONE: "Teléfono",
|
||||
"PROPERTY TAXES": "Predial",
|
||||
"FEDERAL ZONE": "Zona federal",
|
||||
"GAS BUTANO": "Gas butano",
|
||||
"GAS REFILL": "Recarga de gas",
|
||||
"TRUST FEE": "Cuota de fideicomiso",
|
||||
"HOA DUES": "Cuota de asociación",
|
||||
"ALARM SYSTEM": "Sistema de alarma",
|
||||
"HOUSE INSURANCE": "Seguro de casa",
|
||||
"AUTO INSURANCE": "Seguro de auto",
|
||||
"CHECK DEPOSIT": "Depósito con cheque",
|
||||
"CASH DEPOSIT": "Depósito en efectivo",
|
||||
PAYPAL: "PayPal",
|
||||
"RETURNED CHECK": "Cheque devuelto",
|
||||
"ACCOUNT CANCELED": "Cuenta cancelada",
|
||||
"BANK FEE": "Comisión bancaria",
|
||||
"BANK INTEREST": "Interés bancario",
|
||||
SECURITY: "Vigilancia",
|
||||
BALANCE: "Saldo",
|
||||
ACCOUNTANT: "Contador",
|
||||
"RENEWAL CONCESSION": "Renovación de concesión",
|
||||
};
|
||||
|
||||
export function txTypeLabel(
|
||||
type: { nameEs?: string | null; nameEn?: string | null } | null | undefined,
|
||||
): string {
|
||||
const raw = type?.nameEs || type?.nameEn;
|
||||
if (!raw) return "Sin clasificar";
|
||||
return TX_TYPE_LABELS[raw.toUpperCase()] ?? raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy table a movement came from. Shown so a staff member checking a
|
||||
* surprising figure can trace it back to the Access table it was migrated from.
|
||||
*/
|
||||
export const LEDGER_SOURCE_LABELS: Record<string, string> = {
|
||||
datos2: "Facturación 2025–26",
|
||||
"FEE ANUAL": "Cuota anual 2018",
|
||||
fee15: "Cuota anual 2017",
|
||||
"IVA 2015": "IVA 2015",
|
||||
EFECTIVO: "Recibos de caja",
|
||||
EFECTIVO_BACKUP: "Recibos de caja (respaldo)",
|
||||
"EFECTIVO FM3": "Trámites FM3",
|
||||
"CHEQUE FM3": "Trámites FM3 (cheque)",
|
||||
};
|
||||
|
||||
export function ledgerSourceLabel(source: string | null | undefined): string {
|
||||
if (!source) return "—";
|
||||
return LEDGER_SOURCE_LABELS[source] ?? source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance wording. Negative = the customer owes the office; positive = the
|
||||
* customer is in credit (they have money on account).
|
||||
*/
|
||||
export function balancePhrase(balance: string | number): string {
|
||||
const n = typeof balance === "string" ? Number(balance) : balance;
|
||||
if (!Number.isFinite(n) || Math.abs(n) < 0.005) return "Sin saldo";
|
||||
return n < 0 ? "Adeudo" : "A favor";
|
||||
}
|
||||
|
||||
/** CSS-class suffix matching `balancePhrase`, for colouring a figure. */
|
||||
export function balanceTone(balance: string | number): "owing" | "credit" | "flat" {
|
||||
const n = typeof balance === "string" ? Number(balance) : balance;
|
||||
if (!Number.isFinite(n) || Math.abs(n) < 0.005) return "flat";
|
||||
return n < 0 ? "owing" : "credit";
|
||||
}
|
||||
|
||||
// ----- chequera / bank register -----
|
||||
|
||||
/**
|
||||
* The office's own account, so the words are the bank's, not the ledger's:
|
||||
* an ingreso is money arriving, an egreso money leaving, and a zero-amount row
|
||||
* is a cheque that was voided.
|
||||
*/
|
||||
export const BANK_DIRECTION_LABELS: Record<BankDirection, string> = {
|
||||
income: "Ingreso",
|
||||
expense: "Egreso",
|
||||
void: "Cancelado",
|
||||
};
|
||||
|
||||
export function bankDirectionLabel(d: BankDirection): string {
|
||||
return BANK_DIRECTION_LABELS[d] ?? d;
|
||||
}
|
||||
|
||||
/** CSS-class suffix for colouring a bank figure, matching `.tx-amount`. */
|
||||
export function bankTone(d: BankDirection): "pos" | "neg" | "" {
|
||||
if (d === "income") return "pos";
|
||||
return d === "expense" ? "neg" : "";
|
||||
}
|
||||
|
||||
/** Legacy SCOTHIA table a register row came from. */
|
||||
export const BANK_SOURCE_LABELS: Record<string, string> = {
|
||||
"DATOS I": "Ingresos",
|
||||
"DATOS E": "Egresos",
|
||||
};
|
||||
|
||||
export function bankSourceLabel(source: string | null | undefined): string {
|
||||
if (!source) return "—";
|
||||
return BANK_SOURCE_LABELS[source] ?? source;
|
||||
}
|
||||
|
||||
// ----- DB operations (admin) -----
|
||||
|
||||
export const OPS_KIND_LABELS: Record<string, string> = {
|
||||
BACKUP: "Respaldo",
|
||||
RESTORE: "Restauración",
|
||||
REIMPORT: "Reimportación",
|
||||
SYNC: "Sincronización",
|
||||
};
|
||||
|
||||
export const OPS_STATUS_LABELS: Record<string, string> = {
|
||||
RUNNING: "En curso",
|
||||
SUCCESS: "Completado",
|
||||
FAILED: "Con error",
|
||||
};
|
||||
|
||||
/** Bytes → human size (KB/MB/GB), es-MX formatting. */
|
||||
export function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let n = bytes / 1024;
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${n.toLocaleString("es-MX", { maximumFractionDigits: 1 })} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export const MONTH_NAMES = [
|
||||
"Enero",
|
||||
"Febrero",
|
||||
"Marzo",
|
||||
"Abril",
|
||||
"Mayo",
|
||||
"Junio",
|
||||
"Julio",
|
||||
"Agosto",
|
||||
"Septiembre",
|
||||
"Octubre",
|
||||
"Noviembre",
|
||||
"Diciembre",
|
||||
];
|
||||
|
||||
export function monthName(month: number): string {
|
||||
return MONTH_NAMES[month - 1] ?? String(month);
|
||||
}
|
||||
|
||||
// ----- formatting -----
|
||||
|
||||
export function formatMoney(
|
||||
value: string | number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
): string {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
const num = typeof value === "string" ? Number(value) : value;
|
||||
if (Number.isNaN(num)) return String(value);
|
||||
const cur = (currency ?? "USD").toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat("es-MX", {
|
||||
style: "currency",
|
||||
currency: cur,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(num);
|
||||
} catch {
|
||||
// Unknown currency code — fall back to plain number + suffix.
|
||||
return `${num.toLocaleString("es-MX", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} ${cur}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(
|
||||
iso: string | null | undefined,
|
||||
): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const yyyy = d.getUTCFullYear();
|
||||
return `${dd}/${mm}/${yyyy}`;
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString("es-MX");
|
||||
}
|
||||
|
||||
// "sourceSystem" from legacyRefs → display label.
|
||||
export function sourceSystemLabel(source: string): string {
|
||||
const map: Record<string, string> = {
|
||||
utilities: "Servicios",
|
||||
insurance: "Seguros",
|
||||
};
|
||||
return map[source] ?? source;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
# Canonical internal MySQL for the Jorge Cuadros platform.
|
||||
#
|
||||
# Target: Portainer "local" endpoint on cubex (192.168.4.212:9443), which is a
|
||||
# 3-node Docker Swarm. This is the source-of-truth database from the plan's
|
||||
# "internal server" — the migration transform+load and the NestJS API both
|
||||
# point at it, and (later) it becomes the replication SOURCE for the VPS.
|
||||
#
|
||||
# DEV / PROD as two stacks from this one file. Deploy it twice with different
|
||||
# stack names + env_data:
|
||||
# dev : stack jorgecuadros-dev-db MYSQL_PORT=3307 MYSQL_SERVER_ID=11
|
||||
# prod: stack jorgecuadros-prod-db MYSQL_PORT=3306 MYSQL_SERVER_ID=1
|
||||
# Swarm namespaces the named volume by stack name, so the two environments get
|
||||
# fully isolated data (jorgecuadros-dev-db_mysql_data vs -prod-db_...) with no
|
||||
# extra config. Distinct published ports let both run on the same node at once.
|
||||
# server-id must be unique per environment (prod=1 is the replication source).
|
||||
#
|
||||
# Swarm statefulness rules (see portainer-gitea-deploy skill):
|
||||
# - named volume, never a relative bind mount (the API deploy path won't
|
||||
# create host dirs -> "bind source path does not exist").
|
||||
# - a named volume is LOCAL to whichever node the task lands on. With no
|
||||
# shared storage, the DB MUST be pinned to one node or a reschedule would
|
||||
# start against a fresh empty volume. Pinned here via a node label so it is
|
||||
# not tied to a hostname: label exactly one node with
|
||||
# docker node update --label-add jorgecuadros_db=true <node>
|
||||
# and MySQL will always run there, reusing the same volume.
|
||||
#
|
||||
# Secrets (MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD) are injected at deploy time via
|
||||
# Portainer env_data / stack environment, not committed here.
|
||||
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
command:
|
||||
# (caching_sha2_password is already the default in 8.4; the old
|
||||
# --default-authentication-plugin flag was REMOVED in 8.4 and aborts boot.)
|
||||
# binlog + GTID on from day one so this node can be the replication
|
||||
# SOURCE for the VPS replica later without a restart/reconfigure.
|
||||
- --server-id=${MYSQL_SERVER_ID:-1}
|
||||
- --log-bin=mysql-bin
|
||||
- --binlog-format=ROW
|
||||
- --gtid-mode=ON
|
||||
- --enforce-gtid-consistency=ON
|
||||
environment:
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-jorgecuadros}
|
||||
MYSQL_USER: ${MYSQL_USER:-jorgecuadros}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
||||
ports:
|
||||
# Swarm ingress publishes on every node, so this is reachable at
|
||||
# 192.168.4.212:${MYSQL_PORT} regardless of which node the task pins to.
|
||||
- target: 3306
|
||||
published: ${MYSQL_PORT:-3306}
|
||||
protocol: tcp
|
||||
mode: ingress
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.labels.jorgecuadros_db == true
|
||||
restart_policy:
|
||||
condition: any
|
||||
update_config:
|
||||
order: stop-first
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
@@ -0,0 +1,56 @@
|
||||
# S3-compatible object storage (MinIO) for the platform's document blobs.
|
||||
#
|
||||
# Holds the scanned utility bills / IDs / policy docs extracted from the
|
||||
# Access LONGBINARY columns (migration step 4). MySQL keeps only the pointer
|
||||
# (storageKey) + metadata; the bytes live here.
|
||||
#
|
||||
# Target: Portainer local endpoint on cubex (3-node Swarm). Same statefulness
|
||||
# rules as the MySQL stack (deploy/jorgecuadros-db.stack.yml): named volume +
|
||||
# pinned to one node so the data volume is stable. Reuses the same node label.
|
||||
#
|
||||
# DEV / PROD as two stacks from this one file:
|
||||
# dev : stack jorgecuadros-dev-minio API 9100 / console 9101
|
||||
# prod: stack jorgecuadros-prod-minio API 9000 / console 9001
|
||||
# Swarm namespaces the volume per stack name -> isolated data per environment.
|
||||
#
|
||||
# Secrets (MINIO_ROOT_USER / MINIO_ROOT_PASSWORD) injected via Portainer stack
|
||||
# env at deploy time, not committed.
|
||||
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2024-10-13T13-34-11Z
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
ports:
|
||||
- target: 9000
|
||||
published: ${MINIO_API_PORT:-9000}
|
||||
protocol: tcp
|
||||
mode: ingress
|
||||
- target: 9001
|
||||
published: ${MINIO_CONSOLE_PORT:-9001}
|
||||
protocol: tcp
|
||||
mode: ingress
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.labels.jorgecuadros_db == true
|
||||
restart_policy:
|
||||
condition: any
|
||||
update_config:
|
||||
order: stop-first
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mc ready local || curl -f http://localhost:9000/minio/health/live || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
@@ -31,6 +31,12 @@ services:
|
||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||
WEB_ORIGIN: http://localhost:3000
|
||||
PORT: 3001
|
||||
INGEST_DIR: /data/ingest
|
||||
BACKUP_DIR: /data/backups
|
||||
MIGRATION_ENV: dev
|
||||
volumes:
|
||||
- ingest_data:/data/ingest
|
||||
- backup_data:/data/backups
|
||||
ports:
|
||||
- "3001:3001"
|
||||
|
||||
@@ -48,3 +54,5 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
ingest_data:
|
||||
backup_data:
|
||||
|
||||
+49
-4
@@ -1,24 +1,69 @@
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /repo
|
||||
# Pin pnpm 9 to match pnpm-lock.yaml (lockfileVersion 9.0). pnpm 9 runs
|
||||
# dependency build scripts automatically (the v10 build-allowlist gating does
|
||||
# not apply), so argon2's native addon + prisma engines build without extra
|
||||
# approval config.
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
# argon2's native addon has no musl prebuild -> compiles from source here.
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/database/package.json packages/database/package.json
|
||||
RUN npm install --workspace=packages/database --workspace=apps/api --no-audit --no-fund
|
||||
# node-linker=hoisted flattens the store into a single npm-style /repo/node_modules
|
||||
# so the runtime stage can copy one tree (pnpm's default symlinked layout would
|
||||
# break across COPY stages).
|
||||
RUN pnpm install --frozen-lockfile --config.node-linker=hoisted
|
||||
|
||||
FROM deps AS build
|
||||
COPY packages/database packages/database
|
||||
COPY apps/api apps/api
|
||||
RUN npm run generate -w packages/database
|
||||
RUN npm run build -w apps/api
|
||||
RUN pnpm --filter @jorgecuadros/database generate
|
||||
RUN pnpm --filter @jorgecuadros/api build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /repo
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# DB-ops toolchain baked in so the "Operaciones" admin panel can run backups
|
||||
# (mysqldump), restores (mysql), and the re-import pipeline (python + mdbtools)
|
||||
# from inside the API container. Build deps are installed in a throwaway virtual
|
||||
# package so pandas/pyarrow build on musl, then dropped from the final layer.
|
||||
RUN apk add --no-cache python3 mdbtools mysql-client \
|
||||
&& apk add --no-cache --virtual .pybuild python3-dev build-base \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
COPY --from=build /repo/node_modules node_modules
|
||||
COPY --from=build /repo/packages/database packages/database
|
||||
COPY --from=build /repo/apps/api/dist apps/api/dist
|
||||
COPY --from=build /repo/apps/api/package.json apps/api/package.json
|
||||
|
||||
# Migration scripts + their own Python venv (ops.service.ts prefers this venv).
|
||||
COPY migration migration
|
||||
RUN python3 -m venv migration/.venv \
|
||||
&& migration/.venv/bin/pip install --no-cache-dir -r migration/requirements.txt \
|
||||
&& apk del .pybuild
|
||||
|
||||
# Ingest (uploaded Access files) and backups live on mounted volumes.
|
||||
ENV MIGRATION_DIR=/repo/migration \
|
||||
INGEST_DIR=/data/ingest \
|
||||
BACKUP_DIR=/data/backups \
|
||||
MIGRATION_ENV=dev
|
||||
RUN mkdir -p /data/ingest /data/backups
|
||||
|
||||
# Build/version metadata baked in at image build time (see .gitea/workflows/build.yml).
|
||||
# APP_VERSION is the metadata-action primary tag (semver tag, branch, or sha);
|
||||
# GIT_SHA/BUILD_DATE pin the exact commit + build instant. Exposed as ENV so a
|
||||
# running container can self-report what is deployed (e.g. a /version endpoint).
|
||||
ARG APP_VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
ENV APP_VERSION=$APP_VERSION \
|
||||
GIT_SHA=$GIT_SHA \
|
||||
BUILD_DATE=$BUILD_DATE
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "apps/api/dist/main.js"]
|
||||
|
||||
+17
-3
@@ -1,20 +1,34 @@
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /repo
|
||||
# Pin pnpm 9 to match pnpm-lock.yaml (lockfileVersion 9.0).
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
RUN npm install --workspace=apps/web --no-audit --no-fund
|
||||
COPY packages/database/package.json packages/database/package.json
|
||||
# node-linker=hoisted -> single flat /repo/node_modules copied into runtime.
|
||||
RUN pnpm install --frozen-lockfile --config.node-linker=hoisted
|
||||
|
||||
FROM deps AS build
|
||||
COPY apps/web apps/web
|
||||
RUN npm run build -w apps/web
|
||||
RUN pnpm --filter @jorgecuadros/web build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /repo
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /repo/node_modules node_modules
|
||||
COPY --from=build /repo/apps/web apps/web
|
||||
|
||||
# Build/version metadata baked in at image build time (see .gitea/workflows/build.yml).
|
||||
ARG APP_VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
ENV APP_VERSION=$APP_VERSION \
|
||||
GIT_SHA=$GIT_SHA \
|
||||
BUILD_DATE=$BUILD_DATE
|
||||
|
||||
EXPOSE 3000
|
||||
WORKDIR /repo/apps/web
|
||||
CMD ["npx", "next", "start"]
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
_Migration plan step 2. Generated by `reconcile.py` from staged Parquet (`output/stg_utilities`). Regenerate: `./.venv/bin/python reconcile.py > RECONCILIATION.md`._
|
||||
|
||||
**Headline:** none of the three suspected "duplicate" groups are what the plan assumed. They are disjoint historical/period data or a mislabeled batch — see each group's decided rule.
|
||||
**Headline:** `EFECTIVO_BACKUP` *is* a duplicate of `EFECTIVO` and must not be double-loaded; the billing tables are genuinely disjoint period runs; `COBRO3` is a mislabeled charge batch, not a customer master. See each group's decided rule.
|
||||
|
||||
## 1. Cash ledger — `EFECTIVO` vs `EFECTIVO_BACKUP`
|
||||
|
||||
- `EFECTIVO`: 13697 rows. `EFECTIVO_BACKUP`: 12387 rows.
|
||||
- `folio` is per-table sequential: 13697 distinct in EFECTIVO (= row count), 12369 in BACKUP. 12363 folio *numbers* appear in both.
|
||||
- **But of those 12363 shared folio numbers, 12363 carry a *different* transaction** (differ on ['cl', 'fecha', 'monto', 'conepto']). → `folio` collides; it is NOT a stable cross-table id.
|
||||
- On the real business key `['cl', 'fecha', 'monto', 'conepto']`: **2 rows in both**, 13663 only in EFECTIVO, 12385 only in BACKUP.
|
||||
- Date ranges are different eras: BACKUP is dominated by 2017–2022 records, EFECTIVO by recent ones.
|
||||
- **But of those 12363 shared folio numbers, 12204 carry a *different* transaction** (differ on ['cl', 'fecha', 'monto', 'conepto']). → `folio` collides; it is NOT a stable cross-table id, and it cannot be the de-dup key.
|
||||
- On the real business key `['cl', 'fecha', 'monto', 'conepto']`, canonicalized: **12386 rows in both**, 1279 only in EFECTIVO, 1 only in BACKUP — i.e. all but 1 of BACKUP's 12387 rows already exist verbatim in EFECTIVO, same customer, same timestamp to the second, same amount, same concept text.
|
||||
- Yearly row counts track each other almost exactly from 2006 to 2023 (e.g. 2017: 1036 vs 994), which is what a stale copy looks like — not two ledgers covering different eras.
|
||||
|
||||
**Decided rule:** the two tables are **near-disjoint ledgers**, not a live/backup duplicate pair (only 2 shared payments out of ~13k+12k). Migrate **both** into `transactions`, each row keyed internally by `(legacy_source_table, folio)` provenance — do **not** de-dup on `folio` (it collides) and do **not** drop BACKUP (it holds ~12k older payments absent from EFECTIVO). The 2 business-key matches are the only possible double-counts and should be spot-checked, but at that volume they don't threaten balance integrity.
|
||||
**Decided rule (corrected):** `EFECTIVO_BACKUP` is a **stale backup copy of `EFECTIVO`**, not an independent ledger. Load `EFECTIVO` in full, and load from `EFECTIVO_BACKUP` only the rows whose canonicalized business key is absent from `EFECTIVO` (1 row(s)). Loading both in full double-counts 12386 payments and doubles nearly every customer's historical receipt total, which makes any statement or balance view wrong. De-dup on the business key, **not** on `folio` (it collides).
|
||||
|
||||
> This reverses the original verdict in this report, which read `{b}` as 2. That number came from string-comparing `monto`, which `mdb-export` serializes with a different precision per table (`5000` vs `27000.0000`) — see the module docstring.
|
||||
|
||||
> `EFECTIVO FM3` (627) and `CHEQUE FM3` (157) are a separate stream — `fee`/`tax`/`multa` columns instead of `monto` — and migrate as distinct transactions, not reconciled against EFECTIVO.
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Migration plan step 4: extract LONGBINARY document blobs to object storage.
|
||||
|
||||
The Access LONGBINARY columns hold scanned utility bills / IDs / policy docs
|
||||
wrapped in an Access OLE Object container (a "\\x15\\x1c...Pres..." header +
|
||||
optional DIB preview, then the real embedded file). Staging used
|
||||
`mdb-export -b strip` (blobs dropped); this re-reads each table with
|
||||
`-b hex`, carves the embedded file out of the OLE wrapper by locating its
|
||||
magic bytes, uploads it to MinIO (S3), and writes a *_documents row pointing
|
||||
at it (MySQL keeps only the pointer + metadata, per the plan).
|
||||
|
||||
Row alignment: `mdb-export` order is deterministic and identical to the order
|
||||
load_staging used, so a row's position == its staged `_row_num`. Policy docs
|
||||
resolve to a policy by (legacySourceTable, legacyId=row position); property
|
||||
docs resolve by DATMEX numer_id -> propertyId.
|
||||
|
||||
Idempotent: truncates the *_documents tables for the selected models and
|
||||
re-uploads under deterministic keys (overwrite). Use --limit N for a small
|
||||
test pass, --tables to restrict.
|
||||
|
||||
Run: ./.venv/bin/python blob_extract.py --env dev [--limit N] [--tables datmex,mult]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
from dbenv import connect, load_env
|
||||
from extract import sanitize_column_name as san
|
||||
|
||||
csv.field_size_limit(300_000_000)
|
||||
|
||||
# Same source folder as the rest of the pipeline (config.SOURCE_ROOT honours
|
||||
# INGEST_DIR — the web "Operaciones" ingest volume).
|
||||
from config import SOURCE_ROOT
|
||||
|
||||
# (key, access_file, access_table, staged_table_name, blob_cols, model)
|
||||
SOURCES = [
|
||||
# DATMEX's real scanned bills live in the ILUZ/IAGUA/IPREDIAL/ITEL invoice-
|
||||
# image columns (per-service bill scans), not doc_1/doc_2 (which are empty).
|
||||
# In practice only a handful are populated — the .accdb is mostly bloat.
|
||||
dict(key="datmex", file="UTILITIES.accdb", table="DATMEX", staged="DATMEX",
|
||||
blobs=["iluz", "iagua", "ipredial", "itel", "doc_1", "doc_2"], model="service",
|
||||
doctypes={"iluz": "ELECTRIC_BILL", "iagua": "WATER_BILL",
|
||||
"ipredial": "PROPERTY_TAX_BILL", "itel": "PHONE_BILL"}),
|
||||
dict(key="mult", file="SEGUROS 16_be.mdb", table="MULT", staged="mult",
|
||||
blobs=["foto1", "docs_1", "docs_2"], model="policy"),
|
||||
dict(key="autos_ampl", file="SEGUROS 16_be.mdb", table="TABLA AUTOS AMPL",
|
||||
staged="tabla_autos_ampl", blobs=["foto1", "docs_1", "docs_2"], model="policy"),
|
||||
]
|
||||
|
||||
# magic -> (ext, content-type). Order = priority when several appear.
|
||||
MAGICS = [
|
||||
(b"\xff\xd8\xff", "jpg", "image/jpeg"),
|
||||
(b"\x89PNG\r\n\x1a\n", "png", "image/png"),
|
||||
(b"%PDF", "pdf", "application/pdf"),
|
||||
(b"GIF8", "gif", "image/gif"),
|
||||
(b"II*\x00", "tif", "image/tiff"),
|
||||
(b"MM\x00*", "tif", "image/tiff"),
|
||||
]
|
||||
|
||||
|
||||
def carve(b: bytes):
|
||||
"""Locate the embedded file inside the OLE wrapper and return
|
||||
(bytes, ext, content_type) or None if no known type is present."""
|
||||
best = None
|
||||
for sig, ext, ct in MAGICS:
|
||||
i = b.find(sig)
|
||||
if i >= 0 and (best is None or i < best[0]):
|
||||
best = (i, ext, ct)
|
||||
if best is None:
|
||||
return None
|
||||
i, ext, ct = best
|
||||
data = b[i:]
|
||||
# trim trailing OLE junk after the real end marker where we know it
|
||||
if ext == "jpg":
|
||||
e = data.rfind(b"\xff\xd9")
|
||||
if e >= 0:
|
||||
data = data[: e + 2]
|
||||
elif ext == "png":
|
||||
e = data.rfind(b"IEND\xaeB`\x82")
|
||||
if e >= 0:
|
||||
data = data[: e + 8]
|
||||
return data, ext, ct
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--env", default="dev")
|
||||
ap.add_argument("--limit", type=int, default=0, help="max rows per table (0 = all); test with a small N")
|
||||
ap.add_argument("--tables", default="", help="comma list of source keys to run (default all)")
|
||||
args = ap.parse_args()
|
||||
|
||||
only = set(x.strip() for x in args.tables.split(",") if x.strip())
|
||||
sources = [s for s in SOURCES if not only or s["key"] in only]
|
||||
|
||||
env = load_env(args.env)
|
||||
s3 = boto3.client(
|
||||
"s3", endpoint_url=env["S3_ENDPOINT"],
|
||||
aws_access_key_id=env["MINIO_ROOT_USER"], aws_secret_access_key=env["MINIO_ROOT_PASSWORD"],
|
||||
config=Config(signature_version="s3v4"), region_name="us-east-1")
|
||||
bucket = env["S3_BUCKET"]
|
||||
|
||||
conn = connect(args.env)
|
||||
cur = conn.cursor()
|
||||
|
||||
# parent lookups
|
||||
cur.execute("SELECT legacyId, id FROM properties WHERE legacySourceTable='DATMEX'")
|
||||
prop_by_numer = {}
|
||||
for lid, pid in cur.fetchall():
|
||||
prop_by_numer.setdefault(lid, pid) # first property per numer_id
|
||||
cur.execute("SELECT legacySourceTable, legacyId, id FROM policies")
|
||||
pol_by_row = {(t, l): i for t, l, i in cur.fetchall()}
|
||||
|
||||
# fresh rebuild of the doc tables we're loading (unless a limited test pass)
|
||||
if not args.limit:
|
||||
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
if any(s["model"] == "service" for s in sources):
|
||||
cur.execute("TRUNCATE TABLE service_documents")
|
||||
if any(s["model"] == "policy" for s in sources):
|
||||
cur.execute("TRUNCATE TABLE policy_documents")
|
||||
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||
conn.commit()
|
||||
|
||||
svc_rows, pol_rows = [], []
|
||||
stats = {}
|
||||
|
||||
for src in sources:
|
||||
path = SOURCE_ROOT / src["file"]
|
||||
want = {c: None for c in src["blobs"]}
|
||||
uploaded = skipped_noparent = no_magic = empty = 0
|
||||
|
||||
p = subprocess.Popen(["mdb-export", "-b", "hex", str(path), src["table"]],
|
||||
stdout=subprocess.PIPE, text=True, encoding="utf-8",
|
||||
errors="replace", bufsize=1)
|
||||
rdr = csv.reader(p.stdout)
|
||||
hdr = next(rdr)
|
||||
sh = [san(c) for c in hdr]
|
||||
idx = {c: sh.index(c) for c in src["blobs"] if c in sh}
|
||||
numer_idx = sh.index("numer_id") if "numer_id" in sh else None
|
||||
|
||||
for ri, row in enumerate(rdr):
|
||||
if args.limit and ri >= args.limit:
|
||||
break
|
||||
# resolve parent
|
||||
if src["model"] == "service":
|
||||
numer = (row[numer_idx].strip() if numer_idx is not None and numer_idx < len(row) else "")
|
||||
if numer.endswith(".0"):
|
||||
numer = numer[:-2]
|
||||
parent = prop_by_numer.get(numer)
|
||||
else:
|
||||
parent = pol_by_row.get((src["staged"], str(ri)))
|
||||
for col, ci in idx.items():
|
||||
h = row[ci].strip() if ci < len(row) else ""
|
||||
if len(h) < 16:
|
||||
empty += 1
|
||||
continue
|
||||
if not parent:
|
||||
skipped_noparent += 1
|
||||
continue
|
||||
try:
|
||||
raw = bytes.fromhex(h)
|
||||
except ValueError:
|
||||
continue
|
||||
out = carve(raw)
|
||||
if out is None:
|
||||
no_magic += 1
|
||||
continue
|
||||
data, ext, ct = out
|
||||
prefix = "service" if src["model"] == "service" else "policy"
|
||||
key = f"{prefix}/{parent}/{src['staged']}_{ri}_{col}.{ext}"
|
||||
s3.put_object(Bucket=bucket, Key=key, Body=data, ContentType=ct)
|
||||
dtype = src.get("doctypes", {}).get(col, col.upper())
|
||||
if src["model"] == "service":
|
||||
svc_rows.append((str(uuid.uuid4()), parent, dtype, key))
|
||||
else:
|
||||
pol_rows.append((str(uuid.uuid4()), parent, dtype, key, col))
|
||||
uploaded += 1
|
||||
p.stdout.close(); p.wait()
|
||||
stats[src["key"]] = dict(uploaded=uploaded, no_parent=skipped_noparent,
|
||||
no_magic=no_magic, empty_cells=empty)
|
||||
print(f" [{src['key']}] uploaded={uploaded} no_parent={skipped_noparent} "
|
||||
f"no_magic={no_magic}")
|
||||
|
||||
if svc_rows:
|
||||
cur.executemany("INSERT INTO service_documents (id,propertyId,documentType,storageKey) "
|
||||
"VALUES (%s,%s,%s,%s)", svc_rows)
|
||||
if pol_rows:
|
||||
cur.executemany("INSERT INTO policy_documents (id,policyId,documentType,storageKey,originalColumn) "
|
||||
"VALUES (%s,%s,%s,%s,%s)", pol_rows)
|
||||
conn.commit()
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM service_documents"); ns = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM policy_documents"); npd = cur.fetchone()[0]
|
||||
print("=== Blob extraction complete ===")
|
||||
for k, v in stats.items():
|
||||
print(f" {k}: {v}")
|
||||
print(f" -> service_documents rows total: {ns}")
|
||||
print(f" -> policy_documents rows total : {npd}")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+9
-1
@@ -17,9 +17,17 @@ not work on macOS; extraction is being reworked to use mdbtools
|
||||
the four Access source files.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE_ROOT = Path.home() / "Downloads" / "JorgeCuadros-Legacy"
|
||||
# The folder holding the four Access source files. Overridable via INGEST_DIR so
|
||||
# the web "Operaciones" ingest folder (a mounted volume in the API container)
|
||||
# feeds the same pipeline. Falls back to the original macOS download location
|
||||
# for a plain local run.
|
||||
SOURCE_ROOT = Path(
|
||||
os.environ.get("INGEST_DIR")
|
||||
or (Path.home() / "Downloads" / "JorgeCuadros-Legacy")
|
||||
)
|
||||
|
||||
SOURCES = {
|
||||
"utilities": {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Environment selection for the migration scripts.
|
||||
|
||||
Every transform is environment-agnostic: it reads the staged Parquet (same for
|
||||
all environments) and writes to whichever database `--env` selects. The target
|
||||
is defined entirely by `deploy/.env.<env>` (the same file Portainer is fed at
|
||||
deploy time), whose `DATABASE_URL` is the single source of truth for host /
|
||||
port / credentials / database.
|
||||
|
||||
Reproduce the whole migration in a new environment (e.g. prod) by:
|
||||
1. deploy the DB stack for that env (deploy/jorgecuadros-db.stack.yml)
|
||||
2. write deploy/.env.<env> with its DATABASE_URL
|
||||
3. push the schema: DATABASE_URL=... npx prisma@5 db push --schema=packages/database/prisma/schema.prisma
|
||||
4. run: ./.venv/bin/python run_all.py --env <env>
|
||||
|
||||
Usage in a script:
|
||||
from dbenv import connect, env_arg
|
||||
env = env_arg() # --env dev|prod (default dev)
|
||||
conn = connect(env)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import pymysql
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_env(env: str) -> dict:
|
||||
f = REPO / "deploy" / f".env.{env}"
|
||||
if not f.exists():
|
||||
raise SystemExit(
|
||||
f"missing {f} — deploy the '{env}' DB stack and write its .env first "
|
||||
f"(see dbenv.py header)."
|
||||
)
|
||||
out = {}
|
||||
for line in f.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
out[k] = v
|
||||
if "DATABASE_URL" not in out:
|
||||
raise SystemExit(f"{f} has no DATABASE_URL")
|
||||
return out
|
||||
|
||||
|
||||
def database_url(env: str) -> str:
|
||||
"""Target DB URL. A DATABASE_URL in the process environment wins over
|
||||
deploy/.env.<env> — this is how the API container (which has its own
|
||||
DATABASE_URL and no deploy/.env files) drives a re-import against its own
|
||||
database."""
|
||||
return os.environ.get("DATABASE_URL") or load_env(env)["DATABASE_URL"]
|
||||
|
||||
|
||||
def connect(env: str):
|
||||
url = database_url(env)
|
||||
u = urlparse(url) # mysql://user:pass@host:port/db
|
||||
return pymysql.connect(
|
||||
host=u.hostname,
|
||||
port=u.port or 3306,
|
||||
user=unquote(u.username or ""),
|
||||
password=unquote(u.password or ""),
|
||||
database=(u.path or "/").lstrip("/"),
|
||||
autocommit=False,
|
||||
charset="utf8mb4",
|
||||
)
|
||||
|
||||
|
||||
def env_arg(extra_args=None) -> str:
|
||||
"""Parse --env (default 'dev') and return it. Scripts that need more args
|
||||
can pass an argparse parser via extra_args(parser)."""
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--env", default="dev", help="target environment (dev|prod|...): reads deploy/.env.<env>")
|
||||
if extra_args:
|
||||
extra_args(p)
|
||||
return p.parse_args().env
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Migration step 5: drop customers that carry no business records at all.
|
||||
|
||||
A customer is "empty" when it owns zero properties, zero policies and zero
|
||||
transactions — the legacy DATGRAL row exists, but nothing in either business
|
||||
line ever attached to it. These are dead ID slots and never-activated
|
||||
prospects from the Access era, and they pad the staff customer list with
|
||||
rows that can't be acted on.
|
||||
|
||||
This runs LAST in the customer graph, not inside transform_customers.py,
|
||||
because emptiness is only knowable after properties, policies and
|
||||
transactions have loaded. Deciding it here also means the rule stays in one
|
||||
place instead of being re-derived from the staged Parquet by duplicating each
|
||||
downstream transform's source-matching logic.
|
||||
|
||||
Safe by construction: a customer with zero rows in all three tables has
|
||||
nothing pointing at it, so the delete cannot orphan anything. Only its own
|
||||
`customer_legacy_refs` rows go with it.
|
||||
|
||||
Nothing is silently destroyed — every deleted customer is written to
|
||||
`output/pruned_customers.csv` (with its legacy provenance) before the delete,
|
||||
and the whole step is reproducible from the Access sources by re-running the
|
||||
pipeline without it.
|
||||
|
||||
Run: ./.venv/bin/python prune_empty_customers.py --env dev [--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from dbenv import connect
|
||||
|
||||
AUDIT = Path(__file__).parent / "output" / "pruned_customers.csv"
|
||||
|
||||
# The emptiness test. Kept as one string so the audit dump and the delete can
|
||||
# never disagree about what "empty" means.
|
||||
EMPTY_WHERE = """
|
||||
NOT EXISTS (SELECT 1 FROM properties x WHERE x.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM policies x WHERE x.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM transactions x WHERE x.customerId = c.id)
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--env", default="dev", help="target environment (reads deploy/.env.<env>)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="report and write the audit CSV, but delete nothing")
|
||||
args = ap.parse_args()
|
||||
|
||||
conn = connect(args.env)
|
||||
cur = conn.cursor()
|
||||
print(f"[prune] target env: {args.env}")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM customers")
|
||||
before = cur.fetchone()[0]
|
||||
|
||||
# Snapshot what is about to go, provenance included, for the audit trail.
|
||||
cur.execute(f"""
|
||||
SELECT c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state,
|
||||
c.email, c.phone, c.mobile, c.status,
|
||||
GROUP_CONCAT(CONCAT(r.sourceSystem, ':', r.sourceTable, ':', r.legacyId)
|
||||
ORDER BY r.sourceSystem SEPARATOR ' | ')
|
||||
FROM customers c
|
||||
LEFT JOIN customer_legacy_refs r ON r.customerId = c.id
|
||||
WHERE {EMPTY_WHERE}
|
||||
GROUP BY c.id
|
||||
ORDER BY c.nameMissing, c.name
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
|
||||
AUDIT.parent.mkdir(parents=True, exist_ok=True)
|
||||
with AUDIT.open("w", newline="", encoding="utf-8") as fh:
|
||||
w = csv.writer(fh)
|
||||
w.writerow(["id", "name", "nameSource", "nameMissing", "city", "state",
|
||||
"email", "phone", "mobile", "status", "legacyRefs"])
|
||||
w.writerows(rows)
|
||||
|
||||
named = sum(1 for r in rows if not r[3])
|
||||
nameless = len(rows) - named
|
||||
|
||||
if args.dry_run:
|
||||
print(f" dry run — {len(rows)} would be pruned, nothing deleted")
|
||||
else:
|
||||
cur.execute(f"DELETE r FROM customer_legacy_refs r JOIN customers c ON c.id = r.customerId "
|
||||
f"WHERE {EMPTY_WHERE}")
|
||||
refs_deleted = cur.rowcount
|
||||
cur.execute(f"DELETE c FROM customers c WHERE {EMPTY_WHERE}")
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
print(f" deleted {deleted} customers, {refs_deleted} legacy refs")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM customers")
|
||||
after = cur.fetchone()[0]
|
||||
|
||||
print("=== Empty-customer prune complete ===")
|
||||
print(f" customers before : {before}")
|
||||
print(f" empty (no property, policy or transaction) : {len(rows)}")
|
||||
print(f" named : {named}")
|
||||
print(f" without a name : {nameless}")
|
||||
print(f" customers after : {after}")
|
||||
print(f" audit trail : {AUDIT}")
|
||||
|
||||
# Whatever survived must still have every downstream FK intact.
|
||||
for table in ("properties", "policies", "transactions"):
|
||||
cur.execute(f"SELECT COUNT(*) FROM {table} t "
|
||||
f"LEFT JOIN customers c ON c.id = t.customerId WHERE c.id IS NULL")
|
||||
orphans = cur.fetchone()[0]
|
||||
assert orphans == 0, f"{table}: {orphans} orphaned rows after prune"
|
||||
print(" validation: OK (0 orphans)")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+57
-21
@@ -17,9 +17,17 @@ which is a trap — it hides *why*. The interesting question is whether two
|
||||
tables hold the SAME economic records under cosmetic differences, or
|
||||
genuinely DIFFERENT records. So each group is probed on a deliberate
|
||||
*business key* (the columns that identify the real-world thing) and the
|
||||
volatile/identity columns are examined separately. Every table went through
|
||||
the same mdb-export path, so identical source values serialize identically —
|
||||
string comparison on a chosen key is a valid identity test.
|
||||
volatile/identity columns are examined separately.
|
||||
|
||||
Method correction (2026-07-22): this file previously assumed that "every table
|
||||
went through the same mdb-export path, so identical source values serialize
|
||||
identically". That is false. `mdb-export` formats a numeric column according to
|
||||
its *Access column type*, so the same amount is emitted as `5000` from one table
|
||||
and `27000.0000` from another. String-comparing a numeric key column therefore
|
||||
reports near-zero overlap between tables holding identical records — which is
|
||||
exactly what produced the original (wrong) "EFECTIVO / EFECTIVO_BACKUP are
|
||||
near-disjoint ledgers" verdict. Every key column is now canonicalized (numeric
|
||||
columns parsed and re-formatted to a fixed precision) before comparison.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -41,8 +49,26 @@ def load(name: str) -> pd.DataFrame:
|
||||
return df
|
||||
|
||||
|
||||
def canon(sr: pd.Series) -> pd.Series:
|
||||
"""Canonicalize one key column so it compares across tables.
|
||||
|
||||
A column that parses as a number in (nearly) every populated cell is
|
||||
re-emitted at fixed precision, which erases the per-table formatting
|
||||
mdb-export applies from the Access column type. Anything else (dates,
|
||||
free text) is left as the already-stripped string.
|
||||
"""
|
||||
populated = sr.ne(_NULL)
|
||||
if not populated.any():
|
||||
return sr
|
||||
num = pd.to_numeric(sr.where(populated), errors="coerce")
|
||||
if num.notna().sum() < 0.9 * populated.sum():
|
||||
return sr
|
||||
return num.map(lambda v: _NULL if pd.isna(v) else f"{v:.4f}").astype("string")
|
||||
|
||||
|
||||
def keyset(df: pd.DataFrame, cols: list[str]) -> set[str]:
|
||||
return set(df[cols].agg("\x1f".join, axis=1))
|
||||
keyed = pd.DataFrame({c: canon(df[c]) for c in cols})
|
||||
return set(keyed.agg("\x1f".join, axis=1))
|
||||
|
||||
|
||||
def overlap(a: pd.DataFrame, b: pd.DataFrame, cols: list[str]):
|
||||
@@ -65,9 +91,10 @@ def main() -> None:
|
||||
"(`output/stg_utilities`). Regenerate: `./.venv/bin/python reconcile.py "
|
||||
"> RECONCILIATION.md`._")
|
||||
p("")
|
||||
p("**Headline:** none of the three suspected \"duplicate\" groups are what "
|
||||
"the plan assumed. They are disjoint historical/period data or a "
|
||||
"mislabeled batch — see each group's decided rule.")
|
||||
p("**Headline:** `EFECTIVO_BACKUP` *is* a duplicate of `EFECTIVO` and must "
|
||||
"not be double-loaded; the billing tables are genuinely disjoint period "
|
||||
"runs; `COBRO3` is a mislabeled charge batch, not a customer master. See "
|
||||
"each group's decided rule.")
|
||||
p("")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -88,24 +115,33 @@ def main() -> None:
|
||||
m = ef[ef['folio'].isin(both_folio)].drop_duplicates('folio').set_index('folio')
|
||||
n = efb[efb['folio'].isin(both_folio)].drop_duplicates('folio').set_index('folio')
|
||||
ci = m.index.intersection(n.index)
|
||||
folio_conflict = int((m.loc[ci, biz] != n.loc[ci, biz]).any(axis=1).sum())
|
||||
mk = pd.DataFrame({c: canon(m.loc[ci, c]) for c in biz})
|
||||
nk = pd.DataFrame({c: canon(n.loc[ci, c]) for c in biz})
|
||||
folio_conflict = int((mk != nk).any(axis=1).sum())
|
||||
p(f"- **But of those {len(ci)} shared folio numbers, {folio_conflict} carry "
|
||||
f"a *different* transaction** (differ on {biz}). → `folio` collides; it is "
|
||||
"NOT a stable cross-table id.")
|
||||
"NOT a stable cross-table id, and it cannot be the de-dup key.")
|
||||
b, oa, ob = overlap(ef, efb, biz)
|
||||
p(f"- On the real business key `{biz}`: **{b} rows in both**, {oa} only in "
|
||||
f"EFECTIVO, {ob} only in BACKUP.")
|
||||
p("- Date ranges are different eras: BACKUP is dominated by 2017–2022 "
|
||||
"records, EFECTIVO by recent ones.")
|
||||
p(f"- On the real business key `{biz}`, canonicalized: **{b} rows in both**, "
|
||||
f"{oa} only in EFECTIVO, {ob} only in BACKUP — i.e. all but {ob} of "
|
||||
f"BACKUP's {len(efb)} rows already exist verbatim in EFECTIVO, same "
|
||||
"customer, same timestamp to the second, same amount, same concept text.")
|
||||
p("- Yearly row counts track each other almost exactly from 2006 to 2023 "
|
||||
"(e.g. 2017: 1036 vs 994), which is what a stale copy looks like — not "
|
||||
"two ledgers covering different eras.")
|
||||
p("")
|
||||
p("**Decided rule:** the two tables are **near-disjoint ledgers**, not a "
|
||||
"live/backup duplicate pair (only " + str(b) + " shared payments out of "
|
||||
"~13k+12k). Migrate **both** into `transactions`, each row keyed "
|
||||
"internally by `(legacy_source_table, folio)` provenance — do **not** "
|
||||
"de-dup on `folio` (it collides) and do **not** drop BACKUP (it holds "
|
||||
"~12k older payments absent from EFECTIVO). The " + str(b) + " business-"
|
||||
"key matches are the only possible double-counts and should be spot-"
|
||||
"checked, but at that volume they don't threaten balance integrity.")
|
||||
p("**Decided rule (corrected):** `EFECTIVO_BACKUP` is a **stale backup copy "
|
||||
"of `EFECTIVO`**, not an independent ledger. Load `EFECTIVO` in full, and "
|
||||
"load from `EFECTIVO_BACKUP` only the rows whose canonicalized business "
|
||||
f"key is absent from `EFECTIVO` ({ob} row(s)). Loading both in full "
|
||||
f"double-counts {b} payments and doubles nearly every customer's "
|
||||
"historical receipt total, which makes any statement or balance view "
|
||||
"wrong. De-dup on the business key, **not** on `folio` (it collides).")
|
||||
p("")
|
||||
p("> This reverses the original verdict in this report, which put that "
|
||||
"overlap at 2. That number came from string-comparing `monto`, which `mdb-export` "
|
||||
"serializes with a different precision per table (`5000` vs "
|
||||
"`27000.0000`) — see the module docstring.")
|
||||
p("")
|
||||
p("> `EFECTIVO FM3` (627) and `CHEQUE FM3` (157) are a separate stream — "
|
||||
"`fee`/`tax`/`multa` columns instead of `monto` — and migrate as distinct "
|
||||
|
||||
@@ -4,6 +4,7 @@ pandas>=2.2
|
||||
pyarrow>=15.0
|
||||
sqlalchemy>=2.0
|
||||
pymysql>=1.1
|
||||
boto3>=1.34 # S3/MinIO client for blob_extract.py (migration step 4)
|
||||
|
||||
# Windows-only, historical — the DAO/COM object catalog (catalog_objects.py)
|
||||
# was already run on Windows and its output is committed (objects.json). Not
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Run the full data migration against one environment, in dependency order.
|
||||
|
||||
Every step is idempotent (truncate + rebuild), so this is safe to re-run. The
|
||||
target DB is chosen with --env (reads deploy/.env.<env>); the same staged
|
||||
Parquet feeds every environment.
|
||||
|
||||
Prerequisites (once per environment, NOT done here):
|
||||
1. DB stack deployed (deploy/jorgecuadros-db.stack.yml) and deploy/.env.<env> written.
|
||||
2. Prisma schema pushed to it:
|
||||
DATABASE_URL="<that env's url>" \
|
||||
npx prisma@5 db push --schema=packages/database/prisma/schema.prisma
|
||||
|
||||
Then:
|
||||
./.venv/bin/python run_all.py --env dev # data only (staging already present)
|
||||
./.venv/bin/python run_all.py --env prod --stage # re-extract from Access first, then load
|
||||
|
||||
Reproducing dev -> prod is exactly `--env prod` (plus --stage if the staged
|
||||
Parquet isn't present on the machine running it).
|
||||
|
||||
Note: the blob_extract step re-reads the original Access files directly (the
|
||||
blobs are not in the staged Parquet), so the machine running this needs
|
||||
SOURCE_ROOT + mdbtools + MinIO credentials even without --stage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
PY = sys.executable # the venv python running this orchestrator
|
||||
|
||||
# Dependency order. Every step truncates what it owns, so anything downstream
|
||||
# of a truncated table has to be rebuilt in the same pass — blob_extract is in
|
||||
# this list because transform_properties and transform_policies truncate
|
||||
# service_documents / policy_documents, which would otherwise leave the
|
||||
# uploaded MinIO objects with no rows pointing at them.
|
||||
STEPS = [
|
||||
"transform_customers.py",
|
||||
"transform_properties.py",
|
||||
"transform_policies.py",
|
||||
"transform_transactions.py",
|
||||
"prune_empty_customers.py",
|
||||
"transform_bank.py",
|
||||
"blob_extract.py",
|
||||
]
|
||||
|
||||
SYNC_STEPS = [
|
||||
"transform_customers.py",
|
||||
"transform_properties.py",
|
||||
"transform_policies.py",
|
||||
"transform_transactions.py",
|
||||
"transform_bank.py",
|
||||
]
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> None:
|
||||
print("+ " + " ".join(cmd), flush=True)
|
||||
r = subprocess.run(cmd)
|
||||
if r.returncode:
|
||||
sys.exit(r.returncode)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--env", default="dev", help="target environment (reads deploy/.env.<env>)")
|
||||
ap.add_argument("--stage", action="store_true",
|
||||
help="re-run the raw staging load first (needs the Access files + mdbtools)")
|
||||
ap.add_argument("--sync", action="store_true",
|
||||
help="upsert legacy rows and archive removed legacy rows; preserve manual rows")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.stage:
|
||||
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")])
|
||||
|
||||
for step in SYNC_STEPS if args.sync else STEPS:
|
||||
cmd = [PY, str(HERE / step), "--env", args.env]
|
||||
if args.sync:
|
||||
cmd.append("--sync")
|
||||
run(cmd)
|
||||
|
||||
print(f"\n✓ migration complete for env={args.env}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Shared CLI and SQL helpers for migration modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
def parse_mode() -> tuple[str, bool]:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--env", default="dev")
|
||||
parser.add_argument("--sync", action="store_true")
|
||||
args = parser.parse_args()
|
||||
return args.env, args.sync
|
||||
|
||||
|
||||
def existing_ids(cursor, table: str, key_columns: tuple[str, ...], where: str = "") -> dict[tuple, str]:
|
||||
columns = ",".join(("id", *key_columns))
|
||||
cursor.execute(f"SELECT {columns} FROM {table} {where}")
|
||||
return {tuple(row[1:]): row[0] for row in cursor.fetchall()}
|
||||
|
||||
|
||||
def delete_missing(cursor, table: str, key_columns: tuple[str, ...], seen: set[tuple], where: str) -> int:
|
||||
rows = existing_ids(cursor, table, key_columns, where)
|
||||
stale = [row_id for key, row_id in rows.items() if key not in seen]
|
||||
if stale:
|
||||
cursor.executemany(f"DELETE FROM {table} WHERE id=%s", [(row_id,) for row_id in stale])
|
||||
return len(stale)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Migration plan step 3 (bank register): SCOTHIA.mdb -> bank_transactions +
|
||||
business_line_categories. This is the office's OWN operating checking account
|
||||
("chequera"), deliberately separate from customer-facing `transactions` and
|
||||
carrying no customer FK — so it can load independently of the other steps.
|
||||
|
||||
Sources:
|
||||
- DATOS I (ingresos) -> amount = +ingreso, transferred flag, cleared=operado
|
||||
- DATOS E (egresos) -> amount = -egreso (expenses negative), amountInWords
|
||||
from the spelled-out "cantidad en letra"
|
||||
- TABLA RAMODOS -> business_line_categories (line-of-business lookup)
|
||||
|
||||
Category link: DATOS E/I have no explicit FK to TABLA RAMODOS — the ramo is
|
||||
inferred from the CONCEPTO text, which is a fuzzy classification, not a stored
|
||||
key. So the categories are loaded but bank_transactions.categoryId is left
|
||||
NULL for now; a concept->ramo classifier is a later enhancement.
|
||||
|
||||
Idempotent (truncate + rebuild). Run:
|
||||
./.venv/bin/python transform_bank.py --env dev
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from dbenv import connect, env_arg
|
||||
from sync import parse_mode
|
||||
|
||||
STG = Path(__file__).parent / "output" / "stg_scothia"
|
||||
NULL = "∅"
|
||||
|
||||
|
||||
def s(v):
|
||||
if v is None or pd.isna(v):
|
||||
return None
|
||||
v = str(v).strip()
|
||||
return None if v in ("", NULL, "0000-00-00") else v
|
||||
|
||||
|
||||
def dec(v, default=None):
|
||||
v = s(v)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return Decimal(v.replace(",", ""))
|
||||
except (InvalidOperation, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def dt(v):
|
||||
v = s(v)
|
||||
if v is None:
|
||||
return None
|
||||
d = pd.to_datetime(v, errors="coerce")
|
||||
return None if pd.isna(d) else d.to_pydatetime()
|
||||
|
||||
|
||||
def truthy(v):
|
||||
return (s(v) or "0").lower() in {"1", "-1", "true", "si", "sí", "yes"}
|
||||
|
||||
|
||||
def load(name):
|
||||
df = pd.read_parquet(STG / f"{name}.parquet").sort_values("_row_num").reset_index(drop=True)
|
||||
df = df[[c for c in df.columns if c != "_legacy_source_table"]].copy()
|
||||
for c in df.columns:
|
||||
if c != "_row_num":
|
||||
df[c] = df[c].astype("string").str.strip()
|
||||
return df
|
||||
|
||||
|
||||
def main():
|
||||
env, sync_mode = parse_mode()
|
||||
conn = connect(env)
|
||||
print(f"[bank] target env: {env}")
|
||||
c = conn.cursor()
|
||||
|
||||
# business_line_categories (dedup TABLA RAMODOS)
|
||||
cats, seen = [], set()
|
||||
for _, r in load("tabla_ramodos").iterrows():
|
||||
name = s(r["ramo2"])
|
||||
if name and name.upper() not in seen:
|
||||
seen.add(name.upper())
|
||||
cats.append((str(uuid.uuid4()), name))
|
||||
|
||||
rows = []
|
||||
skip_date = 0
|
||||
|
||||
def add(r, amount, income: bool):
|
||||
nonlocal skip_date
|
||||
td = dt(r["fecha"])
|
||||
if td is None:
|
||||
skip_date += 1
|
||||
return
|
||||
rows.append((
|
||||
str(uuid.uuid4()), td, s(r["tipo"]), s(r["num"]), s(r["concepto"]),
|
||||
amount, None, # categoryId left NULL (see header)
|
||||
1 if truthy(r["operado"]) else 0,
|
||||
1 if (income and truthy(r["transferido"])) else 0,
|
||||
s(r["notas"]),
|
||||
None if income else s(r["cantidad_en_letra"]),
|
||||
"DATOS I" if income else "DATOS E", str(int(r["_row_num"])),
|
||||
))
|
||||
|
||||
for _, r in load("datos_i").iterrows():
|
||||
add(r, dec(r["ingreso"], Decimal(0)), income=True)
|
||||
for _, r in load("datos_e").iterrows():
|
||||
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
||||
|
||||
if sync_mode:
|
||||
for row in rows:
|
||||
c.execute("INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row)
|
||||
else:
|
||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
for t in ("bank_transactions", "business_line_categories"):
|
||||
c.execute(f"TRUNCATE TABLE {t}")
|
||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
||||
c.executemany(
|
||||
"INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", rows)
|
||||
conn.commit()
|
||||
|
||||
def count(t):
|
||||
c.execute(f"SELECT COUNT(*) FROM {t}"); return c.fetchone()[0]
|
||||
c.execute("SELECT legacySourceTable, COUNT(*), SUM(amount) FROM bank_transactions GROUP BY legacySourceTable")
|
||||
by_src = c.fetchall()
|
||||
c.execute("SELECT SUM(amount) FROM bank_transactions")
|
||||
net = c.fetchone()[0]
|
||||
|
||||
print("=== Bank register load complete ===")
|
||||
print(f" skipped (unparseable date): {skip_date}")
|
||||
print(f" -> bank_transactions : {count('bank_transactions')}")
|
||||
for src, n, tot in by_src:
|
||||
print(f" {src:10} {n:6} sum {tot}")
|
||||
print(f" net balance movement : {net}")
|
||||
print(f" -> business_line_categories: {count('business_line_categories')}")
|
||||
print(" validation: OK")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
Migration plan step 3 (customers): build the unified customer master.
|
||||
|
||||
Reads staged Parquet and loads `customers` + `customer_legacy_refs` in the
|
||||
Prisma-managed MySQL. This is the core of the whole project — one customer
|
||||
record shared by both business lines — so every later module (policies,
|
||||
properties, transactions) resolves its customer FK through the legacy refs
|
||||
written here.
|
||||
|
||||
Rules come from the reconciliation pass (RECONCILIATION.md):
|
||||
- Utilities `DATGRAL` (1172) is the customer master; one Customer each.
|
||||
- Insurance `DATGRAL` (1070) links to a utilities customer via its
|
||||
`num_util` cross-reference. Matches fold into the existing customer (and
|
||||
enrich it with the ID-document fields the utilities master lacks);
|
||||
non-matches become new insurance-only customers.
|
||||
- `COBRO3` is a charge batch, NOT a customer source -> excluded here
|
||||
(it is still read as a name-recovery source, see below).
|
||||
|
||||
`DATGRAL.NOMBRE` is blank on 266 legacy rows (140 utilities, 126 insurance).
|
||||
Names for most of them are recovered from secondary tables — see
|
||||
`_NAME_SOURCES` — and `customers.nameSource` records which table each
|
||||
recovered name came from.
|
||||
|
||||
Every legacy row folded in gets a `customer_legacy_refs` row
|
||||
(sourceSystem, sourceTable=DATGRAL, legacyId) so the merge is auditable and
|
||||
the load is idempotent (re-run = truncate + rebuild).
|
||||
|
||||
Run: ./.venv/bin/python transform_customers.py
|
||||
(reads deploy/.env.dev for the dev DATABASE credentials)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from dbenv import connect, env_arg
|
||||
from sync import parse_mode
|
||||
|
||||
STG = Path(__file__).parent / "output"
|
||||
NULL = "∅"
|
||||
NOW = datetime.now(timezone.utc).replace(tzinfo=None) # naive UTC for MySQL DATETIME
|
||||
|
||||
_TRUE = {"1", "-1", "true", "verdadero", "si", "sí", "activo", "yes", "y", "t"}
|
||||
_FALSE = {"0", "false", "falso", "no", "inactivo", "n", "f"}
|
||||
|
||||
|
||||
# --------------------------- source normalization --------------------------- #
|
||||
def load(source: str, table: str) -> pd.DataFrame:
|
||||
df = pd.read_parquet(STG / source / f"{table}.parquet")
|
||||
df = df[[c for c in df.columns if c not in ("_legacy_source_table", "_row_num")]].copy()
|
||||
for c in df.columns:
|
||||
df[c] = df[c].astype("string").str.strip()
|
||||
return df
|
||||
|
||||
|
||||
def s(v) -> str | None:
|
||||
"""Cell -> clean string or None (empty/sentinel -> None)."""
|
||||
if v is None or pd.isna(v):
|
||||
return None
|
||||
v = str(v).strip()
|
||||
return None if v in ("", NULL, "0000-00-00") else v
|
||||
|
||||
|
||||
def norm_id(v) -> str | None:
|
||||
v = s(v)
|
||||
if v is None:
|
||||
return None
|
||||
if v.endswith(".0"): # some numeric ids serialize as "521.0"
|
||||
v = v[:-2]
|
||||
return v if v not in ("0",) else None
|
||||
|
||||
|
||||
def as_bool(v, default=True) -> int:
|
||||
v = s(v)
|
||||
if v is None:
|
||||
return 1 if default else 0
|
||||
lv = v.lower()
|
||||
if lv in _TRUE:
|
||||
return 1
|
||||
if lv in _FALSE:
|
||||
return 0
|
||||
return 1 if default else 0
|
||||
|
||||
|
||||
def as_date(v):
|
||||
v = s(v)
|
||||
if v is None:
|
||||
return None
|
||||
dt = pd.to_datetime(v, errors="coerce")
|
||||
if pd.isna(dt):
|
||||
return None
|
||||
return dt.to_pydatetime()
|
||||
|
||||
|
||||
def as_decimal(v):
|
||||
v = s(v)
|
||||
if v is None:
|
||||
return None
|
||||
v = v.replace(",", "")
|
||||
try:
|
||||
return Decimal(v)
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# -------------------------------- name recovery ----------------------------- #
|
||||
NO_NAME = "(SIN NOMBRE)"
|
||||
|
||||
# The old PHP importer skipped blank-name DATGRAL rows outright
|
||||
# (jorgecuadros-intra-webapp/src/tools/customerAdapter.php:47,81). That also
|
||||
# silently dropped those rows' properties, policies and transactions, because
|
||||
# every other adapter resolved its customer FK through the customer_mapping
|
||||
# table those skipped rows never got into (customerServiceAdapter.php:45), and
|
||||
# customerBalanceAdapter.php:52 defaulted the unmapped ones to customer_id 0.
|
||||
# Most blank-name rows are real accounts, so recover the name instead of
|
||||
# skipping: 176 of the 257 carry a property, policy or transaction.
|
||||
#
|
||||
# Per side, most trustworthy source first; a later source only fills ids the
|
||||
# earlier ones left unresolved. UTILSEG is the office's own hand-maintained
|
||||
# name <-> id cross-reference spanning both lines; the rest are billing runs
|
||||
# and policy rows that happen to repeat the customer's name.
|
||||
# (label, staged source, table, id column, name column)
|
||||
_NAME_SOURCES: dict[str, list[tuple[str, str, str, str, str]]] = {
|
||||
"utilities": [
|
||||
("UTILSEG", "stg_seguros", "utilseg", "util", "nombre"),
|
||||
("IVA 2015", "stg_utilities", "iva_2015", "num_id", "nombre"),
|
||||
("COBRO3", "stg_utilities", "cobro3", "num_id", "nombre"),
|
||||
],
|
||||
"insurance": [
|
||||
("UTILSEG", "stg_seguros", "utilseg", "seguros", "nombre"),
|
||||
("MULT", "stg_seguros", "mult", "num_id", "nombre_aseg"),
|
||||
("M EMPR", "stg_seguros", "m_empr", "num_id", "nombre_aseg"),
|
||||
("INCENDIO", "stg_seguros", "incendio", "num_id", "nombre_aseg"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_name_index(system: str) -> dict[str, tuple[str, str]]:
|
||||
"""legacy num_id -> (recovered name, source label) for one business line."""
|
||||
index: dict[str, tuple[str, str]] = {}
|
||||
for label, source, table, id_col, name_col in _NAME_SOURCES[system]:
|
||||
df = load(source, table)
|
||||
if id_col not in df.columns or name_col not in df.columns:
|
||||
raise KeyError(f"{table}: expected columns {id_col}/{name_col}, got {list(df.columns)}")
|
||||
for _, row in df.iterrows():
|
||||
nid, name = norm_id(row[id_col]), s(row[name_col])
|
||||
if nid and name and nid not in index:
|
||||
index[nid] = (name, label)
|
||||
return index
|
||||
|
||||
|
||||
def resolve_name(raw, nid, index) -> tuple[str, str | None]:
|
||||
"""(name, nameSource). nameSource stays None when DATGRAL had the name."""
|
||||
name = s(raw)
|
||||
if name:
|
||||
return name, None
|
||||
if nid and nid in index:
|
||||
return index[nid]
|
||||
return NO_NAME, None
|
||||
|
||||
|
||||
# ------------------------------- record builders ---------------------------- #
|
||||
def customer_from_utilities(row, name_index) -> dict:
|
||||
name, name_source = resolve_name(row["nombre"], norm_id(row["num_id"]), name_index)
|
||||
return dict(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
nameSource=name_source,
|
||||
nameMissing=int(name == NO_NAME),
|
||||
addressLine1=s(row["direccion"]),
|
||||
addressLine2=s(row["colonia"]),
|
||||
city=s(row["ciudad"]),
|
||||
state=s(row["estado"]),
|
||||
zipCode=s(row["codigo"]),
|
||||
country=s(row["pais"]),
|
||||
phone=s(row["telusa"]),
|
||||
mobile=s(row["cel"]),
|
||||
fax=s(row["fax"]),
|
||||
email=s(row["email"]),
|
||||
notes=s(row["observaciones"]),
|
||||
identificationType=None,
|
||||
identificationNumber=None,
|
||||
identificationExpiration=None,
|
||||
customerSince=as_date(row["cliente_desde"]),
|
||||
status=as_bool(row["status"]),
|
||||
feeAmount=as_decimal(row["fee"]),
|
||||
updatedAt=NOW,
|
||||
)
|
||||
|
||||
|
||||
def customer_from_insurance(row, name_index) -> dict:
|
||||
name, name_source = resolve_name(row["nombre"], norm_id(row["num_id"]), name_index)
|
||||
return dict(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
nameSource=name_source,
|
||||
nameMissing=int(name == NO_NAME),
|
||||
addressLine1=s(row["direccion_1"]),
|
||||
addressLine2=s(row["direccion_2"]),
|
||||
city=s(row["ciudad"]),
|
||||
state=s(row["estado"]),
|
||||
zipCode=s(row["codigo"]),
|
||||
country=s(row["pais"]),
|
||||
phone=s(row["telusa"]),
|
||||
mobile=s(row["tel"]),
|
||||
fax=s(row["fax"]),
|
||||
email=s(row["emailaddress"]),
|
||||
notes=s(row["observaciones"]),
|
||||
identificationType=s(row["tipo_identificacion"]),
|
||||
identificationNumber=s(row["no_identificacion"]),
|
||||
identificationExpiration=as_date(row["expira_identificacion"]),
|
||||
customerSince=None,
|
||||
status=1,
|
||||
feeAmount=None,
|
||||
updatedAt=NOW,
|
||||
)
|
||||
|
||||
|
||||
_CUST_COLS = [
|
||||
"id", "name", "nameSource", "nameMissing", "addressLine1", "addressLine2", "city", "state", "zipCode",
|
||||
"country", "phone", "mobile", "fax", "email", "notes", "identificationType",
|
||||
"identificationNumber", "identificationExpiration", "customerSince",
|
||||
"status", "feeAmount", "updatedAt",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
env, sync_mode = parse_mode()
|
||||
conn = connect(env)
|
||||
print(f"[customers] target env: {env}")
|
||||
cur = conn.cursor()
|
||||
|
||||
if not sync_mode:
|
||||
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
cur.execute("TRUNCATE TABLE customer_legacy_refs")
|
||||
cur.execute("TRUNCATE TABLE customers")
|
||||
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||
|
||||
util = load("stg_utilities", "datgral")
|
||||
ins = load("stg_seguros", "datgral")
|
||||
util_names = build_name_index("utilities")
|
||||
ins_names = build_name_index("insurance")
|
||||
|
||||
customers: list[dict] = []
|
||||
refs: list[tuple] = [] # (id, customerId, sourceSystem, sourceTable, legacyId)
|
||||
util_map: dict[str, str] = {} # utilities num_id -> customer_id
|
||||
rec_by_id: dict[str, dict] = {}
|
||||
|
||||
# Phase A: utilities DATGRAL = the master.
|
||||
for _, row in util.iterrows():
|
||||
rec = customer_from_utilities(row, util_names)
|
||||
customers.append(rec)
|
||||
rec_by_id[rec["id"]] = rec
|
||||
nid = norm_id(row["num_id"])
|
||||
legacy = nid or f"rownum_{len(customers)}"
|
||||
refs.append((str(uuid.uuid4()), rec["id"], "utilities", "DATGRAL", legacy))
|
||||
if nid:
|
||||
util_map[nid] = rec["id"]
|
||||
|
||||
# Phase B: insurance DATGRAL links via num_util, else new customer.
|
||||
linked = new_ins = unmatched_numutil = from_ins_side = 0
|
||||
enrich: list[tuple] = [] # (customerId, insurance record) for fill-in
|
||||
for _, row in ins.iterrows():
|
||||
ins_id = norm_id(row["num_id"]) or f"insrow_{new_ins+linked}"
|
||||
nutil = norm_id(row["num_util"])
|
||||
if nutil and nutil in util_map:
|
||||
cust_id = util_map[nutil]
|
||||
linked += 1
|
||||
ins_rec = customer_from_insurance(row, ins_names)
|
||||
# Last name-recovery path: a master whose own line had no name and
|
||||
# no utilities-side fallback can still borrow the name its linked
|
||||
# insurance record resolved to.
|
||||
master = rec_by_id[cust_id]
|
||||
if master["name"] == NO_NAME and ins_rec["name"] != NO_NAME:
|
||||
master["name"] = ins_rec["name"]
|
||||
master["nameSource"] = ins_rec["nameSource"] or "DATGRAL (seguros)"
|
||||
master["nameMissing"] = 0
|
||||
from_ins_side += 1
|
||||
enrich.append((cust_id, ins_rec))
|
||||
else:
|
||||
if nutil and nutil not in util_map:
|
||||
unmatched_numutil += 1
|
||||
rec = customer_from_insurance(row, ins_names)
|
||||
customers.append(rec)
|
||||
rec_by_id[rec["id"]] = rec
|
||||
cust_id = rec["id"]
|
||||
new_ins += 1
|
||||
refs.append((str(uuid.uuid4()), cust_id, "insurance", "DATGRAL", ins_id))
|
||||
|
||||
placeholders = ",".join(["%s"] * len(_CUST_COLS))
|
||||
if sync_mode:
|
||||
existing = {}
|
||||
cur.execute("SELECT id,sourceSystem,sourceTable,legacyId,customerId FROM customer_legacy_refs")
|
||||
for rid, system, table, legacy, customer_id in cur.fetchall():
|
||||
existing[(system, table, legacy)] = (rid, customer_id)
|
||||
for rec, ref in zip(customers, refs):
|
||||
key = (ref[2], ref[3], ref[4])
|
||||
customer_id = existing.get(key, (None, rec["id"]))[1]
|
||||
rec["id"] = customer_id
|
||||
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
|
||||
ref = (existing.get(key, (ref[0], customer_id))[0], customer_id, *ref[2:])
|
||||
cur.execute("INSERT INTO customer_legacy_refs (id,customerId,sourceSystem,sourceTable,legacyId) VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId)", ref)
|
||||
else:
|
||||
cur.executemany(
|
||||
f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders})",
|
||||
[tuple(rec[c] for c in _CUST_COLS) for rec in customers],
|
||||
)
|
||||
cur.executemany(
|
||||
"INSERT INTO customer_legacy_refs (id, customerId, sourceSystem, sourceTable, legacyId) VALUES (%s,%s,%s,%s,%s)",
|
||||
refs,
|
||||
)
|
||||
|
||||
# Enrich linked customers with insurance-only ID-doc fields, and fill any
|
||||
# contact fields the utilities master left empty (COALESCE keeps master's).
|
||||
for cust_id, ir in enrich:
|
||||
cur.execute(
|
||||
"UPDATE customers SET "
|
||||
"identificationType = COALESCE(identificationType, %s), "
|
||||
"identificationNumber = COALESCE(identificationNumber, %s), "
|
||||
"identificationExpiration = COALESCE(identificationExpiration, %s), "
|
||||
"email = COALESCE(email, %s), "
|
||||
"phone = COALESCE(phone, %s), "
|
||||
"mobile = COALESCE(mobile, %s), "
|
||||
"updatedAt = %s "
|
||||
"WHERE id = %s",
|
||||
(ir["identificationType"], ir["identificationNumber"],
|
||||
ir["identificationExpiration"], ir["email"], ir["phone"],
|
||||
ir["mobile"], NOW, cust_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# ---- validation / report ----
|
||||
cur.execute("SELECT COUNT(*) FROM customers")
|
||||
n_cust = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM customer_legacy_refs")
|
||||
n_refs = cur.fetchone()[0]
|
||||
cur.execute("SELECT sourceSystem, COUNT(*) FROM customer_legacy_refs GROUP BY sourceSystem")
|
||||
by_sys = dict(cur.fetchall())
|
||||
cur.execute("SELECT COUNT(*) FROM customer_legacy_refs "
|
||||
"GROUP BY customerId HAVING COUNT(*) > 1")
|
||||
merged = len(cur.fetchall())
|
||||
cur.execute("SELECT nameSource, COUNT(*) FROM customers "
|
||||
"WHERE nameSource IS NOT NULL GROUP BY nameSource ORDER BY 2 DESC")
|
||||
recovered = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,))
|
||||
still_unnamed = cur.fetchone()[0]
|
||||
|
||||
print("=== Customer load complete ===")
|
||||
print(f" utilities DATGRAL rows : {len(util)}")
|
||||
print(f" insurance DATGRAL rows : {len(ins)}")
|
||||
print(f" linked to a utilities customer : {linked}")
|
||||
print(f" new insurance-only customers : {new_ins}")
|
||||
print(f" num_util set but not in utilities master (data-quality) : {unmatched_numutil}")
|
||||
print(f" -> customers : {n_cust} (expected {len(util)} + {new_ins} = {len(util)+new_ins})")
|
||||
print(f" -> customer_legacy_refs : {n_refs} (expected {len(util)+len(ins)} = {len(util)+len(ins)})")
|
||||
print(f" refs by system : {by_sys}")
|
||||
print(f" customers with >1 ref (merged identities) : {merged}")
|
||||
print(" name recovery (DATGRAL.NOMBRE was blank):")
|
||||
for src, n in recovered:
|
||||
print(f" from {src:16} : {n}")
|
||||
print(f" of which via the linked insurance record : {from_ins_side}")
|
||||
print(f" still {NO_NAME} : {still_unnamed}")
|
||||
assert n_cust == len(util) + new_ins, "customer count mismatch"
|
||||
assert n_refs == len(util) + len(ins), "legacy ref count mismatch"
|
||||
print(" validation: OK")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user