Subida a producción como Acana

This commit is contained in:
David Arranz 2025-11-28 16:00:18 +01:00
parent 1783f630cf
commit e8d8403ae1
44 changed files with 177077 additions and 440 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/factuges-server", "name": "@erp/factuges-server",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsup src/index.ts --config tsup.config.ts", "build": "tsup src/index.ts --config tsup.config.ts",

1
apps/web/.env.production Normal file
View File

@ -0,0 +1 @@
VITE_API_SERVER_URL=http://192.168.0.104:3002/api/v1

View File

@ -1,14 +1,14 @@
{ {
"name": "@erp/factuges-web", "name": "@erp/factuges-web",
"private": true, "private": true,
"version": "0.0.14", "version": "0.0.15",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host --clearScreen false", "dev": "vite --host --clearScreen false",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:rodax": "tsc && vite build --mode rodax", "build:rodax": "tsc && vite build --mode rodax",
"build:acana": "tsc && vite build --mode acana", "build:acana": "tsc && vite build --mode acana",
"preview": "vite preview", "preview": "vite preview --host",
"clean": "rm -rf dist && rm -rf node_modules && rm -rf .turbo", "clean": "rm -rf dist && rm -rf node_modules && rm -rf .turbo",
"check:deps": "pnpm exec depcheck", "check:deps": "pnpm exec depcheck",
"lint": "biome lint --fix", "lint": "biome lint --fix",
@ -32,14 +32,13 @@
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@erp/customer-invoices": "workspace:*", "@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*", "@erp/customers": "workspace:*",
"@repo/i18next": "workspace:*",
"@repo/rdx-ui": "workspace:*", "@repo/rdx-ui": "workspace:*",
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.90.6", "@tanstack/react-query": "^5.90.6",
"axios": "^1.9.0", "axios": "^1.9.0",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"i18next": "^25.0.2",
"i18next-browser-languagedetector": "^8.1.0",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.4", "react-hook-form": "^7.56.4",
"react-i18next": "^15.0.1", "react-i18next": "^15.0.1",

View File

@ -10,16 +10,18 @@ import { Suspense } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { i18n } from "@/locales";
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib"; import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
import { getAppRouter } from "./routes"; import { getAppRouter } from "./routes";
import "./app.css"; import "./app.css";
import { initI18Next } from "@repo/i18next";
export const App = () => { export const App = () => {
DineroFactory.globalLocale = "es-ES"; DineroFactory.globalLocale = "es-ES";
const i18n = initI18Next();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@ -29,6 +31,8 @@ export const App = () => {
}, },
}); });
console.log("import.meta.env.VITE_API_SERVER_URL => ", import.meta.env.VITE_API_SERVER_URL);
const axiosInstance = createAxiosInstance({ const axiosInstance = createAxiosInstance({
baseURL: import.meta.env.VITE_API_SERVER_URL, baseURL: import.meta.env.VITE_API_SERVER_URL,
getAccessToken: () => null, //getAccessToken, getAccessToken: () => null, //getAccessToken,

View File

@ -1,34 +1,34 @@
import i18n from "i18next"; import { initI18Next, registerTranslations } from "@repo/i18next";
import LanguageDetector from "i18next-browser-languagedetector"; import { useTranslation as useI18NextTranslation } from "react-i18next";
import { initReactI18next } from "react-i18next";
export const i18n = await initI18Next();
import { useEffect } from "react";
import enResources from "./en.json"; import enResources from "./en.json";
import esResources from "./es.json"; import esResources from "./es.json";
import enUIResources from "@repo/rdx-ui/locales/en.json"; const APP_MODULE_NAME = "FACTUGES_WEB";
import esUIResources from "@repo/rdx-ui/locales/es.json";
i18n const ensureAppTranslations = () => {
// detect user language registerTranslations(APP_MODULE_NAME, "es", esResources);
// learn more: https://github.com/i18next/i18next-browser-languageDetector registerTranslations(APP_MODULE_NAME, "en", enResources);
.use(LanguageDetector) };
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
detection: {
order: ["navigator"],
},
debug: false, //import.meta.env.DEV,
fallbackLng: "es",
interpolation: {
escapeValue: false,
},
resources: {
en: { ...enResources, ...enUIResources },
es: { ...esResources, ...esUIResources },
},
});
export { i18n }; /**
* Hook de traducción específico de la app principal.
*/
export const useAppTranslation = () => {
// Hook base, sin namespace
const base = useI18NextTranslation();
const { i18n } = base;
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
// idempotente: solo añade si faltan bundles
ensureAppTranslations();
}, [i18n]);
// Hook con namespace de la app
return useI18NextTranslation(APP_MODULE_NAME);
};

View File

@ -2,6 +2,8 @@
"files": [], "files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": { "compilerOptions": {
"resolveJsonModule": true,
"esModuleInterop": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/auth", "name": "@erp/auth",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@ -32,7 +32,6 @@
"@repo/rdx-ui": "workspace:*", "@repo/rdx-ui": "workspace:*",
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.90.6", "@tanstack/react-query": "^5.90.6",
"i18next": "^25.1.1",
"react-hook-form": "^7.56.2", "react-hook-form": "^7.56.2",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"react-secure-storage": "^1.3.2" "react-secure-storage": "^1.3.2"

View File

@ -8,7 +8,7 @@ export function mockUser(req: RequestWithAuth, _res: Response, next: NextFunctio
userId: UniqueID.create("9e4dc5b3-96b9-4968-9490-14bd032fec5f").data, userId: UniqueID.create("9e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
email: EmailAddress.create("dev@example.com").data, email: EmailAddress.create("dev@example.com").data,
companyId: UniqueID.create("019a9667-6a65-767a-a737-48234ee50a3a").data, companyId: UniqueID.create("019a9667-6a65-767a-a737-48234ee50a3a").data,
companySlug: "acana", companySlug: "alonsoysal",
roles: ["admin"], roles: ["admin"],
}; };

View File

@ -1,9 +1,10 @@
import { IModuleClient, ModuleClientParams } from "@erp/core/client"; import type { IModuleClient, ModuleClientParams } from "@erp/core/client";
import i18next from "i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { AuthRoutes } from "./auth-routes"; import { AuthRoutes } from "./auth-routes";
//import enResources from "../common/locales/en.json";
//import esResources from "../common/locales/es.json";
const MODULE_NAME = "auth"; const MODULE_NAME = "auth";
const MODULE_VERSION = "1.0.0"; const MODULE_VERSION = "1.0.0";
@ -15,8 +16,8 @@ export const AuthModuleManifiest: IModuleClient = {
layout: "auth", layout: "auth",
routes: (params: ModuleClientParams) => { routes: (params: ModuleClientParams) => {
i18next.addResourceBundle("en", MODULE_NAME, enResources, true, true); //i18next.addResourceBundle("en", MODULE_NAME, enResources, true, true);
i18next.addResourceBundle("es", MODULE_NAME, esResources, true, true); //i18next.addResourceBundle("es", MODULE_NAME, esResources, true, true);
return AuthRoutes(params); return AuthRoutes(params);
}, },
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/core", "name": "@erp/core",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@ -33,6 +33,7 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@repo/i18next": "workspace:*",
"@repo/rdx-criteria": "workspace:*", "@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-logger": "workspace:*", "@repo/rdx-logger": "workspace:*",
@ -45,7 +46,6 @@
"express": "^4.18.2", "express": "^4.18.2",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"http-status": "^2.1.0", "http-status": "^2.1.0",
"i18next": "^25.1.1",
"lucide-react": "^0.503.0", "lucide-react": "^0.503.0",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",

View File

@ -1,25 +1,37 @@
import { i18n } from "i18next"; import { registerTranslations } from "@repo/i18next";
import { useEffect } from "react";
import { useTranslation as useI18NextTranslation } from "react-i18next"; import { useTranslation as useI18NextTranslation } from "react-i18next";
import enResources from "../common/locales/en.json"; import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json"; import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest"; import { MODULE_NAME } from "./manifest";
const addMissingBundles = (i18n: i18n) => { /**
const needsEn = !i18n.hasResourceBundle("en", MODULE_NAME); * Registra dinámicamente las traducciones del módulo.
const needsEs = !i18n.hasResourceBundle("es", MODULE_NAME); */
const ensureModuleTranslations = () => {
if (needsEn) { registerTranslations(MODULE_NAME, "en", enResources);
i18n.addResourceBundle("en", MODULE_NAME, enResources, true, true); registerTranslations(MODULE_NAME, "es", esResources);
}
if (needsEs) {
i18n.addResourceBundle("es", MODULE_NAME, esResources, true, true);
}
}; };
/**
* Hook de traducción del módulo, version adaptada.
*
* - Asegura los bundles del módulo.
* - Devuelve el hook react-i18next con namespace.
*/
export const useTranslation = () => { export const useTranslation = () => {
const { i18n } = useI18NextTranslation(); // Hook base, sin namespace
addMissingBundles(i18n); const base = useI18NextTranslation();
const { i18n } = base;
// Asegura los bundles del módulo al montar (idempotente)
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
ensureModuleTranslations();
}, [i18n]);
// Hook con namespace
return useI18NextTranslation(MODULE_NAME); return useI18NextTranslation(MODULE_NAME);
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/customer-invoices", "name": "@erp/customer-invoices",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@ -38,6 +38,7 @@
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@erp/customers": "workspace:*", "@erp/customers": "workspace:*",
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@repo/i18next": "workspace:*",
"@repo/rdx-criteria": "workspace:*", "@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-logger": "workspace:*", "@repo/rdx-logger": "workspace:*",
@ -51,7 +52,6 @@
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"express": "^4.18.2", "express": "^4.18.2",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"i18next": "^25.1.1",
"libphonenumber-js": "^1.12.7", "libphonenumber-js": "^1.12.7",
"lucide-react": "^0.503.0", "lucide-react": "^0.503.0",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",

View File

@ -1,34 +1,37 @@
import type { KeyPrefix, Namespace, i18n } from "i18next"; import { registerTranslations } from "@repo/i18next";
import { import { useEffect } from "react";
type UseTranslationResponse, import { useTranslation as useI18NextTranslation } from "react-i18next";
useTranslation as useI18NextTranslation,
} from "react-i18next";
import enResources from "../common/locales/en.json"; import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json"; import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest"; import { MODULE_NAME } from "./manifest";
const addMissingBundles = (i18n: i18n) => { /**
const needsEn = !i18n.hasResourceBundle("en", MODULE_NAME); * Registra dinámicamente las traducciones del módulo.
const needsEs = !i18n.hasResourceBundle("es", MODULE_NAME); */
const ensureModuleTranslations = () => {
if (needsEn) { registerTranslations(MODULE_NAME, "en", enResources);
i18n.addResourceBundle("en", MODULE_NAME, enResources, true, true); registerTranslations(MODULE_NAME, "es", esResources);
}
if (needsEs) {
i18n.addResourceBundle("es", MODULE_NAME, esResources, true, true);
}
}; };
export const useTranslation = < /**
Ns extends Namespace = typeof MODULE_NAME, * Hook de traducción del módulo, version adaptada.
K extends KeyPrefix<Ns> = undefined, *
>( * - Asegura los bundles del módulo.
keyPrefix?: K * - Devuelve el hook react-i18next con namespace.
): UseTranslationResponse<Ns, K> => { */
const { i18n } = useI18NextTranslation(); export const useTranslation = () => {
addMissingBundles(i18n); // Hook base, sin namespace
return useI18NextTranslation(MODULE_NAME, { keyPrefix }); const base = useI18NextTranslation();
const { i18n } = base;
// Asegura los bundles del módulo al montar (idempotente)
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
ensureModuleTranslations();
}, [i18n]);
// Hook con namespace
return useI18NextTranslation(MODULE_NAME);
}; };

View File

@ -1,5 +1,5 @@
import type { TaxCatalogProvider } from "@erp/core"; import type { TaxCatalogProvider } from "@erp/core";
import type { TFunction } from "i18next"; import type { ModuleTFunction } from "@repo/rdx-ui/locales/i18n.js";
import { import {
type PropsWithChildren, type PropsWithChildren,
createContext, createContext,
@ -10,7 +10,6 @@ import {
} from "react"; } from "react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import type { MODULE_NAME } from "../../../../manifest";
export type ProformaContextValue = { export type ProformaContextValue = {
company_id: string; company_id: string;
@ -23,7 +22,7 @@ export type ProformaContextValue = {
readOnly: boolean; readOnly: boolean;
taxCatalog: TaxCatalogProvider; taxCatalog: TaxCatalogProvider;
t: TFunction<typeof MODULE_NAME>; t: ModuleTFunction;
changeLanguage: (lang: string) => void; changeLanguage: (lang: string) => void;
changeCurrency: (currency: string) => void; changeCurrency: (currency: string) => void;

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Factura</title>
<style type="text/css">{{ asset 'tailwind.min.css' }}</style>
<style type="text/css">
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: 75%;
}
.logo {
height: 80px;
margin-bottom: 5px;
}
.company-text {
font-size: 7pt;
line-height: 1.2;
padding-left: 10px;
text-align: right;
}
/* Bloque derecho */
.right-block {
display: flex;
flex-direction: column;
/* uno encima de otro */
align-items: flex-end;
/* o flex-start / center según quieras */
justify-content: flex-start;
width: 25%;
padding: 4px;
}
.factura-img {
height: 45px;
}
.factura-text {
font-size: 26px;
font-weight: bolder;
}
/* Fila inferior */
.bottom-header {
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 20px;
}
/* Cuadros */
.info-box {
width: 40%;
}
.info-dire {
width: 70%;
}
/* ---------------------------- */
/* ESTRUCTURA BODY */
/* ---------------------------- */
body {
font-family: Arial, 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;
}
/* Anchos por columna */
.col-concepto { width: 75%; text-align: left; }
.col-cantidad { width: 5%; text-align: center;}
.col-precio { width: 10%; text-align: right;}
.col-total { width: 10%; text-align: right;}
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 {
border-bottom: 1px solid #000;
}
.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">
<div style="display: flex; align-items: center; gap: 4px; align-content: stretch">
<img src="{{asset 'logo_alonsoysal.jpg'}}" alt="Logo Alonso y Sal" class="logo" />
{{#if verifactu.qr_code}}
<div style="flex-grow: 0; flex-shrink: 0; flex-basis:90px;">
<img src="{{verifactu.qr_code}}" alt="QR factura" style="width: 90px; height: 90px;" />
</div>
<div style="text-align: left; flex-grow: 1; flex-shrink: 1; flex-basis: auto;">
QR tributario factura verificable en sede electronica de AEAT VERI*FACTU
</div>
{{/if}}
</div>
</div>
<div class="right-block">
<div>
<div class="company-text">
<p><strong>Cocinas y Baños</strong><br/>Calle Vía Carpetana 340<br/>28047 Madrid</p>
<p>Teléfono: 914 652 842<br/>WhatsApp: 607 528 495<br/>Email: <a href="mailto:info@alonsoysal.com">info@alonsoysal.com</a><br/>Web: <a href="https://www.acanainteriorismo.com" target="_blank">www.alonsoysal.com</a></p>
</div>
</div>
</div>
</div>
<!-- FILA INFERIOR: cuadro factura + cuadro cliente -->
<div class="bottom-header">
<div class="info-box">
<p class="factura-text">FACTURA</p>
<p>Factura nº: {{series}}{{invoice_number}}</p>
<p>Fecha: {{invoice_date}}</p>
</div>
<div class="info-box info-dire">
<h2 style="font-weight:600; text-transform:uppercase; margin-bottom:0.25rem;">{{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="col-concepto">Concepto</th>
<th class="col-cantidad">Cantidad</th>
<th class="col-precio">Precio unidad</th>
<th class="col-total">Importe total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{description}}</td>
<td style="width: 10px !important; text-align:right;">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td style="text-align:right;">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td style="text-align:right;">{{#if taxable_amount}}{{taxable_amount}}{{else}}&nbsp;{{/if}}</td>
</td>
</tr>
{{/each}}
<tr class="resume-table">
<!-- Columna izquierda: notas y forma de pago -->
<td class="left-col" rowspan="10">
{{#if payment_method}}
<p><strong>Forma de pago:</strong> {{payment_method}}</p>
{{/if}}
{{#if notes}}
<p style="margin-top:0.5rem;"><strong>Notas:</strong> {{notes}}</p>
{{/if}}
</td>
<!-- Columna derecha: totales -->
{{#if discount_percentage}}
<td class="label">Importe neto</td>
<td 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 class="label">Dto {{discount_percentage}}</td>
<td 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 class="label">{{tax_name}}</td>
<td class="value">{{taxes_amount}}</td>
</tr>
{{/each}}
<tr class="total-row">
<td 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" style="margin-top:1rem; border-top:1px solid #000000;">
<aside style="margin-top: 1rem;">
<tfoot>
<p style="text-align: 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 style="text-align: left; font-size: 6pt;">Información en protección de datos:<br />De conformidad con lo
dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más
información, y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la
dirección CALLE
LA FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en
caso
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
</tfoot>
</aside>
</footer>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/customers", "name": "@erp/customers",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@ -32,6 +32,7 @@
"@erp/auth": "workspace:*", "@erp/auth": "workspace:*",
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@repo/i18next": "workspace:*",
"@repo/rdx-criteria": "workspace:*", "@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-logger": "workspace:*", "@repo/rdx-logger": "workspace:*",
@ -41,7 +42,6 @@
"@tanstack/react-query": "^5.90.6", "@tanstack/react-query": "^5.90.6",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"express": "^4.18.2", "express": "^4.18.2",
"i18next": "^25.6.0",
"lucide-react": "^0.503.0", "lucide-react": "^0.503.0",
"react-data-table-component": "^7.7.0", "react-data-table-component": "^7.7.0",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",

View File

@ -1,25 +1,37 @@
import { i18n } from "i18next"; import { registerTranslations } from "@repo/i18next";
import { useEffect } from "react";
import { useTranslation as useI18NextTranslation } from "react-i18next"; import { useTranslation as useI18NextTranslation } from "react-i18next";
import enResources from "../common/locales/en.json"; import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json"; import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest"; import { MODULE_NAME } from "./manifest";
const addMissingBundles = (i18n: i18n) => { /**
const needsEn = !i18n.hasResourceBundle("en", MODULE_NAME); * Registra dinámicamente las traducciones del módulo.
const needsEs = !i18n.hasResourceBundle("es", MODULE_NAME); */
const ensureModuleTranslations = () => {
if (needsEn) { registerTranslations(MODULE_NAME, "en", enResources);
i18n.addResourceBundle("en", MODULE_NAME, enResources, true, true); registerTranslations(MODULE_NAME, "es", esResources);
}
if (needsEs) {
i18n.addResourceBundle("es", MODULE_NAME, esResources, true, true);
}
}; };
/**
* Hook de traducción del módulo, version adaptada.
*
* - Asegura los bundles del módulo.
* - Devuelve el hook react-i18next con namespace.
*/
export const useTranslation = () => { export const useTranslation = () => {
const { i18n } = useI18NextTranslation(); // Hook base, sin namespace
addMissingBundles(i18n); const base = useI18NextTranslation();
const { i18n } = base;
// Asegura los bundles del módulo al montar (idempotente)
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
ensureModuleTranslations();
}, [i18n]);
// Hook con namespace
return useI18NextTranslation(MODULE_NAME); return useI18NextTranslation(MODULE_NAME);
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/doc-numbering", "name": "@erp/doc-numbering",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -18,7 +18,7 @@
"ui:add": "pnpm --filter @repo/shadcn-ui ui:add", "ui:add": "pnpm --filter @repo/shadcn-ui ui:add",
"create:package": "ts-node scripts/create-package.ts", "create:package": "ts-node scripts/create-package.ts",
"volta:install": "curl https://get.volta.sh | bash", "volta:install": "curl https://get.volta.sh | bash",
"clean": "turbo run clean && rimraf ./node_modules && rimraf ./package-lock.json" "clean": "turbo run clean && rimraf ./node_modules && rimraf ./package-lock.json && rimraf ./out"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.1", "@biomejs/biome": "2.3.1",

View File

@ -0,0 +1,25 @@
{
"name": "@repo/i18next",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"i18next": "25.6.0",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"react-i18next": "^12.3.1"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.15.12",
"typescript": "^5.9.3"
}
}

57
packages/i18n/src/i18n.ts Normal file
View File

@ -0,0 +1,57 @@
import i18next, { type i18n } from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
let hasInit = false;
export const initI18Next = () => {
if (hasInit) return i18next;
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
detection: { order: ["navigator"] },
debug: false,
fallbackLng: "es",
interpolation: { escapeValue: false },
});
hasInit = true;
return i18next;
};
/**
* Registra dinámicamente traducciones de un módulo.
*
* Cada módulo tendrá su propio namespace.
*
* idempotente: si el bundle ya existe, no se vuelve a añadir.
*/
export const registerTranslations = (
moduleName: string,
locale: string,
resources: Record<string, unknown>
): void => {
if (!hasInit) {
throw new Error("i18n not initialized. Call initI18Next() first.");
}
const ns = moduleName;
const alreadyExists = i18next.hasResourceBundle(locale, ns);
if (!alreadyExists) {
i18next.addResourceBundle(locale, ns, resources, true, true);
}
};
/**
* Acceso al `t()` global por si se necesita en librerías de backend.
*/
export const t = (...args: Parameters<i18n["t"]>) => {
if (!hasInit) {
throw new Error("i18n not initialized. Call initI18Next() first.");
}
return i18next.t(...args);
};

View File

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

View File

@ -0,0 +1,8 @@
{
"extends": "@repo/typescript-config/buildless.json",
"compilerOptions": {
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/rdx-criteria", "name": "@repo/rdx-criteria",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/rdx-ddd", "name": "@repo/rdx-ddd",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/rdx-logger", "name": "@repo/rdx-logger",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -47,6 +47,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@repo/i18next": "workspace:*",
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -1,15 +1,17 @@
import { Button } from "@repo/shadcn-ui/components"; import { Button } from "@repo/shadcn-ui/components";
import { t } from "i18next";
import { ChevronLeftIcon } from "lucide-react"; import { ChevronLeftIcon } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../locales/i18n.ts";
export const BackHistoryButton = () => { export const BackHistoryButton = () => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Button variant='outline' size='icon' className='h-7 w-7' onClick={() => navigate(-1)}> <Button className="h-7 w-7" onClick={() => navigate(-1)} size="icon" variant="outline">
<ChevronLeftIcon className='w-4 h-4' /> <ChevronLeftIcon className="w-4 h-4" />
<span className='sr-only'>{t("common.back")}</span> <span className="sr-only">{t("common.back")}</span>
</Button> </Button>
); );
}; };

View File

@ -9,10 +9,10 @@ import {
DialogTrigger, DialogTrigger,
ScrollArea, ScrollArea,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { t } from "i18next";
import { HelpCircleIcon } from "lucide-react"; import { HelpCircleIcon } from "lucide-react";
import React from "react"; import type React from "react";
import { useTranslation } from "../../locales/i18n.ts";
interface HelpButtonProps { interface HelpButtonProps {
buttonText: string; buttonText: string;
@ -27,24 +27,25 @@ export const HelpButton = ({
content, content,
className = "", className = "",
}: HelpButtonProps) => { }: HelpButtonProps) => {
const { t } = useTranslation();
return ( return (
<div className={`flex items-baseline justify-center mr-4 font-medium ${className}`}> <div className={`flex items-baseline justify-center mr-4 font-medium ${className}`}>
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant='link' className='inline-flex items-center font-medium group'> <Button className="inline-flex items-center font-medium group" variant="link">
<span className='underline-offset-4 group-hover:underline'>{buttonText}</span> <span className="underline-offset-4 group-hover:underline">{buttonText}</span>
<HelpCircleIcon className='w-4 h-4 ml-1 text-muted-foreground' /> <HelpCircleIcon className="w-4 h-4 ml-1 text-muted-foreground" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className='sm:max-w-[425px]'> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
<ScrollArea className='grid gap-4 py-2'> <ScrollArea className="grid gap-4 py-2">
{content} {content}
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button type='button'>{t("common.close")}</Button> <Button type="button">{t("common.close")}</Button>
</DialogClose> </DialogClose>
</DialogFooter> </DialogFooter>
</ScrollArea> </ScrollArea>

View File

@ -1,137 +0,0 @@
import { Header, Table, flexRender } from "@tanstack/react-table";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Separator,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { t } from "i18next";
import { ArrowDownIcon, ArrowDownUpIcon, ArrowUpIcon, EyeOffIcon } from "lucide-react";
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
table: Table<TData>;
header: Header<TData, TValue>;
}
export function DataTableColumnHeader<TData, TValue>({
table,
header,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!header.column.getCanSort()) {
return (
<>
<div className={cn("data-[state=open]:bg-accent tracking-wide text-ellipsis", className)}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{false && header.column.getCanResize() && (
<Separator
orientation='vertical'
className={cn(
"absolute top-0 h-full w-[5px] bg-black/10 cursor-col-resize",
table.options.columnResizeDirection,
header.column.getIsResizing() ? "bg-primary opacity-100" : ""
)}
{...{
onDoubleClick: () => header.column.resetSize(),
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
style: {
transform:
table.options.columnResizeMode === "onEnd" && header.column.getIsResizing()
? `translateX(${
(table.options.columnResizeDirection === "rtl" ? -1 : 1) *
(table.getState().columnSizingInfo.deltaOffset ?? 0)
}px)`
: "",
},
}}
/>
)}
</>
);
}
return (
<div className={cn("flex items-center space-x-2", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={
header.column.getIsSorted() === "desc"
? t("common.sort_desc_description")
: header.column.getIsSorted() === "asc"
? t("common.sort_asc_description")
: t("sort_none_description")
}
size='sm'
variant='ghost'
className='-ml-3 h-8 data-[state=open]:bg-accent font-bold text-muted-foreground'
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === "desc" ? (
<ArrowDownIcon className='w-4 h-4 ml-2' aria-hidden='true' />
) : header.column.getIsSorted() === "asc" ? (
<ArrowUpIcon className='w-4 h-4 ml-2' aria-hidden='true' />
) : (
<ArrowDownUpIcon
className='w-4 h-4 ml-2 text-muted-foreground/30'
aria-hidden='true'
/>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{header.column.getCanSort() && (
<>
<DropdownMenuItem
onClick={() => header.column.toggleSorting(false)}
aria-label={t("common.sort_asc")}
>
<ArrowUpIcon
className='mr-2 h-3.5 w-3.5 text-muted-foreground/70'
aria-hidden='true'
/>
{t("common.sort_asc")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => header.column.toggleSorting(true)}
aria-label={t("common.sort_desc")}
>
<ArrowDownIcon
className='mr-2 h-3.5 w-3.5 text-muted-foreground/70'
aria-hidden='true'
/>
{t("common.sort_desc")}
</DropdownMenuItem>
</>
)}
{header.column.getCanSort() && header.column.getCanHide() && <DropdownMenuSeparator />}
{header.column.getCanHide() && (
<DropdownMenuItem
onClick={() => header.column.toggleVisibility(false)}
aria-label={t("Hide")}
>
<EyeOffIcon
className='mr-2 h-3.5 w-3.5 text-muted-foreground/70'
aria-hidden='true'
/>
{t("Hide")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@ -1,73 +0,0 @@
import { useTranslation } from "@repo/rdx-ui/locales/i18n.ts";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import { CellContext } from "@tanstack/react-table";
import { MoreVerticalIcon } from "lucide-react";
import { ReactElement } from "react";
export type DataTablaRowActionFunction<TData> = (
props: CellContext<TData, unknown>
) => DataTableRowActionDefinition<TData>[];
export type DataTableRowActionDefinition<TData> = {
label: string | "-";
icon?: ReactElement<any, any>;
shortcut?: string;
onClick?: (props: CellContext<TData, unknown>, e: React.BaseSyntheticEvent) => void;
};
export type DataTableRowActionsProps<TData, _TValue = unknown> = {
className?: string;
actions?: DataTablaRowActionFunction<TData>;
rowContext: CellContext<TData, unknown>;
};
export function DataTableRowActions<TData = any, TValue = unknown>({
actions,
rowContext,
}: DataTableRowActionsProps<TData, TValue>) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size='icon' variant='outline' className='w-8 h-8'>
<MoreVerticalIcon className='h-3.5 w-3.5' />
<span className='sr-only'>{t("common.open_menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>{t("common.actions")} </DropdownMenuLabel>
{actions &&
actions(rowContext).map((action, index) => {
// Use a more stable key: for separators, combine 'separator' and index; for items, use label and index
if (action.label === "-") {
// Use a more stable key by combining a static string and the previous/next action label if possible
const prevLabel = actions(rowContext)[index - 1]?.label ?? "start";
const nextLabel = actions(rowContext)[index + 1]?.label ?? "end";
return <DropdownMenuSeparator key={`separator-${prevLabel}-${nextLabel}`} />;
}
return (
<DropdownMenuItem
key={`action-${typeof action.label === "string" ? action.label : "item"}-${index}`}
onClick={(event) => (action.onClick ? action.onClick(rowContext, event) : null)}
>
{action.icon && <>{action.icon}</>}
{action.label}
{action.shortcut && <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut>}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,40 +0,0 @@
import { useSortable } from "@dnd-kit/sortable";
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { t } from "i18next";
import { GripVerticalIcon } from "lucide-react";
export interface DataTableRowDragHandleCellProps {
rowId: string;
className?: string;
}
export const DataTableRowDragHandleCell = ({
rowId,
className,
}: DataTableRowDragHandleCellProps) => {
const { attributes, listeners, isDragging } = useSortable({
id: rowId,
});
return (
<Button
onClick={(event) => {
event.preventDefault();
return;
}}
size='icon'
variant='link'
className={cn(
isDragging ? "cursor-grabbing" : "cursor-grab",
"w-4 h-4 mt-2 text-ring hover:text-muted-foreground",
className
)}
{...attributes}
{...listeners}
>
<GripVerticalIcon className='w-4 h-4' />
<span className='sr-only'>{t("common.move_row")}</span>
</Button>
);
};

View File

@ -1,3 +0,0 @@
export * from "./datatable-column-header.tsx";
export * from "./datatable-row-actions.tsx";
export * from "./datatable-row-drag-handle-cell.tsx";

View File

@ -1,5 +1,7 @@
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../../locales/i18n.ts"; import { useTranslation } from "../../locales/i18n.ts";
import { LoadingSpinIcon } from "./loading-spin-icon.tsx"; import { LoadingSpinIcon } from "./loading-spin-icon.tsx";
export type LoadingIndicatorProps = { export type LoadingIndicatorProps = {
@ -26,7 +28,7 @@ export const LoadingIndicator = ({
return ( return (
<div className={"flex flex-col items-center max-w-xs justify-center w-full h-full mx-auto"}> <div className={"flex flex-col items-center max-w-xs justify-center w-full h-full mx-auto"}>
<LoadingSpinIcon size={12} className={loadingSpinClassName} /> <LoadingSpinIcon className={loadingSpinClassName} size={12} />
{/*<Spinner {...spinnerProps} />*/} {/*<Spinner {...spinnerProps} />*/}
{title ? ( {title ? (
<h2 <h2

View File

@ -1,28 +1,37 @@
import { registerTranslations } from "@repo/i18next";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation as useI18NextTranslation } from "react-i18next"; import { useTranslation as useI18NextTranslation } from "react-i18next";
import { PACKAGE_NAME } from "../index.ts"; import { PACKAGE_NAME } from "../index.ts";
import enResources from "./en.json" with { type: "json" }; import enResources from "./en.json" with { type: "json" };
import esResources from "./es.json" with { type: "json" }; import esResources from "./es.json" with { type: "json" };
const addMissingBundles = (i18n: any) => { /**
const needsEn = !i18n.hasResourceBundle("en", PACKAGE_NAME); * Registra dinámicamente las traducciones del módulo.
const needsEs = !i18n.hasResourceBundle("es", PACKAGE_NAME); */
const ensureModuleTranslations = () => {
if (needsEn) { registerTranslations(PACKAGE_NAME, "en", enResources);
i18n.addResourceBundle("en", PACKAGE_NAME, enResources, true, true); registerTranslations(PACKAGE_NAME, "es", esResources);
}
if (needsEs) {
i18n.addResourceBundle("es", PACKAGE_NAME, esResources, true, true);
}
}; };
/**
* Hook de traducción del módulo, version adaptada.
*
* - Asegura los bundles del módulo.
* - Devuelve el hook react-i18next con namespace.
*/
export const useTranslation = () => { export const useTranslation = () => {
const { i18n } = useI18NextTranslation(); // Hook base, sin namespace
const base = useI18NextTranslation();
const { i18n } = base;
// Asegura los bundles del módulo al montar (idempotente)
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => { useEffect(() => {
addMissingBundles(i18n); ensureModuleTranslations();
}, [i18n]); }, [i18n]);
// Hook con namespace
return useI18NextTranslation(PACKAGE_NAME); return useI18NextTranslation(PACKAGE_NAME);
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/rdx-utils", "name": "@repo/rdx-utils",
"version": "0.0.14", "version": "0.0.15",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -207,6 +207,9 @@ importers:
'@erp/customers': '@erp/customers':
specifier: workspace:* specifier: workspace:*
version: link:../../modules/customers version: link:../../modules/customers
'@repo/i18next':
specifier: workspace:*
version: link:../../packages/i18n
'@repo/rdx-ui': '@repo/rdx-ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-ui version: link:../../packages/rdx-ui
@ -225,12 +228,6 @@ importers:
dinero.js: dinero.js:
specifier: ^1.9.1 specifier: ^1.9.1
version: 1.9.1 version: 1.9.1
i18next:
specifier: ^25.0.2
version: 25.6.0(typescript@5.8.3)
i18next-browser-languagedetector:
specifier: ^8.1.0
version: 8.2.0
react-error-boundary: react-error-boundary:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0(react@19.2.0) version: 6.0.0(react@19.2.0)
@ -310,9 +307,6 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.90.6 specifier: ^5.90.6
version: 5.90.6(react@19.2.0) version: 5.90.6(react@19.2.0)
i18next:
specifier: ^25.1.1
version: 25.6.0(typescript@5.9.3)
react-hook-form: react-hook-form:
specifier: ^7.56.2 specifier: ^7.56.2
version: 7.66.0(react@19.2.0) version: 7.66.0(react@19.2.0)
@ -350,6 +344,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0)) version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
'@repo/i18next':
specifier: workspace:*
version: link:../../packages/i18n
'@repo/rdx-criteria': '@repo/rdx-criteria':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-criteria version: link:../../packages/rdx-criteria
@ -386,9 +383,6 @@ importers:
http-status: http-status:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0 version: 2.1.0
i18next:
specifier: ^25.1.1
version: 25.6.0(typescript@5.9.3)
lucide-react: lucide-react:
specifier: ^0.503.0 specifier: ^0.503.0
version: 0.503.0(react@19.2.0) version: 0.503.0(react@19.2.0)
@ -459,6 +453,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0)) version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
'@repo/i18next':
specifier: workspace:*
version: link:../../packages/i18n
'@repo/rdx-criteria': '@repo/rdx-criteria':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-criteria version: link:../../packages/rdx-criteria
@ -498,9 +495,6 @@ importers:
handlebars: handlebars:
specifier: ^4.7.8 specifier: ^4.7.8
version: 4.7.8 version: 4.7.8
i18next:
specifier: ^25.1.1
version: 25.6.0(typescript@5.9.3)
libphonenumber-js: libphonenumber-js:
specifier: ^1.12.7 specifier: ^1.12.7
version: 1.12.25 version: 1.12.25
@ -571,6 +565,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0)) version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
'@repo/i18next':
specifier: workspace:*
version: link:../../packages/i18n
'@repo/rdx-criteria': '@repo/rdx-criteria':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-criteria version: link:../../packages/rdx-criteria
@ -598,9 +595,6 @@ importers:
express: express:
specifier: ^4.18.2 specifier: ^4.18.2
version: 4.21.2 version: 4.21.2
i18next:
specifier: ^25.6.0
version: 25.6.0(typescript@5.9.3)
lucide-react: lucide-react:
specifier: ^0.503.0 specifier: ^0.503.0
version: 0.503.0(react@19.2.0) version: 0.503.0(react@19.2.0)
@ -679,6 +673,31 @@ importers:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
packages/i18n:
dependencies:
i18next:
specifier: 25.6.0
version: 25.6.0(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: ^8.1.0
version: 8.2.0
i18next-http-backend:
specifier: ^3.0.2
version: 3.0.2
react-i18next:
specifier: ^12.3.1
version: 12.3.1(i18next@25.6.0(typescript@5.9.3))(react@19.2.0)
devDependencies:
'@repo/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/node':
specifier: ^22.15.12
version: 22.19.0
typescript:
specifier: ^5.9.3
version: 5.9.3
packages/rdx-criteria: packages/rdx-criteria:
dependencies: dependencies:
sequelize: sequelize:
@ -759,6 +778,9 @@ importers:
'@radix-ui/react-tabs': '@radix-ui/react-tabs':
specifier: ^1.1.12 specifier: ^1.1.12
version: 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@repo/i18next':
specifier: workspace:*
version: link:../i18n
'@repo/shadcn-ui': '@repo/shadcn-ui':
specifier: workspace:* specifier: workspace:*
version: link:../shadcn-ui version: link:../shadcn-ui
@ -3340,6 +3362,9 @@ packages:
create-require@1.1.1: create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -4036,6 +4061,9 @@ packages:
i18next-browser-languagedetector@8.2.0: i18next-browser-languagedetector@8.2.0:
resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==} resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==}
i18next-http-backend@3.0.2:
resolution: {integrity: sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==}
i18next@25.6.0: i18next@25.6.0:
resolution: {integrity: sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==} resolution: {integrity: sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==}
peerDependencies: peerDependencies:
@ -5107,6 +5135,19 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@12.3.1:
resolution: {integrity: sha512-5v8E2XjZDFzK7K87eSwC7AJcAkcLt5xYZ4+yTPDAW1i7C93oOY1dnr4BaQM7un4Hm+GmghuiPvevWwlca5PwDA==}
peerDependencies:
i18next: '>= 19.0.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
react-i18next@15.7.4: react-i18next@15.7.4:
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
peerDependencies: peerDependencies:
@ -8425,6 +8466,12 @@ snapshots:
create-require@1.1.1: {} create-require@1.1.1: {}
cross-fetch@4.0.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -9188,6 +9235,12 @@ snapshots:
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
i18next-http-backend@3.0.2:
dependencies:
cross-fetch: 4.0.0
transitivePeerDependencies:
- encoding
i18next@25.6.0(typescript@5.8.3): i18next@25.6.0(typescript@5.8.3):
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
@ -10276,6 +10329,13 @@ snapshots:
dependencies: dependencies:
react: 19.2.0 react: 19.2.0
react-i18next@12.3.1(i18next@25.6.0(typescript@5.9.3))(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
html-parse-stringify: 3.0.1
i18next: 25.6.0(typescript@5.9.3)
react: 19.2.0
react-i18next@15.7.4(i18next@25.6.0(typescript@5.8.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.8.3): react-i18next@15.7.4(i18next@25.6.0(typescript@5.8.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.8.3):
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4

View File

@ -1,16 +0,0 @@
{
email soporte@rodax-software.com
auto_https disable_redirects
}
https://presupuestos.uecko.com:13001 {
reverse_proxy backend:3001
encode gzip # Comprime las respuestas con gzip
}
:443 {
root * /srv
file_server
try_files {path} /index.html # Esto asegura que las rutas en React funcionen correctamente
}

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_VERSION="1.2.0" SCRIPT_VERSION="1.2.1"
# ===================================================== # =====================================================
# FACTUGES Build Script # FACTUGES Build Script
@ -216,19 +216,20 @@ if [[ "$MODE" == "api" || "$MODE" == "all" ]]; then
EOF EOF
echo "📦 API manifest generado en ${OUT_API_DIR}/manifest-v${API_VERSION}-${DATE}.json" echo "📦 API manifest generado en ${OUT_API_DIR}/manifest-v${API_VERSION}-${DATE}.json"
fi
if [[ "$LOAD" == true ]]; then if [[ "$LOAD" == true ]]; then
echo "📥 Cargando imagen en producción vps-2.rodax-software.com..." echo "📥 Cargando imagen en producción vps-2.rodax-software.com..."
[[ "$MODE" == "web" || "$MODE" == "all" ]] && scp -r -P 49152 ${OUT_WEB_DIR} rodax@vps-2.rodax-software.com:/opt/factuges/${COMPANY}/ [[ "$MODE" == "web" || "$MODE" == "all" ]] && scp -r -P 49152 ${OUT_WEB_DIR} rodax@vps-2.rodax-software.com:/opt/factuges/${COMPANY}/
[[ "$MODE" == "api" || "$MODE" == "all" ]] && scp -r -P 49152 ${OUT_API_DIR} rodax@vps-2.rodax-software.com:/opt/factuges/${COMPANY}/ [[ "$MODE" == "api" || "$MODE" == "all" ]] && scp -r -P 49152 ${OUT_API_DIR} rodax@vps-2.rodax-software.com:/opt/factuges/${COMPANY}/
[[ "$MODE" == "api" || "$MODE" == "all" ]] && RESULT=$(ssh -p 49152 rodax@vps-2.rodax-software.com "docker load -i /opt/factuges/${COMPANY}/api/${TAR_FILE_LATEST}") [[ "$MODE" == "api" || "$MODE" == "all" ]] && RESULT=$(ssh -p 49152 rodax@vps-2.rodax-software.com "docker load -i ${TAR_FILE_LATEST}")
[[ "$MODE" == "api" || "$MODE" == "all" ]] && echo $RESULT [[ "$MODE" == "api" || "$MODE" == "all" ]] && echo $RESULT
#docker load -i "${TAR_FILE_V}" #docker load -i "${TAR_FILE_V}"
echo "✅ Imagen cargada en producción" echo "✅ Imagen cargada en producción"
fi
fi fi
# ===================================================== # =====================================================
# 3⃣ Resumen # 3⃣ Resumen
# ===================================================== # =====================================================