feat(billing,bank): capture + void web UI (plan phase 5 web)
Completes phase 5 — the ledger and chequera pages get the append+void UI on top of the phase-5 API. Web: - Shared MovementForm (customer picker + línea + cargo/abono sign + amount + moneda + concepto facet + periodo/referencia/cheque/mensaje). Used by both /estado-cuenta (cross-customer, picker) and /estado-cuenta/[id] (customer prefilled). - /estado-cuenta and /estado-cuenta/[id]: "Capturar movimiento" toggle gated ledger:create; per-row "Anular" gated ledger:void; voided rows struck-through. Save/void refresh the list + stats. - /banco: inline BankCaptureForm (ingreso/egreso sign, cheque, operado, transferencia, monto en letras) gated bank:create; per-row "Anular" gated bank:void; voided rows struck-through. - api.ts: createMovement/voidMovement, createBankMovement/voidBankMovement; CreateMovementInput/CreateBankMovementInput types; `voided` on the movement/statement/bank list items. Also: lookups.controller.ts now audit-logs provider/policy-type/adjuster create/update/delete (parity with the other write controllers). API + web compile clean. This is the last piece of the feat/crud-rbac branch — all five sections plus users are now full CRUD with role gating. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,11 +6,14 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} 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 { AbilityGuard } from "../auth/ability.guard";
|
||||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
import { PoliciesService } from "./policies.service";
|
import { PoliciesService } from "./policies.service";
|
||||||
import {
|
import {
|
||||||
AdjusterDto,
|
AdjusterDto,
|
||||||
@@ -29,7 +32,14 @@ import {
|
|||||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("lookups")
|
@Controller("lookups")
|
||||||
export class LookupsController {
|
export class LookupsController {
|
||||||
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()
|
@Get()
|
||||||
list() {
|
list() {
|
||||||
@@ -38,49 +48,99 @@ export class LookupsController {
|
|||||||
|
|
||||||
@Post("providers")
|
@Post("providers")
|
||||||
@RequireAbility("lookup:manage")
|
@RequireAbility("lookup:manage")
|
||||||
createProvider(@Body() dto: ProviderDto) {
|
async createProvider(@Body() dto: ProviderDto, @Req() req: Request) {
|
||||||
return this.policies.createProvider(dto);
|
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")
|
@Patch("providers/:id")
|
||||||
@RequireAbility("lookup:manage")
|
@RequireAbility("lookup:manage")
|
||||||
updateProvider(@Param("id") id: string, @Body() dto: UpdateProviderDto) {
|
async updateProvider(
|
||||||
return this.policies.updateProvider(id, dto);
|
@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")
|
@Delete("providers/:id")
|
||||||
@RequireAbility("lookup:manage")
|
@RequireAbility("lookup:manage")
|
||||||
removeProvider(@Param("id") id: string) {
|
async removeProvider(@Param("id") id: string, @Req() req: Request) {
|
||||||
return this.policies.removeProvider(id);
|
const row = await this.policies.removeProvider(id);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.provider.delete", {
|
||||||
|
providerId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("policy-types")
|
@Post("policy-types")
|
||||||
@RequireAbility("lookup:manage")
|
@RequireAbility("lookup:manage")
|
||||||
createType(@Body() dto: PolicyTypeDto) {
|
async createType(@Body() dto: PolicyTypeDto, @Req() req: Request) {
|
||||||
return this.policies.createPolicyType(dto);
|
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")
|
@Patch("policy-types/:id")
|
||||||
@RequireAbility("lookup:manage")
|
@RequireAbility("lookup:manage")
|
||||||
updateType(@Param("id") id: string, @Body() dto: UpdatePolicyTypeDto) {
|
async updateType(
|
||||||
return this.policies.updatePolicyType(id, dto);
|
@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")
|
@Delete("policy-types/:id")
|
||||||
@RequireAbility("lookup:manage")
|
@RequireAbility("lookup:manage")
|
||||||
removeType(@Param("id") id: string) {
|
async removeType(@Param("id") id: string, @Req() req: Request) {
|
||||||
return this.policies.removePolicyType(id);
|
const row = await this.policies.removePolicyType(id);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.policyType.delete", {
|
||||||
|
policyTypeId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("adjusters")
|
@Post("adjusters")
|
||||||
@RequireAbility("lookup:manage")
|
@RequireAbility("lookup:manage")
|
||||||
createAdjuster(@Body() dto: AdjusterDto) {
|
async createAdjuster(@Body() dto: AdjusterDto, @Req() req: Request) {
|
||||||
return this.policies.createAdjuster(dto);
|
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")
|
@Patch("adjusters/:id")
|
||||||
@RequireAbility("lookup:manage")
|
@RequireAbility("lookup:manage")
|
||||||
updateAdjuster(@Param("id") id: string, @Body() dto: UpdateAdjusterDto) {
|
async updateAdjuster(
|
||||||
return this.policies.updateAdjuster(id, dto);
|
@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")
|
@Delete("adjusters/:id")
|
||||||
@RequireAbility("lookup:manage")
|
@RequireAbility("lookup:manage")
|
||||||
removeAdjuster(@Param("id") id: string) {
|
async removeAdjuster(@Param("id") id: string, @Req() req: Request) {
|
||||||
return this.policies.removeAdjuster(id);
|
const row = await this.policies.removeAdjuster(id);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.adjuster.delete", {
|
||||||
|
adjusterId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import type {
|
|||||||
BillingFacets,
|
BillingFacets,
|
||||||
BillingStats,
|
BillingStats,
|
||||||
BusinessLine,
|
BusinessLine,
|
||||||
|
CreateBankMovementInput,
|
||||||
|
CreateMovementInput,
|
||||||
CustomerDetail,
|
CustomerDetail,
|
||||||
CustomerInput,
|
CustomerInput,
|
||||||
CustomerListResponse,
|
CustomerListResponse,
|
||||||
@@ -43,6 +45,7 @@ import type {
|
|||||||
Role,
|
Role,
|
||||||
ServiceKind,
|
ServiceKind,
|
||||||
Statement,
|
Statement,
|
||||||
|
Transaction,
|
||||||
TransactionDomain,
|
TransactionDomain,
|
||||||
TrustFilter,
|
TrustFilter,
|
||||||
UserRow,
|
UserRow,
|
||||||
@@ -477,6 +480,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 {
|
||||||
@@ -517,6 +536,22 @@ 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 */
|
/* ------------------------------------------------- Users / administration */
|
||||||
|
|
||||||
export function listUsers(): Promise<UserRow[]> {
|
export function listUsers(): Promise<UserRow[]> {
|
||||||
|
|||||||
@@ -683,6 +683,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 {
|
||||||
@@ -909,6 +926,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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user