Facturas de cliente

This commit is contained in:
David Arranz 2025-09-12 20:07:38 +02:00
parent 817dcff8c5
commit af7d3dcf28
17 changed files with 134 additions and 297 deletions

View File

@ -43,8 +43,8 @@
"dependencies": {
"@erp/auth": "workspace:*",
"@erp/core": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*",
"bcrypt": "^5.1.1",
"cls-rtracer": "^2.6.3",
"cors": "^2.8.5",
@ -52,6 +52,7 @@
"dotenv": "^16.5.0",
"express": "^4.18.2",
"express-list-routes": "^1.3.1",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"http": "0.0.1-security",
"jsonwebtoken": "^9.0.2",
@ -62,6 +63,8 @@
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"path": "^0.12.7",
"puppeteer": "^24.20.0",
"puppeteer-report": "^3.2.0",
"reflect-metadata": "^0.2.2",
"response-time": "^2.3.3",
"sequelize": "^6.37.5",
@ -76,9 +79,14 @@
"node": ">=22"
},
"tsup": {
"entry": ["src/index.ts"],
"entry": [
"src/index.ts"
],
"outDir": "dist",
"format": ["esm", "cjs"],
"format": [
"esm",
"cjs"
],
"target": "ES2022",
"sourcemap": true,
"clean": true,

View File

@ -5,7 +5,7 @@ export type IPresenterParams = {
presenterRegistry: IPresenterRegistry;
} & Record<string, unknown>;
export abstract class Presenter implements IPresenter {
export abstract class Presenter<T, S> implements IPresenter<T, S> {
constructor(protected presenterRegistry: IPresenterRegistry) {}
abstract toOutput(source: unknown): unknown;
abstract toOutput(source: T): S;
}

View File

@ -123,7 +123,7 @@ export abstract class ExpressController {
this.res.set({
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename=${filename}`,
//"Content-Length": buffer.length,
"Content-Length": pdfBuffer.length,
});
return this.res.send(pdfBuffer);

View File

@ -13,7 +13,10 @@
"@tanstack/react-query": "^5.74.11",
"dinero.js": "^1.9.1",
"express": "^4.18.2",
"handlebars": "^4.7.8",
"i18next": "^25.1.1",
"puppeteer": "^24.20.0",
"puppeteer-report": "^3.2.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"sequelize": "^6.37.5",
@ -45,11 +48,8 @@
"ag-grid-community": "^33.3.0",
"ag-grid-react": "^33.3.0",
"date-fns": "^4.1.0",
"handlebars": "^4.7.8",
"libphonenumber-js": "^1.12.7",
"lucide-react": "^0.503.0",
"puppeteer": "^24.20.0",
"puppeteer-report": "^3.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.26.0"

View File

@ -45,6 +45,7 @@ export class CustomerInvoiceFullPresenter extends Presenter {
metadata: {
entity: "customer-invoices",
link: "",
},
};
}

View File

@ -2,6 +2,7 @@ import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceService } from "../../domain";
import { CustomerInvoiceReportPDFPresenter } from "./reporter";
type ReportCustomerInvoiceUseCaseInput = {
companyId: UniqueID;
@ -15,7 +16,7 @@ export class ReportCustomerInvoiceUseCase {
private readonly presenterRegistry: IPresenterRegistry
) {}
public execute(params: ReportCustomerInvoiceUseCaseInput) {
public async execute(params: ReportCustomerInvoiceUseCaseInput) {
const { invoice_id, companyId } = params;
const idOrError = UniqueID.create(invoice_id);
@ -29,7 +30,7 @@ export class ReportCustomerInvoiceUseCase {
resource: "customer-invoice",
projection: "REPORT",
format: "PDF",
});
}) as CustomerInvoiceReportPDFPresenter;
return this.transactionManager.complete(async (transaction) => {
try {
@ -43,7 +44,7 @@ export class ReportCustomerInvoiceUseCase {
}
const invoice = invoiceOrError.data;
const pdfData = pdfPresenter.toOutput(invoiceOrError.data);
const pdfData = await pdfPresenter.toOutput(invoice);
return Result.ok({
data: pdfData,
filename: `invoice-${invoice.invoiceNumber}.pdf`,

View File

@ -15,7 +15,7 @@ export class CustomerInvoiceReportHTMLPresenter extends Presenter {
// Obtener y compilar la plantilla HTML
const templateHtml = readFileSync(
path.join(__dirname, "./templates/quote/template.hbs")
path.join(__dirname, "./templates/customer-invoice/template.hbs")
).toString();
const template = handlebars.compile(templateHtml, {});
return template(invoiceDTO);

View File

@ -4,50 +4,53 @@ import report from "puppeteer-report";
import { CustomerInvoice } from "../../../domain";
import { CustomerInvoiceReportHTMLPresenter } from "./customer-invoice.report.html";
export interface ICustomerInvoiceReporter {
toHTML: (invoice: CustomerInvoice) => Promise<string>;
toPDF: (invoice: CustomerInvoice) => Promise<Buffer>;
}
// https://plnkr.co/edit/lWk6Yd?preview
export class CustomerInvoiceReportPDFPresenter extends Presenter {
async toOutput(customerInvoice: CustomerInvoice): Promise<Buffer> {
const htmlPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
projection: "REPORT",
format: "HTML",
}) as CustomerInvoiceReportHTMLPresenter;
export class CustomerInvoiceReportPDFPresenter extends Presenter<
CustomerInvoice,
Promise<Buffer<ArrayBuffer>>
> {
async toOutput(customerInvoice: CustomerInvoice): Promise<Buffer<ArrayBuffer>> {
try {
const htmlPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
projection: "REPORT",
format: "HTML",
}) as CustomerInvoiceReportHTMLPresenter;
const htmlData = htmlPresenter.toOutput(customerInvoice);
const htmlData = htmlPresenter.toOutput(customerInvoice);
// Generar el PDF con Puppeteer
const browser = await puppeteer.launch({
args: [
"--disable-extensions",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
],
});
// Generar el PDF con Puppeteer
const browser = await puppeteer.launch({
args: [
"--disable-extensions",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
],
});
const page = await browser.newPage();
const navigationPromise = page.waitForNavigation();
await page.setContent(htmlData, { waitUntil: "networkidle2" });
const page = await browser.newPage();
const navigationPromise = page.waitForNavigation();
await page.setContent(htmlData, { waitUntil: "networkidle2" });
await navigationPromise;
const reportPDF = await report.pdfPage(page, {
format: "A4",
margin: {
bottom: "10mm",
left: "10mm",
right: "10mm",
top: "10mm",
},
});
await navigationPromise;
const reportPDF = await report.pdfPage(page, {
format: "A4",
margin: {
bottom: "10mm",
left: "10mm",
right: "10mm",
top: "10mm",
},
});
await browser.close();
return Buffer.from(reportPDF);
await browser.close();
return Buffer.from(reportPDF);
} catch (err: unknown) {
console.error(err);
throw err as Error;
}
}
}

View File

@ -0,0 +1,44 @@
<html lang="{{lang_code}}">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
referrerpolicy="no-referrer" />
<title>Presupuesto #{{id}}</title>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
color: #000;
font-size: 11pt;
line-height: 1.6;
}
#header {
margin-bottom: 0;
padding-bottom: 0;
}
#footer {}
@media print {
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
}
</style>
</head>
<body>
<footer id="footer" class="mt-4">
<aside><img src="https://uecko.com/assets/img/uecko-footer_logos.jpg" class="w-full" /></aside>
</footer>
</body>
</html>

View File

@ -1,152 +0,0 @@
<html lang="{{lang_code}}">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
referrerpolicy="no-referrer" />
<title>Presupuesto #{{id}}</title>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
color: #000;
font-size: 11pt;
line-height: 1.6;
}
#header {
margin-bottom: 0;
padding-bottom: 0;
}
#footer {}
@media print {
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
}
</style>
</head>
<body>
<header id="header">
<aside class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">DISTRIBUIDOR OFICIAL</h3>
<div id="logo" class="w-32">
<svg viewBox="0 0 336 133" fill="none" xmlns="http://www.w3.org/2000/svg" class="uecko-logo">
<path
d="M49.7002 83.0001H66.9002V22.5001H49.7002V56.2001C49.7002 64.3001 45.5002 68.5001 39.0002 68.5001C32.5002 68.5001 28.6002 64.3001 28.6002 56.2001V22.5001H0.700195V33.2001H11.4002V61.6001C11.4002 75.5001 19.0002 84.1001 31.9002 84.1001C40.6002 84.1001 45.7002 79.5001 49.6002 74.4001V83.0001H49.7002ZM120.6 48.0001H94.8002C96.2002 40.2001 100.8 35.1001 107.9 35.1001C115.1 35.2001 119.6 40.3001 120.6 48.0001ZM137.1 58.7001C137.2 57.1001 137.3 56.1001 137.3 54.4001V54.2001C137.3 37.0001 128 21.4001 107.8 21.4001C90.2002 21.4001 77.9002 35.6001 77.9002 52.9001V53.1001C77.9002 71.6001 91.3002 84.4001 109.5 84.4001C120.4 84.4001 128.6 80.1001 134.2 73.1001L124.4 64.4001C119.7 68.8001 115.5 70.6001 109.7 70.6001C102 70.6001 96.6002 66.5001 94.9002 58.7001H137.1ZM162.2 52.9001V52.7001C162.2 43.8001 168.3 36.2001 176.9 36.2001C183 36.2001 186.8 38.8001 190.7 42.9001L201.2 31.6001C195.6 25.3001 188.4 21.4001 177 21.4001C158.5 21.4001 145.3 35.6001 145.3 52.9001V53.1001C145.3 70.4001 158.6 84.4001 176.8 84.4001C188.9 84.4001 195.6 79.8001 201.5 73.3001L191.5 63.1001C187.3 67.1001 183.4 69.5001 177.6 69.5001C168.2 69.6001 162.2 62.1001 162.2 52.9001ZM269.1 83.0001L245.3 46.3001L268.3 22.5001H247.8L227.7 44.5001V0.600098H210.5V83.0001H227.7V64.6001L233.7 58.3001L249.5 83.0001H269.1ZM318.5 53.1001C318.5 62.0001 312.6 69.6001 302.8 69.6001C293.3 69.6001 286.9 61.8001 286.9 52.9001V52.7001C286.9 43.8001 292.8 36.2001 302.6 36.2001C312.1 36.2001 318.5 44.0001 318.5 52.9001V53.1001ZM335.4 52.9001V52.7001C335.4 35.3001 321.5 21.4001 302.8 21.4001C284 21.4001 270 35.5001 270 52.9001V53.1001C270 70.5001 283.9 84.4001 302.6 84.4001C321.4 84.4001 335.4 70.3001 335.4 52.9001Z"
fill="black" class="uecko-logo"></path>
</svg>
</div>
</aside>
<section class="flex pb-2 space-x-4">
<img id="dealer-logo" src={{dealer.logo}} alt="Logo distribuidor" />
<address class="text-base not-italic font-medium whitespace-pre-line" id="from">{{dealer.contact_information}}
</address>
</section>
<section class="grid grid-cols-2 gap-4 pb-4 mb-4 border-b">
<aside>
<p class="text-sm"><strong>Presupuesto nº:</strong> {{reference}}</p>
<p class="text-sm"><strong>Fecha:</strong> {{date}}</p>
<p class="text-sm"><strong>Validez:</strong> {{validity}}</p>
<p class="text-sm"><strong>Vendedor:</strong> {{dealer.name}}</p>
<p class="text-sm"><strong>Referencia cliente:</strong> {{customer_reference}}</p>
</aside>
<address class="text-base not-italic font-semibold whitespace-pre-line" id="to">{{customer_information}}
</address>
</section>
<aside class="flex items-center justify-between mb-4">
<h3 class="text-2xl font-normal">PRESUPUESTO</h3>
<div id="header-pagination">
Página <span class="pageNumber"></span> de <span class="totalPages"></span>
</div>
</aside>
</header>
<main id="main">
<section id="details">
<table class="table-header">
<thead>
<tr>
<th class="px-2 py-2 text-right border">Cant.</th>
<th class="px-2 py-2 border">Descripción</th>
<th class="px-2 py-2 text-right border">Prec. Unitario</th>
<th class="px-2 py-2 text-right border">Subtotal</th>
<th class="px-2 py-2 text-right border">Dto (%)</th>
<th class="px-2 py-2 text-right border">Importe total</th>
</tr>
</thead>
<tbody class="table-body">
{{#each items}}
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">{{quantity}}</td>
<td class="px-2 py-2 font-medium">{{description}}</td>
<td class="content-start px-2 py-2 text-right">{{unit_price}}</td>
<td class="content-start px-2 py-2 text-right">{{subtotal_price}}</td>
<td class="content-start px-2 py-2 text-right">{{discount}}</td>
<td class="content-start px-2 py-2 text-right">{{total_price}}</td>
</tr>
{{/each}}
</tbody>
</table>
</section>
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
<div class="grow">
<div class="pt-4">
<p class="text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
</div>
<div class="pt-4">
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
</div>
</div>
<div class="grow">
<table class="min-w-full bg-transparent">
<tbody>
<tr class="border-b">
<td class="px-4 py-2">Importe neto</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{subtotal_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2">% Descuento</td>
<td class="px-4 py-2 text-right border">{{discount.amount}}</td>
<td class="px-4 py-2 text-right border">{{discount_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2">Base imponible</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{before_tax_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2">% IVA</td>
<td class="px-4 py-2 text-right border">{{tax}}</td>
<td class="px-4 py-2 text-right border">{{tax_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2">Importe total</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{total_price}}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="legal_terms">
<p class="text-xs text-gray-500">{{quote.default_legal_terms}}</p>
</section>
</main>
<footer id="footer" class="mt-4">
<aside><img src="https://uecko.com/assets/img/uecko-footer_logos.jpg" class="w-full" /></aside>
</footer>
</body>
</html>

View File

@ -1,5 +1,5 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { GetCustomerInvoiceUseCase as ReportCustomerInvoiceUseCase } from "../../../application";
import { ReportCustomerInvoiceUseCase } from "../../../application";
export class ReportCustomerInvoiceController extends ExpressController {
public constructor(private readonly useCase: ReportCustomerInvoiceUseCase) {
@ -15,7 +15,7 @@ export class ReportCustomerInvoiceController extends ExpressController {
const result = await this.useCase.execute({ invoice_id, companyId });
return result.match(
({ pdfData, filename }) => this.downloadPDF(pdfData, filename),
({ data, filename }) => this.downloadPDF(data, filename),
(err) => this.handleError(err)
);
}

View File

@ -11,6 +11,7 @@ import { MoneyDTO } from "@erp/core";
import { formatDate, formatMoney } from "@erp/core/client";
// Core CSS
import { AgGridReact } from "ag-grid-react";
import { Link } from "react-router-dom";
import { useCustomerInvoicesQuery } from "../hooks";
import { useTranslation } from "../i18n";
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
@ -76,6 +77,13 @@ export const CustomerInvoicesListGrid = () => {
return formatMoney(rawValue);
},
},
{
field: "id",
headerName: t("pages.list.grid_columns.total_amount"),
cellRenderer: (params: ValueFormatterParams) => {
return <Link to={params.value}>Hola</Link>;
},
},
]);
const gridOptions: GridOptions = {

View File

@ -276,98 +276,11 @@ export const CustomerInvoiceEditForm = ({
form.reset(initialData);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit, handleError)}>
<div className='grid xl:grid-cols-2 space-y-6'>
<Card className='border-0 shadow-none xl:border-r xl:border-dashed rounded-none'>
<CardHeader>
<CardTitle>Cliente</CardTitle>
<CardDescription>Description</CardDescription>
<CardAction>
<Button variant='link'>Sign Up</Button>
<Button variant='link'>Sign Up</Button>
<Button variant='link'>Sign Up</Button>
<Button variant='link'>Sign Up</Button>
</CardAction>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-4 space-y-6'>
<ClientSelector />
</CardContent>
<CardFooter className='flex-col gap-2'>
<Button type='submit' className='w-full'>
Login
</Button>
<Button variant='outline' className='w-full'>
Login with Google
</Button>
</CardFooter>{" "}
</Card>
{/* Información básica */}
<Card className='@container border-0 shadow-none'>
<CardHeader>
<CardTitle>Información Básica</CardTitle>
<CardDescription>Detalles generales de la factura</CardDescription>
</CardHeader>
<CardContent className='@xl:grid @xl:grid-cols-2 @xl:gap-x-6 gap-y-8'>
<TextField
control={form.control}
name='invoice_number'
required
disabled
readOnly
label={t("form_fields.invoice_number.label")}
placeholder={t("form_fields.invoice_number.placeholder")}
description={t("form_fields.invoice_number.description")}
/>
<TextField
control={form.control}
name='invoice_series'
required
label={t("form_fields.invoice_series.label")}
placeholder={t("form_fields.invoice_series.placeholder")}
description={t("form_fields.invoice_series.description")}
/>
<DatePickerInputField
control={form.control}
name='invoice_date'
required
label={t("form_fields.invoice_date.label")}
placeholder={t("form_fields.invoice_date.placeholder")}
description={t("form_fields.invoice_date.description")}
/>
<TextField
className='@xl:col-start-1 @xl:col-span-full'
control={form.control}
name='description'
required
label={t("form_fields.description.label")}
placeholder={t("form_fields.description.placeholder")}
description={t("form_fields.description.description")}
/>
<TextAreaField
className='field-sizing-content @xl:col-start-1 @xl:col-span-full'
control={form.control}
name='notes'
label={t("form_fields.notes.label")}
placeholder={t("form_fields.notes.placeholder")}
description={t("form_fields.notes.description")}
/>
</CardContent>
</Card>
</div>
</form>
</Form>
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className='grid grid-cols-1 md:gap-6 md:grid-cols-6'
className='grid grid-cols-1 md:gap-6 md:grid-cols-2'
>
<Card className='border-0 shadow-none md:grid-span-2'>
<CardHeader>

View File

@ -189,8 +189,9 @@ export const ClientSelector = () => {
setSelectedCustomer(customer);
setOpen(false);
}}
onCreate={() => {
setOpen(false);
onCreate={(e) => {
e.preventDefault();
setOpen(true);
console.log("Crear nuevo cliente");
}}
page={pageNumber}

View File

@ -62,6 +62,9 @@ importers:
express-list-routes:
specifier: ^1.3.1
version: 1.3.1
handlebars:
specifier: ^4.7.8
version: 4.7.8
helmet:
specifier: ^8.0.0
version: 8.1.0
@ -92,6 +95,12 @@ importers:
path:
specifier: ^0.12.7
version: 0.12.7
puppeteer:
specifier: ^24.20.0
version: 24.20.0(typescript@5.8.3)
puppeteer-report:
specifier: ^3.2.0
version: 3.2.0
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@ -170,7 +179,7 @@ importers:
version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
@ -12283,7 +12292,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
dependencies:
bs-logger: 0.2.6
ejs: 3.1.10
@ -12301,6 +12310,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.27.4)
esbuild: 0.25.5
jest-util: 29.7.0
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):