Merge feat/crud-rbac: CRUD/RBAC + ops panel + Docker CI/versioning
This commit is contained in:
@@ -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,6 +8,8 @@ build/
|
|||||||
*.log
|
*.log
|
||||||
migration/output/
|
migration/output/
|
||||||
migration/.venv/
|
migration/.venv/
|
||||||
|
migration/ingest/
|
||||||
|
migration/backups/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
packages/database/generated/
|
packages/database/generated/
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
|
|||||||
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.
|
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.
|
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.
|
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.
|
10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
@@ -146,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.
|
- **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.
|
- **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).
|
- **`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)
|
## Open items (ops, not design)
|
||||||
|
|
||||||
@@ -156,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.
|
- 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.
|
- 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.
|
- 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`).
|
||||||
@@ -162,6 +162,17 @@ the reconciliation pass (done, then corrected) are all closed. See §3 and §8.
|
|||||||
5. **`TRASPASOS PAYPAL` is a clearing account, not a customer** — carries -7.03M MXN over
|
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
|
309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in
|
||||||
code; needs a business decision on how to model it.
|
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.
|
||||||
|
|
||||||
## 7. Environment notes (current macOS machine)
|
## 7. Environment notes (current macOS machine)
|
||||||
|
|
||||||
@@ -374,11 +385,14 @@ for what's actually next.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
⏭ **NEXT — where to pick up:**
|
- **Sync implementation — DONE, validation pending.** `run_all.py --sync` performs the
|
||||||
|
non-destructive legacy upsert path for customers, properties, policies, transactions, and
|
||||||
- **Plan step 8–9: VPS + sync worker.** Blocked on VPS provisioning (§6.1) — the only real
|
bank rows. It preserves manual rows and stable legacy-owned primary keys; the admin SYNC job
|
||||||
external dependency left. Pure ops: provider, size, Tailscale, MySQL replica.
|
automatically creates a pre-sync backup. Next validation: apply schema changes, then exercise
|
||||||
- **Plan step 10: reports / email campaigns / admin.**
|
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
|
- **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
|
(§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
|
exposed MySQL password. (c) The `/estado-cuenta` browser visual pass — `/banco` was verified
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
|
import { CommonModule } from "./common/common.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { CustomersModule } from "./customers/customers.module";
|
import { CustomersModule } from "./customers/customers.module";
|
||||||
@@ -8,12 +9,14 @@ import { PoliciesModule } from "./policies/policies.module";
|
|||||||
import { PropertiesModule } from "./properties/properties.module";
|
import { PropertiesModule } from "./properties/properties.module";
|
||||||
import { BillingModule } from "./billing/billing.module";
|
import { BillingModule } from "./billing/billing.module";
|
||||||
import { BankModule } from "./bank/bank.module";
|
import { BankModule } from "./bank/bank.module";
|
||||||
|
import { OpsModule } from "./ops/ops.module";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
CommonModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
@@ -21,6 +24,7 @@ import { AppController } from "./app.controller";
|
|||||||
PropertiesModule,
|
PropertiesModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
BankModule,
|
BankModule,
|
||||||
|
OpsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
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 { LocalAuthGuard } from "./local-auth.guard";
|
||||||
import { AuthenticatedGuard } from "./authenticated.guard";
|
import { AuthenticatedGuard } from "./authenticated.guard";
|
||||||
import { LoginDto } from "./login.dto";
|
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")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -13,13 +21,13 @@ export class AuthController {
|
|||||||
@Post("login")
|
@Post("login")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
|
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
|
||||||
return req.user;
|
return withAbilities(req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard)
|
||||||
@Get("me")
|
@Get("me")
|
||||||
me(@Req() req: Request) {
|
me(@Req() req: Request) {
|
||||||
return req.user;
|
return withAbilities(req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("logout")
|
@Post("logout")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
import {
|
import {
|
||||||
BankCleared,
|
BankCleared,
|
||||||
BankDirection,
|
BankDirection,
|
||||||
BankService,
|
BankService,
|
||||||
BankSort,
|
BankSort,
|
||||||
} from "./bank.service";
|
} from "./bank.service";
|
||||||
|
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||||
|
|
||||||
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||||
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
||||||
@@ -28,10 +42,17 @@ function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
|||||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("bank")
|
@Controller("bank")
|
||||||
export class BankController {
|
export class BankController {
|
||||||
constructor(private readonly bank: BankService) {}
|
constructor(
|
||||||
|
private readonly bank: BankService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
stats() {
|
stats() {
|
||||||
@@ -75,4 +96,25 @@ export class BankController {
|
|||||||
sort: one(SORTS, sort) ?? "date_desc",
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
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.
|
* Bank register (chequera) module — plan step 7.
|
||||||
@@ -160,6 +169,7 @@ export class BankService {
|
|||||||
notes: true,
|
notes: true,
|
||||||
amountInWords: true,
|
amountInWords: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
|
voidedAt: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -182,6 +192,7 @@ export class BankService {
|
|||||||
notes: r.notes,
|
notes: r.notes,
|
||||||
amountInWords: r.amountInWords,
|
amountInWords: r.amountInWords,
|
||||||
source: r.legacySourceTable,
|
source: r.legacySourceTable,
|
||||||
|
voided: r.voidedAt != null,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
page: params.page,
|
page: params.page,
|
||||||
@@ -195,17 +206,17 @@ export class BankService {
|
|||||||
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
|
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
|
||||||
const [income, expense, voided] = await Promise.all([
|
const [income, expense, voided] = await Promise.all([
|
||||||
this.prisma.bankTransaction.aggregate({
|
this.prisma.bankTransaction.aggregate({
|
||||||
where: { AND: [where, { amount: { gt: 0 } }] },
|
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.aggregate({
|
this.prisma.bankTransaction.aggregate({
|
||||||
where: { AND: [where, { amount: { lt: 0 } }] },
|
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.count({
|
this.prisma.bankTransaction.count({
|
||||||
where: { AND: [where, { amount: 0 }] },
|
where: { AND: [where, { amount: 0 }, NOT_VOIDED] },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -225,13 +236,18 @@ export class BankService {
|
|||||||
/** Top-line figures for the bank page header. */
|
/** Top-line figures for the bank page header. */
|
||||||
async stats() {
|
async stats() {
|
||||||
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
||||||
this.prisma.bankTransaction.count(),
|
this.prisma.bankTransaction.count({ where: NOT_VOIDED }),
|
||||||
this.prisma.bankTransaction.aggregate({
|
this.prisma.bankTransaction.aggregate({
|
||||||
|
where: NOT_VOIDED,
|
||||||
_min: { transactionDate: true },
|
_min: { transactionDate: true },
|
||||||
_max: { transactionDate: true },
|
_max: { transactionDate: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.count({ where: { cleared: false } }),
|
this.prisma.bankTransaction.count({
|
||||||
this.prisma.bankTransaction.count({ where: { transferred: true } }),
|
where: { AND: [{ cleared: false }, NOT_VOIDED] },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.count({
|
||||||
|
where: { AND: [{ transferred: true }, NOT_VOIDED] },
|
||||||
|
}),
|
||||||
this.totalsFor({}),
|
this.totalsFor({}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -252,6 +268,7 @@ export class BankService {
|
|||||||
>`
|
>`
|
||||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
|
WHERE voidedAt IS NULL
|
||||||
GROUP BY year
|
GROUP BY year
|
||||||
ORDER BY year DESC
|
ORDER BY year DESC
|
||||||
`;
|
`;
|
||||||
@@ -280,6 +297,7 @@ export class BankService {
|
|||||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
SUM(amount) AS net
|
SUM(amount) AS net
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
|
WHERE voidedAt IS NULL
|
||||||
GROUP BY period
|
GROUP BY period
|
||||||
ORDER BY period ASC
|
ORDER BY period ASC
|
||||||
`;
|
`;
|
||||||
@@ -293,7 +311,7 @@ export class BankService {
|
|||||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
SUM(amount) AS net
|
SUM(amount) AS net
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
WHERE YEAR(transactionDate) = ${year}
|
WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL
|
||||||
GROUP BY period
|
GROUP BY period
|
||||||
ORDER BY period ASC
|
ORDER BY period ASC
|
||||||
`
|
`
|
||||||
@@ -346,6 +364,39 @@ export class BankService {
|
|||||||
opening: opening.toFixed(2),
|
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 {
|
function directionOf(amount: Prisma.Decimal): BankDirection {
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { TransactionDomain } from "@jorgecuadros/database";
|
import { TransactionDomain } from "@jorgecuadros/database";
|
||||||
|
import { Request } from "express";
|
||||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
import {
|
import {
|
||||||
BalanceFilter,
|
BalanceFilter,
|
||||||
BalanceSort,
|
BalanceSort,
|
||||||
@@ -9,6 +22,7 @@ import {
|
|||||||
LedgerDirection,
|
LedgerDirection,
|
||||||
MovementSort,
|
MovementSort,
|
||||||
} from "./billing.service";
|
} from "./billing.service";
|
||||||
|
import { CreateMovementDto } from "./movement.dto";
|
||||||
|
|
||||||
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||||
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
||||||
@@ -39,10 +53,17 @@ function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
|||||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billing: BillingService) {}
|
constructor(
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
stats() {
|
stats() {
|
||||||
@@ -113,4 +134,27 @@ export class BillingController {
|
|||||||
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
|
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateMovementDto } from "./movement.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared billing / statements module — plan step 6.
|
* Shared billing / statements module — plan step 6.
|
||||||
@@ -104,6 +105,13 @@ function dec(v: Prisma.Decimal | null | undefined): string {
|
|||||||
return (v ?? new Prisma.Decimal(0)).toFixed(2);
|
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()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -187,6 +195,7 @@ export class BillingService {
|
|||||||
checkNumber: true,
|
checkNumber: true,
|
||||||
message: true,
|
message: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
|
voidedAt: true,
|
||||||
type: { select: { nameEn: true, nameEs: true } },
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
customer: {
|
customer: {
|
||||||
select: { id: true, name: true, nameSource: true, city: true },
|
select: { id: true, name: true, nameSource: true, city: true },
|
||||||
@@ -200,19 +209,19 @@ export class BillingService {
|
|||||||
// cover everything the filter matched.
|
// cover everything the filter matched.
|
||||||
const totals = await this.prisma.transaction.groupBy({
|
const totals = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where,
|
where: { AND: [where, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
const charges = await this.prisma.transaction.groupBy({
|
const charges = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { AND: [where, { amount: { lt: 0 } }] },
|
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
const credits = await this.prisma.transaction.groupBy({
|
const credits = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { AND: [where, { amount: { gt: 0 } }] },
|
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
@@ -233,6 +242,7 @@ export class BillingService {
|
|||||||
message: r.message,
|
message: r.message,
|
||||||
source: r.legacySourceTable,
|
source: r.legacySourceTable,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
|
voided: r.voidedAt != null,
|
||||||
customerId: r.customer.id,
|
customerId: r.customer.id,
|
||||||
customerName: r.customer.name,
|
customerName: r.customer.name,
|
||||||
customerNameSource: r.customer.nameSource,
|
customerNameSource: r.customer.nameSource,
|
||||||
@@ -326,7 +336,7 @@ export class BillingService {
|
|||||||
MAX(t.transactionDate) AS lastMovement
|
MAX(t.transactionDate) AS lastMovement
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
WHERE 1 = 1 ${nameFilter} ${txFilter}
|
WHERE t.voidedAt IS NULL ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||||
${having}
|
${having}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
@@ -382,17 +392,23 @@ export class BillingService {
|
|||||||
/** Top-line figures for the billing page header. */
|
/** Top-line figures for the billing page header. */
|
||||||
async stats() {
|
async stats() {
|
||||||
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
||||||
this.prisma.transaction.count(),
|
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||||
this.prisma.transaction
|
this.prisma.transaction
|
||||||
.findMany({ distinct: ["customerId"], select: { customerId: true } })
|
.findMany({
|
||||||
|
where: NOT_VOIDED,
|
||||||
|
distinct: ["customerId"],
|
||||||
|
select: { customerId: true },
|
||||||
|
})
|
||||||
.then((r) => r.length),
|
.then((r) => r.length),
|
||||||
this.prisma.transaction.groupBy({
|
this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
|
where: NOT_VOIDED,
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.transaction.groupBy({
|
this.prisma.transaction.groupBy({
|
||||||
by: ["domain", "currency"],
|
by: ["domain", "currency"],
|
||||||
|
where: NOT_VOIDED,
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
@@ -400,13 +416,13 @@ export class BillingService {
|
|||||||
|
|
||||||
const charges = await this.prisma.transaction.groupBy({
|
const charges = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { amount: { lt: 0 } },
|
where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
const credits = await this.prisma.transaction.groupBy({
|
const credits = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { amount: { gt: 0 } },
|
where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
@@ -428,7 +444,7 @@ export class BillingService {
|
|||||||
SUM(bal > 0.005) AS inCredit
|
SUM(bal > 0.005) AS inCredit
|
||||||
FROM (
|
FROM (
|
||||||
SELECT customerId, currency, SUM(amount) AS bal
|
SELECT customerId, currency, SUM(amount) AS bal
|
||||||
FROM transactions GROUP BY customerId, currency
|
FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency
|
||||||
) x
|
) x
|
||||||
GROUP BY currency
|
GROUP BY currency
|
||||||
`;
|
`;
|
||||||
@@ -436,10 +452,12 @@ export class BillingService {
|
|||||||
|
|
||||||
const [firstRow, lastRow] = await Promise.all([
|
const [firstRow, lastRow] = await Promise.all([
|
||||||
this.prisma.transaction.findFirst({
|
this.prisma.transaction.findFirst({
|
||||||
|
where: NOT_VOIDED,
|
||||||
orderBy: { transactionDate: "asc" },
|
orderBy: { transactionDate: "asc" },
|
||||||
select: { transactionDate: true },
|
select: { transactionDate: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.transaction.findFirst({
|
this.prisma.transaction.findFirst({
|
||||||
|
where: NOT_VOIDED,
|
||||||
orderBy: { transactionDate: "desc" },
|
orderBy: { transactionDate: "desc" },
|
||||||
select: { transactionDate: true },
|
select: { transactionDate: true },
|
||||||
}),
|
}),
|
||||||
@@ -449,7 +467,7 @@ export class BillingService {
|
|||||||
// module is one view instead of two.
|
// module is one view instead of two.
|
||||||
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
|
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
|
||||||
SELECT COUNT(*) AS n FROM (
|
SELECT COUNT(*) AS n FROM (
|
||||||
SELECT customerId FROM transactions
|
SELECT customerId FROM transactions WHERE voidedAt IS NULL
|
||||||
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
||||||
) x
|
) x
|
||||||
`;
|
`;
|
||||||
@@ -484,7 +502,7 @@ export class BillingService {
|
|||||||
async facets() {
|
async facets() {
|
||||||
const types = await this.prisma.transaction.groupBy({
|
const types = await this.prisma.transaction.groupBy({
|
||||||
by: ["typeId"],
|
by: ["typeId"],
|
||||||
where: { typeId: { not: null } },
|
where: { AND: [{ typeId: { not: null } }, NOT_VOIDED] },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
orderBy: { _count: { typeId: "desc" } },
|
orderBy: { _count: { typeId: "desc" } },
|
||||||
});
|
});
|
||||||
@@ -496,6 +514,7 @@ export class BillingService {
|
|||||||
|
|
||||||
const sources = await this.prisma.transaction.groupBy({
|
const sources = await this.prisma.transaction.groupBy({
|
||||||
by: ["legacySourceTable"],
|
by: ["legacySourceTable"],
|
||||||
|
where: NOT_VOIDED,
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
orderBy: { _count: { legacySourceTable: "desc" } },
|
orderBy: { _count: { legacySourceTable: "desc" } },
|
||||||
});
|
});
|
||||||
@@ -504,7 +523,7 @@ export class BillingService {
|
|||||||
{ year: number; count: bigint | number | string }[]
|
{ year: number; count: bigint | number | string }[]
|
||||||
>`
|
>`
|
||||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||||
FROM transactions GROUP BY year ORDER BY year DESC
|
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -573,14 +592,18 @@ export class BillingService {
|
|||||||
checkNumber: true,
|
checkNumber: true,
|
||||||
message: true,
|
message: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
|
voidedAt: true,
|
||||||
type: { select: { nameEn: true, nameEs: true } },
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const running = new Map<string, Prisma.Decimal>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
const movements = rows.map((r) => {
|
const movements = rows.map((r) => {
|
||||||
|
const voided = r.voidedAt != null;
|
||||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||||
const next = prev.plus(r.amount);
|
// 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);
|
running.set(r.currency, next);
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -595,6 +618,7 @@ export class BillingService {
|
|||||||
message: r.message,
|
message: r.message,
|
||||||
source: r.legacySourceTable,
|
source: r.legacySourceTable,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
|
voided,
|
||||||
/** Balance in this row's currency after applying it. */
|
/** Balance in this row's currency after applying it. */
|
||||||
balanceAfter: next.toFixed(2),
|
balanceAfter: next.toFixed(2),
|
||||||
};
|
};
|
||||||
@@ -628,6 +652,7 @@ export class BillingService {
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
if (r.voidedAt != null) continue; // voided rows never enter a total
|
||||||
const c =
|
const c =
|
||||||
perCurrency.get(r.currency) ??
|
perCurrency.get(r.currency) ??
|
||||||
{
|
{
|
||||||
@@ -675,6 +700,7 @@ export class BillingService {
|
|||||||
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
||||||
>();
|
>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
if (r.voidedAt != null) continue;
|
||||||
if (!r.amount.lessThan(0)) continue;
|
if (!r.amount.lessThan(0)) continue;
|
||||||
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
||||||
const key = `${name}|${r.currency}`;
|
const key = `${name}|${r.currency}`;
|
||||||
@@ -722,4 +748,45 @@ export class BillingService {
|
|||||||
movements,
|
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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,35 @@
|
|||||||
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
|
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 { 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 { CustomersService } from "./customers.service";
|
||||||
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("customers")
|
@Controller("customers")
|
||||||
export class CustomersController {
|
export class CustomersController {
|
||||||
constructor(private readonly customers: CustomersService) {}
|
constructor(
|
||||||
|
private readonly customers: CustomersService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
stats() {
|
stats() {
|
||||||
@@ -18,14 +42,57 @@ export class CustomersController {
|
|||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@Query("pageSize") pageSize?: string,
|
@Query("pageSize") pageSize?: string,
|
||||||
@Query("line") line?: "utility" | "insurance" | "both",
|
@Query("line") line?: "utility" | "insurance" | "both",
|
||||||
|
@Query("includeArchived") includeArchived?: string,
|
||||||
) {
|
) {
|
||||||
const p = Math.max(1, Number(page) || 1);
|
const p = Math.max(1, Number(page) || 1);
|
||||||
const ps = Math.min(100, Math.max(1, Number(pageSize) || 25));
|
const ps = Math.min(100, Math.max(1, Number(pageSize) || 25));
|
||||||
return this.customers.list({ query, page: p, pageSize: ps, line });
|
return this.customers.list({
|
||||||
|
query,
|
||||||
|
page: p,
|
||||||
|
pageSize: ps,
|
||||||
|
line,
|
||||||
|
includeArchived: includeArchived === "true",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
detail(@Param("id") id: string) {
|
detail(@Param("id") id: string) {
|
||||||
return this.customers.detail(id);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
|
||||||
export interface ListParams {
|
export interface ListParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
line?: "utility" | "insurance" | "both";
|
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()
|
@Injectable()
|
||||||
@@ -14,9 +25,11 @@ export class CustomersService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/** Unified customer list with search + business-line filter, paginated. */
|
/** Unified customer list with search + business-line filter, paginated. */
|
||||||
async list({ query, page, pageSize, line }: ListParams) {
|
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
||||||
const where: Prisma.CustomerWhereInput = {};
|
const where: Prisma.CustomerWhereInput = {};
|
||||||
|
|
||||||
|
if (!includeArchived) where.archivedAt = null;
|
||||||
|
|
||||||
if (query && query.trim()) {
|
if (query && query.trim()) {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
where.OR = [
|
where.OR = [
|
||||||
@@ -54,6 +67,7 @@ export class CustomersService {
|
|||||||
phone: true,
|
phone: true,
|
||||||
mobile: true,
|
mobile: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
archivedAt: true,
|
||||||
_count: { select: { properties: true, policies: true, transactions: true } },
|
_count: { select: { properties: true, policies: true, transactions: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -69,6 +83,7 @@ export class CustomersService {
|
|||||||
phone: r.phone,
|
phone: r.phone,
|
||||||
mobile: r.mobile,
|
mobile: r.mobile,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
|
archived: r.archivedAt != null,
|
||||||
propertyCount: r._count.properties,
|
propertyCount: r._count.properties,
|
||||||
policyCount: r._count.policies,
|
policyCount: r._count.policies,
|
||||||
transactionCount: r._count.transactions,
|
transactionCount: r._count.transactions,
|
||||||
@@ -117,7 +132,8 @@ export class CustomersService {
|
|||||||
// business lines" payoff), computed in the DB rather than in JS.
|
// business lines" payoff), computed in the DB rather than in JS.
|
||||||
const summary = await this.prisma.transaction.groupBy({
|
const summary = await this.prisma.transaction.groupBy({
|
||||||
by: ["domain", "currency"],
|
by: ["domain", "currency"],
|
||||||
where: { customerId: id },
|
// Exclude voided rows so the per-domain balance matches the statement.
|
||||||
|
where: { customerId: id, voidedAt: null },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
@@ -133,6 +149,58 @@ export class CustomersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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. */
|
/** Top-line counts for a dashboard header. */
|
||||||
async stats() {
|
async stats() {
|
||||||
const [customers, withUtilities, withInsurance, policies, properties, transactions] =
|
const [customers, withUtilities, withInsurance, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,37 @@
|
|||||||
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
|
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 { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
import {
|
import {
|
||||||
PoliciesService,
|
PoliciesService,
|
||||||
type PolicySort,
|
type PolicySort,
|
||||||
type PolicyStatus,
|
type PolicyStatus,
|
||||||
} from "./policies.service";
|
} 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 STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"];
|
||||||
const SORTS: PolicySort[] = [
|
const SORTS: PolicySort[] = [
|
||||||
@@ -15,15 +42,21 @@ const SORTS: PolicySort[] = [
|
|||||||
"premium_desc",
|
"premium_desc",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Clamped expiry window; 30 days is the default renewal horizon. */
|
|
||||||
function parseDays(days?: string): number {
|
function parseDays(days?: string): number {
|
||||||
return Math.min(365, Math.max(1, Number(days) || 30));
|
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("policies")
|
@Controller("policies")
|
||||||
export class PoliciesController {
|
export class PoliciesController {
|
||||||
constructor(private readonly policies: PoliciesService) {}
|
constructor(
|
||||||
|
private readonly policies: PoliciesService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
stats(@Query("days") days?: string) {
|
stats(@Query("days") days?: string) {
|
||||||
@@ -45,6 +78,7 @@ export class PoliciesController {
|
|||||||
@Query("typeId") typeId?: string,
|
@Query("typeId") typeId?: string,
|
||||||
@Query("providerId") providerId?: string,
|
@Query("providerId") providerId?: string,
|
||||||
@Query("liquidated") liquidated?: string,
|
@Query("liquidated") liquidated?: string,
|
||||||
|
@Query("includeArchived") includeArchived?: string,
|
||||||
@Query("sort") sort?: string,
|
@Query("sort") sort?: string,
|
||||||
) {
|
) {
|
||||||
return this.policies.list({
|
return this.policies.list({
|
||||||
@@ -59,6 +93,7 @@ export class PoliciesController {
|
|||||||
providerId: providerId || undefined,
|
providerId: providerId || undefined,
|
||||||
liquidated:
|
liquidated:
|
||||||
liquidated === "true" ? true : liquidated === "false" ? false : undefined,
|
liquidated === "true" ? true : liquidated === "false" ? false : undefined,
|
||||||
|
includeArchived: includeArchived === "true",
|
||||||
sort: SORTS.includes(sort as PolicySort)
|
sort: SORTS.includes(sort as PolicySort)
|
||||||
? (sort as PolicySort)
|
? (sort as PolicySort)
|
||||||
: "expiry_desc",
|
: "expiry_desc",
|
||||||
@@ -69,4 +104,140 @@ export class PoliciesController {
|
|||||||
detail(@Param("id") id: string, @Query("days") days?: string) {
|
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||||
return this.policies.detail(id, parseDays(days));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PoliciesController } from "./policies.controller";
|
import { PoliciesController } from "./policies.controller";
|
||||||
|
import { LookupsController } from "./lookups.controller";
|
||||||
import { PoliciesService } from "./policies.service";
|
import { PoliciesService } from "./policies.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PoliciesController],
|
controllers: [PoliciesController, LookupsController],
|
||||||
providers: [PoliciesService],
|
providers: [PoliciesService],
|
||||||
})
|
})
|
||||||
export class PoliciesModule {}
|
export class PoliciesModule {}
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
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
|
* Vigencia buckets, derived from `policyTo` against today. `undated` is a real
|
||||||
@@ -27,6 +48,7 @@ export interface ListParams {
|
|||||||
typeId?: string;
|
typeId?: string;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
liquidated?: boolean;
|
liquidated?: boolean;
|
||||||
|
includeArchived?: boolean;
|
||||||
sort: PolicySort;
|
sort: PolicySort;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +119,13 @@ export class PoliciesService {
|
|||||||
|
|
||||||
/** Policy list with search, vigencia/type/provider filters, paginated. */
|
/** Policy list with search, vigencia/type/provider filters, paginated. */
|
||||||
async list(params: ListParams) {
|
async list(params: ListParams) {
|
||||||
const { query, page, pageSize, status, days, typeId, providerId, liquidated, sort } =
|
const { query, page, pageSize, status, days, typeId, providerId, liquidated,
|
||||||
params;
|
includeArchived, sort } = params;
|
||||||
|
|
||||||
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
|
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
|
||||||
|
|
||||||
|
if (!includeArchived) where.archivedAt = null;
|
||||||
|
|
||||||
if (query && query.trim()) {
|
if (query && query.trim()) {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
where.OR = [
|
where.OR = [
|
||||||
@@ -134,6 +158,7 @@ export class PoliciesService {
|
|||||||
total: true,
|
total: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
liquidated: true,
|
liquidated: true,
|
||||||
|
archivedAt: true,
|
||||||
customer: { select: { id: true, name: true, city: true } },
|
customer: { select: { id: true, name: true, city: true } },
|
||||||
policyType: { select: { id: true, name: true } },
|
policyType: { select: { id: true, name: true } },
|
||||||
insuranceProvider: { select: { id: true, name: true } },
|
insuranceProvider: { select: { id: true, name: true } },
|
||||||
@@ -155,6 +180,7 @@ export class PoliciesService {
|
|||||||
total: r.total,
|
total: r.total,
|
||||||
currency: r.currency,
|
currency: r.currency,
|
||||||
liquidated: r.liquidated,
|
liquidated: r.liquidated,
|
||||||
|
archived: r.archivedAt != null,
|
||||||
customerId: r.customer.id,
|
customerId: r.customer.id,
|
||||||
customerName: r.customer.name,
|
customerName: r.customer.name,
|
||||||
customerCity: r.customer.city,
|
customerCity: r.customer.city,
|
||||||
@@ -278,4 +304,229 @@ export class PoliciesService {
|
|||||||
daysToExpiry: daysUntil(policy.policyTo, from),
|
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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,34 @@
|
|||||||
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ServiceKind } from "@jorgecuadros/database";
|
import { ServiceKind } from "@jorgecuadros/database";
|
||||||
|
import { Request } from "express";
|
||||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
import {
|
import {
|
||||||
PropertiesService,
|
PropertiesService,
|
||||||
type PropertySort,
|
type PropertySort,
|
||||||
type TrustFilter,
|
type TrustFilter,
|
||||||
} from "./properties.service";
|
} from "./properties.service";
|
||||||
|
import {
|
||||||
|
CreatePropertyDto,
|
||||||
|
ServiceDto,
|
||||||
|
TrustDto,
|
||||||
|
UpdatePropertyDto,
|
||||||
|
UpdateServiceDto,
|
||||||
|
} from "./property.dto";
|
||||||
|
|
||||||
const KINDS: ServiceKind[] = [
|
const KINDS: ServiceKind[] = [
|
||||||
"WATER",
|
"WATER",
|
||||||
@@ -35,15 +58,21 @@ const SORTS: PropertySort[] = [
|
|||||||
"trust_due_desc",
|
"trust_due_desc",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Clamped trust-renewal window; 30 days matches the policies module. */
|
|
||||||
function parseDays(days?: string): number {
|
function parseDays(days?: string): number {
|
||||||
return Math.min(365, Math.max(1, Number(days) || 30));
|
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("properties")
|
@Controller("properties")
|
||||||
export class PropertiesController {
|
export class PropertiesController {
|
||||||
constructor(private readonly properties: PropertiesService) {}
|
constructor(
|
||||||
|
private readonly properties: PropertiesService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
stats(@Query("days") days?: string) {
|
stats(@Query("days") days?: string) {
|
||||||
@@ -67,6 +96,7 @@ export class PropertiesController {
|
|||||||
@Query("hasServices") hasServices?: string,
|
@Query("hasServices") hasServices?: string,
|
||||||
@Query("customerId") customerId?: string,
|
@Query("customerId") customerId?: string,
|
||||||
@Query("days") days?: string,
|
@Query("days") days?: string,
|
||||||
|
@Query("includeArchived") includeArchived?: string,
|
||||||
@Query("sort") sort?: string,
|
@Query("sort") sort?: string,
|
||||||
) {
|
) {
|
||||||
return this.properties.list({
|
return this.properties.list({
|
||||||
@@ -85,6 +115,7 @@ export class PropertiesController {
|
|||||||
hasServices === "true" ? true : hasServices === "false" ? false : undefined,
|
hasServices === "true" ? true : hasServices === "false" ? false : undefined,
|
||||||
customerId: customerId || undefined,
|
customerId: customerId || undefined,
|
||||||
days: parseDays(days),
|
days: parseDays(days),
|
||||||
|
includeArchived: includeArchived === "true",
|
||||||
sort: SORTS.includes(sort as PropertySort)
|
sort: SORTS.includes(sort as PropertySort)
|
||||||
? (sort as PropertySort)
|
? (sort as PropertySort)
|
||||||
: "customer",
|
: "customer",
|
||||||
@@ -95,4 +126,81 @@ export class PropertiesController {
|
|||||||
detail(@Param("id") id: string, @Query("days") days?: string) {
|
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||||
return this.properties.detail(id, parseDays(days));
|
return this.properties.detail(id, parseDays(days));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- header writes --------------------------------------------------------
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireAbility("property:create")
|
||||||
|
async create(@Body() dto: CreatePropertyDto, @Req() req: Request) {
|
||||||
|
const p = await this.properties.create(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "property.create", { propertyId: p.id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
async update(@Param("id") id: string, @Body() dto: UpdatePropertyDto, @Req() req: Request) {
|
||||||
|
const p = await this.properties.update(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "property.update", { propertyId: id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@RequireAbility("property:delete")
|
||||||
|
async archive(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const p = await this.properties.archive(id);
|
||||||
|
void this.audit.log(this.actingId(req), "property.archive", { propertyId: id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/restore")
|
||||||
|
@RequireAbility("property:delete")
|
||||||
|
async restore(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const p = await this.properties.restore(id);
|
||||||
|
void this.audit.log(this.actingId(req), "property.restore", { propertyId: id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- services (property:update) -------------------------------------------
|
||||||
|
|
||||||
|
@Post(":id/services")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
addService(@Param("id") id: string, @Body() dto: ServiceDto) {
|
||||||
|
return this.properties.addService(id, dto);
|
||||||
|
}
|
||||||
|
@Patch(":id/services/:childId")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
updateService(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("childId") childId: string,
|
||||||
|
@Body() dto: UpdateServiceDto,
|
||||||
|
) {
|
||||||
|
return this.properties.updateService(id, childId, dto);
|
||||||
|
}
|
||||||
|
@Delete(":id/services/:childId")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
removeService(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.properties.removeService(id, childId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- trust account (1:1) --------------------------------------------------
|
||||||
|
|
||||||
|
@Put(":id/trust")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
upsertTrust(@Param("id") id: string, @Body() dto: TrustDto) {
|
||||||
|
return this.properties.upsertTrust(id, dto);
|
||||||
|
}
|
||||||
|
@Delete(":id/trust")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
removeTrust(@Param("id") id: string) {
|
||||||
|
return this.properties.removeTrust(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- documents (remove pointer only) --------------------------------------
|
||||||
|
|
||||||
|
@Delete(":id/documents/:childId")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
removeDocument(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.properties.removeDocument(id, childId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma, ServiceKind } from "@jorgecuadros/database";
|
import { Prisma, ServiceKind } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { toDate } from "../common/coerce";
|
||||||
|
import {
|
||||||
|
CreatePropertyDto,
|
||||||
|
ServiceDto,
|
||||||
|
TrustDto,
|
||||||
|
UpdatePropertyDto,
|
||||||
|
UpdateServiceDto,
|
||||||
|
} from "./property.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2`
|
* Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2`
|
||||||
@@ -37,6 +45,7 @@ export interface ListParams {
|
|||||||
customerId?: string;
|
customerId?: string;
|
||||||
/** Window in days for the `expiring` trust bucket. */
|
/** Window in days for the `expiring` trust bucket. */
|
||||||
days: number;
|
days: number;
|
||||||
|
includeArchived?: boolean;
|
||||||
sort: PropertySort;
|
sort: PropertySort;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +143,14 @@ export class PropertiesService {
|
|||||||
hasServices,
|
hasServices,
|
||||||
customerId,
|
customerId,
|
||||||
days,
|
days,
|
||||||
|
includeArchived,
|
||||||
sort,
|
sort,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)];
|
const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)];
|
||||||
|
|
||||||
|
if (!includeArchived) and.push({ archivedAt: null });
|
||||||
|
|
||||||
// Sorting by trust due date is only meaningful for properties that have a
|
// Sorting by trust due date is only meaningful for properties that have a
|
||||||
// trust; MySQL would otherwise float the ~966 trust-less rows (NULL first
|
// trust; MySQL would otherwise float the ~966 trust-less rows (NULL first
|
||||||
// on ASC) above every real due date. Scoping is explicit in the UI label.
|
// on ASC) above every real due date. Scoping is explicit in the UI label.
|
||||||
@@ -191,6 +203,7 @@ export class PropertiesService {
|
|||||||
phone2: true,
|
phone2: true,
|
||||||
phone3: true,
|
phone3: true,
|
||||||
zone: true,
|
zone: true,
|
||||||
|
archivedAt: true,
|
||||||
customer: {
|
customer: {
|
||||||
select: { id: true, name: true, city: true, state: true },
|
select: { id: true, name: true, city: true, state: true },
|
||||||
},
|
},
|
||||||
@@ -221,6 +234,7 @@ export class PropertiesService {
|
|||||||
addressLine1: r.addressLine1,
|
addressLine1: r.addressLine1,
|
||||||
addressLine2: r.addressLine2,
|
addressLine2: r.addressLine2,
|
||||||
zone: r.zone,
|
zone: r.zone,
|
||||||
|
archived: r.archivedAt != null,
|
||||||
phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[],
|
phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[],
|
||||||
customerId: r.customer.id,
|
customerId: r.customer.id,
|
||||||
customerName: r.customer.name,
|
customerName: r.customer.name,
|
||||||
@@ -411,7 +425,7 @@ export class PropertiesService {
|
|||||||
});
|
});
|
||||||
const ledger = await this.prisma.transaction.groupBy({
|
const ledger = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { customerId: property.customerId, domain: "UTILITY" },
|
where: { customerId: property.customerId, domain: "UTILITY", voidedAt: null },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
@@ -443,4 +457,102 @@ export class PropertiesService {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- property header writes -----------------------------------------------
|
||||||
|
|
||||||
|
async create(dto: CreatePropertyDto) {
|
||||||
|
const customer = await this.prisma.customer.findUnique({
|
||||||
|
where: { id: dto.customerId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
|
||||||
|
return this.prisma.property.create({ data: { ...dto } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePropertyDto) {
|
||||||
|
await this.ensureProperty(id);
|
||||||
|
return this.prisma.property.update({ where: { id }, data: { ...dto } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(id: string) {
|
||||||
|
await this.ensureProperty(id);
|
||||||
|
return this.prisma.property.update({ where: { id }, data: { archivedAt: new Date() } });
|
||||||
|
}
|
||||||
|
async restore(id: string) {
|
||||||
|
await this.ensureProperty(id);
|
||||||
|
return this.prisma.property.update({ where: { id }, data: { archivedAt: null } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureProperty(id: string) {
|
||||||
|
const found = await this.prisma.property.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) throw new NotFoundException(`Property ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureService(propertyId: string, serviceId: string) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
const row = await this.prisma.propertyService.findFirst({
|
||||||
|
where: { id: serviceId, propertyId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException(`Service ${serviceId} not found on property ${propertyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- services -------------------------------------------------------------
|
||||||
|
|
||||||
|
async addService(propertyId: string, dto: ServiceDto) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
return this.prisma.propertyService.create({ data: { propertyId, ...dto } });
|
||||||
|
}
|
||||||
|
async updateService(propertyId: string, id: string, dto: UpdateServiceDto) {
|
||||||
|
await this.ensureService(propertyId, id);
|
||||||
|
return this.prisma.propertyService.update({ where: { id }, data: { ...dto } });
|
||||||
|
}
|
||||||
|
async removeService(propertyId: string, id: string) {
|
||||||
|
await this.ensureService(propertyId, id);
|
||||||
|
return this.prisma.propertyService.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- trust account (1:1 upsert) -------------------------------------------
|
||||||
|
|
||||||
|
async upsertTrust(propertyId: string, dto: TrustDto) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
const data = {
|
||||||
|
bankName: dto.bankName,
|
||||||
|
trustNumber: dto.trustNumber,
|
||||||
|
bankFee: dto.bankFee,
|
||||||
|
...(dto.dueDate1 !== undefined && { dueDate1: toDate(dto.dueDate1) }),
|
||||||
|
...(dto.dueDate2 !== undefined && { dueDate2: toDate(dto.dueDate2) }),
|
||||||
|
};
|
||||||
|
return this.prisma.trustAccount.upsert({
|
||||||
|
where: { propertyId },
|
||||||
|
create: { propertyId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async removeTrust(propertyId: string) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
const existing = await this.prisma.trustAccount.findUnique({
|
||||||
|
where: { propertyId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException(`No trust account on property ${propertyId}`);
|
||||||
|
return this.prisma.trustAccount.delete({ where: { propertyId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- documents ------------------------------------------------------------
|
||||||
|
// Removing a pointer row only; uploading files needs the object-storage
|
||||||
|
// client wired into the API (today only the migration writes to MinIO).
|
||||||
|
|
||||||
|
async removeDocument(propertyId: string, id: string) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
const row = await this.prisma.serviceDocument.findFirst({
|
||||||
|
where: { id, propertyId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
|
||||||
|
return this.prisma.serviceDocument.delete({ where: { id } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { ServiceKind } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
export class CreatePropertyDto {
|
||||||
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
|
@IsOptional() @IsString() policyId?: string;
|
||||||
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
|
@IsOptional() @IsString() phone1?: string;
|
||||||
|
@IsOptional() @IsString() phone2?: string;
|
||||||
|
@IsOptional() @IsString() phone3?: string;
|
||||||
|
@IsOptional() @IsString() zone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePropertyDto {
|
||||||
|
@IsOptional() @IsString() policyId?: string;
|
||||||
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
|
@IsOptional() @IsString() phone1?: string;
|
||||||
|
@IsOptional() @IsString() phone2?: string;
|
||||||
|
@IsOptional() @IsString() phone3?: string;
|
||||||
|
@IsOptional() @IsString() zone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServiceDto {
|
||||||
|
@IsEnum(ServiceKind) kind!: ServiceKind;
|
||||||
|
@IsOptional() @IsString() accountNumber?: string;
|
||||||
|
@IsOptional() @IsString() meterNumber?: string;
|
||||||
|
@IsOptional() @IsString() route?: string;
|
||||||
|
@IsOptional() @IsString() dueDay?: string;
|
||||||
|
@IsOptional() @IsBoolean() active?: boolean;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
}
|
||||||
|
export class UpdateServiceDto {
|
||||||
|
@IsOptional() @IsEnum(ServiceKind) kind?: ServiceKind;
|
||||||
|
@IsOptional() @IsString() accountNumber?: string;
|
||||||
|
@IsOptional() @IsString() meterNumber?: string;
|
||||||
|
@IsOptional() @IsString() route?: string;
|
||||||
|
@IsOptional() @IsString() dueDay?: string;
|
||||||
|
@IsOptional() @IsBoolean() active?: boolean;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trust is 1:1 with a property — this both creates and updates it (upsert). */
|
||||||
|
export class TrustDto {
|
||||||
|
@IsOptional() @IsString() bankName?: string;
|
||||||
|
@IsOptional() @IsString() trustNumber?: string;
|
||||||
|
@IsOptional() @IsNumber() bankFee?: number;
|
||||||
|
@IsOptional() @IsString() dueDate1?: string;
|
||||||
|
@IsOptional() @IsString() dueDate2?: string;
|
||||||
|
}
|
||||||
@@ -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 { Module } from "@nestjs/common";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
|
import { UsersController } from "./users.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
|
controllers: [UsersController],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@@ -1,11 +1,35 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import {
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import * as argon2 from "argon2";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import type { User } 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()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// --- used by auth (need the hash / full row) -----------------------------
|
||||||
|
|
||||||
findByEmail(email: string): Promise<User | null> {
|
findByEmail(email: string): Promise<User | null> {
|
||||||
return this.prisma.user.findUnique({ where: { email } });
|
return this.prisma.user.findUnique({ where: { email } });
|
||||||
}
|
}
|
||||||
@@ -13,4 +37,89 @@ export class UsersService {
|
|||||||
findById(id: string): Promise<User | null> {
|
findById(id: string): Promise<User | null> {
|
||||||
return this.prisma.user.findUnique({ where: { id } });
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import {
|
import {
|
||||||
|
createBankMovement,
|
||||||
getBankFacets,
|
getBankFacets,
|
||||||
getBankStats,
|
getBankStats,
|
||||||
getBankSummary,
|
getBankSummary,
|
||||||
listBankMovements,
|
listBankMovements,
|
||||||
|
voidBankMovement,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
import {
|
import {
|
||||||
bankDirectionLabel,
|
bankDirectionLabel,
|
||||||
bankSourceLabel,
|
bankSourceLabel,
|
||||||
@@ -27,6 +30,7 @@ import type {
|
|||||||
BankStats,
|
BankStats,
|
||||||
BankSummary,
|
BankSummary,
|
||||||
BankTotals,
|
BankTotals,
|
||||||
|
CreateBankMovementInput,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +81,8 @@ export default function BancoPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BankBrowser() {
|
function BankBrowser() {
|
||||||
|
const canCapture = useCan("bank:create");
|
||||||
|
const canVoid = useCan("bank:void");
|
||||||
const [stats, setStats] = useState<BankStats | null>(null);
|
const [stats, setStats] = useState<BankStats | null>(null);
|
||||||
const [facets, setFacets] = useState<BankFacets | null>(null);
|
const [facets, setFacets] = useState<BankFacets | null>(null);
|
||||||
const [view, setView] = useState<View>("movimientos");
|
const [view, setView] = useState<View>("movimientos");
|
||||||
@@ -93,6 +99,7 @@ function BankBrowser() {
|
|||||||
const [summaryYear, setSummaryYear] = useState<number | null>(null);
|
const [summaryYear, setSummaryYear] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [captureOpen, setCaptureOpen] = useState(false);
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
@@ -237,8 +244,28 @@ function BankBrowser() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{view === "movimientos" && canCapture && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setCaptureOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{view === "movimientos" && captureOpen && (
|
||||||
|
<BankCaptureForm
|
||||||
|
onSaved={() => {
|
||||||
|
setCaptureOpen(false);
|
||||||
|
runSearch(movements?.page ?? 1);
|
||||||
|
getBankStats().then(setStats).catch(() => setStats(null));
|
||||||
|
}}
|
||||||
|
onCancel={() => setCaptureOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{view === "movimientos" && (
|
{view === "movimientos" && (
|
||||||
<div className="filter-row">
|
<div className="filter-row">
|
||||||
<label className="filter-field">
|
<label className="filter-field">
|
||||||
@@ -383,11 +410,26 @@ function BankBrowser() {
|
|||||||
<th>Beneficiario / concepto</th>
|
<th>Beneficiario / concepto</th>
|
||||||
<th>Origen</th>
|
<th>Origen</th>
|
||||||
<th className="num">Monto</th>
|
<th className="num">Monto</th>
|
||||||
|
{canVoid && (
|
||||||
|
<th style={{ width: 1, whiteSpace: "nowrap" }}>
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{movements?.items.map((m) => (
|
{movements?.items.map((m) => (
|
||||||
<BankRow key={m.id} m={m} />
|
<BankRow
|
||||||
|
key={m.id}
|
||||||
|
m={m}
|
||||||
|
canVoid={canVoid}
|
||||||
|
onVoided={() => {
|
||||||
|
runSearch(movements?.page ?? 1);
|
||||||
|
getBankStats()
|
||||||
|
.then(setStats)
|
||||||
|
.catch(() => setStats(null));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -524,17 +566,44 @@ function FilteredTotals({ totals }: { totals: BankTotals }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BankRow({ m }: { m: BankListItem }) {
|
function BankRow({
|
||||||
|
m,
|
||||||
|
canVoid,
|
||||||
|
onVoided,
|
||||||
|
}: {
|
||||||
|
m: BankListItem;
|
||||||
|
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 voidBankMovement(m.id);
|
||||||
|
onVoided();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(
|
||||||
|
(e as Error)?.message ?? "No se pudo anular el movimiento.",
|
||||||
|
);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
|
||||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||||
{formatDate(m.transactionDate)}
|
{formatDate(m.transactionDate)}
|
||||||
</td>
|
</td>
|
||||||
<td className="tx-ref">
|
<td className="tx-ref">
|
||||||
{m.reference || "—"}
|
{m.reference || "—"}
|
||||||
{!m.cleared && (
|
{!m.cleared && <div className="tx-concept">Sin operar</div>}
|
||||||
<div className="tx-concept">Sin operar</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{m.concept || <span className="muted">Sin concepto</span>}
|
{m.concept || <span className="muted">Sin concepto</span>}
|
||||||
@@ -547,6 +616,21 @@ function BankRow({ m }: { m: BankListItem }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -687,6 +771,198 @@ function SummaryView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inline capture form for a single chequera movement. Single currency (MXN);
|
||||||
|
* sign convention: positive = ingreso, negative = egreso. Booked rows are
|
||||||
|
* never edited — fix mistakes with voidBankMovement + a fresh capture. */
|
||||||
|
function BankCaptureForm({
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
onSaved: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [direction, setDirection] = useState<BankDirection>("expense");
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [transactionDate, setTransactionDate] = useState(
|
||||||
|
new Date().toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const [concept, setConcept] = useState("");
|
||||||
|
const [reference, setReference] = useState("");
|
||||||
|
const [transactionType, setTransactionType] = useState("");
|
||||||
|
const [cleared, setCleared] = useState(true);
|
||||||
|
const [transferred, setTransferred] = useState(false);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [amountInWords, setAmountInWords] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function s(v: string): string | undefined {
|
||||||
|
const t = v.trim();
|
||||||
|
return t === "" ? undefined : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const abs = Number(amount);
|
||||||
|
if (!Number.isFinite(abs) || abs <= 0) {
|
||||||
|
setError("El monto debe ser un número mayor a cero.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
|
||||||
|
const payload: CreateBankMovementInput = {
|
||||||
|
amount: signed,
|
||||||
|
transactionDate,
|
||||||
|
concept: s(concept),
|
||||||
|
reference: s(reference),
|
||||||
|
transactionType: s(transactionType),
|
||||||
|
cleared,
|
||||||
|
transferred,
|
||||||
|
notes: s(notes),
|
||||||
|
amountInWords: s(amountInWords),
|
||||||
|
};
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createBankMovement(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 }}>
|
||||||
|
Capturar movimiento de chequera
|
||||||
|
</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Tipo <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={direction}
|
||||||
|
onChange={(e) => setDirection(e.target.value as BankDirection)}
|
||||||
|
>
|
||||||
|
<option value="income">Ingreso</option>
|
||||||
|
<option value="expense">Egreso</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Fecha <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={transactionDate}
|
||||||
|
onChange={(e) => setTransactionDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Monto (MXN) <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
min="0"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Concepto</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={concept}
|
||||||
|
onChange={(e) => setConcept(e.target.value)}
|
||||||
|
placeholder="Beneficiario o motivo"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Referencia / cheque</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={reference}
|
||||||
|
onChange={(e) => setReference(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Tipo en origen</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={transactionType}
|
||||||
|
onChange={(e) => setTransactionType(e.target.value)}
|
||||||
|
placeholder="INGRESO / EGRESO"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Monto en letras</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={amountInWords}
|
||||||
|
onChange={(e) => setAmountInWords(e.target.value)}
|
||||||
|
placeholder="Ej. CIENTO CINCUENTA MIL PESOS 00/100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="field"
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cleared}
|
||||||
|
onChange={(e) => setCleared(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="field-label" style={{ margin: 0 }}>
|
||||||
|
Operado por el banco
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="field"
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={transferred}
|
||||||
|
onChange={(e) => setTransferred(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="field-label" style={{ margin: 0 }}>
|
||||||
|
Transferencia
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field" style={{ marginTop: 16 }}>
|
||||||
|
<span className="field-label">Notas</span>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={2}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(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 Pager({
|
function Pager({
|
||||||
page,
|
page,
|
||||||
pageCount,
|
pageCount,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { getCustomer } from "@/lib/api";
|
import { archiveCustomer, getCustomer, restoreCustomer } from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
import {
|
import {
|
||||||
domainLabel,
|
domainLabel,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -86,12 +87,26 @@ function Detail({ id }: { id: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rise">
|
<div className="rise">
|
||||||
<BackLink />
|
<div className="detail-actionbar">
|
||||||
|
<BackLink />
|
||||||
|
<CustomerActions
|
||||||
|
customer={data}
|
||||||
|
onChange={() => getCustomer(id).then(setData).catch(() => {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
|
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
|
||||||
|
|
||||||
<DatosSection data={data} />
|
<DatosSection data={data} />
|
||||||
<PropiedadesSection properties={data.properties} />
|
<PropiedadesSection
|
||||||
<PolizasSection policies={data.policies} />
|
properties={data.properties}
|
||||||
|
customerId={data.id}
|
||||||
|
customerName={data.name}
|
||||||
|
/>
|
||||||
|
<PolizasSection
|
||||||
|
policies={data.policies}
|
||||||
|
customerId={data.id}
|
||||||
|
customerName={data.name}
|
||||||
|
/>
|
||||||
<EstadoCuentaSection
|
<EstadoCuentaSection
|
||||||
customerId={data.id}
|
customerId={data.id}
|
||||||
summary={data.transactionSummary}
|
summary={data.transactionSummary}
|
||||||
@@ -110,6 +125,58 @@ function BackLink() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 */
|
/* ------------------------------------------------------------------ Hero */
|
||||||
function Hero({
|
function Hero({
|
||||||
data,
|
data,
|
||||||
@@ -274,14 +341,33 @@ function KV({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------- Propiedades y servicios */
|
/* ----------------------------------------------- Propiedades y servicios */
|
||||||
function PropiedadesSection({ properties }: { properties: Property[] }) {
|
function PropiedadesSection({
|
||||||
|
properties,
|
||||||
|
customerId,
|
||||||
|
customerName,
|
||||||
|
}: {
|
||||||
|
properties: Property[];
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
}) {
|
||||||
|
const canCreate = useCan("property:create");
|
||||||
return (
|
return (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<SectionHead
|
<div className="detail-actionbar">
|
||||||
rule="servicios"
|
<SectionHead
|
||||||
title="Propiedades y servicios"
|
rule="servicios"
|
||||||
count={properties.length}
|
title="Propiedades y servicios"
|
||||||
/>
|
count={properties.length}
|
||||||
|
/>
|
||||||
|
{canCreate && (
|
||||||
|
<Link
|
||||||
|
href={`/servicios/nuevo?customerId=${customerId}&customerName=${encodeURIComponent(customerName)}`}
|
||||||
|
className="btn btn-outline"
|
||||||
|
>
|
||||||
|
+ Nueva propiedad
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{properties.length === 0 ? (
|
{properties.length === 0 ? (
|
||||||
<div className="empty-inline">
|
<div className="empty-inline">
|
||||||
@@ -393,14 +479,33 @@ function PropertyCard({ p }: { p: Property }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------ Pólizas de seguro */
|
/* ------------------------------------------------------ Pólizas de seguro */
|
||||||
function PolizasSection({ policies }: { policies: Policy[] }) {
|
function PolizasSection({
|
||||||
|
policies,
|
||||||
|
customerId,
|
||||||
|
customerName,
|
||||||
|
}: {
|
||||||
|
policies: Policy[];
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
}) {
|
||||||
|
const canCreate = useCan("policy:create");
|
||||||
return (
|
return (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<SectionHead
|
<div className="detail-actionbar">
|
||||||
rule="seguros"
|
<SectionHead
|
||||||
title="Pólizas de seguro"
|
rule="seguros"
|
||||||
count={policies.length}
|
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">
|
<div className="card">
|
||||||
{policies.length === 0 ? (
|
{policies.length === 0 ? (
|
||||||
<div className="empty-inline">
|
<div className="empty-inline">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { getStats, listCustomers } from "@/lib/api";
|
import { getStats, listCustomers } from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
import { formatNumber, SIN_NOMBRE } from "@/lib/labels";
|
import { formatNumber, SIN_NOMBRE } from "@/lib/labels";
|
||||||
import type {
|
import type {
|
||||||
BusinessLine,
|
BusinessLine,
|
||||||
@@ -39,6 +40,8 @@ function ClientesBrowser() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canCreate = useCan("customer:create");
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,7 +96,15 @@ function ClientesBrowser() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-head rise">
|
<div className="page-head rise">
|
||||||
<p className="eyebrow">Directorio unificado</p>
|
<p className="eyebrow">Directorio unificado</p>
|
||||||
<h1 className="page-title">Clientes</h1>
|
<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} />
|
<StatStrip stats={stats} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { getStatement } from "@/lib/api";
|
import { MovementForm } from "@/components/MovementForm";
|
||||||
|
import {
|
||||||
|
getBillingFacets,
|
||||||
|
getStatement,
|
||||||
|
voidMovement,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
import {
|
import {
|
||||||
balancePhrase,
|
balancePhrase,
|
||||||
balanceTone,
|
balanceTone,
|
||||||
@@ -17,6 +23,7 @@ import {
|
|||||||
txTypeLabel,
|
txTypeLabel,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type {
|
import type {
|
||||||
|
BillingFacets,
|
||||||
LedgerCurrency,
|
LedgerCurrency,
|
||||||
Statement,
|
Statement,
|
||||||
StatementMovement,
|
StatementMovement,
|
||||||
@@ -48,14 +55,18 @@ export default function EstadoCuentaDetailPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatementView({ id }: { id: string }) {
|
function StatementView({ id }: { id: string }) {
|
||||||
|
const canCapture = useCan("ledger:create");
|
||||||
|
const canVoid = useCan("ledger:void");
|
||||||
const [data, setData] = useState<Statement | null>(null);
|
const [data, setData] = useState<Statement | null>(null);
|
||||||
|
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [captureOpen, setCaptureOpen] = useState(false);
|
||||||
|
|
||||||
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
||||||
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
||||||
|
|
||||||
useEffect(() => {
|
function reload() {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -63,9 +74,10 @@ function StatementView({ id }: { id: string }) {
|
|||||||
.then((d) => {
|
.then((d) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setData(d);
|
setData(d);
|
||||||
// Default to the currency the customer actually moves the most in.
|
// 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];
|
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
||||||
setCurrency(busiest?.currency ?? "MXN");
|
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -80,6 +92,13 @@ function StatementView({ id }: { id: string }) {
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = reload();
|
||||||
|
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||||
|
return cleanup;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const movements = useMemo(() => {
|
const movements = useMemo(() => {
|
||||||
@@ -178,8 +197,35 @@ function StatementView({ id }: { id: string }) {
|
|||||||
title="Movimientos"
|
title="Movimientos"
|
||||||
count={movements.length}
|
count={movements.length}
|
||||||
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
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">
|
<div className="filter-row">
|
||||||
<label className="filter-field">
|
<label className="filter-field">
|
||||||
<span className="filter-label">Moneda</span>
|
<span className="filter-label">Moneda</span>
|
||||||
@@ -228,11 +274,21 @@ function StatementView({ id }: { id: string }) {
|
|||||||
<th>Referencia</th>
|
<th>Referencia</th>
|
||||||
<th className="num">Cargo / Abono</th>
|
<th className="num">Cargo / Abono</th>
|
||||||
<th className="num">Saldo</th>
|
<th className="num">Saldo</th>
|
||||||
|
{canVoid && (
|
||||||
|
<th style={{ width: 1, whiteSpace: "nowrap" }}>
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{movements.map((m) => (
|
{movements.map((m) => (
|
||||||
<StatementRow key={m.id} m={m} />
|
<StatementRow
|
||||||
|
key={m.id}
|
||||||
|
m={m}
|
||||||
|
canVoid={canVoid}
|
||||||
|
onVoided={reload}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -425,10 +481,39 @@ function ConceptosSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatementRow({ m }: { m: StatementMovement }) {
|
function StatementRow({
|
||||||
|
m,
|
||||||
|
canVoid,
|
||||||
|
onVoided,
|
||||||
|
}: {
|
||||||
|
m: StatementMovement;
|
||||||
|
canVoid: boolean;
|
||||||
|
onVoided: () => void;
|
||||||
|
}) {
|
||||||
const concept = m.message || m.period || null;
|
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 (
|
return (
|
||||||
<tr>
|
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
|
||||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||||
{formatDate(m.transactionDate)}
|
{formatDate(m.transactionDate)}
|
||||||
</td>
|
</td>
|
||||||
@@ -455,6 +540,21 @@ function StatementRow({ m }: { m: StatementMovement }) {
|
|||||||
{formatMoney(m.balanceAfter, m.currency)}
|
{formatMoney(m.balanceAfter, m.currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -464,14 +564,23 @@ function SectionHead({
|
|||||||
title,
|
title,
|
||||||
count,
|
count,
|
||||||
countSuffix,
|
countSuffix,
|
||||||
|
right,
|
||||||
}: {
|
}: {
|
||||||
rule: string;
|
rule: string;
|
||||||
title: string;
|
title: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
countSuffix?: string;
|
countSuffix?: string;
|
||||||
|
right?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="section-head">
|
<div
|
||||||
|
className="section-head"
|
||||||
|
style={
|
||||||
|
right
|
||||||
|
? { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<span className={`section-rule ${rule}`} aria-hidden />
|
<span className={`section-rule ${rule}`} aria-hidden />
|
||||||
<h2 className="section-title">{title}</h2>
|
<h2 className="section-title">{title}</h2>
|
||||||
{count != null && (
|
{count != null && (
|
||||||
@@ -479,6 +588,9 @@ function SectionHead({
|
|||||||
{formatNumber(count)} {countSuffix ?? ""}
|
{formatNumber(count)} {countSuffix ?? ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{right && (
|
||||||
|
<div style={{ marginLeft: "auto" }}>{right}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { MovementForm } from "@/components/MovementForm";
|
||||||
import {
|
import {
|
||||||
getBillingFacets,
|
getBillingFacets,
|
||||||
getBillingStats,
|
getBillingStats,
|
||||||
listBalances,
|
listBalances,
|
||||||
listMovements,
|
listMovements,
|
||||||
|
voidMovement,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
import {
|
import {
|
||||||
balancePhrase,
|
balancePhrase,
|
||||||
balanceTone,
|
balanceTone,
|
||||||
@@ -95,6 +98,8 @@ export default function EstadoCuentaPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BillingBrowser() {
|
function BillingBrowser() {
|
||||||
|
const canCapture = useCan("ledger:create");
|
||||||
|
const canVoid = useCan("ledger:void");
|
||||||
const [stats, setStats] = useState<BillingStats | null>(null);
|
const [stats, setStats] = useState<BillingStats | null>(null);
|
||||||
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
||||||
const [view, setView] = useState<View>("saldos");
|
const [view, setView] = useState<View>("saldos");
|
||||||
@@ -119,6 +124,7 @@ function BillingBrowser() {
|
|||||||
const [movements, setMovements] = useState<MovementListResponse | null>(null);
|
const [movements, setMovements] = useState<MovementListResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [captureOpen, setCaptureOpen] = useState(false);
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
@@ -287,8 +293,38 @@ function BillingBrowser() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{view === "movimientos" && canCapture && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setCaptureOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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">
|
<div className="filter-row">
|
||||||
<label className="filter-field">
|
<label className="filter-field">
|
||||||
<span className="filter-label">Moneda</span>
|
<span className="filter-label">Moneda</span>
|
||||||
@@ -506,11 +542,22 @@ function BillingBrowser() {
|
|||||||
<th>Concepto</th>
|
<th>Concepto</th>
|
||||||
<th>Referencia</th>
|
<th>Referencia</th>
|
||||||
<th className="num">Monto</th>
|
<th className="num">Monto</th>
|
||||||
|
{canVoid && <th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{movements?.items.map((m) => (
|
{movements?.items.map((m) => (
|
||||||
<MovementRow key={m.id} m={m} />
|
<MovementRow
|
||||||
|
key={m.id}
|
||||||
|
m={m}
|
||||||
|
canVoid={canVoid}
|
||||||
|
onVoided={() => {
|
||||||
|
runSearch(movements?.page ?? 1);
|
||||||
|
getBillingStats()
|
||||||
|
.then(setStats)
|
||||||
|
.catch(() => setStats(null));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -739,9 +786,32 @@ function BalanceRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MovementRow({ m }: { m: MovementListItem }) {
|
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 (
|
return (
|
||||||
<tr>
|
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
|
||||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||||
{formatDate(m.transactionDate)}
|
{formatDate(m.transactionDate)}
|
||||||
</td>
|
</td>
|
||||||
@@ -776,6 +846,21 @@ function MovementRow({ m }: { m: MovementListItem }) {
|
|||||||
{m.currency} · {directionLabel(m.direction)}
|
{m.currency} · {directionLabel(m.direction)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ button {
|
|||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.appbar-link:hover {
|
.appbar-link:hover {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -267,12 +268,108 @@ button {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(242, 239, 231, 0.7);
|
color: rgba(242, 239, 231, 0.7);
|
||||||
}
|
}
|
||||||
|
.appbar-user-name {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.appbar-user-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(242, 239, 231, 0.45);
|
||||||
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.appbar-user .appbar-user-name {
|
.appbar-user .appbar-user-name {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Admin form grid (users, and future CRUD forms)
|
||||||
|
========================================================================== */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.detail-actionbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.inline-form-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted, #6b7280);
|
||||||
|
margin: 4px 0 14px;
|
||||||
|
}
|
||||||
|
.child-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.child-editor {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
.picker {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.picker-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.picker-list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 4px;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.picker-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.picker-item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Buttons
|
Buttons
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -304,13 +401,24 @@ button {
|
|||||||
}
|
}
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--brand-600);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
/* Dark appbar keeps the light-on-dark ghost button. */
|
||||||
|
.appbar .btn-ghost {
|
||||||
color: rgba(242, 239, 231, 0.85);
|
color: rgba(242, 239, 231, 0.85);
|
||||||
border-color: rgba(255, 255, 255, 0.22);
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
}
|
}
|
||||||
.btn-ghost:hover {
|
.appbar .btn-ghost:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-decoration: none;
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
}
|
}
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -2057,3 +2165,42 @@ button {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Admin DB operations (Operaciones) --- */
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--negative);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
filter: brightness(0.94);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-log {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,17 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { getPolicy } from "@/lib/api";
|
import {
|
||||||
|
addPolicyChild,
|
||||||
|
archivePolicy,
|
||||||
|
getLookups,
|
||||||
|
getPolicy,
|
||||||
|
removePolicyChild,
|
||||||
|
restorePolicy,
|
||||||
|
updatePolicyChild,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
|
||||||
import {
|
import {
|
||||||
expiryPhrase,
|
expiryPhrase,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -12,7 +22,7 @@ import {
|
|||||||
premiumHeadline,
|
premiumHeadline,
|
||||||
SIN_NOMBRE,
|
SIN_NOMBRE,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type { Installment, PolicyDetail } from "@/lib/types";
|
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
||||||
|
|
||||||
export default function PolizaDetailPage({
|
export default function PolizaDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -73,9 +83,14 @@ function Detail({ id }: { id: string }) {
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
const reload = () => getPolicy(id).then(setData).catch(() => {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rise">
|
<div className="rise">
|
||||||
<BackLink />
|
<div className="detail-actionbar">
|
||||||
|
<BackLink />
|
||||||
|
<PolicyActions data={data} onChange={reload} />
|
||||||
|
</div>
|
||||||
<Hero data={data} />
|
<Hero data={data} />
|
||||||
<ClienteSection data={data} />
|
<ClienteSection data={data} />
|
||||||
<CondicionesSection data={data} />
|
<CondicionesSection data={data} />
|
||||||
@@ -87,10 +102,163 @@ function Detail({ id }: { id: string }) {
|
|||||||
{data.claims.length > 0 && <SiniestrosSection data={data} />}
|
{data.claims.length > 0 && <SiniestrosSection data={data} />}
|
||||||
<CoberturasSection data={data} />
|
<CoberturasSection data={data} />
|
||||||
<DocumentosSection data={data} />
|
<DocumentosSection data={data} />
|
||||||
|
<ChildrenEditor data={data} onChange={reload} />
|
||||||
</div>
|
</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() {
|
function BackLink() {
|
||||||
return (
|
return (
|
||||||
<Link href="/polizas" className="back-link">
|
<Link href="/polizas" className="back-link">
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
import {
|
import {
|
||||||
EXPIRY_WINDOW_DAYS,
|
EXPIRY_WINDOW_DAYS,
|
||||||
getPolicyFacets,
|
getPolicyFacets,
|
||||||
@@ -54,6 +55,7 @@ export default function PolizasPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PolizasBrowser() {
|
function PolizasBrowser() {
|
||||||
|
const canCreate = useCan("policy:create");
|
||||||
const [stats, setStats] = useState<PolicyStats | null>(null);
|
const [stats, setStats] = useState<PolicyStats | null>(null);
|
||||||
const [facets, setFacets] = useState<PolicyFacets | null>(null);
|
const [facets, setFacets] = useState<PolicyFacets | null>(null);
|
||||||
|
|
||||||
@@ -122,7 +124,13 @@ function PolizasBrowser() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-head rise">
|
<div className="page-head rise">
|
||||||
<p className="eyebrow">Cartera de seguros</p>
|
<p className="eyebrow">Cartera de seguros</p>
|
||||||
<h1 className="page-title">Pólizas</h1>
|
<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
|
<StatStrip
|
||||||
stats={stats}
|
stats={stats}
|
||||||
status={status}
|
status={status}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { PropertyForm } from "@/components/PropertyForm";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { getProperty } from "@/lib/api";
|
||||||
|
import type { PropertyDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function EditarPropiedadPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<EditarPropiedad id={params.id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditarPropiedad({ id }: { id: string }) {
|
||||||
|
const allowed = useCan("property:update");
|
||||||
|
const [property, setProperty] = useState<PropertyDetail | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowed) return;
|
||||||
|
getProperty(id)
|
||||||
|
.then(setProperty)
|
||||||
|
.catch((e) => setError(e?.message ?? "No se pudo cargar la propiedad."));
|
||||||
|
}, [id, allowed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<Link href={`/servicios/${id}`} className="back-link">← Propiedad</Link>
|
||||||
|
<h1 className="page-title">Editar propiedad</h1>
|
||||||
|
</div>
|
||||||
|
{!allowed ? (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para editar propiedades.
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="state-box state-error">{error}</div>
|
||||||
|
) : !property ? (
|
||||||
|
<div className="empty-inline"><span className="spinner" aria-label="Cargando" /></div>
|
||||||
|
) : (
|
||||||
|
<PropertyForm property={property} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,19 +3,32 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { getProperty } from "@/lib/api";
|
import {
|
||||||
|
addService,
|
||||||
|
archiveProperty,
|
||||||
|
getProperty,
|
||||||
|
removePropertyDocument,
|
||||||
|
removeService,
|
||||||
|
removeTrust,
|
||||||
|
restoreProperty,
|
||||||
|
updateService,
|
||||||
|
upsertTrust,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
|
||||||
import {
|
import {
|
||||||
expiryPhrase,
|
expiryPhrase,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
SERVICE_KIND_LABELS,
|
||||||
serviceKindGlyph,
|
serviceKindGlyph,
|
||||||
serviceKindLabel,
|
serviceKindLabel,
|
||||||
serviceNoteLabel,
|
serviceNoteLabel,
|
||||||
SIN_NOMBRE,
|
SIN_NOMBRE,
|
||||||
trustStatusLabel,
|
trustStatusLabel,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type { PropertyDetail, Service, Transaction } from "@/lib/types";
|
import type { PropertyDetail, Service, Transaction, TrustInput } from "@/lib/types";
|
||||||
|
|
||||||
export default function PropiedadDetailPage({
|
export default function PropiedadDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -76,9 +89,14 @@ function Detail({ id }: { id: string }) {
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
const reload = () => getProperty(id).then(setData).catch(() => {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rise">
|
<div className="rise">
|
||||||
<BackLink />
|
<div className="detail-actionbar">
|
||||||
|
<BackLink />
|
||||||
|
<PropertyActions data={data} onChange={reload} />
|
||||||
|
</div>
|
||||||
<Hero data={data} />
|
<Hero data={data} />
|
||||||
<ClienteSection data={data} />
|
<ClienteSection data={data} />
|
||||||
<ServiciosSection data={data} />
|
<ServiciosSection data={data} />
|
||||||
@@ -86,10 +104,246 @@ function Detail({ id }: { id: string }) {
|
|||||||
{data.policy && <PolizaSection data={data} />}
|
{data.policy && <PolizaSection data={data} />}
|
||||||
<MovimientosSection data={data} />
|
<MovimientosSection data={data} />
|
||||||
<DocumentosSection data={data} />
|
<DocumentosSection data={data} />
|
||||||
|
<PropertyEditor data={data} onChange={reload} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Edit / archive controls for the property header. */
|
||||||
|
function PropertyActions({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: PropertyDetail;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const canEdit = useCan("property:update");
|
||||||
|
const canDelete = useCan("property:delete");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const archived = data.archivedAt != null;
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
const verb = archived ? "restaurar" : "archivar";
|
||||||
|
if (!window.confirm(`¿Seguro que desea ${verb} esta propiedad?`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (archived) await restoreProperty(data.id);
|
||||||
|
else await archiveProperty(data.id);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEdit && !canDelete) return null;
|
||||||
|
return (
|
||||||
|
<div className="row-actions">
|
||||||
|
{archived && <span className="badge badge-negative">Archivada</span>}
|
||||||
|
{canEdit && (
|
||||||
|
<Link href={`/servicios/${data.id}/editar`} className="btn btn-outline">
|
||||||
|
Editar
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={toggle} disabled={busy}>
|
||||||
|
{archived ? "Restaurar" : "Archivar"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable services + trust + documents — only for users who can edit. */
|
||||||
|
function PropertyEditor({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: PropertyDetail;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const canEdit = useCan("property:update");
|
||||||
|
if (!canEdit) return null;
|
||||||
|
|
||||||
|
const SERVICES: ChildConfig = {
|
||||||
|
apiKind: "services",
|
||||||
|
title: "Servicios",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "kind",
|
||||||
|
label: "Tipo",
|
||||||
|
type: "select",
|
||||||
|
options: Object.entries(SERVICE_KIND_LABELS).map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ key: "accountNumber", label: "Cuenta" },
|
||||||
|
{ key: "meterNumber", label: "Medidor" },
|
||||||
|
{ key: "route", label: "Ruta" },
|
||||||
|
{ key: "dueDay", label: "Día pago" },
|
||||||
|
{ key: "notes", label: "Notas" },
|
||||||
|
{ key: "active", label: "Activo", type: "checkbox" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="section-head">
|
||||||
|
<span className="section-rule cuenta" aria-hidden />
|
||||||
|
<h2 className="section-title">Administrar propiedad</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChildCollection
|
||||||
|
config={SERVICES}
|
||||||
|
rows={data.services as unknown as Record<string, unknown>[]}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onAdd={async (p) => { await addService(data.id, p as never); onChange(); }}
|
||||||
|
onSave={async (sid, p) => { await updateService(data.id, sid, p as never); onChange(); }}
|
||||||
|
onRemove={async (sid) => { await removeService(data.id, sid); onChange(); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TrustEditor data={data} onChange={onChange} />
|
||||||
|
|
||||||
|
{data.documents.length > 0 && (
|
||||||
|
<div className="card" style={{ padding: 16 }}>
|
||||||
|
<h3 className="section-title" style={{ marginTop: 0 }}>Documentos</h3>
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Tipo</th><th>Clave</th><th className="num">Acción</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.documents.map((d) => (
|
||||||
|
<tr key={d.id ?? d.storageKey}>
|
||||||
|
<td>{d.documentType ?? "—"}</td>
|
||||||
|
<td className="mono">{d.storageKey ?? "—"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!d.id) return;
|
||||||
|
if (!window.confirm("¿Eliminar este documento?")) return;
|
||||||
|
try {
|
||||||
|
await removePropertyDocument(data.id, d.id);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="inline-form-note">
|
||||||
|
La carga de nuevos documentos requiere el almacenamiento de archivos
|
||||||
|
(pendiente); aquí solo se pueden eliminar los existentes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trust is 1:1 — a small inline form that upserts or clears it. */
|
||||||
|
function TrustEditor({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: PropertyDetail;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const t = data.trustAccount;
|
||||||
|
const [bankName, setBankName] = useState(t?.bankName ?? "");
|
||||||
|
const [trustNumber, setTrustNumber] = useState(t?.trustNumber ?? "");
|
||||||
|
const [bankFee, setBankFee] = useState(t?.bankFee != null ? String(t.bankFee) : "");
|
||||||
|
const [dueDate1, setDueDate1] = useState(toDateInput(t?.dueDate1));
|
||||||
|
const [dueDate2, setDueDate2] = useState(toDateInput(t?.dueDate2));
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true);
|
||||||
|
const input: TrustInput = {
|
||||||
|
bankName: bankName.trim() || undefined,
|
||||||
|
trustNumber: trustNumber.trim() || undefined,
|
||||||
|
bankFee: bankFee.trim() === "" ? undefined : Number(bankFee),
|
||||||
|
dueDate1: dueDate1 || undefined,
|
||||||
|
dueDate2: dueDate2 || undefined,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await upsertTrust(data.id, input);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo guardar el fideicomiso.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear() {
|
||||||
|
if (!window.confirm("¿Eliminar el fideicomiso de esta propiedad?")) return;
|
||||||
|
try {
|
||||||
|
await removeTrust(data.id);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||||
|
<h3 className="section-title" style={{ marginTop: 0 }}>Fideicomiso</h3>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Banco</span>
|
||||||
|
<input className="input" value={bankName} onChange={(e) => setBankName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">No. fideicomiso</span>
|
||||||
|
<input className="input" value={trustNumber} onChange={(e) => setTrustNumber(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Cuota banco</span>
|
||||||
|
<input className="input" type="number" step="0.01" value={bankFee} onChange={(e) => setBankFee(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Vence 1</span>
|
||||||
|
<input className="input" type="date" value={dueDate1} onChange={(e) => setDueDate1(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Vence 2 (próxima)</span>
|
||||||
|
<input className="input" type="date" value={dueDate2} onChange={(e) => setDueDate2(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
|
||||||
|
{busy ? "Guardando…" : t ? "Guardar fideicomiso" : "Crear fideicomiso"}
|
||||||
|
</button>
|
||||||
|
{t && (
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={clear}>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInput(v: string | null | undefined): string {
|
||||||
|
if (!v) return "";
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function BackLink() {
|
function BackLink() {
|
||||||
return (
|
return (
|
||||||
<Link href="/servicios" className="back-link">
|
<Link href="/servicios" className="back-link">
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { PropertyForm } from "@/components/PropertyForm";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
|
||||||
|
export default function NuevaPropiedadPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<NuevaPropiedad />
|
||||||
|
</Suspense>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NuevaPropiedad() {
|
||||||
|
const allowed = useCan("property:create");
|
||||||
|
const params = useSearchParams();
|
||||||
|
const customerId = params.get("customerId") ?? undefined;
|
||||||
|
const customerName = params.get("customerName") ?? undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<Link href="/servicios" className="back-link">← Propiedades</Link>
|
||||||
|
<h1 className="page-title">Nueva propiedad</h1>
|
||||||
|
</div>
|
||||||
|
{allowed ? (
|
||||||
|
<PropertyForm fixedCustomerId={customerId} fixedCustomerName={customerName} />
|
||||||
|
) : (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para crear propiedades.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
import {
|
import {
|
||||||
EXPIRY_WINDOW_DAYS,
|
EXPIRY_WINDOW_DAYS,
|
||||||
getPropertyFacets,
|
getPropertyFacets,
|
||||||
@@ -81,6 +82,7 @@ export default function ServiciosPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ServiciosBrowser() {
|
function ServiciosBrowser() {
|
||||||
|
const canCreate = useCan("property:create");
|
||||||
const [stats, setStats] = useState<PropertyStats | null>(null);
|
const [stats, setStats] = useState<PropertyStats | null>(null);
|
||||||
const [facets, setFacets] = useState<PropertyFacets | null>(null);
|
const [facets, setFacets] = useState<PropertyFacets | null>(null);
|
||||||
|
|
||||||
@@ -164,7 +166,13 @@ function ServiciosBrowser() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-head rise">
|
<div className="page-head rise">
|
||||||
<p className="eyebrow">Administración de servicios</p>
|
<p className="eyebrow">Administración de servicios</p>
|
||||||
<h1 className="page-title">Propiedades</h1>
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
|
<h1 className="page-title" style={{ margin: 0 }}>Propiedades</h1>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{canCreate && (
|
||||||
|
<Link href="/servicios/nuevo" className="btn btn-primary">+ Nueva propiedad</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<StatStrip stats={stats} focus={focus} onPickFocus={pickFocus} />
|
<StatStrip stats={stats} focus={focus} onPickFocus={pickFocus} />
|
||||||
<ServiceMixStrip
|
<ServiceMixStrip
|
||||||
stats={stats}
|
stats={stats}
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,19 +4,25 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { logout, me } from "@/lib/api";
|
import { logout, me } from "@/lib/api";
|
||||||
import type { AuthUser } from "@/lib/types";
|
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
|
* Authenticated shell: gates on /auth/me, redirects to /login when the
|
||||||
* session is missing, renders the brand header + logout, and wraps page
|
* session is missing, renders the brand header + logout, and wraps page
|
||||||
* content. Used by every authenticated page.
|
* content. Provides the AuthContext so any page can read the user's
|
||||||
|
* abilities. Used by every authenticated page.
|
||||||
*/
|
*/
|
||||||
const NAV = [
|
const NAV: { href: string; label: string; ability?: Ability }[] = [
|
||||||
{ href: "/clientes", label: "Clientes" },
|
{ href: "/clientes", label: "Clientes" },
|
||||||
{ href: "/servicios", label: "Propiedades" },
|
{ href: "/servicios", label: "Propiedades" },
|
||||||
{ href: "/polizas", label: "Pólizas" },
|
{ href: "/polizas", label: "Pólizas" },
|
||||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||||
{ href: "/banco", label: "Chequera" },
|
{ 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 }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
@@ -69,7 +75,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AuthContext.Provider value={user}>
|
||||||
<header className="appbar">
|
<header className="appbar">
|
||||||
<div className="appbar-inner">
|
<div className="appbar-inner">
|
||||||
<Link href="/clientes" className="brand">
|
<Link href="/clientes" className="brand">
|
||||||
@@ -82,24 +88,29 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="appbar-nav" aria-label="Principal">
|
<nav className="appbar-nav" aria-label="Principal">
|
||||||
{NAV.map((item) => {
|
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
||||||
const active = pathname?.startsWith(item.href) ?? false;
|
(item) => {
|
||||||
return (
|
const active = pathname?.startsWith(item.href) ?? false;
|
||||||
<Link
|
return (
|
||||||
key={item.href}
|
<Link
|
||||||
href={item.href}
|
key={item.href}
|
||||||
className={`appbar-link${active ? " active" : ""}`}
|
href={item.href}
|
||||||
aria-current={active ? "page" : undefined}
|
className={`appbar-link${active ? " active" : ""}`}
|
||||||
>
|
aria-current={active ? "page" : undefined}
|
||||||
{item.label}
|
>
|
||||||
</Link>
|
{item.label}
|
||||||
);
|
</Link>
|
||||||
})}
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<span className="appbar-spacer" />
|
<span className="appbar-spacer" />
|
||||||
<div className="appbar-user">
|
<div className="appbar-user">
|
||||||
{user && (
|
{user && (
|
||||||
<span className="appbar-user-name">{user.name}</span>
|
<span className="appbar-user-name">
|
||||||
|
{user.name}
|
||||||
|
<span className="appbar-user-role">{ROLE_LABEL[user.role]}</span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -113,6 +124,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="shell-main">{children}</main>
|
<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;
|
||||||
|
}
|
||||||
@@ -16,7 +16,10 @@ import type {
|
|||||||
BillingFacets,
|
BillingFacets,
|
||||||
BillingStats,
|
BillingStats,
|
||||||
BusinessLine,
|
BusinessLine,
|
||||||
|
CreateBankMovementInput,
|
||||||
|
CreateMovementInput,
|
||||||
CustomerDetail,
|
CustomerDetail,
|
||||||
|
CustomerInput,
|
||||||
CustomerListResponse,
|
CustomerListResponse,
|
||||||
CustomerStats,
|
CustomerStats,
|
||||||
LedgerCurrency,
|
LedgerCurrency,
|
||||||
@@ -25,19 +28,31 @@ import type {
|
|||||||
MovementSort,
|
MovementSort,
|
||||||
PolicyDetail,
|
PolicyDetail,
|
||||||
PolicyFacets,
|
PolicyFacets,
|
||||||
|
PolicyInput,
|
||||||
PolicyListResponse,
|
PolicyListResponse,
|
||||||
PolicySort,
|
PolicySort,
|
||||||
PolicyStats,
|
PolicyStats,
|
||||||
PolicyStatus,
|
PolicyStatus,
|
||||||
|
LookupsResponse,
|
||||||
|
OpsJob,
|
||||||
|
OpsJobKind,
|
||||||
|
IngestFile,
|
||||||
|
BackupFile,
|
||||||
PropertyDetail,
|
PropertyDetail,
|
||||||
PropertyFacets,
|
PropertyFacets,
|
||||||
|
PropertyInput,
|
||||||
PropertyListResponse,
|
PropertyListResponse,
|
||||||
PropertySort,
|
PropertySort,
|
||||||
PropertyStats,
|
PropertyStats,
|
||||||
|
ServiceInput,
|
||||||
|
TrustInput,
|
||||||
|
Role,
|
||||||
ServiceKind,
|
ServiceKind,
|
||||||
Statement,
|
Statement,
|
||||||
|
Transaction,
|
||||||
TransactionDomain,
|
TransactionDomain,
|
||||||
TrustFilter,
|
TrustFilter,
|
||||||
|
UserRow,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const API_ORIGIN =
|
export const API_ORIGIN =
|
||||||
@@ -130,6 +145,31 @@ export function getCustomer(id: string): Promise<CustomerDetail> {
|
|||||||
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
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 */
|
/* ------------------------------------------------------ Policies module */
|
||||||
|
|
||||||
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
||||||
@@ -180,6 +220,83 @@ export function getPolicy(
|
|||||||
return apiFetch<PolicyDetail>(`/policies/${id}?days=${days}`);
|
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 */
|
/* ----------------------------------------------------- Utilities module */
|
||||||
|
|
||||||
export interface PropertyQuery {
|
export interface PropertyQuery {
|
||||||
@@ -231,6 +348,71 @@ export function getProperty(
|
|||||||
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
|
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createProperty(input: PropertyInput): Promise<PropertyDetail> {
|
||||||
|
return apiFetch<PropertyDetail>("/properties", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function updateProperty(
|
||||||
|
id: string,
|
||||||
|
input: Partial<PropertyInput>,
|
||||||
|
): Promise<PropertyDetail> {
|
||||||
|
return apiFetch<PropertyDetail>(`/properties/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function archiveProperty(id: string): Promise<PropertyDetail> {
|
||||||
|
return apiFetch<PropertyDetail>(`/properties/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
export function restoreProperty(id: string): Promise<PropertyDetail> {
|
||||||
|
return apiFetch<PropertyDetail>(`/properties/${id}/restore`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service child CRUD.
|
||||||
|
export function addService(propertyId: string, input: ServiceInput): Promise<unknown> {
|
||||||
|
return apiFetch(`/properties/${propertyId}/services`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function updateService(
|
||||||
|
propertyId: string,
|
||||||
|
serviceId: string,
|
||||||
|
input: Partial<ServiceInput>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function removeService(propertyId: string, serviceId: string): Promise<unknown> {
|
||||||
|
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust account (1:1 upsert).
|
||||||
|
export function upsertTrust(propertyId: string, input: TrustInput): Promise<unknown> {
|
||||||
|
return apiFetch(`/properties/${propertyId}/trust`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function removeTrust(propertyId: string): Promise<unknown> {
|
||||||
|
return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePropertyDocument(
|
||||||
|
propertyId: string,
|
||||||
|
documentId: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/properties/${propertyId}/documents/${documentId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------- Billing / statements module */
|
/* ------------------------------------------- Billing / statements module */
|
||||||
|
|
||||||
export interface MovementQuery {
|
export interface MovementQuery {
|
||||||
@@ -302,6 +484,22 @@ export function getStatement(customerId: string): Promise<Statement> {
|
|||||||
return apiFetch<Statement>(`/billing/customers/${customerId}`);
|
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) */
|
/* ------------------------------------------------- Bank register (chequera) */
|
||||||
|
|
||||||
export interface BankQuery {
|
export interface BankQuery {
|
||||||
@@ -341,3 +539,120 @@ export function getBankFacets(): Promise<BankFacets> {
|
|||||||
export function getBankSummary(year?: number): Promise<BankSummary> {
|
export function getBankSummary(year?: number): Promise<BankSummary> {
|
||||||
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
|
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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,23 @@ import type {
|
|||||||
BankDirection,
|
BankDirection,
|
||||||
LedgerDirection,
|
LedgerDirection,
|
||||||
PolicyStatus,
|
PolicyStatus,
|
||||||
|
Role,
|
||||||
ServiceKind,
|
ServiceKind,
|
||||||
TransactionDomain,
|
TransactionDomain,
|
||||||
TrustStatus,
|
TrustStatus,
|
||||||
} from "./types";
|
} 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
|
* Placeholder the migration writes when a legacy record had no name and none
|
||||||
* could be recovered from a secondary table (migration/transform_customers.py).
|
* could be recovered from a secondary table (migration/transform_customers.py).
|
||||||
@@ -254,6 +266,48 @@ export function bankSourceLabel(source: string | null | undefined): string {
|
|||||||
return BANK_SOURCE_LABELS[source] ?? source;
|
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 = [
|
export const MONTH_NAMES = [
|
||||||
"Enero",
|
"Enero",
|
||||||
"Febrero",
|
"Febrero",
|
||||||
|
|||||||
+268
-2
@@ -1,12 +1,77 @@
|
|||||||
// TypeScript types for the Jorge Cuadros & Asociados API responses.
|
// TypeScript types for the Jorge Cuadros & Asociados API responses.
|
||||||
// Decimals arrive as strings, dates as ISO strings.
|
// Decimals arrive as strings, dates as ISO strings.
|
||||||
|
|
||||||
|
export type Currency = "USD" | "MXN";
|
||||||
|
|
||||||
|
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: Role;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
// Resolved server-side from role (abilitiesFor in the API); the UI only ever
|
||||||
|
// reads this map, never re-derives the rules. Server still enforces.
|
||||||
|
abilities: Record<Ability, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------- DB operations (admin) */
|
||||||
|
|
||||||
|
export type OpsJobKind = "BACKUP" | "RESTORE" | "REIMPORT" | "SYNC";
|
||||||
|
export type OpsJobStatus = "RUNNING" | "SUCCESS" | "FAILED";
|
||||||
|
|
||||||
|
export interface OpsJob {
|
||||||
|
id: string;
|
||||||
|
kind: OpsJobKind;
|
||||||
|
status: OpsJobStatus;
|
||||||
|
log: string;
|
||||||
|
params: Record<string, unknown> | null;
|
||||||
|
createdById: string | null;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the four legacy Access files expected in the ingest folder. */
|
||||||
|
export interface IngestFile {
|
||||||
|
name: string;
|
||||||
|
present: boolean;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerStats {
|
export interface CustomerStats {
|
||||||
@@ -33,6 +98,7 @@ export interface CustomerListItem {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
mobile: string | null;
|
mobile: string | null;
|
||||||
status: boolean;
|
status: boolean;
|
||||||
|
archived: boolean;
|
||||||
propertyCount: number;
|
propertyCount: number;
|
||||||
policyCount: number;
|
policyCount: number;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
@@ -120,17 +186,23 @@ export interface Vehicle {
|
|||||||
id: string;
|
id: string;
|
||||||
make: string | null;
|
make: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
modelYear: number | null;
|
modelYear: number | string | null;
|
||||||
licensePlate: string | null;
|
licensePlate: string | null;
|
||||||
bodyType: string | null;
|
bodyType: string | null;
|
||||||
engineNumber?: string | null;
|
engineNumber?: string | null;
|
||||||
vinNumber?: string | null;
|
vinNumber?: string | null;
|
||||||
|
stateCode?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InsuredDriver {
|
export interface InsuredDriver {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
licenseNumber: string | null;
|
licenseNumber: string | null;
|
||||||
|
birthDate?: string | null;
|
||||||
|
sex?: string | null;
|
||||||
|
occupation?: string | null;
|
||||||
|
licenseState?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Beneficiary {
|
export interface Beneficiary {
|
||||||
@@ -202,6 +274,106 @@ export interface PolicyListItem {
|
|||||||
vehicleCount: number;
|
vehicleCount: number;
|
||||||
installmentCount: number;
|
installmentCount: number;
|
||||||
documentCount: number;
|
documentCount: number;
|
||||||
|
archived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable policy-header fields — shared by the create/edit form and API. */
|
||||||
|
export interface PolicyInput {
|
||||||
|
policyNumber: string;
|
||||||
|
customerId: string;
|
||||||
|
policyTypeId?: string;
|
||||||
|
insuranceProviderId?: string;
|
||||||
|
agentName?: string;
|
||||||
|
policyDate?: string;
|
||||||
|
policyFrom?: string;
|
||||||
|
policyTo?: string;
|
||||||
|
coveragePeriodDays?: number;
|
||||||
|
netPremium?: number;
|
||||||
|
policyFee?: number;
|
||||||
|
brokerFee?: number;
|
||||||
|
commission?: number;
|
||||||
|
total?: number;
|
||||||
|
currency?: Currency;
|
||||||
|
observations?: string;
|
||||||
|
notes?: string;
|
||||||
|
endorsement?: boolean;
|
||||||
|
liquidated?: boolean;
|
||||||
|
liquidationNumber?: string;
|
||||||
|
liquidationDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallmentInput {
|
||||||
|
sequence: number;
|
||||||
|
amount?: number;
|
||||||
|
currency?: Currency;
|
||||||
|
dueDate?: string;
|
||||||
|
paidDate?: string;
|
||||||
|
checkNumber?: string;
|
||||||
|
isCash?: boolean;
|
||||||
|
}
|
||||||
|
export interface VehicleInput {
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
modelYear?: string;
|
||||||
|
bodyType?: string;
|
||||||
|
engineNumber?: string;
|
||||||
|
licensePlate?: string;
|
||||||
|
vinNumber?: string;
|
||||||
|
stateCode?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
export interface DriverInput {
|
||||||
|
fullName?: string;
|
||||||
|
birthDate?: string;
|
||||||
|
sex?: string;
|
||||||
|
occupation?: string;
|
||||||
|
licenseNumber?: string;
|
||||||
|
licenseState?: string;
|
||||||
|
}
|
||||||
|
export interface BeneficiaryInput {
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
export interface ClaimInput {
|
||||||
|
claimType?: string;
|
||||||
|
incidentDate?: string;
|
||||||
|
reportedDate?: string;
|
||||||
|
description?: string;
|
||||||
|
adjusterId?: string;
|
||||||
|
claimedAmount?: number;
|
||||||
|
settledAmount?: number;
|
||||||
|
settlementDate?: string;
|
||||||
|
checkNumber?: string;
|
||||||
|
resolved?: boolean;
|
||||||
|
resolutionNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lookups (insurance reference data) */
|
||||||
|
export interface ProviderRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
_count?: { policies: number };
|
||||||
|
}
|
||||||
|
export interface PolicyTypeRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortDescription: string | null;
|
||||||
|
_count?: { policies: number };
|
||||||
|
}
|
||||||
|
export interface AdjusterRow {
|
||||||
|
id: string;
|
||||||
|
company: string | null;
|
||||||
|
city: string | null;
|
||||||
|
name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
beeper: string | null;
|
||||||
|
}
|
||||||
|
export interface LookupsResponse {
|
||||||
|
providers: ProviderRow[];
|
||||||
|
types: PolicyTypeRow[];
|
||||||
|
adjusters: AdjusterRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyListResponse {
|
export interface PolicyListResponse {
|
||||||
@@ -260,6 +432,10 @@ export interface Claim {
|
|||||||
settlementDate: string | null;
|
settlementDate: string | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
adjuster: Adjuster | null;
|
adjuster: Adjuster | null;
|
||||||
|
adjusterId?: string | null;
|
||||||
|
checkNumber?: string | null;
|
||||||
|
resolved?: boolean;
|
||||||
|
resolutionNotes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyCustomerRef {
|
export interface PolicyCustomerRef {
|
||||||
@@ -298,6 +474,7 @@ export interface PolicyDetail {
|
|||||||
legacySourceDb: string | null;
|
legacySourceDb: string | null;
|
||||||
legacySourceTable: string | null;
|
legacySourceTable: string | null;
|
||||||
legacyId: string | null;
|
legacyId: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
status: PolicyStatus;
|
status: PolicyStatus;
|
||||||
daysToExpiry: number | null;
|
daysToExpiry: number | null;
|
||||||
customer: PolicyCustomerRef;
|
customer: PolicyCustomerRef;
|
||||||
@@ -364,6 +541,35 @@ export interface PropertyListItem {
|
|||||||
activeServiceCount: number;
|
activeServiceCount: number;
|
||||||
documentCount: number;
|
documentCount: number;
|
||||||
trust: TrustSummary | null;
|
trust: TrustSummary | null;
|
||||||
|
archived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable property-header fields — shared by the form and the API. */
|
||||||
|
export interface PropertyInput {
|
||||||
|
customerId: string;
|
||||||
|
policyId?: string;
|
||||||
|
addressLine1?: string;
|
||||||
|
addressLine2?: string;
|
||||||
|
phone1?: string;
|
||||||
|
phone2?: string;
|
||||||
|
phone3?: string;
|
||||||
|
zone?: string;
|
||||||
|
}
|
||||||
|
export interface ServiceInput {
|
||||||
|
kind: ServiceKind;
|
||||||
|
accountNumber?: string;
|
||||||
|
meterNumber?: string;
|
||||||
|
route?: string;
|
||||||
|
dueDay?: string;
|
||||||
|
active?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
export interface TrustInput {
|
||||||
|
bankName?: string;
|
||||||
|
trustNumber?: string;
|
||||||
|
bankFee?: number;
|
||||||
|
dueDate1?: string;
|
||||||
|
dueDate2?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PropertyListResponse {
|
export interface PropertyListResponse {
|
||||||
@@ -415,6 +621,7 @@ export interface PropertyDetail {
|
|||||||
phone2: string | null;
|
phone2: string | null;
|
||||||
phone3: string | null;
|
phone3: string | null;
|
||||||
zone: string | null;
|
zone: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
legacySourceTable: string | null;
|
legacySourceTable: string | null;
|
||||||
legacyId: string | null;
|
legacyId: string | null;
|
||||||
customer: PropertyOwnerRef;
|
customer: PropertyOwnerRef;
|
||||||
@@ -507,6 +714,23 @@ export interface Movement {
|
|||||||
/** Legacy table the row came from — `datos2`, `EFECTIVO`, `fee15`, … */
|
/** Legacy table the row came from — `datos2`, `EFECTIVO`, `fee15`, … */
|
||||||
source: string | null;
|
source: string | null;
|
||||||
type: TransactionType | null;
|
type: TransactionType | null;
|
||||||
|
/** App-voided (`voidedAt` set). UI strikes; totals exclude. */
|
||||||
|
voided: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload for POST /billing — a new ledger movement. Sign convention: negative
|
||||||
|
* = cargo (charge), positive = abono (credit). */
|
||||||
|
export interface CreateMovementInput {
|
||||||
|
customerId: string;
|
||||||
|
domain: TransactionDomain;
|
||||||
|
amount: number;
|
||||||
|
transactionDate: string;
|
||||||
|
currency?: Currency;
|
||||||
|
typeId?: string;
|
||||||
|
period?: string;
|
||||||
|
reference?: string;
|
||||||
|
checkNumber?: string;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MovementListItem extends Movement {
|
export interface MovementListItem extends Movement {
|
||||||
@@ -663,8 +887,10 @@ export interface CustomerDetail {
|
|||||||
identificationExpiration: string | null;
|
identificationExpiration: string | null;
|
||||||
customerSince: string | null;
|
customerSince: string | null;
|
||||||
status: boolean;
|
status: boolean;
|
||||||
|
minimumBalance: string | number | null;
|
||||||
feeAmount: string | number | null;
|
feeAmount: string | number | null;
|
||||||
preferredCurrency: string | null;
|
preferredCurrency: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
legacyRefs: LegacyRef[];
|
legacyRefs: LegacyRef[];
|
||||||
properties: Property[];
|
properties: Property[];
|
||||||
policies: Policy[];
|
policies: Policy[];
|
||||||
@@ -672,6 +898,30 @@ export interface CustomerDetail {
|
|||||||
transactionSummary: TransactionSummaryRow[];
|
transactionSummary: TransactionSummaryRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Editable customer fields — shared by the create/edit form and the API. */
|
||||||
|
export interface CustomerInput {
|
||||||
|
name: string;
|
||||||
|
addressLine1?: string;
|
||||||
|
addressLine2?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zipCode?: string;
|
||||||
|
country?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
fax?: string;
|
||||||
|
email?: string;
|
||||||
|
notes?: string;
|
||||||
|
identificationType?: string;
|
||||||
|
identificationNumber?: string;
|
||||||
|
identificationExpiration?: string;
|
||||||
|
customerSince?: string;
|
||||||
|
status?: boolean;
|
||||||
|
minimumBalance?: number;
|
||||||
|
feeAmount?: number;
|
||||||
|
preferredCurrency?: Currency;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------- Bank register (chequera) */
|
/* ------------------------------------------------- Bank register (chequera) */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -707,6 +957,22 @@ export interface BankListItem {
|
|||||||
/** "CIENTO CINCUENTA MIL PESOS 00/100" — egresos only. */
|
/** "CIENTO CINCUENTA MIL PESOS 00/100" — egresos only. */
|
||||||
amountInWords: string | null;
|
amountInWords: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
|
/** App-voided (`voidedAt` set). UI strikes; totals exclude. */
|
||||||
|
voided: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload for POST /bank — a new chequera movement. Sign convention: positive
|
||||||
|
* = ingreso, negative = egreso. MXN only. */
|
||||||
|
export interface CreateBankMovementInput {
|
||||||
|
amount: number;
|
||||||
|
transactionDate: string;
|
||||||
|
concept?: string;
|
||||||
|
reference?: string;
|
||||||
|
transactionType?: string;
|
||||||
|
cleared?: boolean;
|
||||||
|
transferred?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
amountInWords?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BankTotals {
|
export interface BankTotals {
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ services:
|
|||||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
WEB_ORIGIN: http://localhost:3000
|
WEB_ORIGIN: http://localhost:3000
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
|
INGEST_DIR: /data/ingest
|
||||||
|
BACKUP_DIR: /data/backups
|
||||||
|
MIGRATION_ENV: dev
|
||||||
|
volumes:
|
||||||
|
- ingest_data:/data/ingest
|
||||||
|
- backup_data:/data/backups
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
|
||||||
@@ -48,3 +54,5 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
|
ingest_data:
|
||||||
|
backup_data:
|
||||||
|
|||||||
+49
-4
@@ -1,24 +1,69 @@
|
|||||||
FROM node:20-alpine AS base
|
FROM node:20-alpine AS base
|
||||||
WORKDIR /repo
|
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
|
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/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
|
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
|
FROM deps AS build
|
||||||
COPY packages/database packages/database
|
COPY packages/database packages/database
|
||||||
COPY apps/api apps/api
|
COPY apps/api apps/api
|
||||||
RUN npm run generate -w packages/database
|
RUN pnpm --filter @jorgecuadros/database generate
|
||||||
RUN npm run build -w apps/api
|
RUN pnpm --filter @jorgecuadros/api build
|
||||||
|
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
ENV NODE_ENV=production
|
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/node_modules node_modules
|
||||||
COPY --from=build /repo/packages/database packages/database
|
COPY --from=build /repo/packages/database packages/database
|
||||||
COPY --from=build /repo/apps/api/dist apps/api/dist
|
COPY --from=build /repo/apps/api/dist apps/api/dist
|
||||||
COPY --from=build /repo/apps/api/package.json apps/api/package.json
|
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
|
EXPOSE 3001
|
||||||
CMD ["node", "apps/api/dist/main.js"]
|
CMD ["node", "apps/api/dist/main.js"]
|
||||||
|
|||||||
+17
-3
@@ -1,20 +1,34 @@
|
|||||||
FROM node:20-alpine AS base
|
FROM node:20-alpine AS base
|
||||||
WORKDIR /repo
|
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
|
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
|
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
|
FROM deps AS build
|
||||||
COPY apps/web apps/web
|
COPY apps/web apps/web
|
||||||
RUN npm run build -w apps/web
|
RUN pnpm --filter @jorgecuadros/web build
|
||||||
|
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
COPY --from=build /repo/node_modules node_modules
|
COPY --from=build /repo/node_modules node_modules
|
||||||
COPY --from=build /repo/apps/web apps/web
|
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
|
EXPOSE 3000
|
||||||
WORKDIR /repo/apps/web
|
WORKDIR /repo/apps/web
|
||||||
CMD ["npx", "next", "start"]
|
CMD ["npx", "next", "start"]
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ from extract import sanitize_column_name as san
|
|||||||
|
|
||||||
csv.field_size_limit(300_000_000)
|
csv.field_size_limit(300_000_000)
|
||||||
|
|
||||||
SOURCE_ROOT = Path.home() / "Downloads" / "JorgeCuadros-Legacy"
|
# 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)
|
# (key, access_file, access_table, staged_table_name, blob_cols, model)
|
||||||
SOURCES = [
|
SOURCES = [
|
||||||
|
|||||||
+9
-1
@@ -17,9 +17,17 @@ not work on macOS; extraction is being reworked to use mdbtools
|
|||||||
the four Access source files.
|
the four Access source files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
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 = {
|
SOURCES = {
|
||||||
"utilities": {
|
"utilities": {
|
||||||
|
|||||||
+10
-1
@@ -22,6 +22,7 @@ Usage in a script:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
@@ -48,8 +49,16 @@ def load_env(env: str) -> dict:
|
|||||||
return out
|
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):
|
def connect(env: str):
|
||||||
url = load_env(env)["DATABASE_URL"]
|
url = database_url(env)
|
||||||
u = urlparse(url) # mysql://user:pass@host:port/db
|
u = urlparse(url) # mysql://user:pass@host:port/db
|
||||||
return pymysql.connect(
|
return pymysql.connect(
|
||||||
host=u.hostname,
|
host=u.hostname,
|
||||||
|
|||||||
+22
-9
@@ -39,13 +39,21 @@ PY = sys.executable # the venv python running this orchestrator
|
|||||||
# service_documents / policy_documents, which would otherwise leave the
|
# service_documents / policy_documents, which would otherwise leave the
|
||||||
# uploaded MinIO objects with no rows pointing at them.
|
# uploaded MinIO objects with no rows pointing at them.
|
||||||
STEPS = [
|
STEPS = [
|
||||||
"transform_customers.py", # customers + customer_legacy_refs (everything FKs to these)
|
"transform_customers.py",
|
||||||
"transform_properties.py", # properties + services + trust accounts
|
"transform_properties.py",
|
||||||
"transform_policies.py", # policies + installments/vehicles/drivers/benef/claims/adjusters
|
"transform_policies.py",
|
||||||
"transform_transactions.py", # shared ledger + type_transactions + exchange_rates
|
"transform_transactions.py",
|
||||||
"prune_empty_customers.py", # drop customers with no property/policy/transaction
|
"prune_empty_customers.py",
|
||||||
"transform_bank.py", # SCOTHIA bank register (no customer FK; independent)
|
"transform_bank.py",
|
||||||
"blob_extract.py", # document pointers; must follow properties + policies
|
"blob_extract.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
SYNC_STEPS = [
|
||||||
|
"transform_customers.py",
|
||||||
|
"transform_properties.py",
|
||||||
|
"transform_policies.py",
|
||||||
|
"transform_transactions.py",
|
||||||
|
"transform_bank.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -61,13 +69,18 @@ def main() -> None:
|
|||||||
ap.add_argument("--env", default="dev", help="target environment (reads deploy/.env.<env>)")
|
ap.add_argument("--env", default="dev", help="target environment (reads deploy/.env.<env>)")
|
||||||
ap.add_argument("--stage", action="store_true",
|
ap.add_argument("--stage", action="store_true",
|
||||||
help="re-run the raw staging load first (needs the Access files + mdbtools)")
|
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()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if args.stage:
|
if args.stage:
|
||||||
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")])
|
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")])
|
||||||
|
|
||||||
for step in STEPS:
|
for step in SYNC_STEPS if args.sync else STEPS:
|
||||||
run([PY, str(HERE / step), "--env", args.env])
|
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}")
|
print(f"\n✓ migration complete for env={args.env}")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
+13
-11
@@ -28,6 +28,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output" / "stg_scothia"
|
STG = Path(__file__).parent / "output" / "stg_scothia"
|
||||||
NULL = "∅"
|
NULL = "∅"
|
||||||
@@ -72,7 +73,7 @@ def load(name):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[bank] target env: {env}")
|
print(f"[bank] target env: {env}")
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -109,16 +110,17 @@ def main():
|
|||||||
for _, r in load("datos_e").iterrows():
|
for _, r in load("datos_e").iterrows():
|
||||||
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
||||||
|
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
if sync_mode:
|
||||||
for t in ("bank_transactions", "business_line_categories"):
|
for row in rows:
|
||||||
c.execute(f"TRUNCATE TABLE {t}")
|
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)
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
else:
|
||||||
|
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
for t in ("bank_transactions", "business_line_categories"):
|
||||||
c.executemany(
|
c.execute(f"TRUNCATE TABLE {t}")
|
||||||
"INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,"
|
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
"amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) "
|
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
||||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", rows)
|
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()
|
conn.commit()
|
||||||
|
|
||||||
def count(t):
|
def count(t):
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output"
|
STG = Path(__file__).parent / "output"
|
||||||
NULL = "∅"
|
NULL = "∅"
|
||||||
@@ -229,16 +230,16 @@ _CUST_COLS = [
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[customers] target env: {env}")
|
print(f"[customers] target env: {env}")
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
# Fresh, idempotent rebuild.
|
if not sync_mode:
|
||||||
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
cur.execute("TRUNCATE TABLE customer_legacy_refs")
|
cur.execute("TRUNCATE TABLE customer_legacy_refs")
|
||||||
cur.execute("TRUNCATE TABLE customers")
|
cur.execute("TRUNCATE TABLE customers")
|
||||||
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
|
||||||
util = load("stg_utilities", "datgral")
|
util = load("stg_utilities", "datgral")
|
||||||
ins = load("stg_seguros", "datgral")
|
ins = load("stg_seguros", "datgral")
|
||||||
@@ -291,18 +292,28 @@ def main() -> None:
|
|||||||
new_ins += 1
|
new_ins += 1
|
||||||
refs.append((str(uuid.uuid4()), cust_id, "insurance", "DATGRAL", ins_id))
|
refs.append((str(uuid.uuid4()), cust_id, "insurance", "DATGRAL", ins_id))
|
||||||
|
|
||||||
# Insert customers.
|
|
||||||
placeholders = ",".join(["%s"] * len(_CUST_COLS))
|
placeholders = ",".join(["%s"] * len(_CUST_COLS))
|
||||||
cur.executemany(
|
if sync_mode:
|
||||||
f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders})",
|
existing = {}
|
||||||
[tuple(rec[c] for c in _CUST_COLS) for rec in customers],
|
cur.execute("SELECT id,sourceSystem,sourceTable,legacyId,customerId FROM customer_legacy_refs")
|
||||||
)
|
for rid, system, table, legacy, customer_id in cur.fetchall():
|
||||||
# Insert legacy refs.
|
existing[(system, table, legacy)] = (rid, customer_id)
|
||||||
cur.executemany(
|
for rec, ref in zip(customers, refs):
|
||||||
"INSERT INTO customer_legacy_refs (id, customerId, sourceSystem, sourceTable, legacyId) "
|
key = (ref[2], ref[3], ref[4])
|
||||||
"VALUES (%s,%s,%s,%s,%s)",
|
customer_id = existing.get(key, (None, rec["id"]))[1]
|
||||||
refs,
|
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
|
# Enrich linked customers with insurance-only ID-doc fields, and fill any
|
||||||
# contact fields the utilities master left empty (COALESCE keeps master's).
|
# contact fields the utilities master left empty (COALESCE keeps master's).
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output" / "stg_seguros"
|
STG = Path(__file__).parent / "output" / "stg_seguros"
|
||||||
LEGACY_DB = "SEGUROS 16_be"
|
LEGACY_DB = "SEGUROS 16_be"
|
||||||
@@ -171,7 +172,7 @@ def load(name):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[policies] target env: {env}")
|
print(f"[policies] target env: {env}")
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -315,35 +316,44 @@ def main():
|
|||||||
dt(r["fecha_cheque"]), s(r["num_cheque"]),
|
dt(r["fecha_cheque"]), s(r["num_cheque"]),
|
||||||
1 if truthy(r["concluido"]) else 0, s(r["resolucion"])))
|
1 if truthy(r["concluido"]) else 0, s(r["resolucion"])))
|
||||||
|
|
||||||
# --- write (children first on truncate) ---
|
if sync_mode:
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
c.execute("SELECT id,name FROM policy_types")
|
||||||
for t in ("policy_payment_installments", "insured_drivers", "policy_beneficiaries",
|
ptype_ids = dict(c.fetchall())
|
||||||
"claims", "vehicles", "policy_documents", "policies", "policy_types",
|
for n in ptypes:
|
||||||
"insurance_providers", "adjusters"):
|
ptype_ids.setdefault(n, str(uuid.uuid4()))
|
||||||
c.execute(f"TRUNCATE TABLE {t}")
|
c.executemany("INSERT INTO policy_types (id,name) VALUES (%s,%s) ON DUPLICATE KEY UPDATE name=VALUES(name)", [(i, n) for n, i in ptype_ids.items()])
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
c.execute("SELECT id,name FROM insurance_providers")
|
||||||
|
prov_ids = dict(c.fetchall())
|
||||||
|
for n in providers:
|
||||||
|
prov_ids.setdefault(n, str(uuid.uuid4()))
|
||||||
|
c.executemany("INSERT INTO insurance_providers (id,name) VALUES (%s,%s) ON DUPLICATE KEY UPDATE name=VALUES(name)", [(i, n) for n, i in prov_ids.items()])
|
||||||
|
for p in policies:
|
||||||
|
p = list(p); p[3] = ptype_ids[p[3]]; p[4] = prov_ids.get(p[4])
|
||||||
|
c.execute(f"INSERT INTO policies ({pol_cols}) VALUES ({','.join(['%s'] * 23)}) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),policyNumber=VALUES(policyNumber),policyTypeId=VALUES(policyTypeId),insuranceProviderId=VALUES(insuranceProviderId),agentName=VALUES(agentName),policyDate=VALUES(policyDate),policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),policyFee=VALUES(policyFee),commission=VALUES(commission),total=VALUES(total),currency=VALUES(currency),observations=VALUES(observations),coveragesJson=VALUES(coveragesJson),liquidated=VALUES(liquidated),liquidationNumber=VALUES(liquidationNumber),liquidationDate=VALUES(liquidationDate),updatedAt=VALUES(updatedAt),archivedAt=NULL", tuple(p))
|
||||||
|
else:
|
||||||
|
ptype_ids = {n: str(uuid.uuid4()) for n in ptypes}
|
||||||
|
c.executemany("INSERT INTO policy_types (id,name) VALUES (%s,%s)", [(i, n) for n, i in ptype_ids.items()])
|
||||||
|
prov_ids = {n: str(uuid.uuid4()) for n in providers}
|
||||||
|
c.executemany("INSERT INTO insurance_providers (id,name) VALUES (%s,%s)", [(i, n) for n, i in prov_ids.items()])
|
||||||
|
c.executemany("INSERT INTO adjusters (id,company,city,name,phone,beeper) VALUES (%s,%s,%s,%s,%s,%s)", adj_rows)
|
||||||
|
|
||||||
ptype_ids = {n: str(uuid.uuid4()) for n in ptypes}
|
|
||||||
c.executemany("INSERT INTO policy_types (id,name) VALUES (%s,%s)",
|
|
||||||
[(i, n) for n, i in ptype_ids.items()])
|
|
||||||
prov_ids = {n: str(uuid.uuid4()) for n in providers}
|
|
||||||
c.executemany("INSERT INTO insurance_providers (id,name) VALUES (%s,%s)",
|
|
||||||
[(i, n) for n, i in prov_ids.items()])
|
|
||||||
c.executemany("INSERT INTO adjusters (id,company,city,name,phone,beeper) VALUES (%s,%s,%s,%s,%s,%s)", adj_rows)
|
|
||||||
|
|
||||||
# patch policyType/provider FKs into policy tuples
|
|
||||||
pol_cols = ("id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,"
|
pol_cols = ("id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,"
|
||||||
"policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations,"
|
"policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations,"
|
||||||
"coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,"
|
"coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,"
|
||||||
"legacySourceTable,legacyId,updatedAt")
|
"legacySourceTable,legacyId,updatedAt")
|
||||||
fixed = []
|
if not sync_mode:
|
||||||
for p in policies:
|
fixed = []
|
||||||
p = list(p)
|
for p in policies:
|
||||||
p[3] = ptype_ids.get(p[3]) # ptype name -> policyTypeId
|
p = list(p)
|
||||||
p[4] = prov_ids.get(p[4]) # provider name -> insuranceProviderId
|
p[3] = ptype_ids.get(p[3])
|
||||||
fixed.append(tuple(p))
|
p[4] = prov_ids.get(p[4])
|
||||||
ph = ",".join(["%s"] * 23)
|
fixed.append(tuple(p))
|
||||||
c.executemany(f"INSERT INTO policies ({pol_cols}) VALUES ({ph})", fixed)
|
ph = ",".join(["%s"] * 23)
|
||||||
|
c.executemany(f"INSERT INTO policies ({pol_cols}) VALUES ({ph})", fixed)
|
||||||
|
else:
|
||||||
|
c.executemany("INSERT INTO policies (id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations,coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,legacySourceTable,legacyId,updatedAt) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),policyNumber=VALUES(policyNumber),policyTypeId=VALUES(policyTypeId),insuranceProviderId=VALUES(insuranceProviderId),agentName=VALUES(agentName),policyDate=VALUES(policyDate),policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),policyFee=VALUES(policyFee),commission=VALUES(commission),total=VALUES(total),currency=VALUES(currency),observations=VALUES(observations),coveragesJson=VALUES(coveragesJson),liquidated=VALUES(liquidated),liquidationNumber=VALUES(liquidationNumber),liquidationDate=VALUES(liquidationDate),updatedAt=VALUES(updatedAt),archivedAt=NULL", [tuple([p[0],p[1],p[2],ptype_ids.get(p[3]),prov_ids.get(p[4]),*p[5:]]) for p in policies])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
c.executemany("INSERT INTO policy_payment_installments "
|
c.executemany("INSERT INTO policy_payment_installments "
|
||||||
"(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash) "
|
"(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash) "
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import delete_missing, existing_ids, parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output" / "stg_utilities"
|
STG = Path(__file__).parent / "output" / "stg_utilities"
|
||||||
NULL = "∅"
|
NULL = "∅"
|
||||||
@@ -100,8 +101,8 @@ def jkey(numer, casa, direc):
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main():
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[properties] target env: {env}")
|
print(f"[properties] target env: {env}")
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -119,6 +120,7 @@ def main() -> None:
|
|||||||
flags[jkey(r["numerid"], r["casa"], r["direccion"])] = r
|
flags[jkey(r["numerid"], r["casa"], r["direccion"])] = r
|
||||||
|
|
||||||
props, services, trusts = [], [], []
|
props, services, trusts = [], [], []
|
||||||
|
prop_keys: set[tuple] = set()
|
||||||
skipped_no_customer = 0
|
skipped_no_customer = 0
|
||||||
matched_profile = 0
|
matched_profile = 0
|
||||||
|
|
||||||
@@ -129,6 +131,8 @@ def main() -> None:
|
|||||||
skipped_no_customer += 1
|
skipped_no_customer += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
legacy_id = str(int(row["_row_num"]))
|
||||||
|
prop_keys.add(("DATMEX", legacy_id))
|
||||||
pid = str(uuid.uuid4())
|
pid = str(uuid.uuid4())
|
||||||
addr2_parts = []
|
addr2_parts = []
|
||||||
for lbl, col in (("CASA", "casa"), ("MZ", "manzana"), ("LOTE", "lote")):
|
for lbl, col in (("CASA", "casa"), ("MZ", "manzana"), ("LOTE", "lote")):
|
||||||
@@ -137,7 +141,8 @@ def main() -> None:
|
|||||||
props.append((
|
props.append((
|
||||||
pid, cust_id, s(row["direccion"]), ", ".join(addr2_parts) or None,
|
pid, cust_id, s(row["direccion"]), ", ".join(addr2_parts) or None,
|
||||||
s(row["telefono"]), s(row["telefono2"]), s(row["telefono3"]),
|
s(row["telefono"]), s(row["telefono2"]), s(row["telefono3"]),
|
||||||
s(row["zona"]), "DATMEX", nid,
|
s(row["zona"]), "DATMEX", legacy_id,
|
||||||
|
|
||||||
))
|
))
|
||||||
|
|
||||||
pfrow = flags.get(jkey(row["numer_id"], row["casa"], row["direccion"]))
|
pfrow = flags.get(jkey(row["numer_id"], row["casa"], row["direccion"]))
|
||||||
@@ -199,21 +204,28 @@ def main() -> None:
|
|||||||
trusts.append((str(uuid.uuid4()), pid, bank, s(row["trust_num"]),
|
trusts.append((str(uuid.uuid4()), pid, bank, s(row["trust_num"]),
|
||||||
as_dec(row["bfee"]), as_date(row["vence1"]), as_date(row["vence2"])))
|
as_dec(row["bfee"]), as_date(row["vence1"]), as_date(row["vence2"])))
|
||||||
|
|
||||||
# Fresh rebuild (children first).
|
# Fresh rebuild (children first), or additive upsert for legacy-owned rows.
|
||||||
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
if sync_mode:
|
||||||
for t in ("property_services", "service_documents", "trust_accounts", "properties"):
|
existing = existing_ids(cur, "properties", ("legacySourceTable", "legacyId"), "WHERE legacyId IS NOT NULL")
|
||||||
cur.execute(f"TRUNCATE TABLE {t}")
|
for row in props:
|
||||||
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
key = (row[8], row[9])
|
||||||
|
if key in existing:
|
||||||
cur.executemany(
|
row = list(row); row[0] = existing[key]
|
||||||
"INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,"
|
cur.execute(
|
||||||
"phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", props)
|
"INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),phone1=VALUES(phone1),phone2=VALUES(phone2),phone3=VALUES(phone3),zone=VALUES(zone),archivedAt=NULL", tuple(row))
|
||||||
cur.executemany(
|
delete_missing(cur, "properties", ("legacySourceTable", "legacyId"), prop_keys, "WHERE legacyId IS NOT NULL")
|
||||||
"INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,"
|
cur.execute("DELETE ps FROM property_services ps JOIN properties p ON p.id=ps.propertyId WHERE p.legacyId IS NOT NULL")
|
||||||
"dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
cur.execute("DELETE ta FROM trust_accounts ta JOIN properties p ON p.id=ta.propertyId WHERE p.legacyId IS NOT NULL")
|
||||||
cur.executemany(
|
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
||||||
"INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2)"
|
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
||||||
" VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
else:
|
||||||
|
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
|
for t in ("property_services", "service_documents", "trust_accounts", "properties"):
|
||||||
|
cur.execute(f"TRUNCATE TABLE {t}")
|
||||||
|
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
cur.executemany("INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", props)
|
||||||
|
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
||||||
|
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
cur.execute("SELECT COUNT(*) FROM properties"); n_p = cur.fetchone()[0]
|
cur.execute("SELECT COUNT(*) FROM properties"); n_p = cur.fetchone()[0]
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output"
|
STG = Path(__file__).parent / "output"
|
||||||
NULL = "∅"
|
NULL = "∅"
|
||||||
@@ -90,7 +91,7 @@ def load(src, name):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[transactions] target env: {env}")
|
print(f"[transactions] target env: {env}")
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -225,18 +226,17 @@ def main():
|
|||||||
iva()
|
iva()
|
||||||
efectivo_like("stg_seguros", "efectivo", "INSURANCE", ins_cust, "SEGUROS 16_be", "EFECTIVO")
|
efectivo_like("stg_seguros", "efectivo", "INSURANCE", ins_cust, "SEGUROS 16_be", "EFECTIVO")
|
||||||
|
|
||||||
# --- write ---
|
if sync_mode:
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
c.executemany("INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),domain=VALUES(domain),typeId=VALUES(typeId),transactionDate=VALUES(transactionDate),period=VALUES(period),reference=VALUES(reference),amount=VALUES(amount),currency=VALUES(currency),checkNumber=VALUES(checkNumber),message=VALUES(message),updatedAt=NOW(),voidedAt=NULL", tx)
|
||||||
for t in ("transactions", "type_transactions", "exchange_rates"):
|
else:
|
||||||
c.execute(f"TRUNCATE TABLE {t}")
|
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
for t in ("transactions", "type_transactions", "exchange_rates"):
|
||||||
|
c.execute(f"TRUNCATE TABLE {t}")
|
||||||
c.executemany("INSERT INTO type_transactions (id,nameEn,nameEs,isService) VALUES (%s,%s,%s,%s)", type_rows)
|
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
c.executemany("INSERT INTO exchange_rates (id,rate,effectiveDate,effectiveHour) VALUES (%s,%s,%s,%s)", xr_rows)
|
c.executemany("INSERT INTO type_transactions (id,nameEn,nameEs,isService) VALUES (%s,%s,%s,%s)", type_rows)
|
||||||
c.executemany(
|
c.executemany("INSERT INTO exchange_rates (id,rate,effectiveDate,effectiveHour) VALUES (%s,%s,%s,%s)", xr_rows)
|
||||||
"INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,"
|
c.executemany(
|
||||||
"amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,"
|
"INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", tx)
|
||||||
"legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", tx)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def count(t):
|
def count(t):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jorgecuadros-platform",
|
"name": "jorgecuadros-platform",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
|
|||||||
@@ -37,9 +37,14 @@ enum ServiceKind {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ordered access tier (rank): ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the
|
||||||
|
/// read-only role; STAFF and above can write. Enforced by the API's ability
|
||||||
|
/// matrix (apps/api/src/auth/abilities.ts), not by the enum itself.
|
||||||
enum UserRole {
|
enum UserRole {
|
||||||
ADMIN
|
ADMIN
|
||||||
|
MANAGER
|
||||||
STAFF
|
STAFF
|
||||||
|
VIEWER
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -78,6 +83,10 @@ model Customer {
|
|||||||
minimumBalance Decimal? @db.Decimal(12, 2)
|
minimumBalance Decimal? @db.Decimal(12, 2)
|
||||||
feeAmount Decimal? @db.Decimal(12, 2)
|
feeAmount Decimal? @db.Decimal(12, 2)
|
||||||
preferredCurrency Currency @default(USD)
|
preferredCurrency Currency @default(USD)
|
||||||
|
// Soft-delete marker. Distinct from `status` (a legacy business flag): a
|
||||||
|
// non-null archivedAt hides the row from default lists while preserving it
|
||||||
|
// and its legacy provenance. Never hard-delete migrated data.
|
||||||
|
archivedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -156,6 +165,9 @@ model Policy {
|
|||||||
liquidated Boolean @default(false)
|
liquidated Boolean @default(false)
|
||||||
liquidationNumber String?
|
liquidationNumber String?
|
||||||
liquidationDate DateTime?
|
liquidationDate DateTime?
|
||||||
|
// Soft-delete marker (see Customer.archivedAt). Never hard-delete migrated
|
||||||
|
// policy data; archiving hides it from default lists.
|
||||||
|
archivedAt DateTime?
|
||||||
legacySourceDb String?
|
legacySourceDb String?
|
||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
@@ -212,6 +224,7 @@ model Vehicle {
|
|||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
|
|
||||||
|
@@unique([legacySourceTable, legacyId])
|
||||||
@@map("vehicles")
|
@@map("vehicles")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +322,8 @@ model Property {
|
|||||||
phone2 String?
|
phone2 String?
|
||||||
phone3 String?
|
phone3 String?
|
||||||
zone String?
|
zone String?
|
||||||
|
// Soft-delete marker (see Customer.archivedAt).
|
||||||
|
archivedAt DateTime?
|
||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -317,6 +332,7 @@ model Property {
|
|||||||
documents ServiceDocument[]
|
documents ServiceDocument[]
|
||||||
trustAccount TrustAccount?
|
trustAccount TrustAccount?
|
||||||
|
|
||||||
|
@@unique([legacySourceTable, legacyId])
|
||||||
@@map("properties")
|
@@map("properties")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,12 +411,18 @@ model Transaction {
|
|||||||
checkNumber String?
|
checkNumber String?
|
||||||
message String? @db.Text
|
message String? @db.Text
|
||||||
outstanding Boolean @default(false)
|
outstanding Boolean @default(false)
|
||||||
|
// Append + void: booked rows are never edited or hard-deleted. A non-null
|
||||||
|
// voidedAt reverses the movement — it MUST be excluded from every balance
|
||||||
|
// and total (SUM/count) so a voided amount stops affecting the books.
|
||||||
|
voidedAt DateTime?
|
||||||
|
voidedById String?
|
||||||
legacySourceDb String?
|
legacySourceDb String?
|
||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([customerId, transactionDate])
|
@@index([customerId, transactionDate])
|
||||||
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
||||||
@@map("transactions")
|
@@map("transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,9 +465,13 @@ model BankTransaction {
|
|||||||
transferred Boolean @default(false)
|
transferred Boolean @default(false)
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
amountInWords String?
|
amountInWords String?
|
||||||
|
// Append + void (see Transaction.voidedAt): excluded from income/expense/net.
|
||||||
|
voidedAt DateTime?
|
||||||
|
voidedById String?
|
||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
|
|
||||||
|
@@unique([legacySourceTable, legacyId])
|
||||||
@@map("bank_transactions")
|
@@map("bank_transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,3 +537,40 @@ model EmailLog {
|
|||||||
|
|
||||||
@@map("email_log")
|
@@map("email_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin database operations (Operaciones): backup / restore / re-import / sync.
|
||||||
|
// Each long-running op is one OpsJob row so the web UI can poll status + tail
|
||||||
|
// the captured log. Rows are the audit trail for who ran a destructive op.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum OpsJobKind {
|
||||||
|
BACKUP
|
||||||
|
RESTORE
|
||||||
|
REIMPORT
|
||||||
|
SYNC
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OpsJobStatus {
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model OpsJob {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
kind OpsJobKind
|
||||||
|
status OpsJobStatus @default(RUNNING)
|
||||||
|
// Combined stdout+stderr of the spawned process, appended as it runs.
|
||||||
|
log String @db.LongText
|
||||||
|
// Op-specific inputs (e.g. the backup filename a RESTORE targets). No FK on
|
||||||
|
// createdById — the actor id is stored flat, like activity_logs' userId use.
|
||||||
|
params Json?
|
||||||
|
createdById String?
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
finishedAt DateTime?
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@index([startedAt])
|
||||||
|
@@map("ops_jobs")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user