This commit is contained in:
David Arranz 2024-07-24 18:01:31 +02:00
parent ebb2d06903
commit e73aeaeefe
8 changed files with 255 additions and 25 deletions

View File

@ -31,7 +31,9 @@ export const QuotesList = () => {
<TabsTrigger value='all'>
<Trans i18nKey='quotes.list.tabs.all' />
</TabsTrigger>
<TabsTrigger value='active'>Active</TabsTrigger>
<TabsTrigger value='emitted'>
<Trans i18nKey='quotes.list.tabs.emitted' />
</TabsTrigger>
<TabsTrigger value='draft'>
<Trans i18nKey='quotes.list.tabs.draft' />
</TabsTrigger>
@ -50,8 +52,8 @@ export const QuotesList = () => {
<TabsContent value='archived'>
<QuotesDataTable status='archived' />
</TabsContent>
<TabsContent value='active'>
<QuotesDataTable status='active' />
<TabsContent value='emitted'>
<QuotesDataTable status='emitted' />
</TabsContent>
</Tabs>
</DataTableProvider>

View File

@ -81,7 +81,8 @@
"subtitle": "",
"tabs": {
"all": "Todas",
"draft": "Borrador",
"draft": "Borradores",
"emitted": "Emitidas",
"archived": "Archivadas"
},
"columns": {

View File

@ -1,24 +1,52 @@
import * as handlebars from "handlebars";
import * as puppeteer from "puppeteer";
import { Quote } from "../../../../../../domain";
import { ICollection } from "@shared/contexts";
import { Quote, QuoteItem } from "../../../../../../domain";
import { ISalesContext } from "../../../../../Sales.context";
import { readFileSync } from "fs";
import * as handlebars from "handlebars";
import path from "path";
import * as puppeteer from "puppeteer";
export interface IReportQuoteReporter {
toPDF: (quote: Quote, context: ISalesContext) => any;
toHTML: (quote: Quote, context: ISalesContext) => string;
toPDF: (quote: Quote, context: ISalesContext) => Promise<Buffer>;
}
export const ReportQuotePresenter: IReportQuoteReporter = {
toPDF: async (quote: Quote, context: ISalesContext): Promise<Buffer> => {
toHTML: (quote: Quote, context: ISalesContext): string => {
const quote_dto = map(quote, context);
// Obtener y compilar la plantilla HTML
const templateHtml = obtenerPlantillaHTML();
const template = handlebars.compile(templateHtml);
const html = template(quote);
const template = handlebars.compile(templateHtml, {});
const html = template(quote_dto);
return html;
},
toPDF: async (quote: Quote, context: ISalesContext): Promise<Buffer> => {
const html = ReportQuotePresenter.toHTML(quote, context);
// Generar el PDF con Puppeteer
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html);
const pdfBuffer = await page.pdf({ format: "A4" });
await page.setContent(html, { waitUntil: "networkidle2" });
const pdfBuffer = await page.pdf({
//path: "quote.pdf",
format: "A4",
printBackground: false,
displayHeaderFooter: false,
//headerTemplate: `<div style="font-size:12px; text-align:center; width:100%; margin:0 auto;">Quote #${quote_dto.id}</div>`,
//footerTemplate: `<div style="font-size:12px; text-align:center; width:100%; margin:0 auto;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
margin: {
top: "0mm",
bottom: "0mm",
left: "0mm",
right: "0mm",
},
});
await browser.close();
return pdfBuffer;
@ -26,15 +54,64 @@ export const ReportQuotePresenter: IReportQuoteReporter = {
};
const obtenerPlantillaHTML = (): string => {
// Implementar la lógica para obtener la plantilla HTML
return `
<html>
<head>
<title>Factura</title>
</head>
<body>
<h1>Factura: </h1>
<p>Cliente:</p>
</body>
</html>`;
return readFileSync(path.join(__dirname, "./templates/quote.hbs")).toString();
};
const map = (quote: Quote, context: ISalesContext) => {
const { dealer } = context;
return {
id: quote.id.toString(),
status: quote.status.toString(),
date: quote.date.toISO8601(),
reference: quote.reference.toString(),
customer_information: quote.customer.toString(),
lang_code: quote.language.toString(),
currency_code: quote.currency.toString(),
payment_method: quote.paymentMethod.toString(),
validity: quote.validity.toString(),
notes: quote.notes.toString(),
subtotal_price: quote.subtotalPrice.toFormat(),
discount: quote.discount.toFormat(),
discount_price: quote.discountPrice.toFormat(),
before_tax_price: quote.beforeTaxPrice.toFormat(),
tax: quote.tax.toFormat(),
tax_price: quote.taxPrice.toFormat(),
total_price: quote.totalPrice.toFormat(),
items: quoteItemPresenter(quote.items, context),
dealer: {
id: dealer?.id.toString(),
name: dealer?.name.toString(),
currency_code: dealer?.currency.code,
lang_code: dealer?.language.code,
contact_information: dealer?.additionalInfo.get("contact_information"),
default_payment_method: dealer?.additionalInfo.get("contact_information"),
default_notes: dealer?.additionalInfo.get("contact_information"),
default_legal_terms: dealer?.additionalInfo.get("contact_information"),
default_quote_validity: dealer?.additionalInfo.get("contact_information"),
},
};
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const quoteItemPresenter = (items: ICollection<QuoteItem>, context: ISalesContext): any[] =>
items.totalCount > 0
? items.items.map((item: QuoteItem) => ({
article_id: item.articleId.toString(),
description: item.description.toString(),
quantity: item.quantity.toFormat(),
unit_price: item.unitPrice.toFormat(),
subtotal_price: item.subtotalPrice.toFormat(),
discount: item.discount.toFormat(),
total_price: item.totalPrice.toFormat(),
}))
: [];
2;

View File

@ -0,0 +1,129 @@
<html lang="{{lang_code}}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Presupuesto #{{id}}</title>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
referrerpolicy="no-referrer"
/>
</head>
<body class="px-20 py-12 bg-transparent border">
<div>
<div class="flex items-center justify-between pb-4 mb-4 border-b">
<div class="bg-red-400 border border-green-500">
<h1 class="text-2xl font-bold">DISTRIBUIDOR OFICIAL</h1>
<div class="flex space-x-4 bg-blue-500 border">
<div class="mt-2">
<img src="https://via.placeholder.com/100x50" alt="Logo distribuidor" />
</div>
<div class="mt-2 text-sm">
{{dealer.contact_information}}
</div>
</div>
</div>
<div class="text-right">
<img src="https://via.placeholder.com/150x50" alt="Uecko Logo" />
<p class="text-xs text-gray-500">Essential Furniture</p>
<p class="text-xs text-gray-500">PREMIO AMBARRO DEL AÑO 2021</p>
<p class="text-xs text-gray-500">LUXURY SPAIN</p>
<p class="text-xs text-gray-500">ELLE 2021</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4 pb-4 mb-4 border-b">
<div>
<p class="text-sm"><strong>Presupuesto nº:</strong> {{id}}</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> {{reference}}</p>
</div>
<div>
<p class="text-sm">{{customer_information}}</p>
</div>
</div>
<div class="mb-4">
<h2 class="mb-2 text-xl font-semibold">Cotización</h2>
<table class="min-w-full bg-white">
<thead class="bg-gray-200">
<tr class="text-xs">
<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>
{{#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>
</div>
<div class="flex items-center justify-between pb-4 mb-4 border-b">
<div>
<div class="pt-4 border-t">
<p class="text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
</div>
<div class="pt-4 border-t">
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
</div>
</div>
<div>
<table class="min-w-full bg-transparent">
<tbody>
<tr class="border-b">
<td class="px-4 py-2 border">Importe neto</td>
<td class="px-4 py-2 border"></td>
<td class="px-4 py-2 text-right border">{{subtotal_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2 border">% 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 border">Base imponible</td>
<td class="px-4 py-2 border"></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 border">% 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 border">Importe total</td>
<td class="px-4 py-2 border"></td>
<td class="px-4 py-2 text-right border">{{total_price}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="pt-4 mt-4 border-t">
<p class="text-xs text-gray-500">Información básica sobre protección de datos</p>
<p class="text-xs text-gray-500">{{quote.default_legal_terms}}</p>
</div>
</div>
</body>
</html>

View File

@ -6,6 +6,7 @@ import responseTime from "response-time";
import { configurePassportAuth } from "@/contexts/auth";
import morgan from "morgan";
import passport from "passport";
import path from "path";
import { v1Routes } from "./api/v1";
//const logger = initLogger(rTracer);
@ -56,6 +57,10 @@ configurePassportAuth(passport);
// Express configuration
app.set("port", process.env.PORT ?? 3000);
// Public assets
app.use("/assets", express.static(path.join(__dirname, "/public")));
// API
app.use("/api/v1", v1Routes());
export { app };

View File

@ -383,7 +383,7 @@ export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
}
public toFormat(format?: string, roundingMode?: RoundingMode): string {
return this.props.toFormat(format, roundingMode);
return this._isNull ? "" : this.props.toFormat(format, roundingMode);
}
public toUnit(): number {

View File

@ -245,6 +245,14 @@ export class Percentage extends NullableValueObject<IPercentage> {
return this.isNull() ? 0 : Number(this.toString());
}
public toFormat(): string {
return this._isNull
? ""
: Intl.NumberFormat("es-ES", {
maximumFractionDigits: 2,
}).format(this.toNumber());
}
public toPrimitive(): NullOr<number> {
if (this.scale !== Percentage.DEFAULT_SCALE) {
return this.convertScale(Percentage.DEFAULT_SCALE).toPrimitive();

View File

@ -222,6 +222,14 @@ export class Quantity extends NullableValueObject<IQuantity> {
return this.isNull() ? 0 : Number(this.toString());
}
public toFormat(): string {
return this._isNull
? ""
: Intl.NumberFormat("es-ES", {
maximumFractionDigits: 2,
}).format(this.toNumber());
}
public toPrimitive(): NullOr<number> {
if (this.scale !== Quantity.DEFAULT_SCALE) {
return this.convertScale(Quantity.DEFAULT_SCALE).toPrimitive();