From 9faec986d7bccc878bf205fe00851b7afe274406 Mon Sep 17 00:00:00 2001 From: David Arranz Date: Thu, 8 Oct 2026 18:07:30 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20agregar=20soporte=20para=20el=20idioma?= =?UTF-8?q?=20franc=C3=A9s=20en=20la=20aplicaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editors/BlocksPickerDialog/BlockList.tsx | 7 +- client/src/app/settings/edit.tsx | 9 +- client/src/lib/languages.tsx | 1 + client/src/locales/en.json | 5 +- client/src/locales/es.json | 5 +- client/src/locales/fr.json | 450 ++++++++++++++++++ client/src/locales/i18n.ts | 2 + .../reporter/ReportQuote.labels.ts | 27 +- .../reporter/ReportQuote.reporter.test.ts | 17 +- .../IUpdateProfile_Request.dto.test.ts | 4 +- .../IUpdateProfile_Request.dto.ts | 2 +- 11 files changed, 508 insertions(+), 21 deletions(-) create mode 100644 client/src/locales/fr.json diff --git a/client/src/app/quotes/components/editors/BlocksPickerDialog/BlockList.tsx b/client/src/app/quotes/components/editors/BlocksPickerDialog/BlockList.tsx index 17205da..dcce970 100644 --- a/client/src/app/quotes/components/editors/BlocksPickerDialog/BlockList.tsx +++ b/client/src/app/quotes/components/editors/BlocksPickerDialog/BlockList.tsx @@ -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 ( diff --git a/client/src/app/settings/edit.tsx b/client/src/app/settings/edit.tsx index 894815f..28e719e 100644 --- a/client/src/app/settings/edit.tsx +++ b/client/src/app/settings/edit.tsx @@ -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 = () => { - {t("settings.form_fields.language.options.es")} - {t("settings.form_fields.language.options.en")} + {documentedLanguages.map((language) => ( + + + ))} )} diff --git a/client/src/lib/languages.tsx b/client/src/lib/languages.tsx index e75006a..ec1a523 100644 --- a/client/src/lib/languages.tsx +++ b/client/src/lib/languages.tsx @@ -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; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 39801ee..d601bd7 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index aff0d9c..583fc8f 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -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": { diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json new file mode 100644 index 0000000..dd507eb --- /dev/null +++ b/client/src/locales/fr.json @@ -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": {} + } + } +} \ No newline at end of file diff --git a/client/src/locales/i18n.ts b/client/src/locales/i18n.ts index ca1add4..1156fc2 100644 --- a/client/src/locales/i18n.ts +++ b/client/src/locales/i18n.ts @@ -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, }, }); diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.labels.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.labels.ts index a4563d3..deda910 100644 --- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.labels.ts +++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.labels.ts @@ -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"; diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.test.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.test.ts index b9c0dd1..d2f4089 100644 --- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.test.ts +++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.test.ts @@ -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(''); - expect(html).toContain("PRESUPUESTO"); - expect(html).toContain("Descripción"); - expect(html).toContain("% IGIC"); + expect(html).toContain(''); + 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"); }); }); diff --git a/shared/lib/contexts/profile/application/dto/UpdateProfile.dto/IUpdateProfile_Request.dto.test.ts b/shared/lib/contexts/profile/application/dto/UpdateProfile.dto/IUpdateProfile_Request.dto.test.ts index 7eb5117..ceeb44d 100644 --- a/shared/lib/contexts/profile/application/dto/UpdateProfile.dto/IUpdateProfile_Request.dto.test.ts +++ b/shared/lib/contexts/profile/application/dto/UpdateProfile.dto/IUpdateProfile_Request.dto.test.ts @@ -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); diff --git a/shared/lib/contexts/profile/application/dto/UpdateProfile.dto/IUpdateProfile_Request.dto.ts b/shared/lib/contexts/profile/application/dto/UpdateProfile.dto/IUpdateProfile_Request.dto.ts index 9863b72..0a65ae0 100644 --- a/shared/lib/contexts/profile/application/dto/UpdateProfile.dto/IUpdateProfile_Request.dto.ts +++ b/shared/lib/contexts/profile/application/dto/UpdateProfile.dto/IUpdateProfile_Request.dto.ts @@ -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;