diff --git a/CHANGELOG.md b/CHANGELOG.md
index 877d79792..d40c4c4d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,80 @@ Tous les changements notables sur le projet sont documentés dans ce fichier.
Ce projet adhère au principe du [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## 1.0.0 (UNRELEASED)
+
+- Ajoute une popup avec plus d'informations sur le matériel au survol de la référence et du nom du matériel sur le listing du matériel.
+- Améliore la prise en charge des fichiers CSV dans l'import des bénéficiaires (notamment ceux générés via Windows ou Microsoft 365) (Premium).
+- Corrige le rafraîchissement de la liste des tags dans les filtres après ajout, restoration ou suppression.
+- La barre du menu principal a été améliorée, et le menu utilisateur a été déplacé en bas de cette barre de menu.
+- Les pages "Catégories" et "Tags" ont été déplacées dans la page "Paramètres".
+- Un sélecteur, placé tout en haut à gauche de l'écran quand il existe plusieurs parcs, permet de choisir l'un de ces parcs comme contexte d'utilisation global. Ce contexte permet de filtrer les listes de matériel selon le parc choisi, ou de remplir automatiquement le champ "parc" dans les formulaires de création de matériel, ou d'unité de matériel (Premium).
+- Corrige quelques problèmes liés à la restriction des parcs aux utilisateurs (Premium).
+- Dans l'onglet "Historique" de la fenêtre des événements et réservations, est affiché l'historique de toutes les opérations effectuées sur les événements et les réservations (Premium).
+ Cet historique est accessible uniquement par les utilisateurs ayant un accès "administration", et montre une liste horodatée des opérations suivantes (avec la personne ayant effectué l'opération) :
+ - création de l'événement ou de la réservation,
+ - modification des informations de l'événement,
+ - ajout ou suppression de matériel dans la liste,
+ - modification de quantité de matériel dans la liste,
+ - confirmation ou remise en attente de l'événement,
+ - approbation de la réservation,
+ - assignation ou désaffectation des bénéficiaires ou des techniciens de l'événement,
+ - création des devis et des factures,
+ - clôture ou annulation de l'inventaire de départ
+ - clôture ou annulation de l'inventaire de retour,
+ - duplication de l'événement,
+ - envoi de la liste du matériel au(x) bénéficiaire(s),
+ - envoi de la fiche de sortie aux techniciens de l'événement,
+ - envoi d'un e-mail de rappel en cas de non-retour du matériel à temps,
+ - archivage, ou désarchivage.
+- Les caractéristiques spéciales peuvent être liées aux unités de matériel, en plus du matériel (Premium).
+ Lors de l'édition d'une caractéristique spéciale, il suffit de choisir sa portée ("matériel", "unités de matériel", ou les deux). Les valeurs des caractéristiques spéciales des unités de matériel peuvent être définies dans le formulaire d'édition des unités de matériel. Bien sûr, la visibilité d'une caractéristique limitée à certaines catégories dépend de la catégorie du matériel lié à l'unité. Quand une caractéristique spéciale numérique totalisable est liée au matériel et aux unités de matériel, le calcul du total utilise les valeurs des caractéristiques des unités de matériel en priorité, et n'ajoute celles du matériel lui-même que pour le matériel en excédent (Premium).
+- En cliquant sur une ligne de la liste des unités de matériel (ou sur le bouton "œil" en bout de ligne), une fenêtre contenant tous les détails de l'unité s'affiche (Premium).
+- Un nouveau type a été ajouté pour les caractéristiques spéciales : "texte multi-lignes". Ce type de donnée permet de saisir un texte plus long que pour le type "texte", et permet les sauts de ligne.
+- Le nombre d'heures d'exploitation de chaque unité de matériel (Premium) est affiché dans la liste des unités (onglet "unités" de la fiche matériel), ainsi que dans la nouvelle fenêtre "détails de l'unité".
+- Les groupes d'accès des utilisateurs ont été remaniés ainsi : le groupe "admin" est maintenant l'accès _"Administration"_, le groupe "membre" est maintenant l'accès _"Gestion"_, et le groupe "visiteur" devient l'accès _"Consultation du planning général"_.
+ Un nouveau type d'accès nommé _"Consultation de son planning"_ a été ajouté, permettant de n'afficher que les données de planning qui concernent uniquement l'utilisateur lui-même.
+- Les techniciens peuvent être liés à un compte utilisateur, qui leur permet de se connecter au logiciel. Par défaut, le groupe _"Consultation de son planning"_ est utilisé : ils peuvent ainsi consulter leur propre planning, rédiger des notes sur les événements dans lesquels ils sont assignés, mais ne peuvent pas modifier les autres informations de l'événement et n'ont pas accès à l'onglet "Historique" (Premium).
+- Il est possible de lier un compte utilisateur existant à une fiche technicien ou bénéficiaire. Il n'est donc plus nécessaire de créer un nouveau compte pour cela. (Premium)
+- Améliore les messages d'erreurs dans les formulaires.
+- Affiche les numéros de page dans le pied-de-page des documents PDF.
+- Améliore la configuration des cookies pour permettre l'intégration (par exemple dans Notion). NOTE : Uniquement possible dans les contexte sécurisés ou l'application est installée derrière un certificat TLS/SSL et donc accessible via une URL du type `https://...`.
+- La facturation a été revue et améliorée :
+ - La configuration du logiciel permet désormais de définir plusieurs tarifs dégressifs, les taxes et groupes de taxes.
+ (Un groupe de taxes étant la réunion de plusieurs taxes, appliquées en même temps, par exemple une T.V.A à 20% + Une participation écologique à prix fixe)
+ - Chaque matériel peut utiliser son propre tarif dégressif (ou aucun), sa propre taxe, groupe de taxe ou pas de taxe.
+ - La remise globale des événements et réservations se configure dorénavant dans l'édition de l'événement ou la réservation
+ à la nouvelle étape "Facturation" (voir plus bas).
+ - Le prix du matériel peut maintenant être personnalisé directement dans les réservations et événements.
+ - Des lignes de facturation supplémentaires peuvent désormais être ajoutées aux devis et factures des événements et réservations.
+ - L'édition des devis pour les réservations est maintenant disponible.
+ - Il est maintenant possible d'appliquer des remises au niveau de chaque matériel dans les événements et réservations, en plus de la remise globale.
+ - Le mode de calcul des factures et devis a changé (ceci n'impacte évidemment pas les factures / devis déjà édités) :
+ Avant, vu que le tarif dégressif était le même partout, le calcul était :
+ - Pour chaque matériel : quantité x prix unitaire = Total arrondi à deux chiffres après la virgule.
+ - Une fois fait pour chaque matériel, somme de ces totaux puis multiplication par le tarif dégressif global, arrondi
+ du résultat à deux chiffres après la virgule. Celui-ci constituait donc le total hors remise globale.
+ - Si remise globale, application de celle-ci, puis obtention du total hors taxes arrondi.
+ - Si T.V.A, application de celle-ci pour obtenir le total T.T.C arrondi.
+ Maintenant, chaque matériel peut avoir son propre tarif dégressif. Le calcul se passe donc ainsi:
+ - Pour chaque matériel : Prix unitaire x Tarif dégressif, obtention d'un prix pour la période de réservation / de l'événement,
+ arrondi à deux chiffres après la virgule puis multiplié par la quantité et à nouveau arrondi pour avoir le total pour le
+ matériel (si remise sur le matériel, application de la remise puis à nouveau arrondi pour avoir le total final pour le matériel).
+ - Une fois fait pour chaque matériel, somme de ces totaux pour obtenir le total hors remise globale, arrondi.
+ - Si remise globale, application de celle-ci, puis obtention du total hors taxes arrondi.
+ - Si taxes, application de celles-ci pour chaque matériel puis déduction de la remise globale éventuelle pour obtenir le total T.T.C arrondi.
+- Lors de l'ajout d'un matériel à un événement / une réservation, son nom, sa référence, son prix unitaire et son tarif dégressif sont dorénavant "figés" dans l'événement ou la réservation, ceci permettant d'éviter que lors du renommage ou de l'ajustement d'un prix d'un matériel, les événements passés prennent ces nouvelles valeurs, qui n'étaient pas effectives "à l'époque". De la même manière, cela permet, pour une réservation ou un événement futur dont le prix total (et donc celui de chaque matériel) a été accepté par un client, d'éviter de modifier ces prix acceptés, même en cas de hausse de prix à posteriori. Bien sûr l'interface propose donc maintenant de "resynchroniser" ces éléments avec les dernières informations du matériel et vous indique qu'une information est "désynchronisée" en la soulignant en bleu dans l'édition d'une réservation ou d'un événement.
+- Dans la page de modification des événements, une nouvelle étape "Facturation" a été ajoutée après celle du matériel, pour pouvoir ajuster les montant de chaque matériel et ajouter des lignes additionnelles aux devis et factures qui seront générées pour l'événement.
+- Un bouton "modifier" dans la fenêtre des réservations (Premium) permet d'accéder à une nouvelle page de modification de la réservation, similaire à celle des événements, avec 4 étapes :
+ - 1. Informations (pour modifier les dates de la réservation)
+ - 2. Matériel (pour modifier la liste du matériel de la réservation)
+ - 3. Facturation (pour gérer les tarifs du matériel et les lignes additionnelles)
+ - 4. Récapitulatif (pour consulter l'ensemble des informations de la réservation)
+- Des codes-barres peuvent être générés pour le matériel identifié de manière groupée (donc non-unitaire) (Premium).
+ Scanner un code-barres de ce type a pour effet d'ajouter +1 quantité dans l'édition de la liste de matériel d'un événement, d'une réservation ou d'un modèle de liste. Dans les inventaires de départ et retour, cela ajoute +1 quantité pour le matériel groupé uniquement (car pour le matériel unitaire, il faut toujours scanner explicitement l'unité qui sort / revient).
+- Un bouton "Rechercher" en haut du menu principal permet de rechercher un nom ou une référence de matériel ou d'une unité de matériel, ainsi que d'utiliser le scanner de code-barres depuis n'importe quelle page du logiciel, pour obtenir toutes les informations du matériel scanné ou recherché (photo, description, quantités, caractéristiques, tags...), la liste de ses unités, les événements ou réservations dans lesquels il est utilisé actuellement, et l'éventuel inventaire de départ ou de retour qui doit être effectué avec ce matériel (Premium).
+- La recherche de matériel retourne maintenant le matériel dont la référence des unités contient le terme recherché, ou dont le numéro de série est exactement le terme recherché (Premium).
+
## 0.24.4 (2024-05-30)
- Correction d'un bug dans la fonctionnalité de recherche des tableaux lorsque ceux-ci contiennent des dates.
diff --git a/bin/release b/bin/release
index e5f532a61..2d1f2f064 100755
--- a/bin/release
+++ b/bin/release
@@ -84,9 +84,10 @@ cp -R ./client/dist $distFolder/src/public/webclient
echo -e "\e[1m\e[34m-> Nettoyage du dossier de distribution (suppression des fichiers inutiles / privés)...\e[0m"
cd $distFolder
+rm -f src/App/Config/install.json
+rm -f src/App/Config/install.json.bckp
rm -f src/App/Config/settings.json
rm -f src/App/Config/settings.json.bckp
-rm -f src/install/progress.json
rm -r src/var/cache && mkdir -m 777 src/var/cache/
rm -r src/var/logs && mkdir -m 777 src/var/logs/
rm -r src/var/tmp && mkdir -m 777 src/var/tmp/
@@ -96,7 +97,7 @@ mkdir -m 777 data/
echo -e "\e[1m\e[34m-> Définition des permission des dossiers...\e[0m"
chmod 777 src/App/Config
-chmod 777 src/install
+find src/views -type f -exec chmod 664 {} \;
echo -e "\e[1m\e[34m-> Installation des dépendances back-end...\e[0m"
query=(
diff --git a/client/.stylelintrc.json b/client/.stylelintrc.json
index d919644b2..b04ebe654 100644
--- a/client/.stylelintrc.json
+++ b/client/.stylelintrc.json
@@ -1,6 +1,7 @@
{
"extends": "@pulsanova/stylelint-config-scss",
"rules": {
- "custom-property-pattern": null
+ "custom-property-pattern": null,
+ "max-nesting-depth": null
}
}
diff --git a/client/jest.config.js b/client/jest.config.js
index 6a20bf461..4a26fe614 100644
--- a/client/jest.config.js
+++ b/client/jest.config.js
@@ -29,6 +29,7 @@ module.exports = {
'/tests/serializers/datetime.ts',
'/tests/serializers/decimal.ts',
'/tests/serializers/period.ts',
+ '/tests/serializers/currency.ts',
],
transform: {
'^.+\\.(js|mjs|cjs|jsx|ts|mts|cts|tsx)$': 'babel-jest',
diff --git a/client/package.json b/client/package.json
index d3034a059..a0996f91a 100644
--- a/client/package.json
+++ b/client/package.json
@@ -24,6 +24,7 @@
"deep-freeze-strict": "1.1.1",
"invariant": "2.2.4",
"js-cookie": "3.0.5",
+ "jschardet": "^3.1.3",
"lodash": "^4.17.21",
"moment": "2.29.4",
"p-defer": "3.0.0",
diff --git a/client/src/globals/config.ts b/client/src/globals/config.ts
index fa12349ce..42d25db54 100644
--- a/client/src/globals/config.ts
+++ b/client/src/globals/config.ts
@@ -1,23 +1,99 @@
+import { z } from '@/utils/validation';
import deepFreeze from 'deep-freeze-strict';
+import type { SchemaInput, SchemaInfer } from '@/utils/validation';
+
+/** Mode de facturation de l'application. */
+export enum BillingMode {
+ /**
+ * Mode "Location".
+ *
+ * La facturation est toujours activée dans les
+ * événements et réservations.
+ */
+ ALL = 'all',
+
+ /**
+ * Mode hybride: Location et prêt.
+ *
+ * La facturation peut être activée ou désactivée manuellement
+ * dans les événements et réservations.
+ */
+ PARTIAL = 'partial',
+
+ /**
+ * Mode "Prêt".
+ *
+ * La facturation est toujours désactivée dans les
+ * événements et réservations.
+ */
+ NONE = 'none',
+}
+
+const GlobalConfigSchema = z.strictObject({
+ baseUrl: z.string(),
+ isSslEnabled: z.boolean(),
+ version: z.string(),
+ billingMode: z.nativeEnum(BillingMode),
+ defaultLang: z.string(),
+ api: z.strictObject({
+ url: z.string(),
+ headers: z.record(z.string(), z.string()),
+ }),
+ auth: z.strictObject({
+ cookie: z.string(),
+ timeout: z.number().nullable(),
+ }),
+ currency: z.currency(),
+ companyName: z.string().nullable(),
+ defaultPaginationLimit: z.number(),
+ maxConcurrentFetches: z.number(),
+ maxFileUploadSize: z.number(),
+ authorizedFileTypes: z.string().array(),
+ authorizedImageTypes: z.string().array(),
+ colorSwatches: z.string().array().nullable(),
+});
+
+//
+// - Types.
+//
+
+export type RawGlobalConfig = SchemaInput;
+export type GlobalConfig = SchemaInfer;
+
+//
+// - Default config.
+//
+
let baseUrl = process.env.VUE_APP_API_URL ?? '';
-if (window.__SERVER_CONFIG__ && window.__SERVER_CONFIG__.baseUrl) {
+if (window.__SERVER_CONFIG__?.baseUrl !== undefined) {
baseUrl = window.__SERVER_CONFIG__.baseUrl;
}
-const defaultConfig: GlobalConfig = {
+let isSslEnabled: boolean;
+if (window.__SERVER_CONFIG__?.isSslEnabled !== undefined) {
+ isSslEnabled = window.__SERVER_CONFIG__.isSslEnabled;
+} else {
+ try {
+ isSslEnabled = (
+ baseUrl !== '' &&
+ (new URL(baseUrl)).protocol === 'https:'
+ );
+ } catch {
+ isSslEnabled = false;
+ }
+}
+
+const defaultConfig: RawGlobalConfig = {
baseUrl,
+ isSslEnabled,
version: '__DEV__',
api: {
url: `${baseUrl}/api`,
headers: { Accept: 'application/json' },
},
defaultLang: 'fr',
- currency: {
- symbol: '€',
- name: 'Euro',
- iso: 'EUR',
- },
+ currency: 'EUR',
auth: {
cookie: 'Authorization',
timeout: 12, // - En heures (ou `null` pour un cookie de session).
@@ -25,7 +101,7 @@ const defaultConfig: GlobalConfig = {
companyName: null,
defaultPaginationLimit: 100,
maxConcurrentFetches: 2,
- billingMode: 'partial',
+ billingMode: BillingMode.PARTIAL,
maxFileUploadSize: 25 * 1024 * 1024,
colorSwatches: null,
authorizedFileTypes: [
@@ -54,6 +130,10 @@ const defaultConfig: GlobalConfig = {
],
};
-const globalConfig = window.__SERVER_CONFIG__ || defaultConfig;
+//
+// - Final config.
+//
+
+const globalConfig = GlobalConfigSchema.parse(window.__SERVER_CONFIG__ ?? defaultConfig);
export default deepFreeze(globalConfig);
diff --git a/client/src/globals/requester.js b/client/src/globals/requester.js
index 4d8e30ae9..03d0931e4 100644
--- a/client/src/globals/requester.js
+++ b/client/src/globals/requester.js
@@ -78,9 +78,9 @@ requester.interceptors.request.use(
request.params = params;
}
- const token = cookies.get(config.auth.cookie);
- if (token) {
- request.headers.Authorization = `Bearer ${token}`;
+ const authToken = cookies.get(config.auth.cookie);
+ if (authToken) {
+ request.headers.Authorization = `Bearer ${authToken}`;
}
return request;
},
diff --git a/client/src/globals/types/core.d.ts b/client/src/globals/types/core.d.ts
index f4dd9aad9..b9abc30f9 100644
--- a/client/src/globals/types/core.d.ts
+++ b/client/src/globals/types/core.d.ts
@@ -4,6 +4,20 @@
declare module '*.scss';
+//
+// - YAML
+//
+
+declare module '*.yaml' {
+ const data: Record;
+ export default data;
+}
+
+declare module '*.yml' {
+ const data: Record;
+ export default data;
+}
+
//
// - SVG
//
diff --git a/client/src/globals/types/globals.d.ts b/client/src/globals/types/globals.d.ts
index 278f7d798..6445361ad 100644
--- a/client/src/globals/types/globals.d.ts
+++ b/client/src/globals/types/globals.d.ts
@@ -1,35 +1,11 @@
-type GlobalConfig = {
- baseUrl: string,
- version: string,
- billingMode: 'all' | 'partial' | 'none',
- defaultLang: string,
- api: {
- url: string,
- headers: Record,
- },
- auth: {
- cookie: string,
- timeout: number | null,
- },
- currency: {
- symbol: string,
- name: string,
- iso: string,
- },
- companyName: string | null,
- defaultPaginationLimit: number,
- maxConcurrentFetches: number,
- maxFileUploadSize: number,
- authorizedFileTypes: string[],
- authorizedImageTypes: string[],
- colorSwatches: string[] | null,
-};
+import type { RawGlobalConfig } from '../config';
-declare var __SERVER_CONFIG__: GlobalConfig | undefined;
+declare global {
+ type ServerMessage = {
+ type: 'success' | 'info' | 'error',
+ message: string,
+ };
-type ServerMessage = {
- type: 'success' | 'info' | 'error',
- message: string,
-};
-
-declare var __SERVER_MESSAGES__: ServerMessage[] | undefined;
+ declare var __SERVER_CONFIG__: RawGlobalConfig | undefined;
+ declare var __SERVER_MESSAGES__: ServerMessage[] | undefined;
+}
diff --git a/client/src/globals/types/vendors/vue-tables-2.d.ts b/client/src/globals/types/vendors/vue-tables-2.d.ts
index db49e8f31..7366a0cc6 100644
--- a/client/src/globals/types/vendors/vue-tables-2.d.ts
+++ b/client/src/globals/types/vendors/vue-tables-2.d.ts
@@ -20,12 +20,13 @@ declare module 'vue-tables-2-premium' {
type ColumnsVisibility = Record;
type TemplateRenderFunction = (
- (h: CreateElement, row: Datum, index: number) => JSX.Element | JSX.Element[] | string | null
+ (h: CreateElement, row: Datum, index: number) => JSX.Element | JSX.Element[] | string | number | null
);
type RowClickEventPayload = { row: Datum, event: PointerEvent, index: number };
type BaseTableOptions = {
+ uniqueKey?: string,
headings?: Record,
initialPage?: number,
perPage?: number,
@@ -34,6 +35,7 @@ declare module 'vue-tables-2-premium' {
filterable?: boolean | string[],
multiSorting?: Record>,
filterByColumn?: boolean,
+ resizableColumns?: boolean | string[],
columnsDropdown?: boolean,
preserveState?: boolean,
saveState?: boolean,
@@ -41,6 +43,14 @@ declare module 'vue-tables-2-premium' {
columnsClasses?: Record,
templates?: Record>,
rowClassCallback?(row: Datum): VNodeClass,
+ stickyHeader?: boolean,
+ pagination?: {
+ chunk?: number,
+ dropdown?: boolean,
+ edge?: boolean,
+ show?: boolean,
+ virtual?: boolean,
+ },
};
interface BaseTableInstance {
diff --git a/client/src/globals/types/vendors/vue.d.ts b/client/src/globals/types/vendors/vue.d.ts
index 12d0b01ed..c6c26a378 100644
--- a/client/src/globals/types/vendors/vue.d.ts
+++ b/client/src/globals/types/vendors/vue.d.ts
@@ -9,7 +9,7 @@ declare module 'vue' {
| undefined
);
- export type RawComponent> = (
+ export type RawComponent, Methods = DefaultMethods> = (
& ComponentOptions, Methods, DefaultComputed, Props>
& VueConstructor
);
diff --git a/client/src/globals/types/vendors/vuex.d.ts b/client/src/globals/types/vendors/vuex.d.ts
index 2e022b107..83ef87283 100644
--- a/client/src/globals/types/vendors/vuex.d.ts
+++ b/client/src/globals/types/vendors/vuex.d.ts
@@ -1,5 +1,15 @@
/* eslint-disable import/extensions */
declare module 'vuex' {
+ import type { Module } from 'vuex';
+
export * from 'vuex/types/index.d.ts';
+
+ export type ModuleState, S = Required['state']> = (
+ S extends (() => infer R) ? R : S
+ );
+
+ export type ModulesStates>> = {
+ [K in keyof T]: ModuleState
+ };
}
diff --git a/client/src/locale/en/common.js b/client/src/locale/en/common.js
deleted file mode 100644
index b52d52975..000000000
--- a/client/src/locale/en/common.js
+++ /dev/null
@@ -1,390 +0,0 @@
-export default {
- 'hello-name': "Hello {name}!",
- 'your-settings': "Your settings",
- 'logout-quit': "Quit Loxya",
- 'action-add': "Add",
- 'action-edit': "Edit",
- 'action-view': "Display details",
- 'action-view-schedule': "Display schedule",
- 'action-trash': "Trash bin",
- 'action-restore': "Restore",
- 'action-delete': "Delete",
- 'action-remove': "Remove",
- 'action-rename': "Rename",
- 'action-duplicate': "Duplicate",
- 'action-enable': "Enable",
- 'action-disable': "Disable",
- 'action-refresh': "Refresh data",
- 'yes': "Yes",
- 'no': "No",
- 'warning': "Warning!",
- 'loading': "Loading...",
- 'please-confirm': "Please confirm...",
- 'yes-delete': "Yes, delete",
- 'yes-trash': "Yes, move in trash bin",
- 'yes-permanently-delete': "Yes, permanently delete",
- 'yes-regenerate-link': "Yes, regenerate the link",
- 'yes-restore': "Oui, restaurer",
- 'changes-exists-really-cancel': "Some changes have not been saved. Do you really want to leave this page?",
- 'yes-leave-page': "Yes, leave page",
- 'cancel': "Cancel",
- 'close': "Close",
- 'confirm': "Confirm",
- 'copy-to-clipboard': "Copy to clipboard",
- 'copied-to-clipboard': "Copied to clipboard!",
- 'copy': "Copy",
- 'copied': "Copied!",
- 'almost-done': "Almost done...",
- 'done': "Done",
- 'refresh-page': "Refresh the page",
- 'take-control': "Take control",
- 'update-in-progress': "Update in progress",
- 'regenerate-link': "Regenerate link",
- 'please-choose': "Please choose...",
- 'start-typing-to-search': "Start typing to search...",
- 'type-at-least-count-chars-to-search': [
- "Type again, at least {count} character to search...",
- "Type again, at least {count} characters to search...",
- ],
- 'count-chars': ["{count} char", "{count} chars"],
- 'empty-state': "There are no records to display at this time.",
- 'search-term': "Search",
- 'no-result-found-try-another-search': "No results. Try another search term.",
- 'locked': "locked",
- 'clear-filters': "Clear filters",
- 'optional': "Optional",
- 'n-persons': ["{count} person", "{count} persons"],
- 'name-and-n-others': ["{name} and {count} other", "{name} and {count} others"],
- 'add-comment': "Add a comment",
- 'modify-comment': "Modify comment",
- 'save': "Save",
- 'manually-save': "Manually save",
- 'save-draft': "Save draft",
- 'add': "Add",
- 'saving': "Saving...",
- 'saved': "{entity} saved.",
- 'deleting': "Deleting...",
- 'reset-date': "Reset date",
- 'reset-period': "Reset period",
- 'actions': "Actions",
- 'informations': "Information",
- 'connexion-infos': "Credentials",
- 'personal-infos': "Personal information",
- 'minimal-infos': "Minimal information",
- 'extra-infos': "Additional information",
- 'stock-infos': "Stock information",
- 'billing-infos': "Billing information",
- 'other-infos': "Other information",
- 'documents': "Documents",
- 'billing': "Billing",
- 'history': "History",
- 'special-attributes': "Special attributes",
- 'schedule': "Schedule",
- 'pseudo': "Pseudo",
- 'email-address-or-pseudo': "E-mail address or Pseudo",
- 'password': "Password",
- 'first-name': "First name",
- 'last-name': "Last name",
- 'name': "Name",
- 'nickname': "Nickname",
- 'company': "Company",
- 'person': "Person",
- 'legal-name': "Legal name",
- 'contact-details': "Contact details",
- 'email': "E-mail",
- 'phone': "Phone",
- 'address': "Address",
- 'street': "Street and Number",
- 'postal-code': "Postal code",
- 'city': "City",
- 'locality': "City",
- 'country': "Pays",
- 'group': "Group",
- 'admin': "Administrator",
- 'member': "Member",
- 'visitor': "Visitor",
- 'owner': "Owner",
- 'opening-hours': "Opening hours",
- 'hours': "hours",
- 'minutes': "minutes",
- 'notes': "Notes",
- 'description': "Description",
- 'label-colon': "{label}:",
- 'ref': "Ref.",
- 'ref-ref': "Ref.: {reference}",
- 'reference': "Reference",
- 'number': "Number",
- 'park': "Park",
- 'park-name': "Park \"{name}\"",
- 'prices': "Prices",
- 'rental-price': "Rental price",
- 'replacement-price': "Replacement price",
- 'rent-price': "Rent. price",
- 'repl-price': "Repl. price",
- 'value-per-day': '{value}\u00A0/\u00A0day',
- 'serial-number': "Serial n°",
- 'examples-list': "Examples: {list}, etc.",
- 'not-specified': "Not specified",
-
- 'qty': "Qty",
- 'stock-qty': "Stock qty",
- 'stock-quantity': "Stock quantity",
- 'out-of-order-qty': "Out of order qty",
- 'out-of-order-quantity': "Out of order quantity",
- 'remaining-qty': "Remaining qty",
- 'awaited-qty-dots': "Awaited qty:",
- 'actual-qty': "Actual qty",
- 'quantities': "Quantities",
- 'period': "Period",
- 'status': "Status",
- 'materials': "Materials",
-
- 'discountable': "Discountable?",
- 'is-broken': "Out of order?",
- 'broken': "Out of order",
- 'is-lost': "Lost?",
- 'lost': "Lost",
- 'purchase-date': "Purchase date",
- 'material-is-discountable': "The material is \"discountable\": a discount amount can be applied to this material.",
- 'hidden-on-invoice': "Hidden on invoice?",
- 'material-not-displayed-on-invoice': "The material is not displayed on invoices.",
- 'price-must-be-zero': "the rental price must be 0",
- 'all-parks': "All parks combined",
- 'all-categories': "All categories",
- 'all-sub-categories': "All sub-categories",
- 'not-categorized': "Not categorized",
- 'not-limited': "not limited",
- 'open-trash-bin': "Display trash bin",
- 'display-not-deleted-items': "Display not deleted items",
- 'created-at': "Created at: {date}",
- 'updated-at': "Updated at: {date}",
- 'state': "State",
- 'picture': "Picture",
- 'add-a-picture': "Add a picture",
- 'change-the-picture': "Change the picture",
- 'remove-the-picture': "Remove the picture",
-
- 'event-details': "Event's details",
- 'title': "Title",
- 'date': "Date",
- 'dates': "Dates",
- 'start-end-dates': "Start and end dates",
- 'start-date': "Start Date",
- 'end-date': "End Date",
- 'location': "Location",
- 'duration': "Duration",
- 'duration-days': [
- "Duration {duration} day",
- "Duration {duration} days",
- ],
- 'confirmed': "Confirmed",
- 'not-confirmed': "Not confirmed",
- 'is-billable': "Is billable?",
- 'event-is-now-billable': "This event is now billable.",
- 'is-not-billable-help': "\"Loan\" Mode: no billing.",
- 'is-billable-help': "\"Rent\" Mode: billing possible.",
- 'confirm-event': "Confirm event",
- 'unconfirm-event': "Set event back on hold",
- 'delete-event': "Delete event",
- 'duplicate-event': "Duplicate event",
- 'print': "Print",
- 'print-summary': "Print this summary",
- 'open': "Open",
- 'in': "In {location}",
- 'mobilization-period': "Mobilization {period}",
- 'open-in-google-maps': "Open in Google Maps",
- 'or': "or",
- 'for': "For",
- 'with': "With",
- 'into': "into",
- 'in-progress': "in progress",
- 'invoice': "Invoice",
- 'estimate': "Estimate",
- 'estimates': "Estimates",
- 'no-invoice-help': "There is no invoice yet.",
- 'no-estimate-help': "There is no estimate yet.",
- 'warning-no-estimate-before-billing': "Warning, this event does not have any estimate!",
- 'warning-event-has-invoice': "Warning, this event already have an invoice!",
- 'estimate-title': "Estimate of {date} at {hour}",
- 'confirm-delete-estimate': "Do you really want to delete this estimate?",
- 'missing-beneficiary': "Missing beneficiary",
- 'requested-qty': "Requested qty",
- 'missing-qty': "Missing qty",
- 'position-held': "Position held",
- 'not-billable-help': "You can't create an invoice (or estimate) for an event without at least one beneficiary.",
- 'invoice-title': "Invoice n° {number}, generated on {date}",
- 'with-amount-of': "for an amount of {amount}",
- 'with-amount-of-excl-tax': "for an amount of {amount} excl. tax",
- 'create-event-invoice-help': "You can create a new invoice for the first beneficiary in the list (with a discount if needed) by clicking on the button below.",
- 'create-event-estimate-help': "You can create an estimate for the first beneficiary in the list (with a discount if needed) by clicking on the button below.",
- 'contact-someone-to-create-invoice': "If needed, contact a member of the team and ask them to edit the invoice.",
- 'contact-someone-to-create-estimate': "If needed, contact a member of the team and ask them to create an estimate.",
- 'previous-invoices': "Previous invoices",
- 'discount': "Discount",
- 'without-discount': "Without discount",
- 'discount-rate': "{rate}\u00A0% off",
- 'no-discount-applicable': "No discount can be set to this event (no discountable materials).",
- 'max-discount-rate-help': "The maximum discount rate is {rate}\u00A0% because of some not-discountable material presence.",
- 'wanted-discount-rate': "Discount in %",
- 'wanted-total-amount': "Total amount",
- 'create-invoice': "Create invoice",
- 'create-estimate': "Create estimate",
- 'download': "Download",
- 'download-pdf': "Download PDF file",
- 'download-barcode': "Download barcode",
- 'download-invoice': "Download the invoice",
- 'click-here-to-create-estimate': "Click here to create an estimate",
- 'click-here-to-create-invoice': "Click here to create an invoice",
- 'click-here-to-generate-invoice': "Click here to generate an invoice",
- 'click-here-to-regenerate-invoice': "Click here to regenerate the invoice",
- 'create-new-estimate': "Create a new estimate",
- 'estimate-created': "Estimate created.",
- 'estimate-deleted': "The estimate has been deleted.",
- 'invoice-created': "Invoice created.",
- 'total': "Total",
- 'taxes': "Taxes ({rate}%)\u00A0:\u00A0{amount}",
- 'degressive-rate': "Degressive rate",
- 'total-amount-discountable-daily': "Total discountable:\u00A0{amount}\u00A0/\u0A00day",
- 'items-count': [
- "{count} item",
- "{count} items",
- ],
- 'items-count-total': [
- "{count} item in total",
- "{count} items in total",
- ],
- 'used-count': [
- "{count} used",
- "{count} used",
- ],
- 'no-items': "No items",
- 'events-count-total': [
- "Total {count} event",
- "Total {count} events",
- ],
- 'stock-items-count': "{count} in stock",
- 'out-of-order-items-count': "{count} out of order",
- 'sub-total': "Sub-total",
- 'total-amount': "Total amount",
- 'total-amount-without-taxes': "Total amount excl. tax:\u00A0{amount}",
- 'total-amount-after-discount': "Total excl. tax after discount:\u00A0{amount}",
- 'total-replacement': "Total replacement price: {total}",
- 'total-value': "Total value",
- 'total-quantity': "Total quantity: {total}",
- 'daily-amount': "Daily amount: {amount}",
- 'replacement-value-amount': "Remplacement value\u00A0: {amount}",
- 'calculate': "Calculate",
- 'customization': 'Customization',
- 'day': "day",
- 'days-count': [
- '{duration} day',
- '{duration} days',
- ],
- 'price-per-day': "{price}\u00A0/\u00A0day",
- 'current': "current",
- 'time': "time",
- 'delete-selected': "Delete-selected",
-
- "daily-total-without-tax": "Daily total excl. tax:",
- "daily-total": "Daily total:",
- "total-discountable": "Total discountable:",
- "total-after-discount": "Total with discount:",
- "total-without-taxes": "Total excl. tax:",
- "total-taxes": "Taxes:",
- "total-with-taxes": "Total incl. taxes:",
- "excl-tax": "excl. tax",
- 'ratio': "ratio",
- 'ratio-long': "Ratio",
- 'tags': "Tags",
- 'add-tags': "Add tags",
- 'remove-all-tags': "Remove all tags",
- 'remaining-count': "{count} remaining",
- 'departure-inventory': "Departure inventory",
- 'return-inventory': "Return inventory",
- 'grouped-by': "Display grouped by:",
- 'not-grouped': "Not grouped",
- 'start-on': "Start on",
- 'expected-end-on': "Expected end on",
- 'back-to-home': "Back to homepage",
- 'back-to-schedule': "Back to schedule",
- 'previous-month': "Previous month",
- 'next-month': "Next month",
-
- 'used-by': "Used by",
- 'events-count': ['{count} event', '{count} events'],
-
- 'use': "Use",
- 'create-company': "Add a new company",
-
- 'inventories': "Inventories",
- 'terminate-inventory': "Terminate inventory",
- 'inventory-validation-error': "Some quantities are not valid. Please double-check the list.",
-
- 'reuse-list-from-event': "Add materials from another event...",
- 'choose-event-to-reuse-materials-list': "Choose an event to reuse its materials list",
- 'type-to-search-event': "Type in to search an event...",
- 'and-count-more-older-events': [
- "...and one older event.",
- "...and {count} older events.",
- ],
- 'reuse-list-from-event-warning': "Be careful, the materials will be added to the already selected, and the list will be saved right away!",
- 'choose': "Choose",
- 'use-these-materials': "Use these materials",
- 'choose-another-one': "Choose another one",
- 'event-materials': "Materials of event \"{name}\"",
- 'created-by': "Created by",
-
- 'event': "Event",
- 'events': "Events",
- 'user': "User",
- 'beneficiary': "Beneficiary",
- 'main-beneficiary': "Main Beneficiary",
- 'borrower': "Borrower",
- 'technicians': "Technicians",
- 'material': "Material",
- 'category': "Category",
- 'sub-category': "Sub-category",
- 'categories': "Categories",
- 'parks': "Parks",
- 'technician': "Technician",
-
- 'this-feature-is-coming-soon': "This feature implementation is in progress.",
-
- 'external-links': {
- 'official-website': "Official website",
- 'community-forum': "Community Forum",
- 'github-repository': 'Github repository',
- },
-
- 'select-no-options': "No options available.",
- 'select-no-matching-result': "No matching options.",
-
- 'confirm-cancel-upload-change-tab': "Please note that a file is currently being uploaded. If you leave this tab, it will be cancelled. Are you sure you want to continue?",
- 'confirm-cancel-upload-close-modal': "Please note that a file is currently being uploaded. if you close this window, it will be cancelled. Are you sure you want to continue?",
-
- '@event': {
- 'confirm-delete': "Move this event in trash bin?",
-
- 'event-missing-materials': "Missing materials",
- 'event-missing-materials-help': "These are the missing materials for the period of the event, because it is used in another event, the number needed is too high, or there are some out of order.",
- 'missing-material-count': "Need {quantity}, missing\u00A0{missing}",
-
- 'warning-no-beneficiary': "Warning: this event has no beneficiaries!",
- 'warning-no-material': "Warning: this event is empty, there is no material at the moment!",
-
- 'event-not-confirmed-help': "The event is not confirmed yet. It is subject to change at any time.",
- 'event-confirmed-help': "The event is confirmed: its information should no longer change.",
-
- 'statuses': {
- 'is-past': "This event is past.",
- 'is-currently-running': "This event is currently running.",
- 'is-confirmed': "This event is confirmed.",
- 'is-not-confirmed': "This event is not confirmed yet!",
- 'is-archived': "This event is archived.",
- 'is-locked': "This event is locked because it's confirmed or its return inventory is done.",
- 'has-missing-materials': "This event has missing materials.",
- 'needs-its-return-inventory': "It's necessary to make the return inventory of this event!",
- 'has-not-returned-materials': "This event has some not-returned materials.",
- },
- },
-};
diff --git a/client/src/locale/en/common.yml b/client/src/locale/en/common.yml
new file mode 100644
index 000000000..2444d9e86
--- /dev/null
+++ b/client/src/locale/en/common.yml
@@ -0,0 +1,361 @@
+hello-name: 'Hello {name}!'
+action-add: Add
+action-edit: Edit
+action-view: Display details
+action-view-schedule: Display schedule
+action-trash: Trash bin
+action-restore: Restore
+action-delete: Delete
+action-remove: Remove
+action-rename: Rename
+action-duplicate: Duplicate
+action-enable: Enable
+action-disable: Disable
+action-refresh: Refresh data
+'yes': 'Yes'
+'no': 'No'
+warning: Warning!
+loading: Loading...
+please-confirm: Please confirm...
+yes-delete: 'Yes, delete'
+yes-trash: 'Yes, move in trash bin'
+yes-permanently-delete: 'Yes, permanently delete'
+yes-regenerate-link: 'Yes, regenerate the link'
+yes-restore: 'Oui, restaurer'
+changes-exists-really-cancel: Some changes have not been saved. Do you really want to leave this page?
+yes-leave-page: 'Yes, leave page'
+cancel: Cancel
+close: Close
+confirm: Confirm
+copy-to-clipboard: Copy to clipboard
+copied-to-clipboard: Copied to clipboard!
+copy: Copy
+copied: Copied!
+almost-done: Almost done...
+done: Done
+refresh-page: Refresh the page
+take-control: Take control
+update-in-progress: Update in progress
+regenerate-link: Regenerate link
+please-choose: Please choose...
+start-typing-to-search: Start typing to search...
+type-at-least-count-chars-to-search:
+ - 'Type again, at least {count} character to search...'
+ - 'Type again, at least {count} characters to search...'
+count-chars:
+ - '{count} char'
+ - '{count} chars'
+empty-state: There are no records to display at this time.
+search-term: Search
+no-result-found-try-another-search: No results. Try another search term.
+locked-reason: 'Locked: {reason}'
+clear-filters: Clear filters
+optional: Optional
+n-persons:
+ - '{count} person'
+ - '{count} persons'
+name-and-n-others:
+ - '{name} and {count} other'
+ - '{name} and {count} others'
+add-comment: Add a comment
+modify-comment: Modify comment
+save: Save
+manually-save: Manually save
+save-draft: Save draft
+add: Add
+saving: Saving...
+saved: '{entity} saved.'
+deleting: Deleting...
+reset-date: Reset date
+reset-period: Reset period
+actions: Actions
+informations: Information
+connexion-infos: Credentials
+personal-infos: Personal information
+minimal-infos: Minimal information
+extra-infos: Additional information
+stock-infos: Stock information
+billing-infos: Billing information
+other-infos: Other information
+other: Other
+documents: Documents
+billing: Billing
+history: History
+special-attributes: Special attributes
+schedule: Schedule
+pseudo: Pseudo
+email-address-or-pseudo: E-mail address or Pseudo
+password: Password
+first-name: First name
+last-name: Last name
+name: Name
+nickname: Nickname
+company: Company
+person: Person
+legal-name: Legal name
+contact-details: Contact details
+email: E-mail
+phone: Phone
+address: Address
+street: Street and Number
+postal-code: Postal code
+city: City
+locality: City
+country: Pays
+access: Access
+groups:
+ administration: Administration
+ management: Management
+ readonly-planning-general: Schedule consultation
+ external: External
+owner: Owner
+opening-hours: Opening hours
+hours: hours
+minutes: minutes
+notes: Notes
+description: Description
+label-colon: '{label}:'
+ref: Ref.
+ref-ref: 'Ref.: {reference}'
+reference: Reference
+number: Number
+park: Park
+park-name: 'Park "{name}"'
+park-and-location: Park and location
+park-location: Location in park
+prices: Prices
+identification-type: Identification type
+rental-price: Rental price
+replacement-price: Replacement price
+rent-price: Rent. price
+repl-price: Repl. price
+value-per-day: "{value}\_/\_day"
+serial-number: Serial n°
+examples-list: 'Examples: {list}, etc.'
+not-specified: Not specified
+qty: Qty
+stock-qty: Stock qty
+stock-quantity: Stock quantity
+out-of-order-qty: Out of order qty
+out-of-order-quantity: Out of order quantity
+remaining-qty: Remaining qty
+awaited-qty-dots: 'Awaited qty:'
+actual-qty: Actual qty
+quantities: Quantities
+period: Period
+status: Status
+materials: Materials
+discountable: Discountable?
+is-broken: Out of order?
+broken: Out of order
+is-lost: Lost?
+lost: Lost
+purchase-date: Purchase date
+material-is-discountable: >-
+ The material is "discountable": a discount amount can be applied to this material.
+hidden-on-invoice: Hidden on invoice?
+material-not-displayed-on-invoice: The material is not displayed on invoices.
+price-must-be-zero: the rental price must be 0
+all-parks: All parks combined
+all-categories: All categories
+all-sub-categories: All sub-categories
+not-categorized: Not categorized
+not-limited: not limited
+open-trash-bin: Display trash bin
+display-not-deleted-items: Display not deleted items
+created-at: 'Created at: {date}'
+updated-at: 'Updated at: {date}'
+state: State
+picture: Picture
+add-a-picture: Add a picture
+change-the-picture: Change the picture
+remove-the-picture: Remove the picture
+event-details: Event's details
+title: Title
+date: Date
+dates: Dates
+start-end-dates: Start and end dates
+start-date: Start Date
+end-date: End Date
+location: Location
+duration: Duration
+duration-days:
+ - 'Duration: {duration} day'
+ - 'Duration: {duration} days'
+hours-count: "{count} h"
+confirmed: Confirmed
+not-confirmed: Not confirmed
+is-billable: Is billable?
+event-is-now-billable: This event is now billable.
+is-not-billable-help: '"Loan" Mode: no billing.'
+is-billable-help: '"Rent" Mode: billing possible.'
+confirm-event: Confirm event
+unconfirm-event: Set event back on hold
+delete-event: Delete event
+duplicate-event: Duplicate event
+print: Print
+print-summary: Print this summary
+open: Open
+in: 'In {location}'
+for: 'For {beneficiary}'
+mobilization-period: 'Mobilization {period}'
+open-in-google-maps: Open in Google Maps
+or: or
+with: With
+into: into
+in-progress: in progress
+invoice: Invoice
+estimate: Estimate
+estimates: Estimates
+missing-beneficiary: Missing beneficiary
+requested-qty: Requested qty
+missing-qty: Missing qty
+position-held: Position held
+with-amount-of: 'for an amount of {amount}'
+with-amount-of-excl-tax: 'for an amount of {amount} excl. tax'
+discount: Discount
+without-discount: Without discount
+discount-rate: "{rate}\_% off"
+create-invoice: Create invoice
+create-estimate: Create estimate
+download: Download
+download-pdf: Download PDF file
+total: Total
+taxes: "Taxes ({rate}%)\_:\_{amount}"
+tax: Tax
+degressive-rate: Degressive rate
+material-count:
+ - '{count} material'
+ - '{count} materials'
+materials-count-total:
+ - '{count} material in total'
+ - '{count} materials in total'
+used-count:
+ - '{count} used'
+ - '{count} used'
+no-items: No items
+events-count-total:
+ - 'Total {count} event'
+ - 'Total {count} events'
+stock-items-count: '{count} in stock'
+out-of-order-items-count: '{count} out of order'
+available-items-count:
+ - '{count} currently available'
+ - '{count} currently available'
+total-value: Total value
+total-quantity: 'Total quantity: {total}'
+daily-amount: 'Daily amount: {amount}'
+replacement-value-amount: "Remplacement value\_: {amount}"
+calculate: Calculate
+customization: Customization
+day: day
+days-count:
+ - '{duration} day'
+ - '{duration} days'
+price-per-day: "{price}\_/\_day"
+price-for-x-days:
+ - "{price}\_/\_{duration}\_day"
+ - "{price}\_/\_{duration}\_days"
+current: current
+time: time
+delete-selected: Delete-selected
+subtotal: Subtotal
+total-amount: Total amount
+total-amount-without-taxes: "Total amount excl. tax:\_{amount}"
+total-amount-after-discount: "Total excl. tax after discount:\_{amount}"
+total-replacement: 'Total replacement price: {total}'
+daily-total-without-tax: 'Daily total excl. tax:'
+daily-total: 'Daily total:'
+total-without-taxes: 'Total excl. tax:'
+total-taxes: 'Taxes:'
+total-with-taxes: 'Total incl. taxes:'
+total-dots: "Total:"
+excl-tax: excl. tax
+ratio: ratio
+ratio-long: Ratio
+tags: Tags
+add-tags: Add tags
+remaining-count: '{count} remaining'
+departure-inventory: Departure inventory
+return-inventory: Return inventory
+grouped-by: 'Display grouped by:'
+not-grouped: Not grouped
+start-on: Start on
+expected-end-on: Expected end on
+back-to-home: Back to homepage
+back-to-schedule: Back to schedule
+previous-month: Previous month
+next-month: Next month
+used-by: Used by
+events-count:
+ - '{count} event'
+ - '{count} events'
+use: Use
+create-company: Add a new company
+inventories: Inventories
+terminate-inventory: Terminate inventory
+inventory-validation-error: Some quantities are not valid. Please double-check the list.
+reuse-list-from-event: Add materials from another event...
+choose-event-to-reuse-materials-list: Choose an event to reuse its materials list
+type-to-search-event: Type in to search an event...
+and-count-more-older-events:
+ - ...and one older event.
+ - '...and {count} older events.'
+choose: Choose
+use-these-materials: Use these materials
+choose-another-one: Choose another one
+created-by: Created by
+event: Event
+events: Events
+user: User
+beneficiary: Beneficiary
+main-beneficiary: Main Beneficiary
+borrower: Borrower
+technicians: Technicians
+material: Material
+category: Category
+sub-category: Sub-category
+categories: Categories
+parks: Parks
+technician: Technician
+subject: Subject
+subject-is: "Subject\_: {subject}"
+message-body: Message body
+this-feature-is-coming-soon: This feature implementation is in progress.
+external-links:
+ official-website: Official website
+ support-platform: Support platform
+select-no-options: No options available.
+select-no-matching-result: No matching options.
+remove-park-context: Remove global park context
+confirm-cancel-upload-change-tab: >-
+ Please note that a file is currently being uploaded. If you leave this tab,
+ it will be cancelled. Are you sure you want to continue?
+confirm-cancel-upload-close-modal: >-
+ Please note that a file is currently being uploaded. if you close this
+ window, it will be cancelled. Are you sure you want to continue?
+
+'@event':
+ confirm-delete: Move this event in trash bin?
+ event-missing-materials: Missing materials
+ event-missing-materials-help: >-
+ These are the missing materials for the period of the event, because it
+ is used in another event, the number needed is
+ too high, or there are some out of order.
+ missing-material-count: "Need {quantity}, missing\_{missing}"
+ warning-no-beneficiary: 'Warning: this event has no beneficiaries!'
+ warning-no-material: 'Warning: this event is empty; it contains no materials.'
+ event-not-confirmed-help: The event is not confirmed yet. It is subject to change at any time.
+ event-confirmed-help: 'The event is confirmed: its information should no longer change.'
+ statuses:
+ is-past: This event is past.
+ is-currently-running: This event is currently running.
+ is-confirmed: This event is confirmed.
+ is-not-confirmed: This event is not confirmed yet!
+ is-archived: This event is archived.
+ is-locked: >-
+ This event is locked because it's confirmed or its return inventory
+ is done.
+ has-missing-materials: This event has missing materials.
+ needs-its-return-inventory: It's necessary to make the return inventory of this event!
+ has-not-returned-materials: This event has some not-returned materials.
diff --git a/client/src/locale/en/date.js b/client/src/locale/en/date.js
deleted file mode 100644
index b8123331d..000000000
--- a/client/src/locale/en/date.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export default {
- // - Formats simples.
- 'on-date': "on {date}",
- 'from-date': "from\u00A0{date}",
- 'to-date': "to\u00A0{date}",
- 'from-date-to-date': "from\u00A0{from} to\u00A0{to}",
-
- // - Formats "dans une phrase".
- 'date-in-sentence': "the {date}",
- 'period-in-sentence': "the period from {from} to {to}",
-};
diff --git a/client/src/locale/en/date.yml b/client/src/locale/en/date.yml
new file mode 100644
index 000000000..4e8a48208
--- /dev/null
+++ b/client/src/locale/en/date.yml
@@ -0,0 +1,9 @@
+# - Formats simples.
+on-date: 'on {date}'
+from-date: "from\_{date}"
+to-date: "to\_{date}"
+from-date-to-date: "from\_{from} to\_{to}"
+
+# - Formats "dans une phrase".
+date-in-sentence: 'the {date}'
+period-in-sentence: 'the period from {from} to {to}'
diff --git a/client/src/locale/en/errors.js b/client/src/locale/en/errors.js
deleted file mode 100644
index 78c9daf4f..000000000
--- a/client/src/locale/en/errors.js
+++ /dev/null
@@ -1,31 +0,0 @@
-export default {
- errors: {
- 'unexpected': "An unexpected error occurred, please try again.",
- 'unexpected-while-saving': "An unexpected error occurred while saving, please try again.",
- 'unexpected-while-deleting': "An unexpected error occurred while deleting, please try again.",
- 'unexpected-while-restoring': "An unexpected error occurred while restoring, please try again.",
- 'unexpected-while-calculating': "An unexpected error occurred while calculating, please try again.",
- 'unexpected-while-fetching': "An unexpected error occurred while retrieving the data.",
- 'api-unreachable': "The service is currently unreachable. Please check your internet connection and try again.",
- 'record-not-found': "This record does not exist.",
- 'page-not-found': "The requested page does not exist.",
- 'unknown': "Unknown error.",
- 'validation': "Please check the information provided in the form.",
- 'already-exists': "This record already exists.",
- 'critical': [
- "A critical error has occurred, please refresh the page.",
- "If the problem persists, please contact an administrator.",
- ].join('\n'),
-
- //
- // - Erreurs liées à un fichier.
- //
-
- 'file-upload-failed': "An unexpected error occurred while sending the file.",
- 'file-type-not-allowed': "This file type is not supported.",
- 'file-size-exceeded': "File too large. Maximum {max}.",
- 'file-already-exists': "This file already exists in the list.",
- 'file-not-a-valid-image': "The file is not part of the accepted image types.",
- 'file-unknown-error': "Unknown error occurred with the file.",
- },
-};
diff --git a/client/src/locale/en/errors.yml b/client/src/locale/en/errors.yml
new file mode 100644
index 000000000..1574937f4
--- /dev/null
+++ b/client/src/locale/en/errors.yml
@@ -0,0 +1,29 @@
+unexpected: 'An unexpected error occurred, please try again.'
+unexpected-while-saving: 'An unexpected error occurred while saving, please try again.'
+unexpected-while-deleting: 'An unexpected error occurred while deleting, please try again.'
+unexpected-while-restoring: 'An unexpected error occurred while restoring, please try again.'
+unexpected-while-calculating: 'An unexpected error occurred while calculating, please try again.'
+unexpected-while-selecting: 'An unexpected error occurred while selecting, please try again.'
+unexpected-while-fetching: An unexpected error occurred while retrieving the data.
+api-unreachable: >-
+ The service is currently unreachable. Please check your internet
+ connection and try again.
+record-not-found: This record does not exist.
+page-not-found: The requested page does not exist.
+unknown: Unknown error.
+validation: Please check the information provided in the form.
+already-exists: This record already exists.
+critical: |-
+ A critical error has occurred, please refresh the page.
+ If the problem persists, please contact an administrator.
+
+#
+# - Erreurs liées à un fichier.
+#
+
+file-upload-failed: An unexpected error occurred while sending the file.
+file-type-not-allowed: This file type is not supported.
+file-size-exceeded: 'File too large. Maximum {max}.'
+file-already-exists: This file already exists in the list.
+file-not-a-valid-image: The file is not part of the accepted image types.
+file-unknown-error: Unknown error occurred with the file.
diff --git a/client/src/locale/en/index.js b/client/src/locale/en/index.js
deleted file mode 100644
index a991bd892..000000000
--- a/client/src/locale/en/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import common from './common';
-import date from './date';
-import errors from './errors';
-
-export default {
- ...common,
- ...date,
- ...errors,
-};
diff --git a/client/src/locale/en/index.ts b/client/src/locale/en/index.ts
new file mode 100644
index 000000000..f7f9f481d
--- /dev/null
+++ b/client/src/locale/en/index.ts
@@ -0,0 +1,9 @@
+import common from './common.yml';
+import date from './date.yml';
+import errors from './errors.yml';
+
+export default {
+ ...common,
+ ...date,
+ errors,
+};
diff --git a/client/src/locale/fr/common.js b/client/src/locale/fr/common.js
deleted file mode 100644
index 4be906bfe..000000000
--- a/client/src/locale/fr/common.js
+++ /dev/null
@@ -1,390 +0,0 @@
-export default {
- 'hello-name': "Bonjour {name}\u00A0!",
- 'your-settings': "Vos paramètres",
- 'logout-quit': "Quitter Loxya",
- 'action-add': "Ajouter",
- 'action-edit': "Modifier",
- 'action-view': "Afficher en détail",
- 'action-view-schedule': "Afficher le calendrier",
- 'action-trash': "Corbeille",
- 'action-restore': "Restaurer",
- 'action-delete': "Supprimer",
- 'action-remove': "Enlever",
- 'action-rename': "Renommer",
- 'action-duplicate': "Dupliquer",
- 'action-enable': "Activer",
- 'action-disable': "Désactiver",
- 'action-refresh': "Rafraîchir les données",
- 'yes': "Oui",
- 'no': "Non",
- 'warning': "Attention\u00A0!",
- 'loading': "Chargement en cours...",
- 'please-confirm': "Veuillez confirmer...",
- 'yes-delete': "Oui, supprimer",
- 'yes-trash': "Oui, mettre à la corbeille",
- 'yes-permanently-delete': "Oui, supprimer définitivement",
- 'yes-regenerate-link': "Oui, re-générer le lien",
- 'yes-restore': "Oui, restaurer",
- 'changes-exists-really-cancel': "Des modifications n'ont pas été enregistrées. Êtes-vous sûr de vouloir quitter cette page\u00A0?",
- 'yes-leave-page': "Oui, quitter la page",
- 'cancel': "Annuler",
- 'close': "Fermer",
- 'confirm': "Confirmer",
- 'copy-to-clipboard': "Copier dans le presse-papier",
- 'copied-to-clipboard': "Copié dans le presse-papiers\u00A0!",
- 'copy': "Copier",
- 'copied': "Copié\u00A0!",
- 'almost-done': "Presque terminé...",
- 'done': "Terminé",
- 'refresh-page': "Actualiser la page",
- 'take-control': "Prendre la main",
- 'update-in-progress': "Modification en cours",
- 'regenerate-link': "Re-générer le lien",
- 'please-choose': "Veuillez choisir...",
- 'start-typing-to-search': "Commencez à écrire pour rechercher...",
- 'type-at-least-count-chars-to-search': [
- "Entrez au moins {count} lettre de plus pour rechercher...",
- "Entrez au moins {count} lettres de plus pour rechercher...",
- ],
- 'count-chars': ["{count} caractère", "{count} caractères"],
- 'empty-state': "Il n'y a aucun enregistrement à afficher pour le moment.",
- 'search-term': "Recherche",
- 'no-result-found-try-another-search': "Aucun résultat. Essayez avec une autre recherche.",
- 'locked': "verrouillé",
- 'clear-filters': "Réinitialiser les filtres",
- 'optional': "Optionnel",
- 'n-persons': ["{count} personne", "{count} personnes"],
- 'name-and-n-others': ["{name} et {count} autre", "{name} et {count} autres"],
- 'add-comment': "Ajouter un commentaire",
- 'modify-comment': "Modifier le commentaire",
- 'save': "Sauvegarder",
- 'manually-save': "Sauvegarder manuellement",
- 'save-draft': "Sauvegarder le brouillon",
- 'add': "Ajouter",
- 'saving': "Sauvegarde...",
- 'saved': "{entity} sauvegardé.",
- 'deleting': "Suppression...",
- 'reset-date': "Réinitialiser la date",
- 'reset-period': "Réinitialiser la période",
- 'actions': "Actions",
- 'informations': "Informations",
- 'connexion-infos': "Informations de connexion",
- 'personal-infos': "Informations personnelles",
- 'minimal-infos': "Informations minimales",
- 'extra-infos': "Informations additionnelles",
- 'stock-infos': "Informations liées au stock",
- 'billing-infos': "Informations de facturation",
- 'other-infos': "Autres informations",
- 'documents': "Documents",
- 'billing': "Facturation",
- 'history': "Historique",
- 'special-attributes': "Caractéristiques spéciales",
- 'schedule': "Agenda",
- 'pseudo': "Identifiant",
- 'email-address-or-pseudo': "Adresse e-mail / Identifiant",
- 'password': "Mot de passe",
- 'first-name': "Prénom",
- 'last-name': "Nom",
- 'name': "Nom",
- 'nickname': "Surnom",
- 'company': "Société",
- 'person': "Personne",
- 'legal-name': "Raison sociale",
- 'contact-details': "Coordonnées",
- 'email': "E-mail",
- 'phone': "Téléphone",
- 'address': "Adresse",
- 'street': "Numéro et rue",
- 'postal-code': "Code postal",
- 'city': "Ville",
- 'locality': "Ville",
- 'country': "Pays",
- 'group': "Groupe",
- 'admin': "Administrateur",
- 'member': "Membre",
- 'visitor': "Visiteur",
- 'owner': "Propriétaire",
- 'opening-hours': "Horaires d'ouverture",
- 'hours': "heures",
- 'minutes': "minutes",
- 'notes': "Notes",
- 'description': "Description",
- 'label-colon': "{label}\u00A0:",
- 'ref': "Réf.",
- 'ref-ref': "Ref.\u00A0: {reference}",
- 'reference': "Référence",
- 'number': "Numéro",
- 'park': "Parc",
- 'park-name': "Parc «\u00A0{name}\u00A0»",
- 'prices': "Tarifs",
- 'rental-price': "Tarif location",
- 'replacement-price': "Prix de remplacement",
- 'rent-price': "Tarif loc.",
- 'repl-price': "Val. rempl.",
- 'value-per-day': '{value}\u00A0/\u00A0jour',
- 'serial-number': "N° de série",
- 'examples-list': "Exemples\u00A0: {list}, etc.",
- 'not-specified': "Non renseigné(e)",
-
- 'qty': "Qté",
- 'stock-qty': "Qté stock",
- 'stock-quantity': "Quantité en stock",
- 'out-of-order-qty': "Qté en panne",
- 'out-of-order-quantity': "Quantité en panne",
- 'remaining-qty': "Qté restante",
- 'awaited-qty-dots': "Qté attendue\u00A0:",
- 'actual-qty': "Qté effective",
- 'quantities': "Quantités",
- 'period': "Période",
- 'status': "Statut",
- 'materials': "Matériel",
-
- 'discountable': "Remisable\u00A0?",
- 'is-broken': "En panne\u00A0?",
- 'broken': "En panne",
- 'is-lost': "Perdu\u00A0?",
- 'lost': "Perdu",
- 'purchase-date': "Date d'achat",
- 'material-is-discountable': "Le matériel est «\u00A0remisable\u00A0»\u00A0: une remise peut être appliquée sur ce matériel.",
- 'hidden-on-invoice': "Caché sur la facture\u00A0?",
- 'material-not-displayed-on-invoice': "Le matériel n'est pas affiché sur les factures.",
- 'price-must-be-zero': "le tarif de location doit être égal à 0",
- 'all-parks': "Tous parcs confondus",
- 'all-categories': "Toutes catégories",
- 'all-sub-categories': "Toutes sous-catégories",
- 'not-limited': "non limité",
- 'open-trash-bin': "Afficher la corbeille",
- 'display-not-deleted-items': "Afficher les enregistrements non supprimés",
- 'created-at': "Créé le\u00A0: {date}",
- 'updated-at': "Modifié le\u00A0: {date}",
- 'state': "État",
- 'picture': "Photo",
- 'add-a-picture': "Ajouter une photo",
- 'change-the-picture': "Changer la photo",
- 'remove-the-picture': "Supprimer la photo",
-
- 'event-details': "Détails de l'événement",
- 'title': "Titre",
- 'date': "Date",
- 'dates': "Dates",
- 'start-end-dates': "Dates de début et fin",
- 'start-date': "Date de début",
- 'end-date': "Date de fin",
- 'location': "Lieu",
- 'duration': "Durée",
- 'duration-days': [
- "Durée {duration} jour",
- "Durée {duration} jours",
- ],
- 'confirmed': "Confirmé",
- 'not-confirmed': "Non confirmé",
- 'is-billable': "Est facturable\u00A0?",
- 'event-is-now-billable': "Cet événement est maintenant facturable.",
- 'is-not-billable-help': "Mode «\u00A0Prêt\u00A0»\u00A0: pas de facturation.",
- 'is-billable-help': "Mode «\u00A0Location\u00A0»\u00A0: facturation possible.",
- 'confirm-event': "Confirmer l'événement",
- 'unconfirm-event': "Remettre l'événement en attente",
- 'delete-event': "Supprimer l'événement",
- 'duplicate-event': "Dupliquer l'événement",
- 'print': "Imprimer",
- 'print-summary': "Imprimer ce récapitulatif",
- 'open': "Ouvrir",
- 'in': "À {location}",
- 'mobilization-period': "Mobilisation {period}",
- 'open-in-google-maps': "Ouvrir dans Google Maps",
- 'or': "ou",
- 'for': "Pour",
- 'with': "Avec",
- 'into': "dans",
- 'in-progress': "en cours",
- 'invoice': "Facture",
- 'estimate': "Devis",
- 'estimates': "Devis",
- 'no-invoice-help': "Il n'y a aucune facture pour le moment.",
- 'no-estimate-help': "Il n'y a aucun devis pour le moment.",
- 'estimate-title': "Devis du {date} à {hour}",
- 'confirm-delete-estimate': "Voulez-vous vraiment supprimer ce devis\u00A0?",
- 'warning-no-estimate-before-billing': "Attention, cet événement n'a pas eu de devis\u00A0!",
- 'warning-event-has-invoice': "Attention, cet événement a déjà une facture\u00A0!",
- 'missing-beneficiary': "Bénéficiaire manquant",
- 'requested-qty': "Qté demandée",
- 'missing-qty': "Qté manquante",
- 'position-held': "Poste occupé",
- 'not-billable-help': "Vous ne pouvez pas créer de devis ou facture pour un événement qui n'a pas de bénéficiaire.",
- 'invoice-title': "Facture n° {number}, générée le {date}",
- 'with-amount-of': "pour un montant de {amount}",
- 'with-amount-of-excl-tax': "pour un montant de {amount} H.T.",
- 'create-event-invoice-help': "Vous pouvez créer une facture au nom du premier bénéficiaire de la liste (en ajoutant une remise si besoin) en cliquant sur le bouton ci-dessous.",
- 'create-event-estimate-help': "Vous pouvez créer un devis au nom du premier bénéficiaire de la liste (en ajoutant une remise si besoin) en cliquant sur le bouton ci-dessous.",
- 'contact-someone-to-create-invoice': "Si besoin, contactez un membre de l'équipe pour lui demander d'éditer la facture.",
- 'contact-someone-to-create-estimate': "Si besoin, contactez un membre de l'équipe pour lui demander d'éditer un devis.",
- 'previous-invoices': 'Anciennes factures',
- 'discount': "Remise",
- 'without-discount': "sans remise",
- 'discount-rate': "Remise {rate}\u00A0%",
- 'no-discount-applicable': "Aucune remise n'est applicable pour cet événement (pas de matériel remisable).",
- 'max-discount-rate-help': "Le taux de remise maximum est de {rate}\u00A0% car il y a du matériel non-remisable.",
- 'wanted-discount-rate': "Remise en %",
- 'wanted-total-amount': "Montant total",
- 'create-invoice': "Créer la facture",
- 'create-estimate': "Créer un devis",
- 'download': "Télécharger",
- 'download-pdf': "Télécharger au format PDF",
- 'download-barcode': "Télécharger le code-barre",
- 'download-invoice': "Télécharger la facture",
- 'click-here-to-create-estimate': "Cliquez ici pour créer un devis",
- 'click-here-to-create-invoice': "Cliquez ici pour créer une facture",
- 'click-here-to-generate-invoice': "Cliquez ici pour générer une facture",
- 'click-here-to-regenerate-invoice': "Cliquez ici pour refaire une facture",
- 'create-new-estimate': "Créer un nouveau devis",
- 'estimate-created': "Le devis a bien été créé.",
- 'estimate-deleted': "Le devis a été supprimé.",
- 'invoice-created': "La facture a bien été créée.",
- 'total': "Total",
- 'taxes': "Taxes ({rate}%)\u00A0:\u00A0{amount}",
- 'degressive-rate': "Tarif dégressif",
- 'total-amount-discountable-daily': "Total remisable\u00A0:\u00A0{amount}\u00A0/\u00A0jour",
- 'items-count': [
- "{count} article",
- "{count} articles",
- ],
- 'items-count-total': [
- "{count} article au total",
- "{count} articles au total",
- ],
- 'used-count': [
- "{count} utilisé",
- "{count} utilisés",
- ],
- 'no-items': "Aucun article",
- 'events-count-total': [
- "{count} événement au total",
- "{count} événements au total",
- ],
- 'stock-items-count': "{count} en stock",
- 'out-of-order-items-count': "{count} en panne",
- 'sub-total': "Sous-total",
- 'total-amount': "Montant total",
- 'total-amount-without-taxes': "Montant total H.T.\u00A0:\u00A0{amount}",
- 'total-amount-after-discount': "Total H.T. après remise\u00A0:\u00A0{amount}",
- 'total-replacement': "Valeur de remplacement totale\u00A0: {total}",
- 'total-value': "Valeur totale",
- 'total-quantity': "Quantité totale\u00A0: {total}",
- 'daily-amount': "Montant journalier\u00A0: {amount}",
- 'replacement-value-amount': "Valeur de remplacement\u00A0: {amount}",
- 'calculate': "Calculer",
- 'customization': 'Personnalisation',
- 'day': "jour",
- 'days-count': [
- '{duration} jour',
- '{duration} jours',
- ],
- 'price-per-day': "{price}\u00A0/\u00A0jour",
- 'current': "actuel",
- 'time': "heure",
- 'delete-selected': "Effacer la selection",
-
- "daily-total-without-tax": "Total H.T.\u00A0/\u00A0jour\u00A0:",
- "daily-total": "Total\u00A0/\u00A0jour\u00A0:",
- "total-discountable": "Total remisable\u00A0:",
- "total-after-discount": "Total après remise\u00A0:",
- "total-without-taxes": "Total H.T.\u00A0:",
- "total-taxes": "T.V.A.\u00A0:",
- "total-with-taxes": "Total T.T.C.\u00A0:",
- "excl-tax": "H.T.",
- 'ratio': "coef.",
- 'ratio-long': "Coefficient",
- 'tags': "Tags",
- 'add-tags': "Ajouter des tags",
- 'remove-all-tags': "Enlever tous les tags",
- 'remaining-count': "reste {count}",
- 'departure-inventory': "Inventaire de départ",
- 'return-inventory': "Inventaire de retour",
- 'grouped-by': "Voir groupé par\u00A0:",
- 'not-grouped': "Non groupé",
- 'start-on': "Débute le",
- 'expected-end-on': "Fin prévue le",
- 'back-to-home': "Retour à l'accueil",
- 'back-to-schedule': "Retour au planning",
- 'previous-month': "Mois précédent",
- 'next-month': "Mois suivant",
-
- 'used-by': "Utilisé dans",
- 'events-count': ['{count} événement', '{count} événements'],
-
- 'use': "Utiliser",
- 'create-company': "Ajouter une nouvelle société",
-
- 'inventories': "Inventaires",
- 'terminate-inventory': "Terminer l'inventaire",
- 'inventory-validation-error': "Certaines quantités ne sont pas correctes. Veuillez vérifier à nouveau la liste.",
-
- 'reuse-list-from-event': "Ajouter du matériel depuis un autre événement...",
- 'choose-event-to-reuse-materials-list': "Choisir un événement pour réutiliser sa liste de matériel",
- 'type-to-search-event': "Entrez le nom d'un événement...",
- 'and-count-more-older-events': [
- "...et un autre événement plus ancien.",
- "...et {count} autres événements plus anciens.",
- ],
- 'reuse-list-from-event-warning': "Attention, cela va ajouter le matériel à celui déjà sélectionné, et sauvegarder tout de suite après\u00A0!",
- 'choose': "Choisir",
- 'use-these-materials': "Utiliser ce matériel",
- 'choose-another-one': "En choisir un autre",
- 'event-materials': "Matériel de l'événement «\u00A0{name}\u00A0»",
- 'created-by': "Créé par",
-
- 'event': "Événement",
- 'events': "Événements",
- 'user': "Utilisateur",
- 'beneficiary': "Bénéficiaire",
- 'main-beneficiary': "Bénéficiaire principal",
- 'borrower': "Emprunteur",
- 'technicians': "Techniciens",
- 'material': "Matériel",
- 'category': "Catégorie",
- 'sub-category': "Sous-catégorie",
- 'categories': "Catégories",
- 'not-categorized': "Non catégorisé",
- 'parks': "Parcs",
- 'technician': "Technicien",
-
- 'this-feature-is-coming-soon': "Cette fonctionnalité est actuellement en développement.",
-
- 'external-links': {
- 'official-website': "Site web officiel",
- 'community-forum': "Forum de la communauté",
- 'github-repository': 'Dépôt Github',
- },
-
- 'select-no-options': "Aucune option disponible.",
- 'select-no-matching-result': "Aucune option ne correspond à cette recherche",
-
- 'confirm-cancel-upload-change-tab': "Attention, un envoi de fichier est en cours. Si vous quittez cet onglet, l'envoi sera annulé. Êtes-vous sûr de vouloir continuer\u00A0?",
- 'confirm-cancel-upload-close-modal': "Attention, un envoi de fichier est en cours. Si vous fermez cette fenêtre, l'envoi sera annulé. Êtes-vous sûr de vouloir continuer\u00A0?",
-
- '@event': {
- 'confirm-delete': "Mettre cet événement à la corbeille\u00A0?",
-
- 'event-missing-materials': "Matériel manquant",
- 'event-missing-materials-help': "Il s'agit du matériel manquant pour la période de l'événement, car il est utilisé dans un autre événement, le nombre voulu est trop important, ou quelques uns sont en panne.",
- 'missing-material-count': "Besoin de {quantity}, il en manque\u00A0{missing}",
-
- 'warning-no-beneficiary': "Attention, cet événement n'a aucun bénéficiaire\u00A0!",
- 'warning-no-material': "Attention, cet événement est vide, il ne contient aucun matériel pour le moment\u00A0!",
-
- 'event-not-confirmed-help': "L'événement n'est pas encore confirmé, il est susceptible de changer à tout moment.",
- 'event-confirmed-help': "L'événement est confirmé\u00A0: Ses informations ne devraient plus changer.",
-
- 'statuses': {
- 'is-past': "Cet événement est passé.",
- 'is-currently-running': "Cet événement se déroule en ce moment.",
- 'is-confirmed': "Cet événement est confirmé.",
- 'is-not-confirmed': "Cet événement n'est pas encore confirmé\u00A0!",
- 'is-archived': "Cet événement est archivé.",
- 'is-locked': "Cet événement est verrouillé parce qu'il est confirmé ou que son inventaire de retour a été effectué.",
- 'has-missing-materials': "Cet événement a du matériel manquant.",
- 'needs-its-return-inventory': "Il faut faire l'inventaire de retour de cet événement\u00A0!",
- 'has-not-returned-materials': "Cet événement a du matériel qui n'a pas été retourné.",
- },
- },
-};
diff --git a/client/src/locale/fr/common.yml b/client/src/locale/fr/common.yml
new file mode 100644
index 000000000..a4bf0982d
--- /dev/null
+++ b/client/src/locale/fr/common.yml
@@ -0,0 +1,358 @@
+hello-name: "Bonjour {name}\_!"
+action-add: Ajouter
+action-edit: Modifier
+action-view: Afficher en détail
+action-view-schedule: Afficher le calendrier
+action-trash: Corbeille
+action-restore: Restaurer
+action-delete: Supprimer
+action-remove: Enlever
+action-rename: Renommer
+action-duplicate: Dupliquer
+action-enable: Activer
+action-disable: Désactiver
+action-refresh: Rafraîchir les données
+'yes': Oui
+'no': Non
+warning: "Attention\_!"
+loading: Chargement en cours...
+please-confirm: Veuillez confirmer...
+yes-delete: 'Oui, supprimer'
+yes-trash: 'Oui, mettre à la corbeille'
+yes-permanently-delete: 'Oui, supprimer définitivement'
+yes-regenerate-link: 'Oui, re-générer le lien'
+yes-restore: 'Oui, restaurer'
+changes-exists-really-cancel: "Des modifications n'ont pas été enregistrées. Êtes-vous sûr de vouloir quitter cette page\_?"
+yes-leave-page: 'Oui, quitter la page'
+cancel: Annuler
+close: Fermer
+confirm: Confirmer
+copy-to-clipboard: Copier dans le presse-papier
+copied-to-clipboard: "Copié dans le presse-papiers\_!"
+copy: Copier
+copied: "Copié\_!"
+almost-done: Presque terminé...
+done: Terminé
+refresh-page: Actualiser la page
+take-control: Prendre la main
+update-in-progress: Modification en cours
+regenerate-link: Re-générer le lien
+please-choose: Veuillez choisir...
+start-typing-to-search: Commencez à écrire pour rechercher...
+type-at-least-count-chars-to-search:
+ - 'Entrez au moins {count} lettre de plus pour rechercher...'
+ - 'Entrez au moins {count} lettres de plus pour rechercher...'
+count-chars:
+ - '{count} caractère'
+ - '{count} caractères'
+empty-state: Il n'y a aucun enregistrement à afficher pour le moment.
+search-term: Recherche
+no-result-found-try-another-search: Aucun résultat. Essayez avec une autre recherche.
+locked-reason: 'Verrouillé: {reason}'
+clear-filters: Réinitialiser les filtres
+optional: Optionnel
+n-persons:
+ - '{count} personne'
+ - '{count} personnes'
+name-and-n-others:
+ - '{name} et {count} autre'
+ - '{name} et {count} autres'
+add-comment: Ajouter un commentaire
+modify-comment: Modifier le commentaire
+save: Sauvegarder
+manually-save: Sauvegarder manuellement
+save-draft: Sauvegarder le brouillon
+add: Ajouter
+saving: Sauvegarde...
+saved: '{entity} sauvegardé.'
+deleting: Suppression...
+reset-date: Réinitialiser la date
+reset-period: Réinitialiser la période
+actions: Actions
+informations: Informations
+connexion-infos: Informations de connexion
+personal-infos: Informations personnelles
+minimal-infos: Informations minimales
+extra-infos: Informations additionnelles
+stock-infos: Informations liées au stock
+billing-infos: Informations de facturation
+other-infos: Autres informations
+other: Autre
+documents: Documents
+billing: Facturation
+history: Historique
+special-attributes: Caractéristiques spéciales
+schedule: Agenda
+pseudo: Identifiant
+email-address-or-pseudo: Adresse e-mail / Identifiant
+password: Mot de passe
+first-name: Prénom
+last-name: Nom
+name: Nom
+nickname: Surnom
+company: Société
+person: Personne
+legal-name: Raison sociale
+contact-details: Coordonnées
+email: E-mail
+phone: Téléphone
+address: Adresse
+street: Numéro et rue
+postal-code: Code postal
+city: Ville
+locality: Ville
+country: Pays
+access: Accès
+groups:
+ administration: Administration
+ management: Gestion
+ readonly-planning-general: Consultation du planning
+ external: Externe
+owner: Propriétaire
+opening-hours: Horaires d'ouverture
+hours: heures
+minutes: minutes
+notes: Notes
+description: Description
+label-colon: "{label}\_:"
+ref: Réf.
+ref-ref: "Ref.\_: {reference}"
+reference: Référence
+number: Numéro
+park: Parc
+park-name: "Parc «\_{name}\_»"
+park-and-location: Parc et emplacement
+park-location: Emplacement dans le parc
+prices: Tarifs
+identification-type: Type d'identification
+rental-price: Tarif location
+replacement-price: Prix de remplacement
+rent-price: Tarif loc.
+repl-price: Val. rempl.
+value-per-day: "{value}\_/\_jour"
+serial-number: N° de série
+examples-list: "Exemples\_: {list}, etc."
+not-specified: Non renseigné(e)
+qty: Qté
+stock-qty: Qté stock
+stock-quantity: Quantité en stock
+out-of-order-qty: Qté en panne
+out-of-order-quantity: Quantité en panne
+remaining-qty: Qté restante
+awaited-qty-dots: "Qté attendue\_:"
+actual-qty: Qté effective
+quantities: Quantités
+period: Période
+status: Statut
+materials: Matériel
+discountable: "Remisable\_?"
+is-broken: "En panne\_?"
+broken: En panne
+is-lost: "Perdu\_?"
+lost: Perdu
+purchase-date: Date d'achat
+material-is-discountable: "Le matériel est «\_remisable\_»\_: une remise peut être appliquée sur ce matériel."
+hidden-on-invoice: "Caché sur la facture\_?"
+material-not-displayed-on-invoice: Le matériel n'est pas affiché sur les factures.
+price-must-be-zero: le tarif de location doit être égal à 0
+all-parks: Tous parcs confondus
+all-categories: Toutes catégories
+all-sub-categories: Toutes sous-catégories
+not-limited: non limité
+open-trash-bin: Afficher la corbeille
+display-not-deleted-items: Afficher les enregistrements non supprimés
+created-at: "Créé le\_: {date}"
+updated-at: "Modifié le\_: {date}"
+state: État
+picture: Photo
+add-a-picture: Ajouter une photo
+change-the-picture: Changer la photo
+remove-the-picture: Supprimer la photo
+event-details: Détails de l'événement
+title: Titre
+date: Date
+dates: Dates
+start-end-dates: Dates de début et fin
+start-date: Date de début
+end-date: Date de fin
+location: Lieu
+duration: Durée
+duration-days:
+ - "Durée\_: {duration} jour"
+ - "Durée\_: {duration} jours"
+hours-count: "{count} h"
+confirmed: Confirmé
+not-confirmed: Non confirmé
+is-billable: "Est facturable\_?"
+event-is-now-billable: Cet événement est maintenant facturable.
+is-not-billable-help: "Mode «\_Prêt\_»\_: pas de facturation."
+is-billable-help: "Mode «\_Location\_»\_: facturation possible."
+confirm-event: Confirmer l'événement
+unconfirm-event: Remettre l'événement en attente
+delete-event: Supprimer l'événement
+duplicate-event: Dupliquer l'événement
+print: Imprimer
+print-summary: Imprimer ce récapitulatif
+open: Ouvrir
+in: 'À {location}'
+for: 'Pour {beneficiary}'
+mobilization-period: 'Mobilisation {period}'
+open-in-google-maps: Ouvrir dans Google Maps
+or: ou
+with: Avec
+into: dans
+in-progress: en cours
+invoice: Facture
+estimate: Devis
+estimates: Devis
+missing-beneficiary: Bénéficiaire manquant
+requested-qty: Qté demandée
+missing-qty: Qté manquante
+position-held: Poste occupé
+with-amount-of: 'pour un montant de {amount}'
+with-amount-of-excl-tax: 'pour un montant de {amount} H.T.'
+discount: Remise
+without-discount: sans remise
+discount-rate: "Remise de {rate}\_%"
+create-invoice: Créer la facture
+create-estimate: Créer un devis
+download: Télécharger
+download-pdf: Télécharger au format PDF
+total: Total
+taxes: "Taxes ({rate}%)\_:\_{amount}"
+tax: Taxe
+degressive-rate: Tarif dégressif
+materials-count:
+ - '{count} matériel'
+ - '{count} matériels'
+materials-count-total:
+ - '{count} matériel au total'
+ - '{count} matériels au total'
+used-count:
+ - '{count} utilisé'
+ - '{count} utilisés'
+no-items: Aucun article
+events-count-total:
+ - '{count} événement au total'
+ - '{count} événements au total'
+stock-items-count: '{count} en stock'
+out-of-order-items-count: '{count} en panne'
+available-items-count:
+ - '{count} disponible actuellement'
+ - '{count} disponibles actuellement'
+total-value: Valeur totale
+total-quantity: "Quantité totale\_: {total}"
+daily-amount: "Montant journalier\_: {amount}"
+replacement-value-amount: "Valeur de remplacement\_: {amount}"
+calculate: Calculer
+customization: Personnalisation
+day: jour
+days-count:
+ - '{duration} jour'
+ - '{duration} jours'
+price-per-day: "{price}\_/\_jour"
+price-for-x-days:
+ - "{price}\_/\_{duration}\_jour"
+ - "{price}\_/\_{duration}\_jours"
+current: actuel
+time: heure
+delete-selected: Effacer la selection
+subtotal: Sous-total
+total-amount: Montant total
+total-amount-without-taxes: "Montant total H.T.\_:\_{amount}"
+total-amount-after-discount: "Total H.T. après remise\_:\_{amount}"
+total-replacement: "Valeur de remplacement totale\_: {total}"
+daily-total-without-tax: "Total H.T.\_/\_jour\_:"
+daily-total: "Total\_/\_jour\_:"
+total-without-taxes: "Total H.T.\_:"
+total-taxes: "T.V.A.\_:"
+total-with-taxes: "Total T.T.C.\_:"
+total-dots: "Total\_:"
+excl-tax: H.T.
+ratio: coef.
+ratio-long: Coefficient
+tags: Tags
+add-tags: Ajouter des tags
+remaining-count: 'reste {count}'
+departure-inventory: Inventaire de départ
+return-inventory: Inventaire de retour
+grouped-by: "Voir groupé par\_:"
+not-grouped: Non groupé
+start-on: Débute le
+expected-end-on: Fin prévue le
+back-to-home: Retour à l'accueil
+back-to-schedule: Retour au planning
+previous-month: Mois précédent
+next-month: Mois suivant
+used-by: Utilisé dans
+events-count:
+ - '{count} événement'
+ - '{count} événements'
+use: Utiliser
+create-company: Ajouter une nouvelle société
+inventories: Inventaires
+terminate-inventory: Terminer l'inventaire
+inventory-validation-error: >-
+ Certaines quantités ne sont pas correctes. Veuillez vérifier à nouveau la liste.
+choose-event-to-reuse-materials-list: Choisir un événement pour réutiliser sa liste de matériel
+type-to-search-event: Entrez le nom d'un événement...
+and-count-more-older-events:
+ - ...et un autre événement plus ancien.
+ - '...et {count} autres événements plus anciens.'
+choose: Choisir
+use-these-materials: Utiliser ce matériel
+choose-another-one: En choisir un autre
+created-by: Créé par
+event: Événement
+events: Événements
+user: Utilisateur
+beneficiary: Bénéficiaire
+main-beneficiary: Bénéficiaire principal
+borrower: Emprunteur
+technicians: Techniciens
+material: Matériel
+category: Catégorie
+sub-category: Sous-catégorie
+categories: Catégories
+not-categorized: Non catégorisé
+parks: Parcs
+technician: Technicien
+subject: Sujet
+subject-is: 'Sujet: {subject}'
+message-body: Corps du message
+this-feature-is-coming-soon: Cette fonctionnalité est actuellement en développement.
+external-links:
+ official-website: Site web officiel
+ support-platform: Plateforme de support
+select-no-options: Aucune option disponible.
+select-no-matching-result: Aucune option ne correspond à cette recherche
+remove-park-context: Enlever le contexte global par parc
+confirm-cancel-upload-change-tab: "Attention, un envoi de fichier est en cours. Si vous quittez cet onglet, l'envoi sera annulé. Êtes-vous sûr de vouloir continuer\_?"
+confirm-cancel-upload-close-modal: "Attention, un envoi de fichier est en cours. Si vous fermez cette fenêtre, l'envoi sera annulé. Êtes-vous sûr de vouloir continuer\_?"
+
+'@event':
+ confirm-delete: "Mettre cet événement à la corbeille\_?"
+ event-missing-materials: Matériel manquant
+ event-missing-materials-help: >-
+ Il s'agit du matériel manquant pour la période de l'événement, car il
+ est utilisé dans un autre événement, le nombre
+ voulu est trop important, ou quelques uns sont en panne.
+ missing-material-count: "Besoin de {quantity}, il en manque\_{missing}"
+ warning-no-beneficiary: "Attention, cet événement n'a aucun bénéficiaire\_!"
+ warning-no-material: "Attention, cet événement est vide, il ne contient aucun matériel pour le moment\_!"
+ event-not-confirmed-help: >-
+ L'événement n'est pas encore confirmé, il est susceptible de changer à
+ tout moment.
+ event-confirmed-help: "L'événement est confirmé\_: Ses informations ne devraient plus changer."
+ statuses:
+ is-past: Cet événement est passé.
+ is-currently-running: Cet événement se déroule en ce moment.
+ is-confirmed: Cet événement est confirmé.
+ is-not-confirmed: "Cet événement n'est pas encore confirmé\_!"
+ is-archived: Cet événement est archivé.
+ is-locked: >-
+ Cet événement est verrouillé parce qu'il est confirmé ou que son
+ inventaire de retour a été effectué.
+ has-missing-materials: Cet événement a du matériel manquant.
+ needs-its-return-inventory: "Il faut faire l'inventaire de retour de cet événement\_!"
+ has-not-returned-materials: Cet événement a du matériel qui n'a pas été retourné.
diff --git a/client/src/locale/fr/date.js b/client/src/locale/fr/date.js
deleted file mode 100644
index 3c547032b..000000000
--- a/client/src/locale/fr/date.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export default {
- // - Formats simples.
- 'on-date': "le {date}",
- 'from-date': "du\u00A0{date}",
- 'to-date': "au\u00A0{date}",
- 'from-date-to-date': "du\u00A0{from} au\u00A0{to}",
-
- // - Formats "dans une phrase".
- 'date-in-sentence': "le {date}",
- 'period-in-sentence': "la période du {from} au {to}",
-};
diff --git a/client/src/locale/fr/date.yml b/client/src/locale/fr/date.yml
new file mode 100644
index 000000000..d923d7a56
--- /dev/null
+++ b/client/src/locale/fr/date.yml
@@ -0,0 +1,9 @@
+# - Formats simples.
+on-date: 'le {date}'
+from-date: "du\_{date}"
+to-date: "au\_{date}"
+from-date-to-date: "du\_{from} au\_{to}"
+
+# - Formats "dans une phrase".
+date-in-sentence: 'le {date}'
+period-in-sentence: 'la période du {from} au {to}'
diff --git a/client/src/locale/fr/errors.js b/client/src/locale/fr/errors.js
deleted file mode 100644
index cc4cb5cf4..000000000
--- a/client/src/locale/fr/errors.js
+++ /dev/null
@@ -1,31 +0,0 @@
-export default {
- errors: {
- 'unexpected': "Une erreur inattendue s'est produite, veuillez ré-essayer.",
- 'unexpected-while-saving': "Une erreur inattendue s'est produite lors de l'enregistrement, veuillez ré-essayer.",
- 'unexpected-while-deleting': "Une erreur inattendue s'est produite lors de la suppression, veuillez ré-essayer.",
- 'unexpected-while-restoring': "Une erreur inattendue s'est produite lors de la restauration, veuillez ré-essayer.",
- 'unexpected-while-calculating': "Une erreur inattendue s'est produite lors du calcul, veuillez ré-essayer.",
- 'unexpected-while-fetching': "Une erreur inattendue s'est produite lors de la récupération des données.",
- 'api-unreachable': "Le service est actuellement injoignable. Veuillez vérifier votre connexion réseau et réessayer.",
- 'record-not-found': "Cet enregistrement n'existe pas.",
- 'page-not-found': "La page demandée n'existe pas ou plus.",
- 'validation': "Veuillez vérifier les données saisies dans le formulaire.",
- 'unknown': "Erreur inconnue.",
- 'already-exists': "Cet enregistrement existe déjà.",
- 'critical': [
- "Une erreur s'est produite, veuillez actualiser la page.",
- "Si le problème persiste, veuillez contacter un administrateur.",
- ].join('\n'),
-
- //
- // - Erreurs liées à un fichier.
- //
-
- 'file-upload-failed': "Une erreur inattendue s'est produite lors de l'envoi du fichier.",
- 'file-type-not-allowed': "Ce type de fichier n'est pas pris en charge.",
- 'file-size-exceeded': "Fichier trop gros. Maximum {max}.",
- 'file-already-exists': "Ce fichier est déjà présent dans la liste.",
- 'file-not-a-valid-image': "Le fichier ne fait pas partie des types d'image acceptés.",
- 'file-unknown-error': "Erreur inconnue survenue avec le fichier",
- },
-};
diff --git a/client/src/locale/fr/errors.yml b/client/src/locale/fr/errors.yml
new file mode 100644
index 000000000..1b66ab9ca
--- /dev/null
+++ b/client/src/locale/fr/errors.yml
@@ -0,0 +1,36 @@
+unexpected: "Une erreur inattendue s'est produite, veuillez ré-essayer."
+unexpected-while-saving: >-
+ Une erreur inattendue s'est produite lors de l'enregistrement, veuillez ré-essayer.
+unexpected-while-deleting: >-
+ Une erreur inattendue s'est produite lors de la suppression, veuillez ré-essayer.
+unexpected-while-restoring: >-
+ Une erreur inattendue s'est produite lors de la restauration, veuillez ré-essayer.
+unexpected-while-calculating: >-
+ Une erreur inattendue s'est produite lors du calcul, veuillez ré-essayer.
+unexpected-while-selecting: >-
+ Une erreur inattendue s'est produite lors de la sélection, veuillez ré-essayer.
+unexpected-while-fetching: >-
+ Une erreur inattendue s'est produite lors de la récupération des données.
+api-unreachable: >-
+ Le service est actuellement injoignable. Veuillez vérifier votre
+ connexion réseau et réessayer.
+
+record-not-found: Cet enregistrement n'existe pas.
+page-not-found: La page demandée n'existe pas ou plus.
+validation: Veuillez vérifier les données saisies dans le formulaire.
+unknown: Erreur inconnue.
+already-exists: Cet enregistrement existe déjà.
+critical: |-
+ Une erreur s'est produite, veuillez actualiser la page.
+ Si le problème persiste, veuillez contacter un administrateur.
+
+#
+# - Erreurs liées à un fichier.
+#
+
+file-upload-failed: Une erreur inattendue s'est produite lors de l'envoi du fichier.
+file-type-not-allowed: Ce type de fichier n'est pas pris en charge.
+file-size-exceeded: 'Fichier trop gros. Maximum {max}.'
+file-already-exists: Ce fichier est déjà présent dans la liste.
+file-not-a-valid-image: Le fichier ne fait pas partie des types d'image acceptés.
+file-unknown-error: Erreur inconnue survenue avec le fichier
diff --git a/client/src/locale/fr/index.js b/client/src/locale/fr/index.js
deleted file mode 100644
index a991bd892..000000000
--- a/client/src/locale/fr/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import common from './common';
-import date from './date';
-import errors from './errors';
-
-export default {
- ...common,
- ...date,
- ...errors,
-};
diff --git a/client/src/locale/fr/index.ts b/client/src/locale/fr/index.ts
new file mode 100644
index 000000000..f7f9f481d
--- /dev/null
+++ b/client/src/locale/fr/index.ts
@@ -0,0 +1,9 @@
+import common from './common.yml';
+import date from './date.yml';
+import errors from './errors.yml';
+
+export default {
+ ...common,
+ ...date,
+ errors,
+};
diff --git a/client/src/stores/api/@types.ts b/client/src/stores/api/@types.ts
index 2e7b980e4..5deac029b 100644
--- a/client/src/stores/api/@types.ts
+++ b/client/src/stores/api/@types.ts
@@ -25,7 +25,7 @@ export type SortableParams = {
*/
// TODO: Modifier ça pour quelque chose du genre : `{ direction: Direction }`.
// (il faudra adapter le component de tableau qui utilise cette notation obsolète).
- ascending?: 0 | 1,
+ ascending?: 0 | 1 | boolean,
};
export type PaginationParams = {
@@ -53,38 +53,6 @@ export type ListingParams = (
& PaginationParams
);
-//
-// - Types liés aux imports.
-//
-
-export type CsvDelimiter = ',' | ';' | ':' | `\t`;
-
-export type CsvMapping = Record;
-
-export type CsvImport = {
- mapping: T,
- file: File,
- delimiter: CsvDelimiter,
-};
-
-export type CsvColumnError = {
- field: keyof T | string,
- value: string | null,
- error: string,
-};
-
-export type CsvImportError = {
- line: number,
- message: string,
- errors: Array>,
-};
-
-export type CsvImportResults = {
- total: number,
- success: number,
- errors: Array>,
-};
-
//
// - Enveloppes.
//
diff --git a/client/src/stores/api/attributes.ts b/client/src/stores/api/attributes.ts
index ac44ae11e..16da200cb 100644
--- a/client/src/stores/api/attributes.ts
+++ b/client/src/stores/api/attributes.ts
@@ -11,8 +11,15 @@ import type { SchemaInfer } from '@/utils/validation';
// -
// ------------------------------------------------------
+/** Entité concernée par l'attribut. */
+export enum AttributeEntity {
+ MATERIAL = 'material',
+}
+
+/** Type d'attribut. */
export enum AttributeType {
STRING = 'string',
+ TEXT = 'text',
INTEGER = 'integer',
FLOAT = 'float',
BOOLEAN = 'boolean',
@@ -22,6 +29,7 @@ export enum AttributeType {
const AttributeBaseSchema = z.strictObject({
id: z.number(),
name: z.string(),
+ entities: z.array(z.nativeEnum(AttributeEntity)),
});
// NOTE: Pour le moment, pas moyen de faire ça mieux en gardant l'objet `strict`.
@@ -44,6 +52,9 @@ export const AttributeSchema = z.discriminatedUnion('type', [
AttributeBaseSchema.extend({
type: z.literal(AttributeType.DATE),
}),
+ AttributeBaseSchema.extend({
+ type: z.literal(AttributeType.TEXT),
+ }),
]);
// NOTE: Pour le moment, pas moyen de faire ça mieux en gardant l'objet `strict`.
@@ -54,6 +65,10 @@ export const AttributeWithValueSchema = z.discriminatedUnion('type', [
max_length: z.number().nullable(),
value: z.string().nullable(),
}),
+ AttributeBaseSchema.extend({
+ type: z.literal(AttributeType.TEXT),
+ value: z.string().nullable(),
+ }),
AttributeBaseSchema.extend({
type: z.enum([AttributeType.INTEGER, AttributeType.FLOAT]),
unit: z.string().nullable(),
@@ -92,7 +107,7 @@ export const AttributeDetailsSchema = (() => {
),
}),
baseSchema.extend({
- type: z.enum([AttributeType.BOOLEAN, AttributeType.DATE]),
+ type: z.enum([AttributeType.TEXT, AttributeType.BOOLEAN, AttributeType.DATE]),
}),
]);
})();
@@ -115,6 +130,7 @@ export type AttributeDetails = SchemaInfer;
export type AttributeCreate = {
name: string,
+ entities: AttributeEntity[],
type?: AttributeType,
unit?: string,
max_length?: string | number | null,
@@ -130,8 +146,11 @@ export type AttributeEdit = Omit;
// -
// ------------------------------------------------------
-const all = async (categoryId?: Category['id'] | 'none'): Promise => {
- const params = { ...(categoryId !== undefined ? { category: categoryId } : {}) };
+const all = async (categoryId?: Category['id'] | 'none', entity?: AttributeEntity): Promise => {
+ const params = {
+ ...(categoryId !== undefined ? { category: categoryId } : {}),
+ ...(entity !== undefined) ? { entity } : {},
+ };
const response = await requester.get('/attributes', { params });
return AttributeDetailsSchema.array().parse(response.data);
};
diff --git a/client/src/stores/api/beneficiaries.ts b/client/src/stores/api/beneficiaries.ts
index 551b6f22b..75596fc2b 100644
--- a/client/src/stores/api/beneficiaries.ts
+++ b/client/src/stores/api/beneficiaries.ts
@@ -81,6 +81,7 @@ export type BeneficiaryEdit = {
postal_code: string | null,
locality: string | null,
country_id: number | null,
+ user_id?: number,
pseudo?: string,
password?: string,
note: string | null,
diff --git a/client/src/stores/api/bookings.ts b/client/src/stores/api/bookings.ts
index 7c4679e73..2a730331d 100644
--- a/client/src/stores/api/bookings.ts
+++ b/client/src/stores/api/bookings.ts
@@ -3,16 +3,25 @@ import requester from '@/globals/requester';
import { BeneficiarySchema } from './beneficiaries';
import { withPaginationEnvelope } from './@schema';
import {
+ EventExtraSchema,
EventTechnicianSchema,
createEventDetailsSchema,
+ EventMaterialBillableSchema,
+ EventMaterialNotBillableSchema,
} from './events';
+import type Decimal from 'decimal.js';
import type Period from '@/utils/period';
+import type { ZodRawShape } from 'zod';
+import type { Tax } from '@/stores/api/taxes';
import type { Material, UNCATEGORIZED } from '@/stores/api/materials';
import type { Category } from '@/stores/api/categories';
import type { Park } from '@/stores/api/parks';
import type { SchemaInfer } from '@/utils/validation';
-import type { ZodRawShape } from 'zod';
+import type {
+ EventTax,
+ EventTaxTotal,
+} from '@/stores/api/events';
import type {
PaginatedData,
SortableParams,
@@ -29,12 +38,25 @@ export enum BookingEntity {
EVENT = 'event',
}
+//
+// - Schemas secondaires
+//
+
+const BookingMaterialNotBillableSchema = EventMaterialNotBillableSchema;
+const BookingMaterialBillableSchema = EventMaterialBillableSchema;
+const BookingMaterialSchema = z.union([
+ BookingMaterialNotBillableSchema,
+ BookingMaterialBillableSchema,
+]);
+
+const BookingExtraSchema = EventExtraSchema;
+
//
// - Schemas principaux
//
// - Booking excerpt schema.
-export const BookingExcerptSchema = z.strictObject({
+export const BookingExcerptSchema = (() => z.strictObject({
id: z.number(),
entity: z.literal(BookingEntity.EVENT),
title: z.string(),
@@ -52,49 +74,45 @@ export const BookingExcerptSchema = z.strictObject({
categories: z.number().array(), // - Ids des catégories liés.
parks: z.number().array(), // - Ids des parcs liés.
created_at: z.datetime(),
-});
+}))();
// - Booking summary schema.
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-export const createBookingSummarySchema = (augmentation: T) => (
- z
- .strictObject({
- id: z.number(),
- entity: z.literal(BookingEntity.EVENT),
- title: z.string(),
- reference: z.string().nullable(),
- description: z.string().nullable(),
- location: z.string().nullable(),
- color: z.string().nullable(),
- mobilization_period: z.period(),
- operation_period: z.period(),
- beneficiaries: z.lazy(() => BeneficiarySchema.array()),
- technicians: z.lazy(() => EventTechnicianSchema.array()),
- is_confirmed: z.boolean(),
- is_billable: z.boolean(),
- is_archived: z.boolean(),
- is_departure_inventory_done: z.boolean(),
- is_return_inventory_done: z.boolean(),
- has_missing_materials: z.boolean().nullable(),
- has_not_returned_materials: z.boolean().nullable(),
- categories: z.number().array(), // - Ids des catégories liés.
- parks: z.number().array(), // - Ids des parcs liés.
- created_at: z.datetime(),
- })
- .extend(augmentation)
-);
+export const createBookingSummarySchema = (augmentation: T) => z.strictObject({
+ id: z.number(),
+ entity: z.literal(BookingEntity.EVENT),
+ title: z.string(),
+ reference: z.string().nullable(),
+ description: z.string().nullable(),
+ location: z.string().nullable(),
+ color: z.string().nullable(),
+ mobilization_period: z.period(),
+ operation_period: z.period(),
+ beneficiaries: z.lazy(() => BeneficiarySchema.array()),
+ technicians: z.lazy(() => EventTechnicianSchema.array()),
+ materials_count: z.number().nonnegative(),
+ is_confirmed: z.boolean(),
+ is_billable: z.boolean(),
+ is_archived: z.boolean(),
+ is_departure_inventory_done: z.boolean(),
+ is_return_inventory_done: z.boolean(),
+ has_missing_materials: z.boolean().nullable(),
+ has_not_returned_materials: z.boolean().nullable(),
+ categories: z.number().array(), // - Ids des catégories liés.
+ parks: z.number().array(), // - Ids des parcs liés.
+ created_at: z.datetime(),
+}).extend(augmentation);
+
export const BookingSummarySchema = createBookingSummarySchema({});
// - Booking schema.
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-export const createBookingSchema = (augmentation: T) => (
- z.lazy(() => (
- createEventDetailsSchema({
- entity: z.literal(BookingEntity.EVENT),
- ...augmentation,
- })
- ))
-);
+export const createBookingSchema = (augmentation: T) => z.lazy(() => (
+ createEventDetailsSchema({
+ entity: z.literal(BookingEntity.EVENT),
+ ...augmentation,
+ })
+));
export const BookingSchema = createBookingSchema({});
// ------------------------------------------------------
@@ -119,10 +137,26 @@ export type BookingSummary = (
NarrowBooking, Entity>
);
-export type Booking = (
- NarrowBooking, Entity>
+export type Booking = (
+ IsBillable extends true
+ ? Extract, { entity: Entity, is_billable: true }>
+ : Extract, { entity: Entity, is_billable: false }>
);
+//
+// - Secondary Types
+//
+
+export type BookingMaterial =
+ IsBillable extends true
+ ? SchemaInfer
+ : SchemaInfer;
+
+export type BookingExtra = SchemaInfer;
+
+export type BookingTax = EventTax;
+export type BookingTaxTotal = EventTaxTotal;
+
//
// - Édition
//
@@ -132,6 +166,43 @@ export type MaterialQuantity = {
quantity: number,
};
+export type MaterialBillingData = {
+ id: Material['id'],
+ unit_price: Decimal,
+ discount_rate: Decimal,
+};
+
+export type ExtraBillingTaxData = {
+ name: string,
+ is_rate: boolean,
+ value: Decimal,
+};
+
+export type ExtraBillingData = {
+ id: number | null,
+ description: string | null,
+ quantity: number,
+ unit_price: Decimal | null,
+ tax_id: Tax['id'] | null,
+ taxes?: ExtraBillingTaxData[],
+};
+
+export type BillingData = {
+ materials: MaterialBillingData[],
+ extras: ExtraBillingData[],
+ global_discount_rate: Decimal,
+};
+
+export type MaterialResynchronizableField =
+ | 'name'
+ | 'reference'
+ | 'unit_price'
+ | 'degressive_rate'
+ | 'taxes';
+
+export type ExtraResynchronizableField =
+ | 'taxes';
+
//
// - Récupération
//
@@ -172,13 +243,47 @@ const oneSummary = async (entity: BookingEntity, id: Booking['id']): Promise => {
+ const response = await requester.get(`/bookings/${entity}/${id}`);
+ return BookingSchema.parse(response.data);
+};
+
const updateMaterials = async (entity: BookingEntity, id: Booking['id'], materials: MaterialQuantity[]): Promise => {
const response = await requester.put(`/bookings/${entity}/${id}/materials`, materials);
return BookingSchema.parse(response.data);
};
+const updateBilling = async (entity: BookingEntity, id: Booking['id'], data: BillingData): Promise => {
+ const response = await requester.put(`/bookings/${entity}/${id}/billing`, data);
+ return BookingSchema.parse(response.data);
+};
+
+const resynchronizeMaterial = async (
+ entity: BookingEntity,
+ id: Booking['id'],
+ materialId: BookingMaterial['id'],
+ selection: MaterialResynchronizableField[],
+): Promise => {
+ const response = await requester.put(`/bookings/${entity}/${id}/materials/${materialId}/resynchronize`, selection);
+ return BookingMaterialSchema.parse(response.data);
+};
+
+const resynchronizeExtra = async (
+ entity: BookingEntity,
+ id: Booking['id'],
+ extraId: BookingExtra['id'],
+ selection: ExtraResynchronizableField[],
+): Promise => {
+ const response = await requester.put(`/bookings/${entity}/${id}/extras/${extraId}/resynchronize`, selection);
+ return BookingExtraSchema.parse(response.data);
+};
+
export default {
all,
+ one,
oneSummary,
updateMaterials,
+ updateBilling,
+ resynchronizeMaterial,
+ resynchronizeExtra,
};
diff --git a/client/src/stores/api/degressive-rates.ts b/client/src/stores/api/degressive-rates.ts
new file mode 100644
index 000000000..0d7973080
--- /dev/null
+++ b/client/src/stores/api/degressive-rates.ts
@@ -0,0 +1,75 @@
+import { z } from '@/utils/validation';
+import requester from '@/globals/requester';
+
+import type { SchemaInfer } from '@/utils/validation';
+
+// ------------------------------------------------------
+// -
+// - Schema / Enums
+// -
+// ------------------------------------------------------
+
+const DegressiveRateTierSchema = z.strictObject({
+ from_day: z.number().int().positive(),
+ is_rate: z.boolean(),
+ value: z.decimal(),
+});
+
+export const DegressiveRateSchema = z.strictObject({
+ id: z.number(),
+ name: z.string(),
+ is_used: z.boolean(),
+ tiers: z.lazy(() => DegressiveRateTierSchema.array()),
+});
+
+// ------------------------------------------------------
+// -
+// - Types
+// -
+// ------------------------------------------------------
+
+export type DegressiveRate = SchemaInfer;
+
+export type DegressiveRateTier = SchemaInfer;
+
+//
+// - Edition
+//
+
+export type DegressiveRateTierEdit = {
+ from_day: number | string | null,
+ is_rate: boolean | null,
+ value: string | null,
+};
+
+export type DegressiveRateEdit = {
+ name: string | null,
+ tiers: DegressiveRateTierEdit[],
+};
+
+// ------------------------------------------------------
+// -
+// - Fonctions
+// -
+// ------------------------------------------------------
+
+const all = async (): Promise => {
+ const response = await requester.get('/degressive-rates');
+ return DegressiveRateSchema.array().parse(response.data);
+};
+
+const create = async (data: DegressiveRateEdit): Promise => {
+ const response = await requester.post('/degressive-rates', data);
+ return DegressiveRateSchema.parse(response.data);
+};
+
+const update = async (id: DegressiveRate['id'], data: DegressiveRateEdit): Promise => {
+ const response = await requester.put(`/degressive-rates/${id}`, data);
+ return DegressiveRateSchema.parse(response.data);
+};
+
+const remove = async (id: DegressiveRate['id']): Promise => {
+ await requester.delete(`/degressive-rates/${id}`);
+};
+
+export default { all, create, update, remove };
diff --git a/client/src/stores/api/estimates.ts b/client/src/stores/api/estimates.ts
index 5ee459358..03129209c 100644
--- a/client/src/stores/api/estimates.ts
+++ b/client/src/stores/api/estimates.ts
@@ -9,14 +9,21 @@ import type { SchemaInfer } from '@/utils/validation';
// -
// ------------------------------------------------------
+const EstimateTaxSchema = z.strictObject({
+ name: z.string(),
+ is_rate: z.boolean(),
+ value: z.decimal(),
+ total: z.decimal(),
+});
+
export const EstimateSchema = z.strictObject({
id: z.number(),
date: z.datetime(),
url: z.string(),
- discount_rate: z.decimal(),
total_without_taxes: z.decimal(),
+ total_taxes: z.lazy(() => EstimateTaxSchema.array()),
total_with_taxes: z.decimal(),
- currency: z.string(),
+ currency: z.currency(),
});
// ------------------------------------------------------
diff --git a/client/src/stores/api/events.ts b/client/src/stores/api/events.ts
index 3c7375ba1..a87da17b2 100644
--- a/client/src/stores/api/events.ts
+++ b/client/src/stores/api/events.ts
@@ -1,9 +1,8 @@
import { z } from '@/utils/validation';
-import Decimal from 'decimal.js';
import requester from '@/globals/requester';
import { UserSchema } from './users';
+import { MaterialWithContextExcerptSchema } from './materials';
import { DocumentSchema } from './documents';
-import { createMaterialSchema } from './materials';
import { TechnicianSchema } from './technicians';
import { withCountedEnvelope } from './@schema';
import { EstimateSchema } from './estimates';
@@ -13,6 +12,7 @@ import {
} from './beneficiaries';
import type Period from '@/utils/period';
+import type { ZodRawShape } from 'zod';
import type { CountedData } from './@types';
import type { SchemaInfer } from '@/utils/validation';
import type { Document } from './documents';
@@ -22,7 +22,6 @@ import type { Material } from './materials';
import type { Technician } from './technicians';
import type { Beneficiary } from './beneficiaries';
import type { AxiosRequestConfig as RequestConfig } from 'axios';
-import type { ZodRawShape } from 'zod';
// ------------------------------------------------------
// -
@@ -43,30 +42,87 @@ export const EventTechnicianSchema = z.strictObject({
technician: z.lazy(() => TechnicianSchema),
});
+const EventTaxSchema = z.strictObject({
+ name: z.string(),
+ is_rate: z.boolean(),
+ value: z.decimal(),
+});
+
+const EventTaxTotalSchema = EventTaxSchema
+ .extend({ total: z.decimal() });
+
//
// -- Event material schemas / factory
//
-// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-const createEventMaterialSchema = (augmentation: T) => {
- const pivotSchema = z
- .strictObject({
- quantity: z.number().positive(),
- quantity_departed: z.number().nonnegative().nullable(),
- quantity_returned: z.number().nonnegative().nullable(),
- quantity_returned_broken: z.number().nonnegative().nullable(),
- departure_comment: z.string().nullable(),
- })
- .extend(augmentation);
+const EventMaterialBaseSchema = z.strictObject({
+ id: z.number(), // - Id du matériel.
+ name: z.string(),
+ reference: z.string(),
+ category_id: z.number().nullable(),
+ material: z.lazy(() => MaterialWithContextExcerptSchema),
+ quantity: z.number().positive(),
+ quantity_departed: z.number().nonnegative().nullable(),
+ quantity_returned: z.number().nonnegative().nullable(),
+ quantity_returned_broken: z.number().nonnegative().nullable(),
+ departure_comment: z.string().nullable(),
+ unit_replacement_price: z.decimal().nullable(),
+ total_replacement_price: z.decimal().nullable(),
+});
- return z.lazy(() => createMaterialSchema({ pivot: pivotSchema }));
-};
+const createEventMaterialSchemaFactory = (augmentation: T) => (
+ (innerAugmentation: InnerT) => (
+ EventMaterialBaseSchema
+ .extend(augmentation)
+ .extend(innerAugmentation)
+ )
+);
-const EventMaterialSchema = createEventMaterialSchema({});
+const createMaterialNotBillableSchema = createEventMaterialSchemaFactory({});
+const createMaterialBillableSchema = createEventMaterialSchemaFactory({
+ unit_price: z.decimal(),
+ degressive_rate: z.decimal(),
+ unit_price_period: z.decimal(),
+ total_without_discount: z.decimal(),
+ discount_rate: z.decimal(),
+ total_discount: z.decimal(),
+ total_without_taxes: z.decimal(),
+ taxes: z.lazy(() => EventTaxSchema.array()),
+});
-const EventMaterialWithQuantityMissingSchema = createEventMaterialSchema({
+// - Matériel d'événement de base.
+export const EventMaterialNotBillableSchema = createMaterialNotBillableSchema({});
+export const EventMaterialBillableSchema = createMaterialBillableSchema({});
+export const EventMaterialSchema = z.union([
+ EventMaterialNotBillableSchema,
+ EventMaterialBillableSchema,
+]);
+
+// - Matériel d'événement avec quantité manquante.
+const EventMaterialNotBillableWithQuantityMissingSchema = createMaterialNotBillableSchema({
+ quantity_missing: z.number().nonnegative(),
+});
+const EventMaterialBillableWithQuantityMissingSchema = createMaterialBillableSchema({
quantity_missing: z.number().nonnegative(),
});
+const EventMaterialWithQuantityMissingSchema = z.union([
+ EventMaterialBillableWithQuantityMissingSchema,
+ EventMaterialNotBillableWithQuantityMissingSchema,
+]);
+
+//
+// -- Event extra schemas / factories
+//
+
+export const EventExtraSchema = z.strictObject({
+ id: z.number(),
+ description: z.string(),
+ quantity: z.number().positive(),
+ unit_price: z.decimal(),
+ tax_id: z.number().nullable(),
+ taxes: z.lazy(() => EventTaxSchema.array()),
+ total_without_taxes: z.decimal(),
+});
//
// - Schemas principaux
@@ -89,6 +145,7 @@ export const EventSchema = EventSummarySchema.extend({
is_archived: z.boolean(),
is_departure_inventory_done: z.boolean(),
is_return_inventory_done: z.boolean(),
+ materials_count: z.number().nonnegative(),
note: z.string().nullable(),
created_at: z.datetime(),
updated_at: z.datetime().nullable(),
@@ -105,11 +162,11 @@ export const createEventDetailsSchema = (augmentation: T)
})
.extend({
total_replacement: z.decimal(),
- currency: z.string(),
+ currency: z.currency(),
+ has_deleted_materials: z.boolean(),
beneficiaries: z.lazy(() => BeneficiarySchema.array()),
technicians: z.lazy(() => EventTechnicianSchema.array()),
- materials: z.lazy(() => EventMaterialSchema.array()),
- note: z.string().nullable(),
+ note: z.string().nullable().optional(),
author: z.lazy(() => UserSchema).nullable(),
})
.extend(augmentation)
@@ -155,21 +212,20 @@ export const createEventDetailsSchema = (augmentation: T)
.and(z.discriminatedUnion('is_billable', [
z.object({ // TODO: `strictObject` lorsque ce sera possible.
is_billable: z.literal(true),
- estimates: z.lazy(() => EstimateSchema.array()),
- invoices: z.lazy(() => InvoiceSchema.array()),
- degressive_rate: z.decimal(),
- discount_rate: z.decimal(),
- vat_rate: z.decimal(),
- daily_total: z.decimal(),
- total_without_discount: z.decimal(),
- total_discountable: z.decimal(),
- total_discount: z.decimal(),
+ materials: z.lazy(() => EventMaterialBillableSchema.array()),
+ extras: z.lazy(() => EventExtraSchema.array()),
+ estimates: z.lazy(() => EstimateSchema.array()).optional(),
+ invoices: z.lazy(() => InvoiceSchema.array()).optional(),
+ total_without_global_discount: z.decimal(),
+ global_discount_rate: z.decimal(),
+ total_global_discount: z.decimal(),
total_without_taxes: z.decimal(),
- total_taxes: z.decimal(),
+ total_taxes: z.lazy(() => EventTaxTotalSchema.array()),
total_with_taxes: z.decimal(),
}),
z.object({ // TODO: `strictObject` lorsque ce sera possible.
is_billable: z.literal(false),
+ materials: z.lazy(() => EventMaterialNotBillableSchema.array()),
}),
]))
);
@@ -202,11 +258,20 @@ export type EventSummary = SchemaInfer;
// - Secondary Types
//
-export type EventMaterial = SchemaInfer;
+export type EventMaterial =
+ IsBillable extends true
+ ? SchemaInfer
+ : SchemaInfer;
+
export type EventMaterialWithQuantityMissing = SchemaInfer;
+export type EventExtra = SchemaInfer;
+
export type EventTechnician = SchemaInfer;
+export type EventTax = SchemaInfer;
+export type EventTaxTotal = SchemaInfer;
+
//
// - Edition
//
@@ -225,10 +290,11 @@ export type EventEdit = {
note?: string | null,
};
-type EventDuplicatePayload = Nullable<{
- operation_period: Period,
- mobilization_period: Period,
-}>;
+type EventDuplicatePayload = {
+ operation_period: Period | null,
+ mobilization_period: Period | null,
+ keepBillingData?: boolean,
+};
type EventReturnInventoryMaterial = {
id: Material['id'],
@@ -256,6 +322,7 @@ export type EventTechnicianEdit = {
type GetAllParams = {
search?: string,
exclude?: number | undefined,
+ onlySelectable?: boolean,
};
// ------------------------------------------------------
@@ -324,13 +391,13 @@ const cancelReturnInventory = async (id: Event['id']): Promise =>
return EventDetailsSchema.parse(response.data);
};
-const createInvoice = async (id: Event['id'], discountRate: Decimal = new Decimal(0)): Promise => {
- const response = await requester.post(`/events/${id}/invoices`, { discountRate });
+const createInvoice = async (id: Event['id']): Promise => {
+ const response = await requester.post(`/events/${id}/invoices`);
return InvoiceSchema.parse(response.data);
};
-const createEstimate = async (id: Event['id'], discountRate: Decimal = new Decimal(0)): Promise => {
- const response = await requester.post(`/events/${id}/estimates`, { discountRate });
+const createEstimate = async (id: Event['id']): Promise => {
+ const response = await requester.post(`/events/${id}/estimates`);
return EstimateSchema.parse(response.data);
};
diff --git a/client/src/stores/api/groups.ts b/client/src/stores/api/groups.ts
index 8b1e36b3c..9116874da 100644
--- a/client/src/stores/api/groups.ts
+++ b/client/src/stores/api/groups.ts
@@ -8,13 +8,16 @@ import Vue from 'vue';
export enum Group {
/** Représente le groupe des administrateurs. */
- ADMIN = 'admin',
+ ADMINISTRATION = 'administration',
- /** Représente le groupe des membres de l'équipe. */
- MEMBER = 'member',
+ /** Représente le groupe des gestionnaires, membres de l'équipe. */
+ MANAGEMENT = 'management',
- /** Représente le groupe des visiteurs. */
- VISITOR = 'visitor',
+ /**
+ * Représente le groupe des utilisateurs ayant accès au
+ * planning général, en lecture seule.
+ */
+ READONLY_PLANNING_GENERAL = 'readonly-planning-general',
}
// ------------------------------------------------------
@@ -38,14 +41,10 @@ const all = (): GroupDetails[] => {
const { translate: __ } = (Vue as any).i18n;
return [
- { id: Group.ADMIN, name: __('admin') },
- { id: Group.MEMBER, name: __('member') },
- { id: Group.VISITOR, name: __('visitor') },
+ { id: Group.ADMINISTRATION, name: __('groups.administration') },
+ { id: Group.MANAGEMENT, name: __('groups.management') },
+ { id: Group.READONLY_PLANNING_GENERAL, name: __('groups.readonly-planning-general') },
];
};
-const one = (group: Group): GroupDetails | undefined => (
- all().find(({ id }: GroupDetails) => id === group)
-);
-
-export default { all, one };
+export default { all };
diff --git a/client/src/stores/api/invoices.ts b/client/src/stores/api/invoices.ts
index 9731374c4..8f156bc54 100644
--- a/client/src/stores/api/invoices.ts
+++ b/client/src/stores/api/invoices.ts
@@ -8,15 +8,22 @@ import type { SchemaInfer } from '@/utils/validation';
// -
// ------------------------------------------------------
+const InvoiceTaxSchema = z.strictObject({
+ name: z.string(),
+ is_rate: z.boolean(),
+ value: z.decimal(),
+ total: z.decimal(),
+});
+
export const InvoiceSchema = z.strictObject({
id: z.number(),
number: z.string(),
date: z.datetime(),
url: z.string(),
- discount_rate: z.decimal(),
total_without_taxes: z.decimal(),
+ total_taxes: z.lazy(() => InvoiceTaxSchema.array()),
total_with_taxes: z.decimal(),
- currency: z.string(),
+ currency: z.currency(),
});
// ------------------------------------------------------
diff --git a/client/src/stores/api/materials.ts b/client/src/stores/api/materials.ts
index 48df82820..a38201a35 100644
--- a/client/src/stores/api/materials.ts
+++ b/client/src/stores/api/materials.ts
@@ -1,9 +1,10 @@
import requester from '@/globals/requester';
import { z } from '@/utils/validation';
+import { omit } from 'lodash';
import { TagSchema } from './tags';
import { DocumentSchema } from './documents';
import { AttributeWithValueSchema } from './attributes';
-import { createBookingSummarySchema } from './bookings';
+import { BookingSummarySchema, createBookingSummarySchema } from './bookings';
import { withPaginationEnvelope } from './@schema';
import type Period from '@/utils/period';
@@ -13,12 +14,14 @@ import type { Park } from './parks';
import type { Category } from './categories';
import type { Event } from './events';
import type { SubCategory } from './subcategories';
-import type { Attribute } from './attributes';
+import type { Attribute, AttributeWithValue } from './attributes';
import type { Tag } from './tags';
import type { Document } from './documents';
import type { SchemaInfer } from '@/utils/validation';
import type { ZodRawShape } from 'zod';
import type { Simplify } from 'type-fest';
+import type { DegressiveRate } from './degressive-rates';
+import type { Tax } from './taxes';
// ------------------------------------------------------
// -
@@ -37,11 +40,14 @@ const MaterialBaseSchema = z.strictObject({
id: z.number(),
name: z.string(),
reference: z.string(),
+ park_id: z.number(),
picture: z.string().nullable(),
description: z.string().nullable(),
category_id: z.number().nullable(),
sub_category_id: z.number().nullable(),
rental_price: z.decimal().nullable().optional(),
+ degressive_rate_id: z.number().nullable().optional(),
+ tax_id: z.number().nullable().optional(),
replacement_price: z.decimal().nullable(),
stock_quantity: z.number().nullable().transform(
(value: number | null): number => value ?? 0,
@@ -49,10 +55,8 @@ const MaterialBaseSchema = z.strictObject({
out_of_order_quantity: z.number().nullable().transform(
(value: number | null): number => value ?? 0,
),
- park_id: z.number(),
is_hidden_on_bill: z.boolean().optional(),
is_discountable: z.boolean().optional(),
- is_reservable: z.boolean(),
attributes: z.lazy(() => AttributeWithValueSchema.array()),
tags: z.lazy(() => TagSchema.array()),
note: z.string().nullable(),
@@ -70,10 +74,36 @@ const createMaterialSchemaFactory = (augmentation: T) =>
export const createMaterialSchema = createMaterialSchemaFactory({});
-export const createMaterialDetailsSchema = createMaterialSchemaFactory({});
+export const createMaterialDetailsSchema = createMaterialSchemaFactory(
+ {
+ available_quantity: z.number(),
+ departure_inventory_todo: z.lazy(() => BookingSummarySchema).nullable(),
+ return_inventory_todo: z.lazy(() => BookingSummarySchema).nullable(),
+ },
+);
export const createMaterialWithAvailabilitySchema = createMaterialSchemaFactory(
- { available_quantity: z.number() },
+ {
+ available_quantity: z.number(),
+ is_deleted: z.boolean(),
+ },
+);
+
+export const createMaterialWithContextExcerptSchema = createMaterialSchemaFactory(
+ {
+ degressive_rate: z.decimal().nullable().optional(),
+ rental_price_period: z.decimal().nullable().optional(),
+ is_deleted: z.boolean(),
+ },
+);
+
+export const createMaterialWithContextSchema = createMaterialSchemaFactory(
+ {
+ degressive_rate: z.decimal().nullable().optional(),
+ rental_price_period: z.decimal().nullable().optional(),
+ available_quantity: z.number(),
+ is_deleted: z.boolean(),
+ },
);
const MaterialBookingSummarySchema = z.lazy(() => (
@@ -93,9 +123,13 @@ export const MaterialSchema = createMaterialSchema({});
export const MaterialDetailsSchema = createMaterialDetailsSchema({});
export const MaterialWithAvailabilitySchema = createMaterialWithAvailabilitySchema({});
+export const MaterialWithContextExcerptSchema = createMaterialWithContextExcerptSchema({});
+export const MaterialWithContextSchema = createMaterialWithContextSchema({});
export const MaterialPublicSchema = (() => {
const baseSchema = MaterialBaseSchema.extend({
+ degressive_rate: z.decimal(),
+ rental_price_period: z.decimal().nullable(),
available_quantity: z.number(),
});
@@ -104,8 +138,10 @@ export const MaterialPublicSchema = (() => {
name: true,
description: true,
picture: true,
+ degressive_rate: true,
available_quantity: true,
rental_price: true,
+ rental_price_period: true,
});
})();
@@ -121,6 +157,8 @@ export type MaterialDetails = SchemaInfer;
export type MaterialWithAvailability = SchemaInfer;
+export type MaterialWithContext = SchemaInfer;
+
export type MaterialPublic = SchemaInfer;
//
@@ -135,25 +173,26 @@ export type MaterialBookingSummary = SchemaInfer,
attributes?: MaterialEditAttribute[],
};
@@ -178,10 +217,16 @@ export type Filters = Simplify<(
}>
)>;
-type GetAllParamsBase = Filters & SortableParams & { deleted?: boolean };
+type GetAllParamsBase = Filters & SortableParams & { withDeleted?: boolean, onlyDeleted?: boolean };
type GetAllParamsPaginated = GetAllParamsBase & PaginationParams & { paginated?: true };
type GetAllParamsRaw = GetAllParamsBase & { paginated: false };
+type GetBookingsParams = (
+ & PaginationParams
+ & { period?: Period }
+ & Pick
+);
+
// ------------------------------------------------------
// -
// - Fonctions
@@ -208,9 +253,9 @@ async function all({ quantitiesPeriod, ...otherParams }: GetAllParamsPaginated |
return schema.parse(response.data);
}
-const allWhileEvent = async (eventId: Event['id']): Promise => {
+const allWhileEvent = async (eventId: Event['id']): Promise => {
const response = await requester.get(`/materials/while-event/${eventId}`);
- return MaterialWithAvailabilitySchema.array().parse(response.data);
+ return MaterialWithContextSchema.array().parse(response.data);
};
const one = async (id: Material['id']): Promise => {
@@ -241,9 +286,12 @@ const remove = async (id: Material['id']): Promise => {
await requester.delete(`/materials/${id}`);
};
-const bookings = async (id: Material['id'], params?: PaginationParams): Promise> => {
- const config = { ...(params ? { params } : {}) };
- const response = await requester.get(`/materials/${id}/bookings`, config);
+const bookings = async (id: Material['id'], params: GetBookingsParams = {}): Promise> => {
+ const normalizedParams = {
+ ...omit(params, ['period']),
+ ...params?.period?.toQueryParams('period'),
+ };
+ const response = await requester.get(`/materials/${id}/bookings`, { params: normalizedParams });
return withPaginationEnvelope(MaterialBookingSummarySchema).parse(response.data);
};
diff --git a/client/src/stores/api/parks.ts b/client/src/stores/api/parks.ts
index 92db4880d..fed775c37 100644
--- a/client/src/stores/api/parks.ts
+++ b/client/src/stores/api/parks.ts
@@ -3,6 +3,7 @@ import requester from '@/globals/requester';
import { withPaginationEnvelope } from './@schema';
import { MaterialSchema } from './materials';
+import type Decimal from 'decimal.js';
import type { Material } from './materials';
import type { SchemaInfer } from '@/utils/validation';
import type { PaginatedData, ListingParams } from './@types';
@@ -88,9 +89,9 @@ const one = async (id: Park['id']): Promise => {
return ParkDetailsSchema.parse(response.data);
};
-const oneTotalAmount = async (id: Park['id']): Promise => {
+const oneTotalAmount = async (id: Park['id']): Promise => {
const response = await requester.get(`/parks/${id}/total-amount`);
- return z.number().nonnegative().parse(response.data);
+ return z.decimal().parse(response.data);
};
const materials = async (id: Park['id']): Promise => {
diff --git a/client/src/stores/api/session.ts b/client/src/stores/api/session.ts
index 617ee2701..df78fb881 100644
--- a/client/src/stores/api/session.ts
+++ b/client/src/stores/api/session.ts
@@ -19,7 +19,8 @@ export enum AppContext {
INTERNAL = 'internal',
}
-const SessionSchema = UserDetailsSchema.merge(UserSettingsSchema);
+const SessionSchema = UserDetailsSchema
+ .merge(UserSettingsSchema);
const NewSessionSchema = SessionSchema.extend({
token: z.string(),
diff --git a/client/src/stores/api/settings.ts b/client/src/stores/api/settings.ts
index 9039e1b15..0132a5f6e 100644
--- a/client/src/stores/api/settings.ts
+++ b/client/src/stores/api/settings.ts
@@ -1,7 +1,7 @@
import requester from '@/globals/requester';
import { z } from '@/utils/validation';
-import type { OmitDeep, PartialDeep } from 'type-fest';
+import type { PartialDeep } from 'type-fest';
import type { SchemaInfer } from '@/utils/validation';
// ------------------------------------------------------
@@ -74,6 +74,10 @@ const SettingsSchema = z.strictObject({
returnInventory: z.strictObject({
mode: z.nativeEnum(ReturnInventoryMode),
}),
+ billing: z.strictObject({
+ defaultDegressiveRate: z.number().nullable(),
+ defaultTax: z.number().nullable(),
+ }),
});
// ------------------------------------------------------
@@ -90,7 +94,7 @@ export type Settings = SchemaInfer;
// - Edition
//
-export type SettingsEdit = PartialDeep>;
+export type SettingsEdit = PartialDeep;
// ------------------------------------------------------
// -
diff --git a/client/src/stores/api/taxes.ts b/client/src/stores/api/taxes.ts
new file mode 100644
index 000000000..facda97aa
--- /dev/null
+++ b/client/src/stores/api/taxes.ts
@@ -0,0 +1,90 @@
+import { z } from '@/utils/validation';
+import requester from '@/globals/requester';
+
+import type { SchemaInfer } from '@/utils/validation';
+
+// ------------------------------------------------------
+// -
+// - Schema / Enums
+// -
+// ------------------------------------------------------
+
+const TaxComponentSchema = z.strictObject({
+ name: z.string(),
+ is_rate: z.boolean(),
+ value: z.decimal(),
+});
+
+export const TaxSchema = z
+ .strictObject({
+ id: z.number(),
+ name: z.string(),
+ is_used: z.boolean(),
+ })
+ .strip() // TODO: À enlever lorsqu'on pourra garder les objets stricts avec les intersections.
+ .and(z.discriminatedUnion('is_group', [
+ z.object({ // TODO: `strictObject` lorsque ce sera possible.
+ is_group: z.literal(true),
+ components: z.lazy(() => TaxComponentSchema.array()),
+ }),
+ z.object({ // TODO: `strictObject` lorsque ce sera possible.
+ is_group: z.literal(false),
+ is_rate: z.boolean(),
+ value: z.decimal(),
+ }),
+ ]));
+
+// ------------------------------------------------------
+// -
+// - Types
+// -
+// ------------------------------------------------------
+
+export type Tax = SchemaInfer;
+
+export type TaxComponent = SchemaInfer;
+
+//
+// - Edition
+//
+
+export type TaxComponentEdit = {
+ name: string | null,
+ is_rate: boolean | null,
+ value: string | null,
+};
+
+export type TaxEdit = {
+ name: string | null,
+ is_group: boolean,
+ is_rate: boolean | null,
+ value: string | null,
+ components: TaxComponentEdit[],
+};
+
+// ------------------------------------------------------
+// -
+// - Fonctions
+// -
+// ------------------------------------------------------
+
+const all = async (): Promise => {
+ const response = await requester.get('/taxes');
+ return TaxSchema.array().parse(response.data);
+};
+
+const create = async (data: TaxEdit): Promise => {
+ const response = await requester.post('/taxes', data);
+ return TaxSchema.parse(response.data);
+};
+
+const update = async (id: Tax['id'], data: TaxEdit): Promise => {
+ const response = await requester.put(`/taxes/${id}`, data);
+ return TaxSchema.parse(response.data);
+};
+
+const remove = async (id: Tax['id']): Promise => {
+ await requester.delete(`/taxes/${id}`);
+};
+
+export default { all, create, update, remove };
diff --git a/client/src/stores/api/technicians.ts b/client/src/stores/api/technicians.ts
index 094b1c908..68a37f234 100644
--- a/client/src/stores/api/technicians.ts
+++ b/client/src/stores/api/technicians.ts
@@ -3,6 +3,7 @@ import requester from '@/globals/requester';
import { CountrySchema } from './countries';
import { DocumentSchema } from './documents';
import { EventSchema } from './events';
+import { UserSchema } from './users';
import { withPaginationEnvelope } from './@schema';
import type Period from '@/utils/period';
@@ -54,6 +55,10 @@ export const TechnicianSchema = z.strictObject({
note: z.string().nullable(),
});
+export const TechnicianDetailsSchema = TechnicianSchema.extend({
+ user: z.lazy(() => UserSchema).nullable(),
+});
+
export const TechnicianWithEventsSchema = TechnicianSchema.extend({
events: TechnicianEventSchema.array(),
});
@@ -66,6 +71,8 @@ export const TechnicianWithEventsSchema = TechnicianSchema.extend({
export type Technician = SchemaInfer;
+export type TechnicianDetails = SchemaInfer;
+
export type TechnicianEvent = SchemaInfer;
export type TechnicianWithEvents = SchemaInfer;
@@ -83,6 +90,7 @@ export type TechnicianEdit = {
postal_code: string | null,
locality: string | null,
country_id: number | null,
+ user_id?: number,
note: string | null,
};
@@ -121,24 +129,26 @@ const allWhileEvent = async (eventId: Event['id']): Promise => {
+const one = async (id: Technician['id']): Promise => {
const response = await requester.get(`/technicians/${id}`);
- return TechnicianSchema.parse(response.data);
+ return TechnicianDetailsSchema.parse(response.data);
};
-const create = async (data: TechnicianEdit): Promise => {
- const response = await requester.post('/technicians', data);
- return TechnicianSchema.parse(response.data);
+const create = async (data: TechnicianEdit, withUser?: boolean): Promise => {
+ const params = withUser !== undefined ? { withUser } : undefined;
+ const response = await requester.post('/technicians', data, { params });
+ return TechnicianDetailsSchema.parse(response.data);
};
-const update = async (id: Technician['id'], data: TechnicianEdit): Promise => {
- const response = await requester.put(`/technicians/${id}`, data);
- return TechnicianSchema.parse(response.data);
+const update = async (id: Technician['id'], data: TechnicianEdit, withUser?: boolean): Promise => {
+ const params = withUser !== undefined ? { withUser } : undefined;
+ const response = await requester.put(`/technicians/${id}`, data, { params });
+ return TechnicianDetailsSchema.parse(response.data);
};
-const restore = async (id: Technician['id']): Promise => {
+const restore = async (id: Technician['id']): Promise => {
const response = await requester.put(`/technicians/restore/${id}`);
- return TechnicianSchema.parse(response.data);
+ return TechnicianDetailsSchema.parse(response.data);
};
const remove = async (id: Technician['id']): Promise => {
diff --git a/client/src/stores/api/users.ts b/client/src/stores/api/users.ts
index b5c72623a..c060fec8e 100644
--- a/client/src/stores/api/users.ts
+++ b/client/src/stores/api/users.ts
@@ -1,6 +1,7 @@
import { z } from '@/utils/validation';
import requester from '@/globals/requester';
import { Group } from './groups';
+import { CountrySchema } from './countries';
import { withPaginationEnvelope } from './@schema';
import type { PaginatedData, ListingParams } from './@types';
@@ -27,10 +28,9 @@ export enum BookingsViewMode {
LISTING = 'listing',
}
-export const UserSchema = z.strictObject({
+export const UserSummarySchema = z.strictObject({
id: z.number(),
- pseudo: z.string(),
- first_name: z.string().nullable().transform(
+ full_name: z.string().nullable().transform(
// NOTE: Le `?` ci-dessous n'est pas idéal et doit être évité dans la mesure
// du possible, mais étant donné qu'il est possible que la `person` lié
// ait été supprimée, on préfère utiliser `?` en fallback plutôt que de
@@ -38,7 +38,13 @@ export const UserSchema = z.strictObject({
// géré, au cas où.
(value: string | null) => value ?? '?',
),
- last_name: z.string().nullable().transform(
+ // TODO [zod@>3.22.4]: Remettre `email()`.
+ email: z.string(),
+});
+
+export const UserSchema = UserSummarySchema.extend({
+ pseudo: z.string(),
+ first_name: z.string().nullable().transform(
// NOTE: Le `?` ci-dessous n'est pas idéal et doit être évité dans la mesure
// du possible, mais étant donné qu'il est possible que la `person` lié
// ait été supprimée, on préfère utiliser `?` en fallback plutôt que de
@@ -46,7 +52,7 @@ export const UserSchema = z.strictObject({
// géré, au cas où.
(value: string | null) => value ?? '?',
),
- full_name: z.string().nullable().transform(
+ last_name: z.string().nullable().transform(
// NOTE: Le `?` ci-dessous n'est pas idéal et doit être évité dans la mesure
// du possible, mais étant donné qu'il est possible que la `person` lié
// ait été supprimée, on préfère utiliser `?` en fallback plutôt que de
@@ -55,12 +61,17 @@ export const UserSchema = z.strictObject({
(value: string | null) => value ?? '?',
),
phone: z.string().nullable(),
- // TODO [zod@>3.22.4]: Remettre `email()`.
- email: z.string(),
group: z.nativeEnum(Group),
});
-export const UserDetailsSchema = UserSchema;
+export const UserDetailsSchema = UserSchema.extend({
+ street: z.string().nullable(),
+ postal_code: z.string().nullable(),
+ locality: z.string().nullable(),
+ country_id: z.number().nullable(),
+ country: z.lazy(() => CountrySchema).nullable(),
+ full_address: z.string().nullable(),
+});
export const UserSettingsSchema = z.strictObject({
language: z.string(),
@@ -76,7 +87,7 @@ export const UserSettingsSchema = z.strictObject({
export type UserSettings = SchemaInfer;
export type User = SchemaInfer;
-
+export type UserSummary = SchemaInfer;
export type UserDetails = SchemaInfer;
//
@@ -103,7 +114,7 @@ export type UserSettingsEdit = Partial;
// - Récupération
//
-type GetAllParams = ListingParams & {
+export type GetAllParams = ListingParams & {
deleted?: boolean,
group?: Group,
};
diff --git a/client/src/stores/auth.ts b/client/src/stores/auth.ts
index 60a5c708e..9b5fa4dee 100644
--- a/client/src/stores/auth.ts
+++ b/client/src/stores/auth.ts
@@ -8,6 +8,7 @@ import type { Module, ActionContext } from 'vuex';
import type { Session, Credentials } from '@/stores/api/session';
import type { Group } from '@/stores/api/groups';
import type { UserSettings } from '@/stores/api/users';
+import type { RootState } from '.';
export type State = {
user: Session | null,
@@ -16,7 +17,14 @@ export type State = {
const setSessionCookie = (token: string): void => {
const { cookie, timeout } = config.auth;
- const cookieConfig: Cookies.CookieAttributes = {};
+ const cookieConfig: Cookies.CookieAttributes = {
+ secure: config.isSslEnabled,
+
+ // - Note: Permet la création de cookies lorsque Loxya est
+ // intégré dans des systèmes tiers (e.g. Notion).
+ sameSite: config.isSslEnabled ? 'None' : 'Lax',
+ };
+
if (timeout) {
const timeoutMs = timeout * 60 * 60 * 1000;
const timeoutDate = new Date(Date.now() + timeoutMs);
@@ -26,7 +34,7 @@ const setSessionCookie = (token: string): void => {
cookies.set(cookie, token, cookieConfig);
};
-const store: Module = {
+const store: Module = {
namespaced: true,
state: {
user: null,
@@ -63,7 +71,7 @@ const store: Module = {
},
},
actions: {
- async fetch({ dispatch, commit }: ActionContext) {
+ async fetch({ dispatch, commit }: ActionContext) {
if (!cookies.get(config.auth.cookie)) {
commit('setUser', null);
return;
@@ -82,7 +90,7 @@ const store: Module = {
}
},
- async login({ dispatch, commit }: ActionContext, credentials: Credentials) {
+ async login({ dispatch, commit }: ActionContext, credentials: Credentials) {
const { token, ...user } = await apiSession.create(credentials);
commit('setUser', user);
setSessionCookie(token);
@@ -92,7 +100,7 @@ const store: Module = {
await dispatch('settings/fetch', undefined, { root: true });
},
- async logout(_: ActionContext, full: boolean = true) {
+ async logout(_: ActionContext, full: boolean = true) {
const theme = '';
if (full) {
diff --git a/client/src/stores/index.ts b/client/src/stores/index.ts
new file mode 100644
index 000000000..3d8339ddb
--- /dev/null
+++ b/client/src/stores/index.ts
@@ -0,0 +1,15 @@
+import authStore from './auth';
+import settingsStore from './settings';
+
+import type { State as AuthStoreState } from './auth';
+import type { State as SettingsStoreState } from './settings';
+
+export type RootState = {
+ auth: AuthStoreState,
+ settings: SettingsStoreState,
+};
+
+export default {
+ auth: authStore,
+ settings: settingsStore,
+};
diff --git a/client/src/stores/settings.ts b/client/src/stores/settings.ts
index aa071a53b..f14a9da13 100644
--- a/client/src/stores/settings.ts
+++ b/client/src/stores/settings.ts
@@ -1,10 +1,14 @@
import Day from '@/utils/day';
import Period from '@/utils/period';
import DateTime from '@/utils/datetime';
-import apiSettings, { MaterialDisplayMode, ReturnInventoryMode } from '@/stores/api/settings';
+import apiSettings, {
+ MaterialDisplayMode,
+ ReturnInventoryMode,
+} from '@/stores/api/settings';
import type { Module, ActionContext } from 'vuex';
import type { OpeningDay, Settings } from '@/stores/api/settings';
+import type { RootState } from '.';
export type State = Settings;
@@ -36,9 +40,13 @@ const getDefaults = (): Settings => ({
returnInventory: {
mode: ReturnInventoryMode.START_EMPTY,
},
+ billing: {
+ defaultDegressiveRate: null,
+ defaultTax: null,
+ },
});
-const store: Module = {
+const store: Module = {
namespaced: true,
state: getDefaults(),
getters: {
@@ -77,7 +85,7 @@ const store: Module = {
},
},
actions: {
- async boot({ dispatch }: ActionContext) {
+ async boot({ dispatch }: ActionContext) {
await dispatch('fetch');
const refresh = async (): Promise => {
@@ -86,11 +94,11 @@ const store: Module = {
setInterval(refresh, 30_000); // - 30 secondes.
},
- async fetch({ commit }: ActionContext) {
+ async fetch({ commit }: ActionContext) {
commit('set', await apiSettings.all());
},
- reset({ commit }: ActionContext) {
+ reset({ commit }: ActionContext) {
commit('reset');
},
},
diff --git a/client/src/themes/default/components/Alert/_variables.scss b/client/src/themes/default/components/Alert/_variables.scss
index 12fb7c9c0..aa7f0c44d 100644
--- a/client/src/themes/default/components/Alert/_variables.scss
+++ b/client/src/themes/default/components/Alert/_variables.scss
@@ -1,11 +1,13 @@
+@use '~@/themes/default/style/globals';
+
//
// - Variantes
//
$warning-variant: (
icon: 'exclamation-triangle',
- background: #644a2b,
- color: #fff,
+ background: globals.$bg-color-emphasis-warning,
+ color: globals.$color-emphasis-warning-base,
) !default;
$info-variant: (
diff --git a/client/src/themes/default/components/Alert/index.scss b/client/src/themes/default/components/Alert/index.scss
index 6b2b40ac8..5272f608d 100644
--- a/client/src/themes/default/components/Alert/index.scss
+++ b/client/src/themes/default/components/Alert/index.scss
@@ -11,9 +11,19 @@
gap: globals.$spacing-medium;
&::before {
+ flex: none;
opacity: 0.8;
}
+ &__text {
+ flex: 1;
+ }
+
+ &__action {
+ flex: none;
+ margin-left: auto;
+ }
+
//
// - Variantes
//
diff --git a/client/src/themes/default/components/Alert/index.tsx b/client/src/themes/default/components/Alert/index.tsx
index fd4730a60..d407f1cd8 100644
--- a/client/src/themes/default/components/Alert/index.tsx
+++ b/client/src/themes/default/components/Alert/index.tsx
@@ -1,9 +1,12 @@
import './index.scss';
+import Button from '@/themes/default/components/Button';
import { defineComponent } from '@vue/composition-api';
+import type { Location } from 'vue-router';
import type { PropType } from '@vue/composition-api';
+import type { Props as IconProps } from '@/themes/default/components/Icon';
-enum Type {
+export enum Type {
/** Une alerte d'avertissement. */
WARNING = 'warning',
@@ -11,9 +14,57 @@ enum Type {
INFO = 'info',
}
+export type Action = {
+ /** Le contenu à afficher dans le bouton d'action. */
+ label: string,
+
+ /**
+ * Si l'action est un lien, la cible du lien sous forme de chaîne,
+ * ou d'objet `Location` compatible avec Vue-Router.
+ *
+ * Si non définie, un élément HTML `
- {missingMaterials.map((missingMaterial) => (
+ {missingMaterials.map((missingMaterial: EventMaterialWithQuantityMissing) => (
-
{missingMaterial.name}
{__('@event.missing-material-count', {
- quantity: missingMaterial.pivot.quantity,
- missing: missingMaterial.pivot.quantity_missing,
+ quantity: missingMaterial.quantity,
+ missing: missingMaterial.quantity_missing,
})}
diff --git a/client/src/themes/default/components/EventTotals/index.tsx b/client/src/themes/default/components/EventTotals/index.tsx
deleted file mode 100644
index c1434e729..000000000
--- a/client/src/themes/default/components/EventTotals/index.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-import './index.scss';
-import { defineComponent } from '@vue/composition-api';
-import formatAmount from '@/utils/formatAmount';
-
-import type { PropType } from '@vue/composition-api';
-import type { EventDetails, EventMaterial } from '@/stores/api/events';
-
-type Props = {
- /** L'événement dont on veut afficher les totaux. */
- event: EventDetails,
-};
-
-/** Totaux d'un événement. */
-const EventTotals = defineComponent({
- name: 'EventTotals',
- props: {
- event: {
- type: Object as PropType['event']>,
- required: true,
- },
- },
- computed: {
- duration(): number {
- const { operation_period: operationPeriod } = this.event;
- return operationPeriod.asDays();
- },
-
- useTaxes(): boolean {
- const { is_billable: isBillable } = this.event;
- if (!isBillable) {
- return false;
- }
-
- const { vat_rate: vatRate } = this.event;
- return vatRate.greaterThan(0);
- },
-
- hasDiscount(): boolean {
- const { is_billable: isBillable } = this.event;
- if (!isBillable) {
- return false;
- }
-
- const { discount_rate: discountRate } = this.event;
- return discountRate.greaterThan(0);
- },
-
- isNotFullyDiscountable(): boolean {
- const { is_billable: isBillable } = this.event;
- if (!isBillable) {
- return false;
- }
-
- const {
- total_without_discount: totalWithoutDiscount,
- total_discountable: totalDiscountable,
- } = this.event;
-
- return !totalDiscountable.eq(totalWithoutDiscount);
- },
-
- itemsCount(): number {
- const { materials } = this.event;
-
- return materials.reduce(
- (total: number, material: EventMaterial) => (
- total + material.pivot.quantity
- ),
- 0,
- );
- },
- },
- created() {
- this.$store.dispatch('categories/fetch');
- },
- render() {
- const {
- $t: __,
- event,
- itemsCount,
- duration,
- useTaxes,
- hasDiscount,
- isNotFullyDiscountable,
- } = this;
- const {
- is_billable: isBillable,
- total_replacement: totalReplacement,
- currency,
- } = event;
-
- if (itemsCount === 0) {
- return null;
- }
-
- const renderInfos = (): JSX.Element => (
-
-
- {__('items-count-total', { count: itemsCount }, itemsCount)}
-
-
- {__('duration-days', { duration }, duration)}
-
-
- {__('total-replacement', { total: formatAmount(totalReplacement, currency) })}
-
-
- );
-
- if (!isBillable) {
- return (
-
- {renderInfos()}
-
- );
- }
-
- const {
- vat_rate: vatRate,
- daily_total: dailyTotal,
- degressive_rate: degressiveRate,
- total_without_discount: totalWithoutDiscount,
- discount_rate: discountRate,
- total_discountable: totalDiscountable,
- total_discount: totalDiscount,
- total_without_taxes: totalWithoutTaxes,
- total_taxes: totalTaxes,
- total_with_taxes: totalWithTaxes,
- } = event;
-
- return (
-
- {renderInfos()}
-
-
-
-
- {useTaxes ? __('daily-total-without-tax') : __('daily-total')}
-
-
- {formatAmount(dailyTotal, currency)}
-
-
-
-
- {__('days-count', { duration }, duration)}, {__('ratio')}
-
-
- × {degressiveRate.toString()}
-
-
-
-
- {useTaxes ? __('total-without-taxes') : __('total')}
-
-
- {formatAmount(totalWithoutDiscount, currency)}
-
-
-
- {hasDiscount && (
-
- {isNotFullyDiscountable && (
-
-
- {__('total-discountable')}
-
-
- {formatAmount(totalDiscountable, currency)}
-
-
- )}
-
-
- {__('discount-rate', { rate: discountRate.toString() })}
-
-
- - {formatAmount(totalDiscount, currency)}
-
-
-
-
- {__('total-after-discount')}
-
-
- {formatAmount(totalWithoutTaxes, currency)}
-
-
-
- )}
- {useTaxes && (
-
-
-
- {__('total-taxes')} {vatRate.toNumber()}%
-
-
- {formatAmount(totalTaxes, currency)}
-
-
-
-
- {__('total-with-taxes')}
-
-
- {formatAmount(totalWithTaxes, currency)}
-
-
-
- )}
- {(!hasDiscount && isNotFullyDiscountable) && (
-
-
- {__('total-discountable')}
-
-
- {formatAmount(totalDiscountable, currency)}
-
-
- )}
-
-
- );
- },
-});
-
-export default EventTotals;
diff --git a/client/src/themes/default/components/Fieldset/index.js b/client/src/themes/default/components/Fieldset/index.js
deleted file mode 100644
index 6991bff3d..000000000
--- a/client/src/themes/default/components/Fieldset/index.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import './index.scss';
-import { defineComponent } from '@vue/composition-api';
-
-// @vue/component
-const Fieldset = defineComponent({
- name: 'Fieldset',
- props: {
- title: { type: String, default: undefined },
- help: { type: String, default: undefined },
- },
- render() {
- const { title, help } = this;
- const children = this.$slots.default;
-
- const classNames = ['Fieldset', {
- 'Fieldset--with-help': !!help,
- }];
-
- return (
-
- {title && {title}
}
- {help && {help}
}
-
- {children}
-
-
- );
- },
-});
-
-export default Fieldset;
diff --git a/client/src/themes/default/components/Fieldset/index.scss b/client/src/themes/default/components/Fieldset/index.scss
index c540fff91..402c9c30d 100644
--- a/client/src/themes/default/components/Fieldset/index.scss
+++ b/client/src/themes/default/components/Fieldset/index.scss
@@ -3,28 +3,37 @@
.Fieldset {
$block: &;
- &__title {
- margin: 0 0 globals.$spacing-medium;
- font-size: 1.3rem;
- }
+ &__header {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: globals.$spacing-medium;
+ gap: globals.$spacing-small;
- &__help {
- margin: 0 0 globals.$spacing-medium;
- color: globals.$text-light-color;
- white-space: pre-line;
- }
+ &__main {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: globals.$spacing-small;
+ }
- & + & {
- margin-top: globals.$spacing-large;
- }
+ &__title {
+ flex: 1;
+ margin: 0;
+ font-size: 1.3rem;
+ }
- //
- // - Variantes
- //
+ &__actions {
+ margin-left: auto;
+ }
- &--with-help {
- #{$block}__title {
- margin-bottom: globals.$spacing-small;
+ &__help {
+ margin: 0;
+ color: globals.$text-soft-color;
+ white-space: pre-line;
}
}
+
+ & + & {
+ margin-top: globals.$spacing-large;
+ }
}
diff --git a/client/src/themes/default/components/Fieldset/index.tsx b/client/src/themes/default/components/Fieldset/index.tsx
new file mode 100644
index 000000000..747d5882c
--- /dev/null
+++ b/client/src/themes/default/components/Fieldset/index.tsx
@@ -0,0 +1,79 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+
+import type { VNode } from 'vue';
+import type { PropType } from '@vue/composition-api';
+
+export type Props = {
+ /**
+ * L'éventuel titre de la page.
+ *
+ * Celui-ci sera utilisé dans le header de l'application
+ * ainsi que dans le `` du document.
+ */
+ title?: string,
+
+ /** Un éventuel message d'aide global à la page. */
+ help?: string,
+
+ /**
+ * Les éventuelles actions contextuelles de la page.
+ * (sous forme de nœuds vue dans un tableau)
+ */
+ actions?: VNode[],
+};
+
+/** Un groupe de champ ou une section de formulaire. */
+const Fieldset = defineComponent({
+ name: 'Fieldset',
+ props: {
+ title: {
+ type: String as PropType,
+ default: undefined,
+ },
+ help: {
+ type: String as PropType,
+ default: undefined,
+ },
+ actions: {
+ type: Array as PropType,
+ default: undefined,
+ },
+ },
+ render() {
+ const { title, help, actions } = this;
+ const children = this.$slots.default;
+
+ const renderHeader = (): JSX.Element | null => {
+ const hasActions = actions && actions.length > 0;
+ if (!title && !help && !hasActions) {
+ return null;
+ }
+
+ return (
+
+ );
+ };
+
+ return (
+
+ {renderHeader()}
+
+ {children}
+
+
+ );
+ },
+});
+
+export default Fieldset;
diff --git a/client/src/themes/default/components/FileManager/components/Document/index.tsx b/client/src/themes/default/components/FileManager/components/Document/index.tsx
index 412f625a5..5043b6158 100644
--- a/client/src/themes/default/components/FileManager/components/Document/index.tsx
+++ b/client/src/themes/default/components/FileManager/components/Document/index.tsx
@@ -11,9 +11,12 @@ import type { Document } from '@/stores/api/documents';
type Props = {
/** Le document à afficher. */
file: Document,
+
+ /** Est-ce que la suppression du fichier doit être désactivée ? */
+ readonly?: boolean,
};
-// @vue/component
+/** Document du gestionnaire de fichiers. */
const FileManagerDocument = defineComponent({
name: 'FileManagerDocument',
props: {
@@ -21,10 +24,14 @@ const FileManagerDocument = defineComponent({
type: Object as PropType['file']>,
required: true,
},
+ readonly: {
+ type: Boolean as PropType['readonly']>,
+ default: false,
+ },
},
emits: ['delete'],
computed: {
- icon() {
+ icon(): string {
return getIconFromFile(this.file);
},
@@ -38,7 +45,7 @@ const FileManagerDocument = defineComponent({
: undefined;
},
- size() {
+ size(): string {
return formatBytes(this.file.size);
},
},
@@ -50,6 +57,9 @@ const FileManagerDocument = defineComponent({
// ------------------------------------------------------
handleClickDelete() {
+ if (this.readonly) {
+ return;
+ }
this.$emit('delete', this.file.id);
},
},
@@ -59,6 +69,7 @@ const FileManagerDocument = defineComponent({
size,
basename,
extension,
+ readonly,
file: { name, url },
handleClickDelete,
} = this;
@@ -85,7 +96,9 @@ const FileManagerDocument = defineComponent({
download={name}
external
/>
-
+ {!readonly && (
+
+ )}
);
diff --git a/client/src/themes/default/components/FileManager/components/UploadArea/Upload/index.tsx b/client/src/themes/default/components/FileManager/components/UploadArea/Upload/index.tsx
index c17a18d7f..428be1e2d 100644
--- a/client/src/themes/default/components/FileManager/components/UploadArea/Upload/index.tsx
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/Upload/index.tsx
@@ -16,6 +16,7 @@ type Props = {
upload: UploadType,
};
+/** Affiche l'état d'un fichier en cours d'upload. */
const FileManagerUpload = defineComponent({
name: 'FileManagerUpload',
props: {
@@ -26,7 +27,7 @@ const FileManagerUpload = defineComponent({
},
emits: ['cancel'],
computed: {
- hasError() {
+ hasError(): boolean {
return this.upload.error !== null;
},
@@ -42,7 +43,7 @@ const FileManagerUpload = defineComponent({
: undefined;
},
- progress() {
+ progress(): number {
return this.upload.progress;
},
@@ -93,7 +94,7 @@ const FileManagerUpload = defineComponent({
: __('upload-in-process');
},
- cancellable() {
+ cancellable(): boolean {
if (this.upload.isCancelled) {
return false;
}
diff --git a/client/src/themes/default/components/FileManager/components/UploadArea/index.tsx b/client/src/themes/default/components/FileManager/components/UploadArea/index.tsx
index af2d16e2c..fdd944356 100644
--- a/client/src/themes/default/components/FileManager/components/UploadArea/index.tsx
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/index.tsx
@@ -39,7 +39,7 @@ type Data = {
*/
const MAX_CONCURRENT_UPLOADS = 5;
-// @vue/component
+/** Upload de fichiers avec système de queue et persistance. */
const FileManagerUploadArea = defineComponent({
name: 'FileManagerUploadArea',
props: {
diff --git a/client/src/themes/default/components/FileManager/index.tsx b/client/src/themes/default/components/FileManager/index.tsx
index 416e4d0b4..5785630d5 100644
--- a/client/src/themes/default/components/FileManager/index.tsx
+++ b/client/src/themes/default/components/FileManager/index.tsx
@@ -31,9 +31,15 @@ type Props = {
/** Le type de layout à utiliser (@see {@link FileManagerLayout}) */
layout?: FileManagerLayout,
+
+ /** Est-ce que l'upload et la suppression des fichiers doivent être désactivés ? */
+ readonly?: boolean,
};
-// @vue/component
+/**
+ * Gestionnaire de fichiers, avec zone de glisser-déposer
+ * pour uploader des documents.
+ */
const FileManager = defineComponent({
name: 'FileManager',
props: {
@@ -49,6 +55,10 @@ const FileManager = defineComponent({
type: String as PropType['layout']>,
default: FileManagerLayout.HORIZONTAL,
},
+ readonly: {
+ type: Boolean as PropType['readonly']>,
+ default: false,
+ },
},
emits: ['documentUploaded', 'documentDelete'],
computed: {
@@ -64,10 +74,16 @@ const FileManager = defineComponent({
// ------------------------------------------------------
handleDocumentUploaded(document: DocumentType) {
+ if (this.readonly) {
+ return;
+ }
this.$emit('documentUploaded', document);
},
- async handleDocumentDelete(id: DocumentType['id']) {
+ handleDocumentDelete(id: DocumentType['id']) {
+ if (this.readonly) {
+ return;
+ }
this.$emit('documentDelete', id);
},
@@ -105,6 +121,7 @@ const FileManager = defineComponent({
const {
__,
isEmpty,
+ readonly,
layout,
documents,
persister,
@@ -127,18 +144,21 @@ const FileManager = defineComponent({
key={document.id}
file={document}
onDelete={handleDocumentDelete}
+ readonly={readonly}
/>
))}
)}
-
+ {!readonly && (
+
+ )}
);
},
diff --git a/client/src/themes/default/components/FormField/index.scss b/client/src/themes/default/components/FormField/index.scss
index f25ec0c9f..0bd38ef84 100644
--- a/client/src/themes/default/components/FormField/index.scss
+++ b/client/src/themes/default/components/FormField/index.scss
@@ -51,11 +51,13 @@
}
&__help {
+ color: globals.$text-soft-color;
font-style: italic;
}
&__error {
color: globals.$text-danger-color;
+ white-space: pre-line;
}
& + & {
@@ -99,14 +101,12 @@
&--switch {
display: flex;
flex-flow: row wrap;
- align-items: center;
#{$block}__label {
text-align: right;
/* stylelint-disable declaration-no-important, order/properties-order -- Pour surcharger la mise en forme verticale */
- padding: 0 globals.$input-padding-horizontal 0 0 !important;
- align-self: auto !important;
+ padding: 3px globals.$input-padding-horizontal 0 0 !important;
/* stylelint-enable declaration-no-important */
}
diff --git a/client/src/themes/default/components/FormField/index.js b/client/src/themes/default/components/FormField/index.tsx
similarity index 52%
rename from client/src/themes/default/components/FormField/index.js
rename to client/src/themes/default/components/FormField/index.tsx
index 95425a4b4..cf2900fbc 100644
--- a/client/src/themes/default/components/FormField/index.js
+++ b/client/src/themes/default/components/FormField/index.tsx
@@ -5,7 +5,7 @@ import Select from '@/themes/default/components/Select';
import Radio from '@/themes/default/components/Radio';
import DatePicker, { Type as DatePickerType } from '@/themes/default/components/DatePicker';
import SwitchToggle from '@/themes/default/components/SwitchToggle';
-import Input, { TYPES as INPUT_TYPES } from '@/themes/default/components/Input';
+import Input, { InputType } from '@/themes/default/components/Input';
import Textarea from '@/themes/default/components/Textarea';
import InputCopy from '@/themes/default/components/InputCopy';
import InputColor from '@/themes/default/components/InputColor';
@@ -14,21 +14,206 @@ import Period from '@/utils/period';
import Color from '@/utils/color';
import Day from '@/utils/day';
-const TYPES = [
+import type { RawColor } from '@/utils/color';
+import type { PropType } from '@vue/composition-api';
+import type { ComponentRef } from 'vue';
+import type { Option } from '@/themes/default/components/Select';
+import type { DisableDateFunction } from '@/themes/default/components/DatePicker';
+
+enum OtherType {
+ COLOR = 'color',
+ COPY = 'copy',
+ STATIC = 'static',
+ SELECT = 'select',
+ RADIO = 'radio',
+ TEXTAREA = 'textarea',
+ SWITCH = 'switch',
+ CUSTOM = 'custom',
+}
+
+type FieldType = DatePickerType | InputType | OtherType;
+
+const TYPES: FieldType[] = [
...Object.values(DatePickerType),
- ...INPUT_TYPES,
- 'color',
- 'copy',
- 'static',
- 'select',
- 'radio',
- 'textarea',
- 'switch',
- 'custom',
+ ...Object.values(InputType),
+ ...Object.values(OtherType),
];
-// @vue/component
-export default defineComponent({
+type Props = {
+ /** Le label du champ de formulaire. */
+ label?: string,
+
+ /**
+ * Le nom du champ (attribut `[name]`).
+ *
+ * Ceci permettra notamment de récupérer la valeur du champ dans
+ * le jeu de données d'un formulaire parent lors de la soumission
+ * (`submit`) de celui-ci.
+ */
+ name?: string,
+
+ /**
+ * Type du champ (e.g. `date`, `text`, `email`, `color`, etc.).
+ * @see {@link FieldType} pour les types possibles.
+ *
+ * @default {@link InputType.TEXT}
+ */
+ type?: FieldType,
+
+ /**
+ * Le champ est-il requis pour soumettre le formulaire ?
+ *
+ * @default false
+ */
+ required?: boolean,
+
+ /**
+ * Le champ est-il désactivé ?
+ *
+ * @default false
+ */
+ disabled?: boolean,
+
+ /**
+ * Le champ doit-il être en lecture seule ?
+ *
+ * @default false
+ */
+ readonly?: boolean,
+
+ /**
+ * Un petit texte d'aide à afficher sous le champ.
+ *
+ * Note : il est également possible d'utiliser le `scopedSlots.help`
+ * pour afficher un élément HTML ou un component.
+ */
+ help?: string,
+
+ /**
+ * Un texte d'erreur à afficher en rouge sous le champ, quand
+ * une erreur de validation se produit.
+ *
+ * Si sa valeur est non-nulle, alors le champ sera marqué
+ * comme invalide (avec un liseret rouge autour du champ).
+ *
+ * @default null
+ */
+ error?: string | null,
+
+ /**
+ * L'éventuel texte affiché en filigrane dans le champ, quand
+ * celui-ci est vide.
+ *
+ * Si est égal à `true`, le texte sera le contenu de la prop `label`.
+ *
+ * @see {@link Select} pour utilisation avec le type {@link OtherType.SELECT}.
+ * @see {@link InputColor} pour utilisation avec le type {@link OtherType.COLOR}.
+ */
+ placeholder?: string | boolean | Color | RawColor | null,
+
+ /** La valeur actuelle du champ. */
+ value?: (
+ | string
+ | number
+ | boolean
+ | Option['value']
+ | Array