Informes
This commit is contained in:
parent
f8b45618ab
commit
156dc9db0f
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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"],
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./logger";
|
||||
export * from "./sequelize-func";
|
||||
@ -1,5 +1,4 @@
|
||||
export * from "./application";
|
||||
export * from "./domain";
|
||||
export * from "./helpers";
|
||||
export * from "./infrastructure";
|
||||
export * from "./modules";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
1
modules/core/src/api/infrastructure/logger/index.ts
Normal file
1
modules/core/src/api/infrastructure/logger/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./logger";
|
||||
@ -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";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FindOptions } from "sequelize";
|
||||
import type { FindOptions } from "sequelize";
|
||||
|
||||
// orderItem puede ser: ['campo', 'ASC'|'DESC']
|
||||
// o [Sequelize.literal('score'), 'DESC']
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
2
modules/core/src/api/infrastructure/templates/index.ts
Normal file
2
modules/core/src/api/infrastructure/templates/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./handlebars-template-resolver";
|
||||
export * from "./template-resolver";
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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..."
|
||||
}
|
||||
},
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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> - <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> {{series}}{{invoice_number}}</strong></p>
|
||||
<p><span>Fecha:<strong> {{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}} {{recipient.city}} {{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 neto</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{subtotal_amount}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 text-right">Descuento {{discount_percentage}}</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{discount_amount.value}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<!-- dto 0-->
|
||||
{{/if}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Base imponible</td>
|
||||
<td class="w-5"> </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"> </td>
|
||||
<td class="px-4 text-right">{{taxes_amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
<tr class="">
|
||||
<td class="px-4 text-right accent-color">
|
||||
Total factura
|
||||
</td>
|
||||
<td class="w-5"> </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>
|
||||
|
||||
|
||||
@ -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: [],
|
||||
};
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -35,6 +35,7 @@ export const proformasRouter = (params: ModuleParams) => {
|
||||
database: Sequelize;
|
||||
baseRoutePath: string;
|
||||
logger: ILogger;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
const deps = buildProformasDependencies(params);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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]
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
export * from "./proforma-delete-dialog";
|
||||
export * from "./proforma-issue-dialog";
|
||||
export * from "./proforma-layout";
|
||||
export * from "./proforma-tax-summary";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
BIN
modules/customer-invoices/templates/acana/factura_acana.jpg
Normal file
BIN
modules/customer-invoices/templates/acana/factura_acana.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
349
modules/customer-invoices/templates/acana/issued-invoice.hbs
Normal file
349
modules/customer-invoices/templates/acana/issued-invoice.hbs
Normal 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"> </th>
|
||||
<th class="py-2">Imp. total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{#each items}}
|
||||
<tr>
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/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>
|
||||
BIN
modules/customer-invoices/templates/acana/logo_acana.jpg
Normal file
BIN
modules/customer-invoices/templates/acana/logo_acana.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
349
modules/customer-invoices/templates/acana/proforma.hbs
Normal file
349
modules/customer-invoices/templates/acana/proforma.hbs
Normal 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"> </th>
|
||||
<th class="py-2">Imp. total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{#each items}}
|
||||
<tr>
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/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>
|
||||
176011
modules/customer-invoices/templates/acana/tailwind.css
Normal file
176011
modules/customer-invoices/templates/acana/tailwind.css
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -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> - <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> {{series}}{{invoice_number}}</strong></p>
|
||||
<p><span>Fecha:<strong> {{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"> </th>
|
||||
<th class="py-2">Imp. total</th>
|
||||
<th class="py-2">Cantidad</th>
|
||||
<th class="py-2">Precio unidad</th>
|
||||
<th class="py-2">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@ -186,7 +175,6 @@
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/if}}</td>
|
||||
</td>
|
||||
</tr>
|
||||
@ -220,13 +208,11 @@
|
||||
<tbody>
|
||||
{{#if discount_percentage}}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="px-4 text-right">Importe neto</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{subtotal_amount}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="px-4 text-right">Descuento {{discount_percentage}}</td>
|
||||
<td class="w-5"> </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 imponible</td>
|
||||
<td class="w-5"> </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"> </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 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>
|
||||
|
||||
260
modules/customer-invoices/templates/rodax/proforma.hbs
Normal file
260
modules/customer-invoices/templates/rodax/proforma.hbs
Normal 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> {{series}}{{invoice_number}}</strong></p>
|
||||
<p><span>Fecha:<strong> {{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}} {{recipient.city}} {{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 unidad</th>
|
||||
<th class="py-2">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{#each items}}
|
||||
<tr>
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/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 neto</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{subtotal_amount}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 text-right">Descuento {{discount_percentage}}</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{discount_amount.value}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<!-- dto 0-->
|
||||
{{/if}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Base imponible</td>
|
||||
<td class="w-5"> </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"> </td>
|
||||
<td class="px-4 text-right">{{taxes_amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
<tr class="">
|
||||
<td class="px-4 text-right accent-color">
|
||||
Total factura
|
||||
</td>
|
||||
<td class="w-5"> </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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user