From c5aff5d2cb56ee13e52f7ed31feae4d251808e7d Mon Sep 17 00:00:00 2001 From: David Arranz Date: Wed, 14 Oct 2026 12:00:19 +0200 Subject: [PATCH] v1.2.5 --- bloques.xml | 354 ------------------ client/index.html | 8 +- client/package.json | 2 +- client/src/App.tsx | 26 +- client/src/Routes.tsx | 2 +- client/src/app/ErrorPage.tsx | 11 +- client/src/app/auth/LoginPage.tsx | 38 +- .../auth/LoginPageWithLanguageSelector.tsx | 33 +- client/src/app/auth/loginSchema.test.ts | 42 +++ client/src/app/auth/loginSchema.ts | 26 ++ .../components/useCatalogTableColumns.tsx | 12 +- .../dealers/components/DealerDataTable.tsx | 7 +- .../components/useCatalogTableColumns.tsx | 12 +- .../quotes/components/DownloadQuoteDialog.tsx | 5 +- .../editors/BlocksPickerDialog/BlockList.tsx | 120 +----- .../BlocksPickerDialog/locales/en.json | 38 ++ .../BlocksPickerDialog/locales/es.json | 38 ++ .../BlocksPickerDialog/locales/fr.json | 44 +++ .../editors/QuoteAdditionalCurrencyEditor.tsx | 6 +- .../editors/QuoteDocumentsCardEditor.tsx | 9 +- .../components/useQuoteDataTableColumns.tsx | 10 +- client/src/app/quotes/edit.tsx | 7 +- client/src/app/settings/edit.tsx | 7 +- client/src/components/DataTable/DataTable.tsx | 5 +- .../SorteableDataTable/AddNewRowButton.tsx | 37 +- .../SortableDataTableToolbar.tsx | 48 +-- .../src/lib/axios/createAxiosAuthActions.ts | 5 +- .../hooks/useCustomDialog/useCustomDialog.tsx | 13 +- .../useLocalization/useLocatlization.tsx | 11 + client/src/locales/ca.json | 45 ++- client/src/locales/en.json | 49 ++- client/src/locales/es.json | 53 ++- client/src/locales/fr.json | 170 ++++++++- client/src/locales/resources.test.ts | 58 +++ client/vite.config.ts | 24 ++ package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 38 files changed, 759 insertions(+), 622 deletions(-) delete mode 100644 bloques.xml create mode 100644 client/src/app/auth/loginSchema.test.ts create mode 100644 client/src/app/auth/loginSchema.ts create mode 100644 client/src/app/quotes/components/editors/BlocksPickerDialog/locales/en.json create mode 100644 client/src/app/quotes/components/editors/BlocksPickerDialog/locales/es.json create mode 100644 client/src/app/quotes/components/editors/BlocksPickerDialog/locales/fr.json create mode 100644 client/src/locales/resources.test.ts diff --git a/bloques.xml b/bloques.xml deleted file mode 100644 index ba9b109..0000000 --- a/bloques.xml +++ /dev/null @@ -1,354 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/index.html b/client/index.html index 3abfc8a..78bff20 100644 --- a/client/index.html +++ b/client/index.html @@ -1,5 +1,5 @@ - + @@ -7,13 +7,13 @@ - Presupuestador Uecko + <!-- app-title --> - +
- \ No newline at end of file + diff --git a/client/package.json b/client/package.json index db2b897..31b79ca 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "@uecko-presupuestador/client", "private": true, - "version": "1.2.4", + "version": "1.2.5", "author": "Rodax Software ", "type": "module", "scripts": { diff --git a/client/src/App.tsx b/client/src/App.tsx index 164422c..c03f3fd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,8 +2,8 @@ import { AuthProvider, ThemeProvider, UnsavedWarnProvider } from "@/lib/hooks"; import { Toaster, TooltipProvider } from "@/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { Suspense } from "react"; -import { I18nextProvider } from "react-i18next"; +import { Suspense, useEffect, useState } from "react"; +import { I18nextProvider, useTranslation } from "react-i18next"; import { Routes } from "./Routes"; import { LoadingOverlay, TailwindIndicator } from "./components"; import { createAxiosAuthActions, createAxiosDataProvider } from "./lib/axios"; @@ -12,14 +12,22 @@ import i18n from "./locales/i18n.ts"; import { AppUpdateNotice } from "./components/AppUpdateNotice"; function App() { - const queryClient = 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 + const { t } = useTranslation(); + useEffect(() => { + document.title = t("app.title"); + document.documentElement.lang = i18n.resolvedLanguage || "es"; + }, [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 ( diff --git a/client/src/Routes.tsx b/client/src/Routes.tsx index b90177b..34e0fa6 100644 --- a/client/src/Routes.tsx +++ b/client/src/Routes.tsx @@ -24,7 +24,7 @@ export const Routes = () => { const routesForErrors = [ { path: "*", - Element: ( + element: ( }> diff --git a/client/src/app/ErrorPage.tsx b/client/src/app/ErrorPage.tsx index 06ef747..6fa4e7b 100644 --- a/client/src/app/ErrorPage.tsx +++ b/client/src/app/ErrorPage.tsx @@ -1,19 +1,22 @@ import { Button } from "@/ui"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; export const ErrorPage = () => { + const { t } = useTranslation(); return (

- Oops, page not found! + {t("error_page.title")}

- The page you're looking for doesn't exist or has been moved. + {t("error_page.description")}

-
diff --git a/client/src/app/auth/LoginPage.tsx b/client/src/app/auth/LoginPage.tsx index 956c13a..7d61e6a 100644 --- a/client/src/app/auth/LoginPage.tsx +++ b/client/src/app/auth/LoginPage.tsx @@ -15,22 +15,22 @@ import { } from "@/ui"; import { joiResolver } from "@hookform/resolvers/joi"; import { ILogin_DTO } from "@shared/contexts"; -import { t } from "i18next"; -import Joi from "joi"; import { AlertCircleIcon } from "lucide-react"; +import { useEffect } from "react"; 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 SpanishJoiMessages from "../../spanish-joi-messages.json"; +import { createLoginSchema } from "./loginSchema"; type LoginDataForm = ILogin_DTO; export const LoginPage = () => { + const { t, i18n } = useTranslation(); const { mutate: login } = useLogin({ onSuccess: (data) => { const { success, error } = data; if (!success && error) { - form.setError("root", error); + form.setError("root", { type: error.name, message: error.message }); } }, onError: (error) => { @@ -44,19 +44,17 @@ export const LoginPage = () => { email: "", password: "", }, - resolver: joiResolver( - Joi.object({ - email: Joi.string() - .email({ tlds: { allow: false } }) - .required(), - password: Joi.string().min(4).alphanum().required(), - }), - { - messages: SpanishJoiMessages, - } - ), + resolver: joiResolver(createLoginSchema(t)), }); + 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 = async (data) => { login({ email: data.email, password: data.password }, {}); }; @@ -113,7 +111,11 @@ export const LoginPage = () => { - {form.formState.errors.root?.message} + + {form.formState.errors.root?.type === "InvalidCredentials" + ? t("login_page.invalid_credentials") + : form.formState.errors.root?.message} + )} @@ -138,7 +140,7 @@ export const LoginPage = () => {
Image { @@ -56,7 +55,7 @@ export const LoginPageWithLanguageSelector = () => { onSuccess: (data) => { const { success, error } = data; if (!success && error) { - form.setError("root", error); + form.setError("root", { type: error.name, message: error.message }); } if (success) { @@ -76,19 +75,17 @@ export const LoginPageWithLanguageSelector = () => { email: "", password: "", }, - resolver: joiResolver( - Joi.object({ - email: Joi.string() - .email({ tlds: { allow: false } }) - .required(), - password: Joi.string().min(4).alphanum().required(), - }), - { - messages: SpanishJoiMessages, - } - ), + resolver: joiResolver(createLoginSchema(t)), }); + 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 = async (data) => { login({ email: data.email, password: data.password }, {}); }; @@ -172,7 +169,11 @@ export const LoginPageWithLanguageSelector = () => { - {form.formState.errors.root?.message} + + {form.formState.errors.root?.type === "InvalidCredentials" + ? t("login_page.invalid_credentials") + : form.formState.errors.root?.message} + )} @@ -201,7 +202,7 @@ export const LoginPageWithLanguageSelector = () => {
Image { + 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); + }); +}); diff --git a/client/src/app/auth/loginSchema.ts b/client/src/app/auth/loginSchema.ts new file mode 100644 index 0000000..fb15ef9 --- /dev/null +++ b/client/src/app/auth/loginSchema.ts @@ -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"), + }), + }); diff --git a/client/src/app/catalog/components/useCatalogTableColumns.tsx b/client/src/app/catalog/components/useCatalogTableColumns.tsx index 4f0cca5..13570dc 100644 --- a/client/src/app/catalog/components/useCatalogTableColumns.tsx +++ b/client/src/app/catalog/components/useCatalogTableColumns.tsx @@ -4,10 +4,12 @@ import { ColumnDef } from "@tanstack/react-table"; import { DataTablaRowActionFunction, DataTableRowActions } from "@/components"; import { Badge } from "@/ui"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; export const useCatalogTableColumns = ( actions: DataTablaRowActionFunction ): ColumnDef[] => { + const { t } = useTranslation(); const customerColumns: ColumnDef[] = useMemo( () => [ /*{ @@ -42,14 +44,14 @@ export const useCatalogTableColumns = ( { id: "company", accessorKey: "company_name", - header: "Compañía", + header: t("common.company"), enableSorting: true, sortingFn: "alphanumeric", }, { id: "state", accessorKey: "state", - header: "Estado", + header: t("quotes.list.columns.status"), cell: ({ renderValue }) => ( <>{renderValue()} @@ -59,19 +61,19 @@ export const useCatalogTableColumns = ( { id: "phone", accessorKey: "phone", - header: "Phone", + header: t("common.phone"), enableSorting: true, sortingFn: "alphanumeric", }, { id: "actions", - header: "Acciones", + header: t("common.actions"), cell: (context) => { return ; }, }, ], - [actions] + [actions, t] ); return customerColumns; }; diff --git a/client/src/app/dealers/components/DealerDataTable.tsx b/client/src/app/dealers/components/DealerDataTable.tsx index 2fba56c..a66e0e6 100644 --- a/client/src/app/dealers/components/DealerDataTable.tsx +++ b/client/src/app/dealers/components/DealerDataTable.tsx @@ -7,12 +7,13 @@ import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar"; import { useDataTable, useDataTableContext } from "@/lib/hooks"; import { IListArticles_Response_DTO } from "@shared/contexts"; import { ColumnDef } from "@tanstack/react-table"; -import { t } from "i18next"; +import { useTranslation } from "react-i18next"; import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useDealerList } from "../hooks"; export const DealerDataTable = () => { + const { t } = useTranslation(); const navigate = useNavigate(); const { pagination, globalFilter, isFiltered } = useDataTableContext(); @@ -29,17 +30,19 @@ export const DealerDataTable = () => { { id: "id" as const, accessorKey: "id", + header: () => t("common.identifier"), enableResizing: false, size: 10, }, { id: "name" as const, accessorKey: "name", + header: () => t("common.name"), enableResizing: false, size: 10, }, ], - [] + [t] ); const { table } = useDataTable({ diff --git a/client/src/app/dealers/components/useCatalogTableColumns.tsx b/client/src/app/dealers/components/useCatalogTableColumns.tsx index 5626742..7fabdc0 100644 --- a/client/src/app/dealers/components/useCatalogTableColumns.tsx +++ b/client/src/app/dealers/components/useCatalogTableColumns.tsx @@ -4,10 +4,12 @@ import { ColumnDef } from "@tanstack/react-table"; import { DataTablaRowActionFunction, DataTableRowActions } from "@/components"; import { Badge } from "@/ui"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; export const useCustomerInvoiceDataTableColumns = ( actions: DataTablaRowActionFunction ): ColumnDef[] => { + const { t } = useTranslation(); const customerColumns: ColumnDef[] = useMemo( () => [ /*{ @@ -42,14 +44,14 @@ export const useCustomerInvoiceDataTableColumns = ( { id: "company", accessorKey: "company_name", - header: "Compañía", + header: t("common.company"), enableSorting: true, sortingFn: "alphanumeric", }, { id: "state", accessorKey: "state", - header: "Estado", + header: t("quotes.list.columns.status"), cell: ({ renderValue }) => ( <>{renderValue()} @@ -59,19 +61,19 @@ export const useCustomerInvoiceDataTableColumns = ( { id: "phone", accessorKey: "phone", - header: "Phone", + header: t("common.phone"), enableSorting: true, sortingFn: "alphanumeric", }, { id: "actions", - header: "Acciones", + header: t("common.actions"), cell: (context) => { return ; }, }, ], - [actions] + [actions, t] ); return customerColumns; }; diff --git a/client/src/app/quotes/components/DownloadQuoteDialog.tsx b/client/src/app/quotes/components/DownloadQuoteDialog.tsx index 25c7d7e..fc28b3d 100644 --- a/client/src/app/quotes/components/DownloadQuoteDialog.tsx +++ b/client/src/app/quotes/components/DownloadQuoteDialog.tsx @@ -11,7 +11,7 @@ import { Label, Progress, } from "@/ui"; -import { t } from "i18next"; +import { useTranslation } from "react-i18next"; import { useEffect, useId } from "react"; type DownloadQuoteDialogProps = Omit & { @@ -19,6 +19,7 @@ type DownloadQuoteDialogProps = Omit & { }; export const DownloadQuoteDialog = (props: DownloadQuoteDialogProps) => { + const { t } = useTranslation(); const { percentage, cancel, error, isInProgress, onFinishDownload } = props; const panelId = useId(); @@ -52,7 +53,7 @@ export const DownloadQuoteDialog = (props: DownloadQuoteDialogProps) => { max={100} getValueLabel={() => t("quotes.downloading_dialog.progress.label", {})} /> - {error &&

Error: {JSON.stringify(error)}

} + {error &&

{t("quotes.downloading_dialog.error")}

}
diff --git a/client/src/app/quotes/components/editors/BlocksPickerDialog/BlockList.tsx b/client/src/app/quotes/components/editors/BlocksPickerDialog/BlockList.tsx index 6e7c30a..9cb3162 100644 --- a/client/src/app/quotes/components/editors/BlocksPickerDialog/BlockList.tsx +++ b/client/src/app/quotes/components/editors/BlocksPickerDialog/BlockList.tsx @@ -1,123 +1,17 @@ import { ScrollArea } from "@/ui"; 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 = { - id: number; + id?: number; code: string; title: string; body: string; }; -const blockData: Record = { - 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 :", - }, - ], -}; +const blockData: Record = { es: esBlocks, en: enBlocks, fr: frBlocks }; export const BlockList = ({ onSelect, @@ -135,10 +29,10 @@ export const BlockList = ({ listState .filter(({ title, body }) => title || body) .sort((a, b) => a.title.localeCompare(b.title)) - .map((_block, _blockIdx) => { + .map((_block) => { return ( -); +}: AddNewRowButtonProps): JSX.Element => { + const { t } = useTranslation(); + const buttonLabel = label ?? t("common.append_empty_row"); + return ( + + ); +}; AddNewRowButton.displayName = "AddNewRowButton"; diff --git a/client/src/components/SorteableDataTable/SortableDataTableToolbar.tsx b/client/src/components/SorteableDataTable/SortableDataTableToolbar.tsx index 785cff2..d6533ad 100644 --- a/client/src/components/SorteableDataTable/SortableDataTableToolbar.tsx +++ b/client/src/components/SorteableDataTable/SortableDataTableToolbar.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, DropdownMenu, @@ -31,13 +32,14 @@ export const SortableDataTableToolbar = ({ }: { table: Table; }) => { + const { t } = useTranslation(); const selectedRowsCount = table.getSelectedRowModel().rows.length; if (selectedRowsCount) { return (
- + - Duplica las fila(s) seleccionadas(s) + {t("common.duplicate_selected_rows_tooltip")} @@ -64,10 +66,10 @@ export const SortableDataTableToolbar = ({ onClick={() => table.options.meta?.deleteItems()} > - Eliminar + {t("common.remove_selected_rows")} - Elimina las fila(s) seleccionada(s) + {t("common.remove_selected_rows_tooltip")} @@ -79,10 +81,10 @@ export const SortableDataTableToolbar = ({ onClick={() => table.resetRowSelection()} > - Quitar selección + {t("common.reset_selected_rows")} - Quita la selección + {t("common.reset_selected_rows_tooltip")}
@@ -101,10 +103,10 @@ export const SortableDataTableToolbar = ({ onClick={() => table.options.meta?.appendItem()} > - Añadir + {t("common.add")} - Añadir fila + {t("common.append_empty_row")}
-
Snooze until
+
{t("components.sortable_data_table.snooze_until")}
@@ -148,7 +150,7 @@ export const SortableDataTableToolbar = ({
- Snooze + {t("components.sortable_data_table.snooze")}
@@ -156,28 +158,28 @@ export const SortableDataTableToolbar = ({ - Reply + {t("components.sortable_data_table.reply")} - Reply all + {t("components.sortable_data_table.reply_all")} - Forward + {t("components.sortable_data_table.forward")}
diff --git a/client/src/lib/axios/createAxiosAuthActions.ts b/client/src/lib/axios/createAxiosAuthActions.ts index 138a27b..69f4e32 100644 --- a/client/src/lib/axios/createAxiosAuthActions.ts +++ b/client/src/lib/axios/createAxiosAuthActions.ts @@ -1,4 +1,5 @@ import { IGetProfileResponse_DTO, ILogin_DTO, ILogin_Response_DTO } from "@shared/contexts"; +import { t } from "i18next"; import secureLocalStorage from "react-secure-storage"; import { IAuthActions } from "../hooks"; import { createAxiosInstance } from "./axiosInstance"; @@ -31,8 +32,8 @@ export const createAxiosAuthActions = ( return { success: false, error: { - message: "Login failed", - name: "Invalid email or password", + message: t("login_page.invalid_credentials"), + name: "InvalidCredentials", }, }; } diff --git a/client/src/lib/hooks/useCustomDialog/useCustomDialog.tsx b/client/src/lib/hooks/useCustomDialog/useCustomDialog.tsx index 92ec2bc..8b79b19 100644 --- a/client/src/lib/hooks/useCustomDialog/useCustomDialog.tsx +++ b/client/src/lib/hooks/useCustomDialog/useCustomDialog.tsx @@ -1,5 +1,5 @@ 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"; interface DialogConfig { @@ -13,15 +13,18 @@ interface DialogConfig { export const useCustomDialog = (config?: Partial) => { const [isOpen, setIsOpen] = useState(false); - const [dialogConfig, setDialogConfig] = useState({ - title: "Título", - description: "Descripción", + const { t } = useTranslation(); + const [dialogOverrides, setDialogConfig] = useState>(); + const dialogConfig: DialogConfig = { + title: t("common.confirmation"), + description: "", cancelLabel: t("common.no"), confirmLabel: t("common.yes"), onCancel: () => {}, onConfirm: () => {}, ...config, - }); + ...dialogOverrides, + }; const openDialog = (event?: SyntheticEvent, config?: DialogConfig) => { event?.preventDefault(); diff --git a/client/src/lib/hooks/useLocalization/useLocatlization.tsx b/client/src/lib/hooks/useLocalization/useLocatlization.tsx index 7e66de2..f38ce26 100644 --- a/client/src/lib/hooks/useLocalization/useLocatlization.tsx +++ b/client/src/lib/hooks/useLocalization/useLocatlization.tsx @@ -76,9 +76,20 @@ export const useCustomLocalization = (props: UseLocalizationProps) => { [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 { formatCurrency, formatNumber, formatPercentage, + formatCurrencyName, }; }; diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index 276718e..33c92d5 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -7,12 +7,55 @@ "reload": "Recarregar" }, "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", "LoginPage": { "title": "Pressupostador per a distribuïdors", "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" + } } } } diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 8317236..fd1d961 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -82,7 +82,13 @@ "button_add_term": "Add filter term", "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." - } + }, + "company": "Company", + "phone": "Phone", + "name": "Name", + "identifier": "Identifier", + "confirmation": "Confirmation", + "product_image": "Product image" }, "components": { "loading_indicator": { @@ -106,6 +112,17 @@ "no_results": "No results found.", "clear_filters": "Clear filters", "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": { @@ -146,7 +163,15 @@ "forgotten_password": "Forgot your password?", "become_dealer": "Do you want to become a Uecko partner?", "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": { "welcome": "Welcome" @@ -260,7 +285,8 @@ "status_stopped": "stopped" }, "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": { "title": "Select catalog items", @@ -359,7 +385,11 @@ "label": "Document language", "desc": "Language used to generate the quote PDF", "placeholder": "", - "options": { "es": "Spanish", "en": "English", "fr": "French" } + "options": { + "es": "Spanish", + "en": "English", + "fr": "French" + } }, "currency_code": { "label": "Currency", @@ -378,7 +408,7 @@ }, "payment_method": { "label": "Payment method", - "placeholder": "placeholder", + "placeholder": "", "desc": "Method of payment for this quote" }, "notes": { @@ -569,6 +599,15 @@ } }, "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" } } } diff --git a/client/src/locales/es.json b/client/src/locales/es.json index c56e917..e1ff14e 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -82,7 +82,13 @@ "button_add_term": "Añadir término al filtro", "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." - } + }, + "company": "Compañía", + "phone": "Teléfono", + "name": "Nombre", + "identifier": "Identificador", + "confirmation": "Confirmación", + "product_image": "Imagen del producto" }, "components": { "loading_indicator": { @@ -106,6 +112,17 @@ "no_results": "No se han encontrado resultados.", "clear_filters": "Limpiar filtros", "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": { @@ -146,7 +163,15 @@ "forgotten_password": "¿Has olvidado tu contraseña?", "become_dealer": "¿Quieres ser distribuidor de Uecko?", "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": { "welcome": "Bienvenido" @@ -260,7 +285,8 @@ "status_stopped": "parada" }, "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": { "title": "Seleccionar artículos del catálogo", @@ -359,7 +385,11 @@ "label": "Idioma del documento", "desc": "Idioma utilizado para generar el presupuesto en PDF", "placeholder": "", - "options": { "es": "Español", "en": "Inglés", "fr": "Francés" } + "options": { + "es": "Español", + "en": "Inglés", + "fr": "Francés" + } }, "currency_code": { "label": "Moneda", @@ -378,7 +408,7 @@ }, "payment_method": { "label": "Forma de pago", - "placeholder": "placeholder", + "placeholder": "", "desc": "Forma de pago que se utilizará en esta cotización" }, "notes": { @@ -394,7 +424,7 @@ "subtotal_price": { "label": "Importe neto", "placeholder": "", - "desc": "" + "desc": "Subtotal del presupuesto" }, "discount": { "label": "Descuento (%)", @@ -414,7 +444,7 @@ "before_tax_price": { "label": "Base imponible", "placeholder": "", - "desc": "" + "desc": "Base imponible" }, "tax": { "label": "Impuesto (%)", @@ -569,6 +599,15 @@ } }, "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" } } } diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 92d79c9..ac815f3 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -21,21 +21,74 @@ "close": "Fermer", "more": "Plus", "pagination": "Pagination", + "breadcrumb": "Fil d’Ariane", "previous": "Précédent", "next": "Suivant", + "more_pages": "Plus de pages", + "previous_slide": "Diapositive précédente", + "next_slide": "Diapositive suivante", "add": "Ajouter", "submit": "Envoyer", "download": "Télécharger", "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", "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", "remove": "Supprimer", "archive": "Archiver", "duplicate": "Dupliquer", - "pick_date": "Choisir une date", - "today": "Aujourd’hui", - "required_field": "Ce champ est obligatoire" + "disable_preview": "Masquer l’aperçu", + "enable_preview": "Afficher l’aperçu", + "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": { "loading_indicator": { @@ -59,6 +112,17 @@ "no_results": "Aucun résultat trouvé.", "clear_filters": "Effacer les filtres", "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": { @@ -99,7 +163,15 @@ "forgotten_password": "Mot de passe oublié ?", "become_dealer": "Vous souhaitez devenir distributeur Uecko ?", "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": { "welcome": "Bienvenue" @@ -128,6 +200,12 @@ "empty_state": { "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": { "all": "Tous", "draft": "Brouillons", @@ -207,7 +285,8 @@ "status_stopped": "arrêté" }, "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": { "title": "Sélectionner des articles du catalogue", @@ -225,6 +304,64 @@ "description": "Voulez-vous envoyer ce devis à Uecko ? Cette action est irréversible.", "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": { "draft": "Brouillon", "ready": "Prêt", @@ -287,13 +424,18 @@ "subtotal_price": { "label": "Montant net", "placeholder": "", - "desc": "" + "desc": "Sous-total du devis" }, "discount": { "label": "Remise (%)", "placeholder": "", "desc": "Pourcentage de remise" }, + "discount_value": { + "label": "Remise ({{value}}%)", + "placeholder": "", + "desc": "Pourcentage de remise" + }, "discount_price": { "label": "Montant de la remise", "placeholder": "", @@ -302,13 +444,18 @@ "before_tax_price": { "label": "Base imposable", "placeholder": "", - "desc": "" + "desc": "Base imposable" }, "tax": { "label": "Taxe (%)", "placeholder": "", "desc": "Pourcentage de taxe" }, + "tax_value": { + "label": "{{label}} ({{value}}%)", + "placeholder": "", + "desc": "Pourcentage de taxe" + }, "tax_price": { "label": "Montant de la taxe", "placeholder": "", @@ -452,6 +599,15 @@ } }, "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" } } } diff --git a/client/src/locales/resources.test.ts b/client/src/locales/resources.test.ts new file mode 100644 index 0000000..44a058a --- /dev/null +++ b/client/src/locales/resources.test.ts @@ -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, prefix = ""): Record => + 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, 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(""); + } + }); +}); diff --git a/client/vite.config.ts b/client/vite.config.ts index b4fd239..b11e681 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -10,6 +10,15 @@ import { viteStaticCopy } from "vite-plugin-static-copy"; const pkg = JSON.parse(readFileSync("./package.json", "utf-8")); const require = createRequire(import.meta.url); 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, "&").replace(//g, ">"); const pdfjsDistPath = path.dirname(require.resolve("pdfjs-dist/package.json")); const cMapsDir = normalizePath(path.join(pdfjsDistPath, "cmaps")); @@ -27,6 +36,21 @@ export default defineConfig({ emptyOutDir: true, }, plugins: [ + { + name: "localized-html", + transformIndexHtml(html) { + return html + .replace("", escapeHtml(htmlTranslations[0].labels.title)) + .replace( + "", + htmlTranslations + .map(({ language, labels }) => + `

${escapeHtml(labels.javascript_required)}

` + ) + .join("\n") + ); + }, + }, { name: "app-build-version", apply: "build", diff --git a/package.json b/package.json index d175b07..b355c69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uecko-presupuestador", - "version": "1.2.4", + "version": "1.2.5", "author": "Rodax Software ", "license": "ISC", "private": true, diff --git a/server/package.json b/server/package.json index 6a741c6..3b9ed56 100644 --- a/server/package.json +++ b/server/package.json @@ -1,7 +1,7 @@ { "name": "@uecko-presupuestador/server", "private": true, - "version": "1.2.4", + "version": "1.2.5", "author": "Rodax Software ", "main": "./src/index.ts", "scripts": { diff --git a/shared/package.json b/shared/package.json index b894a19..486c5cb 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,7 +1,7 @@ { "name": "@uecko-presupuestador/shared", "private": false, - "version": "1.2.4", + "version": "1.2.5", "main": "./index.ts", "author": "Rodax Software ", "license": "ISC",