This commit is contained in:
David Arranz 2026-10-14 12:00:19 +02:00
parent a5dc3395e3
commit c5aff5d2cb
38 changed files with 759 additions and 622 deletions

View File

@ -1,354 +0,0 @@
<?xml version="1.0"
encoding="ISO-8859-1"
standalone="yes"?>
<datapacket version="2.000" destination="" day="8" month="10" year="2026" hour="19" min="0" sec="28" msec="755">
<metadata fielddefs="yes" indexdefs="no" recorddata="yes" changes="yes">
<fielddefs count="5">
<fielddef name="ID" fieldkind="data" datatype="integer" fieldsize="0" displaylabel="ID" editmask="" displaywidth="10" fieldindex="0" required="false" readonly="false"/>
<fielddef name="CODIGO" fieldkind="data" datatype="string" fieldsize="50" displaylabel="CODIGO" editmask="" displaywidth="50" fieldindex="1" required="false" readonly="false"/>
<fielddef name="VALOR" fieldkind="data" datatype="string" fieldsize="2000" displaylabel="VALOR" editmask="" displaywidth="150" fieldindex="2" required="false" readonly="false"/>
<fielddef name="ID_EMPRESA" fieldkind="data" datatype="integer" fieldsize="0" displaylabel="ID_EMPRESA" editmask="" displaywidth="10" fieldindex="3" required="false" readonly="false"/>
<fielddef name="VALOR_BLOB" fieldkind="data" datatype="memo" fieldsize="8" displaylabel="VALOR_BLOB" editmask="" displaywidth="10" fieldindex="4" required="false" readonly="false"/>
</fielddefs>
<indexdefs count="0">
</indexdefs>
</metadata>
<recorddata count="48">
<row>
<field name="ID" value="2000000139"/>
<field name="CODIGO" value="CAP_SEP_AMBIENTES_TITULO_FR"/>
<field name="VALOR" value=""/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" value=""/>
</row>
<row>
<field name="ID" value="2000000140"/>
<field name="CODIGO" value="CAP_SEP_AMBIENTES_FR"/>
<field name="VALOR" value=""/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" value=""/>
</row>
<row>
<field name="ID" value="50"/>
<field name="CODIGO" value="CAP_SEP_AMBIENTES_ES"/>
<field name="VALOR" value="CARACTERISTICAS SEPARACIÓN DE AMBIENTES&#xD;&#xA;Medida de Hueco: 2450x3000 mm.&#xD;&#xA;Modelo: LYN / Acabado: ANODIZADO / Color: NEGRO&#xD;&#xA;Nº de travesaños: 2 TRAVESAÑOS HORIZONTALES&#xD;&#xA;Acabado paneles: TRANSPARENTE TEMPLADO &#xD;&#xA;Tirador: DOBLE POR PUERTA&#xD;&#xA;Freno: DOBLE POR PUERTA (APERTURA Y CIERRE)&#xD;&#xA;Guía: VISTA (DOBLE EMBELLECEDOR)&#xD;&#xA;Nº de hojas: 2 CORREDERAS + 2 FIJOS"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="51"/>
<field name="CODIGO" value="CAP_FRE_ARM_CORREDERA_ES"/>
<field name="VALOR" value="CARACTERISTICAS FRENTE DE ARMARIO&#xD;&#xA;Medida de Hueco:2440x5180mm.&#xD;&#xA;Modelo: PANORAMIC P.O. / Acabado: LACADO / Color: BLANCO&#xD;&#xA;Nº de lístelos: SIN LISTELOS / Acabado paneles: PANEL LACADO&#xD;&#xA;Tirador:&#xD;&#xA;Cerco perimetral: &#xD;&#xA;Nº de hojas:&#xD;&#xA;FRENO HIDRAULICO INCORPORADO O FRENO MAGNETICO INCORPORADO&#xD;&#xA;Compuesto por:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="52"/>
<field name="CODIGO" value="CAP_FRE_ARM_ABATIBLE_ES"/>
<field name="VALOR" value="CARACTERISTICAS FRENTE DE ARMARIO&#xD;&#xA;Medida de hueco: 2275x1390 mm.&#xD;&#xA;Modelo: ARGO / Acabado: LACADO / Color: A DETERMINAR&#xD;&#xA;Grosor hoja: 22 o 19 mm&#xD;&#xA;Acabado de hojas: LISAS&#xD;&#xA;Tirador: UÑERO A DETERMINAR&#xD;&#xA;Acabado herrajes: CROMADO O ANTRACITA&#xD;&#xA;Jambas perimetrales: VISTAS, REMETIDAS O COMBI A DETERMINAR.&#xD;&#xA;Nº de hojas: 2&#xD;&#xA;Compuesto por:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="53"/>
<field name="CODIGO" value="CAP_INT_ARM_FORRADO_ES"/>
<field name="VALOR" value="CARACTERISTICAS INTERIOR DE ARMARIO&#xD;&#xA;Acabado: MELAMINA / color: A DETERMINAR&#xD;&#xA;Espesor bloque: 19 mm. / Espesor complementos: 19 mm.&#xD;&#xA;Barras de colgar: CROMO/ NEGRA/ BRONCE&#xD;&#xA;Frentes cristal cajones: RECTO / “U” – FLOAT BRONCE C-424&#xD;&#xA;Tirador cajones: FRENTE BAJO / Cajones: 4 DE 180 MM. A DETERMINAR&#xD;&#xA;Baldas deslizantes: X DE 145 MM. A DETERMINAR&#xD;&#xA;Compuesto por:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="54"/>
<field name="CODIGO" value="CAP_CLA_SEMI_SUSPENDIDO_ES"/>
<field name="VALOR" value="Acabado: MELAMINA / color: A DETERMINAR&#xD;&#xA;Medida de Hueco: 2450x3000 mm.&#xD;&#xA;Espesor bloque: 25 mm. / Espesor complementos: 25 mm.&#xD;&#xA;Barras de colgar: CROMO/ NEGRA/ BRONCE&#xD;&#xA;Frentes cristal cajones: RECTO / “U” – FLOAT BRONCE C-424&#xD;&#xA;Tirador cajones: FRENTE BAJO / Cajones: 4 DE 180 MM. A DETERMINAR&#xD;&#xA;Baldas deslizantes: X DE 145 MM. A DETERMINAR&#xD;&#xA;Compuesto por:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="55"/>
<field name="CODIGO" value="CAP_INSTALACION_ES"/>
<field name="VALOR" value="Instalación Armario x"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="56"/>
<field name="CODIGO" value="CAP_SUMINISTRO_ES"/>
<field name="VALOR" value="Entrega de material en domicilio cliente"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000098"/>
<field name="CODIGO" value="CAP_CLA_SEMI_SUSPENDIDO_EN"/>
<field name="VALOR" value="Finish: MELAMINE / colour: TO BE DETERMINED&#xD;&#xA;Size of opening: 2450x3000 mm.&#xD;&#xA;Thickness block: 19 mm / Thickness complements: 19 mm.&#xD;&#xA;Hanging rods: CHROME/ BLACK&#xD;&#xA;Glass drawer fronts: STRAIGHT / &quot;U&quot; - FLOAT BRONZE C-424 &#xD;&#xA;Drawer handle: xxxxx / Drawers: 4 DE 180 MM.&#xD;&#xA;Shoe rack door: YES/NO / Profile finish: BLACK LACQUER / SILVER MATTE&#xD;&#xA;Shoe cabinet door glass finish: FLOAT BRONZE C-424&#xD;&#xA;Composed of:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000097"/>
<field name="CODIGO" value="CAP_CLA_SEMI_SUSPENDIDO_TITULO_EN"/>
<field name="VALOR" value="CLASSIC DRESSER / SEMI-HUNG SUSPENDED / SUSPENDED"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000096"/>
<field name="CODIGO" value="CAP_CLA_SEMI_SUSPENDIDO_TITULO_ES"/>
<field name="VALOR" value="CLASICO / SEMISUSPENDIDO / SUSPENDIDO"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000084"/>
<field name="CODIGO" value="CAP_FRE_ARM_ABATIBLE_EN"/>
<field name="VALOR" value="CHARACTERISTICS OF THE FRONT OF THE HINGED WARDROBE&#xD;&#xA;Size of opening: 2275x1390 mm.&#xD;&#xA;Model: ARGO / Finish: LACQUER / Colour: TO BE DETERMINED&#xD;&#xA;Door thickness: 22 or 19 mm&#xD;&#xA;Door finish: SMOOTH &#xD;&#xA;Handle: KNOB TO BE DETERMINED&#xD;&#xA;Hardware finish: CHROME OR ANTHRACITE&#xD;&#xA;Perimetral doorposts: VISIBLE, REMETED OR COMBI TO BE DETERMINED.&#xD;&#xA;Number of doors: 2 &#xD;&#xA;Composed of:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000086"/>
<field name="CODIGO" value="CAP_FRE_ARM_ABATIBLE_TITULO_EN"/>
<field name="VALOR" value="FRONT OF THE HINGED WARDROBE"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000085"/>
<field name="CODIGO" value="CAP_FRE_ARM_ABATIBLE_TITULO_ES"/>
<field name="VALOR" value="FRENTE DE ARMARIO ABATIBLE"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000089"/>
<field name="CODIGO" value="CAP_FRE_ARM_CORREDERA_EN"/>
<field name="VALOR" value="Size of opening: 2440x5180mm. &#xD;&#xA;Model: PANORAMIC P.O. / Finish: LACQUER / Colour: WHITE&#xD;&#xA;No. of listers: NO LISTELS / Panels finish: LACQUERED PANEL &#xD;&#xA;Handle: &#xD;&#xA;Perimeter frame: &#xD;&#xA;Nº of doors: &#xD;&#xA;INCORPORATED HYDRAULIC BRAKE (not available in lower doors of 650 mm.) OR INCORPORATED MAGNETIC BRAKE. &#xD;&#xA;Composed of:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000088"/>
<field name="CODIGO" value="CAP_FRE_ARM_CORREDERA_TITULO_EN"/>
<field name="VALOR" value="SLIDING WARDROBE FRONT"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000087"/>
<field name="CODIGO" value="CAP_FRE_ARM_CORREDERA_TITULO_ES"/>
<field name="VALOR" value="FRENTE DE ARMARIO CORREDERA"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000113"/>
<field name="CODIGO" value="CAP_INSTALACION_EN"/>
<field name="VALOR" value="Installation Cabinet x"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000115"/>
<field name="CODIGO" value="CAP_INSTALACION_TITULO_EN"/>
<field name="VALOR" value="INSTALLATION SERVICE - NET PRICE"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000114"/>
<field name="CODIGO" value="CAP_INSTALACION_TITULO_ES"/>
<field name="VALOR" value="SERVICIO DE INSTALACIÓN - PRECIO NETO"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000092"/>
<field name="CODIGO" value="CAP_INT_ARM_FORRADO_EN"/>
<field name="VALOR" value="Finish: MELAMINE / colour: TO BE DETERMINED&#xD;&#xA;Thickness block: 19 mm / Thickness complements: 19 mm.&#xD;&#xA;Hanging rods: CHROME/ BLACK&#xD;&#xA;Glass drawer fronts: STRAIGHT / &quot;U&quot; - FLOAT BRONZE C-424 &#xD;&#xA;Drawer handle: xxxxx / Drawers: 4 DE 180 MM.&#xD;&#xA;Composed of:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000091"/>
<field name="CODIGO" value="CAP_INT_ARM_FORRADO_TITULO_EN"/>
<field name="VALOR" value="INTERIOR OF THE LINED WARDROBE"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000090"/>
<field name="CODIGO" value="CAP_INT_ARM_FORRADO_TITULO_ES"/>
<field name="VALOR" value="INTERIOR DE ARMARIO FORRADO"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000109"/>
<field name="CODIGO" value="CAP_INT_EST_CRISTAL_EN"/>
<field name="VALOR" value="Frames finish: BLACK ANODIZED&#xD;&#xA;Side frame panel finish: BRONZE FLOAT BRONZE GLASS C-424&#xD;&#xA;Rear panel finish: BRONZE FLOAT GLASS C-424&#xD;&#xA;Complements finish: MELAMIN / Colour: DENBER OAK M-242&#xD;&#xA;Thickness of accessories: 25 mm.&#xD;&#xA;Hanging bars: BLACK&#xD;&#xA;Glass drawer fronts: STRAIGHT / &quot;U&quot; - FLOAT BRONZE C-424 &#xD;&#xA;Drawer handle: xxxxx / Drawers: 4 DE 180 MM.&#xD;&#xA;Composed of:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000108"/>
<field name="CODIGO" value="CAP_INT_EST_CRISTAL_ES"/>
<field name="VALOR" value="Acabado bastidores: ANODIZADO NEGRO&#xD;&#xA;Acabado panel bastidores laterales: CRISTAL TEMPLADO FLOAT BRONCE C-424&#xD;&#xA;Acabado trasera: CRISTAL TEMPLADO FLOAT BRONCE C-424&#xD;&#xA;Acabado complementos: MELAMINA / Color: ROBLE DENBER M-242&#xD;&#xA;Espesor complementos: 25 mm.&#xD;&#xA;Barras de colgar: NEGRA&#xD;&#xA;Frentes cristal cajones: RECTO / “U” – FLOAT BRONCE C-424 &#xD;&#xA;Tirador cajones: xxxxx / Cajones: 4 DE 180 MM.&#xD;&#xA;Compuesto por:"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000107"/>
<field name="CODIGO" value="CAP_INT_EST_CRISTAL_TITULO_EN"/>
<field name="VALOR" value="INSIDE CHARACTERISTICS OF WARDROBE GLASS STRUCTURE"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000106"/>
<field name="CODIGO" value="CAP_INT_EST_CRISTAL_TITULO_ES"/>
<field name="VALOR" value="INTERIOR DE ARMARIO ESTRUCTURA DE CRISTAL"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000095"/>
<field name="CODIGO" value="CAP_SEP_AMBIENTES_EN"/>
<field name="VALOR" value="CHARACTERISTICS SEPARATION OF ROOMS&#xD;&#xA;Size of opening: 2450x3000 mm.&#xD;&#xA;Model: LYN / Finish: ANODISED / Colour: BLACK&#xD;&#xA;No. of crosspieces: 2 HORIZONTAL CROSSBARS&#xD;&#xA;Panel finish: TRANSPARENT TEMPLARED &#xD;&#xA;Handle: DOUBLE PER DOOR&#xD;&#xA;Brake: DOUBLE PER DOOR (OPENING AND CLOSING)&#xD;&#xA;Guide: VISTA (DOUBLE EMBELLECEDOR)&#xD;&#xA;Number of panels: 2 SLIDING + 2 FIXED"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000094"/>
<field name="CODIGO" value="CAP_SEP_AMBIENTES_TITULO_EN"/>
<field name="VALOR" value="SEPARATION OF ROOMS"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000093"/>
<field name="CODIGO" value="CAP_SEP_AMBIENTES_TITULO_ES"/>
<field name="VALOR" value="SEPARACIÓN DE AMBIENTES"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000110"/>
<field name="CODIGO" value="CAP_SUMINISTRO_EN"/>
<field name="VALOR" value="Delivery of material to the customer's home"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000112"/>
<field name="CODIGO" value="CAP_SUMINISTRO_TITULO_EN"/>
<field name="VALOR" value="SUPPLY SERVICE - NET PRICE"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000111"/>
<field name="CODIGO" value="CAP_SUMINISTRO_TITULO_ES"/>
<field name="VALOR" value="SERVICIO DE SUMINISTRO - PRECIO NETO"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000122"/>
<field name="CODIGO" value="CAP_CLA_SEMI_SUSPENDIDO_TITULO_FR"/>
<field name="VALOR" value="CARACTÉRISTIQUES DRESSING CLASSIQUE / SEMI-SUSPENDU / SUSPENDU"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000121"/>
<field name="CODIGO" value="CAP_CLA_SEMI_SUSPENDIDO_FR"/>
<field name="VALOR" value="Finition : MÉLAMINE / Couleur : À DÉTERMINER&#xD;&#xA;Épaisseur du bloc : 19 mm / Épaisseur des accessoires : 19 mm&#xD;&#xA;Barres de penderie : CHROME / NOIR&#xD;&#xA;Façades des tiroirs en verre : DROITE / “U” – FLOAT BRONZE C-424&#xD;&#xA;Poignée des tiroirs : xxxxx&#xD;&#xA;Tiroirs : 4 DE 180 MM&#xD;&#xA;Porte de chaussures : OUI / NON&#xD;&#xA;Finition du profil : LAQUÉ NOIR / ARGENT MAT&#xD;&#xA;Finition du verre de la porte de chaussures : FLOAT BRONZE C-424&#xD;&#xA;Composé de :"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000124"/>
<field name="CODIGO" value="CAP_FRE_ARM_ABATIBLE_TITULO_FR"/>
<field name="VALOR" value="CARACTÉRISTIQUES FAÇADE DE PLACARD BATTANT"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000123"/>
<field name="CODIGO" value="CAP_FRE_ARM_ABATIBLE_FR"/>
<field name="VALOR" value="Dimension de l’ouverture : 2275x1390 mm&#xD;&#xA;Modèle : ARGO / Finition : LAQUÉE / Couleur : À DÉTERMINER&#xD;&#xA;Épaisseur des vantaux : 22 ou 19 mm&#xD;&#xA;Finition des vantaux : LISSES&#xD;&#xA;Poignée : PRISE DE MAIN À DÉTERMINER&#xD;&#xA;Finition des ferrures : CHROMÉ OU ANTHRACITE&#xD;&#xA;Jambages périmétraux : APPARENTS, ENCASTRÉS OU COMBINÉS À DÉTERMINER&#xD;&#xA;Nombre de vantaux : 2&#xD;&#xA;Composé de :"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000126"/>
<field name="CODIGO" value="CAP_FRE_ARM_CORREDERA_TITULO_FR"/>
<field name="VALOR" value="CARACTÉRISTIQUES FAÇADE DE PLACARD COULISSANT"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000125"/>
<field name="CODIGO" value="CAP_FRE_ARM_CORREDERA_FR"/>
<field name="VALOR" value="Dimension de l’ouverture : 2440x5180mm&#xD;&#xA;Modèle : PANORAMIC P.O. / Finition : LAQUÉE / Couleur : BLANC&#xD;&#xA;Nombre de listels : SANS LISTELS&#xD;&#xA;Finition des panneaux : PANNEAU LAQUÉ&#xD;&#xA;Poignée :&#xD;&#xA;Encadrement périmétral :&#xD;&#xA;Nombre de vantaux :&#xD;&#xA;FREIN HYDRAULIQUE INTÉGRÉ (non disponible pour les portes inférieures à 650 mm) OU FREIN MAGNÉTIQUE INTÉGRÉ&#xD;&#xA;Composé de :"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000128"/>
<field name="CODIGO" value="CAP_INSTALACION_TITULO_FR"/>
<field name="VALOR" value="SERVICE D'INSTALLATION - PRIX NET"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000127"/>
<field name="CODIGO" value="CAP_INSTALACION_FR"/>
<field name="VALOR" value="Armoire d'installation x"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000130"/>
<field name="CODIGO" value="CAP_INT_ARM_FORRADO_TITULO_FR"/>
<field name="VALOR" value="CARACTÉRISTIQUES INTÉRIEUR DE PLACARD HABILLÉ"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000129"/>
<field name="CODIGO" value="CAP_INT_ARM_FORRADO_FR"/>
<field name="VALOR" value="Finition : MÉLAMINE / Couleur : À DÉTERMINER&#xD;&#xA;Épaisseur du bloc : 19 mm / Épaisseur des accessoires : 19 mm&#xD;&#xA;Barres de penderie : CHROME / NOIR&#xD;&#xA;Façades des tiroirs en verre : DROITE / “U” – FLOAT BRONZE C-424&#xD;&#xA;Poignée des tiroirs : xxxxx&#xD;&#xA;Tiroirs : 4 DE 180 MM&#xD;&#xA;Composé de :"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000132"/>
<field name="CODIGO" value="CAP_INT_EST_CRISTAL_TITULO_FR"/>
<field name="VALOR" value="CARACTÉRISTIQUES INTÉRIEUR DE PLACARD STRUCTURE EN VERRE"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000131"/>
<field name="CODIGO" value="CAP_INT_EST_CRISTAL_FR"/>
<field name="VALOR" value="Finition des cadres : ANODISÉ NOIR&#xD;&#xA;Finition des panneaux des cadres latéraux : VERRE TREMPÉ FLOAT BRONZE C-424&#xD;&#xA;Finition du dos : VERRE TREMPÉ FLOAT BRONZE C-424&#xD;&#xA;Finition des accessoires : MÉLAMINE / Couleur : CHÊNE DENBER M-242&#xD;&#xA;Épaisseur des accessoires : 25 mm&#xD;&#xA;Barres de penderie : NOIRES&#xD;&#xA;Façades des tiroirs en verre : DROITE / “U” – FLOAT BRONZE C-424&#xD;&#xA;Poignée des tiroirs : xxxxx&#xD;&#xA;Tiroirs : 4 DE 180 MM&#xD;&#xA;Composé de :"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000135"/>
<field name="CODIGO" value="CAP_SUMINISTRO_TITULO_FR"/>
<field name="VALOR" value="SERVICE DE FOURNITURE - PRIX NET"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
<row>
<field name="ID" value="2000000134"/>
<field name="CODIGO" value="CAP_SUMINISTRO_FR"/>
<field name="VALOR" value="Livraison du matériel au domicile du client"/>
<field name="ID_EMPRESA" size="-1"/>
<field name="VALOR_BLOB" size="-1"/>
</row>
</recorddata>
</datapacket>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -7,13 +7,13 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" /> <link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
<title>Presupuestador Uecko</title> <title><!-- app-title --></title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript><!-- app-noscript --></noscript>
<div id="uecko"></div> <div id="uecko"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@ -1,7 +1,7 @@
{ {
"name": "@uecko-presupuestador/client", "name": "@uecko-presupuestador/client",
"private": true, "private": true,
"version": "1.2.4", "version": "1.2.5",
"author": "Rodax Software <dev@rodax-software.com>", "author": "Rodax Software <dev@rodax-software.com>",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -2,8 +2,8 @@ import { AuthProvider, ThemeProvider, UnsavedWarnProvider } from "@/lib/hooks";
import { Toaster, TooltipProvider } from "@/ui"; import { Toaster, TooltipProvider } from "@/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Suspense } from "react"; import { Suspense, useEffect, useState } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider, useTranslation } from "react-i18next";
import { Routes } from "./Routes"; import { Routes } from "./Routes";
import { LoadingOverlay, TailwindIndicator } from "./components"; import { LoadingOverlay, TailwindIndicator } from "./components";
import { createAxiosAuthActions, createAxiosDataProvider } from "./lib/axios"; import { createAxiosAuthActions, createAxiosDataProvider } from "./lib/axios";
@ -12,14 +12,22 @@ import i18n from "./locales/i18n.ts";
import { AppUpdateNotice } from "./components/AppUpdateNotice"; import { AppUpdateNotice } from "./components/AppUpdateNotice";
function App() { function App() {
const queryClient = new QueryClient({ const { t } = useTranslation();
defaultOptions: { useEffect(() => {
queries: { document.title = t("app.title");
retry: false, document.documentElement.lang = i18n.resolvedLanguage || "es";
staleTime: 10000, // Specify a staleTime to only fetch when the data is older than a certain amount of time }, [t]);
const [queryClient] = useState(() =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 10000, // Specify a staleTime to only fetch when the data is older than a certain amount of time
},
}, },
}, })
}); );
return ( return (
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>

View File

@ -24,7 +24,7 @@ export const Routes = () => {
const routesForErrors = [ const routesForErrors = [
{ {
path: "*", path: "*",
Element: ( element: (
<Suspense fallback={<LoadingOverlay />}> <Suspense fallback={<LoadingOverlay />}>
<ErrorPage /> <ErrorPage />
</Suspense> </Suspense>

View File

@ -1,19 +1,22 @@
import { Button } from "@/ui"; import { Button } from "@/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export const ErrorPage = () => { export const ErrorPage = () => {
const { t } = useTranslation();
return ( return (
<div className='flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8'> <div className='flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8'>
<div className='max-w-md mx-auto text-center'> <div className='max-w-md mx-auto text-center'>
<div className='w-12 h-12 mx-auto text-primary' /> <div className='w-12 h-12 mx-auto text-primary' />
<h1 className='mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl'> <h1 className='mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl'>
Oops, page not found! {t("error_page.title")}
</h1> </h1>
<p className='mt-4 text-muted-foreground'> <p className='mt-4 text-muted-foreground'>
The page you're looking for doesn't exist or has been moved. {t("error_page.description")}
</p> </p>
<div className='mt-6'> <div className='mt-6'>
<Button className='inline-flex items-center px-4 py-2 text-sm font-medium transition-colors rounded-md shadow-sm bg-primary text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'> <Button asChild className='inline-flex items-center px-4 py-2 text-sm font-medium transition-colors rounded-md shadow-sm bg-primary text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'>
Go to Homepage <Link to='/'>{t("error_page.go_home")}</Link>
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -15,22 +15,22 @@ import {
} from "@/ui"; } from "@/ui";
import { joiResolver } from "@hookform/resolvers/joi"; import { joiResolver } from "@hookform/resolvers/joi";
import { ILogin_DTO } from "@shared/contexts"; import { ILogin_DTO } from "@shared/contexts";
import { t } from "i18next";
import Joi from "joi";
import { AlertCircleIcon } from "lucide-react"; import { AlertCircleIcon } from "lucide-react";
import { useEffect } from "react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { Trans } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import SpanishJoiMessages from "../../spanish-joi-messages.json"; import { createLoginSchema } from "./loginSchema";
type LoginDataForm = ILogin_DTO; type LoginDataForm = ILogin_DTO;
export const LoginPage = () => { export const LoginPage = () => {
const { t, i18n } = useTranslation();
const { mutate: login } = useLogin({ const { mutate: login } = useLogin({
onSuccess: (data) => { onSuccess: (data) => {
const { success, error } = data; const { success, error } = data;
if (!success && error) { if (!success && error) {
form.setError("root", error); form.setError("root", { type: error.name, message: error.message });
} }
}, },
onError: (error) => { onError: (error) => {
@ -44,19 +44,17 @@ export const LoginPage = () => {
email: "", email: "",
password: "", password: "",
}, },
resolver: joiResolver( resolver: joiResolver(createLoginSchema(t)),
Joi.object({
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
password: Joi.string().min(4).alphanum().required(),
}),
{
messages: SpanishJoiMessages,
}
),
}); });
const { getFieldState, trigger } = form;
useEffect(() => {
const invalidFields = (["email", "password"] as const).filter(
(field) => getFieldState(field).invalid
);
if (invalidFields.length) void trigger(invalidFields);
}, [i18n.resolvedLanguage, getFieldState, trigger]);
const onSubmit: SubmitHandler<LoginDataForm> = async (data) => { const onSubmit: SubmitHandler<LoginDataForm> = async (data) => {
login({ email: data.email, password: data.password }, {}); login({ email: data.email, password: data.password }, {});
}; };
@ -113,7 +111,11 @@ export const LoginPage = () => {
<AlertTitle> <AlertTitle>
<Trans i18nKey='common.error' /> <Trans i18nKey='common.error' />
</AlertTitle> </AlertTitle>
<AlertDescription>{form.formState.errors.root?.message}</AlertDescription> <AlertDescription>
{form.formState.errors.root?.type === "InvalidCredentials"
? t("login_page.invalid_credentials")
: form.formState.errors.root?.message}
</AlertDescription>
</Alert> </Alert>
)} )}
@ -138,7 +140,7 @@ export const LoginPage = () => {
<div className='hidden bg-muted lg:block'> <div className='hidden bg-muted lg:block'>
<img <img
src='/img/nara2.jpg' src='/img/nara2.jpg'
alt='Image' alt=''
width='1920' width='1920'
height='1080' height='1080'
className='h-full w-full object-cover dark:brightness-[0.2] dark:grayscale' className='h-full w-full object-cover dark:brightness-[0.2] dark:grayscale'

View File

@ -21,7 +21,6 @@ import {
} from "@/ui"; } from "@/ui";
import { joiResolver } from "@hookform/resolvers/joi"; import { joiResolver } from "@hookform/resolvers/joi";
import { ILogin_DTO } from "@shared/contexts"; import { ILogin_DTO } from "@shared/contexts";
import Joi from "joi";
import { useToast } from "@/ui/use-toast"; import { useToast } from "@/ui/use-toast";
import { AlertCircleIcon, ChevronDownIcon } from "lucide-react"; import { AlertCircleIcon, ChevronDownIcon } from "lucide-react";
@ -29,7 +28,7 @@ import { useEffect, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import SpanishJoiMessages from "../../spanish-joi-messages.json"; import { createLoginSchema } from "./loginSchema";
type LoginDataForm = ILogin_DTO; type LoginDataForm = ILogin_DTO;
export const LoginPageWithLanguageSelector = () => { export const LoginPageWithLanguageSelector = () => {
@ -56,7 +55,7 @@ export const LoginPageWithLanguageSelector = () => {
onSuccess: (data) => { onSuccess: (data) => {
const { success, error } = data; const { success, error } = data;
if (!success && error) { if (!success && error) {
form.setError("root", error); form.setError("root", { type: error.name, message: error.message });
} }
if (success) { if (success) {
@ -76,19 +75,17 @@ export const LoginPageWithLanguageSelector = () => {
email: "", email: "",
password: "", password: "",
}, },
resolver: joiResolver( resolver: joiResolver(createLoginSchema(t)),
Joi.object({
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
password: Joi.string().min(4).alphanum().required(),
}),
{
messages: SpanishJoiMessages,
}
),
}); });
const { getFieldState, trigger } = form;
useEffect(() => {
const invalidFields = (["email", "password"] as const).filter(
(field) => getFieldState(field).invalid
);
if (invalidFields.length) void trigger(invalidFields);
}, [i18n.resolvedLanguage, getFieldState, trigger]);
const onSubmit: SubmitHandler<LoginDataForm> = async (data) => { const onSubmit: SubmitHandler<LoginDataForm> = async (data) => {
login({ email: data.email, password: data.password }, {}); login({ email: data.email, password: data.password }, {});
}; };
@ -172,7 +169,11 @@ export const LoginPageWithLanguageSelector = () => {
<AlertTitle> <AlertTitle>
<Trans i18nKey='common.error' /> <Trans i18nKey='common.error' />
</AlertTitle> </AlertTitle>
<AlertDescription>{form.formState.errors.root?.message}</AlertDescription> <AlertDescription>
{form.formState.errors.root?.type === "InvalidCredentials"
? t("login_page.invalid_credentials")
: form.formState.errors.root?.message}
</AlertDescription>
</Alert> </Alert>
)} )}
@ -201,7 +202,7 @@ export const LoginPageWithLanguageSelector = () => {
<div className='hidden bg-muted lg:block'> <div className='hidden bg-muted lg:block'>
<img <img
src='/img/nara2.jpg' src='/img/nara2.jpg'
alt='Image' alt=''
width='1920' width='1920'
height='1080' height='1080'
className='h-full w-full object-cover dark:brightness-[0.2] dark:grayscale' className='h-full w-full object-cover dark:brightness-[0.2] dark:grayscale'

View File

@ -0,0 +1,42 @@
import { createInstance } from "i18next";
import es from "../../locales/es.json";
import en from "../../locales/en.json";
import fr from "../../locales/fr.json";
import ca from "../../locales/ca.json";
import { createLoginSchema } from "./loginSchema";
describe("login validation", () => {
it.each(["es", "en", "fr", "ca"])("uses %s for every login validation rule", async (lng) => {
const i18n = createInstance();
await i18n.init({ lng, fallbackLng: false, resources: { es, en, fr, ca } });
const schema = createLoginSchema(i18n.t);
const valid = { email: "dealer@example.com", password: "abcd1234" };
const cases = [
{ data: { ...valid, email: "" }, key: "email_required" },
{ data: { password: valid.password }, key: "email_required" },
{ data: { ...valid, email: "invalid" }, key: "email_invalid" },
{ data: { ...valid, password: "" }, key: "password_required" },
{ data: { email: valid.email }, key: "password_required" },
{ data: { ...valid, password: "abc" }, key: "password_min" },
{ data: { ...valid, password: "abcd!" }, key: "password_alphanum" },
];
expect(schema.validate(valid).error).toBeUndefined();
for (const { data, key } of cases) {
const message = schema.validate(data).error?.details[0].message;
expect(message).toBe(i18n.t(`login_page.validation.${key}`, { count: 4 }));
expect(message).not.toContain("{{");
}
});
it("uses the new language when the resolver schema is rebuilt", async () => {
const i18n = createInstance();
await i18n.init({ lng: "es", resources: { es, fr } });
const invalid = { email: "invalid", password: "abcd1234" };
expect(createLoginSchema(i18n.t).validate(invalid).error?.message)
.toBe(es.translation.login_page.validation.email_invalid);
await i18n.changeLanguage("fr");
expect(createLoginSchema(i18n.t).validate(invalid).error?.message)
.toBe(fr.translation.login_page.validation.email_invalid);
});
});

View File

@ -0,0 +1,26 @@
import { TFunction } from "i18next";
import Joi from "joi";
export const createLoginSchema = (t: TFunction) =>
Joi.object({
email: Joi.string()
.email({ tlds: { allow: false } })
.required()
.messages({
"any.required": t("login_page.validation.email_required"),
"string.empty": t("login_page.validation.email_required"),
"string.base": t("login_page.validation.email_invalid"),
"string.email": t("login_page.validation.email_invalid"),
}),
password: Joi.string()
.min(4)
.alphanum()
.required()
.messages({
"any.required": t("login_page.validation.password_required"),
"string.empty": t("login_page.validation.password_required"),
"string.base": t("login_page.validation.password_alphanum"),
"string.min": t("login_page.validation.password_min", { count: 4 }),
"string.alphanum": t("login_page.validation.password_alphanum"),
}),
});

View File

@ -4,10 +4,12 @@ import { ColumnDef } from "@tanstack/react-table";
import { DataTablaRowActionFunction, DataTableRowActions } from "@/components"; import { DataTablaRowActionFunction, DataTableRowActions } from "@/components";
import { Badge } from "@/ui"; import { Badge } from "@/ui";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export const useCatalogTableColumns = ( export const useCatalogTableColumns = (
actions: DataTablaRowActionFunction<IListArticles_Response_DTO> actions: DataTablaRowActionFunction<IListArticles_Response_DTO>
): ColumnDef<IListArticles_Response_DTO>[] => { ): ColumnDef<IListArticles_Response_DTO>[] => {
const { t } = useTranslation();
const customerColumns: ColumnDef<IListArticles_Response_DTO>[] = useMemo( const customerColumns: ColumnDef<IListArticles_Response_DTO>[] = useMemo(
() => [ () => [
/*{ /*{
@ -42,14 +44,14 @@ export const useCatalogTableColumns = (
{ {
id: "company", id: "company",
accessorKey: "company_name", accessorKey: "company_name",
header: "Compañía", header: t("common.company"),
enableSorting: true, enableSorting: true,
sortingFn: "alphanumeric", sortingFn: "alphanumeric",
}, },
{ {
id: "state", id: "state",
accessorKey: "state", accessorKey: "state",
header: "Estado", header: t("quotes.list.columns.status"),
cell: ({ renderValue }) => ( cell: ({ renderValue }) => (
<Badge variant={"destructive"}> <Badge variant={"destructive"}>
<>{renderValue()}</> <>{renderValue()}</>
@ -59,19 +61,19 @@ export const useCatalogTableColumns = (
{ {
id: "phone", id: "phone",
accessorKey: "phone", accessorKey: "phone",
header: "Phone", header: t("common.phone"),
enableSorting: true, enableSorting: true,
sortingFn: "alphanumeric", sortingFn: "alphanumeric",
}, },
{ {
id: "actions", id: "actions",
header: "Acciones", header: t("common.actions"),
cell: (context) => { cell: (context) => {
return <DataTableRowActions rowContext={context} actions={actions} />; return <DataTableRowActions rowContext={context} actions={actions} />;
}, },
}, },
], ],
[actions] [actions, t]
); );
return customerColumns; return customerColumns;
}; };

View File

@ -7,12 +7,13 @@ import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar";
import { useDataTable, useDataTableContext } from "@/lib/hooks"; import { useDataTable, useDataTableContext } from "@/lib/hooks";
import { IListArticles_Response_DTO } from "@shared/contexts"; import { IListArticles_Response_DTO } from "@shared/contexts";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { t } from "i18next"; import { useTranslation } from "react-i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useDealerList } from "../hooks"; import { useDealerList } from "../hooks";
export const DealerDataTable = () => { export const DealerDataTable = () => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, globalFilter, isFiltered } = useDataTableContext(); const { pagination, globalFilter, isFiltered } = useDataTableContext();
@ -29,17 +30,19 @@ export const DealerDataTable = () => {
{ {
id: "id" as const, id: "id" as const,
accessorKey: "id", accessorKey: "id",
header: () => t("common.identifier"),
enableResizing: false, enableResizing: false,
size: 10, size: 10,
}, },
{ {
id: "name" as const, id: "name" as const,
accessorKey: "name", accessorKey: "name",
header: () => t("common.name"),
enableResizing: false, enableResizing: false,
size: 10, size: 10,
}, },
], ],
[] [t]
); );
const { table } = useDataTable({ const { table } = useDataTable({

View File

@ -4,10 +4,12 @@ import { ColumnDef } from "@tanstack/react-table";
import { DataTablaRowActionFunction, DataTableRowActions } from "@/components"; import { DataTablaRowActionFunction, DataTableRowActions } from "@/components";
import { Badge } from "@/ui"; import { Badge } from "@/ui";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export const useCustomerInvoiceDataTableColumns = ( export const useCustomerInvoiceDataTableColumns = (
actions: DataTablaRowActionFunction<IListArticles_Response_DTO> actions: DataTablaRowActionFunction<IListArticles_Response_DTO>
): ColumnDef<IListArticles_Response_DTO>[] => { ): ColumnDef<IListArticles_Response_DTO>[] => {
const { t } = useTranslation();
const customerColumns: ColumnDef<IListArticles_Response_DTO>[] = useMemo( const customerColumns: ColumnDef<IListArticles_Response_DTO>[] = useMemo(
() => [ () => [
/*{ /*{
@ -42,14 +44,14 @@ export const useCustomerInvoiceDataTableColumns = (
{ {
id: "company", id: "company",
accessorKey: "company_name", accessorKey: "company_name",
header: "Compañía", header: t("common.company"),
enableSorting: true, enableSorting: true,
sortingFn: "alphanumeric", sortingFn: "alphanumeric",
}, },
{ {
id: "state", id: "state",
accessorKey: "state", accessorKey: "state",
header: "Estado", header: t("quotes.list.columns.status"),
cell: ({ renderValue }) => ( cell: ({ renderValue }) => (
<Badge variant={"destructive"}> <Badge variant={"destructive"}>
<>{renderValue()}</> <>{renderValue()}</>
@ -59,19 +61,19 @@ export const useCustomerInvoiceDataTableColumns = (
{ {
id: "phone", id: "phone",
accessorKey: "phone", accessorKey: "phone",
header: "Phone", header: t("common.phone"),
enableSorting: true, enableSorting: true,
sortingFn: "alphanumeric", sortingFn: "alphanumeric",
}, },
{ {
id: "actions", id: "actions",
header: "Acciones", header: t("common.actions"),
cell: (context) => { cell: (context) => {
return <DataTableRowActions rowContext={context} actions={actions} />; return <DataTableRowActions rowContext={context} actions={actions} />;
}, },
}, },
], ],
[actions] [actions, t]
); );
return customerColumns; return customerColumns;
}; };

View File

@ -11,7 +11,7 @@ import {
Label, Label,
Progress, Progress,
} from "@/ui"; } from "@/ui";
import { t } from "i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useId } from "react"; import { useEffect, useId } from "react";
type DownloadQuoteDialogProps = Omit<UseDownloader, "download"> & { type DownloadQuoteDialogProps = Omit<UseDownloader, "download"> & {
@ -19,6 +19,7 @@ type DownloadQuoteDialogProps = Omit<UseDownloader, "download"> & {
}; };
export const DownloadQuoteDialog = (props: DownloadQuoteDialogProps) => { export const DownloadQuoteDialog = (props: DownloadQuoteDialogProps) => {
const { t } = useTranslation();
const { percentage, cancel, error, isInProgress, onFinishDownload } = props; const { percentage, cancel, error, isInProgress, onFinishDownload } = props;
const panelId = useId(); const panelId = useId();
@ -52,7 +53,7 @@ export const DownloadQuoteDialog = (props: DownloadQuoteDialogProps) => {
max={100} max={100}
getValueLabel={() => t("quotes.downloading_dialog.progress.label", {})} getValueLabel={() => t("quotes.downloading_dialog.progress.label", {})}
/> />
{error && <p>Error: {JSON.stringify(error)}</p>} {error && <p role="alert">{t("quotes.downloading_dialog.error")}</p>}
</div> </div>
<DialogFooter className='sm:justify-end'> <DialogFooter className='sm:justify-end'>
<DialogClose asChild> <DialogClose asChild>

View File

@ -1,123 +1,17 @@
import { ScrollArea } from "@/ui"; import { ScrollArea } from "@/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import esBlocks from "./locales/es.json";
import enBlocks from "./locales/en.json";
import frBlocks from "./locales/fr.json";
export type BlockDataType = { export type BlockDataType = {
id: number; id?: number;
code: string; code: string;
title: string; title: string;
body: string; body: string;
}; };
const blockData: Record<string, BlockDataType[]> = { const blockData: Record<string, BlockDataType[]> = { es: esBlocks, en: enBlocks, fr: frBlocks };
es: [
{
id: 50,
code: "CAP_SEP_AMBIENTES_ES",
title: "CARACTERISTICAS SEPARACIÓN DE AMBIENTES",
body: "Medida de Hueco: 2450x3000 mm.\r\nModelo: LYN / Acabado: ANODIZADO / Color: NEGRO\r\nNº de travesaños: 2 TRAVESAÑOS HORIZONTALES\r\nAcabado paneles: TRANSPARENTE TEMPLADO \r\nTirador: DOBLE POR PUERTA\r\nFreno: DOBLE POR PUERTA (APERTURA Y CIERRE)\r\nGuía: VISTA (DOBLE EMBELLECEDOR)\r\nNº de hojas: 2 CORREDERAS + 2 FIJOS",
},
{
id: 51,
code: "CAP_FRE_ARM_CORREDERA_ES",
title: "CARACTERISTICAS FRENTE DE ARMARIO CORREDERA",
body: "Medida de Hueco:2440x5180mm.\r\nModelo: PANORAMIC P.O. / Acabado: LACADO / Color: BLANCO\r\nNº de lístelos: SIN LISTELOS / Acabado paneles: PANEL LACADO\r\nTirador:\r\nCerco perimetral: \r\nNº de hojas:\r\nFRENO HIDRAULICO INCORPORADO O FRENO MAGNETICO INCORPORADO\r\nCompuesto por:",
},
{
id: 52,
code: "CAP_FRE_ARM_ABATIBLE_ES",
title: "CARACTERISTICAS FRENTE DE ARMARIO",
body: "Medida de hueco: 2275x1390 mm.\r\nModelo: ARGO / Acabado: LACADO / Color: A DETERMINAR\r\nGrosor hoja: 22 o 19 mm\r\nAcabado de hojas: LISAS\r\nTirador: UÑERO A DETERMINAR\r\nAcabado herrajes: CROMADO O ANTRACITA\r\nJambas perimetrales: VISTAS, REMETIDAS O COMBI A DETERMINAR.\r\nNº de hojas: 2\r\nCompuesto por:",
},
{
id: 53,
code: "CAP_INT_ARM_FORRADO_ES",
title: "CARACTERISTICAS INTERIOR DE ARMARIO",
body: "Acabado: MELAMINA / color: A DETERMINAR\r\nEspesor bloque: 19 mm. / Espesor complementos: 19 mm.\r\nBarras de colgar: CROMO/ NEGRA/ BRONCE\r\nFrentes cristal cajones: RECTO / “U” – FLOAT BRONCE C-424\r\nTirador cajones: FRENTE BAJO / Cajones: 4 DE 180 MM. A DETERMINAR\r\nBaldas deslizantes: X DE 145 MM. A DETERMINAR\r\nCompuesto por:",
},
{
id: 54,
code: "CAP_CLA_SEMI_SUSPENDIDO_ES",
title: "Acabado: MELAMINA / color: A DETERMINAR",
body: "Medida de Hueco: 2450x3000 mm.\r\nEspesor bloque: 25 mm. / Espesor complementos: 25 mm.\r\nBarras de colgar: CROMO/ NEGRA/ BRONCE\r\nFrentes cristal cajones: RECTO / “U” – FLOAT BRONCE C-424\r\nTirador cajones: FRENTE BAJO / Cajones: 4 DE 180 MM. A DETERMINAR\r\nBaldas deslizantes: X DE 145 MM. A DETERMINAR\r\nCompuesto por:",
},
{
id: 2000000108,
code: "CAP_INT_EST_CRISTAL_ES",
title: "Acabado bastidores: ANODIZADO NEGRO",
body: "Acabado panel bastidores laterales: CRISTAL TEMPLADO FLOAT BRONCE C-424\r\nAcabado trasera: CRISTAL TEMPLADO FLOAT BRONCE C-424\r\nAcabado complementos: MELAMINA / Color: ROBLE DENBER M-242\r\nEspesor complementos: 25 mm.\r\nBarras de colgar: NEGRA\r\nFrentes cristal cajones: RECTO / “U” – FLOAT BRONCE C-424 \r\nTirador cajones: xxxxx / Cajones: 4 DE 180 MM.\r\nCompuesto por:",
},
],
en: [
{
id: 2000000098,
code: "CAP_CLA_SEMI_SUSPENDIDO_EN",
title: "Finish: MELAMINE / colour: TO BE DETERMINED",
body: 'Size of opening: 2450x3000 mm.\r\nThickness block: 19 mm / Thickness complements: 19 mm.\r\nHanging rods: CHROME/ BLACK\r\nGlass drawer fronts: STRAIGHT / "U" - FLOAT BRONZE C-424 \r\nDrawer handle: xxxxx / Drawers: 4 DE 180 MM.\r\nShoe rack door: YES/NO / Profile finish: BLACK LACQUER / SILVER MATTE\r\nShoe cabinet door glass finish: FLOAT BRONZE C-424\r\nComposed of:',
},
{
id: 2000000084,
code: "CAP_FRE_ARM_ABATIBLE_EN",
title: "CHARACTERISTICS OF THE FRONT OF THE HINGED WARDROBE",
body: "Size of opening: 2275x1390 mm.\r\nModel: ARGO / Finish: LACQUER / Colour: TO BE DETERMINED\r\nDoor thickness: 22 or 19 mm\r\nDoor finish: SMOOTH \r\nHandle: KNOB TO BE DETERMINED\r\nHardware finish: CHROME OR ANTHRACITE\r\nPerimetral doorposts: VISIBLE, REMETED OR COMBI TO BE DETERMINED.\r\nNumber of doors: 2 \r\nComposed of:",
},
{
id: 2000000089,
code: "CAP_FRE_ARM_CORREDERA_EN",
title: "Size of opening: 2440x5180mm. ",
body: "Model: PANORAMIC P.O. / Finish: LACQUER / Colour: WHITE\r\nNo. of listers: NO LISTELS / Panels finish: LACQUERED PANEL \r\nHandle: \r\nPerimeter frame: \r\nNº of doors: \r\nINCORPORATED HYDRAULIC BRAKE (not available in lower doors of 650 mm.) OR INCORPORATED MAGNETIC BRAKE. \r\nComposed of:",
},
{
id: 2000000092,
code: "CAP_INT_ARM_FORRADO_EN",
title: "Finish: MELAMINE / colour: TO BE DETERMINED",
body: 'Thickness block: 19 mm / Thickness complements: 19 mm.\r\nHanging rods: CHROME/ BLACK\r\nGlass drawer fronts: STRAIGHT / "U" - FLOAT BRONZE C-424 \r\nDrawer handle: xxxxx / Drawers: 4 DE 180 MM.\r\nComposed of:',
},
{
id: 2000000109,
code: "CAP_INT_EST_CRISTAL_EN",
title: "Frames finish: BLACK ANODIZED",
body: 'Side frame panel finish: BRONZE FLOAT BRONZE GLASS C-424\r\nRear panel finish: BRONZE FLOAT GLASS C-424\r\nComplements finish: MELAMIN / Colour: DENBER OAK M-242\r\nThickness of accessories: 25 mm.\r\nHanging bars: BLACK\r\nGlass drawer fronts: STRAIGHT / "U" - FLOAT BRONZE C-424 \r\nDrawer handle: xxxxx / Drawers: 4 DE 180 MM.\r\nComposed of:',
},
{
id: 2000000095,
code: "CAP_SEP_AMBIENTES_EN",
title: "CHARACTERISTICS SEPARATION OF ROOMS",
body: "Size of opening: 2450x3000 mm.\r\nModel: LYN / Finish: ANODISED / Colour: BLACK\r\nNo. of crosspieces: 2 HORIZONTAL CROSSBARS\r\nPanel finish: TRANSPARENT TEMPLARED \r\nHandle: DOUBLE PER DOOR\r\nBrake: DOUBLE PER DOOR (OPENING AND CLOSING)\r\nGuide: VISTA (DOUBLE EMBELLECEDOR)\r\nNumber of panels: 2 SLIDING + 2 FIXED",
},
],
fr: [
{
id: 2000000121,
code: "CAP_CLA_SEMI_SUSPENDIDO_FR",
title: "Finition : MÉLAMINE / Couleur : À DÉTERMINER",
body: "Épaisseur du bloc : 19 mm / Épaisseur des accessoires : 19 mm\r\nBarres de penderie : CHROME / NOIR\r\nFaçades des tiroirs en verre : DROITE / “U” – FLOAT BRONZE C-424\r\nPoignée des tiroirs : xxxxx\r\nTiroirs : 4 DE 180 MM\r\nPorte de chaussures : OUI / NON\r\nFinition du profil : LAQUÉ NOIR / ARGENT MAT\r\nFinition du verre de la porte de chaussures : FLOAT BRONZE C-424\r\nComposé de :",
},
{
id: 2000000123,
code: "CAP_FRE_ARM_ABATIBLE_FR",
title: "Dimension de l’ouverture : 2275x1390 mm",
body: "Modèle : ARGO / Finition : LAQUÉE / Couleur : À DÉTERMINER\r\nÉpaisseur des vantaux : 22 ou 19 mm\r\nFinition des vantaux : LISSES\r\nPoignée : PRISE DE MAIN À DÉTERMINER\r\nFinition des ferrures : CHROMÉ OU ANTHRACITE\r\nJambages périmétraux : APPARENTS, ENCASTRÉS OU COMBINÉS À DÉTERMINER\r\nNombre de vantaux : 2\r\nComposé de :",
},
{
id: 2000000125,
code: "CAP_FRE_ARM_CORREDERA_FR",
title: "Dimension de l’ouverture : 2440x5180mm",
body: "Modèle : PANORAMIC P.O. / Finition : LAQUÉE / Couleur : BLANC\r\nNombre de listels : SANS LISTELS\r\nFinition des panneaux : PANNEAU LAQUÉ\r\nPoignée :\r\nEncadrement périmétral :\r\nNombre de vantaux :\r\nFREIN HYDRAULIQUE INTÉGRÉ (non disponible pour les portes inférieures à 650 mm) OU FREIN MAGNÉTIQUE INTÉGRÉ\r\nComposé de :",
},
{
id: 2000000129,
code: "CAP_INT_ARM_FORRADO_FR",
title: "Finition : MÉLAMINE / Couleur : À DÉTERMINER",
body: "Épaisseur du bloc : 19 mm / Épaisseur des accessoires : 19 mm\r\nBarres de penderie : CHROME / NOIR\r\nFaçades des tiroirs en verre : DROITE / “U” – FLOAT BRONZE C-424\r\nPoignée des tiroirs : xxxxx\r\nTiroirs : 4 DE 180 MM\r\nComposé de :",
},
{
id: 2000000131,
code: "CAP_INT_EST_CRISTAL_FR",
title: "Finition des cadres : ANODISÉ NOIR",
body: "Finition des panneaux des cadres latéraux : VERRE TREMPÉ FLOAT BRONZE C-424\r\nFinition du dos : VERRE TREMPÉ FLOAT BRONZE C-424\r\nFinition des accessoires : MÉLAMINE / Couleur : CHÊNE DENBER M-242\r\nÉpaisseur des accessoires : 25 mm\r\nBarres de penderie : NOIRES\r\nFaçades des tiroirs en verre : DROITE / “U” – FLOAT BRONZE C-424\r\nPoignée des tiroirs : xxxxx\r\nTiroirs : 4 DE 180 MM\r\nComposé de :",
},
],
};
export const BlockList = ({ export const BlockList = ({
onSelect, onSelect,
@ -135,10 +29,10 @@ export const BlockList = ({
listState listState
.filter(({ title, body }) => title || body) .filter(({ title, body }) => title || body)
.sort((a, b) => a.title.localeCompare(b.title)) .sort((a, b) => a.title.localeCompare(b.title))
.map((_block, _blockIdx) => { .map((_block) => {
return ( return (
<button <button
key={_block.id} key={_block.code}
className='flex flex-col items-start gap-2 p-3 text-sm text-left transition-all border rounded-lg hover:bg-accent' className='flex flex-col items-start gap-2 p-3 text-sm text-left transition-all border rounded-lg hover:bg-accent'
onClick={() => onSelect && onSelect(_block, 1)} onClick={() => onSelect && onSelect(_block, 1)}
> >

View File

@ -0,0 +1,38 @@
[
{
"id": 2000000098,
"code": "CAP_CLA_SEMI_SUSPENDIDO_EN",
"title": "Finish: MELAMINE / colour: TO BE DETERMINED",
"body": "Size of opening: 2450x3000 mm.\r\nThickness block: 19 mm / Thickness complements: 19 mm.\r\nHanging rods: CHROME/ BLACK\r\nGlass drawer fronts: STRAIGHT / \"U\" - FLOAT BRONZE C-424 \r\nDrawer handle: xxxxx / Drawers: 4 DE 180 MM.\r\nShoe rack door: YES/NO / Profile finish: BLACK LACQUER / SILVER MATTE\r\nShoe cabinet door glass finish: FLOAT BRONZE C-424\r\nComposed of:"
},
{
"id": 2000000084,
"code": "CAP_FRE_ARM_ABATIBLE_EN",
"title": "CHARACTERISTICS OF THE FRONT OF THE HINGED WARDROBE",
"body": "Size of opening: 2275x1390 mm.\r\nModel: ARGO / Finish: LACQUER / Colour: TO BE DETERMINED\r\nDoor thickness: 22 or 19 mm\r\nDoor finish: SMOOTH \r\nHandle: KNOB TO BE DETERMINED\r\nHardware finish: CHROME OR ANTHRACITE\r\nPerimetral doorposts: VISIBLE, REMETED OR COMBI TO BE DETERMINED.\r\nNumber of doors: 2 \r\nComposed of:"
},
{
"id": 2000000089,
"code": "CAP_FRE_ARM_CORREDERA_EN",
"title": "Size of opening: 2440x5180mm. ",
"body": "Model: PANORAMIC P.O. / Finish: LACQUER / Colour: WHITE\r\nNo. of listers: NO LISTELS / Panels finish: LACQUERED PANEL \r\nHandle: \r\nPerimeter frame: \r\nNº of doors: \r\nINCORPORATED HYDRAULIC BRAKE (not available in lower doors of 650 mm.) OR INCORPORATED MAGNETIC BRAKE. \r\nComposed of:"
},
{
"id": 2000000092,
"code": "CAP_INT_ARM_FORRADO_EN",
"title": "Finish: MELAMINE / colour: TO BE DETERMINED",
"body": "Thickness block: 19 mm / Thickness complements: 19 mm.\r\nHanging rods: CHROME/ BLACK\r\nGlass drawer fronts: STRAIGHT / \"U\" - FLOAT BRONZE C-424 \r\nDrawer handle: xxxxx / Drawers: 4 DE 180 MM.\r\nComposed of:"
},
{
"id": 2000000109,
"code": "CAP_INT_EST_CRISTAL_EN",
"title": "Frames finish: BLACK ANODIZED",
"body": "Side frame panel finish: BRONZE FLOAT BRONZE GLASS C-424\r\nRear panel finish: BRONZE FLOAT GLASS C-424\r\nComplements finish: MELAMIN / Colour: DENBER OAK M-242\r\nThickness of accessories: 25 mm.\r\nHanging bars: BLACK\r\nGlass drawer fronts: STRAIGHT / \"U\" - FLOAT BRONZE C-424 \r\nDrawer handle: xxxxx / Drawers: 4 DE 180 MM.\r\nComposed of:"
},
{
"id": 2000000095,
"code": "CAP_SEP_AMBIENTES_EN",
"title": "CHARACTERISTICS SEPARATION OF ROOMS",
"body": "Size of opening: 2450x3000 mm.\r\nModel: LYN / Finish: ANODISED / Colour: BLACK\r\nNo. of crosspieces: 2 HORIZONTAL CROSSBARS\r\nPanel finish: TRANSPARENT TEMPLARED \r\nHandle: DOUBLE PER DOOR\r\nBrake: DOUBLE PER DOOR (OPENING AND CLOSING)\r\nGuide: VISTA (DOUBLE EMBELLECEDOR)\r\nNumber of panels: 2 SLIDING + 2 FIXED"
}
]

View File

@ -0,0 +1,38 @@
[
{
"id": 50,
"code": "CAP_SEP_AMBIENTES_ES",
"title": "CARACTERISTICAS SEPARACIÓN DE AMBIENTES",
"body": "Medida de Hueco: 2450x3000 mm.\r\nModelo: LYN / Acabado: ANODIZADO / Color: NEGRO\r\nNº de travesaños: 2 TRAVESAÑOS HORIZONTALES\r\nAcabado paneles: TRANSPARENTE TEMPLADO \r\nTirador: DOBLE POR PUERTA\r\nFreno: DOBLE POR PUERTA (APERTURA Y CIERRE)\r\nGuía: VISTA (DOBLE EMBELLECEDOR)\r\nNº de hojas: 2 CORREDERAS + 2 FIJOS"
},
{
"id": 51,
"code": "CAP_FRE_ARM_CORREDERA_ES",
"title": "CARACTERISTICAS FRENTE DE ARMARIO CORREDERA",
"body": "Medida de Hueco:2440x5180mm.\r\nModelo: PANORAMIC P.O. / Acabado: LACADO / Color: BLANCO\r\nNº de lístelos: SIN LISTELOS / Acabado paneles: PANEL LACADO\r\nTirador:\r\nCerco perimetral: \r\nNº de hojas:\r\nFRENO HIDRAULICO INCORPORADO O FRENO MAGNETICO INCORPORADO\r\nCompuesto por:"
},
{
"id": 52,
"code": "CAP_FRE_ARM_ABATIBLE_ES",
"title": "CARACTERISTICAS FRENTE DE ARMARIO",
"body": "Medida de hueco: 2275x1390 mm.\r\nModelo: ARGO / Acabado: LACADO / Color: A DETERMINAR\r\nGrosor hoja: 22 o 19 mm\r\nAcabado de hojas: LISAS\r\nTirador: UÑERO A DETERMINAR\r\nAcabado herrajes: CROMADO O ANTRACITA\r\nJambas perimetrales: VISTAS, REMETIDAS O COMBI A DETERMINAR.\r\nNº de hojas: 2\r\nCompuesto por:"
},
{
"id": 53,
"code": "CAP_INT_ARM_FORRADO_ES",
"title": "CARACTERISTICAS INTERIOR DE ARMARIO",
"body": "Acabado: MELAMINA / color: A DETERMINAR\r\nEspesor bloque: 19 mm. / Espesor complementos: 19 mm.\r\nBarras de colgar: CROMO/ NEGRA/ BRONCE\r\nFrentes cristal cajones: RECTO / “U” – FLOAT BRONCE C-424\r\nTirador cajones: FRENTE BAJO / Cajones: 4 DE 180 MM. A DETERMINAR\r\nBaldas deslizantes: X DE 145 MM. A DETERMINAR\r\nCompuesto por:"
},
{
"id": 54,
"code": "CAP_CLA_SEMI_SUSPENDIDO_ES",
"title": "Acabado: MELAMINA / color: A DETERMINAR",
"body": "Medida de Hueco: 2450x3000 mm.\r\nEspesor bloque: 25 mm. / Espesor complementos: 25 mm.\r\nBarras de colgar: CROMO/ NEGRA/ BRONCE\r\nFrentes cristal cajones: RECTO / “U” – FLOAT BRONCE C-424\r\nTirador cajones: FRENTE BAJO / Cajones: 4 DE 180 MM. A DETERMINAR\r\nBaldas deslizantes: X DE 145 MM. A DETERMINAR\r\nCompuesto por:"
},
{
"id": 2000000108,
"code": "CAP_INT_EST_CRISTAL_ES",
"title": "Acabado bastidores: ANODIZADO NEGRO",
"body": "Acabado panel bastidores laterales: CRISTAL TEMPLADO FLOAT BRONCE C-424\r\nAcabado trasera: CRISTAL TEMPLADO FLOAT BRONCE C-424\r\nAcabado complementos: MELAMINA / Color: ROBLE DENBER M-242\r\nEspesor complementos: 25 mm.\r\nBarras de colgar: NEGRA\r\nFrentes cristal cajones: RECTO / “U” – FLOAT BRONCE C-424 \r\nTirador cajones: xxxxx / Cajones: 4 DE 180 MM.\r\nCompuesto por:"
}
]

View File

@ -0,0 +1,44 @@
[
{
"id": 2000000121,
"code": "CAP_CLA_SEMI_SUSPENDIDO_FR",
"title": "CARACTÉRISTIQUES DRESSING CLASSIQUE / SEMI-SUSPENDU / SUSPENDU",
"body": "Finition : MÉLAMINE / Couleur : À DÉTERMINER\r\nÉpaisseur du bloc : 19 mm / Épaisseur des accessoires : 19 mm\r\nBarres de penderie : CHROME / NOIR\r\nFaçades des tiroirs en verre : DROITE / “U” – FLOAT BRONZE C-424\r\nPoignée des tiroirs : xxxxx\r\nTiroirs : 4 DE 180 MM\r\nPorte de chaussures : OUI / NON\r\nFinition du profil : LAQUÉ NOIR / ARGENT MAT\r\nFinition du verre de la porte de chaussures : FLOAT BRONZE C-424\r\nComposé de :"
},
{
"id": 2000000123,
"code": "CAP_FRE_ARM_ABATIBLE_FR",
"title": "CARACTÉRISTIQUES FAÇADE DE PLACARD BATTANT",
"body": "Dimension de l’ouverture : 2275x1390 mm\r\nModèle : ARGO / Finition : LAQUÉE / Couleur : À DÉTERMINER\r\nÉpaisseur des vantaux : 22 ou 19 mm\r\nFinition des vantaux : LISSES\r\nPoignée : PRISE DE MAIN À DÉTERMINER\r\nFinition des ferrures : CHROMÉ OU ANTHRACITE\r\nJambages périmétraux : APPARENTS, ENCASTRÉS OU COMBINÉS À DÉTERMINER\r\nNombre de vantaux : 2\r\nComposé de :"
},
{
"id": 2000000125,
"code": "CAP_FRE_ARM_CORREDERA_FR",
"title": "CARACTÉRISTIQUES FAÇADE DE PLACARD COULISSANT",
"body": "Dimension de l’ouverture : 2440x5180mm\r\nModèle : PANORAMIC P.O. / Finition : LAQUÉE / Couleur : BLANC\r\nNombre de listels : SANS LISTELS\r\nFinition des panneaux : PANNEAU LAQUÉ\r\nPoignée :\r\nEncadrement périmétral :\r\nNombre de vantaux :\r\nFREIN HYDRAULIQUE INTÉGRÉ (non disponible pour les portes inférieures à 650 mm) OU FREIN MAGNÉTIQUE INTÉGRÉ\r\nComposé de :"
},
{
"id": 2000000129,
"code": "CAP_INT_ARM_FORRADO_FR",
"title": "CARACTÉRISTIQUES INTÉRIEUR DE PLACARD HABILLÉ",
"body": "Finition : MÉLAMINE / Couleur : À DÉTERMINER\r\nÉpaisseur du bloc : 19 mm / Épaisseur des accessoires : 19 mm\r\nBarres de penderie : CHROME / NOIR\r\nFaçades des tiroirs en verre : DROITE / “U” – FLOAT BRONZE C-424\r\nPoignée des tiroirs : xxxxx\r\nTiroirs : 4 DE 180 MM\r\nComposé de :"
},
{
"id": 2000000131,
"code": "CAP_INT_EST_CRISTAL_FR",
"title": "CARACTÉRISTIQUES INTÉRIEUR DE PLACARD STRUCTURE EN VERRE",
"body": "Finition des cadres : ANODISÉ NOIR\r\nFinition des panneaux des cadres latéraux : VERRE TREMPÉ FLOAT BRONZE C-424\r\nFinition du dos : VERRE TREMPÉ FLOAT BRONZE C-424\r\nFinition des accessoires : MÉLAMINE / Couleur : CHÊNE DENBER M-242\r\nÉpaisseur des accessoires : 25 mm\r\nBarres de penderie : NOIRES\r\nFaçades des tiroirs en verre : DROITE / “U” – FLOAT BRONZE C-424\r\nPoignée des tiroirs : xxxxx\r\nTiroirs : 4 DE 180 MM\r\nComposé de :"
},
{
"id": 2000000127,
"code": "CAP_INSTALACION_FR",
"title": "SERVICE D'INSTALLATION - PRIX NET",
"body": "Armoire d'installation x"
},
{
"id": 2000000134,
"code": "CAP_SUMINISTRO_FR",
"title": "SERVICE DE FOURNITURE - PRIX NET",
"body": "Livraison du matériel au domicile du client"
}
]

View File

@ -32,7 +32,7 @@ const getExchangeRateRequestDate = (value: unknown): string | undefined => {
export const QuoteAdditionalCurrencyEditor = () => { export const QuoteAdditionalCurrencyEditor = () => {
const { control, formState, getValues, setValue } = useFormContext(); const { control, formState, getValues, setValue } = useFormContext();
const { formatNumber } = useLocalization(); const { formatNumber, formatCurrencyName } = useLocalization();
const { useExchangeRate } = useQuotes(); const { useExchangeRate } = useQuotes();
const { mutate, isPending } = useExchangeRate(); const { mutate, isPending } = useExchangeRate();
const enabled = Boolean(useWatch({ control, name: "additional_currency_code" })); const enabled = Boolean(useWatch({ control, name: "additional_currency_code" }));
@ -153,13 +153,13 @@ export const QuoteAdditionalCurrencyEditor = () => {
<SelectContent> <SelectContent>
{priorityCurrencies.map((item) => ( {priorityCurrencies.map((item) => (
<SelectItem key={item.code} value={item.code} disabled={item.code === "EUR"}> <SelectItem key={item.code} value={item.code} disabled={item.code === "EUR"}>
{item.code} - {item.name} {item.code} - {formatCurrencyName(item.code)}
</SelectItem> </SelectItem>
))} ))}
<SelectSeparator /> <SelectSeparator />
{remainingCurrencies.map((item) => ( {remainingCurrencies.map((item) => (
<SelectItem key={item.code} value={item.code}> <SelectItem key={item.code} value={item.code}>
{item.code} - {item.name} {item.code} - {formatCurrencyName(item.code)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@ -1,6 +1,6 @@
import { FormGroup } from "@/components"; import { FormGroup } from "@/components";
import { Button, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/ui"; import { Button, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/ui";
import { t } from "i18next"; import { useTranslation } from "react-i18next";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
@ -9,6 +9,7 @@ export const QuoteDocumentsCardEditor = () => {
defaultUploadedFiles: [], defaultUploadedFiles: [],
});*/ });*/
const { t } = useTranslation();
const { control, formState } = useFormContext(); const { control, formState } = useFormContext();
return ( return (
@ -47,7 +48,7 @@ export const QuoteDocumentsCardEditor = () => {
> >
<div className='grid gap-2'> <div className='grid gap-2'>
<img <img
alt='Product image' alt={t("common.product_image")}
className='object-cover w-full rounded-md aspect-square' className='object-cover w-full rounded-md aspect-square'
height='300' height='300'
src='https://placehold.co/300' src='https://placehold.co/300'
@ -56,7 +57,7 @@ export const QuoteDocumentsCardEditor = () => {
<div className='grid grid-cols-3 gap-2'> <div className='grid grid-cols-3 gap-2'>
<button> <button>
<img <img
alt='Product image' alt={t("common.product_image")}
className='object-cover w-full rounded-md aspect-square' className='object-cover w-full rounded-md aspect-square'
height='84' height='84'
src='https://placehold.co/84' src='https://placehold.co/84'
@ -65,7 +66,7 @@ export const QuoteDocumentsCardEditor = () => {
</button> </button>
<button> <button>
<img <img
alt='Product image' alt={t("common.product_image")}
className='object-cover w-full rounded-md aspect-square' className='object-cover w-full rounded-md aspect-square'
height='84' height='84'
src='https://placehold.co/84' src='https://placehold.co/84'

View File

@ -4,10 +4,12 @@ import { ColumnDef } from "@tanstack/react-table";
import { DataTablaRowActionFunction, DataTableRowActions } from "@/components"; import { DataTablaRowActionFunction, DataTableRowActions } from "@/components";
import { Badge } from "@/ui"; import { Badge } from "@/ui";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export const useQuoteDataTableColumns = ( export const useQuoteDataTableColumns = (
actions: DataTablaRowActionFunction<IListQuotes_Response_DTO> actions: DataTablaRowActionFunction<IListQuotes_Response_DTO>
): ColumnDef<IListQuotes_Response_DTO>[] => { ): ColumnDef<IListQuotes_Response_DTO>[] => {
const { t } = useTranslation();
const customerColumns: ColumnDef<IListQuotes_Response_DTO>[] = useMemo( const customerColumns: ColumnDef<IListQuotes_Response_DTO>[] = useMemo(
() => [ () => [
/*{ /*{
@ -42,7 +44,7 @@ export const useQuoteDataTableColumns = (
{ {
id: "state", id: "state",
accessorKey: "state", accessorKey: "state",
header: "Estado", header: t("quotes.list.columns.status"),
cell: ({ renderValue }) => ( cell: ({ renderValue }) => (
<Badge variant={"destructive"}> <Badge variant={"destructive"}>
<>{renderValue()}</> <>{renderValue()}</>
@ -52,19 +54,19 @@ export const useQuoteDataTableColumns = (
{ {
id: "client", id: "client",
accessorKey: "client", accessorKey: "client",
header: "Cliente", header: t("quotes.list.columns.customer_information"),
enableSorting: false, enableSorting: false,
sortingFn: "alphanumeric", sortingFn: "alphanumeric",
}, },
{ {
id: "actions", id: "actions",
header: "Acciones", header: t("common.actions"),
cell: (context) => { cell: (context) => {
return <DataTableRowActions rowContext={context} actions={actions} />; return <DataTableRowActions rowContext={context} actions={actions} />;
}, },
}, },
], ],
[actions] [actions, t]
); );
return customerColumns; return customerColumns;
}; };

View File

@ -18,7 +18,7 @@ import {
IGetQuote_Response_DTO, IGetQuote_Response_DTO,
Language, Language,
} from "@shared/contexts"; } from "@shared/contexts";
import { t } from "i18next"; import { useTranslation } from "react-i18next";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -32,6 +32,7 @@ export type QuoteDataFormItem = IGetQuote_QuoteItem_Response_DTO;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export const QuoteEdit = () => { export const QuoteEdit = () => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const quoteId = useUrlId(); const quoteId = useUrlId();
const { toast } = useToast(); const { toast } = useToast();
@ -90,7 +91,7 @@ export const QuoteEdit = () => {
amount: undefined, amount: undefined,
scale: 0, scale: 0,
}, },
tax_label: "% IVA", tax_label: t("settings.form_fields.default_tax_label.placeholder"),
tax_price: { tax_price: {
amount: undefined, amount: undefined,
scale: 2, scale: 2,
@ -131,7 +132,7 @@ export const QuoteEdit = () => {
}, },
], ],
}), }),
[quoteCurrency.code] [quoteCurrency.code, t]
); );
const { useOne, useUpdate } = useQuotes(); const { useOne, useUpdate } = useQuotes();

View File

@ -27,7 +27,6 @@ import {
Switch, Switch,
} from "@/ui"; } from "@/ui";
import { t } from "i18next";
import { AlertCircleIcon } from "lucide-react"; import { AlertCircleIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { Controller, SubmitHandler, useForm } from "react-hook-form";
@ -47,7 +46,7 @@ export const SettingsEditor = () => {
const [activeSection, setActiveSection] = useState("profile"); const [activeSection, setActiveSection] = useState("profile");
const { useOne, useUpdate, useUploadLogo } = useSettings(); const { useOne, useUpdate, useUploadLogo } = useSettings();
const { toast } = useToast(); const { toast } = useToast();
const { i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { data, status, error: queryError } = useOne(); const { data, status, error: queryError } = useOne();
@ -64,9 +63,9 @@ export const SettingsEditor = () => {
amount: undefined, amount: undefined,
scale: 2, scale: 2,
}, },
default_tax_label: "% IVA", default_tax_label: t("settings.form_fields.default_tax_label.placeholder"),
}), }),
[] [t]
); );
const { mutate } = useUpdate(); const { mutate } = useUpdate();

View File

@ -18,6 +18,8 @@ import {
TableRow, TableRow,
} from "@/ui"; } from "@/ui";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DataTableColumnHeader } from "./DataTableColumnHeader"; import { DataTableColumnHeader } from "./DataTableColumnHeader";
import { DataTablePagination, DataTablePaginationProps } from "./DataTablePagination"; import { DataTablePagination, DataTablePaginationProps } from "./DataTablePagination";
@ -65,6 +67,7 @@ export function DataTable<TData>({
onRowClick, onRowClick,
activeRowIndex, activeRowIndex,
}: DataTableProps<TData>) { }: DataTableProps<TData>) {
const { t } = useTranslation();
const headerVisible = headerOptions?.visible; const headerVisible = headerOptions?.visible;
return ( return (
@ -146,7 +149,7 @@ export function DataTable<TData>({
className={cn("h-24 text-center", cellClassName)} className={cn("h-24 text-center", cellClassName)}
colSpan={table.getAllColumns.length} colSpan={table.getAllColumns.length}
> >
No hay datos para mostrar {t("components.data_table.no_results")}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}

View File

@ -1,5 +1,6 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button, ButtonProps } from "@/ui"; import { Button, ButtonProps } from "@/ui";
import { useTranslation } from "react-i18next";
import { PlusCircleIcon } from "lucide-react"; import { PlusCircleIcon } from "lucide-react";
export interface AddNewRowButtonProps extends ButtonProps { export interface AddNewRowButtonProps extends ButtonProps {
@ -8,23 +9,27 @@ export interface AddNewRowButtonProps extends ButtonProps {
} }
export const AddNewRowButton = ({ export const AddNewRowButton = ({
label = "Añade nueva fila", label,
className, className,
...props ...props
}: AddNewRowButtonProps): JSX.Element => ( }: AddNewRowButtonProps): JSX.Element => {
<Button const { t } = useTranslation();
type="button" const buttonLabel = label ?? t("common.append_empty_row");
variant="outline" return (
size="icon" <Button
className={cn( type="button"
"w-full gap-1 border-dashed text-muted-foreground border-muted-foreground/50", variant="outline"
className size="icon"
)} className={cn(
{...props} "w-full gap-1 border-dashed text-muted-foreground border-muted-foreground/50",
> className
<PlusCircleIcon className={label ? "w-4 h-4 mr-2" : "w-4 h-4"} /> )}
{label && <>{label}</>} {...props}
</Button> >
); <PlusCircleIcon className={buttonLabel ? "w-4 h-4 mr-2" : "w-4 h-4"} />
{buttonLabel && <>{buttonLabel}</>}
</Button>
);
};
AddNewRowButton.displayName = "AddNewRowButton"; AddNewRowButton.displayName = "AddNewRowButton";

View File

@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { import {
Button, Button,
DropdownMenu, DropdownMenu,
@ -31,13 +32,14 @@ export const SortableDataTableToolbar = ({
}: { }: {
table: Table<unknown>; table: Table<unknown>;
}) => { }) => {
const { t } = useTranslation();
const selectedRowsCount = table.getSelectedRowModel().rows.length; const selectedRowsCount = table.getSelectedRowModel().rows.length;
if (selectedRowsCount) { if (selectedRowsCount) {
return ( return (
<div className="flex items-center h-12 p-1 text-white rounded-md bg-primary "> <div className="flex items-center h-12 p-1 text-white rounded-md bg-primary ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button>{`${selectedRowsCount} filas seleccionadas`}</Button> <Button>{t("common.rows_selected", { count: selectedRowsCount })}</Button>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -48,11 +50,11 @@ export const SortableDataTableToolbar = ({
onClick={() => table.options.meta?.duplicateItems()} onClick={() => table.options.meta?.duplicateItems()}
> >
<CopyPlusIcon className="w-4 h-4" /> <CopyPlusIcon className="w-4 h-4" />
<span className="sm:sr-only">Duplicar</span> <span className="sm:sr-only">{t("common.duplicate_selected_rows")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
Duplica las fila(s) seleccionadas(s) {t("common.duplicate_selected_rows_tooltip")}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
@ -64,10 +66,10 @@ export const SortableDataTableToolbar = ({
onClick={() => table.options.meta?.deleteItems()} onClick={() => table.options.meta?.deleteItems()}
> >
<Trash2Icon className="w-4 h-4" /> <Trash2Icon className="w-4 h-4" />
<span className="sm:sr-only">Eliminar</span> <span className="sm:sr-only">{t("common.remove_selected_rows")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Elimina las fila(s) seleccionada(s)</TooltipContent> <TooltipContent>{t("common.remove_selected_rows_tooltip")}</TooltipContent>
</Tooltip> </Tooltip>
<Separator orientation="vertical" className="h-6 mx-1 bg-muted/50" /> <Separator orientation="vertical" className="h-6 mx-1 bg-muted/50" />
<Tooltip> <Tooltip>
@ -79,10 +81,10 @@ export const SortableDataTableToolbar = ({
onClick={() => table.resetRowSelection()} onClick={() => table.resetRowSelection()}
> >
<ScanIcon className="w-4 h-4 md:mr-2" /> <ScanIcon className="w-4 h-4 md:mr-2" />
<span className="sm:sr-only">Quitar selección</span> <span className="sm:sr-only">{t("common.reset_selected_rows")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Quita la selección</TooltipContent> <TooltipContent>{t("common.reset_selected_rows_tooltip")}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@ -101,10 +103,10 @@ export const SortableDataTableToolbar = ({
onClick={() => table.options.meta?.appendItem()} onClick={() => table.options.meta?.appendItem()}
> >
<CirclePlusIcon className="w-4 h-4" /> <CirclePlusIcon className="w-4 h-4" />
<span className="sr-only">Añadir</span> <span className="sr-only">{t("common.add")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Añadir fila</TooltipContent> <TooltipContent>{t("common.append_empty_row")}</TooltipContent>
</Tooltip> </Tooltip>
<Separator <Separator
@ -117,28 +119,28 @@ export const SortableDataTableToolbar = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ClockIcon className="w-4 h-4" /> <ClockIcon className="w-4 h-4" />
<span className="sr-only">Snooze</span> <span className="sr-only">{t("components.sortable_data_table.snooze")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="flex w-[535px] p-0"> <PopoverContent className="flex w-[535px] p-0">
<div className="flex flex-col gap-2 px-2 py-4 border-r"> <div className="flex flex-col gap-2 px-2 py-4 border-r">
<div className="px-4 text-sm font-medium">Snooze until</div> <div className="px-4 text-sm font-medium">{t("components.sortable_data_table.snooze_until")}</div>
<div className="grid min-w-[250px] gap-1"> <div className="grid min-w-[250px] gap-1">
<Button variant="ghost" className="justify-start font-normal"> <Button variant="ghost" className="justify-start font-normal">
Later today{" "} {t("components.sortable_data_table.later_today")}{" "}
<span className="ml-auto text-muted-foreground"></span> <span className="ml-auto text-muted-foreground"></span>
</Button> </Button>
<Button variant="ghost" className="justify-start font-normal"> <Button variant="ghost" className="justify-start font-normal">
Tomorrow {t("components.sortable_data_table.tomorrow")}
<span className="ml-auto text-muted-foreground"></span> <span className="ml-auto text-muted-foreground"></span>
</Button> </Button>
<Button variant="ghost" className="justify-start font-normal"> <Button variant="ghost" className="justify-start font-normal">
This weekend {t("components.sortable_data_table.this_weekend")}
<span className="ml-auto text-muted-foreground"></span> <span className="ml-auto text-muted-foreground"></span>
</Button> </Button>
<Button variant="ghost" className="justify-start font-normal"> <Button variant="ghost" className="justify-start font-normal">
Next week {t("components.sortable_data_table.next_week")}
<span className="ml-auto text-muted-foreground"></span> <span className="ml-auto text-muted-foreground"></span>
</Button> </Button>
</div> </div>
@ -148,7 +150,7 @@ export const SortableDataTableToolbar = ({
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<TooltipContent>Snooze</TooltipContent> <TooltipContent>{t("components.sortable_data_table.snooze")}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
@ -156,28 +158,28 @@ export const SortableDataTableToolbar = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ReplyIcon className="w-4 h-4" /> <ReplyIcon className="w-4 h-4" />
<span className="sr-only">Reply</span> <span className="sr-only">{t("components.sortable_data_table.reply")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Reply</TooltipContent> <TooltipContent>{t("components.sortable_data_table.reply")}</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ReplyAllIcon className="w-4 h-4" /> <ReplyAllIcon className="w-4 h-4" />
<span className="sr-only">Reply all</span> <span className="sr-only">{t("components.sortable_data_table.reply_all")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Reply all</TooltipContent> <TooltipContent>{t("components.sortable_data_table.reply_all")}</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ForwardIcon className="w-4 h-4" /> <ForwardIcon className="w-4 h-4" />
<span className="sr-only">Forward</span> <span className="sr-only">{t("components.sortable_data_table.forward")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Forward</TooltipContent> <TooltipContent>{t("components.sortable_data_table.forward")}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Separator <Separator
@ -188,7 +190,7 @@ export const SortableDataTableToolbar = ({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<MoreVerticalIcon className="w-4 h-4" /> <MoreVerticalIcon className="w-4 h-4" />
<span className="sr-only">Columnas</span> <span className="sr-only">{t("components.data_table.columns")}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">

View File

@ -1,4 +1,5 @@
import { IGetProfileResponse_DTO, ILogin_DTO, ILogin_Response_DTO } from "@shared/contexts"; import { IGetProfileResponse_DTO, ILogin_DTO, ILogin_Response_DTO } from "@shared/contexts";
import { t } from "i18next";
import secureLocalStorage from "react-secure-storage"; import secureLocalStorage from "react-secure-storage";
import { IAuthActions } from "../hooks"; import { IAuthActions } from "../hooks";
import { createAxiosInstance } from "./axiosInstance"; import { createAxiosInstance } from "./axiosInstance";
@ -31,8 +32,8 @@ export const createAxiosAuthActions = (
return { return {
success: false, success: false,
error: { error: {
message: "Login failed", message: t("login_page.invalid_credentials"),
name: "Invalid email or password", name: "InvalidCredentials",
}, },
}; };
} }

View File

@ -1,5 +1,5 @@
import { CustomDialog } from "@/components/CustomDialog"; // Ajusta la ruta según sea necesario import { CustomDialog } from "@/components/CustomDialog"; // Ajusta la ruta según sea necesario
import { t } from "i18next"; import { useTranslation } from "react-i18next";
import { ReactNode, SyntheticEvent, useState } from "react"; import { ReactNode, SyntheticEvent, useState } from "react";
interface DialogConfig { interface DialogConfig {
@ -13,15 +13,18 @@ interface DialogConfig {
export const useCustomDialog = (config?: Partial<DialogConfig>) => { export const useCustomDialog = (config?: Partial<DialogConfig>) => {
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [dialogConfig, setDialogConfig] = useState<DialogConfig | undefined>({ const { t } = useTranslation();
title: "Título", const [dialogOverrides, setDialogConfig] = useState<Partial<DialogConfig>>();
description: "Descripción", const dialogConfig: DialogConfig = {
title: t("common.confirmation"),
description: "",
cancelLabel: t("common.no"), cancelLabel: t("common.no"),
confirmLabel: t("common.yes"), confirmLabel: t("common.yes"),
onCancel: () => {}, onCancel: () => {},
onConfirm: () => {}, onConfirm: () => {},
...config, ...config,
}); ...dialogOverrides,
};
const openDialog = (event?: SyntheticEvent, config?: DialogConfig) => { const openDialog = (event?: SyntheticEvent, config?: DialogConfig) => {
event?.preventDefault(); event?.preventDefault();

View File

@ -76,9 +76,20 @@ export const useCustomLocalization = (props: UseLocalizationProps) => {
[locale] [locale]
); );
const formatCurrencyName = useCallback(
(currencyCode: string) =>
new Intl.NumberFormat(locale ?? "es", {
style: "currency",
currency: currencyCode,
currencyDisplay: "name",
}).formatToParts(1).find((part) => part.type === "currency")?.value ?? currencyCode,
[locale]
);
return { return {
formatCurrency, formatCurrency,
formatNumber, formatNumber,
formatPercentage, formatPercentage,
formatCurrencyName,
}; };
}; };

View File

@ -7,12 +7,55 @@
"reload": "Recarregar" "reload": "Recarregar"
}, },
"common": { "common": {
"today": "Avui" "today": "Avui",
"company": "Empresa",
"phone": "Telèfon",
"name": "Nom",
"identifier": "Identificador",
"confirmation": "Confirmació",
"product_image": "Imatge del producte"
}, },
"title": "Presupuestador para distribuidores", "title": "Presupuestador para distribuidores",
"LoginPage": { "LoginPage": {
"title": "Pressupostador per a distribuïdors", "title": "Pressupostador per a distribuïdors",
"description": "Introdueixi la seva adreça de correu electrònic i contrasenya per accedir-hi" "description": "Introdueixi la seva adreça de correu electrònic i contrasenya per accedir-hi"
},
"app": {
"title": "Pressupostador Uecko",
"javascript_required": "Cal activar JavaScript per utilitzar aquesta aplicació."
},
"error_page": {
"title": "Pàgina no trobada",
"description": "La pàgina que cerques no existeix o s’ha mogut.",
"go_home": "Anar a l’inici"
},
"login_page": {
"invalid_credentials": "El correu electrònic o la contrasenya són incorrectes.",
"validation": {
"email_required": "El correu electrònic és obligatori.",
"email_invalid": "Introdueix un correu electrònic vàlid.",
"password_required": "La contrasenya és obligatòria.",
"password_min": "La contrasenya ha de tenir com a mínim {{count}} caràcters.",
"password_alphanum": "La contrasenya només pot contenir lletres i números."
}
},
"quotes": {
"downloading_dialog": {
"error": "No s’ha pogut descarregar el pressupost. Torna-ho a provar."
}
},
"components": {
"sortable_data_table": {
"snooze": "Ajornar",
"snooze_until": "Ajornar fins a",
"later_today": "Més tard avui",
"tomorrow": "Demà",
"this_weekend": "Aquest cap de setmana",
"next_week": "La setmana vinent",
"reply": "Respondre",
"reply_all": "Respondre a tothom",
"forward": "Reenviar"
}
} }
} }
} }

View File

@ -82,7 +82,13 @@
"button_add_term": "Add filter term", "button_add_term": "Add filter term",
"button_add_term_tooltip": "Add filter term (or press Enter)", "button_add_term_tooltip": "Add filter term (or press Enter)",
"help_text": "Press Enter or click the + button to add a term to the filter. Use multiple terms for a broader search." "help_text": "Press Enter or click the + button to add a term to the filter. Use multiple terms for a broader search."
} },
"company": "Company",
"phone": "Phone",
"name": "Name",
"identifier": "Identifier",
"confirmation": "Confirmation",
"product_image": "Product image"
}, },
"components": { "components": {
"loading_indicator": { "loading_indicator": {
@ -106,6 +112,17 @@
"no_results": "No results found.", "no_results": "No results found.",
"clear_filters": "Clear filters", "clear_filters": "Clear filters",
"selected_count": "{{count}} selected" "selected_count": "{{count}} selected"
},
"sortable_data_table": {
"snooze": "Snooze",
"snooze_until": "Snooze until",
"later_today": "Later today",
"tomorrow": "Tomorrow",
"this_weekend": "This weekend",
"next_week": "Next week",
"reply": "Reply",
"reply_all": "Reply all",
"forward": "Forward"
} }
}, },
"hooks": { "hooks": {
@ -146,7 +163,15 @@
"forgotten_password": "Forgot your password?", "forgotten_password": "Forgot your password?",
"become_dealer": "Do you want to become a Uecko partner?", "become_dealer": "Do you want to become a Uecko partner?",
"contact_us": "Contact us", "contact_us": "Contact us",
"login": "Log in" "login": "Log in",
"invalid_credentials": "The email address or password is incorrect.",
"validation": {
"email_required": "Email is required.",
"email_invalid": "Enter a valid email address.",
"password_required": "Password is required.",
"password_min": "Password must contain at least {{count}} characters.",
"password_alphanum": "Password may only contain letters and numbers."
}
}, },
"dashboard": { "dashboard": {
"welcome": "Welcome" "welcome": "Welcome"
@ -260,7 +285,8 @@
"status_stopped": "stopped" "status_stopped": "stopped"
}, },
"cancel_button": "Cancel the download", "cancel_button": "Cancel the download",
"toast_success": "Quote downloaded" "toast_success": "Quote downloaded",
"error": "The quote could not be downloaded. Please try again."
}, },
"catalog_picker_dialog": { "catalog_picker_dialog": {
"title": "Select catalog items", "title": "Select catalog items",
@ -359,7 +385,11 @@
"label": "Document language", "label": "Document language",
"desc": "Language used to generate the quote PDF", "desc": "Language used to generate the quote PDF",
"placeholder": "", "placeholder": "",
"options": { "es": "Spanish", "en": "English", "fr": "French" } "options": {
"es": "Spanish",
"en": "English",
"fr": "French"
}
}, },
"currency_code": { "currency_code": {
"label": "Currency", "label": "Currency",
@ -378,7 +408,7 @@
}, },
"payment_method": { "payment_method": {
"label": "Payment method", "label": "Payment method",
"placeholder": "placeholder", "placeholder": "",
"desc": "Method of payment for this quote" "desc": "Method of payment for this quote"
}, },
"notes": { "notes": {
@ -569,6 +599,15 @@
} }
}, },
"form_fields": {} "form_fields": {}
},
"app": {
"title": "Uecko quoting tool",
"javascript_required": "You need to enable JavaScript to use this application."
},
"error_page": {
"title": "Page not found",
"description": "The page you are looking for does not exist or has been moved.",
"go_home": "Go to homepage"
} }
} }
} }

View File

@ -82,7 +82,13 @@
"button_add_term": "Añadir término al filtro", "button_add_term": "Añadir término al filtro",
"button_add_term_tooltip": "Añadir término al filtro (o pulsa Enter)", "button_add_term_tooltip": "Añadir término al filtro (o pulsa Enter)",
"help_text": "Presiona Enter o haz clic en el botón + para añadir un término al filtro. Usa múltiples términos para una búsqueda más amplia." "help_text": "Presiona Enter o haz clic en el botón + para añadir un término al filtro. Usa múltiples términos para una búsqueda más amplia."
} },
"company": "Compañía",
"phone": "Teléfono",
"name": "Nombre",
"identifier": "Identificador",
"confirmation": "Confirmación",
"product_image": "Imagen del producto"
}, },
"components": { "components": {
"loading_indicator": { "loading_indicator": {
@ -106,6 +112,17 @@
"no_results": "No se han encontrado resultados.", "no_results": "No se han encontrado resultados.",
"clear_filters": "Limpiar filtros", "clear_filters": "Limpiar filtros",
"selected_count": "{{count}} seleccionados" "selected_count": "{{count}} seleccionados"
},
"sortable_data_table": {
"snooze": "Posponer",
"snooze_until": "Posponer hasta",
"later_today": "Más tarde hoy",
"tomorrow": "Mañana",
"this_weekend": "Este fin de semana",
"next_week": "La próxima semana",
"reply": "Responder",
"reply_all": "Responder a todos",
"forward": "Reenviar"
} }
}, },
"hooks": { "hooks": {
@ -146,7 +163,15 @@
"forgotten_password": "¿Has olvidado tu contraseña?", "forgotten_password": "¿Has olvidado tu contraseña?",
"become_dealer": "¿Quieres ser distribuidor de Uecko?", "become_dealer": "¿Quieres ser distribuidor de Uecko?",
"contact_us": "Contacta con nosotros", "contact_us": "Contacta con nosotros",
"login": "Entrar" "login": "Entrar",
"invalid_credentials": "El correo electrónico o la contraseña no son correctos.",
"validation": {
"email_required": "El correo electrónico es obligatorio.",
"email_invalid": "Introduce un correo electrónico válido.",
"password_required": "La contraseña es obligatoria.",
"password_min": "La contraseña debe tener al menos {{count}} caracteres.",
"password_alphanum": "La contraseña solo puede contener letras y números."
}
}, },
"dashboard": { "dashboard": {
"welcome": "Bienvenido" "welcome": "Bienvenido"
@ -260,7 +285,8 @@
"status_stopped": "parada" "status_stopped": "parada"
}, },
"cancel_button": "Cancelar la descarga", "cancel_button": "Cancelar la descarga",
"toast_success": "Cotización descargada" "toast_success": "Cotización descargada",
"error": "No se ha podido descargar el presupuesto. Vuelve a intentarlo."
}, },
"catalog_picker_dialog": { "catalog_picker_dialog": {
"title": "Seleccionar artículos del catálogo", "title": "Seleccionar artículos del catálogo",
@ -359,7 +385,11 @@
"label": "Idioma del documento", "label": "Idioma del documento",
"desc": "Idioma utilizado para generar el presupuesto en PDF", "desc": "Idioma utilizado para generar el presupuesto en PDF",
"placeholder": "", "placeholder": "",
"options": { "es": "Español", "en": "Inglés", "fr": "Francés" } "options": {
"es": "Español",
"en": "Inglés",
"fr": "Francés"
}
}, },
"currency_code": { "currency_code": {
"label": "Moneda", "label": "Moneda",
@ -378,7 +408,7 @@
}, },
"payment_method": { "payment_method": {
"label": "Forma de pago", "label": "Forma de pago",
"placeholder": "placeholder", "placeholder": "",
"desc": "Forma de pago que se utilizará en esta cotización" "desc": "Forma de pago que se utilizará en esta cotización"
}, },
"notes": { "notes": {
@ -394,7 +424,7 @@
"subtotal_price": { "subtotal_price": {
"label": "Importe neto", "label": "Importe neto",
"placeholder": "", "placeholder": "",
"desc": "" "desc": "Subtotal del presupuesto"
}, },
"discount": { "discount": {
"label": "Descuento (%)", "label": "Descuento (%)",
@ -414,7 +444,7 @@
"before_tax_price": { "before_tax_price": {
"label": "Base imponible", "label": "Base imponible",
"placeholder": "", "placeholder": "",
"desc": "" "desc": "Base imponible"
}, },
"tax": { "tax": {
"label": "Impuesto (%)", "label": "Impuesto (%)",
@ -569,6 +599,15 @@
} }
}, },
"form_fields": {} "form_fields": {}
},
"app": {
"title": "Presupuestador Uecko",
"javascript_required": "Necesitas activar JavaScript para utilizar esta aplicación."
},
"error_page": {
"title": "Página no encontrada",
"description": "La página que buscas no existe o se ha movido.",
"go_home": "Ir al inicio"
} }
} }
} }

View File

@ -21,21 +21,74 @@
"close": "Fermer", "close": "Fermer",
"more": "Plus", "more": "Plus",
"pagination": "Pagination", "pagination": "Pagination",
"breadcrumb": "Fil d’Ariane",
"previous": "Précédent", "previous": "Précédent",
"next": "Suivant", "next": "Suivant",
"more_pages": "Plus de pages",
"previous_slide": "Diapositive précédente",
"next_slide": "Diapositive suivante",
"add": "Ajouter", "add": "Ajouter",
"submit": "Envoyer", "submit": "Envoyer",
"download": "Télécharger", "download": "Télécharger",
"help": "Aide", "help": "Aide",
"select_all": "Tout sélectionner",
"select_row": "Sélectionner la ligne",
"sort_asc": "Croissant",
"sort_asc_description": "Ordre croissant. Cliquez pour trier par ordre décroissant.",
"sort_desc": "Décroissant",
"sort_desc_description": "Ordre décroissant. Cliquez pour trier par ordre croissant.",
"sort_none_description": "Aucun tri. Cliquez pour trier par ordre croissant.",
"rows_selected": "{{count}} ligne(s) sélectionnée(s).",
"rows_selected_of_total": "{{count}} ligne(s) sélectionnée(s) sur {{total}}.",
"rows_per_page": "Lignes par page",
"num_page_of_total": "Page {{count}} sur {{total}}",
"go_to_first_page": "Aller à la première page",
"go_to_prev_page": "Aller à la page précédente",
"go_to_next_page": "Aller à la page suivante",
"go_to_last_page": "Aller à la dernière page",
"error": "Erreur", "error": "Erreur",
"actions": "Actions", "actions": "Actions",
"open_menu": "Ouvrir le menu",
"duplicate_row": "Dupliquer",
"duplicate_selected_rows": "Dupliquer",
"duplicate_selected_rows_tooltip": "Dupliquer les lignes sélectionnées",
"append_empty_row": "Ajouter une ligne",
"append_empty_row_tooltip": "Ajouter une ligne vide",
"append_article": "Ajouter un article",
"append_article_tooltip": "Choisir un article du catalogue et l’ajouter",
"append_block": "Ajouter un bloc de texte",
"append_block_tooltip": "Choisir un bloc de texte et l’ajouter",
"move_row": "Déplacer la ligne",
"remove_row": "Supprimer",
"remove_selected_rows": "Supprimer",
"remove_selected_rows_tooltip": "Supprimer les lignes sélectionnées",
"reset_selected_rows": "Désélectionner",
"reset_selected_rows_tooltip": "Désélectionner les lignes sélectionnées",
"insert_row_above": "Insérer une ligne au-dessus",
"insert_row_below": "Insérer une ligne en dessous",
"pick_date": "Choisir une date",
"today": "Aujourd’hui",
"required_field": "Ce champ est obligatoire",
"edit": "Modifier", "edit": "Modifier",
"remove": "Supprimer", "remove": "Supprimer",
"archive": "Archiver", "archive": "Archiver",
"duplicate": "Dupliquer", "duplicate": "Dupliquer",
"pick_date": "Choisir une date", "disable_preview": "Masquer l’aperçu",
"today": "Aujourd’hui", "enable_preview": "Afficher l’aperçu",
"required_field": "Ce champ est obligatoire" "filter": {
"reset_filter": "Réinitialiser le filtre",
"badget_remove_tooltip": "Retirer ce terme du filtre",
"input_placeholder": "Saisissez du texte pour filtrer…",
"button_add_term": "Ajouter un terme au filtre",
"button_add_term_tooltip": "Ajouter un terme au filtre (ou appuyer sur Entrée)",
"help_text": "Appuyez sur Entrée ou cliquez sur le bouton + pour ajouter un terme au filtre. Utilisez plusieurs termes pour élargir la recherche."
},
"company": "Entreprise",
"phone": "Téléphone",
"name": "Nom",
"identifier": "Identifiant",
"confirmation": "Confirmation",
"product_image": "Image du produit"
}, },
"components": { "components": {
"loading_indicator": { "loading_indicator": {
@ -59,6 +112,17 @@
"no_results": "Aucun résultat trouvé.", "no_results": "Aucun résultat trouvé.",
"clear_filters": "Effacer les filtres", "clear_filters": "Effacer les filtres",
"selected_count": "{{count}} sélectionné(s)" "selected_count": "{{count}} sélectionné(s)"
},
"sortable_data_table": {
"snooze": "Reporter",
"snooze_until": "Reporter jusqu’à",
"later_today": "Plus tard aujourd’hui",
"tomorrow": "Demain",
"this_weekend": "Ce week-end",
"next_week": "La semaine prochaine",
"reply": "Répondre",
"reply_all": "Répondre à tous",
"forward": "Transférer"
} }
}, },
"hooks": { "hooks": {
@ -99,7 +163,15 @@
"forgotten_password": "Mot de passe oublié ?", "forgotten_password": "Mot de passe oublié ?",
"become_dealer": "Vous souhaitez devenir distributeur Uecko ?", "become_dealer": "Vous souhaitez devenir distributeur Uecko ?",
"contact_us": "Contactez-nous", "contact_us": "Contactez-nous",
"login": "Se connecter" "login": "Se connecter",
"invalid_credentials": "L’adresse e-mail ou le mot de passe est incorrect.",
"validation": {
"email_required": "L’adresse e-mail est obligatoire.",
"email_invalid": "Saisissez une adresse e-mail valide.",
"password_required": "Le mot de passe est obligatoire.",
"password_min": "Le mot de passe doit contenir au moins {{count}} caractères.",
"password_alphanum": "Le mot de passe ne peut contenir que des lettres et des chiffres."
}
}, },
"dashboard": { "dashboard": {
"welcome": "Bienvenue" "welcome": "Bienvenue"
@ -128,6 +200,12 @@
"empty_state": { "empty_state": {
"subtitle": "Commencez par créer un devis" "subtitle": "Commencez par créer un devis"
}, },
"status_help": {
"title": "Aide sur le statut des devis",
"intro": "Le statut d’un devis indique sa position actuelle dans le processus commercial.",
"possible": "Les statuts possibles sont :",
"outro": "Utilisez ces statuts pour suivre efficacement vos devis et optimiser votre processus de vente. Le statut Archivé permet de conserver l’historique sans interférer avec les devis actifs."
},
"tabs": { "tabs": {
"all": "Tous", "all": "Tous",
"draft": "Brouillons", "draft": "Brouillons",
@ -207,7 +285,8 @@
"status_stopped": "arrêté" "status_stopped": "arrêté"
}, },
"cancel_button": "Annuler le téléchargement", "cancel_button": "Annuler le téléchargement",
"toast_success": "Devis téléchargé" "toast_success": "Devis téléchargé",
"error": "Le devis n’a pas pu être téléchargé. Veuillez réessayer."
}, },
"catalog_picker_dialog": { "catalog_picker_dialog": {
"title": "Sélectionner des articles du catalogue", "title": "Sélectionner des articles du catalogue",
@ -225,6 +304,64 @@
"description": "Voulez-vous envoyer ce devis à Uecko ? Cette action est irréversible.", "description": "Voulez-vous envoyer ce devis à Uecko ? Cette action est irréversible.",
"toast_status_changed": "Devis envoyé à Uecko" "toast_status_changed": "Devis envoyé à Uecko"
}, },
"quote_status_editor": {
"trigger_button": "Changer le statut",
"title": "Changer le statut du devis",
"status": {
"draft": {
"title": "Brouillon",
"description": "Le devis est en cours de création."
},
"ready": {
"title": "Prêt",
"description": "Le devis est complet et prêt à être envoyé au client."
},
"delivered": {
"title": "Envoyé",
"description": "Le devis a été envoyé au client et une réponse est attendue."
},
"accepted": {
"title": "Accepté",
"description": "Le client a approuvé le devis."
},
"rejected": {
"title": "Refusé",
"description": "Le client n’a pas accepté le devis."
},
"archived": {
"title": "Archivé",
"description": "Le devis a été conservé pour référence future."
}
},
"submit_button": "Changer le statut",
"toast_status_changed": "Statut du devis modifié : {{newStatus}}"
},
"discount_column": {
"label": "Afficher la colonne de remise",
"description": "Afficher ou masquer la colonne de remise dans les lignes.",
"confirmation": {
"title": "Masquer les remises par ligne",
"description": "Certaines lignes comportent une remise. Masquer la colonne ne supprimera pas ces remises : elles continueront à être prises en compte dans le montant du devis, mais ne seront plus affichées dans les lignes ni dans le PDF.",
"confirm": "Masquer la colonne"
}
},
"additional_currency": {
"title": "Devise supplémentaire",
"description": "Afficher, si nécessaire, le montant total du devis dans une autre devise.",
"enabled": "Afficher dans une autre devise",
"currency": "Devise",
"date": "Date du taux de change",
"rate": "Taux de change (1 EUR = …)",
"refresh": "Actualiser le taux de change",
"total": "Total en {{code}}"
},
"inline_row_actions": {
"delete_confirmation": {
"title": "Supprimer la ligne",
"description": "Voulez-vous vraiment supprimer cette ligne ? Cette action est irréversible.",
"confirm": "Supprimer la ligne"
}
},
"status": { "status": {
"draft": "Brouillon", "draft": "Brouillon",
"ready": "Prêt", "ready": "Prêt",
@ -287,13 +424,18 @@
"subtotal_price": { "subtotal_price": {
"label": "Montant net", "label": "Montant net",
"placeholder": "", "placeholder": "",
"desc": "" "desc": "Sous-total du devis"
}, },
"discount": { "discount": {
"label": "Remise (%)", "label": "Remise (%)",
"placeholder": "", "placeholder": "",
"desc": "Pourcentage de remise" "desc": "Pourcentage de remise"
}, },
"discount_value": {
"label": "Remise ({{value}}%)",
"placeholder": "",
"desc": "Pourcentage de remise"
},
"discount_price": { "discount_price": {
"label": "Montant de la remise", "label": "Montant de la remise",
"placeholder": "", "placeholder": "",
@ -302,13 +444,18 @@
"before_tax_price": { "before_tax_price": {
"label": "Base imposable", "label": "Base imposable",
"placeholder": "", "placeholder": "",
"desc": "" "desc": "Base imposable"
}, },
"tax": { "tax": {
"label": "Taxe (%)", "label": "Taxe (%)",
"placeholder": "", "placeholder": "",
"desc": "Pourcentage de taxe" "desc": "Pourcentage de taxe"
}, },
"tax_value": {
"label": "{{label}} ({{value}}%)",
"placeholder": "",
"desc": "Pourcentage de taxe"
},
"tax_price": { "tax_price": {
"label": "Montant de la taxe", "label": "Montant de la taxe",
"placeholder": "", "placeholder": "",
@ -452,6 +599,15 @@
} }
}, },
"form_fields": {} "form_fields": {}
},
"app": {
"title": "Outil de devis Uecko",
"javascript_required": "Vous devez activer JavaScript pour utiliser cette application."
},
"error_page": {
"title": "Page introuvable",
"description": "La page que vous recherchez n’existe pas ou a été déplacée.",
"go_home": "Aller à l’accueil"
} }
} }
} }

View File

@ -0,0 +1,58 @@
import { createInstance } from "i18next";
import es from "./es.json";
import en from "./en.json";
import fr from "./fr.json";
import esBlocks from "../app/quotes/components/editors/BlocksPickerDialog/locales/es.json";
import enBlocks from "../app/quotes/components/editors/BlocksPickerDialog/locales/en.json";
import frBlocks from "../app/quotes/components/editors/BlocksPickerDialog/locales/fr.json";
const flatten = (resource: Record<string, unknown>, prefix = ""): Record<string, string> =>
Object.fromEntries(
Object.entries(resource).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return typeof value === "string"
? [[path, value]]
: Object.entries(flatten(value as Record<string, unknown>, path));
})
);
const variables = (value: string) => [...value.matchAll(/{{\s*(.*?)\s*}}/g)]
.map((match) => match[1])
.sort();
describe("translation resources", () => {
const reference = flatten(es.translation);
it.each([en, fr])("covers every Spanish key and preserves interpolation variables", (resource) => {
const translations = flatten(resource.translation);
expect(Object.keys(translations).sort()).toEqual(Object.keys(reference).sort());
for (const [key, value] of Object.entries(reference)) {
expect({ key, variables: variables(translations[key]) }).toEqual({
key,
variables: variables(value),
});
if (value.trim()) expect({ key, empty: !translations[key].trim() }).toEqual({ key, empty: false });
}
});
it("renders the previously missing French strings without Spanish fallback", async () => {
const i18n = createInstance();
await i18n.init({ lng: "fr", fallbackLng: false, resources: { fr } });
expect(i18n.t("common.append_article")).toBe("Ajouter un article");
expect(i18n.t("common.num_page_of_total", { count: 2, total: 5 })).toBe("Page 2 sur 5");
expect(i18n.t("quotes.quote_status_editor.status.accepted.title")).toBe("Accepté");
expect(i18n.t("quotes.additional_currency.total", { code: "USD" })).toBe("Total en USD");
});
it.each([{ blocks: enBlocks }, { blocks: frBlocks }])("provides every predefined text block in each document language", ({ blocks }) => {
const codes = (entries: Array<{ code: string }>) => entries
.map(({ code }) => code.replace(/_(ES|EN|FR)$/, ""))
.sort();
expect(codes(blocks)).toEqual(codes(esBlocks));
expect(new Set(blocks.map(({ code }) => code)).size).toBe(blocks.length);
for (const block of blocks) {
expect(block.title.trim()).not.toBe("");
expect(block.body.trim()).not.toBe("");
}
});
});

View File

@ -10,6 +10,15 @@ import { viteStaticCopy } from "vite-plugin-static-copy";
const pkg = JSON.parse(readFileSync("./package.json", "utf-8")); const pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const buildId = randomUUID(); const buildId = randomUUID();
const htmlTranslations: Array<{
language: string;
labels: { title: string; javascript_required: string };
}> = ["es", "en", "fr", "ca"].map((language) => ({
language,
labels: JSON.parse(readFileSync(`./src/locales/${language}.json`, "utf8")).translation.app,
}));
const escapeHtml = (value: string) =>
value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const pdfjsDistPath = path.dirname(require.resolve("pdfjs-dist/package.json")); const pdfjsDistPath = path.dirname(require.resolve("pdfjs-dist/package.json"));
const cMapsDir = normalizePath(path.join(pdfjsDistPath, "cmaps")); const cMapsDir = normalizePath(path.join(pdfjsDistPath, "cmaps"));
@ -27,6 +36,21 @@ export default defineConfig({
emptyOutDir: true, emptyOutDir: true,
}, },
plugins: [ plugins: [
{
name: "localized-html",
transformIndexHtml(html) {
return html
.replace("<!-- app-title -->", escapeHtml(htmlTranslations[0].labels.title))
.replace(
"<!-- app-noscript -->",
htmlTranslations
.map(({ language, labels }) =>
`<p lang="${language}">${escapeHtml(labels.javascript_required)}</p>`
)
.join("\n")
);
},
},
{ {
name: "app-build-version", name: "app-build-version",
apply: "build", apply: "build",

View File

@ -1,6 +1,6 @@
{ {
"name": "uecko-presupuestador", "name": "uecko-presupuestador",
"version": "1.2.4", "version": "1.2.5",
"author": "Rodax Software <dev@rodax-software.com>", "author": "Rodax Software <dev@rodax-software.com>",
"license": "ISC", "license": "ISC",
"private": true, "private": true,

View File

@ -1,7 +1,7 @@
{ {
"name": "@uecko-presupuestador/server", "name": "@uecko-presupuestador/server",
"private": true, "private": true,
"version": "1.2.4", "version": "1.2.5",
"author": "Rodax Software <dev@rodax-software.com>", "author": "Rodax Software <dev@rodax-software.com>",
"main": "./src/index.ts", "main": "./src/index.ts",
"scripts": { "scripts": {

View File

@ -1,7 +1,7 @@
{ {
"name": "@uecko-presupuestador/shared", "name": "@uecko-presupuestador/shared",
"private": false, "private": false,
"version": "1.2.4", "version": "1.2.5",
"main": "./index.ts", "main": "./index.ts",
"author": "Rodax Software <dev@rodax-software.com>", "author": "Rodax Software <dev@rodax-software.com>",
"license": "ISC", "license": "ISC",