This commit is contained in:
David Arranz 2025-11-19 17:05:01 +01:00
parent f8b45618ab
commit 156dc9db0f
60 changed files with 178628 additions and 876 deletions

View File

@ -56,5 +56,11 @@
},
"[sql]": {
"editor.defaultFormatter": "cweijan.vscode-mysql-client2"
} // <- your root font size here
}, // <- your root font size here
"invisibleAiChartDetector.watermark.includeSpaceFamily": true,
"invisibleAiChartDetector.watermark.includeUnicodeCf": true,
"invisibleAiChartDetector.doubleBlankThreshold": 2,
"invisibleAiChartDetector.replace.format": "unicode",
"invisibleAiChartDetector.clean.replaceSpaceLikesToAscii": true
}

View File

@ -1,9 +1,10 @@
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import { Request } from "express";
import type { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import type { Request } from "express";
export type RequestUser = {
userId: UniqueID;
companyId: UniqueID;
companySlug: string;
roles?: string[];
email?: EmailAddress;
};

View File

@ -1,12 +1,14 @@
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import { NextFunction, Response } from "express";
import { RequestWithAuth } from "./auth-types";
import type { NextFunction, Response } from "express";
import type { RequestWithAuth } from "./auth-types";
export function mockUser(req: RequestWithAuth, _res: Response, next: NextFunction) {
req.user = {
userId: UniqueID.create("9e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
email: EmailAddress.create("dev@example.com").data,
companyId: UniqueID.create("5e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
companySlug: "acana",
roles: ["admin"],
};

View File

@ -1,6 +1,7 @@
import { ExpressController, UnauthorizedApiError } from "@erp/core/api";
import { NextFunction, Response } from "express";
import { RequestWithAuth } from "./auth-types";
import type { NextFunction, Response } from "express";
import type { RequestWithAuth } from "./auth-types";
/**
* Middleware que exige presencia de usuario y companyId.
@ -9,7 +10,7 @@ import { RequestWithAuth } from "./auth-types";
export function enforceTenant() {
return (req: RequestWithAuth, res: Response, next: NextFunction) => {
// Validación básica del tenant
if (!req.user || !req.user.companyId) {
if (!req.user?.companyId) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
next();

View File

@ -1,6 +1,7 @@
import { ExpressController, UnauthorizedApiError } from "@erp/core/api";
import { NextFunction, Response } from "express";
import { RequestWithAuth } from "./auth-types";
import type { NextFunction, Response } from "express";
import type { RequestWithAuth } from "./auth-types";
/**
* Middleware que exige presencia de usuario (sin validar companyId).

View File

@ -4,12 +4,10 @@
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {
".": "./src/common/index.ts",
"./common": "./src/common/index.ts",
@ -27,6 +25,7 @@
"@hookform/devtools": "^4.4.0",
"@types/dinero.js": "^1.9.4",
"@types/express": "^4.17.21",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@ -44,9 +43,11 @@
"axios": "^1.9.0",
"dinero.js": "^1.9.1",
"express": "^4.18.2",
"handlebars": "^4.7.8",
"http-status": "^2.1.0",
"i18next": "^25.1.1",
"lucide-react": "^0.503.0",
"mime-types": "^3.0.1",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"react-router-dom": "^6.26.0",

View File

@ -1,4 +1,5 @@
export * from "./presenter";
export * from "./presenter.interface";
export * from "./presenter-registry";
export * from "./presenter-registry.interface";
export * from "./presenter.interface";
export * from "./template-presenter";

View File

@ -4,6 +4,8 @@ import type { IPresenterRegistry } from "./presenter-registry.interface";
export abstract class Presenter<TSource = unknown, TOutput = unknown>
implements IPresenter<TSource, TOutput>
{
constructor(protected presenterRegistry: IPresenterRegistry) {}
constructor(protected presenterRegistry: IPresenterRegistry) {
//
}
abstract toOutput(source: TSource, params?: IPresenterOutputParams): TOutput;
}

View File

@ -0,0 +1,17 @@
import type { HandlebarsTemplateResolver } from "../../infrastructure";
import { Presenter } from "./presenter";
import type { IPresenter } from "./presenter.interface";
import type { IPresenterRegistry } from "./presenter-registry.interface";
export abstract class TemplatePresenter<TSource = unknown, TOutput = unknown>
extends Presenter<TSource, TOutput>
implements IPresenter<TSource, TOutput>
{
constructor(
protected presenterRegistry: IPresenterRegistry,
protected templateResolver: HandlebarsTemplateResolver
) {
super(presenterRegistry);
}
}

View File

@ -1,2 +0,0 @@
export * from "./logger";
export * from "./sequelize-func";

View File

@ -1,5 +1,4 @@
export * from "./application";
export * from "./domain";
export * from "./helpers";
export * from "./infrastructure";
export * from "./modules";

View File

@ -1,6 +1,8 @@
import { Result } from "@repo/rdx-utils";
import { logger } from "../../helpers";
import { ITransactionManager } from "./transaction-manager.interface";
import { logger } from "../logger";
import type { ITransactionManager } from "./transaction-manager.interface";
export abstract class TransactionManager implements ITransactionManager {
protected _transaction: unknown | null = null;

View File

@ -1,6 +1,7 @@
import { NextFunction, Request, Response } from "express";
import { logger } from "../../../helpers";
import { ApiErrorContext, ApiErrorMapper, toProblemJson } from "../api-error-mapper";
import type { NextFunction, Request, Response } from "express";
import { logger } from "../../logger";
import { type ApiErrorContext, ApiErrorMapper, toProblemJson } from "../api-error-mapper";
// ✅ Construye tu mapper una vez (composition root del adaptador HTTP)
export const apiErrorMapper = ApiErrorMapper.default();

View File

@ -1,5 +1,7 @@
export * from "./database";
export * from "./errors";
export * from "./express";
export * from "./logger";
export * from "./mappers";
export * from "./sequelize";
export * from "./templates";

View File

@ -0,0 +1 @@
export * from "./logger";

View File

@ -1,4 +1,5 @@
export * from "./mappers";
export * from "./sequelize-error-translator";
export * from "./sequelize-func";
export * from "./sequelize-repository";
export * from "./sequelize-transaction-manager";

View File

@ -1,4 +1,4 @@
import { FindOptions } from "sequelize";
import type { FindOptions } from "sequelize";
// orderItem puede ser: ['campo', 'ASC'|'DESC']
// o [Sequelize.literal('score'), 'DESC']

View File

@ -1,8 +1,9 @@
import { Result } from "@repo/rdx-utils";
import { Sequelize, Transaction } from "sequelize";
import { logger } from "../../helpers";
import { type Sequelize, Transaction } from "sequelize";
import { TransactionManager } from "../database";
import { InfrastructureError, InfrastructureUnavailableError } from "../errors";
import { logger } from "../logger";
export class SequelizeTransactionManager extends TransactionManager {
protected _database: Sequelize | null = null;

View File

@ -0,0 +1,102 @@
import { existsSync, readFileSync } from "node:fs";
import Handlebars from "handlebars";
import { lookup } from "mime-types";
import { TemplateResolver } from "./template-resolver";
interface AssetHelperOptions {
baseDir: string;
mode: "local_file" | "base64";
}
export class HandlebarsTemplateResolver extends TemplateResolver {
protected readonly hbs = Handlebars.create();
protected registered = false;
protected readonly assetCache = new Map<string, string>();
/**
* Registra el helper "asset".
*
* - Si `mode === "local_file"` devuelve file://...
* - Si `mode === "base64"`:
* - Si el fichero termina en .b64 se asume que el contenido ya es base64
* - Si no se lee binario y se convierte a base64
*/
protected registerAssetHelper(templateDir: string, mode: "local_file" | "base64") {
// Si ya está registrado, no hacer nada
if (this.registered) return;
this.hbs.registerHelper("asset", (resource: string) => {
const assetPath = this.resolveAssetPath(templateDir, resource);
const cacheKey = `${mode}:${assetPath}`;
// 1) Caché en memoria
const cached = this.assetCache.get(cacheKey);
if (cached) {
return cached;
}
if (!existsSync(assetPath)) {
throw new Error(`Asset not found: ${assetPath}`);
}
// 2) Modo "local_file": solo devolver la ruta de fichero
if (mode === "local_file") {
const value = `file://${assetPath.replace(/\\/g, "/")}`;
this.assetCache.set(cacheKey, value);
return value;
}
// 3) Modo "base64"
const isPreencoded = assetPath.endsWith(".b64");
let base64: string;
let mimeType: string;
if (isPreencoded) {
// Fichero ya contiene el base64 en texto plano
base64 = readFileSync(assetPath, "utf8").trim();
// Para el MIME usamos el nombre "original" sin .b64
const mimeLookupPath = assetPath.replace(/\.b64$/, "");
mimeType = (lookup(mimeLookupPath) || "application/octet-stream") as string;
} else {
// Fichero binario normal → convertimos a base64
const buffer = readFileSync(assetPath);
mimeType = (lookup(assetPath) || "application/octet-stream") as string;
base64 = buffer.toString("base64");
}
const value = `data:${mimeType};base64,${base64}`;
this.assetCache.set(cacheKey, value);
return value;
});
this.registered = true;
}
/** Compilación directa desde string (sin resolución de rutas) */
public compile(templateSource: string) {
return this.hbs.compile(templateSource);
}
/** Localiza → lee → registra helpers → compila */
public compileTemplate(
module: string,
companySlug: string,
templateName: string
): Handlebars.TemplateDelegate {
const isDev = process.env.NODE_ENV === "development";
// 1) Directorio de plantillas
const templateDir = this.resolveTemplateDirectory(module, companySlug);
const templatePath = this.resolveTemplatePath(module, companySlug, templateName); // 2) Path completo del template
const source = this.readTemplateFile(templatePath); // Contenido
this.registerAssetHelper(templateDir, isDev ? "local_file" : "base64");
// 5) Compilar
return this.compile(source);
}
}

View File

@ -0,0 +1,2 @@
export * from "./handlebars-template-resolver";
export * from "./template-resolver";

View File

@ -0,0 +1,71 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
export interface ITemplateResolver {
/** Devuelve la ruta absoluta del fichero de plantilla */
resolveTemplatePath(module: string, companySlug: string, templateName: string): string;
/** Compila el contenido de la plantilla (string) */
compile(templateSource: string): unknown;
/** Localiza y compila el template */
compileTemplate(module: string, companySlug: string, templateName: string): unknown;
}
/**
* Resuelve rutas de plantillas para desarrollo y producción.
*/
export abstract class TemplateResolver implements ITemplateResolver {
constructor(protected readonly rootPath: string) {}
/** Une partes de ruta relativas al rootPath */
protected resolveJoin(parts: string[]): string {
return join(this.rootPath, ...parts);
}
/**
* Devuelve el directorio donde residen las plantillas de un módulo/empresa
* según el entorno (dev/prod).
*/
protected resolveTemplateDirectory(module: string, companySlug: string): string {
const isDev = process.env.NODE_ENV === "development";
if (isDev) {
// <root>/<module>/templates/<companySlug>/
return this.resolveJoin([module, "templates", companySlug]);
}
// <root>/templates/<module>/<companySlug>/
return this.resolveJoin(["templates", module, companySlug]);
}
/** Resuelve una ruta de recurso relativa al directorio de plantilla */
protected resolveAssetPath(templateDir: string, relative: string): string {
return join(templateDir, relative);
}
/**
* Devuelve la ruta absoluta del fichero de plantilla.
*/
public resolveTemplatePath(module: string, companySlug: string, templateName: string): string {
const dir = this.resolveTemplateDirectory(module, companySlug);
const filePath = this.resolveAssetPath(dir, templateName);
if (!existsSync(filePath)) {
throw new Error(
`Template not found: module=${module} company=${companySlug} name=${templateName}`
);
}
return filePath;
}
/** Lee el contenido de un fichero plantilla */
protected readTemplateFile(templatePath: string): string {
return readFileSync(templatePath, "utf8");
}
abstract compile(templateSource: string): unknown;
abstract compileTemplate(module: string, companySlug: string, templateName: string): unknown;
}

View File

@ -1,4 +1,4 @@
import { Model, ModelStatic, Sequelize } from "sequelize";
import type { Model, ModelStatic, Sequelize } from "sequelize";
export interface SequelizeModel extends Model {
initialize: (sequelize: Sequelize) => void;

View File

@ -14,7 +14,7 @@
"simple_search_input": {
"search_button": "Buscar",
"loading": "Buscando",
"clear_search": "Limpiar búsquedaClear search",
"clear_search": "Limpiar búsqueda",
"search_placeholder": "Escribe aquí para buscar..."
}
},

View File

@ -1,11 +1,11 @@
import { useDebounce } from "@repo/rdx-ui/components";
import {
Button,
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
Spinner,
} from "@repo/shadcn-ui/components";
import { Spinner } from "@repo/shadcn-ui/components/spinner";
import { SearchIcon, XIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
@ -104,8 +104,8 @@ export const SimpleSearchInput = ({
};
return (
<div className="relative flex-1 max-w-xl">
<InputGroup className="bg-background" data-disabled={loading}>
<div className="relative flex flex-1 items-center gap-4">
<InputGroup className="bg-background " data-disabled={loading}>
<InputGroupInput
autoComplete="off"
disabled={loading}
@ -126,30 +126,24 @@ export const SimpleSearchInput = ({
{loading && (
<Spinner aria-label={t("components.simple_search_input.loading", "Loading")} />
)}
{!(searchValue || loading) && (
<InputGroupButton
className="cursor-pointer"
onClick={() => onSearchChange(searchValue)}
variant="secondary"
>
{t("components.simple_search_input.search_button", "Search")}
</InputGroupButton>
)}
{searchValue && !loading && (
<InputGroupButton
aria-label={t("components.simple_search_input.clear_search", "Clear search")}
className="cursor-pointer"
onClick={handleClear}
variant="secondary"
>
<XIcon aria-hidden className="size-4" />
<span className="sr-only">
{t("components.simple_search_input.clear_search", "Clear")}
</span>
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
{!(searchValue || loading) && (
<Button variant="outline">
{t("components.simple_search_input.search_button", "Search")}
</Button>
)}
{searchValue && !loading && (
<Button
aria-label={t("components.simple_search_input.clear_search", "Clear search")}
className="cursor-pointer"
onClick={handleClear}
variant="outline"
>
<XIcon aria-hidden className="size-4" />
<span>{t("components.simple_search_input.clear_search", "Clear")}</span>
</Button>
)}
</div>
);
};

View File

@ -8,6 +8,7 @@ import type { IssuedInvoiceReportPDFPresenter } from "./reporter/issued-invoice.
type ReportIssuedInvoiceUseCaseInput = {
companyId: UniqueID;
companySlug: string;
invoice_id: string;
};
@ -19,7 +20,7 @@ export class ReportIssuedInvoiceUseCase {
) {}
public async execute(params: ReportIssuedInvoiceUseCaseInput) {
const { invoice_id, companyId } = params;
const { invoice_id, companyId, companySlug } = params;
const idOrError = UniqueID.create(invoice_id);
@ -47,7 +48,7 @@ export class ReportIssuedInvoiceUseCase {
}
const invoice = invoiceOrError.data;
const pdfData = await pdfPresenter.toOutput(invoice);
const pdfData = await pdfPresenter.toOutput(invoice, { companySlug });
return Result.ok({
data: pdfData,
filename: `invoice-${invoice.invoiceNumber}.pdf`,

View File

@ -1,9 +1,4 @@
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Presenter } from "@erp/core/api";
import Handlebars from "handlebars";
import { TemplatePresenter } from "@erp/core/api";
import type { CustomerInvoice } from "../../../../../domain";
import type {
@ -11,20 +6,9 @@ import type {
IssuedInvoiceReportPresenter,
} from "../../../../presenters";
/** Helper para trabajar relativo al fichero actual (ESM) */
export function fromHere(metaUrl: string) {
const file = fileURLToPath(metaUrl);
const dir = dirname(file);
return {
file, // ruta absoluta al fichero actual
dir, // ruta absoluta al directorio actual
resolve: (...parts: string[]) => resolve(dir, ...parts),
join: (...parts: string[]) => join(dir, ...parts),
};
}
export class IssuedInvoiceReportHTMLPresenter extends Presenter {
toOutput(invoice: CustomerInvoice): string {
export class IssuedInvoiceReportHTMLPresenter extends TemplatePresenter {
toOutput(invoice: CustomerInvoice, params: { companySlug: string }): string {
const { companySlug } = params;
const dtoPresenter = this.presenterRegistry.getPresenter({
resource: "issued-invoice",
projection: "FULL",
@ -40,11 +24,12 @@ export class IssuedInvoiceReportHTMLPresenter extends Presenter {
const prettyDTO = prePresenter.toOutput(invoiceDTO);
// Obtener y compilar la plantilla HTML
const here = fromHere(import.meta.url);
const template = this.templateResolver.compileTemplate(
"customer-invoices",
companySlug,
"issued-invoice.hbs"
);
const templatePath = here.resolve("./templates/proforma/template.hbs");
const templateHtml = readFileSync(templatePath).toString();
const template = Handlebars.compile(templateHtml, {});
return template(prettyDTO);
}
}

View File

@ -12,7 +12,10 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
CustomerInvoice,
Promise<Buffer<ArrayBuffer>>
> {
async toOutput(invoice: CustomerInvoice): Promise<Buffer<ArrayBuffer>> {
async toOutput(
invoice: CustomerInvoice,
params: { companySlug: string }
): Promise<Buffer<ArrayBuffer>> {
try {
const htmlPresenter = this.presenterRegistry.getPresenter({
resource: "issued-invoice",
@ -20,26 +23,25 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
format: "HTML",
}) as IssuedInvoiceReportHTMLPresenter;
const htmlData = htmlPresenter.toOutput(invoice);
const htmlData = htmlPresenter.toOutput(invoice, params);
// Generar el PDF con Puppeteer
const browser = await puppeteer.launch({
headless: "new",
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
args: [
"--disable-extensions",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
],
args: ["--font-render-hinting=medium"],
});
const page = await browser.newPage();
const navigationPromise = page.waitForNavigation();
await page.setContent(htmlData, { waitUntil: "networkidle2" });
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(60000);
await navigationPromise;
await page.setContent(htmlData, {
waitUntil: "networkidle0",
});
// Espera extra opcional si hay imágenes base64 muy grandes
await page.waitForNetworkIdle({ idleTime: 200, timeout: 5000 });
const reportPDF = await report.pdfPage(page, {
format: "A4",
@ -56,10 +58,11 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
displayHeaderFooter: false,
headerTemplate: "<div />",
footerTemplate:
'<div style="text-align: center;width: 297mm;font-size: 10px;">Página <span style="margin-right: 1cm"><span class="pageNumber"></span> de <span class="totalPages"></span></span></div>',
'<div style="text-align: center;width: 297mm;font-size: 10px;">Página <span style="margin-right: 1cm"><span class="pageNumber"></span> de <span class="totalPages"></span></span></span></div>',
});
await browser.close();
return Buffer.from(reportPDF);
} catch (err: unknown) {
console.error(err);

View File

@ -8,6 +8,7 @@ import type { ProformaReportPDFPresenter } from "./reporter";
type ReportProformaUseCaseInput = {
companyId: UniqueID;
companySlug: string;
proforma_id: string;
};
@ -19,7 +20,7 @@ export class ReportProformaUseCase {
) {}
public async execute(params: ReportProformaUseCaseInput) {
const { proforma_id, companyId } = params;
const { proforma_id, companySlug, companyId } = params;
const idOrError = UniqueID.create(proforma_id);
@ -46,7 +47,7 @@ export class ReportProformaUseCase {
}
const proforma = proformaOrError.data;
const pdfData = await pdfPresenter.toOutput(proforma);
const pdfData = await pdfPresenter.toOutput(proforma, { companySlug });
return Result.ok({
data: pdfData,
filename: `proforma-${proforma.invoiceNumber}.pdf`,

View File

@ -1,27 +1,11 @@
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Presenter } from "@erp/core/api";
import Handlebars from "handlebars";
import { TemplatePresenter } from "@erp/core/api";
import type { CustomerInvoice } from "../../../../../domain";
import type { ProformaFullPresenter, ProformaReportPresenter } from "../../../../presenters";
/** Helper para trabajar relativo al fichero actual (ESM) */
export function fromHere(metaUrl: string) {
const file = fileURLToPath(metaUrl);
const dir = dirname(file);
return {
file, // ruta absoluta al fichero actual
dir, // ruta absoluta al directorio actual
resolve: (...parts: string[]) => resolve(dir, ...parts),
join: (...parts: string[]) => join(dir, ...parts),
};
}
export class ProformaReportHTMLPresenter extends Presenter {
toOutput(proforma: CustomerInvoice): string {
export class ProformaReportHTMLPresenter extends TemplatePresenter {
toOutput(proforma: CustomerInvoice, params: { companySlug: string }): string {
const { companySlug } = params;
const dtoPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "FULL",
@ -37,11 +21,12 @@ export class ProformaReportHTMLPresenter extends Presenter {
const prettyDTO = prePresenter.toOutput(invoiceDTO);
// Obtener y compilar la plantilla HTML
const here = fromHere(import.meta.url);
const template = this.templateResolver.compileTemplate(
"customer-invoices",
companySlug,
"proforma.hbs"
);
const templatePath = here.resolve("./templates/proforma/template.hbs");
const templateHtml = readFileSync(templatePath).toString();
const template = Handlebars.compile(templateHtml, {});
return template(prettyDTO);
}
}

View File

@ -12,7 +12,10 @@ export class ProformaReportPDFPresenter extends Presenter<
CustomerInvoice,
Promise<Buffer<ArrayBuffer>>
> {
async toOutput(proforma: CustomerInvoice): Promise<Buffer<ArrayBuffer>> {
async toOutput(
proforma: CustomerInvoice,
params: { companySlug: string }
): Promise<Buffer<ArrayBuffer>> {
try {
const htmlPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
@ -20,26 +23,26 @@ export class ProformaReportPDFPresenter extends Presenter<
format: "HTML",
}) as ProformaReportHTMLPresenter;
const htmlData = htmlPresenter.toOutput(proforma);
const htmlData = htmlPresenter.toOutput(proforma, params);
// Generar el PDF con Puppeteer
// Generar el PDF con Puppeteer
const browser = await puppeteer.launch({
headless: "new",
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
args: [
"--disable-extensions",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
],
args: ["--font-render-hinting=medium"],
});
const page = await browser.newPage();
const navigationPromise = page.waitForNavigation();
await page.setContent(htmlData, { waitUntil: "networkidle2" });
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(60000);
await navigationPromise;
await page.setContent(htmlData, {
waitUntil: "networkidle0",
});
// Espera extra opcional si hay imágenes base64 muy grandes
await page.waitForNetworkIdle({ idleTime: 200, timeout: 5000 });
const reportPDF = await report.pdfPage(page, {
format: "A4",
@ -56,10 +59,11 @@ export class ProformaReportPDFPresenter extends Presenter<
displayHeaderFooter: false,
headerTemplate: "<div />",
footerTemplate:
'<div style="text-align: center;width: 297mm;font-size: 10px;">Página <span style="margin-right: 1cm"><span class="pageNumber"></span> de <span class="totalPages"></span></span></div>',
'<div style="text-align: center;width: 297mm;font-size: 10px;">Página <span style="margin-right: 1cm"><span class="pageNumber"></span> de <span class="totalPages"></span></span></span></div>',
});
await browser.close();
return Buffer.from(reportPDF);
} catch (err: unknown) {
console.error(err);

View File

@ -7,51 +7,90 @@
referrerpolicy="no-referrer" />
<title>Factura F26200</title>
<style>
/* ---------------------------- */
/* ESTRUCTURA CABECERA */
/* ---------------------------- */
header {
width: 100%;
margin-bottom: 15px;
}
/* Fila superior */
.top-header {
display: flex;
justify-content: space-between;
width: 100%;
}
/* Bloque izquierdo */
.left-block {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 60%;
}
.logo {
height: 70px;
margin-bottom: 5px;
}
.company-text {
font-size: 7pt;
line-height: 1.2;
padding-left: 10px;
}
/* Bloque derecho */
.right-block {
display: flex;
align-items: flex-start;
justify-content: flex-end;
width: 40%;
}
.factura-img {
height: 45px;
}
/* Fila inferior */
.bottom-header {
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 20px;
}
/* Cuadros */
.info-box {
border: 1px solid black;
border-radius: 12px;
padding: 8px 12px;
width: 45%;
}
.info-dire {
width: 65%;
}
/* ---------------------------- */
/* ESTRUCTURA BODY */
/* ---------------------------- */
body {
font-family: Tahoma, sans-serif;
margin: 40px;
color: #333;
font-size: 11pt;
line-height: 1.6;
}
header {
font-family: Tahoma, sans-serif;
display: flex;
justify-content: space-between;
margin-bottom: 20px;
margin-bottom: 0;
padding-bottom: 0;
}
.accent-color {
background-color: #F08119;
}
.company-info,
.invoice-meta {
width: 48%;
}
.invoice-meta {
text-align: right;
}
h1 {
font-size: 20px;
margin-bottom: 5px;
}
.contact {
font-size: 14px;
margin-top: 10px;
font-size: 9pt;
line-height: 1.5;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 0px;
margin-bottom: 15px;
margin-bottom: 10px;
}
table th,
@ -88,17 +127,58 @@
font-weight: bold;
}
.resume-table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
font-family: Tahoma, sans-serif;
}
/* Columna izquierda (notas / forma de pago) */
.left-col {
width: 70%;
vertical-align: top;
padding: 10px;
border: 1px solid #000;
}
/* Etiquetas */
.resume-table .label {
width: 15%;
padding: 6px 8px;
text-align: right;
border: 1px solid #000;
}
/* Valores numéricos */
.resume-table .value {
width: 15%;
padding: 6px 8px;
text-align: right;
border: 1px solid #000;
}
/* Total factura */
.total-row .label,
.total-row .value {
background-color: #eee;
font-size: 9pt;
border: 1px solid #000;
}
.total {
color: #d10000;
font-weight: bold;
text-align: right;
}
.resume-table .empty {
border: 1px solid transparent;
}
footer {
margin-top: 40px;
font-size: 10px;
}
.highlight {
background-color: #eef;
}
.accent-color {
background-color: #F08119;
font-size: 8px;
}
@media print {
@ -118,47 +198,52 @@
</head>
<body>
<header>
<aside class="flex items-start mb-4 w-full">
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
<div class="w-[70%] flex flex-col items-start text-left">
<img src="https://rodax-software.com/images/logo_acana.jpg" alt="Logo Acana" class="block h-24 w-auto mb-1" />
<div class="p-3 not-italic text-xs leading-tight" style="font-size: 8pt;">
<!-- FILA SUPERIOR: logo + dirección / imagen factura -->
<div class="top-header">
<div class="left-block">
<img src="https://rodax-software.com/images/logo_acana.jpg" alt="Logo Acana" class="logo" />
<div class="company-text">
<p>Aliso Design S.L. B86913910</p>
<p>C/ La Fundición, 27. Pol. Santa Ana</p>
<p>Rivas Vaciamadrid 28522 Madrid</p>
<p>Telf: 91 301 65 57 / 91 301 65 58</p>
<p><a href="mailto:info@acanainteriorismo.com"
class="hover:underline">info@acanainteriorismo.com</a>&nbsp;-&nbsp;<a
href="https://www.acanainteriorismo.com" target="_blank" rel="noopener"
class="hover:underline">www.acanainteriorismo.com</a></p>
</div>
<div class="flex w-full">
<div class="p-3 ">
<p>Factura nº:<strong>&nbsp;{{series}}{{invoice_number}}</strong></p>
<p><span>Fecha:<strong>&nbsp;{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div>
<div class="p-3 ml-9">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
<p>{{recipient.postal_code}}&nbsp;&nbsp;{{recipient.city}}&nbsp;&nbsp;{{recipient.province}}</p>
</div>
<p>
<a href="mailto:info@acanainteriorismo.com">info@acanainteriorismo.com</a> -
<a href="https://www.acanainteriorismo.com" target="_blank">www.acanainteriorismo.com</a>
</p>
</div>
</div>
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
<div class="ml-auto flex flex-col items-end text-right">
<img src="https://rodax-software.com/images/factura_acana.jpg" alt="Factura"
class="block h-14 w-auto md:h-8 mb-1" />
<div class="right-block">
<img src="https://rodax-software.com/images/factura_acana.jpg" alt="Factura" class="factura-img" />
</div>
</aside>
</div>
<!-- FILA INFERIOR: cuadro factura + cuadro cliente -->
<div class="bottom-header">
<div class="info-box">
<p>Factura nº: <strong>{{series}}{{invoice_number}}</strong></p>
<p>Fecha: <strong>{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div>
<div class="info-box info-dire">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
</div>
</div>
</header>
<main id="main">
<section id="details" class="border-b border-black ">
<section id="details">
<!-- Tu tabla -->
@ -184,86 +269,73 @@
</td>
</tr>
{{/each}}
<tr class="resume-table">
<!-- Columna izquierda: notas y forma de pago -->
<td class="left-col" rowspan="10">
{{#if payment_method}}
<p><strong>Forma de pago:</strong> {{payment_method}}</p>
{{/if}}
{{#if notes}}
<p class="mt-2"><strong>Notas:</strong> {{notes}}</p>
{{/if}}
</td>
<!-- Columna derecha: totales -->
{{#if discount_percentage}}
<td colspan="2" class="label">Importe neto</td>
<td colspan="2" class="value">{{subtotal_amount}}</td>
{{else}}
<td colspan="2" class="label">Base imponible</td>
<td colspan="2" class="value">{{taxable_amount}}</td>
{{/if}}
</tr>
{{#if discount_percentage}}
<tr class="resume-table">
<td colspan="2" class="label">Dto {{discount_percentage}}</td>
<td colspan="2" class="value">{{discount_amount.value}}</td>
</tr>
<tr class="resume-table">
<td colspan="2" class="label">Base imponible</td>
<td colspan="2" class="value">{{taxable_amount}}</td>
</tr>
{{/if}}
{{#each taxes}}
<tr class="resume-table">
<td colspan="2" class="label">{{tax_name}}</td>
<td colspan="2" class="value">{{taxes_amount}}</td>
</tr>
{{/each}}
<tr class="total-row">
<td colspan="2" class="label"><strong>Total factura</strong></td>
<td colspan="2" class="value total"><strong>{{total_amount}}</strong></td>
</tr>
</tbody>
</table>
</section>
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
<div class="grow relative pt-10 self-start">
{{#if payment_method}}
<div class="">
<p class=" text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
</div>
{{else}}
<!-- Empty payment method-->
{{/if}}
{{#if notes}}
<div class="pt-4">
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
</div>
{{else}}
<!-- Empty notes-->
{{/if}}
</div>
<div class="relative pt-10 grow">
<table class=" table-header min-w-full bg-transparent">
<tbody>
{{#if discount_percentage}}
<tr>
<td class="px-4 text-right">Importe&nbsp;neto</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{subtotal_amount}}</td>
</tr>
<tr>
<td class="px-4 text-right">Descuento&nbsp;{{discount_percentage}}</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{discount_amount.value}}</td>
</tr>
{{else}}
<!-- dto 0-->
{{/if}}
<tr>
<td class="px-4 text-right">Base&nbsp;imponible</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxable_amount}}</td>
</tr>
{{#each taxes}}
<tr>
<td class="px-4 text-right">{{tax_name}}</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxes_amount}}</td>
</tr>
{{/each}}
<tr class="">
<td class="px-4 text-right accent-color">
Total&nbsp;factura
</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right accent-color">
{{total_amount}}</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<footer id="footer" class="mt-4">
<aside>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja M-572991
CIF: B86913910</p>
<p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más información,
y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la dirección CALLE LA
FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en caso
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
<footer id="footer" class="mt-4 border-t border-black">
<aside class="mt-4">
<tfoot>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja
M-572991
CIF: B86913910</p>
<p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más
información, y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la
dirección CALLE
LA FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en
caso
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
</tfoot>
</aside>
</footer>

View File

@ -19,7 +19,7 @@ export enum INVOICE_STATUS {
const INVOICE_TRANSITIONS: Record<string, string[]> = {
draft: [INVOICE_STATUS.SENT],
sent: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED],
approved: [INVOICE_STATUS.ISSUED],
approved: [INVOICE_STATUS.ISSUED, INVOICE_STATUS.DRAFT],
rejected: [INVOICE_STATUS.DRAFT],
issued: [],
};

View File

@ -17,9 +17,11 @@ export class ReportIssuedInvoiceController extends ExpressController {
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { companySlug } = this.getUser();
const { invoice_id } = this.req.params;
const result = await this.useCase.execute({ invoice_id, companyId });
const result = await this.useCase.execute({ invoice_id, companyId, companySlug });
return result.match(
({ data, filename }) => this.downloadPDF(data, filename),

View File

@ -17,9 +17,11 @@ export class ReportProformaController extends ExpressController {
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { companySlug } = this.getUser();
const { proforma_id } = this.req.params;
const result = await this.useCase.execute({ proforma_id, companyId });
const result = await this.useCase.execute({ proforma_id, companyId, companySlug });
return result.match(
({ data, filename }) => this.downloadPDF(data, filename),

View File

@ -35,6 +35,7 @@ export const proformasRouter = (params: ModuleParams) => {
database: Sequelize;
baseRoutePath: string;
logger: ILogger;
templateRootPath: string;
};
const deps = buildProformasDependencies(params);

View File

@ -1,8 +1,14 @@
// modules/invoice/infrastructure/invoice-dependencies.factory.ts
import { type JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "@erp/core";
import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api";
import type {
IMapperRegistry,
IPresenterRegistry,
ITemplateResolver,
ModuleParams,
} from "@erp/core/api";
import {
HandlebarsTemplateResolver,
InMemoryMapperRegistry,
InMemoryPresenterRegistry,
SequelizeTransactionManager,
@ -37,6 +43,7 @@ export type IssuedInvoicesDeps = {
presenterRegistry: IPresenterRegistry;
repo: CustomerInvoiceRepository;
appService: CustomerInvoiceApplicationService;
templateResolver: ITemplateResolver;
catalogs: {
taxes: JsonTaxCatalogProvider;
};
@ -48,12 +55,13 @@ export type IssuedInvoicesDeps = {
};
export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInvoicesDeps {
const { database } = params;
const { database, templateRootPath } = params;
/** Dominio */
const catalogs = { taxes: SpainTaxCatalogProvider() };
/** Infraestructura */
const templateResolver = new HandlebarsTemplateResolver(templateRootPath);
const transactionManager = new SequelizeTransactionManager(database);
const mapperRegistry = new InMemoryMapperRegistry();
@ -118,7 +126,7 @@ export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInv
},
{
key: { resource: "issued-invoice", projection: "REPORT", format: "HTML" },
presenter: new IssuedInvoiceReportHTMLPresenter(presenterRegistry),
presenter: new IssuedInvoiceReportHTMLPresenter(presenterRegistry, templateResolver),
},
{
key: { resource: "issued-invoice", projection: "REPORT", format: "PDF" },
@ -143,6 +151,7 @@ export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInv
presenterRegistry,
appService,
catalogs,
templateResolver,
useCases,
};
}

View File

@ -1,10 +1,14 @@
// modules/invoice/infrastructure/invoice-dependencies.factory.ts
import { type JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "@erp/core";
import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api";
import {
HandlebarsTemplateResolver,
type IMapperRegistry,
type IPresenterRegistry,
type ITemplateResolver,
InMemoryMapperRegistry,
InMemoryPresenterRegistry,
type ModuleParams,
SequelizeTransactionManager,
} from "@erp/core/api";
@ -44,6 +48,7 @@ export type ProformasDeps = {
catalogs: {
taxes: JsonTaxCatalogProvider;
};
templateResolver: ITemplateResolver;
useCases: {
list_proformas: () => ListProformasUseCase;
get_proforma: () => GetProformaUseCase;
@ -57,12 +62,13 @@ export type ProformasDeps = {
};
export function buildProformasDependencies(params: ModuleParams): ProformasDeps {
const { database } = params;
const { database, templateRootPath } = params;
/** Dominio */
const catalogs = { taxes: SpainTaxCatalogProvider() };
/** Infraestructura */
const templateResolver = new HandlebarsTemplateResolver(templateRootPath);
const transactionManager = new SequelizeTransactionManager(database);
const mapperRegistry = new InMemoryMapperRegistry();
@ -123,7 +129,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasDeps
},
{
key: { resource: "proforma", projection: "REPORT", format: "HTML" },
presenter: new ProformaReportHTMLPresenter(presenterRegistry),
presenter: new ProformaReportHTMLPresenter(presenterRegistry, templateResolver),
},
{
key: { resource: "proforma", projection: "REPORT", format: "PDF" },
@ -154,6 +160,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasDeps
mapperRegistry,
presenterRegistry,
appService,
templateResolver,
catalogs,
useCases,
};

View File

@ -0,0 +1,513 @@
import { formatDate } from "@erp/core/client";
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Button,
ButtonGroup,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import {
ArrowBigRightDashIcon,
CopyIcon,
DownloadIcon,
EditIcon,
ExternalLinkIcon,
MailIcon,
MoreVerticalIcon,
Trash2Icon,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "../../../../i18n";
import type { ProformaSummaryData } from "../../../schema";
import { ProformaStatusBadge } from "../ui";
type GridActionHandlers = {
onEdit?: (proforma: ProformaSummaryData) => void;
onDuplicate?: (proforma: ProformaSummaryData) => void;
onDownloadPdf?: (proforma: ProformaSummaryData) => void;
onSendEmail?: (proforma: ProformaSummaryData) => void;
onDelete?: (proforma: ProformaSummaryData) => void;
};
export function useProformasGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<ProformaSummaryData, unknown>[] {
const { t } = useTranslation();
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
return React.useMemo<ColumnDef<ProformaSummaryData>[]>(
() => [
// Select
{
id: "select",
header: ({ table }) => (
<Checkbox
aria-label="Seleccionar todo"
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Seleccionar fila"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableSorting: false,
enableHiding: false,
},
// Nº
{
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums justify-end"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_number")}
/>
),
cell: ({ row }) => (
<div className="font-semibold tabular-nums">{row.getValue("invoice_number")}</div>
),
enableHiding: false,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_number"),
},
},
// Estado
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.status")}
/>
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<ProformaStatusBadge status={row.original.status} />
{row.original.status === "issued" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button asChild className="size-6" size="icon" variant="ghost">
<a href={`/facturas/${row.original.issued_invoice_id}`}>
<ExternalLinkIcon className="size-4 text-foreground" />
<span className="sr-only">
Ver factura #{row.original.issued_invoice_id}
</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Ver factura #{row.original.issued_invoice_id}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
),
enableSorting: false,
size: 64,
minSize: 64,
meta: {
title: t("pages.proformas.list.grid_columns.status"),
},
},
{
id: "recipient",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.recipient")}
/>
),
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
enableHiding: false,
minSize: 120,
cell: ({ row }) => {
const c = row.original.recipient;
return (
<div className="flex items-start gap-1">
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold truncate text-primary">{c.name}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="text-xs text-muted-foreground truncate">{c.tin}</span>}
</div>
</div>
</div>
);
},
meta: {
title: t("pages.proformas.list.grid_columns.recipient"),
},
},
// Serie
{
accessorKey: "series",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.series")}
/>
),
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
enableSorting: false,
size: 64,
minSize: 64,
meta: {
title: t("pages.proformas.list.grid_columns.series"),
},
},
// Referencia
{
accessorKey: "reference",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.reference")}
/>
),
cell: ({ row }) => <div className="font-medium text-left">{row.original.reference}</div>,
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.reference"),
},
},
// Fecha factura
{
accessorKey: "invoice_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.invoice_date)}
</div>
),
size: 96,
minSize: 96,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_date"),
},
},
// Fecha operación
{
accessorKey: "operation_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.operation_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.operation_date)}
</div>
),
size: 96,
minSize: 96,
meta: {
title: t("pages.proformas.list.grid_columns.operation_date"),
},
},
// Subtotal amount
{
accessorKey: "subtotal_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.subtotal_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.subtotal_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.subtotal_amount"),
},
},
// Discount amount
{
accessorKey: "discount_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.discount_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.discount_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.discount_amount"),
},
},
// Taxes amount
{
accessorKey: "taxes_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.taxes_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">{row.original.taxes_amount_fmt}</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.taxes_amount"),
},
},
// Total amount
{
accessorKey: "total_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.total_amount")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-right tabular-nums">
{row.original.total_amount_fmt}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.proformas.list.grid_columns.total_amount"),
},
},
// ─────────────────────────────
// Acciones
// ─────────────────────────────
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("common.actions")}
/>
),
enableSorting: false,
enableHiding: false,
size: 110,
minSize: 96,
cell: ({ row }) => {
const proforma = row.original;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<ButtonGroup>
{/* Emitir factura: approved -> issued */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.edit_row")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onEdit?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<ArrowBigRightDashIcon aria-hidden="true" className="size-4 " />
<span className="sr-only">Emitir</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit_row")}</TooltipContent>
</Tooltip>
{/* Editar (acción primaria) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.edit_row")}
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
onClick={(e) => {
e.stopPropagation();
onEdit?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<EditIcon aria-hidden="true" className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit_row")}</TooltipContent>
</Tooltip>
{/* Duplicar */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.duplicate_row")}
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
onClick={(e) => {
e.stopPropagation();
onDuplicate?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<CopyIcon aria-hidden="true" className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_row")}</TooltipContent>
</Tooltip>
{/* Descargar en PDF */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.download_pdf")}
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
onClick={(e) => {
e.stopPropagation();
onDownloadPdf?.(proforma);
}}
size="icon-sm"
type="button"
variant="ghost"
>
<DownloadIcon aria-hidden="true" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.delete_row")}
className="cursor-pointer text-destructive hover:bg-destructive/90 hover:text-white"
onClick={() => onDelete?.(proforma)}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2Icon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.delete_row")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.delete_row")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
{false !== false && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.more_actions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDuplicate?.(proforma)}
>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate_row")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDownloadPdf?.(proforma)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onSendEmail?.(proforma)}
>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
onClick={() => onDelete?.(proforma)}
>
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
{t("common.delete_row")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</ButtonGroup>
);
},
meta: {
title: t("common.actions"),
},
},
],
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
);
}

View File

@ -1,25 +1,18 @@
import { formatDate } from "@erp/core/client";
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Button,
ButtonGroup,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Checkbox,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import {
ArrowBigRightDashIcon,
CopyIcon,
DownloadIcon,
EditIcon,
MailIcon,
MoreVerticalIcon,
ArrowUpDownIcon,
ExternalLinkIcon,
FileTextIcon,
PencilIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-react";
import * as React from "react";
@ -44,422 +37,253 @@ export function useProformasGridColumns(
return React.useMemo<ColumnDef<ProformaSummaryData>[]>(
() => [
// Nº
{
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums justify-end"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_number")}
id: "select",
header: ({ table }) => (
<Checkbox
aria-label="Seleccionar todo"
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<div className="text-right tabular-nums">{row.original.invoice_number}</div>
<Checkbox
aria-label="Seleccionar fila"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableSorting: false,
enableHiding: false,
maxSize: 48,
size: 48,
minSize: 48,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_number"),
},
},
// Estado
{
accessorKey: "invoice_number",
header: ({ column }) => {
return (
<Button
className="-ml-4 h-8 font-semibold"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
#
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => <div className="font-medium">{row.getValue("invoice_number")}</div>,
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.status")}
/>
),
cell: ({ row }) => <ProformaStatusBadge className="my-0.5" status={row.original.status} />,
enableSorting: false,
size: 64,
minSize: 64,
meta: {
title: t("pages.proformas.list.grid_columns.status"),
},
},
{
id: "recipient",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.recipient")}
/>
),
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
enableHiding: false,
minSize: 120,
header: "Estado",
cell: ({ row }) => {
const c = row.original.recipient;
const status = String(row.getValue("status"));
const isIssued = status === "issued";
const proforma = row.original;
return (
<div className="flex items-start gap-1">
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold truncate text-primary">{c.name}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
</div>
</div>
<div className="flex items-center gap-2">
<ProformaStatusBadge status={status} />
{isIssued && proforma.id && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button asChild className="size-6" size="icon" variant="ghost">
<a href={`/facturas/${proforma.id}`}>
<ExternalLinkIcon className="size-3 text-muted-foreground" />
<span className="sr-only">Ver factura #{proforma.id}</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Ver factura #{proforma.id}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
meta: {
title: t("pages.proformas.list.grid_columns.recipient"),
},
},
// Serie
{
accessorKey: "series",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.series")}
/>
),
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
enableSorting: false,
size: 64,
minSize: 64,
meta: {
title: t("pages.proformas.list.grid_columns.series"),
},
},
// Referencia
{
accessorKey: "reference",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.reference")}
/>
),
cell: ({ row }) => <div className="font-medium text-left">{row.original.reference}</div>,
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.reference"),
},
},
// Fecha factura
{
accessorKey: "invoice_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.invoice_date)}
</div>
),
size: 96,
minSize: 96,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_date"),
},
},
// Fecha operación
{
accessorKey: "operation_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.operation_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.operation_date)}
</div>
),
size: 96,
minSize: 96,
meta: {
title: t("pages.proformas.list.grid_columns.operation_date"),
},
},
// Subtotal amount
{
accessorKey: "subtotal_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.subtotal_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.subtotal_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.subtotal_amount"),
},
},
// Discount amount
{
accessorKey: "discount_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.discount_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.discount_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.discount_amount"),
},
},
// Taxes amount
{
accessorKey: "taxes_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.taxes_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">{row.original.taxes_amount_fmt}</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.taxes_amount"),
},
},
// Total amount
{
accessorKey: "total_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.total_amount")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-right tabular-nums">
{row.original.total_amount_fmt}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.proformas.list.grid_columns.total_amount"),
},
},
// ─────────────────────────────
// Acciones
// ─────────────────────────────
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("common.actions")}
/>
),
enableSorting: false,
enableHiding: false,
size: 110,
minSize: 96,
cell: ({ row }) => {
const proforma = row.original;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
accessorKey: "client_name",
header: ({ column }) => {
return (
<ButtonGroup>
{/* Emitir factura: approved -> issued */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.edit_row")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onEdit?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<ArrowBigRightDashIcon aria-hidden="true" className="size-4 " />
<span className="sr-only">Emitir</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit_row")}</TooltipContent>
</Tooltip>
{/* Editar (acción primaria) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.edit_row")}
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
onClick={(e) => {
e.stopPropagation();
onEdit?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<EditIcon aria-hidden="true" className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit_row")}</TooltipContent>
</Tooltip>
{/* Duplicar */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.duplicate_row")}
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
onClick={(e) => {
e.stopPropagation();
onDuplicate?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<CopyIcon aria-hidden="true" className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_row")}</TooltipContent>
</Tooltip>
{/* Descargar en PDF */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.download_pdf")}
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
onClick={(e) => {
e.stopPropagation();
onDownloadPdf?.(proforma);
}}
size="icon-sm"
type="button"
variant="ghost"
>
<DownloadIcon aria-hidden="true" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.delete_row")}
className="cursor-pointer text-destructive hover:bg-destructive/90 hover:text-white"
onClick={() => onDelete?.(proforma)}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2Icon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.delete_row")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.delete_row")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
{false !== false && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.more_actions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDuplicate?.(proforma)}
>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate_row")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDownloadPdf?.(proforma)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onSendEmail?.(proforma)}
>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
onClick={() => onDelete?.(proforma)}
>
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
{t("common.delete_row")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</ButtonGroup>
<Button
className="-ml-4 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
Cliente
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
meta: {
title: t("common.actions"),
cell: ({ row }) => {
const proforma = row.original;
return (
<div>
<a
className="text-blue-600 hover:underline"
href={`/customers/${proforma.customer_id}`}
>
{proforma.recipient.name}
</a>
<div className="text-xs text-muted-foreground">{proforma.recipient.tin}</div>
</div>
);
},
},
{
accessorKey: "series",
header: "Serie",
},
{
accessorKey: "reference",
header: "Reference",
},
{
accessorKey: "invoice_date",
header: ({ column }) => {
return (
<Button
className="-ml-4 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
Fecha de proforma
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
},
{
accessorKey: "operation_date",
header: ({ column }) => {
return (
<Button
className="-ml-4 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
Fecha de operación
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
},
{
accessorKey: "subtotal_amount_fmt",
header: () => <div className="text-right">Subtotal</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums">{row.getValue("subtotal_amount_fmt")}</div>
),
},
{
accessorKey: "discount_amount_fmt",
header: () => <div className="text-right">Descuentos</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums">{row.getValue("discount_amount_fmt")}</div>
),
},
{
accessorKey: "taxes_amount_fmt",
header: () => <div className="text-right">Impuestos</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums">{row.getValue("taxes_amount_fmt")}</div>
),
},
{
accessorKey: "total_amount_fmt",
header: () => <div className="text-right">Importe total</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums font-medium">
{row.getValue("total_amount_fmt")}
</div>
),
},
{
id: "actions",
header: "Acciones",
cell: ({ row }) => {
const proforma = row.original;
const isIssued = proforma.status === "issued";
const isApproved = proforma.status === "approved";
return (
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button asChild className="size-8" size="icon" variant="ghost">
<a href={`/proformas/${proforma.id}`}>
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Editar</TooltipContent>
</Tooltip>
</TooltipProvider>
{!isIssued && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-8"
onClick={() => {
row.toggleSelected(true);
//setChangeStatusOpen(true);
}}
size="icon"
variant="ghost"
>
<RefreshCwIcon className="size-4" />
<span className="sr-only">Cambiar estado</span>
</Button>
</TooltipTrigger>
<TooltipContent>Cambiar estado</TooltipContent>
</Tooltip>
{isApproved && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-8"
onClick={() => null /*handleIssueInvoice(proforma)*/}
size="icon"
variant="ghost"
>
<FileTextIcon className="size-4" />
<span className="sr-only">Emitir a factura</span>
</Button>
</TooltipTrigger>
<TooltipContent>Emitir a factura</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-8 text-destructive hover:text-destructive"
onClick={() => {
//setProformaToDelete(proforma.id);
//setDeleteDialogOpen(true);
}}
size="icon"
variant="ghost"
>
<Trash2Icon className="size-4" />
<span className="sr-only">Eliminar</span>
</Button>
</TooltipTrigger>
<TooltipContent>Eliminar</TooltipContent>
</Tooltip>
</>
)}
</div>
);
},
},
],

View File

@ -1,8 +1,15 @@
import { PageHeader } from "@erp/core/components";
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon, PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
@ -45,6 +52,26 @@ export const ProformaListPage = () => {
/>
</AppHeader>
<AppContent>
{/* Search and filters */}
<div className="flex items-center justify-between gap-16">
<SimpleSearchInput loading={list.isLoading} onSearchChange={list.setSearchValue} />
<Select defaultValue="all" onValueChange={list.setStatusFilter}>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.proformas.status.all")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
</SelectContent>
</Select>
</div>
<ProformasGrid
data={list.data}
loading={list.isLoading}

View File

@ -1,6 +1,5 @@
import { Badge } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { forwardRef } from "react";
import { useTranslation } from "../../../../i18n";
@ -8,61 +7,57 @@ export type ProformaStatus = "draft" | "sent" | "approved" | "rejected" | "issue
export type ProformaStatusBadgeProps = {
status: string | ProformaStatus; // permitir cualquier valor
dotVisible?: boolean;
className?: string;
};
const statusColorConfig: Record<ProformaStatus, { badge: string; dot: string }> = {
draft: {
badge:
"bg-gray-500/10 dark:bg-gray-500/20 hover:bg-gray-500/10 text-gray-600 border-gray-400/60",
dot: "bg-gray-500",
},
sent: {
badge:
"bg-amber-500/10 dark:bg-amber-500/20 hover:bg-amber-500/10 text-amber-500 border-amber-600/60",
dot: "bg-amber-500",
},
approved: {
badge:
"bg-emerald-500/10 dark:bg-emerald-500/20 hover:bg-emerald-500/10 text-emerald-500 border-emerald-600/60",
dot: "bg-emerald-500",
},
rejected: {
badge: "bg-red-500/10 dark:bg-red-500/20 hover:bg-red-500/10 text-red-500 border-red-600/60",
dot: "bg-red-500",
},
issued: {
badge:
"bg-blue-600/10 dark:bg-blue-600/20 hover:bg-blue-600/10 text-blue-500 border-blue-600/60",
dot: "bg-blue-500",
},
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as ProformaStatus;
const getVariant = (
status: ProformaStatus
): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case "draft":
return "outline";
case "sent":
return "secondary";
case "approved":
return "default";
case "rejected":
return "destructive";
case "issued":
return "default";
default:
return "outline";
}
};
const getColor = (status: ProformaStatus): string => {
switch (status) {
case "draft":
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
case "sent":
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
case "approved":
return "bg-green-100 text-green-700 hover:bg-green-100";
case "rejected":
return "bg-red-100 text-red-700 hover:bg-red-100";
case "issued":
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
default:
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
}
};
return (
<Badge
className={cn(getColor(normalizedStatus), "font-semibold", className)}
variant={getVariant(normalizedStatus)}
>
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
</Badge>
);
};
export const ProformaStatusBadge = forwardRef<HTMLDivElement, ProformaStatusBadgeProps>(
({ status, dotVisible, className, ...props }, ref) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as ProformaStatus;
const config = statusColorConfig[normalizedStatus];
const commonClassName =
"transition-colors duration-200 cursor-pointer shadow-none rounded-full";
if (!config) {
return (
<Badge className={cn(commonClassName, className)} ref={ref} {...props}>
{status}
</Badge>
);
}
return (
<Badge className={cn(commonClassName, config.badge, className)} {...props}>
{dotVisible && <div className={cn("h-1.5 w-1.5 rounded-full mr-2", config.dot)} />}
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
</Badge>
);
}
);
ProformaStatusBadge.displayName = "ProformaStatusBadge";

View File

@ -1,13 +1,4 @@
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
@ -33,13 +24,9 @@ export const ProformasGrid = ({
loading,
pageIndex,
pageSize,
searchValue,
onSearchChange,
onPageChange,
onPageSizeChange,
onRowClick,
onExportClick,
onStatusFilterChange,
}: ProformasGridProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
@ -64,37 +51,17 @@ export const ProformasGrid = ({
);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4">
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
<Select defaultValue="all" onValueChange={onStatusFilterChange}>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.proformas.status.all")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={items}
enablePagination
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={(row, _index) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
/>
</div>
<DataTable
columns={columns}
data={items}
enablePagination
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={(row, _index) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
/>
);
};

View File

@ -1,2 +1,4 @@
export * from "./proforma-delete-dialog";
export * from "./proforma-issue-dialog";
export * from "./proforma-layout";
export * from "./proforma-tax-summary";

View File

@ -0,0 +1,85 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Spinner,
} from "@repo/shadcn-ui/components";
import { useState } from "react";
interface IssueInvoiceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
proformaId: number;
proformaReference: string;
}
export function ProformaDeleteDialog({
open,
onOpenChange,
proformaId,
proformaReference,
}: IssueInvoiceDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleDelete = async () => {
setIsSubmitting(true);
/*try {
const result = await issueInvoiceFromProforma(proformaId);
if (result.success) {
toast({
title: "Factura emitida",
description: `Se ha emitido la factura #${result.invoiceId} desde la proforma.`,
});
onOpenChange(false);
} else {
throw new Error(result.error);
}
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al emitir la factura",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}*/
};
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar proforma?</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. La proforma será eliminada permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>Cancelar</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isSubmitting}
onClick={() => proformaId && handleDelete()}
>
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
Eliminando...
</>
) : (
"Eliminar proforma"
)}
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,87 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Spinner,
} from "@repo/shadcn-ui/components";
import { useState } from "react";
interface IssueInvoiceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
proformaId: number;
proformaReference: string;
}
export function ProformaIssueDialog({
open,
onOpenChange,
proformaId,
proformaReference,
}: IssueInvoiceDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
//const { toast } = useToast();
const handleIssue = async () => {
setIsSubmitting(true);
/*try {
const result = await issueInvoiceFromProforma(proformaId);
if (result.success) {
toast({
title: "Factura emitida",
description: `Se ha emitido la factura #${result.invoiceId} desde la proforma.`,
});
onOpenChange(false);
} else {
throw new Error(result.error);
}
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al emitir la factura",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}*/
};
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Emitir factura</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas emitir una factura de cliente desde la proforma{" "}
<strong>{proformaReference}</strong>?
<br />
<br />
Esta acción creará una nueva factura definitiva y la proforma pasará al estado
"Emitida", no pudiendo modificarse ni eliminarse posteriormente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
Cancelar
</Button>
<Button disabled={isSubmitting} onClick={handleIssue}>
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
Emitiendo...
</>
) : (
"Emitir factura"
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -0,0 +1,349 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<style type="text/css">
{
{
asset 'tailwind.css.b64'
}
}
</style>
<title>Factura</title>
<style>
/* ---------------------------- */
/* ESTRUCTURA CABECERA */
/* ---------------------------- */
header {
width: 100%;
margin-bottom: 15px;
}
/* Fila superior */
.top-header {
display: flex;
justify-content: space-between;
width: 100%;
}
/* Bloque izquierdo */
.left-block {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 60%;
}
.logo {
height: 70px;
margin-bottom: 5px;
}
.company-text {
font-size: 7pt;
line-height: 1.2;
padding-left: 10px;
}
/* Bloque derecho */
.right-block {
display: flex;
align-items: flex-start;
justify-content: flex-end;
width: 40%;
}
.factura-img {
height: 45px;
}
/* Fila inferior */
.bottom-header {
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 20px;
}
/* Cuadros */
.info-box {
border: 1px solid black;
border-radius: 12px;
padding: 8px 12px;
width: 45%;
}
.info-dire {
width: 65%;
}
/* ---------------------------- */
/* ESTRUCTURA BODY */
/* ---------------------------- */
body {
font-family: Tahoma, sans-serif;
margin: 40px;
color: #333;
font-size: 9pt;
line-height: 1.5;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 0px;
margin-bottom: 10px;
}
table th,
table td {
border-top: 0px solid;
border-left: 1px solid #000;
border-right: 1px solid #000;
border-bottom: 0px solid;
padding: 3px 10px;
text-align: left;
vertical-align: top;
}
table th {
margin-bottom: 10px;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
text-align: center;
background-color: #e7e0df;
color: #ff0014;
}
.totals {
margin-top: 20px;
width: 100%;
}
.totals td {
padding: 5px 10px;
}
.totals td.label {
text-align: right;
font-weight: bold;
}
.resume-table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
font-family: Tahoma, sans-serif;
}
/* Columna izquierda (notas / forma de pago) */
.left-col {
width: 70%;
vertical-align: top;
padding: 10px;
border: 1px solid #000;
}
/* Etiquetas */
.resume-table .label {
width: 15%;
padding: 6px 8px;
text-align: right;
border: 1px solid #000;
}
/* Valores numéricos */
.resume-table .value {
width: 15%;
padding: 6px 8px;
text-align: right;
border: 1px solid #000;
}
/* Total factura */
.total-row .label,
.total-row .value {
background-color: #eee;
font-size: 9pt;
border: 1px solid #000;
}
.total {
color: #d10000;
font-weight: bold;
text-align: right;
}
.resume-table .empty {
border: 1px solid transparent;
}
footer {
margin-top: 40px;
font-size: 8px;
}
@media print {
* {
-webkit-print-color-adjust: exact;
}
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
}
</style>
</head>
<body>
<header>
<!-- FILA SUPERIOR: logo + dirección / imagen factura -->
<div class="top-header">
<div class="left-block">
<img src="{{asset 'logo_acana.jpg'}}" alt="Logo Acana" class="logo" />
<div class="company-text">
<p>Aliso Design S.L. B86913910</p>
<p>C/ La Fundición, 27. Pol. Santa Ana</p>
<p>Rivas Vaciamadrid 28522 Madrid</p>
<p>Telf: 91 301 65 57 / 91 301 65 58</p>
<p>
<a href="mailto:info@acanainteriorismo.com">info@acanainteriorismo.com</a> -
<a href="https://www.acanainteriorismo.com" target="_blank">www.acanainteriorismo.com</a>
</p>
</div>
</div>
<div class="right-block">
<img src="{{asset 'factura_acana.jpg'}}" alt="Factura" class="factura-img" />
</div>
</div>
<!-- FILA INFERIOR: cuadro factura + cuadro cliente -->
<div class="bottom-header">
<div class="info-box">
<p>Factura nº: <strong>{{series}}{{invoice_number}}</strong></p>
<p>Fecha: <strong>{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div>
<div class="info-box info-dire">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
</div>
</div>
</header>
<main id="main">
<section id="details">
<!-- Tu tabla -->
<table class="table-header">
<thead>
<tr>
<th class="py-2">Concepto</th>
<th class="py-2">Ud.</th>
<th class="py-2">Imp.</th>
<th class="py-2">&nbsp;</th>
<th class="py-2">Imp.&nbsp;total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{description}}</td>
<td class="text-right">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}}&nbsp;{{/if}}</td>
</td>
</tr>
{{/each}}
<tr class="resume-table">
<!-- Columna izquierda: notas y forma de pago -->
<td class="left-col" rowspan="10">
{{#if payment_method}}
<p><strong>Forma de pago:</strong> {{payment_method}}</p>
{{/if}}
{{#if notes}}
<p class="mt-2"><strong>Notas:</strong> {{notes}}</p>
{{/if}}
</td>
<!-- Columna derecha: totales -->
{{#if discount_percentage}}
<td colspan="2" class="label">Importe neto</td>
<td colspan="2" class="value">{{subtotal_amount}}</td>
{{else}}
<td colspan="2" class="label">Base imponible</td>
<td colspan="2" class="value">{{taxable_amount}}</td>
{{/if}}
</tr>
{{#if discount_percentage}}
<tr class="resume-table">
<td colspan="2" class="label">Dto {{discount_percentage}}</td>
<td colspan="2" class="value">{{discount_amount.value}}</td>
</tr>
<tr class="resume-table">
<td colspan="2" class="label">Base imponible</td>
<td colspan="2" class="value">{{taxable_amount}}</td>
</tr>
{{/if}}
{{#each taxes}}
<tr class="resume-table">
<td colspan="2" class="label">{{tax_name}}</td>
<td colspan="2" class="value">{{taxes_amount}}</td>
</tr>
{{/each}}
<tr class="total-row">
<td colspan="2" class="label"><strong>Total factura</strong></td>
<td colspan="2" class="value total"><strong>{{total_amount}}</strong></td>
</tr>
</tbody>
</table>
</section>
</main>
<footer id="footer" class="mt-4 border-t border-black">
<aside class="mt-4">
<tfoot>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja
M-572991
CIF: B86913910</p>
<p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más
información, y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la
dirección CALLE
LA FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en
caso
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
</tfoot>
</aside>
</footer>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,349 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<style type="text/css">
{
{
asset 'tailwind.css.b64'
}
}
</style>
<title>Factura proforma</title>
<style>
/* ---------------------------- */
/* ESTRUCTURA CABECERA */
/* ---------------------------- */
header {
width: 100%;
margin-bottom: 15px;
}
/* Fila superior */
.top-header {
display: flex;
justify-content: space-between;
width: 100%;
}
/* Bloque izquierdo */
.left-block {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 60%;
}
.logo {
height: 70px;
margin-bottom: 5px;
}
.company-text {
font-size: 7pt;
line-height: 1.2;
padding-left: 10px;
}
/* Bloque derecho */
.right-block {
display: flex;
align-items: flex-start;
justify-content: flex-end;
width: 40%;
}
.factura-img {
height: 45px;
}
/* Fila inferior */
.bottom-header {
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 20px;
}
/* Cuadros */
.info-box {
border: 1px solid black;
border-radius: 12px;
padding: 8px 12px;
width: 45%;
}
.info-dire {
width: 65%;
}
/* ---------------------------- */
/* ESTRUCTURA BODY */
/* ---------------------------- */
body {
font-family: Tahoma, sans-serif;
margin: 40px;
color: #333;
font-size: 9pt;
line-height: 1.5;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 0px;
margin-bottom: 10px;
}
table th,
table td {
border-top: 0px solid;
border-left: 1px solid #000;
border-right: 1px solid #000;
border-bottom: 0px solid;
padding: 3px 10px;
text-align: left;
vertical-align: top;
}
table th {
margin-bottom: 10px;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
text-align: center;
background-color: #e7e0df;
color: #ff0014;
}
.totals {
margin-top: 20px;
width: 100%;
}
.totals td {
padding: 5px 10px;
}
.totals td.label {
text-align: right;
font-weight: bold;
}
.resume-table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
font-family: Tahoma, sans-serif;
}
/* Columna izquierda (notas / forma de pago) */
.left-col {
width: 70%;
vertical-align: top;
padding: 10px;
border: 1px solid #000;
}
/* Etiquetas */
.resume-table .label {
width: 15%;
padding: 6px 8px;
text-align: right;
border: 1px solid #000;
}
/* Valores numéricos */
.resume-table .value {
width: 15%;
padding: 6px 8px;
text-align: right;
border: 1px solid #000;
}
/* Total factura */
.total-row .label,
.total-row .value {
background-color: #eee;
font-size: 9pt;
border: 1px solid #000;
}
.total {
color: #d10000;
font-weight: bold;
text-align: right;
}
.resume-table .empty {
border: 1px solid transparent;
}
footer {
margin-top: 40px;
font-size: 8px;
}
@media print {
* {
-webkit-print-color-adjust: exact;
}
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
}
</style>
</head>
<body>
<header>
<!-- FILA SUPERIOR: logo + dirección / imagen factura -->
<div class="top-header">
<div class="left-block">
<img src="{{asset 'logo_acana.jpg'}}" alt="Logo Acana" class="logo" />
<div class="company-text">
<p>Aliso Design S.L. B86913910</p>
<p>C/ La Fundición, 27. Pol. Santa Ana</p>
<p>Rivas Vaciamadrid 28522 Madrid</p>
<p>Telf: 91 301 65 57 / 91 301 65 58</p>
<p>
<a href="mailto:info@acanainteriorismo.com">info@acanainteriorismo.com</a> -
<a href="https://www.acanainteriorismo.com" target="_blank">www.acanainteriorismo.com</a>
</p>
</div>
</div>
<div class="right-block">
<img src="{{asset 'factura_acana.jpg' }}" alt="Factura" class="factura-img" />
</div>
</div>
<!-- FILA INFERIOR: cuadro factura + cuadro cliente -->
<div class="bottom-header">
<div class="info-box">
<p>Factura nº: <strong>{{series}}{{invoice_number}}</strong></p>
<p>Fecha: <strong>{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div>
<div class="info-box info-dire">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
</div>
</div>
</header>
<main id="main">
<section id="details">
<!-- Tu tabla -->
<table class="table-header">
<thead>
<tr>
<th class="py-2">Concepto</th>
<th class="py-2">Ud.</th>
<th class="py-2">Imp.</th>
<th class="py-2">&nbsp;</th>
<th class="py-2">Imp.&nbsp;total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{description}}</td>
<td class="text-right">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}}&nbsp;{{/if}}</td>
</td>
</tr>
{{/each}}
<tr class="resume-table">
<!-- Columna izquierda: notas y forma de pago -->
<td class="left-col" rowspan="10">
{{#if payment_method}}
<p><strong>Forma de pago:</strong> {{payment_method}}</p>
{{/if}}
{{#if notes}}
<p class="mt-2"><strong>Notas:</strong> {{notes}}</p>
{{/if}}
</td>
<!-- Columna derecha: totales -->
{{#if discount_percentage}}
<td colspan="2" class="label">Importe neto</td>
<td colspan="2" class="value">{{subtotal_amount}}</td>
{{else}}
<td colspan="2" class="label">Base imponible</td>
<td colspan="2" class="value">{{taxable_amount}}</td>
{{/if}}
</tr>
{{#if discount_percentage}}
<tr class="resume-table">
<td colspan="2" class="label">Dto {{discount_percentage}}</td>
<td colspan="2" class="value">{{discount_amount.value}}</td>
</tr>
<tr class="resume-table">
<td colspan="2" class="label">Base imponible</td>
<td colspan="2" class="value">{{taxable_amount}}</td>
</tr>
{{/if}}
{{#each taxes}}
<tr class="resume-table">
<td colspan="2" class="label">{{tax_name}}</td>
<td colspan="2" class="value">{{taxes_amount}}</td>
</tr>
{{/each}}
<tr class="total-row">
<td colspan="2" class="label"><strong>Total factura</strong></td>
<td colspan="2" class="value total"><strong>{{total_amount}}</strong></td>
</tr>
</tbody>
</table>
</section>
</main>
<footer id="footer" class="mt-4 border-t border-black">
<aside class="mt-4">
<tfoot>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja
M-572991
CIF: B86913910</p>
<p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más
información, y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la
dirección CALLE
LA FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en
caso
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
</tfoot>
</aside>
</footer>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@
<title>Factura F26200</title>
<style>
body {
font-family: Tahoma, sans-serif;
font-family: Arial, sans-serif;
margin: 40px;
color: #333;
font-size: 11pt;
@ -16,7 +16,6 @@
}
header {
font-family: Tahoma, sans-serif;
display: flex;
justify-content: space-between;
margin-bottom: 20px;
@ -56,10 +55,7 @@
table th,
table td {
border-top: 0px solid;
border-left: 1px solid #000;
border-right: 1px solid #000;
border-bottom: 0px solid;
border: 0px solid;
padding: 3px 10px;
text-align: left;
vertical-align: top;
@ -67,11 +63,6 @@
table th {
margin-bottom: 10px;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
text-align: center;
background-color: #e7e0df;
color: #ff0014;
}
.totals {
@ -101,13 +92,6 @@
background-color: #F08119;
}
.info-box {
border: 2px solid black;
border-radius: 12px;
padding: 3px 3px;
display: inline-block;
}
@media print {
* {
-webkit-print-color-adjust: exact;
@ -130,24 +114,13 @@
<aside class="flex items-start mb-4 w-full">
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
<div class="w-[70%] flex flex-col items-start text-left">
<img src="https://rodax-software.com/images/logo_acana.jpg" alt="Logo Acana" class="block h-24 w-auto mb-1" />
<div class="p-3 not-italic text-xs leading-tight" style="font-size: 8pt;">
<p>Aliso Design S.L. B86913910</p>
<p>C/ La Fundición, 27. Pol. Santa Ana</p>
<p>Rivas Vaciamadrid 28522 Madrid</p>
<p>Telf: 91 301 65 57 / 91 301 65 58</p>
<p><a href="mailto:info@acanainteriorismo.com"
class="hover:underline">info@acanainteriorismo.com</a>&nbsp;-&nbsp;<a
href="https://www.acanainteriorismo.com" target="_blank" rel="noopener"
class="hover:underline">www.acanainteriorismo.com</a></p>
</div>
<img src="https://rodax-software.com/images/logo_rodax.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
<div class="flex w-full">
<div class="info-box" style="border: 2px solid black; border-radius: 12px; padding: 10px 20px;">
<div class="p-1 ">
<p>Factura nº:<strong>&nbsp;{{series}}{{invoice_number}}</strong></p>
<p><span>Fecha:<strong>&nbsp;{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div>
<div class="p-3 ml-9">
<div class="p-1 ml-9">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
@ -158,8 +131,14 @@
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
<div class="ml-auto flex flex-col items-end text-right">
<img src="https://rodax-software.com/images/factura_acana.jpg" alt="Factura"
class="block h-14 w-auto md:h-8 mb-1" />
<img src="https://rodax-software.com/images/logo2.jpg" alt="Logo secundario"
class="block h-5 w-auto md:h-8 mb-1" />
<div class="not-italic text-xs leading-tight">
<p>Telf: 91 785 02 47 / 686 62 10 59</p>
<p><a href="mailto:info@rodax-software.com" class="hover:underline">info@rodax-software.com</a></p>
<p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
class="hover:underline">www.rodax-software.com</a></p>
</div>
</div>
</aside>
</header>
@ -167,16 +146,26 @@
<main id="main">
<section id="details" class="border-b border-black ">
<div class="relative pt-0 border-b border-black">
<!-- Badge TOTAL decorado con imagen -->
<div class="absolute -top-9 right-0">
<div class="relative text-sm font-semibold text-black pr-2 pl-10 py-2 justify-center bg-red-900"
style="background-image: url('https://rodax-software.com/images/img-total2.jpg'); background-size: cover; background-position: left;">
<!-- Texto del total -->
<span>TOTAL: {{total_amount}}</span>
</div>
</div>
</div>
<!-- Tu tabla -->
<table class="table-header">
<thead>
<tr>
<tr class="text-left">
<th class="py-2">Concepto</th>
<th class="py-2">Ud.</th>
<th class="py-2">Imp.</th>
<th class="py-2">&nbsp;</th>
<th class="py-2">Imp.&nbsp;total</th>
<th class="py-2">Cantidad</th>
<th class="py-2">Precio&nbsp;unidad</th>
<th class="py-2">Importe&nbsp;total</th>
</tr>
</thead>
@ -186,7 +175,6 @@
<td>{{description}}</td>
<td class="text-right">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}}&nbsp;{{/if}}</td>
</td>
</tr>
@ -220,13 +208,11 @@
<tbody>
{{#if discount_percentage}}
<tr>
<td></td>
<td class="px-4 text-right">Importe&nbsp;neto</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{subtotal_amount}}</td>
</tr>
<tr>
<td></td>
<td class="px-4 text-right">Descuento&nbsp;{{discount_percentage}}</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{discount_amount.value}}</td>
@ -235,21 +221,18 @@
<!-- dto 0-->
{{/if}}
<tr>
<td></td>
<td class="px-4 text-right">Base&nbsp;imponible</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxable_amount}}</td>
</tr>
{{#each taxes}}
<tr>
<td></td>
<td class="px-4 text-right">{{tax_name}}</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxes_amount}}</td>
</tr>
{{/each}}
<tr class="">
<td></td>
<td class="px-4 text-right accent-color">
Total&nbsp;factura
</td>
@ -266,16 +249,9 @@
<footer id="footer" class="mt-4">
<aside>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja M-572991
CIF: B86913910</p>
<p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más información,
y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la dirección CALLE LA
FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en caso
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212
| CIF: B83999441 -
Rodax Software S.L.</p>
</aside>
</footer>

View File

@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.css"
referrerpolicy="no-referrer" />
<title>Factura F26200</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 40px;
color: #333;
font-size: 11pt;
line-height: 1.6;
}
header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
margin-bottom: 0;
padding-bottom: 0;
}
.accent-color {
background-color: #F08119;
}
.company-info,
.invoice-meta {
width: 48%;
}
.invoice-meta {
text-align: right;
}
h1 {
font-size: 20px;
margin-bottom: 5px;
}
.contact {
font-size: 14px;
margin-top: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 0px;
margin-bottom: 15px;
}
table th,
table td {
border: 0px solid;
padding: 3px 10px;
text-align: left;
vertical-align: top;
}
table th {
margin-bottom: 10px;
}
.totals {
margin-top: 20px;
width: 100%;
}
.totals td {
padding: 5px 10px;
}
.totals td.label {
text-align: right;
font-weight: bold;
}
footer {
margin-top: 40px;
font-size: 10px;
}
.highlight {
background-color: #eef;
}
.accent-color {
background-color: #F08119;
}
@media print {
* {
-webkit-print-color-adjust: exact;
}
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
}
</style>
</head>
<body>
<header>
<aside class="flex items-start mb-4 w-full">
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
<div class="w-[70%] flex flex-col items-start text-left">
<img src="https://rodax-software.com/images/logo_rodax.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
<div class="flex w-full">
<div class="p-1 ">
<p>Factura nº:<strong>&nbsp;{{series}}{{invoice_number}}</strong></p>
<p><span>Fecha:<strong>&nbsp;{{invoice_date}}</strong></p>
</div>
<div class="p-1 ml-9">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
<p>{{recipient.postal_code}}&nbsp;&nbsp;{{recipient.city}}&nbsp;&nbsp;{{recipient.province}}</p>
</div>
</div>
</div>
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
<div class="ml-auto flex flex-col items-end text-right">
<img src="https://rodax-software.com/images/logo2.jpg" alt="Logo secundario"
class="block h-5 w-auto md:h-8 mb-1" />
<div class="not-italic text-xs leading-tight">
<p>Telf: 91 785 02 47 / 686 62 10 59</p>
<p><a href="mailto:info@rodax-software.com" class="hover:underline">info@rodax-software.com</a></p>
<p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
class="hover:underline">www.rodax-software.com</a></p>
</div>
</div>
</aside>
</header>
<main id="main">
<section id="details" class="border-b border-black ">
<div class="relative pt-0 border-b border-black">
<!-- Badge TOTAL decorado con imagen -->
<div class="absolute -top-9 right-0">
<div class="relative text-sm font-semibold text-black pr-2 pl-10 py-2 justify-center bg-red-900"
style="background-image: url('https://rodax-software.com/images/img-total2.jpg'); background-size: cover; background-position: left;">
<!-- Texto del total -->
<span>TOTAL: {{total_amount}}</span>
</div>
</div>
</div>
<!-- Tu tabla -->
<table class="table-header">
<thead>
<tr class="text-left">
<th class="py-2">Concepto</th>
<th class="py-2">Cantidad</th>
<th class="py-2">Precio&nbsp;unidad</th>
<th class="py-2">Importe&nbsp;total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{description}}</td>
<td class="text-right">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}}&nbsp;{{/if}}</td>
</td>
</tr>
{{/each}}
</tbody>
</table>
</section>
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
<div class="grow relative pt-10 self-start">
{{#if payment_method}}
<div class="">
<p class=" text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
</div>
{{else}}
<!-- Empty payment method-->
{{/if}}
{{#if notes}}
<div class="pt-4">
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
</div>
{{else}}
<!-- Empty notes-->
{{/if}}
</div>
<div class="relative pt-10 grow">
<table class=" table-header min-w-full bg-transparent">
<tbody>
{{#if discount_percentage}}
<tr>
<td class="px-4 text-right">Importe&nbsp;neto</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{subtotal_amount}}</td>
</tr>
<tr>
<td class="px-4 text-right">Descuento&nbsp;{{discount_percentage}}</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{discount_amount.value}}</td>
</tr>
{{else}}
<!-- dto 0-->
{{/if}}
<tr>
<td class="px-4 text-right">Base&nbsp;imponible</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxable_amount}}</td>
</tr>
{{#each taxes}}
<tr>
<td class="px-4 text-right">{{tax_name}}</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxes_amount}}</td>
</tr>
{{/each}}
<tr class="">
<td class="px-4 text-right accent-color">
Total&nbsp;factura
</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right accent-color">
{{total_amount}}</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<footer id="footer" class="mt-4">
<aside>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212
| CIF: B83999441 -
Rodax Software S.L.</p>
</aside>
</footer>
</body>
</html>

View File

@ -8,7 +8,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@repo/shadcn-ui/components";
import { useState } from 'react';
import { useState } from "react";
interface CustomDialogProps {
open: boolean;
@ -36,21 +36,23 @@ export const CustomDialog = ({
return (
<AlertDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !closedByAction) onConfirm(false);
if (!(nextOpen || closedByAction)) onConfirm(false);
if (nextOpen) setClosedByAction(false);
}}
open={open}
>
<AlertDialogContent className="max-w-md rounded-2xl border border-border shadow-lg">
<AlertDialogHeader className="space-y-2">
<AlertDialogTitle className="text-lg font-semibold">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-sm text-muted-foreground" aria-live="assertive">
<AlertDialogDescription aria-live="assertive" className="text-sm text-muted-foreground">
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-12">
<AlertDialogCancel autoFocus className="min-w-[120px]">{cancelLabel}</AlertDialogCancel>
<AlertDialogCancel autoFocus className="min-w-[120px]">
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction className="min-w-[120px] bg-destructive text-white hover:bg-destructive/90">
{confirmLabel}
</AlertDialogAction>

View File

@ -7,7 +7,7 @@ import {
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { Column } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
import { ArrowDown, ArrowDownIcon, ArrowUp, ArrowUpIcon, ChevronsUpDownIcon } from "lucide-react";
import { useTranslation } from "../../locales/i18n.ts";
@ -41,18 +41,17 @@ export function DataTableColumnHeader<TData, TValue>({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="data-[state=open]:bg-accent -ml-3 h-8 text-xs text-muted-foreground font-semibold text-nowrap cursor-pointer"
size="sm"
className="data-[state=open]:bg-accent -ml-4 h-8 text-xs text-muted-foreground font-semibold text-nowrap cursor-pointer"
type="button"
variant="ghost"
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDown />
<ArrowDownIcon className="ml-2 size-4" />
) : column.getIsSorted() === "asc" ? (
<ArrowUp />
<ArrowUpIcon className="ml-2 size-4" />
) : (
<ChevronsUpDown />
<ChevronsUpDownIcon className="ml-2 size-4" />
)}
</Button>
</DropdownMenuTrigger>

View File

@ -83,7 +83,10 @@ export function DataTablePagination<TData>({
})}
</span>
)}
</div>
{/* Controles derecha */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<span>{t("components.datatable.pagination.rows_per_page")}</span>
<Select onValueChange={handlePageSizeChange} value={String(pageSize)}>
@ -99,10 +102,6 @@ export function DataTablePagination<TData>({
</SelectContent>
</Select>
</div>
</div>
{/* Controles derecha */}
<div className="flex items-center gap-2">
<Pagination>
<PaginationContent>
<PaginationItem>

View File

@ -74,7 +74,7 @@ export function DataTableToolbar<TData>({
// Render principal
return (
<div className={cn("flex items-center justify-between gap-2 py-2 bg-transparent", className)}>
<div className={cn("flex items-center justify-between gap-2 py-4 bg-transparent", className)}>
{/* IZQUIERDA: acciones + contador */}
<div className="flex flex-1 items-center gap-3 flex-wrap">
{/* Botón añadir */}

View File

@ -10,7 +10,7 @@ import {
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import type { Column, Table } from "@tanstack/react-table";
import { Settings2 } from "lucide-react";
import { SettingsIcon } from "lucide-react";
import { useTranslation } from "../../locales/i18n.ts";
@ -19,8 +19,8 @@ export function DataTableViewOptions<TData>({ table }: { table: Table<TData> })
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="ml-auto hidden h-8 lg:flex" size="sm" type="button" variant="outline">
<Settings2 />
<Button className="ml-auto hidden h-8 lg:flex" size={"sm"} type="button" variant="ghost">
<SettingsIcon />
{t("components.datatable_view_options.columns_button")}
</Button>
</DropdownMenuTrigger>

View File

@ -380,6 +380,9 @@ importers:
express:
specifier: ^4.18.2
version: 4.21.2
handlebars:
specifier: ^4.7.8
version: 4.7.8
http-status:
specifier: ^2.1.0
version: 2.1.0
@ -389,6 +392,9 @@ importers:
lucide-react:
specifier: ^0.503.0
version: 0.503.0(react@19.2.0)
mime-types:
specifier: ^3.0.1
version: 3.0.1
react-hook-form:
specifier: ^7.58.1
version: 7.66.0(react@19.2.0)
@ -414,6 +420,9 @@ importers:
'@types/express':
specifier: ^4.17.21
version: 4.17.25
'@types/mime-types':
specifier: ^3.0.1
version: 3.0.1
'@types/react':
specifier: ^19.1.2
version: 19.2.2
@ -2769,6 +2778,9 @@ packages:
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/mime-types@3.0.1':
resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==}
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
@ -4528,10 +4540,18 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime-types@3.0.1:
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
engines: {node: '>= 0.6'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
@ -7843,6 +7863,8 @@ snapshots:
'@types/luxon@3.7.1': {}
'@types/mime-types@3.0.1': {}
'@types/mime@1.3.5': {}
'@types/minimatch@5.1.2': {}
@ -9650,10 +9672,16 @@ snapshots:
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.1:
dependencies:
mime-db: 1.54.0
mime@1.6.0: {}
mimic-fn@2.1.0: {}