feat: agregar soporte para el idioma francés en la aplicación

This commit is contained in:
David Arranz 2026-10-08 18:07:30 +02:00
parent 7e7044ac1c
commit 9faec986d7
11 changed files with 508 additions and 21 deletions

View File

@ -1,5 +1,4 @@
import { ScrollArea } from "@/ui";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export type BlockDataType = {
@ -99,10 +98,8 @@ export const BlockList = ({
onSelect: (data: BlockDataType, quantity: number) => void;
}) => {
const { i18n } = useTranslation();
const [language] = useState(i18n.resolvedLanguage || "es");
const [listState] = useState(blockData[language.toLowerCase()]);
console.log(language);
const language = i18n.resolvedLanguage || "es";
const listState = blockData[language.toLowerCase()] ?? blockData.es;
return (
<ScrollArea className='h-96'>

View File

@ -35,6 +35,7 @@ import { Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import { useUnsavedChangesNotifier } from "@/lib/hooks";
import { documentedLanguages } from "@/lib/languages";
import { cn } from "@/lib/utils";
import { useToast } from "@/ui/use-toast";
import { IUpdateProfile_Request_DTO } from "@shared/contexts";
@ -219,8 +220,12 @@ export const SettingsEditor = () => {
<SelectValue placeholder={t("settings.form_fields.language.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value='es'>{t("settings.form_fields.language.options.es")}</SelectItem>
<SelectItem value='en'>{t("settings.form_fields.language.options.en")}</SelectItem>
{documentedLanguages.map((language) => (
<SelectItem key={language.code} value={language.code}>
<span aria-hidden='true' className={`mr-2 ${language.flagClassName}`} />
{language.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}

View File

@ -4,4 +4,5 @@ import "/node_modules/flag-icons/css/flag-icons.min.css";
export const documentedLanguages = [
{ code: "es", name: "Español", flagClassName: "fi fi-es" },
{ code: "en", name: "English", flagClassName: "fi fi-gb" },
{ code: "fr", name: "Français", flagClassName: "fi fi-fr" },
] as const;

View File

@ -342,7 +342,7 @@
"label": "Document language",
"desc": "Language used to generate the quote PDF",
"placeholder": "",
"options": { "es": "Spanish", "en": "English" }
"options": { "es": "Spanish", "en": "English", "fr": "French" }
},
"currency_code": {
"label": "Currency",
@ -469,7 +469,8 @@
"desc": "Preferred language for the interface",
"options": {
"es": "Spanish",
"en": "English"
"en": "English",
"fr": "French"
}
},
"logo": {

View File

@ -342,7 +342,7 @@
"label": "Idioma del documento",
"desc": "Idioma utilizado para generar el presupuesto en PDF",
"placeholder": "",
"options": { "es": "Español", "en": "Inglés" }
"options": { "es": "Español", "en": "Inglés", "fr": "Francés" }
},
"currency_code": {
"label": "Moneda",
@ -469,7 +469,8 @@
"desc": "Idioma preferido para la interfaz",
"options": {
"es": "Español",
"en": "Inglés"
"en": "Inglés",
"fr": "Francés"
}
},
"logo": {

450
client/src/locales/fr.json Normal file
View File

@ -0,0 +1,450 @@
{
"translation": {
"common": {
"required": "obligatoire",
"cancel": "Annuler",
"no": "Non",
"yes": "Oui",
"save": "Enregistrer",
"save_close": "Enregistrer et fermer",
"discard": "Abandonner",
"hide": "Masquer",
"back": "Retour",
"upload": "Téléverser",
"continue": "Continuer",
"close": "Fermer",
"more": "Plus",
"pagination": "Pagination",
"previous": "Précédent",
"next": "Suivant",
"add": "Ajouter",
"submit": "Envoyer",
"download": "Télécharger",
"help": "Aide",
"error": "Erreur",
"actions": "Actions",
"edit": "Modifier",
"remove": "Supprimer",
"archive": "Archiver",
"duplicate": "Dupliquer",
"pick_date": "Choisir une date",
"required_field": "Ce champ est obligatoire"
},
"components": {
"loading_indicator": {
"title": "Chargement…"
},
"loading_overlay": {
"title": "Chargement…",
"subtitle": "Cela peut prendre quelques secondes. Veuillez ne pas fermer cette page."
},
"error_overlay": {
"title": "Une erreur s’est produite",
"go_home": "Aller à l’accueil"
},
"empty_state": {
"title": "C’est très vide ici",
"subtitle": "Commencez par demander le chargement du catalogue",
"button": "Demander le chargement du catalogue"
},
"data_table": {
"columns": "Colonnes",
"no_results": "Aucun résultat trouvé.",
"clear_filters": "Effacer les filtres",
"selected_count": "{{count}} sélectionné(s)"
}
},
"hooks": {
"use_unsaved_changes_notifier": {
"title": "Des modifications ont été détectées",
"subtitle": "Attention, des modifications ne sont pas enregistrées sur cette page.\nSi vous continuez, vous les perdrez.",
"confirm_text": "Continuer",
"cancel_text": "Ne pas continuer"
}
},
"main_menu": {
"toggle_navigation": "Basculer le menu de navigation",
"settings": "Paramètres",
"catalog": "Catalogue",
"quotes": "Devis",
"search_placeholder": "Rechercher des produits, devis, etc.",
"user": {
"user_menu": "Menu utilisateur",
"my_account": "Mon compte",
"profile": "Profil",
"settings": "Paramètres",
"support": "Assistance",
"logout": "Se déconnecter"
},
"logout_dialog": {
"title": "Se déconnecter",
"description": "Voulez-vous vous déconnecter ?",
"confirm_label": "Se déconnecter",
"cancel_label": "Annuler"
}
},
"login_page": {
"title": "Outil de devis pour distributeurs",
"description": "Saisissez votre adresse e-mail et votre mot de passe pour accéder",
"email_label": "E-mail",
"email_placeholder": "monemail@exemple.com",
"password_label": "Mot de passe",
"forgotten_password": "Mot de passe oublié ?",
"become_dealer": "Vous souhaitez devenir distributeur Uecko ?",
"contact_us": "Contactez-nous",
"login": "Se connecter"
},
"dashboard": {
"welcome": "Bienvenue"
},
"catalog": {
"list": {
"title": "Catalogue d’articles",
"empty_state": {
"subtitle": "Commencez par charger les articles du catalogue"
},
"columns": {
"description": "Description",
"points": "Points",
"retail_price": "Prix public",
"quantity": "Quantité"
}
}
},
"quotes": {
"list": {
"title": "Devis",
"subtitle": "",
"tabs_title": "États",
"select_status": "Sélectionner un état",
"preview_aria": "Afficher l’aperçu du devis",
"empty_state": {
"subtitle": "Commencez par créer un devis"
},
"tabs": {
"all": "Tous",
"draft": "Brouillons",
"ready": "Prêts",
"delivered": "Livrés",
"accepted": "Acceptés",
"rejected": "Refusés",
"archived": "Archivés"
},
"columns": {
"date": "Date",
"date_sent": "Envoyé à Uecko",
"reference": "Référence",
"status": "État",
"customer_reference": "Réf. client",
"customer_information": "Client",
"total_price": "Montant total"
},
"resume": {
"title": "Devis",
"tabs": {
"resume": "Résumé",
"preview": "Aperçu"
},
"quote_information": "Informations",
"customer_information": "Client",
"price_information": "Montants",
"select_quote": "Sélectionnez un devis"
}
},
"notifications": {
"created": "Devis créé",
"saved": "Devis enregistré",
"saving": "Enregistrement du devis"
},
"pdf_generation": {
"title": "Génération du PDF",
"error": "Erreur lors de la génération du PDF",
"progress": "Progression : {{percentage}}%",
"elapsed": "Temps écoulé : {{elapsed}} secondes",
"remaining": "Temps restant estimé : {{estimatedTime}} secondes",
"cancel": "Annuler la génération"
},
"create": {
"title": "Nouveau devis",
"form_groups": {
"general": {
"title": "Données générales",
"desc": "Données générales et client auquel le devis est destiné"
},
"status": {
"title": "État",
"desc": "État du devis"
},
"documents": {
"title": "Documents",
"desc": "Ajoutez à votre devis des documents tels que photos, plans, croquis, etc.",
"images": "Images"
}
}
},
"edit": {
"title": "Devis",
"buttons": {
"save_quote": "Enregistrer le devis"
}
},
"duplicate_action": {
"toast_success": "Devis dupliqué"
},
"downloading_dialog": {
"title": "Téléchargement du devis",
"description": "Veuillez patienter pendant la génération et le téléchargement du devis au format PDF…",
"progress": {
"label": "Le téléchargement est {{status}}",
"status_in_progress": "en cours",
"status_stopped": "arrêté"
},
"cancel_button": "Annuler le téléchargement",
"toast_success": "Devis téléchargé"
},
"catalog_picker_dialog": {
"title": "Sélectionner des articles du catalogue",
"description": "Pour compléter votre devis, vous pouvez ajouter des articles du catalogue.",
"toast_article_added": "Article du catalogue ajouté :"
},
"blocks_picker_dialog": {
"title": "Sélectionner des blocs de texte",
"description": "Pour vous aider à compléter le devis, vous pouvez ajouter des blocs de texte prédéfinis de cette liste.",
"toast_article_added": "Bloc de texte ajouté :"
},
"quote_sent_to_editor": {
"trigger_button": "Envoyer à Uecko",
"title": "Envoyer le devis à Uecko",
"description": "Voulez-vous envoyer ce devis à Uecko ? Cette action est irréversible.",
"toast_status_changed": "Devis envoyé à Uecko"
},
"status": {
"draft": "Brouillon",
"ready": "Prêt",
"delivered": "Livré",
"accepted": "Accepté",
"rejected": "Refusé",
"archived": "Archivé"
},
"form_fields": {
"date": {
"label": "Date",
"desc": "Date de ce devis",
"placeholder": ""
},
"reference": {
"label": "Référence",
"desc": "Référence de ce devis",
"placeholder": ""
},
"lang_code": {
"label": "Langue du document",
"desc": "Langue utilisée pour générer le devis au format PDF",
"placeholder": "",
"options": {
"es": "Espagnol",
"en": "Anglais",
"fr": "Français"
}
},
"currency_code": {
"label": "Devise",
"desc": "Devise du devis",
"placeholder": ""
},
"customer_reference": {
"label": "Référence client",
"desc": "Référence client de ce devis",
"placeholder": ""
},
"customer_information": {
"label": "Informations client",
"desc": "Indiquez le nom, l’adresse, le code postal et la ville du client.",
"placeholder": "Nom et prénom\nRue et numéro\nCode postal et ville…"
},
"payment_method": {
"label": "Mode de paiement",
"placeholder": "",
"desc": "Mode de paiement utilisé pour ce devis"
},
"notes": {
"label": "Notes",
"placeholder": "",
"desc": "Notes à inclure dans le devis"
},
"validity": {
"label": "Validité du devis",
"placeholder": "",
"desc": "Validité de ce devis"
},
"subtotal_price": {
"label": "Montant net",
"placeholder": "",
"desc": ""
},
"discount": {
"label": "Remise (%)",
"placeholder": "",
"desc": "Pourcentage de remise"
},
"discount_price": {
"label": "Montant de la remise",
"placeholder": "",
"desc": "Montant de la remise"
},
"before_tax_price": {
"label": "Base imposable",
"placeholder": "",
"desc": ""
},
"tax": {
"label": "Taxe (%)",
"placeholder": "",
"desc": "Pourcentage de taxe"
},
"tax_price": {
"label": "Montant de la taxe",
"placeholder": "",
"desc": "Montant de la taxe"
},
"total_price": {
"label": "Montant total",
"placeholder": "",
"desc": "Montant total"
},
"items": {
"quantity": {
"label": "Quantité",
"placeholder": "",
"desc": ""
},
"description": {
"label": "Description",
"placeholder": "",
"desc": ""
},
"unit_price": {
"label": "Prix unitaire",
"placeholder": "",
"desc": "Prix unitaire de l’article"
},
"subtotal_price": {
"label": "Sous-total",
"placeholder": "",
"desc": ""
},
"discount": {
"label": "Remise (%)",
"placeholder": "",
"desc": "Pourcentage de remise"
},
"total_price": {
"label": "Montant total",
"placeholder": "",
"desc": "Montant total après application de la remise"
}
}
}
},
"settings": {
"edit": {
"title": "Paramètres",
"tabs": {
"profile": "Paramètres du profil",
"quotes": "Paramètres des devis",
"legal": "Paramètres juridiques"
}
},
"form_fields": {
"language": {
"label": "Langue",
"placeholder": "Sélectionnez une langue",
"desc": "Langue préférée de l’interface",
"options": {
"es": "Espagnol",
"en": "Anglais",
"fr": "Français"
}
},
"logo": {
"label": "Logo",
"desc": "Ce logo apparaîtra dans les propositions exportées au format PDF",
"requirements": "Les fichiers JPG ou PNG sont acceptés.",
"upload": "Téléverser le logo",
"invalid_format": "Seuls les fichiers JPG ou PNG sont acceptés",
"invalid_dimensions": "Les dimensions du logo sont invalides."
},
"contact_information": {
"label": "Informations de contact",
"placeholder": "",
"desc": "Ces coordonnées apparaîtront dans les devis remis à vos clients."
},
"default_tax": {
"label": "Taxe par défaut (%)",
"placeholder": "",
"desc": "Pourcentage de taxe par défaut dans les devis"
},
"default_tax_label": {
"placeholder": "% TVA",
"desc": "Libellé affiché à côté de la taxe"
},
"default_show_line_discount_column": {
"label": "Afficher la colonne de remise par défaut",
"desc": "Les nouveaux devis afficheront initialement la colonne de remise."
},
"default_legal_terms": {
"label": "Clauses juridiques",
"placeholder": "",
"desc": "Texte juridique à inclure dans les nouveaux devis"
},
"default_payment_method": {
"label": "Mode de paiement",
"placeholder": "",
"desc": "Texte utilisé dans les nouveaux devis"
},
"default_notes": {
"label": "Notes",
"placeholder": "",
"desc": "Notes à inclure dans les nouveaux devis"
},
"default_quote_validity": {
"label": "Validité par défaut",
"placeholder": "",
"desc": "Durée de validité du devis"
}
},
"notifications": {
"saved": "Paramètres enregistrés",
"saving": "Enregistrement des paramètres"
}
},
"support": {
"modal": {
"title": "Envoyer une incidence",
"subtitle": "Utilisez ce formulaire pour signaler un problème rencontré pendant l’utilisation de l’application.",
"open": "Ouvrir la fenêtre d’assistance",
"placeholder": "Décrivez l’incidence ici…",
"submit": "Envoyer l’incidence",
"validation": {
"empty": "Vous devez écrire quelque chose avant d’envoyer",
"min": "Le texte est trop court.",
"max": "Le texte est trop long.",
"required": "La description est obligatoire"
},
"notifications": {
"sent_title": "Incident envoyé",
"sent_description": "L’incident a été envoyé correctement",
"error_title": "Erreur d’envoi",
"error_description": "L’incident n’a pas pu être envoyé. Veuillez réessayer."
},
"cancel_confirmation": {
"title": "Voulez-vous annuler ?",
"description": "Vous perdrez les modifications non enregistrées.",
"back": "Retour au formulaire",
"confirm": "Oui, fermer"
}
},
"form_fields": {}
}
}
}

View File

@ -4,6 +4,7 @@ import { initReactI18next } from "react-i18next";
import catResources from "./ca.json";
import enResources from "./en.json";
import esResources from "./es.json";
import frResources from "./fr.json";
i18n
// detect user language
@ -26,6 +27,7 @@ i18n
ca: catResources,
en: enResources,
es: esResources,
fr: frResources,
},
});

View File

@ -49,9 +49,34 @@ export const reportLabels = {
percentage_discount: "% Discount",
taxable_amount: "Taxable amount",
},
fr: {
official_distributor: "DISTRIBUTEUR OFFICIEL",
dealer_logo: "Logo du distributeur",
document_title: "DEVIS",
browser_title: "Devis",
quote_number: "Devis n° :",
date: "Date :",
validity: "Validité :",
salesperson: "Commercial :",
customer_reference: "Référence client :",
page: "Page",
of: "sur",
quantity: "Qté",
description: "Description",
unit_price: "Prix unitaire",
subtotal: "Sous-total",
discount: "Remise (%)",
total_amount: "Montant total",
payment_method: "Mode de paiement :",
notes: "Notes :",
net_amount: "Montant net",
discounts: "Remises",
percentage_discount: "% de remise",
taxable_amount: "Base imposable",
},
} as const;
export type ReportLanguage = keyof typeof reportLabels;
export const resolveReportLanguage = (language: string): ReportLanguage =>
language === "en" ? "en" : "es";
language === "en" || language === "fr" ? language : "es";

View File

@ -1,5 +1,6 @@
import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
import { createQuoteMapper } from "@/contexts/sales/infrastructure/mappers/quote.mapper";
import { resolveReportLanguage } from "./ReportQuote.labels";
import { ReportQuotePresenter } from "./ReportQuote.reporter";
const source = (
@ -69,12 +70,16 @@ describe("ReportQuotePresenter", () => {
expect(html).not.toContain(">PRESUPUESTO<");
});
it("falls back to Spanish labels and language for unsupported quote languages", async () => {
const html = await ReportQuotePresenter.toHTML(mapper.mapToDomain(source(false, 0, "% IGIC", "fr")), context);
it("renders French report labels", async () => {
const html = await ReportQuotePresenter.toHTML(mapper.mapToDomain(source(false, 0, "% TVA", "fr")), context);
expect(html).toContain('<html lang="es">');
expect(html).toContain("PRESUPUESTO");
expect(html).toContain("Descripción");
expect(html).toContain("% IGIC");
expect(html).toContain('<html lang="fr">');
expect(html).toContain("DEVIS");
expect(html).toContain("Description");
expect(html).toContain("% TVA");
});
it("falls back to Spanish labels and language for unsupported quote languages", async () => {
expect(resolveReportLanguage("zz")).toBe("es");
});
});

View File

@ -26,13 +26,13 @@ describe("ensureUpdateProfile_Request_DTOIsValid default_tax_label", () => {
).toHaveProperty("isFailure", true);
});
it.each([undefined, "es", "en"])('accepts interface language %p', (lang_code) => {
it.each([undefined, "es", "en", "fr"])('accepts interface language %p', (lang_code) => {
expect(
ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, lang_code })
).toHaveProperty("isSuccess", true);
});
it.each(["ca", "fr", "zz"])('rejects unsupported interface language %p', (lang_code) => {
it.each(["ca", "zz"])('rejects unsupported interface language %p', (lang_code) => {
expect(
ensureUpdateProfile_Request_DTOIsValid({ ...validProfile, lang_code })
).toHaveProperty("isFailure", true);

View File

@ -1,7 +1,7 @@
import Joi from "joi";
import { IPercentage_DTO, Result, RuleValidator } from "../../../../common";
export const SUPPORTED_INTERFACE_LANGUAGE_CODES = ["es", "en"] as const;
export const SUPPORTED_INTERFACE_LANGUAGE_CODES = ["es", "en", "fr"] as const;
export interface IUpdateProfile_Request_DTO {
lang_code?: string;