feat(bank): chequera register module (plan step 7)
Adds the office's own bank-register browser over the migrated SCOTHIA
data (22,354 bank_transactions), the last self-contained feature module.
API (apps/api/src/bank):
- GET /bank register browser: search over concepto/reference/notes/
amountInWords; direction (income|expense|void), cleared and
date-range filters; 5 sorts; income/expense/net totals for
the whole filtered set, not just the page
- GET /bank/stats headline income/expense/net + counts, date span, pending
- GET /bank/facets year list for the period filter
- GET /bank/summary year and month rollups with a running net-movement figure
Web (/banco): "Movimientos" register + "Resumen por periodo" with year->month
drill-down; added to the AppShell nav as "Chequera".
Deliberately kept OUT of /estado-cuenta: this is the office's own money, not
customer balances, and the two are never summed or shown together.
No category/ramo dimension, and the deferred concept->ramo classifier is
dropped as won't-build: concepto is a payee name (0 of 22,354 match a
category) and TABLA RAMODOS is an expense chart of accounts + owner names,
not the insurance/servicios/fideicomiso split it was assumed to be, so a
classifier would invent data. Single currency (MXN); the "acumulado" is net
movement since the register opened (no opening balance in the source), not a
bank balance. Verified end-to-end in the browser; totals reconcile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -153,9 +153,12 @@ the reconciliation pass (done, then corrected) are all closed. See §3 and §8.
|
|||||||
3. **Old external-DB credential** — the old repo's `dbConnection.php` has a hardcoded
|
3. **Old external-DB credential** — the old repo's `dbConnection.php` has a hardcoded
|
||||||
plaintext MySQL password committed to git history. Not carried into the new platform,
|
plaintext MySQL password committed to git history. Not carried into the new platform,
|
||||||
but rotate it regardless; it is already exposed.
|
but rotate it regardless; it is already exposed.
|
||||||
4. **`bank_transactions.categoryId` is null on all 22354 rows** — the concept→ramo
|
4. **`bank_transactions.categoryId` is null on all 22354 rows — RESOLVED as won't-build**
|
||||||
classifier was deferred. Needed before any "insurance vs utilities vs trust" split of
|
(plan step 7). The concept→ramo classifier was investigated and dropped: `concepto` is a
|
||||||
the office's own bank activity.
|
payee name (0 of 22354 match a category), and TABLA RAMODOS is a property-management
|
||||||
|
expense chart of accounts + owner names, not the insurance/servicios/fideicomiso split it
|
||||||
|
was assumed to be — so a classifier would invent data rather than produce a business-line
|
||||||
|
view. The `/banco` module intentionally has no category dimension. See §8 step 7(b).
|
||||||
5. **`TRASPASOS PAYPAL` is a clearing account, not a customer** — carries -7.03M MXN over
|
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.
|
||||||
@@ -321,6 +324,40 @@ for what's actually next.
|
|||||||
(d) The biggest debtor by far is **"TRASPASOS PAYPAL"** (-7.03M MXN over 309 movements) —
|
(d) The biggest debtor by far is **"TRASPASOS PAYPAL"** (-7.03M MXN over 309 movements) —
|
||||||
a house/clearing account, not a person. Left in rather than special-cased, but it will
|
a house/clearing account, not a person. Left in rather than special-cased, but it will
|
||||||
head the adeudo worklist until someone decides how to model it.
|
head the adeudo worklist until someone decides how to model it.
|
||||||
|
- **Bank register module (plan step 7) — DONE**: `apps/api/src/bank/`
|
||||||
|
(`GET /bank` register browser with search over concepto/beneficiario, cheque
|
||||||
|
`reference`, `notes` and `amountInWords`; filters for direction
|
||||||
|
(income|expense|void), cleared status and a from/to date range, 5 sorts, and
|
||||||
|
**income/expense/net totals for the whole filtered set**; `/bank/stats`,
|
||||||
|
`/bank/facets` (year list), `/bank/summary` year/month rollup with a running
|
||||||
|
net figure) + web `/banco` (two tabs: "Movimientos" register and "Resumen por
|
||||||
|
periodo" with clickable year→month drill-down). Added to the AppShell nav as
|
||||||
|
"Chequera". Verified end-to-end in the browser: totals reconcile
|
||||||
|
(6948 income + 14615 expense + 791 void = 22354; net +899,375.77 matches
|
||||||
|
stats; 2013 months open at $0 and close at the year's net $794,295.78).
|
||||||
|
**Design decisions / data findings:**
|
||||||
|
(a) **Kept separate from `/estado-cuenta` on purpose** — this is the office's
|
||||||
|
own chequera, not customer money; the two ledgers are never summed or shown
|
||||||
|
together. Distinct nav entry, distinct page, distinct API module.
|
||||||
|
(b) **No category/ramo dimension, and the concept→ramo classifier was NOT
|
||||||
|
built** (closes open item §6.4): the data cannot support it. `DATOS E/I` have
|
||||||
|
no ramo column to migrate; `concepto` is a *payee* name (PAYPAL, CFE, ~1,900
|
||||||
|
individuals), and 0 of 22354 concepts match a `business_line_categories` name;
|
||||||
|
and the 66 TABLA RAMODOS rows are a property-management expense chart of
|
||||||
|
accounts (Payroll, Pool Labor, Gardening) + owner names, *not* the
|
||||||
|
insurance/servicios/fideicomiso split the migration comment implied. A
|
||||||
|
classifier would invent data, so `categoryId` stays null and the module does
|
||||||
|
not filter on it. Register is browsable by date/payee/amount/cheque instead.
|
||||||
|
(c) **Single currency (MXN).** `bank_transactions` has no currency column and
|
||||||
|
every `amountInWords` is spelled out in PESOS — so, unlike the customer
|
||||||
|
ledger, everything here is one currency and not split per-currency.
|
||||||
|
(d) **The "acumulado" is net movement since the register opened, not a bank
|
||||||
|
balance** — SCOTHIA carries no opening balance (its `ban` table holds only the
|
||||||
|
bank's name), so the running total starts at 0 in 2013. Labelled as such in
|
||||||
|
the UI so it is never read as a statement balance.
|
||||||
|
(e) Sign convention (from `transform_bank.py`): positive = ingreso,
|
||||||
|
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
|
||||||
|
CANCELADO/VOID) — voids are excluded from both the income and expense sides.
|
||||||
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers →
|
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers →
|
||||||
properties → policies → transactions → prune → bank → blobs in order (all idempotent);
|
properties → policies → transactions → prune → bank → blobs in order (all idempotent);
|
||||||
add `--stage` to re-extract from the Access files first. Verified end-to-end against dev.
|
add `--stage` to re-extract from the Access files first. Verified end-to-end against dev.
|
||||||
@@ -331,21 +368,18 @@ for what's actually next.
|
|||||||
`deploy/jorgecuadros-db.stack.yml` deploys prod from the same file as
|
`deploy/jorgecuadros-db.stack.yml` deploys prod from the same file as
|
||||||
`jorgecuadros-prod-db` on :3306. MinIO for documents deployed as `jorgecuadros-dev-minio`.
|
`jorgecuadros-prod-db` on :3306. MinIO for documents deployed as `jorgecuadros-dev-minio`.
|
||||||
|
|
||||||
6. **Staff web UI** — **DONE** for all four modules (§8 step 4: clientes, polizas, servicios,
|
6. **Staff web UI** — **DONE** for all five modules (§8 step 4 + step 7: clientes, polizas,
|
||||||
estado-cuenta). Spanish-first, session-cookie auth against the API, verified against real
|
servicios, estado-cuenta, banco). Spanish-first, session-cookie auth against the API,
|
||||||
migrated data.
|
verified against real migrated data. **All app-layer feature work is complete.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
⏭ **NEXT — where to pick up:**
|
⏭ **NEXT — where to pick up:**
|
||||||
|
|
||||||
- **Plan step 7: bank register module.** SCOTHIA data is already migrated (22354
|
|
||||||
`bank_transactions` + 66 `business_line_categories`) and has **no customer FK**, so it is
|
|
||||||
self-contained and low-risk — API + `/banco` browser only. Blocked on nothing.
|
|
||||||
Ties into open item §6.4 (`categoryId` is null on every row).
|
|
||||||
- **Plan step 8–9: VPS + sync worker.** Blocked on VPS provisioning (§6.1) — the only real
|
- **Plan step 8–9: VPS + sync worker.** Blocked on VPS provisioning (§6.1) — the only real
|
||||||
external dependency left.
|
external dependency left. Pure ops: provider, size, Tailscale, MySQL replica.
|
||||||
- **Plan step 10: reports / email campaigns / admin.**
|
- **Plan step 10: reports / email campaigns / admin.**
|
||||||
- **Uncommitted work:** the billing module and the ledger de-dup fix are written, built and
|
- **Small / open:** (a) `TRASPASOS PAYPAL` clearing account still tops the adeudo worklist
|
||||||
verified but **not committed** — `git status` is dirty at the time of writing. The browser
|
(§6.4d) — a business modelling call, not code. (b) Credential rotation on the old repo's
|
||||||
visual pass on `/estado-cuenta` was also never completed (it needs an interactive login).
|
exposed MySQL password. (c) The `/estado-cuenta` browser visual pass — `/banco` was verified
|
||||||
|
in-browser this session; `/estado-cuenta` still worth a look.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { CustomersModule } from "./customers/customers.module";
|
|||||||
import { PoliciesModule } from "./policies/policies.module";
|
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 { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -19,6 +20,7 @@ import { AppController } from "./app.controller";
|
|||||||
PoliciesModule,
|
PoliciesModule,
|
||||||
PropertiesModule,
|
PropertiesModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
BankModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import {
|
||||||
|
BankCleared,
|
||||||
|
BankDirection,
|
||||||
|
BankService,
|
||||||
|
BankSort,
|
||||||
|
} from "./bank.service";
|
||||||
|
|
||||||
|
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||||
|
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
||||||
|
const SORTS: BankSort[] = [
|
||||||
|
"date_desc",
|
||||||
|
"date_asc",
|
||||||
|
"amount_desc",
|
||||||
|
"amount_asc",
|
||||||
|
"reference",
|
||||||
|
];
|
||||||
|
|
||||||
|
function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
||||||
|
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
||||||
|
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AuthenticatedGuard)
|
||||||
|
@Controller("bank")
|
||||||
|
export class BankController {
|
||||||
|
constructor(private readonly bank: BankService) {}
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
stats() {
|
||||||
|
return this.bank.stats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("facets")
|
||||||
|
facets() {
|
||||||
|
return this.bank.facets();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year and month rollups with a running net-movement figure. */
|
||||||
|
@Get("summary")
|
||||||
|
summary(@Query("year") year?: string) {
|
||||||
|
const y = Number(year);
|
||||||
|
return this.bank.summary(
|
||||||
|
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The register browser. */
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query("query") query?: string,
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("pageSize") pageSize?: string,
|
||||||
|
@Query("direction") direction?: string,
|
||||||
|
@Query("cleared") cleared?: string,
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
@Query("sort") sort?: string,
|
||||||
|
) {
|
||||||
|
return this.bank.list({
|
||||||
|
query,
|
||||||
|
page: Math.max(1, Number(page) || 1),
|
||||||
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
|
direction: one(DIRECTIONS, direction),
|
||||||
|
cleared: one(CLEARED, cleared),
|
||||||
|
from: parseDate(from),
|
||||||
|
to: parseDate(to, true),
|
||||||
|
sort: one(SORTS, sort) ?? "date_desc",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { BankController } from "./bank.controller";
|
||||||
|
import { BankService } from "./bank.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BankController],
|
||||||
|
providers: [BankService],
|
||||||
|
})
|
||||||
|
export class BankModule {}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank register (chequera) module — plan step 7.
|
||||||
|
*
|
||||||
|
* This is the office's OWN operating checking account, migrated from SCOTHIA's
|
||||||
|
* `DATOS I` (ingresos) / `DATOS E` (egresos) into one signed-amount table. It
|
||||||
|
* carries no customer FK and is deliberately NOT part of `/estado-cuenta`: that
|
||||||
|
* ledger is what customers owe the office, this one is the office's own money.
|
||||||
|
* The two must never be added together or shown in the same total.
|
||||||
|
*
|
||||||
|
* SIGN CONVENTION (set by migration/transform_bank.py):
|
||||||
|
* - positive = ingreso (a deposit into the account)
|
||||||
|
* - negative = egreso (a payment out of it)
|
||||||
|
* - exactly zero = a cancelled/void cheque. 787 of the 791 zero rows say
|
||||||
|
* CANCELADO or VOID in the concept; they are neither an income nor an
|
||||||
|
* expense and are excluded from both sides, the way the ~193 zero rows are
|
||||||
|
* in the customer ledger.
|
||||||
|
*
|
||||||
|
* SINGLE CURRENCY. Unlike the customer ledger there is no currency column here:
|
||||||
|
* `bank_transactions` has none, and every `amountInWords` on the egreso side is
|
||||||
|
* spelled out in PESOS. All figures in this module are MXN.
|
||||||
|
*
|
||||||
|
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
|
||||||
|
* rows and this module does not filter or group by it, because the data cannot
|
||||||
|
* support it:
|
||||||
|
* - `DATOS E` / `DATOS I` have no ramo column at all — the only columns are
|
||||||
|
* fecha, tipo, num, concepto, ingreso/egreso, operado, notas and (egresos)
|
||||||
|
* cantidad en letra. There is no key to migrate.
|
||||||
|
* - `concepto` is a *payee* name (PAYPAL, CFE, TELEFONOS DEL NOROESTE, and
|
||||||
|
* ~1,900 individual people), not a classification. Zero of the 22,354
|
||||||
|
* concepts match a `business_line_categories` name.
|
||||||
|
* - the 66 categories in TABLA RAMODOS are a property-management expense
|
||||||
|
* chart of accounts (Payroll, Pool (Labor), Gardening, Trash Coll) plus
|
||||||
|
* owner names with property numbers — not the insurance/servicios/
|
||||||
|
* fideicomiso split. Classifying concepts into them would not produce a
|
||||||
|
* business-line breakdown even if it worked.
|
||||||
|
* A concept->ramo classifier would therefore be invented data, so the register
|
||||||
|
* is browsable by date, payee, amount and cheque number instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Which side of the register a movement is on. */
|
||||||
|
export type BankDirection = "income" | "expense" | "void";
|
||||||
|
|
||||||
|
/** `operado` in the source: whether the bank has cleared the movement. */
|
||||||
|
export type BankCleared = "cleared" | "pending";
|
||||||
|
|
||||||
|
export type BankSort =
|
||||||
|
| "date_desc"
|
||||||
|
| "date_asc"
|
||||||
|
| "amount_desc"
|
||||||
|
| "amount_asc"
|
||||||
|
| "reference";
|
||||||
|
|
||||||
|
export interface BankListParams {
|
||||||
|
query?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
direction?: BankDirection;
|
||||||
|
cleared?: BankCleared;
|
||||||
|
/** Inclusive bounds on `transactionDate`. */
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
sort: BankSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw shape of a year/month rollup row. */
|
||||||
|
interface PeriodRow {
|
||||||
|
period: number;
|
||||||
|
count: bigint | number | string;
|
||||||
|
income: Prisma.Decimal | null;
|
||||||
|
expense: Prisma.Decimal | null;
|
||||||
|
net: Prisma.Decimal | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: bigint | number | string | null | undefined): number {
|
||||||
|
if (v === null || v === undefined) return 0;
|
||||||
|
return typeof v === "number" ? v : Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dec(v: Prisma.Decimal | null | undefined): string {
|
||||||
|
return (v ?? new Prisma.Decimal(0)).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BankService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
||||||
|
const and: Prisma.BankTransactionWhereInput[] = [];
|
||||||
|
|
||||||
|
if (p.query && p.query.trim()) {
|
||||||
|
const q = p.query.trim();
|
||||||
|
and.push({
|
||||||
|
OR: [
|
||||||
|
{ concept: { contains: q } },
|
||||||
|
{ reference: { contains: q } },
|
||||||
|
{ notes: { contains: q } },
|
||||||
|
{ amountInWords: { contains: q } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (p.direction === "income") and.push({ amount: { gt: 0 } });
|
||||||
|
if (p.direction === "expense") and.push({ amount: { lt: 0 } });
|
||||||
|
if (p.direction === "void") and.push({ amount: 0 });
|
||||||
|
if (p.cleared) and.push({ cleared: p.cleared === "cleared" });
|
||||||
|
if (p.from || p.to) {
|
||||||
|
and.push({
|
||||||
|
transactionDate: {
|
||||||
|
...(p.from ? { gte: p.from } : {}),
|
||||||
|
...(p.to ? { lte: p.to } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return and.length ? { AND: and } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private orderBy(
|
||||||
|
sort: BankSort,
|
||||||
|
): Prisma.BankTransactionOrderByWithRelationInput[] {
|
||||||
|
switch (sort) {
|
||||||
|
case "date_asc":
|
||||||
|
return [{ transactionDate: "asc" }, { reference: "asc" }];
|
||||||
|
case "amount_desc":
|
||||||
|
return [{ amount: "desc" }];
|
||||||
|
case "amount_asc":
|
||||||
|
return [{ amount: "asc" }];
|
||||||
|
case "reference":
|
||||||
|
// `reference` is the cheque number on egresos and the deposit slip on
|
||||||
|
// ingresos; it is a string column, so this is a lexical sort.
|
||||||
|
return [{ reference: "asc" }];
|
||||||
|
default:
|
||||||
|
return [{ transactionDate: "desc" }, { reference: "desc" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The register itself: every deposit and payment, filterable. */
|
||||||
|
async list(params: BankListParams) {
|
||||||
|
const where = this.where(params);
|
||||||
|
|
||||||
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
|
this.prisma.bankTransaction.count({ where }),
|
||||||
|
this.prisma.bankTransaction.findMany({
|
||||||
|
where,
|
||||||
|
skip: (params.page - 1) * params.pageSize,
|
||||||
|
take: params.pageSize,
|
||||||
|
orderBy: this.orderBy(params.sort),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
transactionDate: true,
|
||||||
|
transactionType: true,
|
||||||
|
reference: true,
|
||||||
|
concept: true,
|
||||||
|
amount: true,
|
||||||
|
cleared: true,
|
||||||
|
transferred: true,
|
||||||
|
notes: true,
|
||||||
|
amountInWords: true,
|
||||||
|
legacySourceTable: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Totals cover the whole filtered set, not just the page — the figure staff
|
||||||
|
// read off a filtered view ("what did we pay CFE in 2025") has to.
|
||||||
|
const totals = await this.totalsFor(where);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
transactionDate: r.transactionDate,
|
||||||
|
transactionType: r.transactionType,
|
||||||
|
reference: r.reference,
|
||||||
|
concept: r.concept,
|
||||||
|
amount: r.amount,
|
||||||
|
direction: directionOf(r.amount),
|
||||||
|
cleared: r.cleared,
|
||||||
|
transferred: r.transferred,
|
||||||
|
notes: r.notes,
|
||||||
|
amountInWords: r.amountInWords,
|
||||||
|
source: r.legacySourceTable,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
pageCount: Math.ceil(total / params.pageSize),
|
||||||
|
totals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Income / expense / void split over an arbitrary filter. */
|
||||||
|
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
|
||||||
|
const [income, expense, voided] = await Promise.all([
|
||||||
|
this.prisma.bankTransaction.aggregate({
|
||||||
|
where: { AND: [where, { amount: { gt: 0 } }] },
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.aggregate({
|
||||||
|
where: { AND: [where, { amount: { lt: 0 } }] },
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.count({
|
||||||
|
where: { AND: [where, { amount: 0 }] },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inSum = income._sum.amount ?? new Prisma.Decimal(0);
|
||||||
|
const outSum = expense._sum.amount ?? new Prisma.Decimal(0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
income: inSum.toFixed(2),
|
||||||
|
incomeCount: income._count._all,
|
||||||
|
expense: outSum.toFixed(2),
|
||||||
|
expenseCount: expense._count._all,
|
||||||
|
net: inSum.plus(outSum).toFixed(2),
|
||||||
|
voidCount: voided,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-line figures for the bank page header. */
|
||||||
|
async stats() {
|
||||||
|
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
||||||
|
this.prisma.bankTransaction.count(),
|
||||||
|
this.prisma.bankTransaction.aggregate({
|
||||||
|
_min: { transactionDate: true },
|
||||||
|
_max: { transactionDate: true },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.count({ where: { cleared: false } }),
|
||||||
|
this.prisma.bankTransaction.count({ where: { transferred: true } }),
|
||||||
|
this.totalsFor({}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
movements: count,
|
||||||
|
firstMovement: bounds._min.transactionDate,
|
||||||
|
lastMovement: bounds._max.transactionDate,
|
||||||
|
pending,
|
||||||
|
transferred,
|
||||||
|
...totals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year list for the period filter, newest first. */
|
||||||
|
async facets() {
|
||||||
|
const years = await this.prisma.$queryRaw<
|
||||||
|
{ year: number; count: bigint | number | string }[]
|
||||||
|
>`
|
||||||
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||||
|
FROM bank_transactions
|
||||||
|
GROUP BY year
|
||||||
|
ORDER BY year DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
years: years.map((y) => ({ year: Number(y.year), count: num(y.count) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Period rollup for the "Resumen" view: one row per year, plus one row per
|
||||||
|
* month when a year is selected.
|
||||||
|
*
|
||||||
|
* `cumulative` is the running sum of every movement from the start of the
|
||||||
|
* register — NOT the bank balance. SCOTHIA carries no opening balance (its
|
||||||
|
* `BAN` table holds only the bank's name), so the register starts at zero on
|
||||||
|
* its first row in 2013 and the running figure is the net movement since
|
||||||
|
* then. Labelled as such in the UI so it is never read as a statement balance.
|
||||||
|
*/
|
||||||
|
async summary(year?: number) {
|
||||||
|
const years = await this.prisma.$queryRaw<PeriodRow[]>`
|
||||||
|
SELECT
|
||||||
|
YEAR(transactionDate) AS period,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
||||||
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
|
SUM(amount) AS net
|
||||||
|
FROM bank_transactions
|
||||||
|
GROUP BY period
|
||||||
|
ORDER BY period ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const months = year
|
||||||
|
? await this.prisma.$queryRaw<PeriodRow[]>`
|
||||||
|
SELECT
|
||||||
|
MONTH(transactionDate) AS period,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
||||||
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
|
SUM(amount) AS net
|
||||||
|
FROM bank_transactions
|
||||||
|
WHERE YEAR(transactionDate) = ${year}
|
||||||
|
GROUP BY period
|
||||||
|
ORDER BY period ASC
|
||||||
|
`
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Cumulative across years runs from the first row of the register; the
|
||||||
|
// monthly cumulative opens at the selected year's opening figure so the two
|
||||||
|
// tables agree.
|
||||||
|
let running = new Prisma.Decimal(0);
|
||||||
|
const yearRows = years.map((r) => {
|
||||||
|
const net = r.net ?? new Prisma.Decimal(0);
|
||||||
|
const opening = running;
|
||||||
|
running = running.plus(net);
|
||||||
|
return {
|
||||||
|
period: Number(r.period),
|
||||||
|
count: num(r.count),
|
||||||
|
income: dec(r.income),
|
||||||
|
expense: dec(r.expense),
|
||||||
|
net: net.toFixed(2),
|
||||||
|
opening: opening.toFixed(2),
|
||||||
|
cumulative: running.toFixed(2),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const opening =
|
||||||
|
year === undefined
|
||||||
|
? new Prisma.Decimal(0)
|
||||||
|
: new Prisma.Decimal(
|
||||||
|
yearRows.find((y) => y.period === year)?.opening ?? "0",
|
||||||
|
);
|
||||||
|
|
||||||
|
let monthRunning = opening;
|
||||||
|
const monthRows = months.map((r) => {
|
||||||
|
const net = r.net ?? new Prisma.Decimal(0);
|
||||||
|
monthRunning = monthRunning.plus(net);
|
||||||
|
return {
|
||||||
|
period: Number(r.period),
|
||||||
|
count: num(r.count),
|
||||||
|
income: dec(r.income),
|
||||||
|
expense: dec(r.expense),
|
||||||
|
net: net.toFixed(2),
|
||||||
|
cumulative: monthRunning.toFixed(2),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: year ?? null,
|
||||||
|
years: yearRows,
|
||||||
|
months: monthRows,
|
||||||
|
opening: opening.toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function directionOf(amount: Prisma.Decimal): BankDirection {
|
||||||
|
if (amount.greaterThan(0)) return "income";
|
||||||
|
return amount.lessThan(0) ? "expense" : "void";
|
||||||
|
}
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import {
|
||||||
|
getBankFacets,
|
||||||
|
getBankStats,
|
||||||
|
getBankSummary,
|
||||||
|
listBankMovements,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import {
|
||||||
|
bankDirectionLabel,
|
||||||
|
bankSourceLabel,
|
||||||
|
bankTone,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
formatNumber,
|
||||||
|
monthName,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
BankCleared,
|
||||||
|
BankDirection,
|
||||||
|
BankFacets,
|
||||||
|
BankListItem,
|
||||||
|
BankListResponse,
|
||||||
|
BankSort,
|
||||||
|
BankStats,
|
||||||
|
BankSummary,
|
||||||
|
BankTotals,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank register (chequera) browser — plan step 7.
|
||||||
|
*
|
||||||
|
* This is the office's OWN checking account, not customer money. It is a
|
||||||
|
* separate page from /estado-cuenta on purpose: nothing here belongs in a
|
||||||
|
* customer's statement and the two sets of figures are never combined.
|
||||||
|
*
|
||||||
|
* Two views:
|
||||||
|
* - "Movimientos": the register itself — every deposit and payment, by date,
|
||||||
|
* payee, cheque number or amount.
|
||||||
|
* - "Resumen": ingresos vs egresos per year, and per month inside a year,
|
||||||
|
* with the running net movement since the register opened in 2013.
|
||||||
|
*
|
||||||
|
* Single currency (MXN) — the source has no currency column. See the module
|
||||||
|
* header in `bank.service.ts` for why there is no category/ramo filter.
|
||||||
|
*/
|
||||||
|
type View = "movimientos" | "resumen";
|
||||||
|
|
||||||
|
const DIRECTIONS: { key: BankDirection | ""; label: string }[] = [
|
||||||
|
{ key: "", label: "Ingresos y egresos" },
|
||||||
|
{ key: "income", label: "Sólo ingresos" },
|
||||||
|
{ key: "expense", label: "Sólo egresos" },
|
||||||
|
{ key: "void", label: "Sólo cancelados" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLEARED: { key: BankCleared | ""; label: string }[] = [
|
||||||
|
{ key: "", label: "Operados y pendientes" },
|
||||||
|
{ key: "cleared", label: "Sólo operados" },
|
||||||
|
{ key: "pending", label: "Sólo pendientes" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORTS: { key: BankSort; label: string }[] = [
|
||||||
|
{ key: "date_desc", label: "Fecha (más reciente)" },
|
||||||
|
{ key: "date_asc", label: "Fecha (más antigua)" },
|
||||||
|
{ key: "amount_desc", label: "Ingreso más grande" },
|
||||||
|
{ key: "amount_asc", label: "Egreso más grande" },
|
||||||
|
{ key: "reference", label: "Número de cheque" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BancoPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<BankBrowser />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BankBrowser() {
|
||||||
|
const [stats, setStats] = useState<BankStats | null>(null);
|
||||||
|
const [facets, setFacets] = useState<BankFacets | null>(null);
|
||||||
|
const [view, setView] = useState<View>("movimientos");
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [direction, setDirection] = useState<BankDirection | "">("");
|
||||||
|
const [cleared, setCleared] = useState<BankCleared | "">("");
|
||||||
|
const [from, setFrom] = useState("");
|
||||||
|
const [to, setTo] = useState("");
|
||||||
|
const [sort, setSort] = useState<BankSort>("date_desc");
|
||||||
|
|
||||||
|
const [movements, setMovements] = useState<BankListResponse | null>(null);
|
||||||
|
const [summary, setSummary] = useState<BankSummary | null>(null);
|
||||||
|
const [summaryYear, setSummaryYear] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBankStats().then(setStats).catch(() => setStats(null));
|
||||||
|
getBankFacets().then(setFacets).catch(() => setFacets(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runSearch = useCallback(
|
||||||
|
(p: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
listBankMovements({
|
||||||
|
query: query || undefined,
|
||||||
|
direction: direction || undefined,
|
||||||
|
cleared: cleared || undefined,
|
||||||
|
from: from || undefined,
|
||||||
|
to: to || undefined,
|
||||||
|
sort,
|
||||||
|
page: p,
|
||||||
|
pageSize: 25,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setMovements(res);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setError(e?.message ?? "No se pudieron cargar los movimientos.");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[query, direction, cleared, from, to, sort],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "movimientos") return;
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [runSearch, view]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "resumen") return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getBankSummary(summaryYear ?? undefined)
|
||||||
|
.then((res) => {
|
||||||
|
setSummary(res);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setError(e?.message ?? "No se pudo cargar el resumen.");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [view, summaryYear]);
|
||||||
|
|
||||||
|
function goToPage(p: number) {
|
||||||
|
runSearch(p);
|
||||||
|
if (typeof window !== "undefined")
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clicking a year in the resumen drills into its months. */
|
||||||
|
function pickYear(year: number) {
|
||||||
|
setSummaryYear((prev) => (prev === year ? null : year));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jumping from a headline figure lands on the matching filtered register. */
|
||||||
|
function pickDirection(d: BankDirection) {
|
||||||
|
setView("movimientos");
|
||||||
|
setDirection(d);
|
||||||
|
setSort(d === "income" ? "amount_desc" : "amount_asc");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
query !== "" ||
|
||||||
|
direction !== "" ||
|
||||||
|
cleared !== "" ||
|
||||||
|
from !== "" ||
|
||||||
|
to !== "" ||
|
||||||
|
sort !== "date_desc";
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setQuery("");
|
||||||
|
setDirection("");
|
||||||
|
setCleared("");
|
||||||
|
setFrom("");
|
||||||
|
setTo("");
|
||||||
|
setSort("date_desc");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head rise">
|
||||||
|
<p className="eyebrow">Cuenta propia de la oficina</p>
|
||||||
|
<h1 className="page-title">Chequera</h1>
|
||||||
|
<BankStatStrip
|
||||||
|
stats={stats}
|
||||||
|
direction={view === "movimientos" ? direction : ""}
|
||||||
|
onPickDirection={pickDirection}
|
||||||
|
/>
|
||||||
|
<p className="section-note">
|
||||||
|
Movimientos de la cuenta bancaria de la oficina, en pesos. No forma
|
||||||
|
parte del estado de cuenta de los clientes y sus cifras no se suman
|
||||||
|
con las de ellos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="search-box">
|
||||||
|
<span className="search-icon" aria-hidden>
|
||||||
|
⌕
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input search-input"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Buscar por beneficiario, cheque, nota…"
|
||||||
|
aria-label="Buscar en la chequera"
|
||||||
|
disabled={view === "resumen"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="seg" role="tablist" aria-label="Vista">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: "movimientos" as View, label: "Movimientos" },
|
||||||
|
{ key: "resumen" as View, label: "Resumen por periodo" },
|
||||||
|
]
|
||||||
|
).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={view === v.key}
|
||||||
|
className={`seg-btn ${view === v.key ? "active" : ""}`}
|
||||||
|
onClick={() => setView(v.key)}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === "movimientos" && (
|
||||||
|
<div className="filter-row">
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Movimiento</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={direction}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDirection(e.target.value as BankDirection | "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{DIRECTIONS.map((d) => (
|
||||||
|
<option key={d.key} value={d.key}>
|
||||||
|
{d.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Estatus</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={cleared}
|
||||||
|
onChange={(e) => setCleared(e.target.value as BankCleared | "")}
|
||||||
|
>
|
||||||
|
{CLEARED.map((c) => (
|
||||||
|
<option key={c.key} value={c.key}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Desde</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
value={from}
|
||||||
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Hasta</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
value={to}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Ordenar por</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value as BankSort)}
|
||||||
|
>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<option key={s.key} value={s.key}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{filtered && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost filter-clear"
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "resumen" && facets && facets.years.length > 0 && (
|
||||||
|
<div className="filter-row">
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Año</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={summaryYear ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSummaryYear(e.target.value ? Number(e.target.value) : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Todos los años</option>
|
||||||
|
{facets.years.map((y) => (
|
||||||
|
<option key={y.year} value={y.year}>
|
||||||
|
{y.year} ({formatNumber(y.count)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "movimientos" && movements && !loading && !error && (
|
||||||
|
<div className="result-meta" aria-live="polite">
|
||||||
|
{movements.total === 0
|
||||||
|
? "Sin resultados"
|
||||||
|
: `${formatNumber(movements.total)} ${
|
||||||
|
movements.total === 1 ? "movimiento" : "movimientos"
|
||||||
|
}`}
|
||||||
|
{query ? ` para “${query}”` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "movimientos" && movements && !loading && (
|
||||||
|
<FilteredTotals totals={movements.totals} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<ListSkeleton />
|
||||||
|
) : view === "resumen" ? (
|
||||||
|
<SummaryView
|
||||||
|
summary={summary}
|
||||||
|
year={summaryYear}
|
||||||
|
onPickYear={pickYear}
|
||||||
|
/>
|
||||||
|
) : movements && movements.total === 0 ? (
|
||||||
|
<EmptyState query={query} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Cheque / ref.</th>
|
||||||
|
<th>Beneficiario / concepto</th>
|
||||||
|
<th>Origen</th>
|
||||||
|
<th className="num">Monto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{movements?.items.map((m) => (
|
||||||
|
<BankRow key={m.id} m={m} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{movements && movements.pageCount > 1 && (
|
||||||
|
<Pager
|
||||||
|
page={movements.page}
|
||||||
|
pageCount={movements.pageCount}
|
||||||
|
onChange={goToPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Headline figures; the ingreso/egreso cells double as register shortcuts. */
|
||||||
|
function BankStatStrip({
|
||||||
|
stats,
|
||||||
|
direction,
|
||||||
|
onPickDirection,
|
||||||
|
}: {
|
||||||
|
stats: BankStats | null;
|
||||||
|
direction: BankDirection | "";
|
||||||
|
onPickDirection: (d: BankDirection) => void;
|
||||||
|
}) {
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="stat-strip" aria-hidden>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div className="stat-cell" key={i}>
|
||||||
|
<div className="skeleton" style={{ height: 25, width: "60%" }} />
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 11, width: "80%", marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stat-strip">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stat-cell stat-cell-btn accent${
|
||||||
|
direction === "income" ? " selected" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onPickDirection("income")}
|
||||||
|
aria-pressed={direction === "income"}
|
||||||
|
>
|
||||||
|
<div className="stat-value tx-amount pos">
|
||||||
|
{formatMoney(stats.income, "MXN")}
|
||||||
|
</div>
|
||||||
|
<div className="stat-label">
|
||||||
|
En ingresos · {formatNumber(stats.incomeCount)} movimientos
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stat-cell stat-cell-btn${
|
||||||
|
direction === "expense" ? " selected" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onPickDirection("expense")}
|
||||||
|
aria-pressed={direction === "expense"}
|
||||||
|
>
|
||||||
|
<div className="stat-value tx-amount neg">
|
||||||
|
{formatMoney(stats.expense, "MXN")}
|
||||||
|
</div>
|
||||||
|
<div className="stat-label">
|
||||||
|
En egresos · {formatNumber(stats.expenseCount)} movimientos
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="stat-cell">
|
||||||
|
<div className="stat-value">{formatMoney(stats.net, "MXN")}</div>
|
||||||
|
{/* Not the bank balance: the register carries no opening balance. */}
|
||||||
|
<div className="stat-label">Movimiento neto acumulado</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-cell">
|
||||||
|
<div className="stat-value">{formatNumber(stats.movements)}</div>
|
||||||
|
<div className="stat-label">
|
||||||
|
Movimientos · {formatDate(stats.firstMovement)} a{" "}
|
||||||
|
{formatDate(stats.lastMovement)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stat-cell stat-cell-btn${
|
||||||
|
direction === "void" ? " selected" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onPickDirection("void")}
|
||||||
|
aria-pressed={direction === "void"}
|
||||||
|
>
|
||||||
|
<div className="stat-value">{formatNumber(stats.voidCount)}</div>
|
||||||
|
<div className="stat-label">
|
||||||
|
Cheques cancelados · {formatNumber(stats.pending)} sin operar
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Totals for everything the current filter matched, not just the page. */
|
||||||
|
function FilteredTotals({ totals }: { totals: BankTotals }) {
|
||||||
|
if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0)
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
<div className="filtered-totals">
|
||||||
|
<div className="filtered-total">
|
||||||
|
<span className="filtered-total-cur">MXN</span>
|
||||||
|
<span>
|
||||||
|
<strong className="tx-amount pos">
|
||||||
|
{formatMoney(totals.income, "MXN")}
|
||||||
|
</strong>{" "}
|
||||||
|
en ingresos · {formatNumber(totals.incomeCount)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className="tx-amount neg">
|
||||||
|
{formatMoney(totals.expense, "MXN")}
|
||||||
|
</strong>{" "}
|
||||||
|
en egresos · {formatNumber(totals.expenseCount)}
|
||||||
|
</span>
|
||||||
|
<span className="filtered-total-net">
|
||||||
|
Neto <strong>{formatMoney(totals.net, "MXN")}</strong>
|
||||||
|
</span>
|
||||||
|
{totals.voidCount > 0 && (
|
||||||
|
<span>{formatNumber(totals.voidCount)} cancelados</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BankRow({ m }: { m: BankListItem }) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(m.transactionDate)}
|
||||||
|
</td>
|
||||||
|
<td className="tx-ref">
|
||||||
|
{m.reference || "—"}
|
||||||
|
{!m.cleared && (
|
||||||
|
<div className="tx-concept">Sin operar</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{m.concept || <span className="muted">Sin concepto</span>}
|
||||||
|
{m.notes && <div className="tx-concept">Nota: {m.notes}</div>}
|
||||||
|
</td>
|
||||||
|
<td>{bankSourceLabel(m.source)}</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className={`tx-amount ${bankTone(m.direction)}`}>
|
||||||
|
{m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")}
|
||||||
|
</span>
|
||||||
|
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingresos vs egresos per period. `cumulative` is the net movement since the
|
||||||
|
* register opened in 2013, not a bank balance — SCOTHIA has no opening figure.
|
||||||
|
*/
|
||||||
|
function SummaryView({
|
||||||
|
summary,
|
||||||
|
year,
|
||||||
|
onPickYear,
|
||||||
|
}: {
|
||||||
|
summary: BankSummary | null;
|
||||||
|
year: number | null;
|
||||||
|
onPickYear: (y: number) => void;
|
||||||
|
}) {
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Año</th>
|
||||||
|
<th className="num">Movimientos</th>
|
||||||
|
<th className="num">Ingresos</th>
|
||||||
|
<th className="num">Egresos</th>
|
||||||
|
<th className="num">Neto</th>
|
||||||
|
<th className="num">Acumulado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{summary.years.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.period}
|
||||||
|
onClick={() => onPickYear(r.period)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
className={year === r.period ? "selected" : undefined}
|
||||||
|
>
|
||||||
|
<td className="mono">
|
||||||
|
<strong>{r.period}</strong>
|
||||||
|
</td>
|
||||||
|
<td className="num">{formatNumber(r.count)}</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className="tx-amount pos">
|
||||||
|
{formatMoney(r.income, "MXN")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className="tx-amount neg">
|
||||||
|
{formatMoney(r.expense, "MXN")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span
|
||||||
|
className={`tx-amount ${
|
||||||
|
Number(r.net) < 0 ? "neg" : "pos"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatMoney(r.net, "MXN")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num mono">{formatMoney(r.cumulative, "MXN")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="section-note">
|
||||||
|
El acumulado es el movimiento neto desde que inicia el registro; la
|
||||||
|
chequera heredada no trae saldo inicial, así que no equivale al saldo
|
||||||
|
del banco. Selecciona un año para ver sus meses.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{year && summary.months.length > 0 && (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-head">
|
||||||
|
<span className="section-rule cuenta" />
|
||||||
|
<h2 className="section-title">Meses de {year}</h2>
|
||||||
|
<span className="section-count">
|
||||||
|
abre en {formatMoney(summary.opening, "MXN")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mes</th>
|
||||||
|
<th className="num">Movimientos</th>
|
||||||
|
<th className="num">Ingresos</th>
|
||||||
|
<th className="num">Egresos</th>
|
||||||
|
<th className="num">Neto</th>
|
||||||
|
<th className="num">Acumulado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{summary.months.map((r) => (
|
||||||
|
<tr key={r.period}>
|
||||||
|
<td>{monthName(r.period)}</td>
|
||||||
|
<td className="num">{formatNumber(r.count)}</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className="tx-amount pos">
|
||||||
|
{formatMoney(r.income, "MXN")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className="tx-amount neg">
|
||||||
|
{formatMoney(r.expense, "MXN")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span
|
||||||
|
className={`tx-amount ${
|
||||||
|
Number(r.net) < 0 ? "neg" : "pos"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatMoney(r.net, "MXN")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num mono">
|
||||||
|
{formatMoney(r.cumulative, "MXN")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pager({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
onChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav className="pager" aria-label="Paginación">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => onChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
← Anterior
|
||||||
|
</button>
|
||||||
|
<span className="pager-info">
|
||||||
|
Página <strong>{page}</strong> de {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => onChange(page + 1)}
|
||||||
|
disabled={page >= pageCount}
|
||||||
|
>
|
||||||
|
Siguiente →
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="cust-list" aria-hidden>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div className="skeleton skel-row" key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ query }: { query: string }) {
|
||||||
|
return (
|
||||||
|
<div className="state-box">
|
||||||
|
<div className="state-glyph" aria-hidden>
|
||||||
|
⌕
|
||||||
|
</div>
|
||||||
|
<h3>Sin resultados</h3>
|
||||||
|
<p>
|
||||||
|
{query
|
||||||
|
? `No encontramos movimientos para “${query}”.`
|
||||||
|
: "No hay movimientos que coincidan con los filtros."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1344,6 +1344,11 @@ button {
|
|||||||
.tx-table tr:hover td {
|
.tx-table tr:hover td {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
/* Drill-down rows (the chequera's yearly summary) read as pressable. */
|
||||||
|
.tx-table tr.selected td {
|
||||||
|
background: var(--surface-2);
|
||||||
|
box-shadow: inset 2px 0 0 var(--brand-600);
|
||||||
|
}
|
||||||
.tx-domain-cell {
|
.tx-domain-cell {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const NAV = [
|
|||||||
{ 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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import type {
|
|||||||
BalanceFilter,
|
BalanceFilter,
|
||||||
BalanceListResponse,
|
BalanceListResponse,
|
||||||
BalanceSort,
|
BalanceSort,
|
||||||
|
BankCleared,
|
||||||
|
BankDirection,
|
||||||
|
BankFacets,
|
||||||
|
BankListResponse,
|
||||||
|
BankSort,
|
||||||
|
BankStats,
|
||||||
|
BankSummary,
|
||||||
BillingFacets,
|
BillingFacets,
|
||||||
BillingStats,
|
BillingStats,
|
||||||
BusinessLine,
|
BusinessLine,
|
||||||
@@ -294,3 +301,43 @@ export function getBillingFacets(): Promise<BillingFacets> {
|
|||||||
export function getStatement(customerId: string): Promise<Statement> {
|
export function getStatement(customerId: string): Promise<Statement> {
|
||||||
return apiFetch<Statement>(`/billing/customers/${customerId}`);
|
return apiFetch<Statement>(`/billing/customers/${customerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------- Bank register (chequera) */
|
||||||
|
|
||||||
|
export interface BankQuery {
|
||||||
|
query?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
direction?: BankDirection;
|
||||||
|
cleared?: BankCleared;
|
||||||
|
/** `YYYY-MM-DD`, inclusive on both ends. */
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
sort?: BankSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q.query) params.set("query", q.query);
|
||||||
|
if (q.page) params.set("page", String(q.page));
|
||||||
|
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||||
|
if (q.direction) params.set("direction", q.direction);
|
||||||
|
if (q.cleared) params.set("cleared", q.cleared);
|
||||||
|
if (q.from) params.set("from", q.from);
|
||||||
|
if (q.to) params.set("to", q.to);
|
||||||
|
if (q.sort) params.set("sort", q.sort);
|
||||||
|
const qs = params.toString();
|
||||||
|
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBankStats(): Promise<BankStats> {
|
||||||
|
return apiFetch<BankStats>("/bank/stats");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBankFacets(): Promise<BankFacets> {
|
||||||
|
return apiFetch<BankFacets>("/bank/facets");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBankSummary(year?: number): Promise<BankSummary> {
|
||||||
|
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Spanish label maps + formatting helpers. Single source of truth for i18n.
|
// Spanish label maps + formatting helpers. Single source of truth for i18n.
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
BankDirection,
|
||||||
LedgerDirection,
|
LedgerDirection,
|
||||||
PolicyStatus,
|
PolicyStatus,
|
||||||
ServiceKind,
|
ServiceKind,
|
||||||
@@ -219,6 +220,59 @@ export function balanceTone(balance: string | number): "owing" | "credit" | "fla
|
|||||||
return n < 0 ? "owing" : "credit";
|
return n < 0 ? "owing" : "credit";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- chequera / bank register -----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The office's own account, so the words are the bank's, not the ledger's:
|
||||||
|
* an ingreso is money arriving, an egreso money leaving, and a zero-amount row
|
||||||
|
* is a cheque that was voided.
|
||||||
|
*/
|
||||||
|
export const BANK_DIRECTION_LABELS: Record<BankDirection, string> = {
|
||||||
|
income: "Ingreso",
|
||||||
|
expense: "Egreso",
|
||||||
|
void: "Cancelado",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bankDirectionLabel(d: BankDirection): string {
|
||||||
|
return BANK_DIRECTION_LABELS[d] ?? d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS-class suffix for colouring a bank figure, matching `.tx-amount`. */
|
||||||
|
export function bankTone(d: BankDirection): "pos" | "neg" | "" {
|
||||||
|
if (d === "income") return "pos";
|
||||||
|
return d === "expense" ? "neg" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy SCOTHIA table a register row came from. */
|
||||||
|
export const BANK_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
"DATOS I": "Ingresos",
|
||||||
|
"DATOS E": "Egresos",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bankSourceLabel(source: string | null | undefined): string {
|
||||||
|
if (!source) return "—";
|
||||||
|
return BANK_SOURCE_LABELS[source] ?? source;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MONTH_NAMES = [
|
||||||
|
"Enero",
|
||||||
|
"Febrero",
|
||||||
|
"Marzo",
|
||||||
|
"Abril",
|
||||||
|
"Mayo",
|
||||||
|
"Junio",
|
||||||
|
"Julio",
|
||||||
|
"Agosto",
|
||||||
|
"Septiembre",
|
||||||
|
"Octubre",
|
||||||
|
"Noviembre",
|
||||||
|
"Diciembre",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function monthName(month: number): string {
|
||||||
|
return MONTH_NAMES[month - 1] ?? String(month);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- formatting -----
|
// ----- formatting -----
|
||||||
|
|
||||||
export function formatMoney(
|
export function formatMoney(
|
||||||
|
|||||||
@@ -671,3 +671,91 @@ export interface CustomerDetail {
|
|||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
transactionSummary: TransactionSummaryRow[];
|
transactionSummary: TransactionSummaryRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------- Bank register (chequera) */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The office's own checking account. Single-currency (MXN) and with no customer
|
||||||
|
* link — see `bank.service.ts`. Positive is a deposit, negative a payment, and
|
||||||
|
* exactly zero a cancelled cheque.
|
||||||
|
*/
|
||||||
|
export type BankDirection = "income" | "expense" | "void";
|
||||||
|
|
||||||
|
export type BankCleared = "cleared" | "pending";
|
||||||
|
|
||||||
|
export type BankSort =
|
||||||
|
| "date_desc"
|
||||||
|
| "date_asc"
|
||||||
|
| "amount_desc"
|
||||||
|
| "amount_asc"
|
||||||
|
| "reference";
|
||||||
|
|
||||||
|
export interface BankListItem {
|
||||||
|
id: string;
|
||||||
|
transactionDate: string;
|
||||||
|
/** "INGRESO" / "EGRESO" as recorded in the source. */
|
||||||
|
transactionType: string | null;
|
||||||
|
/** Cheque number on egresos, deposit slip on ingresos. */
|
||||||
|
reference: string | null;
|
||||||
|
/** The payee or payer — a name, not a category. */
|
||||||
|
concept: string | null;
|
||||||
|
amount: string;
|
||||||
|
direction: BankDirection;
|
||||||
|
cleared: boolean;
|
||||||
|
transferred: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
/** "CIENTO CINCUENTA MIL PESOS 00/100" — egresos only. */
|
||||||
|
amountInWords: string | null;
|
||||||
|
source: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankTotals {
|
||||||
|
income: string;
|
||||||
|
incomeCount: number;
|
||||||
|
expense: string;
|
||||||
|
expenseCount: number;
|
||||||
|
net: string;
|
||||||
|
voidCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankListResponse {
|
||||||
|
items: BankListItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageCount: number;
|
||||||
|
/** Totals for the whole filtered set, not just the current page. */
|
||||||
|
totals: BankTotals;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankStats extends BankTotals {
|
||||||
|
movements: number;
|
||||||
|
firstMovement: string | null;
|
||||||
|
lastMovement: string | null;
|
||||||
|
/** `operado` is false — recorded but not yet cleared by the bank. */
|
||||||
|
pending: number;
|
||||||
|
transferred: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankFacets {
|
||||||
|
years: { year: number; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankPeriodRow {
|
||||||
|
/** Year, or month number 1–12 in the monthly table. */
|
||||||
|
period: number;
|
||||||
|
count: number;
|
||||||
|
income: string;
|
||||||
|
expense: string;
|
||||||
|
net: string;
|
||||||
|
/** Net movement since the register opened — not a bank balance. */
|
||||||
|
cumulative: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankSummary {
|
||||||
|
year: number | null;
|
||||||
|
years: (BankPeriodRow & { opening: string })[];
|
||||||
|
months: BankPeriodRow[];
|
||||||
|
/** Cumulative figure the selected year opened on. */
|
||||||
|
opening: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user