diff --git a/CHANGELOG.md b/CHANGELOG.md
index dadca7a88..6e22c57ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,36 @@ 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).
+## 0.21.0 (2023-05-11)
+
+- Dans la liste du matériel, le champ "Afficher les quantités restantes à date" est pré-rempli avec
+ la date courante, et la quantité disponible est affichée à côté de la quantité totale en stock,
+ pour faciliter la comparaison.
+- Corrige le comportement de la pagination des listings quand on essaye de charger une plage de données
+ qui n'existe pas ou plus (Premium #229).
+- Les caractéristiques spéciales peuvent être totalisées en bas de la liste du matériel
+ de la fiche de sortie des événements et réservations (Premium #266). Un nouveau champ "Totalisable"
+ permet de contrôler si la caractéristique doit être utilisée ou non dans les totaux.
+- Tous les champs des caractéristiques spéciales du matériel peuvent être modifiés, à l'exception du
+ champ "type", qui ne peut pas changer.
+- Ajout de la possibilité de personnaliser les échantillons de couleurs proposés dans le sélecteur de
+ couleur via la clé `colorSwatches` dans configuration JSON du projet (`settings.json`).
+- Il est maintenant possible de rattacher des documents aux techniciens, aux réservations et aux
+ événements (Premium #264, #298).
+- L'URL de la partie "réservation en ligne" (/external) peut être copiée directement depuis la page des
+ paramètres de la réservation en ligne.
+- Un nouvel onglet dans les paramètres du logiciel permet de contrôler le comportement des inventaires
+ de retour : soit l'inventaire est vide au départ, et doit être rempli manuellement (comportement par
+ défaut), soit les quantités retournées sont pré-remplies, et il faut décocher ce qui n'est pas revenu.
+- Ajoute la possibilité de modifier la liste du matériel des réservations approuvées ou en attente,
+ tant que la facturation n'est pas activée (Premium #287).
+- Les unités de matériel qui sont utilisées dans les événements ou les réservations sont à nouveau
+ affichées dans l'onglet "Périodes de réservation" de la fiche matériel (Premium #284).
+- Les références des unités utilisées dans un événement ou une réservation sont affichées dans
+ l'onglet "materiel" de la fenêtre de l'événement ou réservation (Premium #284).
+- Quand l'utilisateur connecté a des parcs restreints et qu'il n'a accès qu'à un seul parc de matériel,
+ le filtre par parc du calendrier est pré-rempli avec ce parc (Premium #163).
+
## 0.20.6 (2023-04-14)
- Pour les réservations en ligne, le comportement du délai minimum avant réservation a été revu
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 37fcbf56f..9db80d929 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -100,7 +100,7 @@ Cette commande vous permet de lancer un serveur de développement front-end, ave
qui servira les sources JS, CSS et les assets, à l'adresse `http://localhost:8081/`.
Pour travailler, créez un fichier `.env` dans le dossier `server/` qui contient la variable `APP_ENV=development`,
-puis ouvrez l'application sur son serveur back-end (par ex. `http://robert2.local`).
+puis ouvrez l'application sur son serveur back-end (par ex. `http://loxya.test`).
#### `yarn build`
@@ -109,11 +109,11 @@ _(Pensez à exécuter cette commande et à commiter le résultat dans votre PR l
## URL de l'API en développement
-En développement, l'hôte par défaut utilisé par la partie client pour communiquer avec l'API est `http://robert2.local`.
+En développement, l'hôte par défaut utilisé par la partie client pour communiquer avec l'API est `http://loxya.test/`.
Si vous souhaitez modifier ceci, vous pouvez créer un fichier `.env.development.local` à la racine du dossier
client et surcharger la variable d'environnement `VUE_APP_API_URL` avec votre propre URL d'API (par
-exemple `http://localhost/robert2`).
+exemple `http://localhost/loxya`).
## Migration de la base de données
diff --git a/VERSION b/VERSION
index 752e63038..885415662 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.20.6
+0.21.0
diff --git a/client/.env.development b/client/.env.development
index da429c19b..70efd2809 100644
--- a/client/.env.development
+++ b/client/.env.development
@@ -1 +1 @@
-VUE_APP_API_URL='http://robert2.local'
+VUE_APP_API_URL='http://loxya.test'
diff --git a/client/.eslintrc.js b/client/.eslintrc.js
index e531c70ad..cb8b872f7 100644
--- a/client/.eslintrc.js
+++ b/client/.eslintrc.js
@@ -1,10 +1,14 @@
-/* eslint-disable strict */
-
'use strict';
module.exports = {
extends: '@pulsanova/vue',
+ // - Globals
+ globals: {
+ require: 'readonly',
+ process: 'readonly',
+ },
+
// - Parseur
parserOptions: {
project: './tsconfig.json',
@@ -33,7 +37,7 @@ module.exports = {
},
},
{
- files: ['**/tests/**/*', '**/__tests__/*'],
+ files: ['**/tests/**/*', '**/__tests__/*', '**/*.spec.*'],
env: { jest: true },
settings: {
'import/resolver': {
@@ -58,6 +62,17 @@ module.exports = {
'import/order': ['off'],
},
},
+ {
+ files: [
+ '**/jest.config.js',
+ '**/babel.config.js',
+ '**/eslint.config.js',
+ '**/postcss.config.js',
+ '**/vue.config.js',
+ '**/.eslintrc.js',
+ ],
+ extends: '@pulsanova/node',
+ },
// - Autorise le `snake_case` dans les types d'API vu que pour le moment
// celle-ci accepte et retourne uniquement sous ce format.
{
diff --git a/client/.gitignore b/client/.gitignore
index 57d59a0f7..ecaf3cc66 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -1,7 +1,7 @@
node_modules/
/dist/
-/test/unit/coverage
+/tests/coverage
# - Logs
npm-debug.log*
diff --git a/client/babel.config.js b/client/babel.config.js
index ec4b636cc..1c6487677 100644
--- a/client/babel.config.js
+++ b/client/babel.config.js
@@ -1,3 +1,5 @@
+'use strict';
+
module.exports = {
presets: [
'vca-jsx',
diff --git a/client/jest.config.js b/client/jest.config.js
index 68fabc066..38eda5714 100644
--- a/client/jest.config.js
+++ b/client/jest.config.js
@@ -1,23 +1,30 @@
+'use strict';
+
module.exports = {
- moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'vue'],
- transform: {
- '^.+\\.vue$': 'vue-jest',
- '.+\\.(css|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
- '^.+\\.(j|t)sx?$': 'babel-jest',
- },
- transformIgnorePatterns: ['/node_modules/'],
+ rootDir: __dirname,
+ collectCoverageFrom: ['src/**/*.{js,ts,tsx}', '!src/**/*.d.ts'],
+ coverageDirectory: '/tests/coverage',
+ coverageReporters: ['lcov', 'html', 'text-summary'],
+ testMatch: [
+ '/tests/specs/**/*.{js,ts,tsx}',
+ '/src/**/__tests__/**/*.{js,ts,tsx}',
+ '/src/**/?(*.)spec.{js,ts,tsx}',
+ ],
+ testEnvironment: 'jsdom',
+ testPathIgnorePatterns: ['/node_modules/'],
+ transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|cts|mjs|ts|cts|mts|tsx)$'],
+ moduleFileExtensions: ['js', 'mjs', 'cjs', 'ts', 'tsx', 'mts', 'cts', 'json'],
moduleNameMapper: {
'^@/(.*)$': '/src/$1',
'^@fixtures/(.*)$': '/tests/fixtures/$1',
},
- snapshotSerializers: ['jest-serializer-vue'],
- testMatch: [
- '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)',
- '**/__tests__/*.(js|jsx|ts|tsx)',
- ],
- testURL: 'http://localhost/',
+ transform: {
+ '^.+\\.(js|mjs|cjs|jsx|ts|mts|cts|tsx)$': 'babel-jest',
+ '^(?!.*\\.(js|mjs|cjs|ts|mts|cts|tsx|json)$)': 'jest-transform-stub',
+ },
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
+ resetMocks: true,
};
diff --git a/client/package.json b/client/package.json
index 42ec40cf9..bbde70f75 100644
--- a/client/package.json
+++ b/client/package.json
@@ -3,66 +3,78 @@
"scripts": {
"start": "vue-cli-service serve",
"build": "vue-cli-service build",
- "lint:js": "eslint './**/*.{js,vue,ts,tsx}' --report-unused-disable-directives",
- "lint:scss": "stylelint 'src/**/*.scss'",
+ "test": "npx cross-env TZ=UTC jest",
"check-types": "tsc",
- "test": "npx cross-env TZ=UTC vue-cli-service test:unit"
+ "lint:js": "eslint './**/*.{js,vue,ts,tsx}' --report-unused-disable-directives",
+ "lint:scss": "stylelint 'src/**/*.scss'"
},
"dependencies": {
+ "@floating-ui/dom": "1.2.6",
"@fortawesome/fontawesome-free": "5.15.3",
+ "@loxya/vis-timeline": "file:./vendors/vis-timeline",
"@pulsanova/reboot.css": "2.1.3",
- "@robert2/vis-timeline": "file:./vendors/vis-timeline",
- "@vue/composition-api": "1.1.5",
- "axios": "0.21.1",
- "debounce": "1.2.1",
- "decimal.js": "10.4.2",
+ "@vue/composition-api": "1.7.1",
+ "axios": "0.24.0",
+ "clsx": "1.2.1",
+ "decimal.js": "10.4.3",
"deep-freeze-strict": "1.1.1",
"invariant": "2.2.4",
- "js-cookie": "2.2.1",
+ "js-cookie": "3.0.4",
"lodash": "^4.17.21",
- "moment": "2.29.1",
+ "moment": "2.29.4",
+ "p-queue": "6.6.2",
+ "portal-vue": "2.1.7",
"status-code-enum": "~1.0.0",
+ "style-object-to-css-string": "1.0.1",
"sweetalert2": "11.0.20",
+ "tinycolor2": "1.6.0",
"v-tooltip": "2.1.3",
"vue": "2.6.14",
"vue-click-outside": "1.1.0",
- "vue-js-modal": "2.0.0-rc.6",
- "vue-query": "1.11.0",
+ "vue-js-modal": "2.0.1",
+ "vue-query": "1.26.0",
"vue-router": "3.5.2",
"vue-select": "3.12.1",
"vue-simple-calendar": "5.0.0",
- "vue-tables-2": "2.3.4",
+ "vue-tables-2-premium": "2.3.6",
"vue-toasted": "1.1.28",
- "vue2-datepicker": "3.11.0",
+ "vue2-datepicker": "3.11.1",
"vuex": "3.6.2",
"vuex-i18n": "1.13.1",
"warning": "4.0.3"
},
"devDependencies": {
- "@babel/core": "7.20.12",
- "@babel/preset-typescript": "7.18.6",
- "@pulsanova/eslint-config-vue": "2.1.2",
+ "@babel/core": "7.21.4",
+ "@babel/preset-typescript": "7.21.4",
+ "@pulsanova/eslint-config-node": "2.3.0",
+ "@pulsanova/eslint-config-vue": "2.3.1",
"@pulsanova/stylelint-config-scss": "2.0.1",
"@types/debounce": "1.2",
"@types/invariant": "2.2",
- "@types/lodash": "^4.14.191",
+ "@types/jest": "29",
+ "@types/js-cookie": "3.0",
+ "@types/lodash": "4",
+ "@types/tinycolor2": "1",
"@vue/babel-preset-app": "4.5.15",
"@vue/cli-plugin-babel": "4.5.13",
"@vue/cli-plugin-typescript": "4.5.13",
- "@vue/cli-plugin-unit-jest": "4.5.13",
"@vue/cli-service": ">=4.5.13",
"@vue/test-utils": "1.2.2",
"babel-core": "7.0.0-bridge.0",
- "babel-jest": "27.0.6",
+ "babel-jest": "29.5.0",
"babel-loader": "8.2.2",
"babel-preset-vca-jsx": "0.3.6",
- "eslint": "8.3.0",
+ "eslint": "8.39.0",
"eslint-import-resolver-custom-alias": "1.3.0",
"eslint-import-resolver-webpack": "0.13.2",
- "sass": "1.36.0",
+ "jest": "29.5.0",
+ "jest-environment-jsdom": "29.5.0",
+ "jest-transform-stub": "2.0.0",
+ "jest-watch-typeahead": "2.2.2",
+ "sass": "1.62.0",
"sass-loader": "10.2.0",
"stylelint": "14.1.0",
- "typescript": "4.9.5",
+ "typescript": "5.0.4",
"vue-cli-plugin-svg": "0.1.3",
"vue-cli-plugin-yaml": "1.0.2",
"vue-template-compiler": "2.6.14"
diff --git a/client/postcss.config.js b/client/postcss.config.js
index a3db8ae97..25f2a3bc2 100644
--- a/client/postcss.config.js
+++ b/client/postcss.config.js
@@ -1,3 +1,5 @@
+'use strict';
+
module.exports = {
plugins: {
autoprefixer: {},
diff --git a/client/src/components/Fragment/index.js b/client/src/components/Fragment/index.tsx
similarity index 100%
rename from client/src/components/Fragment/index.js
rename to client/src/components/Fragment/index.tsx
diff --git a/client/src/globals/config.js b/client/src/globals/config.js
index 1e26ebbf0..ac6c3644e 100644
--- a/client/src/globals/config.js
+++ b/client/src/globals/config.js
@@ -26,6 +26,7 @@ const defaultConfig = {
defaultPaginationLimit: 100,
billingMode: 'partial',
maxFileUploadSize: 25 * 1024 * 1024,
+ colorSwatches: null,
};
const globalConfig = window.__SERVER_CONFIG__ || defaultConfig;
diff --git a/client/src/globals/constants.js b/client/src/globals/constants.js
index 3cf3f3688..676aa2900 100644
--- a/client/src/globals/constants.js
+++ b/client/src/globals/constants.js
@@ -3,15 +3,19 @@ import moment from 'moment';
const APP_NAME = 'Loxya (Robert2)';
const DATE_DB_FORMAT = 'YYYY-MM-DD HH:mm:ss';
-const DEBOUNCE_WAIT = 500; // - in milliseconds
+const DEBOUNCE_WAIT = 500; // - En millisecondes
+
+const ALLOWED_IMAGE_TYPES = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+];
const AUTHORIZED_FILE_TYPES = [
+ ...ALLOWED_IMAGE_TYPES,
'application/pdf',
'application/zip',
'application/x-rar-compressed',
- 'image/jpeg',
- 'image/png',
- 'image/webp',
'text/plain',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.ms-excel',
@@ -21,12 +25,6 @@ const AUTHORIZED_FILE_TYPES = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
-const ALLOWED_IMAGE_TYPES = [
- 'image/jpeg',
- 'image/png',
- 'image/webp',
-];
-
const TECHNICIAN_EVENT_STEP = moment.duration(15, 'minutes');
const TECHNICIAN_EVENT_MIN_DURATION = moment.duration(15, 'minutes');
diff --git a/client/src/globals/queryClient.ts b/client/src/globals/queryClient.ts
index 2c528482f..e238b1e95 100644
--- a/client/src/globals/queryClient.ts
+++ b/client/src/globals/queryClient.ts
@@ -5,7 +5,7 @@ import { isRequestErrorStatusCode } from '@/utils/errors';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- staleTime: 10 * 60000, // - 10 minutes
+ staleTime: 10 * 60_000, // - 10 minutes
retry: (failureCount: number, error: unknown) => {
if (isRequestErrorStatusCode(error, HttpCode.ClientErrorNotFound)) {
return false;
diff --git a/client/src/globals/types/vendors/vue-click-outside.d.ts b/client/src/globals/types/vendors/vue-click-outside.d.ts
new file mode 100644
index 000000000..ba949557b
--- /dev/null
+++ b/client/src/globals/types/vendors/vue-click-outside.d.ts
@@ -0,0 +1 @@
+declare module 'vue-click-outside';
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 46f3897e8..4d0d67fe9 100644
--- a/client/src/globals/types/vendors/vue-tables-2.d.ts
+++ b/client/src/globals/types/vendors/vue-tables-2.d.ts
@@ -1,4 +1,4 @@
-declare module 'vue-tables-2' {
+declare module 'vue-tables-2-premium' {
import type { VNode } from 'vue';
import type { PaginationParams } from '@/stores/api/@types';
diff --git a/client/src/globals/types/vendors/vue.d.ts b/client/src/globals/types/vendors/vue.d.ts
index e8500232a..e195e9676 100644
--- a/client/src/globals/types/vendors/vue.d.ts
+++ b/client/src/globals/types/vendors/vue.d.ts
@@ -1,6 +1,8 @@
declare module 'vue' {
import type { VueConstructor as VueConstructorCore } from 'vue';
+ export type { VNode } from 'vue';
+
export interface VueConstructor extends VueConstructorCore {
$store: any;
$router: any;
diff --git a/client/src/hooks/useNow.ts b/client/src/hooks/useNow.ts
index dfcff686e..fe7ed4a76 100644
--- a/client/src/hooks/useNow.ts
+++ b/client/src/hooks/useNow.ts
@@ -6,7 +6,7 @@ import type { Ref } from '@vue/composition-api';
* Permet de récupérer le timestamp courant (équivalent à `Date.now()`).
* Ce timestamp sera mis à jour toutes les minutes.
*
- * @return Le timestamp courant (voir `Date.now()`).
+ * @returns Le timestamp courant (voir `Date.now()`).
*/
const useNow = (): Ref => {
const now = ref(Date.now());
diff --git a/client/src/locale/en/common.js b/client/src/locale/en/common.js
index 4ac9b4142..18ec9c2a1 100644
--- a/client/src/locale/en/common.js
+++ b/client/src/locale/en/common.js
@@ -62,6 +62,7 @@ export default {
'and-n-others': ["and {count} other", "and {count} others"],
'save': "Save",
+ 'manually-save': "Manually save",
'save-draft': "Save draft",
'add': "Add",
'saving': "Saving...",
@@ -122,7 +123,7 @@ export default {
'replacement-price': "Replacement price",
'rent-price': "Rent. price",
'repl-price': "Repl. price",
- 'value-per-day': '{value}\u00a0/\u00a0day',
+ 'value-per-day': '{value}\u00A0/\u00A0day',
'serial-number': "Serial n°",
'examples-list': "Examples: {list}, etc.",
@@ -158,16 +159,12 @@ export default {
'display-not-deleted-items': "Display not deleted items",
'created-at': "Created at:",
'updated-at': "Updated at:",
- 'units': "Units",
'state': "State",
'picture': "Picture",
'add-a-picture': "Add a picture",
'change-the-picture': "Change the picture",
'remove-the-picture': "Remove the picture",
- 'reservation-simple-title': "Reservation from {date}",
- 'reservation-full-title': "Reservation from {date} for {borrower}",
-
'event-details': "Event's details",
'title': "Title",
'dates': "Dates",
@@ -184,6 +181,7 @@ export default {
'confirmed': "Confirmed",
'not-confirmed': "Not confirmed",
'is-billable': "Is billable?",
+ 'color-on-calendar': "Color on calendar",
'event-is-now-billable': "This event is now billable.",
'is-not-billable-help': "\"Loan\" Mode: no billing.",
'is-billable-help': "\"Rent\" Mode: billing possible.",
@@ -199,9 +197,9 @@ export default {
'in': "In {location}",
'open-in-google-maps': "Open in Google Maps",
'on-date': "On {date}",
- 'from-date': "from\u00a0{date}",
- 'to-date': "to\u00a0{date}",
- 'from-date-to-date': "from\u00a0{from} to\u00a0{to}",
+ 'from-date': "from\u00A0{date}",
+ 'to-date': "to\u00A0{date}",
+ 'from-date-to-date': "from\u00A0{from} to\u00A0{to}",
'or': "or",
'for': "For",
'with': "With",
@@ -231,9 +229,9 @@ export default {
'previous-invoices': "Previous invoices",
'discount': "Discount",
'without-discount': "Without discount",
- 'discount-rate': "{rate}\u00a0% off",
+ '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.",
+ '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",
@@ -251,9 +249,9 @@ export default {
'estimate-deleted': "The estimate has been deleted.",
'invoice-created': "Invoice created.",
'total': "Total",
- 'taxes': "Taxes ({rate}%)\u00a0:\u00a0{amount}",
+ 'taxes': "Taxes ({rate}%)\u00A0:\u00A0{amount}",
'degressive-rate': "Degressive rate",
- 'total-amount-discountable-daily': "Total discountable:\u00a0{amount}\u00a0/\u0a00day",
+ 'total-amount-discountable-daily': "Total discountable:\u00A0{amount}\u00A0/\u0A00day",
'items-count': [
"{count} item",
"{count} items",
@@ -271,20 +269,21 @@ export default {
'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-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-value': "Total value",
'total-quantity': "Total quantity: {total}",
'daily-amount': "Daily amount: {amount}",
- 'replacement-value-amount': "Remplacement value\u00a0: {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",
+ 'price-per-day': "{price}\u00A0/\u00A0day",
'current': "current",
'time': "time",
'delete-selected': "Delete-selected",
@@ -303,7 +302,10 @@ export default {
'add-tags': "Add tags",
'remove-all-tags': "Remove all tags",
'remaining-count': "{count} remaining",
- 'stock-count': "{count} available in stock",
+ 'stock-count': [
+ "{count} available in stock",
+ "{count} available in stock",
+ ],
'surplus-count': "{count} in surplus / broken",
'return-inventory': "Return inventory",
'grouped-by': "Display grouped by:",
@@ -327,7 +329,7 @@ export default {
'no-list-template-available': "No list template available!",
'create-list-template': "Create a material list template",
'create-list-template-from-event': "Create a list template with this event",
- 'list-template-created': "Le modèle de liste de matériel nommé «\u00a0{name}\u00a0» a bien été créé",
+ 'list-template-created': "Le modèle de liste de matériel nommé «\u00A0{name}\u00A0» a bien été créé",
'create-company': "Add a new company",
'inventories': "Inventories",
@@ -381,13 +383,15 @@ export default {
'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?",
- 'no-units-available': "No unit available during this event for this material.",
'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. These materials must therefore be added to the park, or rented from another company.",
- 'missing-material-count': "Need {quantity}, missing\u00a0{missing}!",
+ '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!",
diff --git a/client/src/locale/en/errors.js b/client/src/locale/en/errors.js
index f1a97e7d6..19568f0bc 100644
--- a/client/src/locale/en/errors.js
+++ b/client/src/locale/en/errors.js
@@ -3,7 +3,6 @@ export default {
'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-uploading': "An unexpected error occurred while sending your files, 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': "Sorry, but the API is unreachable... Please check your access to network.",
@@ -28,9 +27,15 @@ export default {
"If the problem persists, please contact an administrator.",
].join('\n'),
- 'file-type-not-allowed': "Type '{type}' not supported.",
+ //
+ // - 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/fr/common.js b/client/src/locale/fr/common.js
index 282e3f669..35f97a172 100644
--- a/client/src/locale/fr/common.js
+++ b/client/src/locale/fr/common.js
@@ -1,5 +1,5 @@
export default {
- 'hello-name': "Bonjour {name}\u00a0!",
+ 'hello-name': "Bonjour {name}\u00A0!",
'your-settings': "Vos paramètres",
'logout-quit': "Quitter Loxya (Robert2)",
@@ -18,22 +18,22 @@ export default {
'yes': "Oui",
'no': "Non",
- 'warning': "Attention\u00a0!",
+ 'warning': "Attention\u00A0!",
'loading': "Chargement en cours...",
'please-confirm': "Veuillez confirmer...",
'yes-delete': "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 changements n'ont pas été sauvegardés. Voulez-vous vraiment quitter cette page\u00a0?",
+ 'changes-exists-really-cancel': "Des changements n'ont pas été sauvegardés. Voulez-vous vraiment 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-in-clipboard': "Copié dans le presse-papier\u00a0!",
+ 'copied-in-clipboard': "Copié dans le presse-papier\u00A0!",
'copy': "Copier",
- 'copied': "Copié\u00a0!",
+ 'copied': "Copié\u00A0!",
'almost-done': "Presque terminé...",
'done': "Terminé",
'refresh-page': "Actualiser la page",
@@ -54,7 +54,7 @@ export default {
'add-item': "Ajouter un {item}",
'remove-item': "Enlever ce {item}",
'cancel-add-item': "Annuler l'ajout de {item}",
- 'item-not-found': "{item} introuvable. Peut-être a-t-il été supprimé\u00a0?",
+ 'item-not-found': "{item} introuvable. Peut-être a-t-il été supprimé\u00A0?",
'locked': "verrouillé",
'clear-filters': "Réinitialiser les filtres",
'optional': "Optionnel",
@@ -62,6 +62,7 @@ export default {
'and-n-others': ["et {count} autre", "et {count} autres"],
'save': "Sauvegarder",
+ 'manually-save': "Sauvegarder manuellement",
'save-draft': "Sauvegarder le brouillon",
'add': "Ajouter",
'saving': "Sauvegarde...",
@@ -106,14 +107,14 @@ export default {
'visitor': "Visiteur",
'external': "Externe",
'owner': "Propriétaire",
- 'owner-key': "Propriétaire\u00a0:",
+ 'owner-key': "Propriétaire\u00A0:",
'opening-hours': "Horaires d'ouverture",
'hours': "heures",
'minutes': "minutes",
'notes': "Notes",
'description': "Description",
'ref': "Réf.",
- 'ref-ref': "Ref.\u00a0: {reference}",
+ 'ref-ref': "Ref.\u00A0: {reference}",
'reference': "Référence",
'park': "Parc",
'prices': "Tarifs",
@@ -122,9 +123,9 @@ export default {
'replacement-price': "Prix de remplacement",
'rent-price': "Tarif loc.",
'repl-price': "Val. rempl.",
- 'value-per-day': '{value}\u00a0/\u00a0jour',
+ 'value-per-day': '{value}\u00A0/\u00A0jour',
'serial-number': "N° de série",
- 'examples-list': "Exemples\u00a0: {list}, etc.",
+ 'examples-list': "Exemples\u00A0: {list}, etc.",
'qty': "Qté",
'stock-qty': "Qté stock",
@@ -132,21 +133,21 @@ export default {
'out-of-order-qty': "Qté en panne",
'out-of-order-quantity': "Quantité en panne",
'remaining-qty': "Qté restante",
- 'awaited-qty-dots': "Qté attendue\u00a0:",
+ '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?",
+ 'discountable': "Remisable\u00A0?",
+ 'is-broken': "En panne\u00A0?",
'broken': "En panne",
- 'is-lost': "Perdu\u00a0?",
+ '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-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",
@@ -155,18 +156,14 @@ export default {
'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:",
- 'updated-at': "Modifié le\u00a0:",
- 'units': "Unités",
+ 'created-at': "Créé le\u00A0:",
+ 'updated-at': "Modifié le\u00A0:",
'state': "État",
'picture': "Photo",
'add-a-picture': "Ajouter une photo",
'change-the-picture': "Changer la photo",
'remove-the-picture': "Supprimer la photo",
- 'reservation-simple-title': "Réservation du {date}",
- 'reservation-full-title': "Réservation du {date} pour {borrower}",
-
'event-details': "Détails de l'événement",
'title': "Titre",
'dates': "Dates",
@@ -182,15 +179,16 @@ export default {
'please-choose-dates': "Veuillez choisir les dates",
'confirmed': "Confirmé",
'not-confirmed': "Non confirmé",
- 'is-billable': "Est facturable\u00a0?",
+ '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.",
+ 'is-not-billable-help': "Mode «\u00A0prêt\u00A0»\u00A0: pas de facturation.",
+ 'is-billable-help': "Mode «\u00A0location\u00A0»\u00A0: facturation possible.",
+ 'color-on-calendar': "Couleur sur le calendrier",
'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",
- 'duplicate-the-event': "Dupliquer l'événement «\u00a0{title}\u00a0»",
+ 'duplicate-the-event': "Dupliquer l'événement «\u00A0{title}\u00A0»",
'dates-of-duplicated-event': "Dates du nouvel événement",
'print': "Imprimer",
'print-summary': "Imprimer ce récapitulatif",
@@ -198,9 +196,9 @@ export default {
'in': "À {location}",
'open-in-google-maps': "Ouvrir dans Google Maps",
'on-date': "Le {date}",
- 'from-date': "du\u00a0{date}",
- 'to-date': "au\u00a0{date}",
- 'from-date-to-date': "du\u00a0{from} au\u00a0{to}",
+ 'from-date': "du\u00A0{date}",
+ 'to-date': "au\u00A0{date}",
+ 'from-date-to-date': "du\u00A0{from} au\u00A0{to}",
'or': "ou",
'for': "Pour",
'with': "Avec",
@@ -212,9 +210,9 @@ export default {
'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!",
+ '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",
@@ -230,9 +228,9 @@ export default {
'previous-invoices': 'Anciennes factures',
'discount': "Remise",
'without-discount': "sans remise",
- 'discount-rate': "Remise {rate}\u00a0%",
+ '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.",
+ '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",
@@ -250,9 +248,9 @@ export default {
'estimate-deleted': "Le devis a été supprimé.",
'invoice-created': "La facture a bien été créée.",
'total': "Total",
- 'taxes': "Taxes ({rate}%)\u00a0:\u00a0{amount}",
+ 'taxes': "Taxes ({rate}%)\u00A0:\u00A0{amount}",
'degressive-rate': "Tarif dégressif",
- 'total-amount-discountable-daily': "Total remisable\u00a0:\u00a0{amount}\u00a0/\u00a0jour",
+ 'total-amount-discountable-daily': "Total remisable\u00A0:\u00A0{amount}\u00A0/\u00A0jour",
'items-count': [
"{count} article",
"{count} articles",
@@ -270,31 +268,32 @@ export default {
'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-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-value': "Valeur totale",
- 'total-quantity': "Quantité totale\u00a0: {total}",
- 'daily-amount': "Montant journalier\u00a0: {amount}",
- 'replacement-value-amount': "Valeur de remplacement\u00a0: {amount}",
+ '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",
+ 'price-per-day': "{price}\u00A0/\u00A0jour",
'current': "actuel",
'time': "heure",
'delete-selected': "Effacer la selection",
- "daily-total": "Total\u00a0/\u00a0jour\u00a0:",
- "daily-total-discountable": "Total remisable\u00a0/\u00a0jour\u00a0:",
- "daily-total-without-tax-after-discount": "Total H.T.\u00a0/\u00a0jour après remise\u00a0:",
- "daily-total-after-discount": "Total\u00a0/\u00a0jour 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:",
+ "daily-total": "Total\u00A0/\u00A0jour\u00A0:",
+ "daily-total-discountable": "Total remisable\u00A0/\u00A0jour\u00A0:",
+ "daily-total-without-tax-after-discount": "Total H.T.\u00A0/\u00A0jour après remise\u00A0:",
+ "daily-total-after-discount": "Total\u00A0/\u00A0jour 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",
@@ -302,10 +301,13 @@ export default {
'add-tags': "Ajouter des tags",
'remove-all-tags': "Enlever tous les tags",
'remaining-count': "reste {count}",
- 'stock-count': '{count} disponible en stock',
+ 'stock-count': [
+ "{count} disponible en stock",
+ "{count} disponibles en stock",
+ ],
'surplus-count': ["{count} en excédent / cassé", "{count} en excédent / cassés"],
'return-inventory': "Inventaire de retour",
- 'grouped-by': "Voir groupé par\u00a0:",
+ 'grouped-by': "Voir groupé par\u00A0:",
'not-grouped': "Non groupé",
'return-scheduled-on': "Retour prévu le",
'back-to-calendar': "Retour au calendrier",
@@ -320,13 +322,13 @@ export default {
'use-list-template': "Utiliser un modèle...",
'choose-list-template-to-use': "Choisir un modèle de liste à utiliser",
'use': "Utiliser",
- 'list-template-details': "Détails du modèle de liste «\u00a0{name}\u00a0»",
- 'list-template-use-warning': "Attention, utiliser ce modèle de liste va ajouter le matériel à celui déjà sélectionné, et sauvegarder la liste du matériel tout de suite après\u00a0!",
+ 'list-template-details': "Détails du modèle de liste «\u00A0{name}\u00A0»",
+ 'list-template-use-warning': "Attention, utiliser ce modèle de liste va ajouter le matériel à celui déjà sélectionné, et sauvegarder la liste du matériel tout de suite après\u00A0!",
'use-this-template': "Utiliser ce modèle de liste",
- 'no-list-template-available': "Aucun modèle de liste disponible\u00a0!",
+ 'no-list-template-available': "Aucun modèle de liste disponible\u00A0!",
'create-list-template': "Créer un modèle de liste de matériel",
'create-list-template-from-event': "Créer un modèle de liste avec cet événement",
- 'list-template-created': "Le modèle de liste de matériel nommé «\u00a0{name}\u00a0» a bien été créé",
+ 'list-template-created': "Le modèle de liste de matériel nommé «\u00A0{name}\u00A0» a bien été créé",
'create-company': "Ajouter une nouvelle société",
'inventories': "Inventaires",
@@ -348,11 +350,11 @@ export default {
"...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!",
+ '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»",
+ 'event-materials': "Matériel de l'événement «\u00A0{name}\u00A0»",
'created-by': "Créé par",
'event': "Événement",
@@ -381,29 +383,31 @@ export default {
'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, celui-ci 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, celui-ci sera annulé. Êtes-vous sûr de vouloir continuer\u00A0?",
+
'@event': {
- 'confirm-delete': "Mettre cet événement à la corbeille\u00a0?",
- 'no-units-available': "Aucune unité disponible pendant cet événement pour ce matériel.",
+ '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. Ce matériel doit donc être ajouté au parc, ou bien loué auprès d'une autre société.",
- 'missing-material-count': "Besoin de {quantity}, il en manque\u00a0{missing}\u00a0!",
+ 'missing-material-count': "Besoin de {quantity}, il en manque\u00A0{missing}\u00A0!",
- '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!",
+ '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.",
+ '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-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!",
+ '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é.",
},
},
@@ -414,7 +418,7 @@ export default {
'is-currently-running': "Cette réservation se déroule en ce moment.",
'is-archived': "Cette réservation est archivée.",
'has-missing-materials': "Cette réservation a du matériel manquant.",
- 'needs-its-return-inventory': "Il faut faire l'inventaire de retour de cette réservation\u00a0!",
+ 'needs-its-return-inventory': "Il faut faire l'inventaire de retour de cette réservation\u00A0!",
'has-not-returned-materials': "Cette réservation a du matériel qui n'a pas été retourné.",
},
},
diff --git a/client/src/locale/fr/errors.js b/client/src/locale/fr/errors.js
index 4378a7ee1..5995c652e 100644
--- a/client/src/locale/fr/errors.js
+++ b/client/src/locale/fr/errors.js
@@ -3,7 +3,6 @@ export default {
'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-uploading': "Une erreur inattendue s'est produite lors de l'envoi de vos fichiers, 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': "Désolé, mais l'API est inaccessible... Veuillez vérifier votre accès au réseau.",
@@ -19,18 +18,24 @@ export default {
'details-intro-forum': "le forum",
'details-intro3': "ou sur",
'details-intro-not-detailed': "Pour obtenir plus de détails sur l'erreur, vous pouvez modifier le paramètre `displayErrorDetails` à 'true' dans le fichier 'src/App/Config/settings.json'.",
- 'details-request': "Requête API\u00a0:",
+ 'details-request': "Requête API\u00A0:",
'details-message': "Message de l'erreur",
- 'details-file': "Fichier\u00a0:",
- 'details-stacktrace': "Trace de la pile\u00a0:",
+ 'details-file': "Fichier\u00A0:",
+ 'details-stacktrace': "Trace de la pile\u00A0:",
'critical': [
"Une erreur s'est produite, veuillez actualiser la page.",
"Si le problème persiste, veuillez contacter un administrateur.",
].join('\n'),
- 'file-type-not-allowed': "Le type '{type}' n'est pas pris en charge.",
+ //
+ // - 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/stores/api/attributes.ts b/client/src/stores/api/attributes.ts
index 95b569a82..1ae64aed8 100644
--- a/client/src/stores/api/attributes.ts
+++ b/client/src/stores/api/attributes.ts
@@ -6,6 +6,8 @@ import type { Category } from './categories';
// - Types
//
+export type AttributeType = 'string' | 'integer' | 'float' | 'boolean' | 'date';
+
type AttributeBase = {
id: number,
name: string,
@@ -13,7 +15,7 @@ type AttributeBase = {
export type Attribute = AttributeBase & (
| { type: 'string', maxLength: number | null }
- | { type: 'integer' | 'float', unit: string | null }
+ | { type: 'integer' | 'float', unit: string | null, isTotalisable: boolean }
| { type: 'boolean' | 'date' }
);
@@ -23,10 +25,14 @@ export type AttributeDetails = Attribute & {
export type AttributeEdit = {
name: string,
+ type?: AttributeType,
+ unit?: string,
+ isTotalisable?: boolean,
+ maxLength?: string | null,
categories: Array,
};
-export type AttributePut = Partial>;
+export type AttributePut = Omit;
//
// - Fonctions
@@ -39,6 +45,10 @@ const all = async (categoryId?: Category['id'] | 'none'): Promise => (
+ (await requester.get(`/attributes/${id}`)).data
+);
+
const create = async (data: AttributeEdit): Promise => (
(await requester.post('/attributes', data)).data
);
@@ -51,4 +61,4 @@ const remove = async (id: Attribute['id']): Promise => {
await requester.delete(`/attributes/${id}`);
};
-export default { all, create, update, remove };
+export default { all, one, create, update, remove };
diff --git a/client/src/stores/api/bookings.ts b/client/src/stores/api/bookings.ts
index 1d6e60b3d..3164ae548 100644
--- a/client/src/stores/api/bookings.ts
+++ b/client/src/stores/api/bookings.ts
@@ -1,8 +1,10 @@
import requester from '@/globals/requester';
+import { normalize as normalizeEvent } from '@/stores/api/events';
import type { Moment } from 'moment';
-import type { Event } from '@/stores/api/events';
+import type { Event, RawEvent } from '@/stores/api/events';
import type { Park } from '@/stores/api/parks';
+import type { Material } from '@/stores/api/materials';
//
// - Constants
@@ -16,18 +18,78 @@ export enum BookingEntity {
// - Types
//
+type EventBookingSummary = (
+ & Pick
+ & {
+ entity: BookingEntity.EVENT,
+ parks: Array,
+ }
+);
+
+export type BookingSummary = EventBookingSummary;
+
+type RawEventBooking = RawEvent & {
+ entity: BookingEntity.EVENT,
+};
+
+type RawBooking = RawEventBooking;
+
export type EventBooking = Event & {
entity: BookingEntity.EVENT,
- parks: Array,
};
-export type Booking = EventBooking;
+type Booking = EventBooking;
+
+export type BookingMaterialQuantity = {
+ id: Material['id'],
+ quantity: number,
+};
+
+type UpdateBookingMaterialsParams = {
+ entity: BookingEntity,
+ materials: BookingMaterialQuantity[],
+};
+
+//
+// - Normalizer
+//
+
+const normalize = (rawBooking: RawBooking): Booking => {
+ const { entity, ...booking } = rawBooking;
+ if (entity === BookingEntity.EVENT) {
+ return {
+ entity: BookingEntity.EVENT,
+ ...normalizeEvent(booking as RawEvent),
+ };
+ }
+ throw new Error(`Entity '${entity}' not recognized.`);
+};
//
// - Fonctions
//
-const all = async (start: Moment, end: Moment): Promise => {
+const all = async (start: Moment, end: Moment): Promise => {
const params = {
start: start.format('YYYY-MM-DD HH:mm:ss'),
end: end.format('YYYY-MM-DD HH:mm:ss'),
@@ -35,4 +97,8 @@ const all = async (start: Moment, end: Moment): Promise => {
return (await requester.get('/bookings', { params })).data;
};
-export default { all };
+const updateMaterials = async (id: BookingSummary['id'], params: UpdateBookingMaterialsParams): Promise => (
+ normalize((await requester.put(`/bookings/${id}/materials`, params)).data)
+);
+
+export default { all, updateMaterials };
diff --git a/client/src/stores/api/documents.ts b/client/src/stores/api/documents.ts
index 1233f963e..28670b6c2 100644
--- a/client/src/stores/api/documents.ts
+++ b/client/src/stores/api/documents.ts
@@ -9,6 +9,8 @@ export type Document = {
name: string,
type: string,
size: number,
+ url: string,
+ created_at: string,
};
//
diff --git a/client/src/stores/api/events.ts b/client/src/stores/api/events.ts
index 9c31cf6f5..6a11c7b0e 100644
--- a/client/src/stores/api/events.ts
+++ b/client/src/stores/api/events.ts
@@ -1,35 +1,62 @@
-import Decimal from 'decimal.js';
import requester from '@/globals/requester';
+import Decimal from 'decimal.js';
import { normalize as normalizeEstimate } from '@/stores/api/estimates';
import { normalize as normalizeInvoice } from '@/stores/api/invoices';
+import type { AxiosRequestConfig as RequestConfig } from 'axios';
import type { WithCount } from '@/stores/api/@types';
import type { Beneficiary } from '@/stores/api/beneficiaries';
import type { Technician } from '@/stores/api/technicians';
import type { Material } from '@/stores/api/materials';
import type { RawEstimate, Estimate } from '@/stores/api/estimates';
import type { RawInvoice, Invoice } from '@/stores/api/invoices';
+import type { Document } from '@/stores/api/documents';
import type { User } from '@/stores/api/users';
//
// - Types
//
-export type Event = (
+export type EventMaterial = (
+ & Material
& {
+ pivot: {
+ id: number,
+ event_id: Event['id'],
+ material_id: Material['id'],
+ quantity: number,
+ quantity_missing: number,
+ quantity_returned: number,
+ quantity_returned_broken: number,
+ },
+ }
+);
+
+export type RawEvent<
+ DecimalType extends string | Decimal = string,
+ IsBillable extends boolean = boolean,
+> = (
+ {
id: number,
title: string,
reference: string | null,
description: string | null,
start_date: string,
end_date: string,
+ duration: number, // - En jours.
+ color: string | null,
location: string | null,
+ total_replacement: DecimalType,
+ currency: string,
beneficiaries: Beneficiary[],
technicians: Technician[],
+ materials: EventMaterial[],
is_confirmed: boolean,
- is_billable: boolean,
+ is_return_inventory_started: boolean,
is_return_inventory_done: boolean,
user_id: User['id'] | null,
+ user: User | null,
+ note: string | null,
created_at: string,
updated_at: string,
}
@@ -45,32 +72,6 @@ export type Event = (
has_not_returned_materials: boolean | null,
}
)
-);
-
-export type MaterialWithPivot = Material & {
- pivot: {
- id: number,
- event_id: Event['id'],
- material_id: Material['id'],
- quantity: number,
- quantity_missing: number,
- quantity_returned: number,
- quantity_returned_broken: number,
- },
-};
-
-export type RawEventDetails<
- DecimalType extends string | Decimal = string,
- IsBillable extends boolean = boolean,
-> = (
- Event
- & {
- duration: number,
- materials: MaterialWithPivot[],
- total_replacement: DecimalType,
- currency: string,
- user: User | null,
- }
& (
IsBillable extends true
? {
@@ -96,7 +97,9 @@ export type RawEventDetails<
)
);
-export type EventDetails = RawEventDetails;
+export type Event<
+ IsBillable extends boolean = boolean,
+> = RawEvent;
export type EventSummary = Pick {
+export const normalize = (rawEvent: RawEvent): Event => {
if (!rawEvent.is_billable) {
return {
...rawEvent,
@@ -136,7 +139,11 @@ const normalize = (rawEvent: RawEventDetails): EventDetails => {
};
}
- const { estimates: rawEstimates, invoices: rawInvoices, ...event } = rawEvent;
+ const {
+ estimates: rawEstimates,
+ invoices: rawInvoices,
+ ...event
+ } = rawEvent;
const invoices = rawInvoices !== undefined
? rawInvoices.map(normalizeInvoice)
@@ -163,7 +170,7 @@ const normalize = (rawEvent: RawEventDetails): EventDetails => {
total_taxes: new Decimal(event.total_taxes),
total_with_taxes: new Decimal(event.total_with_taxes),
total_replacement: new Decimal(event.total_replacement),
- } as EventDetails;
+ } as Event;
};
//
@@ -174,31 +181,31 @@ const all = async (params: SearchParams): Promise> =>
(await requester.get('/events', { params })).data
);
-const one = async (id: Event['id']): Promise => (
+const one = async (id: Event['id']): Promise => (
normalize((await requester.get(`/events/${id}`)).data)
);
-const missingMaterials = async (id: Event['id']): Promise => (
+const missingMaterials = async (id: Event['id']): Promise => (
(await requester.get(`/events/${id}/missing-materials`)).data
);
-const setConfirmed = async (id: Event['id'], isConfirmed: boolean): Promise => (
+const setConfirmed = async (id: Event['id'], isConfirmed: boolean): Promise => (
normalize((await requester.put(`/events/${id}`, { is_confirmed: isConfirmed })).data)
);
-const archive = async (id: Event['id']): Promise => (
+const archive = async (id: Event['id']): Promise => (
normalize((await requester.put(`/events/${id}/archive`)).data)
);
-const unarchive = async (id: Event['id']): Promise => (
+const unarchive = async (id: Event['id']): Promise => (
normalize((await requester.put(`/events/${id}/unarchive`)).data)
);
-const updateReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => (
+const updateReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => (
normalize((await requester.put(`/events/${id}/inventory`, inventory)).data)
);
-const finishReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => (
+const finishReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => (
normalize((await requester.put(`/events/${id}/inventory/finish`, inventory)).data)
);
@@ -210,15 +217,15 @@ const createEstimate = async (id: Event['id'], discountRate: number = 0): Promis
normalizeEstimate((await requester.post(`/events/${id}/estimates`, { discountRate })).data)
);
-const create = async (params: any): Promise => (
+const create = async (params: any): Promise => (
normalize((await requester.post(`/events`, params)).data)
);
-const update = async (id: Event['id'], params: any): Promise => (
+const update = async (id: Event['id'], params: any): Promise => (
normalize((await requester.put(`/events/${id}`, params)).data)
);
-const duplicate = async (id: Event['id'], data: EventDuplicatePayload): Promise => (
+const duplicate = async (id: Event['id'], data: EventDuplicatePayload): Promise => (
normalize((await requester.post(`/events/${id}/duplicate`, data)).data)
);
@@ -226,6 +233,15 @@ const remove = async (id: Event['id']): Promise => {
await requester.delete(`/events/${id}`);
};
+const documents = async (id: Event['id']): Promise => (
+ (await requester.get(`/events/${id}/documents`)).data
+);
+
+const attachDocument = async (id: Event['id'], file: File, options: RequestConfig = {}): Promise => {
+ const formData = new FormData(); formData.append('file', file);
+ return (await requester.post(`/events/${id}/documents`, formData, options)).data;
+};
+
export default {
all,
one,
@@ -241,4 +257,6 @@ export default {
duplicate,
update,
remove,
+ documents,
+ attachDocument,
};
diff --git a/client/src/stores/api/materials.ts b/client/src/stores/api/materials.ts
index 51067967d..d1a4fc463 100644
--- a/client/src/stores/api/materials.ts
+++ b/client/src/stores/api/materials.ts
@@ -1,9 +1,9 @@
import requester from '@/globals/requester';
-import type { ProgressCallback } from 'axios';
+import type { ProgressCallback, AxiosRequestConfig as RequestConfig } from 'axios';
import type { PaginatedData, ListingParams } from '@/stores/api/@types';
import type { Event } from '@/stores/api/events';
-import type { Booking } from '@/stores/api/bookings';
+import type { BookingSummary } from '@/stores/api/bookings';
import type { Category } from '@/stores/api/categories';
import type { Subcategory } from '@/stores/api/subcategories';
import type { Park } from '@/stores/api/parks';
@@ -47,6 +47,7 @@ export type Material = (
attributes: MaterialAttribute[],
created_at: string,
updated_at: string,
+ is_unitary: false,
park_id: Park['id'],
}
);
@@ -57,7 +58,7 @@ export type MaterialWithAvailabilities = Material & {
available_quantity?: number,
};
-export type BookingWithPivot = Booking & {
+export type BookingWithPivot = BookingSummary & {
pivot: {
quantity: number,
},
@@ -98,7 +99,6 @@ type GetAllRaw = GetAllParams & { paginated: false };
// - Fonctions
//
-/* eslint-disable func-style */
async function all(params: GetAllRaw): Promise;
async function all(params: GetAllPaginated): Promise>;
async function all(params: GetAllPaginated | GetAllRaw): Promise {
@@ -138,13 +138,9 @@ const documents = async (id: Material['id']): Promise => (
(await requester.get(`/materials/${id}/documents`)).data
);
-const attachDocuments = async (id: Material['id'], files: File[], onProgress?: ProgressCallback): Promise => {
- const formData = new FormData();
- files.forEach((file: File, index: number) => {
- formData.append(`file-${index}`, file);
- });
-
- await requester.post(`/materials/${id}/documents`, formData, { onProgress });
+const attachDocument = async (id: Material['id'], file: File, options: RequestConfig = {}): Promise => {
+ const formData = new FormData(); formData.append('file', file);
+ return (await requester.post(`/materials/${id}/documents`, formData, options)).data;
};
export default {
@@ -157,5 +153,5 @@ export default {
remove,
bookings,
documents,
- attachDocuments,
+ attachDocument,
};
diff --git a/client/src/stores/api/settings.ts b/client/src/stores/api/settings.ts
index 6bd187e2d..d429b24b7 100644
--- a/client/src/stores/api/settings.ts
+++ b/client/src/stores/api/settings.ts
@@ -6,6 +6,11 @@ import requester from '@/globals/requester';
export type MaterialDisplayMode = 'categories' | 'sub-categories' | 'parks' | 'flat';
+export enum ReturnInventoryMode {
+ START_EMPTY = 'start-empty',
+ START_FULL = 'start-full',
+}
+
export type Settings = {
eventSummary: {
customText: {
@@ -21,6 +26,9 @@ export type Settings = {
showBorrower: boolean,
},
},
+ returnInventory: {
+ mode: ReturnInventoryMode,
+ },
};
//
diff --git a/client/src/stores/api/technicians.ts b/client/src/stores/api/technicians.ts
index 3731666fe..2cb9a80ad 100644
--- a/client/src/stores/api/technicians.ts
+++ b/client/src/stores/api/technicians.ts
@@ -1,7 +1,9 @@
import requester from '@/globals/requester';
+import type { AxiosRequestConfig as RequestConfig } from 'axios';
import type { PaginatedData, ListingParams } from '@/stores/api/@types';
import type { Country } from '@/stores/api/countries';
+import type { Document } from '@/stores/api/documents';
import type { Event } from './events';
//
@@ -73,6 +75,15 @@ const remove = async (id: Technician['id']): Promise => {
await requester.delete(`/technicians/${id}`);
};
+const documents = async (id: Technician['id']): Promise => (
+ (await requester.get(`/technicians/${id}/documents`)).data
+);
+
+const attachDocument = async (id: Technician['id'], file: File, options: RequestConfig = {}): Promise => {
+ const formData = new FormData(); formData.append('file', file);
+ return (await requester.post(`/technicians/${id}/documents`, formData, options)).data;
+};
+
export default {
all,
allWhileEvent,
@@ -81,4 +92,6 @@ export default {
update,
remove,
restore,
+ documents,
+ attachDocument,
};
diff --git a/client/src/stores/api/users.ts b/client/src/stores/api/users.ts
index 3625e1dc1..566d39bcf 100644
--- a/client/src/stores/api/users.ts
+++ b/client/src/stores/api/users.ts
@@ -59,7 +59,6 @@ const create = async (data: UserEdit): Promise => (
(await requester.post('/users', data)).data
);
-/* eslint-disable func-style */
async function update(id: 'self', data: UserEditSelf): Promise;
async function update(id: User['id'], data: UserEdit): Promise;
async function update(id: User['id'] | 'self', data: UserEdit | UserEditSelf): Promise {
diff --git a/client/src/stores/auth.js b/client/src/stores/auth.js
index 97c9e84a1..27755665b 100644
--- a/client/src/stores/auth.js
+++ b/client/src/stores/auth.js
@@ -9,7 +9,7 @@ const setSessionCookie = (token) => {
const cookieConfig = {};
if (timeout) {
const timeoutMs = timeout * 60 * 60 * 1000;
- const timeoutDate = new Date(new Date().getTime() + timeoutMs);
+ const timeoutDate = new Date(Date.now() + timeoutMs);
cookieConfig.expires = timeoutDate;
}
diff --git a/client/src/themes/default/components/App/index.js b/client/src/themes/default/components/App/index.js
index d46a0f2e1..3d74d47f6 100644
--- a/client/src/themes/default/components/App/index.js
+++ b/client/src/themes/default/components/App/index.js
@@ -1,15 +1,12 @@
import invariant from 'invariant';
import HttpCode from 'status-code-enum';
-import { useQueryProvider } from 'vue-query';
import { computed, watch } from '@vue/composition-api';
-import queryClient from '@/globals/queryClient';
import useRouter from '@/hooks/useRouter';
import layouts from '@/themes/default/layouts';
import { isRequestErrorStatusCode } from '@/utils/errors';
// @vue/component
const App = (props, { root }) => {
- useQueryProvider(queryClient);
const { route } = useRouter();
const layout = computed(() => {
const routeMeta = route.value?.meta;
@@ -31,7 +28,7 @@ const App = (props, { root }) => {
watch(route, () => { root.$modal.hideAll(); });
return () => {
- invariant(layout.value in layouts, `Le layout "${layout}" n'existe pas.`);
+ invariant(layout.value in layouts, `Le layout "${layout.value}" n'existe pas.`);
const Layout = layouts[layout.value];
return (
diff --git a/client/src/themes/default/components/Button/index.js b/client/src/themes/default/components/Button/index.js
index 902f0f9c0..ea69986d1 100644
--- a/client/src/themes/default/components/Button/index.js
+++ b/client/src/themes/default/components/Button/index.js
@@ -54,11 +54,11 @@ const Button = (props, { slots, emit }) => {
tooltip,
} = toRefs(props);
- const handleClick = () => {
+ const handleClick = (e) => {
if (disabled.value) {
return;
}
- emit('click');
+ emit('click', e);
};
const getPredefinedValue = (key) => {
@@ -100,11 +100,11 @@ const Button = (props, { slots, emit }) => {
const _tooltip = computed(() => {
const predefinedValue = getPredefinedValue('tooltip');
- if (predefinedValue == null || typeof tooltip.value === 'string') {
+ if ([undefined, null].includes(predefinedValue) || typeof tooltip.value === 'string') {
return tooltip.value;
}
- if (tooltip.value == null) {
+ if ([undefined, null].includes(tooltip.value)) {
return predefinedValue;
}
@@ -140,7 +140,6 @@ const Button = (props, { slots, emit }) => {
const isOutside = typeof to.value === 'string' && to.value.includes('://');
return (
- // eslint-disable-next-line react/jsx-no-target-blank
['color']>,
+ required: true,
+ },
+ },
+ computed: {
+ hue(): number {
+ return this.color.getHue();
+ },
+
+ hex(): HexColorString {
+ return this.color.toHexString();
+ },
+
+ hsva(): HsvaColorObject {
+ return this.color.toHsv();
+ },
+ },
+ created() {
+ this.debouncedMoveMarker = throttle(this.moveMarker.bind(this), 50);
+
+ // - Binding.
+ this.handleMouseUp = this.handleMouseUp.bind(this);
+ this.handleMouseMove = this.handleMouseMove.bind(this);
+ this.handleTouchMove = this.handleTouchMove.bind(this);
+ this.handleTouchEnd = this.handleTouchEnd.bind(this);
+ },
+ beforeDestroy() {
+ this.debouncedMoveMarker.cancel();
+
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ document.removeEventListener('mouseup', this.handleMouseUp);
+ document.removeEventListener('touchmove', this.handleTouchMove);
+ document.removeEventListener('touchend', this.handleTouchEnd);
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleClick(event: PointerEvent) {
+ event.stopPropagation();
+
+ this.moveMarker(event.pageX, event.pageY);
+ },
+
+ //
+ // - Mouse events.
+ //
+
+ handleMouseMove(event: MouseEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.debouncedMoveMarker(event.pageX, event.pageY);
+ },
+
+ handleMouseUp() {
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ },
+
+ handleMouseDown() {
+ document.addEventListener('mousemove', this.handleMouseMove);
+ document.addEventListener('mouseup', this.handleMouseUp);
+ },
+
+ //
+ // - Touch events.
+ //
+
+ handleTouchMove(event: TouchEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const { pageX, pageY } = event.changedTouches[0];
+ this.debouncedMoveMarker(pageX, pageY);
+ },
+
+ handleTouchEnd() {
+ document.removeEventListener('touchmove', this.handleTouchMove);
+ },
+
+ handleTouchStart() {
+ document.addEventListener('touchmove', this.handleTouchMove, { passive: false });
+ document.addEventListener('touchend', this.handleTouchEnd);
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
+ moveMarker(pageX: number, pageY: number) {
+ const containerPos = (this.$refs.containerRef as HTMLDivElement).getBoundingClientRect();
+ let x = pageX - (containerPos.left + window.pageXOffset);
+ let y = pageY - (containerPos.top + window.pageYOffset);
+
+ x = x < 0 ? 0 : (x > containerPos.width ? containerPos.width : x);
+ y = y < 0 ? 0 : (y > containerPos.height ? containerPos.height : y);
+
+ const newColor = new Color({
+ h: this.hsva.h,
+ s: (x / containerPos.width),
+ v: (1 - (y / containerPos.height)),
+ a: this.hsva.a,
+ });
+
+ this.$emit('change', newColor);
+ this.$refs.markerRef.focus();
+ },
+ },
+ render() {
+ const {
+ hex,
+ hue,
+ hsva,
+ handleClick,
+ handleMouseDown,
+ handleTouchStart,
+ } = this;
+
+ return (
+
+ );
+ },
+});
+
+export default ColorPickerGradient;
diff --git a/client/src/themes/default/components/ColorPicker/index.scss b/client/src/themes/default/components/ColorPicker/index.scss
new file mode 100644
index 000000000..d3744fe3c
--- /dev/null
+++ b/client/src/themes/default/components/ColorPicker/index.scss
@@ -0,0 +1,182 @@
+@use '~@/themes/default/style/globals';
+
+.ColorPicker {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ width: 200px;
+ border-radius: 10px;
+ background-color: #fff;
+ direction: ltr;
+ box-shadow:
+ 0 0 5px rgba(0, 0, 0, 0.05),
+ 0 5px 20px rgba(0, 0, 0, 0.1);
+ user-select: none;
+
+ &__body {
+ width: 100%;
+ padding: 15px 16px;
+ }
+
+ //
+ // - Transparence / Teinte.
+ //
+
+ &__hue,
+ &__alpha {
+ position: relative;
+ height: 8px;
+ margin: 0 4px;
+ border-radius: 4px;
+
+ &__slider {
+ position: absolute;
+ top: -4px;
+ left: -8px;
+ width: calc(100% + 16px);
+ height: 16px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ border-radius: 0;
+ background-color: transparent;
+ color: inherit;
+ line-height: 1;
+ opacity: 0;
+ cursor: pointer;
+ appearance: none;
+
+ &::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 8px;
+ }
+
+ &::-webkit-slider-thumb {
+ width: 8px;
+ height: 8px;
+ appearance: none;
+ }
+
+ &::-moz-range-track {
+ width: 100%;
+ height: 8px;
+ border: 0;
+ }
+
+ &::-moz-range-thumb {
+ width: 8px;
+ height: 8px;
+ border: 0;
+ }
+ }
+
+ &__marker {
+ position: absolute;
+ top: 50%;
+ left: 0;
+ width: 16px;
+ height: 16px;
+ margin-left: -8px;
+ border: 2px solid #fff;
+ border-radius: 50%;
+ background-color: hsl(var(--ColorPicker--hue), 100%, 50%);
+ transform: translateY(-50%);
+ box-shadow: 0 0 1px #888;
+ pointer-events: none;
+
+ &:focus,
+ &:focus-visible {
+ outline: none;
+ }
+ }
+ }
+
+ &__hue {
+ // stylelint-disable-next-line declaration-colon-newline-after
+ background-image: linear-gradient(
+ to right,
+ #f00 0%,
+ #ff0 16.66%,
+ #0f0 33.33%,
+ #0ff 50%,
+ #00f 66.66%,
+ #f0f 83.33%,
+ #f00 100%
+ );
+ }
+
+ &__alpha {
+ @include globals.checkerboard;
+
+ &__marker {
+ @include globals.checkerboard;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ background-color: var(--ColorPicker--color);
+ }
+ }
+
+ &__current-color {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ background-image: linear-gradient(90deg, rgba(0, 0, 0, 0), var(--ColorPicker--opaque-color));
+ }
+ }
+
+ &__hue + &__alpha {
+ margin-top: 10px;
+ }
+
+ //
+ // - Échantillons
+ //
+
+ &__swatches {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ margin-top: 15px;
+
+ &__color {
+ position: relative;
+ overflow: hidden;
+ width: 20px;
+ height: 20px;
+ margin: 0 4px 6px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background-image:
+ repeating-linear-gradient(45deg, #aaa 25%, transparent 25%, transparent 75%, #aaa 75%, #aaa),
+ repeating-linear-gradient(45deg, #aaa 25%, #fff 25%, #fff 75%, #aaa 75%, #aaa);
+ background-position: 0 0, 4px 4px;
+ background-size: 8px 8px;
+ color: var(--ColorPicker__swatches__color--color);
+ text-indent: -1000px;
+ white-space: nowrap;
+ cursor: pointer;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ background-color: currentColor;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
+ }
+ }
+ }
+}
diff --git a/client/src/themes/default/components/ColorPicker/index.tsx b/client/src/themes/default/components/ColorPicker/index.tsx
new file mode 100644
index 000000000..21a78538c
--- /dev/null
+++ b/client/src/themes/default/components/ColorPicker/index.tsx
@@ -0,0 +1,266 @@
+import './index.scss';
+import Color from '@/utils/color';
+import { defineComponent } from '@vue/composition-api';
+import Gradient from './components/Gradient';
+import config from '@/globals/config';
+
+import type { RawColor } from '@/utils/color';
+import type { PropType } from '@vue/composition-api';
+
+/** Mode de gestion de la transparence. */
+export enum AlphaMode {
+ AUTO = 'auto',
+ FORCE = 'force',
+ NONE = 'none',
+}
+
+type Props = {
+ /**
+ * Valeur (= couleur) actuelle.
+ *
+ * Peut-être fournie en différent formats:
+ * - Une chaîne de caractère représentant une couleur (e.g. `#ffffff`, `rgb(255, 255, 255)`, etc.)
+ * - Un objet littéral représentant une couleur (e.g. `{ r: 255, g: 255, b: 255, a: 0.5 }`)
+ * - Une instance de `Color` (e.g. `new Color('#ffffff')`).
+ * - La valeur `null` si le champ est "vide".
+ */
+ value: Color | RawColor | null,
+
+ /** Échantillons de couleur à proposer. */
+ swatches?: RawColor[],
+
+ /**
+ * Mode de gestion de la transparence parmi:
+ * - `auto`: La transparence est gérée automatiquement en fonction de la couleur sélectionnée.
+ * Si de la transparence est ajoutée à une couleur, c'est la version avec alpha qui
+ * sera retournée (RGBA, HSLA, etc.)) (défaut)
+ * - `none`: La gestion de la transparence est désactivée et les couleurs sous toujours retournées
+ * en version "opaque" (RGB, HSL, etc.)
+ * - `force`: La gestion de la transparence est activée et les couleurs sont toujours retournées
+ * avec gestion de la transparence activée (RGBA, HSLA, etc.)
+ */
+ alphaMode?: AlphaMode,
+};
+
+/**
+ * This component is highly based on Mohammed Bassit work
+ * on `Coloris` package (https://github.com/mdbassit/Coloris). This
+ * package is subject to the MIT License whose terms are as follows:
+ *
+ * Copyright 2021 Mohammed Bassit
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without restriction,
+ * including without limitation the rights to use, copy, modify, merge,
+ * publish, distribute, sublicense, and/or sell copies of the Software,
+ * and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
+ * AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
+ * OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+const ColorPicker = defineComponent({
+ name: 'ColorPicker',
+ props: {
+ value: {
+ type: [String, Object] as PropType['value']>,
+ required: true,
+ validator: (value: unknown | null) => (
+ value === null || Color.isValid(value)
+ ),
+ },
+ swatches: {
+ type: Array as PropType['swatches']>,
+ default: () => config.colorSwatches ?? [
+ '#264653',
+ '#2a9d8f',
+ '#e9c46a',
+ '#f4a261',
+ '#e76f51',
+ '#d62828',
+ '#023e8a',
+ '#0077b6',
+ '#0096c7',
+ '#00b4d8',
+ '#48cae4',
+ ],
+ validator: (values: unknown) => {
+ if (!Array.isArray(values)) {
+ return false;
+ }
+ return !values.some((value: unknown) => !Color.isValid(value));
+ },
+ },
+ alphaMode: {
+ type: String as PropType['alphaMode']>,
+ default: AlphaMode.AUTO,
+ validator: (value: unknown) => {
+ if (typeof value !== 'string') {
+ return false;
+ }
+ return (Object.values(AlphaMode) as string[]).includes(value);
+ },
+ },
+ },
+ emits: ['change', 'pick'],
+ computed: {
+ color(): Color {
+ if (this.value === null || !Color.isValid(this.value)) {
+ return new Color('#000000');
+ }
+ return new Color(this.value);
+ },
+
+ hue(): number {
+ return this.color.getHue();
+ },
+
+ alpha(): number {
+ return this.color.getAlpha();
+ },
+
+ formattedSwatches(): Color[] {
+ return this.swatches
+ .filter((rawColor: RawColor) => Color.isValid(rawColor))
+ .map((rawColor: RawColor) => new Color(rawColor));
+ },
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleContainerMouseDown(event: MouseEvent) {
+ event.stopPropagation();
+ },
+
+ handleGradientChange(newColor: Color) {
+ this.$emit('change', newColor);
+ },
+
+ handleHueChange(e: Event) {
+ const hue = +((e.target! as HTMLInputElement).value);
+ this.$emit('change', this.color.withHue(hue));
+ },
+
+ handleAlphaChange(e: Event) {
+ const alpha = +((e.target! as HTMLInputElement).value) / 100;
+ this.$emit('change', this.color.withAlpha(alpha));
+ },
+
+ handleSwatchClick(color: Color) {
+ this.$emit('change', color);
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
+ __(key: string, params?: Record, count?: number): string {
+ key = !key.startsWith('global.')
+ ? `components.ColorPicker.${key}`
+ : key.replace(/^global\./, '');
+
+ return this.$t(key, params, count);
+ },
+ },
+ render() {
+ const {
+ __,
+ hue,
+ color,
+ alpha,
+ alphaMode,
+ formattedSwatches,
+ handleHueChange,
+ handleSwatchClick,
+ handleAlphaChange,
+ handleGradientChange,
+ handleContainerMouseDown,
+ } = this;
+
+ return (
+
+
+
+
+ {alphaMode !== AlphaMode.NONE && (
+
+ )}
+ {formattedSwatches.length > 0 && (
+
+ {formattedSwatches.map((swatch: Color, index: number) => {
+ const colorHex = swatch.toHexString();
+ return (
+
+ )}
+
+
+ );
+ },
+});
+
+export default ColorPicker;
diff --git a/client/src/themes/default/components/ColorPicker/translations/en.yml b/client/src/themes/default/components/ColorPicker/translations/en.yml
new file mode 100644
index 000000000..46a4fab81
--- /dev/null
+++ b/client/src/themes/default/components/ColorPicker/translations/en.yml
@@ -0,0 +1,11 @@
+close-picker: Close color picker
+clear-color: Clear the selected color
+marker: "Saturation: {saturation}. Brightness: {brightness}."
+hue-slider: Hue slider
+alpha-slider: Opacity slider
+color-swatch: "Color swatch \"{color}\""
+
+color-formats-short-name:
+ hex: Hex
+ rgb: RGB
+ hsl: HSL
diff --git a/client/src/themes/default/components/ColorPicker/translations/fr.yml b/client/src/themes/default/components/ColorPicker/translations/fr.yml
new file mode 100644
index 000000000..cda277a69
--- /dev/null
+++ b/client/src/themes/default/components/ColorPicker/translations/fr.yml
@@ -0,0 +1,11 @@
+close-picker: Fermer le sélecteur de couleur
+clear-color: Effacer la couleur sélectionnée
+marker: "Saturation: {saturation}. Luminosité: {brightness}."
+hue-slider: Curseur de teinte
+alpha-slider: Curseur d'opacité
+color-swatch: "Échantillon de couleur \"{color}\""
+
+color-formats-short-name:
+ hex: Hex
+ rgb: RGB
+ hsl: HSL
diff --git a/client/src/themes/default/components/Datepicker/index.js b/client/src/themes/default/components/Datepicker/index.js
index 4543c0b50..0dd0a32cd 100644
--- a/client/src/themes/default/components/Datepicker/index.js
+++ b/client/src/themes/default/components/Datepicker/index.js
@@ -24,7 +24,7 @@ export default defineComponent({
default: undefined,
validator(value) {
const isValidDateString = (_date) => (
- _date == null ||
+ [undefined, null].includes(_date) ||
(typeof _date === 'string' && moment(_date).isValid())
);
diff --git a/client/src/themes/default/components/EventMissingMaterials/index.js b/client/src/themes/default/components/EventMissingMaterials/index.js
index 70026f549..2f37afefc 100644
--- a/client/src/themes/default/components/EventMissingMaterials/index.js
+++ b/client/src/themes/default/components/EventMissingMaterials/index.js
@@ -29,7 +29,7 @@ const EventMissingMaterials = {
const { eventId } = this;
try {
this.missingMaterials = await apiEvents.missingMaterials(eventId);
- } catch (err) {
+ } catch {
this.hasFetchError = true;
}
},
diff --git a/client/src/themes/default/components/EventTotals/index.tsx b/client/src/themes/default/components/EventTotals/index.tsx
index 0e0257403..6c58bd04f 100644
--- a/client/src/themes/default/components/EventTotals/index.tsx
+++ b/client/src/themes/default/components/EventTotals/index.tsx
@@ -1,14 +1,15 @@
import './index.scss';
import { defineComponent } from '@vue/composition-api';
-import type { PropType } from '@vue/composition-api';
-import type { EventDetails } from '@/stores/api/events';
import formatAmount from '@/utils/formatAmount';
import getEventMaterialItemsCount from '@/utils/getEventMaterialItemsCount';
import Fragment from '@/components/Fragment';
+import type { PropType } from '@vue/composition-api';
+import type { Event } from '@/stores/api/events';
+
type Props = {
/** L'événement dont on veut afficher les totaux. */
- event: EventDetails,
+ event: Event,
};
// @vue/component
@@ -21,11 +22,11 @@ const EventTotals = defineComponent({
},
},
computed: {
- itemsCount() {
+ itemsCount(): number {
return getEventMaterialItemsCount(this.event.materials);
},
- useTaxes() {
+ useTaxes(): boolean {
const { is_billable: isBillable } = this.event;
if (!isBillable) {
return false;
@@ -35,7 +36,7 @@ const EventTotals = defineComponent({
return vatRate.toNumber() > 0;
},
- hasDiscount() {
+ hasDiscount(): boolean {
const { is_billable: isBillable } = this.event;
if (!isBillable) {
return false;
@@ -45,7 +46,7 @@ const EventTotals = defineComponent({
return discountRate.toNumber() > 0;
},
- discountableDifferentFromTotal() {
+ discountableDifferentFromTotal(): boolean {
const { is_billable: isBillable } = this.event;
if (!isBillable) {
return false;
diff --git a/client/src/themes/default/components/Fieldset/index.js b/client/src/themes/default/components/Fieldset/index.js
index 42895bd04..26a2aa6ee 100644
--- a/client/src/themes/default/components/Fieldset/index.js
+++ b/client/src/themes/default/components/Fieldset/index.js
@@ -1,7 +1,8 @@
import './index.scss';
+import { defineComponent } from '@vue/composition-api';
// @vue/component
-export default {
+const Fieldset = defineComponent({
name: 'Fieldset',
inject: {
verticalForm: { default: false },
@@ -28,4 +29,6 @@ export default {
);
},
-};
+});
+
+export default Fieldset;
diff --git a/client/src/themes/default/components/FileManager/_utils.ts b/client/src/themes/default/components/FileManager/_utils.ts
new file mode 100644
index 000000000..d546cc699
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/_utils.ts
@@ -0,0 +1,42 @@
+/* eslint-disable import/prefer-default-export */
+
+import hasIncludes from '@/utils/hasIncludes';
+
+interface FileObject {
+ name: string;
+ size: number;
+ type: string | null;
+}
+
+export const getIconFromFile = (file: FileObject): string => {
+ if (file.type) {
+ if (file.type === 'application/pdf') {
+ return 'file-pdf';
+ }
+ if (file.type.startsWith('image/')) {
+ return 'file-image';
+ }
+ if (file.type.startsWith('video/')) {
+ return 'file-video';
+ }
+ if (file.type.startsWith('audio/')) {
+ return 'file-audio';
+ }
+ if (file.type.startsWith('text/')) {
+ return 'file-alt';
+ }
+ if (hasIncludes(file.type, ['zip', 'octet-stream', 'x-rar', 'x-tar', 'x-7z'])) {
+ return 'file-archive';
+ }
+ if (hasIncludes(file.type, ['sheet', 'excel'])) {
+ return 'file-excel';
+ }
+ if (hasIncludes(file.type, ['wordprocessingml.document', 'msword'])) {
+ return 'file-word';
+ }
+ if (hasIncludes(file.type, ['presentation', 'powerpoint'])) {
+ return 'file-powerpoint';
+ }
+ }
+ return 'file';
+};
diff --git a/client/src/themes/default/components/FileManager/_variables.scss b/client/src/themes/default/components/FileManager/_variables.scss
new file mode 100644
index 000000000..4e9d97fa1
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/_variables.scss
@@ -0,0 +1,16 @@
+@use '~@/themes/default/style/globals';
+
+//
+// - Drop zone
+//
+
+$drop-zone-background-color: transparent !default; // globals.$bg-color-emphasis
+$drop-zone-color: globals.$text-base-color !default;
+$drop-zone-border-radius: 10px !default;
+$drop-zone-border-width: 5px !default;
+$drop-zone-border-color: globals.$divider-color !default;
+$drop-zone-icon-color: globals.$text-soft-color !default;
+
+$drop-zone-active-color: $drop-zone-color !default;
+$drop-zone-active-icon-color: $drop-zone-icon-color !default;
+$drop-zone-active-background-color: rgba(224, 68, 6, 0.3) !default;
diff --git a/client/src/themes/default/pages/MaterialView/tabs/Documents/Item/index.scss b/client/src/themes/default/components/FileManager/components/Document/index.scss
similarity index 57%
rename from client/src/themes/default/pages/MaterialView/tabs/Documents/Item/index.scss
rename to client/src/themes/default/components/FileManager/components/Document/index.scss
index 620289522..29b867d86 100644
--- a/client/src/themes/default/pages/MaterialView/tabs/Documents/Item/index.scss
+++ b/client/src/themes/default/components/FileManager/components/Document/index.scss
@@ -1,37 +1,51 @@
@use '~@/themes/default/style/globals';
@use 'sass:color';
-.MaterialViewDocumentsItem {
+.FileManagerDocument {
display: flex;
align-items: center;
margin: 0 0 10px;
- padding: 0;
+ padding: globals.$content-padding-small-vertical;
border-radius: globals.$input-border-radius;
background: globals.$bg-color-emphasis;
color: globals.$text-base-color;
list-style: none;
+ gap: 10px;
&__link {
flex: 1;
display: flex;
align-items: center;
- padding: globals.$content-padding-small-vertical;
+ overflow: hidden;
color: globals.$text-base-color;
+ gap: 10px;
+ }
+
+ &__name {
+ display: flex;
+ overflow: hidden;
+ width: 100%;
+ margin: 0;
+ white-space: nowrap;
+
+ &__base {
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
}
&__icon {
- margin-right: globals.$content-padding-small-vertical;
font-size: 1.8rem;
}
&__size {
flex: 0 0 auto;
- margin-right: globals.$content-padding-small-vertical;
color: globals.$text-light-color;
}
&__actions {
flex: 0 0 auto;
- padding-right: globals.$content-padding-small-vertical;
}
}
diff --git a/client/src/themes/default/components/FileManager/components/Document/index.tsx b/client/src/themes/default/components/FileManager/components/Document/index.tsx
new file mode 100644
index 000000000..412f625a5
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/components/Document/index.tsx
@@ -0,0 +1,95 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import formatBytes from '@/utils/formatBytes';
+import Button from '@/themes/default/components/Button';
+import Icon from '@/themes/default/components/Icon';
+import { getIconFromFile } from '../../_utils';
+
+import type { PropType } from '@vue/composition-api';
+import type { Document } from '@/stores/api/documents';
+
+type Props = {
+ /** Le document à afficher. */
+ file: Document,
+};
+
+// @vue/component
+const FileManagerDocument = defineComponent({
+ name: 'FileManagerDocument',
+ props: {
+ file: {
+ type: Object as PropType['file']>,
+ required: true,
+ },
+ },
+ emits: ['delete'],
+ computed: {
+ icon() {
+ return getIconFromFile(this.file);
+ },
+
+ basename(): string {
+ return this.file.name.split('.').slice(0, -1).join('.');
+ },
+
+ extension(): string | undefined {
+ return this.file.name.indexOf('.') > 0
+ ? `.${this.file.name.split('.').pop()!.toLowerCase()}`
+ : undefined;
+ },
+
+ size() {
+ return formatBytes(this.file.size);
+ },
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleClickDelete() {
+ this.$emit('delete', this.file.id);
+ },
+ },
+ render() {
+ const {
+ icon,
+ size,
+ basename,
+ extension,
+ file: { name, url },
+ handleClickDelete,
+ } = this;
+
+ return (
+
+
+
+
+ {basename}
+ {undefined !== extension && (
+ {extension}
+ )}
+
+
+
+ {size}
+
+
+
+
+
+
+ );
+ },
+});
+
+export default FileManagerDocument;
diff --git a/client/src/themes/default/components/FileManager/components/UploadArea/DropZone/index.scss b/client/src/themes/default/components/FileManager/components/UploadArea/DropZone/index.scss
new file mode 100644
index 000000000..c421c8d65
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/DropZone/index.scss
@@ -0,0 +1,104 @@
+@use '../../../variables' as *;
+@use '~@/themes/default/style/globals';
+@use 'sass:color';
+
+.FileManagerDropZone {
+ $block: &;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ padding: 40px 20px;
+ border-radius: $drop-zone-border-radius;
+ background-color: $drop-zone-background-color;
+ color: $drop-zone-color;
+ text-align: center;
+ transition: background-color 300ms ease;
+ cursor: pointer;
+ user-select: none;
+
+ /* stylelint-disable declaration-colon-newline-after, indentation */
+ @if ($drop-zone-border-width > 0) {
+ background-image: url(
+ 'data:image/svg+xml,' +
+ ''
+ );
+ }
+ /* stylelint-enable declaration-colon-newline-after, indentation */
+
+ &__icon {
+ margin-bottom: 20px;
+ color: $drop-zone-icon-color;
+ font-size: 38px;
+ }
+
+ &__instruction {
+ margin: 0;
+ font-size: 1.2rem;
+
+ &__sub-line {
+ color: color.adjust($drop-zone-color, $lightness: -10%);
+ font-size: 1rem;
+ font-style: italic;
+ }
+ }
+
+ &__choose-files {
+ margin: 20px 0 0;
+ }
+
+ &__file-input {
+ display: none;
+ }
+
+ //
+ // - States
+ //
+
+ &:active,
+ &--dragging {
+ background-color: $drop-zone-active-background-color;
+ color: $drop-zone-active-color;
+
+ #{$block}__icon {
+ color: $drop-zone-active-icon-color;
+ }
+
+ #{$block}__instruction__sub-line {
+ color: color.adjust($drop-zone-active-color, $lightness: -10%);
+ }
+ }
+
+ &--dragging {
+ animation-name: FileManagerDropZone--gradient;
+ animation-duration: 2000ms;
+ animation-timing-function: linear;
+ animation-iteration-count: infinite;
+ }
+}
+
+@keyframes FileManagerDropZone--gradient {
+ 0%,
+ 100% {
+ background: $drop-zone-background-color;
+ }
+
+ 50% {
+ background: $drop-zone-active-background-color;
+ }
+}
diff --git a/client/src/themes/default/components/FileManager/components/UploadArea/DropZone/index.tsx b/client/src/themes/default/components/FileManager/components/UploadArea/DropZone/index.tsx
new file mode 100644
index 000000000..d199993d1
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/DropZone/index.tsx
@@ -0,0 +1,155 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import { AUTHORIZED_FILE_TYPES } from '@/globals/constants';
+import config from '@/globals/config';
+import formatBytes from '@/utils/formatBytes';
+import Button from '@/themes/default/components/Button';
+import Icon from '@/themes/default/components/Icon';
+
+type Data = {
+ isDragging: boolean,
+};
+
+// @vue/component
+const FileManagerDropZone = defineComponent({
+ name: 'FileManagerDropZone',
+ emits: ['input', 'dragStart', 'dragStop'],
+ data: (): Data => ({
+ isDragging: false,
+ }),
+ computed: {
+ maxSize: () => formatBytes(config.maxFileUploadSize),
+ },
+ mounted() {
+ // - Binding.
+ this.handleDragOver = this.handleDragOver.bind(this);
+ this.handleDragLeave = this.handleDragLeave.bind(this);
+ this.handleDrop = this.handleDrop.bind(this);
+
+ // - Global listeners.
+ document.addEventListener('dragover', this.handleDragOver);
+ document.addEventListener('dragleave', this.handleDragLeave);
+ document.addEventListener('drop', this.handleDrop);
+ },
+ beforeDestroy() {
+ document.removeEventListener('dragover', this.handleDragOver);
+ document.removeEventListener('dragleave', this.handleDragLeave);
+ document.removeEventListener('drop', this.handleDrop);
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleDragOver(e: DragEvent) {
+ e.preventDefault();
+
+ this.isDragging = true;
+ this.$emit('dragStart');
+ },
+
+ handleDragLeave(e: DragEvent) {
+ e.preventDefault();
+
+ this.isDragging = false;
+ this.$emit('dragStop');
+ },
+
+ handleDrop(e: DragEvent) {
+ e.preventDefault();
+
+ this.isDragging = false;
+ this.$emit('dragStop');
+
+ const files = e.dataTransfer?.files;
+ if (!files || files.length === 0) {
+ return;
+ }
+
+ this.$emit('input', files);
+ },
+
+ handleClickOpenFileBrowser(e: Event) {
+ e.stopPropagation();
+
+ this.$refs.inputFileRef.click();
+ },
+
+ handleAddFiles(e: Event) {
+ e.preventDefault();
+
+ this.isDragging = false;
+ this.$emit('dragStop');
+
+ const files = (e.target as HTMLInputElement)?.files;
+ if (!files || files.length === 0) {
+ return;
+ }
+
+ this.$emit('input', files);
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
+ __(key: string, params?: Record, count?: number): string {
+ key = !key.startsWith('global.')
+ ? `components.FileManager.${key}`
+ : key.replace(/^global\./, '');
+
+ return this.$t(key, params, count);
+ },
+ },
+ render() {
+ const {
+ __,
+ maxSize,
+ isDragging,
+ handleAddFiles,
+ handleClickOpenFileBrowser,
+ } = this;
+
+ const className = ['FileManagerDropZone', {
+ 'FileManagerDropZone--dragging': isDragging,
+ }];
+
+ return (
+
+
+
+
+ {__('drag-and-drop-files-here')}
+
+ {__('max-size', { size: maxSize })}
+
+
+
+
+
+
+ );
+ },
+});
+
+export default FileManagerDropZone;
diff --git a/client/src/themes/default/components/FileManager/components/UploadArea/Upload/index.scss b/client/src/themes/default/components/FileManager/components/UploadArea/Upload/index.scss
new file mode 100644
index 000000000..bd0a57858
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/Upload/index.scss
@@ -0,0 +1,79 @@
+@use '~@/themes/default/style/globals';
+@use 'sass:color';
+
+.FileManagerUpload {
+ $block: &;
+
+ position: relative;
+ padding: 15px 20px;
+ border-radius: 10px;
+ background: globals.$bg-color-emphasis;
+ color: globals.$text-base-color;
+
+ &__data {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+
+ &__icon {
+ font-size: 1.8rem;
+ }
+
+ &__main {
+ flex: 1;
+ overflow: hidden;
+ width: 100%;
+ }
+ }
+
+ &__name {
+ display: flex;
+ width: 100%;
+ margin: 0;
+
+ &__base {
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ &__infos {
+ margin-top: 5px;
+ color: color.adjust(globals.$text-base-color, $lightness: -20%);
+ gap: 15px;
+
+ &__info + &__info::before {
+ content: '•';
+ margin: 0 10px;
+ color: color.adjust(globals.$text-base-color, $lightness: -20%);
+ }
+ }
+
+ &__progress {
+ margin-top: 10px;
+ }
+
+ &__button-cancel {
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ }
+
+ //
+ // - States
+ //
+
+ &--error {
+ background: rgba(globals.$text-danger-color, 0.25);
+
+ #{$block}__infos__info--status {
+ color: globals.$text-danger-color;
+ }
+ }
+
+ &--cancellable {
+ padding-right: 65px;
+ }
+}
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
new file mode 100644
index 000000000..c17a18d7f
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/Upload/index.tsx
@@ -0,0 +1,194 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import config from '@/globals/config';
+import formatBytes from '@/utils/formatBytes';
+import Button from '@/themes/default/components/Button';
+import Icon from '@/themes/default/components/Icon';
+import Progressbar from '@/themes/default/components/Progressbar';
+import { getIconFromFile } from '../../../_utils';
+import { FileError } from '../_utils';
+
+import type { Upload as UploadType } from '../index';
+import type { PropType } from '@vue/composition-api';
+
+type Props = {
+ /** L'object représentant l'upload en cours. */
+ upload: UploadType,
+};
+
+const FileManagerUpload = defineComponent({
+ name: 'FileManagerUpload',
+ props: {
+ upload: {
+ type: Object as PropType['upload']>,
+ required: true,
+ },
+ },
+ emits: ['cancel'],
+ computed: {
+ hasError() {
+ return this.upload.error !== null;
+ },
+
+ basename(): string {
+ const { file } = this.upload;
+ return file.name.split('.').slice(0, -1).join('.');
+ },
+
+ extension(): string | undefined {
+ const { file } = this.upload;
+ return file.name.indexOf('.') > 0
+ ? `.${file.name.split('.').pop()!.toLowerCase()}`
+ : undefined;
+ },
+
+ progress() {
+ return this.upload.progress;
+ },
+
+ icon(): string {
+ return getIconFromFile(this.upload.file);
+ },
+
+ size(): string {
+ const { file } = this.upload;
+ return formatBytes(file.size);
+ },
+
+ status(): string {
+ const { __, hasError, upload } = this;
+
+ if (hasError) {
+ if (upload.error === FileError.TYPE_NOT_ALLOWED) {
+ return __('global.errors.file-type-not-allowed');
+ }
+
+ if (upload.error === FileError.SIZE_EXCEEDED) {
+ return __('global.errors.file-size-exceeded', {
+ max: formatBytes(config.maxFileUploadSize),
+ });
+ }
+
+ if (upload.error === FileError.UPLOAD_ERROR) {
+ return __('global.errors.file-upload-failed');
+ }
+
+ return __('global.errors.file-unknown-error');
+ }
+
+ if (upload.isCancelled) {
+ return __('upload-cancelled');
+ }
+
+ if (!upload.isStarted) {
+ return __('upload-in-queue');
+ }
+
+ if (upload.isFinished) {
+ return __('upload-complete');
+ }
+
+ return upload.progress < 100
+ ? __('upload-in-progress', { progress: upload.progress })
+ : __('upload-in-process');
+ },
+
+ cancellable() {
+ if (this.upload.isCancelled) {
+ return false;
+ }
+ return this.hasError || !this.upload.isFinished;
+ },
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleCancel() {
+ if (!this.cancellable) {
+ return;
+ }
+ this.$emit('cancel', this.upload.uid);
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
+ __(key: string, params?: Record, count?: number): string {
+ key = !key.startsWith('global.')
+ ? `components.FileManager.${key}`
+ : key.replace(/^global\./, '');
+
+ return this.$t(key, params, count);
+ },
+ },
+ render() {
+ const {
+ icon,
+ basename,
+ extension,
+ size,
+ status,
+ progress,
+ hasError,
+ cancellable,
+ handleCancel,
+ } = this;
+
+ const className = ['FileManagerUpload', {
+ 'FileManagerUpload--error': hasError,
+ 'FileManagerUpload--cancellable': cancellable,
+ }];
+
+ return (
+
+ {cancellable && (
+
+ )}
+
+
+
+
+ {basename}
+ {undefined !== extension && (
+ {extension}
+ )}
+
+
+
+ {size}
+
+
+ {status}
+
+
+
+
+ {!hasError && (
+
+ )}
+
+ );
+ },
+});
+
+export default FileManagerUpload;
diff --git a/client/tests/unit/utils/getFileError.spec.js b/client/src/themes/default/components/FileManager/components/UploadArea/_utils.spec.js
similarity index 62%
rename from client/tests/unit/utils/getFileError.spec.js
rename to client/src/themes/default/components/FileManager/components/UploadArea/_utils.spec.js
index 033815d51..8b1846d52 100644
--- a/client/tests/unit/utils/getFileError.spec.js
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/_utils.spec.js
@@ -1,5 +1,5 @@
import config from '@/globals/config';
-import getFileError from '@/utils/getFileError';
+import { getFileError } from './_utils';
describe('getFileError', () => {
it('returns null with a file OK', () => {
@@ -22,14 +22,4 @@ describe('getFileError', () => {
Object.defineProperty(file, 'size', { value: config.maxFileUploadSize + 1 });
expect(getFileError(file)).toEqual('size-exceeded');
});
-
- it('returns "already-exists" with a file that has the same name of one given in an array of files', () => {
- const existingFiles = [
- new File(['fakeContent1'], 'test1.txt', { type: 'text/plain' }),
- new File(['fakeContent2'], 'test2.txt', { type: 'text/plain' }),
- new File(['fakeContent3'], 'test3.txt', { type: 'text/plain' }),
- ];
- const file = new File(['fakeContent2'], 'test2.txt', { type: 'text/plain' });
- expect(getFileError(file, existingFiles)).toEqual('already-exists');
- });
});
diff --git a/client/src/themes/default/components/FileManager/components/UploadArea/_utils.ts b/client/src/themes/default/components/FileManager/components/UploadArea/_utils.ts
new file mode 100644
index 000000000..b137ae3b6
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/_utils.ts
@@ -0,0 +1,25 @@
+import { AUTHORIZED_FILE_TYPES } from '@/globals/constants';
+import config from '@/globals/config';
+
+export enum FileError {
+ /** Le type de fichier n'est pas supporté. */
+ TYPE_NOT_ALLOWED = 'type-not-allowed',
+
+ /** La taille du fichier excède celle autorisée. */
+ SIZE_EXCEEDED = 'size-exceeded',
+
+ /** Une erreur est survenue pendant l'upload du fichier. */
+ UPLOAD_ERROR = 'upload-error',
+}
+
+export const getFileError = (file: File): FileError | null => {
+ if (!AUTHORIZED_FILE_TYPES.includes(file.type)) {
+ return FileError.TYPE_NOT_ALLOWED;
+ }
+
+ if (file.size > config.maxFileUploadSize) {
+ return FileError.SIZE_EXCEEDED;
+ }
+
+ return null;
+};
diff --git a/client/src/themes/default/components/FileManager/components/UploadArea/index.scss b/client/src/themes/default/components/FileManager/components/UploadArea/index.scss
new file mode 100644
index 000000000..df2e2aa6c
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/index.scss
@@ -0,0 +1,39 @@
+@use '~@/themes/default/style/globals';
+
+.FileManagerUploadArea {
+ $block: &;
+
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ &__drop-zone {
+ flex: 1;
+ }
+
+ &__uploads {
+ @extend %reset-list;
+
+ flex-grow: 0;
+ width: 100%;
+
+ &__item + &__item {
+ margin-top: 10px;
+ }
+ }
+
+ //
+ // - States.
+ //
+
+ &--uploading {
+ #{$block}__drop-zone {
+ flex: unset;
+ }
+
+ #{$block}__uploads {
+ flex-grow: 1;
+ margin-top: 20px;
+ }
+ }
+}
diff --git a/client/src/themes/default/components/FileManager/components/UploadArea/index.tsx b/client/src/themes/default/components/FileManager/components/UploadArea/index.tsx
new file mode 100644
index 000000000..1d899d623
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/components/UploadArea/index.tsx
@@ -0,0 +1,192 @@
+import './index.scss';
+
+import Queue from 'p-queue';
+import { defineComponent } from '@vue/composition-api';
+import { FileError, getFileError } from './_utils';
+import uniqueId from 'lodash/uniqueId';
+import DropZone from './DropZone';
+import UploadItem from './Upload';
+
+import type { ProgressCallback } from 'axios';
+import type { PropType } from '@vue/composition-api';
+import type { Document } from '@/stores/api/documents';
+
+export type Upload = {
+ uid: string,
+ file: File,
+ error: FileError | null,
+ progress: number,
+ isStarted: boolean,
+ isFinished: boolean,
+ isCancelled: boolean,
+ signal: AbortSignal,
+ cancel(): void,
+};
+
+type Props = {
+ /** Fonction permettant de persister un nouveau document. */
+ persister(file: File, signal: AbortSignal, onProgress: ProgressCallback): Promise,
+};
+
+type Data = {
+ uploadQueue: Queue,
+ uploads: Upload[],
+};
+
+/** Nombre d'upload simultanés maximum (au delà, les uploads seront placés dans une queue). */
+const MAX_CONCURRENT_UPLOADS = 5;
+
+// @vue/component
+const FileManagerUploadArea = defineComponent({
+ name: 'FileManagerUploadArea',
+ props: {
+ persister: {
+ type: Function as PropType['persister']>,
+ required: true,
+ },
+ },
+ emits: ['upload'],
+ data: (): Data => ({
+ uploadQueue: new Queue({ concurrency: MAX_CONCURRENT_UPLOADS }),
+ uploads: [],
+ }),
+ beforeDestroy() {
+ // - Vide la queue courante.
+ this.uploadQueue.clear();
+
+ // - Annule les envois en cours...
+ this.uploads.forEach((upload: Upload) => {
+ if (upload.isFinished || upload.isCancelled) {
+ return;
+ }
+ upload.cancel();
+ });
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ async handleAddFiles(files: FileList) {
+ const uploads: Upload[] = Array.from(files).map((file: File) => {
+ const abortController = new AbortController();
+ const error = getFileError(file);
+ const hasError = error !== null;
+
+ const upload: Upload = {
+ uid: uniqueId(),
+ file,
+ error,
+ signal: abortController.signal,
+ isStarted: false,
+ isFinished: hasError,
+ isCancelled: false,
+ cancel: () => {},
+ progress: 0,
+ };
+
+ upload.cancel = () => {
+ this.$set(file, 'isCancelled', true);
+ abortController.abort();
+ };
+
+ return upload;
+ });
+
+ // - Ajoute les uploads aux uploads en cours.
+ this.uploads.unshift(...uploads);
+
+ // - Upload les fichiers qui doivent l'être.
+ const waitingUploads = uploads.filter(({ isFinished }: Upload) => !isFinished);
+ waitingUploads.forEach(async (upload: Upload) => {
+ try {
+ const newDocument = await this.uploadQueue.add(async (): Promise => {
+ if (upload.isCancelled) {
+ throw new Error('aborted.');
+ }
+
+ this.$set(upload, 'isStarted', true);
+
+ return this.persister(upload.file, upload.signal, (progress: number): void => {
+ this.$set(upload, 'progress', Math.min(100, Math.round(progress)));
+ });
+ });
+
+ this.$set(upload, 'isFinished', true);
+ this.$emit('upload', newDocument);
+
+ const handleFinishedUploaded = (): void => {
+ const index = this.uploads.indexOf(upload);
+ if (index === -1) {
+ return;
+ }
+ this.$delete(this.uploads, index);
+ };
+ setTimeout(handleFinishedUploaded, 3000);
+ } catch {
+ if (upload.isCancelled) {
+ return;
+ }
+
+ // TODO: Améliorer la prise en charge des erreurs de validation.
+ this.$set(upload, 'error', FileError.UPLOAD_ERROR);
+ }
+ });
+ },
+
+ handleCancelUpload(uid: Upload['uid']) {
+ const upload = this.uploads.find((_upload: Upload) => _upload.uid === uid);
+ if (upload === undefined) {
+ return;
+ }
+
+ if (!upload.isFinished && !upload.isCancelled) {
+ upload.cancel();
+ }
+
+ this.uploads.splice(this.uploads.indexOf(upload), 1);
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - API Publique
+ // -
+ // ------------------------------------------------------
+
+ isUploading(): boolean {
+ return this.uploads.some(
+ ({ isFinished }: Upload) => !isFinished,
+ );
+ },
+ },
+ render() {
+ const { uploads, handleAddFiles, handleCancelUpload } = this;
+ const isUploading = uploads.length > 0;
+
+ const className = ['FileManagerUploadArea', {
+ 'FileManagerUploadArea--uploading': isUploading,
+ }];
+
+ return (
+
+
+ {uploads.length > 0 && (
+
+ {uploads.map((upload: Upload) => (
+ -
+
+
+ ))}
+
+ )}
+
+ );
+ },
+});
+
+export default FileManagerUploadArea;
diff --git a/client/src/themes/default/components/FileManager/index.scss b/client/src/themes/default/components/FileManager/index.scss
new file mode 100644
index 000000000..704ee06a2
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/index.scss
@@ -0,0 +1,84 @@
+@use '~@/themes/default/style/globals';
+
+.FileManager {
+ $block: &;
+
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+
+ &__files {
+ flex: 1;
+ height: 100%;
+ min-width: 300px;
+ padding-right: globals.$content-padding-small-vertical;
+
+ &__empty {
+ margin: 0;
+ padding: 30px 20px;
+ color: globals.$text-light-color;
+ font-size: 1.2rem;
+ font-style: italic;
+ text-align: center;
+ }
+
+ &__list {
+ margin: 0;
+ padding: 0;
+ }
+
+ &--empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+
+ &__upload-area {
+ flex: 1;
+ width: 100%;
+ height: 100%;
+ max-width: 650px;
+ padding-left: globals.$content-padding-small-vertical;
+ border-left: 1px solid globals.$divider-color;
+ }
+
+ //
+ // - Vertical layout
+ //
+
+ @mixin _vertical-layout {
+ flex-direction: column;
+ align-items: stretch;
+
+ #{$block}__upload-area {
+ order: 1;
+ flex: unset;
+ height: auto;
+ max-width: unset;
+ padding: 0 0 15px;
+ border-left: none;
+ }
+
+ #{$block}__files {
+ order: 2;
+ padding: 15px 0 0;
+ border-top: 1px solid globals.$divider-color;
+ }
+ }
+
+ &--vertical {
+ @include _vertical-layout;
+ }
+
+ //
+ // - Responsive
+ //
+
+ @media (max-width: globals.$screen-desktop) {
+ @include _vertical-layout;
+ }
+}
diff --git a/client/src/themes/default/components/FileManager/index.tsx b/client/src/themes/default/components/FileManager/index.tsx
new file mode 100644
index 000000000..060db5457
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/index.tsx
@@ -0,0 +1,141 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import Document from './components/Document';
+import UploadArea from './components/UploadArea';
+
+import type { ProgressCallback } from 'axios';
+import type { PropType } from '@vue/composition-api';
+import type { Document as DocumentType } from '@/stores/api/documents';
+
+export enum FileManagerLayout {
+ /**
+ * Avec ce layout, la "drop-zone" et les fichiers en cours d'upload
+ * seront affichés à côté de la liste des documents.
+ */
+ HORIZONTAL = 'horizontal',
+
+ /**
+ * Avec ce layout, la "drop-zone" et les fichiers en cours d'upload
+ * seront affichés au-dessus de la liste des documents.
+ */
+ VERTICAL = 'vertical',
+}
+
+type Props = {
+ /** La liste des documents déjà présents. */
+ documents: DocumentType[],
+
+ /** Fonction permettant de persister un nouveau document. */
+ persister(file: File, signal: AbortSignal, onProgress: ProgressCallback): Promise,
+
+ /** Le type de layout à utiliser (@see {@link FileManagerLayout}) */
+ layout?: FileManagerLayout,
+};
+
+// @vue/component
+const FileManager = defineComponent({
+ name: 'FileManager',
+ props: {
+ documents: {
+ type: Array as PropType['documents']>,
+ required: true,
+ },
+ persister: {
+ type: Function as PropType['persister']>,
+ required: true,
+ },
+ layout: {
+ type: String as PropType['layout']>,
+ default: FileManagerLayout.HORIZONTAL,
+ },
+ },
+ emits: ['documentUploaded', 'documentDelete'],
+ computed: {
+ isEmpty() {
+ return this.documents.length === 0;
+ },
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleDocumentUploaded(document: DocumentType) {
+ this.$emit('documentUploaded', document);
+ },
+
+ async handleDocumentDelete(id: DocumentType['id']) {
+ this.$emit('documentDelete', id);
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - API Publique
+ // -
+ // ------------------------------------------------------
+
+ isUploading(): boolean {
+ const { uploadAreaRef } = this.$refs;
+ return uploadAreaRef.isUploading();
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
+ __(key: string, params?: Record, count?: number): string {
+ key = !key.startsWith('global.')
+ ? `components.FileManager.${key}`
+ : key.replace(/^global\./, '');
+
+ return this.$t(key, params, count);
+ },
+ },
+ render() {
+ const {
+ __,
+ isEmpty,
+ layout,
+ documents,
+ persister,
+ handleDocumentUploaded,
+ handleDocumentDelete,
+ } = this;
+
+ return (
+
+
+ {isEmpty && (
+
+ {__('no-document')}
+
+ )}
+ {!isEmpty && (
+
+ {documents.map((document: DocumentType) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+ },
+});
+
+export default FileManager;
diff --git a/client/src/themes/default/components/FileManager/translations/en.yml b/client/src/themes/default/components/FileManager/translations/en.yml
new file mode 100644
index 000000000..277e01391
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/translations/en.yml
@@ -0,0 +1,11 @@
+no-document: No document yet.
+drag-and-drop-files-here: Drag and drop in this area to add new files.
+max-size: 'Maximum size: {size}'
+choose-files: Or click here to choose files to add
+send-files: ['Send file', 'Send {count} files']
+
+upload-in-queue: "In queue..."
+upload-in-progress: "Upload in progress...\_{progress}%"
+upload-in-process: "Upload is being processed..."
+upload-cancelled: "Upload cancelled."
+upload-complete: "Upload complete!"
diff --git a/client/src/themes/default/components/FileManager/translations/fr.yml b/client/src/themes/default/components/FileManager/translations/fr.yml
new file mode 100644
index 000000000..771dd9007
--- /dev/null
+++ b/client/src/themes/default/components/FileManager/translations/fr.yml
@@ -0,0 +1,11 @@
+no-document: Aucun document pour le moment.
+drag-and-drop-files-here: Glissez-déposez dans cette zone pour ajouter de nouveaux fichiers.
+max-size: "Taille maximum\_: {size}"
+choose-files: Ou cliquez ici pour choisir des fichiers à ajouter
+send-files: ['Envoyer le fichier', 'Envoyer {count} fichiers']
+
+upload-in-queue: "Dans la file d'attente..."
+upload-in-progress: "Envoi en cours...\_{progress}%"
+upload-in-process: "En cours de traitement..."
+upload-cancelled: "Envoi annulé."
+upload-complete: "Envoi terminé\_!"
diff --git a/client/src/themes/default/components/FormField/index.js b/client/src/themes/default/components/FormField/index.js
index 124b35fa3..c82107d2f 100644
--- a/client/src/themes/default/components/FormField/index.js
+++ b/client/src/themes/default/components/FormField/index.js
@@ -7,10 +7,13 @@ import SwitchToggle from '@/themes/default/components/SwitchToggle';
import Input, { TYPES as INPUT_TYPES } 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';
+import Color from '@/utils/color';
const TYPES = [
...DATEPICKER_TYPES,
...INPUT_TYPES,
+ 'color',
'copy',
'static',
'select',
@@ -42,9 +45,12 @@ export default defineComponent({
disabled: { type: [Boolean, String], default: false },
help: { type: String, default: undefined },
errors: { type: Array, default: null },
- placeholder: { type: [String, Boolean], default: undefined },
+ placeholder: {
+ type: [String, Boolean, Object],
+ default: undefined,
+ },
value: {
- type: [String, Number, Date, Array, Boolean],
+ type: [String, Number, Date, Array, Boolean, Color],
default: undefined,
},
rows: { type: Number, default: undefined },
@@ -230,6 +236,18 @@ export default defineComponent({
onChange={handleChange}
/>
)}
+ {type === 'color' && (
+
+ )}
{type === 'switch' && (
['message']>,
+ required: true,
+ },
+ },
+ render() {
+ const { name, variant, spin, message } = this;
+
+ return (
+
+ {message}
+
+ );
+ },
+});
+
+export default IconMessage;
diff --git a/client/src/themes/default/components/Input/index.js b/client/src/themes/default/components/Input/index.js
index 32b7321c5..7abf112f3 100644
--- a/client/src/themes/default/components/Input/index.js
+++ b/client/src/themes/default/components/Input/index.js
@@ -1,9 +1,10 @@
import './index.scss';
+import { defineComponent } from '@vue/composition-api';
export const TYPES = ['text', 'email', 'tel', 'password', 'number'];
// @vue/component
-export default {
+const Input = defineComponent({
name: 'Input',
inject: {
'input.invalid': { default: { value: false } },
@@ -78,6 +79,10 @@ export default {
this.focused = false;
},
+ handleKeyup(e) {
+ this.$emit('keyup', e);
+ },
+
// ------------------------------------------------------
// -
// - Public API
@@ -106,6 +111,7 @@ export default {
handleBlur,
handleInput,
handleChange,
+ handleKeyup,
} = this;
const className = ['Input', {
@@ -133,10 +139,13 @@ export default {
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
+ onKeyup={handleKeyup}
/>
{addon && {addon}
}
);
},
-};
+});
+
+export default Input;
diff --git a/client/src/themes/default/components/InputColor/index.scss b/client/src/themes/default/components/InputColor/index.scss
new file mode 100644
index 000000000..57285ae00
--- /dev/null
+++ b/client/src/themes/default/components/InputColor/index.scss
@@ -0,0 +1,81 @@
+@use '~@/themes/default/style/globals';
+@use 'sass:color';
+
+.InputColor {
+ $block: &;
+
+ &__field {
+ flex: 1;
+ width: 100%;
+ height: globals.$input-min-height;
+ max-width: var(--input-width, 100%);
+ padding: 4px;
+ border: globals.$input-border-size solid globals.$bg-color-input-normal;
+ border-radius: globals.$input-border-radius;
+ background-color: globals.$bg-color-input-normal;
+ cursor: pointer;
+
+ &__preview {
+ width: 100%;
+ height: 100%;
+ border-radius: 2px;
+ background-color: var(--InputColor--value, var(--InputColor--placeholder, transparent));
+ }
+ }
+
+ &__picker {
+ position: absolute;
+ z-index: 100;
+ top: 0;
+ left: 0;
+ }
+
+ //
+ // - Vide
+ //
+
+ &--empty {
+ #{$block}__field__preview {
+ $empty-bar-color: #9c272d;
+ $empty-color-accent: color.scale(globals.$bg-color-input-normal, $lightness: -40%);
+
+ @include globals.checkerboard($empty-color-accent, transparent);
+
+ &::before {
+ content: '';
+ display: block;
+ width: 100%;
+ height: 100%;
+ background:
+ linear-gradient(
+ to top right,
+ transparent 0%,
+ transparent calc(50% - 2px),
+ $empty-bar-color 50%,
+ transparent calc(50% + 2px),
+ transparent 100%
+ );
+ }
+ }
+ }
+
+ //
+ // - Désactivé
+ //
+
+ &--disabled {
+ #{$block}__field {
+ cursor: not-allowed;
+ }
+ }
+
+ //
+ // - Invalide
+ //
+
+ &--invalid {
+ #{$block}__field {
+ border-color: globals.$input-error-border-color;
+ }
+ }
+}
diff --git a/client/src/themes/default/components/InputColor/index.tsx b/client/src/themes/default/components/InputColor/index.tsx
new file mode 100644
index 000000000..d352e1705
--- /dev/null
+++ b/client/src/themes/default/components/InputColor/index.tsx
@@ -0,0 +1,320 @@
+import './index.scss';
+import Color from '@/utils/color';
+import ClickOutside from 'vue-click-outside';
+import { computePosition, autoUpdate, flip, shift, offset } from '@floating-ui/dom';
+import { MountingPortal as Portal } from 'portal-vue';
+import { defineComponent } from '@vue/composition-api';
+import ColorPicker from '@/themes/default/components/ColorPicker';
+
+import type { PropType } from '@vue/composition-api';
+import type { RawColor } from '@/utils/color';
+
+type Position = { x: number, y: number };
+
+type Props = {
+ /**
+ * 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 | null,
+
+ /**
+ * Valeur actuelle (= couleur) du champ.
+ *
+ * Peut-être fournie en différent formats:
+ * - Une chaîne de caractère représentant une couleur (e.g. `#ffffff`, `rgb(255, 255, 255)`, etc.)
+ * - Un objet littéral représentant une couleur (e.g. `{ r: 255, g: 255, b: 255, a: 0.5 }`)
+ * - Une instance de `Color` (e.g. `new Color('#ffffff')`).
+ * - La valeur `null` si le champ est "vide".
+ */
+ value: Color | RawColor | null,
+
+ /**
+ * Couleur "placeholder" utilisée lorsque le champ n'est pas remplie (= `value` à `null`).
+ *
+ * Cela peut correspondre par exemple à la couleur utilisée
+ * en l'absence de surcharge de ce champ par l'utilisateur.
+ *
+ * Peut-être fournie en différent formats:
+ * - Une chaîne de caractère représentant une couleur (e.g. `#ffffff`, `rgb(255, 255, 255)`, etc.)
+ * - Un objet littéral représentant une couleur (e.g. `{ r: 255, g: 255, b: 255, a: 0.5 }`)
+ * - Une instance de `Color` (e.g. `new Color('#ffffff')`).
+ *
+ * Si non spécifié, un damier barré sera utilisé.
+ */
+ placeholder?: Color | RawColor,
+
+ /** Le champ est-il désactivé ? */
+ disabled?: boolean,
+
+ /** Le champ doit-il être marqué comme invalide ? */
+ invalid?: boolean,
+};
+
+type Data = {
+ currentColor: Color | null,
+ showPicker: boolean,
+ pickerPosition: Position,
+};
+
+// @vue/component
+const InputColor = defineComponent({
+ name: 'InputColor',
+ directives: { ClickOutside },
+ inject: {
+ 'input.invalid': { default: { value: false } },
+ 'input.disabled': { default: { value: false } },
+ },
+ props: {
+ name: {
+ type: String as PropType['name']>,
+ default: null,
+ },
+ value: {
+ type: null as unknown as PropType['value']>,
+ required: true,
+ validator: (value: unknown | null) => (
+ value === null || Color.isValid(value)
+ ),
+ },
+ placeholder: {
+ type: null as unknown as PropType,
+ default: undefined,
+ validator: (value: unknown | null) => (
+ value === null || Color.isValid(value)
+ ),
+ },
+ disabled: {
+ type: Boolean as PropType,
+ default: undefined,
+ },
+ invalid: {
+ type: Boolean as PropType,
+ default: undefined,
+ },
+ },
+ emits: ['input', 'change'],
+ data: (): Data => ({
+ currentColor: null,
+ showPicker: false,
+ pickerPosition: { x: 0, y: 0 },
+ }),
+ computed: {
+ inheritedInvalid() {
+ if (this.invalid !== undefined) {
+ return this.invalid;
+ }
+ return this['input.invalid'].value;
+ },
+
+ inheritedDisabled() {
+ if (this.disabled !== undefined) {
+ return this.disabled;
+ }
+ return this['input.disabled'].value;
+ },
+
+ color(): Color | null {
+ if (!this.inheritedDisabled && this.currentColor !== null) {
+ return this.currentColor;
+ }
+
+ if (this.value === null || !Color.isValid(this.value)) {
+ return null;
+ }
+
+ return !(this.value instanceof Color)
+ ? new Color(this.value)
+ : this.value;
+ },
+
+ placeholderColor(): Color | null {
+ if (!this.placeholder || !Color.isValid(this.placeholder)) {
+ return null;
+ }
+
+ return !(this.placeholder instanceof Color)
+ ? new Color(this.placeholder)
+ : this.placeholder;
+ },
+
+ formattedValue(): string | undefined {
+ return this.color?.toString();
+ },
+
+ formattedPlaceholder(): string | undefined {
+ return this.placeholderColor?.toString();
+ },
+ },
+ watch: {
+ value() {
+ this.currentColor = null;
+ },
+
+ inheritedDisabled(disabled: boolean) {
+ if (disabled) {
+ this.showPicker = false;
+ this.currentColor = null;
+ }
+ },
+ },
+ mounted() {
+ this.registerPickerPositionUpdater();
+ },
+ updated() {
+ this.registerPickerPositionUpdater();
+ },
+ beforeDestroy() {
+ this.cleanupPickerPositionUpdater();
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleFieldClick() {
+ if (this.inheritedDisabled) {
+ return;
+ }
+ this.showPicker = true;
+ },
+
+ handleChange(color: Color) {
+ if (this.inheritedDisabled) {
+ return;
+ }
+ this.currentColor = color;
+ },
+
+ handlePickerClose(e: Event) {
+ if (this.inheritedDisabled || !this.showPicker) {
+ return;
+ }
+
+ // - Si c'est un click sur le champ, on ignore.
+ const fieldRef = this.$refs.fieldRef as HTMLDivElement;
+ if (fieldRef.contains(e.target as Element)) {
+ return;
+ }
+
+ this.showPicker = false;
+
+ if (this.currentColor !== null) {
+ this.$emit('input', this.currentColor.toString());
+ this.$emit('change', this.currentColor.toString());
+ }
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
+ async updatePickerPosition(): Promise {
+ const fieldRef = this.$refs.fieldRef as HTMLDivElement;
+ const pickerRef = this.$refs.pickerRef as HTMLDivElement | undefined;
+
+ if (!this.showPicker || !pickerRef) {
+ return;
+ }
+
+ const oldPosition = { ...this.pickerPosition };
+ const newPosition = await computePosition(fieldRef, pickerRef, {
+ placement: 'bottom',
+ middleware: [flip(), shift(), offset(10)],
+ });
+
+ if (newPosition.x === oldPosition.x && newPosition.y === oldPosition.y) {
+ return;
+ }
+
+ this.pickerPosition = newPosition;
+ },
+
+ cleanupPickerPositionUpdater() {
+ if (typeof this.cancelPickerPositionUpdater === 'function') {
+ this.cancelPickerPositionUpdater();
+ this.cancelPickerPositionUpdater = undefined;
+ }
+ },
+
+ registerPickerPositionUpdater() {
+ this.cleanupPickerPositionUpdater();
+
+ if (this.$refs.fieldRef && this.$refs.pickerRef) {
+ this.cancelPickerPositionUpdater = autoUpdate(
+ this.$refs.fieldRef,
+ this.$refs.pickerRef,
+ this.updatePickerPosition.bind(this),
+ );
+ }
+ },
+ },
+ render() {
+ const {
+ name,
+ color,
+ showPicker,
+ pickerPosition,
+ placeholderColor,
+ formattedValue,
+ formattedPlaceholder,
+ inheritedInvalid: invalid,
+ inheritedDisabled: disabled,
+ handleChange,
+ handleFieldClick,
+ handlePickerClose,
+ } = this;
+ const hasColor = color !== null || placeholderColor !== null;
+
+ const className = ['InputColor', {
+ 'InputColor--empty': !hasColor,
+ 'InputColor--invalid': invalid,
+ 'InputColor--disabled': disabled,
+ }];
+
+ return (
+
+
+ {showPicker && (
+
+
+
+
+
+ )}
+
+ );
+ },
+});
+
+export default InputColor;
diff --git a/client/src/themes/default/components/InputCopy/index.js b/client/src/themes/default/components/InputCopy/index.js
index 79b5f21ee..dba73bcb8 100644
--- a/client/src/themes/default/components/InputCopy/index.js
+++ b/client/src/themes/default/components/InputCopy/index.js
@@ -40,7 +40,7 @@ export default {
// ------------------------------------------------------
// -
- // - Internal methods
+ // - Méthodes internes
// -
// ------------------------------------------------------
diff --git a/client/src/themes/default/components/InputImage/index.js b/client/src/themes/default/components/InputImage/index.js
index 376c05406..8688f99f3 100644
--- a/client/src/themes/default/components/InputImage/index.js
+++ b/client/src/themes/default/components/InputImage/index.js
@@ -22,7 +22,7 @@ export default {
},
computed: {
isEmpty() {
- return this.value == null;
+ return [undefined, null].includes(this.value);
},
isUploading() {
diff --git a/client/src/themes/default/components/Inventory/index.tsx b/client/src/themes/default/components/Inventory/index.tsx
index d3904459e..bd309a411 100644
--- a/client/src/themes/default/components/Inventory/index.tsx
+++ b/client/src/themes/default/components/Inventory/index.tsx
@@ -59,7 +59,7 @@ type Props = {
displayGroup?: DisplayGroup,
/** Permet de "verrouiller" l'inventaire (= Lecture seule). */
- locked?: boolean,
+ locked?: boolean | string[],
/**
* Permet d'activer ou non le mode "strict".
diff --git a/client/src/themes/default/components/Link/index.tsx b/client/src/themes/default/components/Link/index.tsx
index a95773fb1..05777b84a 100644
--- a/client/src/themes/default/components/Link/index.tsx
+++ b/client/src/themes/default/components/Link/index.tsx
@@ -96,7 +96,6 @@ const Link = defineComponent({
const isOutside = typeof to === 'string' && to.includes('://');
return (
- // eslint-disable-next-line react/jsx-no-target-blank
{
- describe('Utils / getMaterialsQuantities()', () => {
- it('should return an object containing the quantities related to a material', () => {
+ describe('Utils / getEventMaterialsQuantities()', () => {
+ it('should return an object containing the quantities related to an event material', () => {
const materials = [
{ id: 1, pivot: { quantity: 2 } },
{ id: 2, pivot: { quantity: 6 } },
@@ -11,7 +14,7 @@ describe('MaterialsListEditor', () => {
{ id: 5 },
{ id: 6 },
];
- expect(getMaterialsQuantities(materials)).toEqual([
+ expect(getEventMaterialsQuantities(materials)).toEqual([
{ id: 1, quantity: 2 },
{ id: 2, quantity: 6 },
{ id: 3, quantity: 0 },
diff --git a/client/src/themes/default/components/MaterialsListEditor/_store.js b/client/src/themes/default/components/MaterialsListEditor/_store.js
index 06ecd723c..a8d1580fb 100644
--- a/client/src/themes/default/components/MaterialsListEditor/_store.js
+++ b/client/src/themes/default/components/MaterialsListEditor/_store.js
@@ -7,7 +7,7 @@ export default new Vuex.Store({
mutations: {
init(state, materials) {
const reducer = (acc, material) => {
- const { quantity } = material.pivot;
+ const { quantity } = material;
return { ...acc, [material.id]: { quantity } };
};
state.materials = materials.reduce(reducer, {});
diff --git a/client/src/themes/default/components/MaterialsListEditor/_utils.ts b/client/src/themes/default/components/MaterialsListEditor/_utils.ts
index 2b8b6a964..54ea72ce0 100644
--- a/client/src/themes/default/components/MaterialsListEditor/_utils.ts
+++ b/client/src/themes/default/components/MaterialsListEditor/_utils.ts
@@ -1,6 +1,6 @@
import isValidInteger from '@/utils/isValidInteger';
-import type { Material } from '@/stores/api/materials';
+import type { Event, EventMaterial } from '@/stores/api/events';
//
// - Types
@@ -21,15 +21,8 @@ export type MaterialsFiltersType = {
tags?: string[],
};
-export type MaterialWithPivot = Material & {
- pivot: {
- id: number,
- quantity: number,
- },
-};
-
//
-// - Filters
+// - Filtres
//
export const normalizeFilters = (rawFilters: RawFilters, extended: boolean = true): MaterialsFiltersType => {
@@ -55,7 +48,7 @@ export const normalizeFilters = (rawFilters: RawFilters, extended: boolean = tru
['park', 'subCategory'].forEach((key: string) => {
if (key in rawFilters && isValidInteger(rawFilters[key])) {
- // @ts-ignore - Ici, on sait que `key` est un nombre.
+ // @ts-expect-error - Ici, on sait que `key` est un nombre.
filters[key] = parseInt(rawFilters[key] as string, 10);
}
});
@@ -64,11 +57,11 @@ export const normalizeFilters = (rawFilters: RawFilters, extended: boolean = tru
};
//
-// - Quantities
+// - Quantités
//
-export const getMaterialsQuantities = (materials: MaterialWithPivot[]): MaterialQuantity[] => (
- materials.map(({ id, pivot }: MaterialWithPivot) => {
+export const getEventMaterialsQuantities = (materials: Event['materials']): MaterialQuantity[] => (
+ materials.map(({ id, pivot }: EventMaterial) => {
const data = { id, quantity: pivot?.quantity || 0 };
return data;
})
diff --git a/client/src/themes/default/components/MaterialsListEditor/components/Availability/index.scss b/client/src/themes/default/components/MaterialsListEditor/components/Availability/index.scss
new file mode 100644
index 000000000..495edf23d
--- /dev/null
+++ b/client/src/themes/default/components/MaterialsListEditor/components/Availability/index.scss
@@ -0,0 +1,20 @@
+@use '~@/themes/default/style/globals';
+
+.MaterialsListEditorAvailability {
+ min-width: 205px;
+
+ &__stock {
+ display: block;
+ }
+
+ &__surplus {
+ display: block;
+ margin-top: 3px;
+ color: globals.$text-warning-color;
+ font-style: italic;
+ }
+
+ &--warning {
+ color: globals.$text-warning-color;
+ }
+}
diff --git a/client/src/themes/default/components/MaterialsListEditor/components/Availability/index.tsx b/client/src/themes/default/components/MaterialsListEditor/components/Availability/index.tsx
new file mode 100644
index 000000000..2785bfd0a
--- /dev/null
+++ b/client/src/themes/default/components/MaterialsListEditor/components/Availability/index.tsx
@@ -0,0 +1,64 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import MaterialsStore from '../../_store';
+
+import type { PropType } from '@vue/composition-api';
+import type {
+ MaterialWithAvailabilities,
+} from '@/stores/api/materials';
+import type { MaterialsFiltersType } from '../../_utils';
+
+type Props = {
+ /** Le matériel avec ses quantités disponibles. */
+ material: MaterialWithAvailabilities,
+
+ /** Les filtres utilisés, pour en tenir compte dans les quantités affichées. */
+ filters: MaterialsFiltersType,
+};
+
+const MaterialsListEditorAvailability = defineComponent({
+ name: 'MaterialsListEditorAvailability',
+ props: {
+ material: {
+ type: Object as PropType['material']>,
+ required: true,
+ },
+ filters: {
+ type: Object as PropType['filters']>,
+ required: true,
+ },
+ },
+ computed: {
+ availability() {
+ const { material } = this;
+ const quantityUsed = MaterialsStore.getters.getQuantity(material.id);
+
+ const availableQuantity = (material.available_quantity || 0) - quantityUsed;
+ return {
+ stock: Math.max(availableQuantity, 0),
+ surplus: Math.abs(Math.min(0, availableQuantity)),
+ };
+ },
+ },
+ render() {
+ const { $t: __, availability } = this;
+ const classNames = ['MaterialsListEditorAvailability', {
+ 'MaterialsListEditorAvailability--warning': availability.stock === 0,
+ }];
+
+ return (
+
+
+ {__('stock-count', { count: availability.stock }, availability.stock)}
+
+ {availability.surplus > 0 && (
+
+ ({__('surplus-count', { count: availability.surplus }, availability.surplus)})
+
+ )}
+
+ );
+ },
+});
+
+export default MaterialsListEditorAvailability;
diff --git a/client/src/themes/default/components/MaterialsListEditor/components/Quantity/index.tsx b/client/src/themes/default/components/MaterialsListEditor/components/Quantity/index.tsx
index 8b0972e9f..78ec4f9e5 100644
--- a/client/src/themes/default/components/MaterialsListEditor/components/Quantity/index.tsx
+++ b/client/src/themes/default/components/MaterialsListEditor/components/Quantity/index.tsx
@@ -1,48 +1,82 @@
-import debounce from 'debounce';
-import { toRefs, ref, watch } from '@vue/composition-api';
+import { defineComponent } from '@vue/composition-api';
+import debounce from 'lodash/debounce';
+import { DEBOUNCE_WAIT } from '@/globals/constants';
import QuantityInput from '@/themes/default/components/QuantityInput';
-import type { Component, SetupContext } from '@vue/composition-api';
-import type { Material } from '@/stores/api/materials';
+import type { PropType } from '@vue/composition-api';
+import type { MaterialWithAvailabilities } from '@/stores/api/materials';
type Props = {
- material: Material,
+ /** Le matériel dont on veut définir les quantités. */
+ material: MaterialWithAvailabilities,
+
+ /** La quantité initiale à appliquer. */
initialQuantity: number,
};
-type Events = {
- onChange(material: Material, newQuantity: number): void,
+type Data = {
+ quantity: number,
};
// @vue/component
-const MaterialsListEditorQuantity: Component = (props: Props, { emit }: SetupContext) => {
- const { material, initialQuantity } = toRefs(props as Required);
-
- const quantity = ref(initialQuantity.value);
-
- const updateQuantityDebounced = debounce(() => {
- emit('change', material.value, quantity.value);
- }, 400);
+const MaterialsListEditorQuantity = defineComponent({
+ name: 'MaterialsListEditorQuantity',
+ props: {
+ material: {
+ type: Object as PropType>,
+ required: true,
+ },
+ initialQuantity: {
+ type: Number as PropType>,
+ required: true,
+ },
+ },
+ data(): Data {
+ return {
+ quantity: this.initialQuantity,
+ };
+ },
+ watch: {
+ initialQuantity(newValue: number) {
+ this.quantity = newValue;
+ },
+ },
+ created() {
+ this.updateQuantityDebounced = debounce(this.updateQuantity.bind(this), DEBOUNCE_WAIT);
+ },
+ beforeUnmount() {
+ this.updateQuantityDebounced.cancel();
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
- const handleChange = (value: string): void => {
- quantity.value = parseInt(value, 10) || 0;
- updateQuantityDebounced();
- };
+ handleChange(value: string) {
+ this.quantity = parseInt(value, 10) || 0;
+ this.updateQuantityDebounced();
+ },
- watch(initialQuantity, (newValue: number) => {
- quantity.value = newValue;
- });
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
- return () => (
-
- );
-};
-
-MaterialsListEditorQuantity.props = {
- material: { type: Object, required: true },
- initialQuantity: { type: Number, default: 0 },
-};
+ updateQuantity() {
+ const { material, quantity } = this;
+ this.$emit('change', material, quantity);
+ },
+ },
+ render() {
+ const { quantity, handleChange } = this;
-MaterialsListEditorQuantity.emits = ['change'];
+ return (
+
+ );
+ },
+});
export default MaterialsListEditorQuantity;
diff --git a/client/src/themes/default/components/MaterialsListEditor/index.js b/client/src/themes/default/components/MaterialsListEditor/index.js
deleted file mode 100644
index a5e253ad6..000000000
--- a/client/src/themes/default/components/MaterialsListEditor/index.js
+++ /dev/null
@@ -1,369 +0,0 @@
-import './index.scss';
-import {
- toRefs,
- ref,
- computed,
- onMounted,
- reactive,
-} from '@vue/composition-api';
-import { useQuery } from 'vue-query';
-import Fragment from '@/components/Fragment';
-import config from '@/globals/config';
-import useI18n from '@/hooks/useI18n';
-import useRouter from '@/hooks/useRouter';
-import showModal from '@/utils/showModal';
-import formatAmount from '@/utils/formatAmount';
-import apiMaterials from '@/stores/api/materials';
-import ErrorMessage from '@/themes/default/components/ErrorMessage';
-import MaterialsFilters from '@/themes/default/components/MaterialsFilters';
-import SwitchToggle from '@/themes/default/components/SwitchToggle';
-import Button from '@/themes/default/components/Button';
-import Icon from '@/themes/default/components/Icon';
-import Dropdown from '@/themes/default/components/Dropdown';
-import MaterialsStore from './_store';
-import ReuseEventMaterials from './modals/ReuseEventMaterials';
-import Quantity from './components/Quantity';
-import {
- normalizeFilters,
- getMaterialsQuantities,
- materialsHasChanged,
-} from './_utils';
-
-const NO_PAGINATION_LIMIT = 100_000;
-
-// @vue/component
-const MaterialsListEditor = (props, { root, emit }) => {
- const __ = useI18n();
- const { selected, event } = toRefs(props);
- const { route } = useRouter();
- const dataTableRef = ref(null);
- const filtersRef = ref(null);
- const showSelectedOnly = ref(selected.value.length > 0);
- const manualOrder = ref([]);
- const { data: materials, isLoading, error } = useQuery(
- reactive(['materials-while-event', { eventId: event?.value?.id }]),
- () => (
- event?.value?.id
- ? apiMaterials.allWhileEvent(event.value.id)
- : apiMaterials.all({ paginated: false })
- ),
- );
-
- const hasMaterials = computed(() => (
- (selected.value.length || 0) > 0
- ));
-
- const columns = ref([
- 'child-toggler',
- 'qty',
- 'reference',
- 'name',
- 'availability',
- 'price',
- 'quantity',
- 'amount',
- 'actions',
- ]);
-
- const getFilters = (extended = true, isInit = false) => {
- const filters = {};
-
- if (extended) {
- filters.onlySelected = isInit
- ? selected.value.length
- : showSelectedOnly.value;
- }
-
- ['park', 'category', 'subCategory'].forEach((key) => {
- if (route.value?.query && key in route.value.query) {
- filters[key] = route.value?.query[key];
- }
- });
-
- if (route.value?.query?.tags) {
- filters.tags = JSON.parse(route.value.query.tags);
- }
-
- return normalizeFilters(filters, extended);
- };
-
- const setSelectedOnly = (onlySelected) => {
- dataTableRef.value?.setCustomFilters({ ...getFilters(), onlySelected });
- dataTableRef.value?.setLimit(
- onlySelected ? NO_PAGINATION_LIMIT : config.defaultPaginationLimit,
- );
- showSelectedOnly.value = onlySelected;
- };
-
- const handleChanges = () => {
- const allMaterials = Object.entries(MaterialsStore.state.materials)
- .map(([id, { quantity }]) => ({
- id: parseInt(id, 10),
- quantity,
- }));
-
- if (allMaterials.every(({ quantity }) => quantity === 0)) {
- setSelectedOnly(false);
- }
-
- emit('change', allMaterials);
- };
-
- const getQuantity = (material) => (
- MaterialsStore.getters.getQuantity(material.id)
- );
-
- const setQuantity = (material, quantity) => {
- MaterialsStore.commit('setQuantity', { material, quantity });
- handleChanges();
- };
-
- const tableOptions = ref({
- columnsDropdown: false,
- preserveState: false,
- showChildRowToggler: false,
- orderBy: { column: 'reference', ascending: true },
- perPage: hasMaterials.value ? NO_PAGINATION_LIMIT : config.defaultPaginationLimit,
- sortable: ['reference', 'name'],
- columnsClasses: {
- 'child-toggler': 'MaterialsListEditor__child-toggler ',
- 'qty': 'MaterialsListEditor__qty ',
- 'reference': 'MaterialsListEditor__ref ',
- 'name': 'MaterialsListEditor__name ',
- 'availability': 'MaterialsListEditor__availability ',
- 'price': 'MaterialsListEditor__price ',
- 'quantity': 'MaterialsListEditor__quantity ',
- 'amount': 'MaterialsListEditor__amount ',
- 'actions': 'MaterialsListEditor__actions ',
- },
- initFilters: getFilters(true, true),
- headings: {
- 'child-toggler': '',
- 'qty': __('qty'),
- 'reference': __('reference'),
- 'name': __('name'),
- 'stock_quantity': __('stock-qty'),
- 'quantity': '',
- 'actions': '',
- },
- customSorting: {
- custom: (ascending) => (a, b) => {
- let result = null;
-
- // - Si on est en mode "sélectionnés uniquement" et qu'au moins l'un
- // des deux à un ordre manuellement défini, on l'utilise.
- if (showSelectedOnly.value) {
- const aManualOrderIndex = manualOrder.value?.indexOf(a.id);
- const bManualOrderIndex = manualOrder.value?.indexOf(b.id);
- if (aManualOrderIndex !== -1 || bManualOrderIndex !== -1) {
- result = aManualOrderIndex > bManualOrderIndex ? -1 : 1;
- }
- }
-
- // - Sinon on fallback sur le tri par reference.
- if (result === null) {
- result = a.reference.localeCompare(b.reference, undefined, { ignorePunctuation: true });
- }
-
- return ascending || result === 0 ? result : -result;
- },
- },
- customFilters: [
- {
- name: 'park',
- callback: (row, parkId) => (
- row.park_id === parkId
- ),
- },
- {
- name: 'category',
- callback: (row, categoryId) => (
- (row.category_id === null && categoryId === 'uncategorized') ||
- row.category_id === categoryId
- ),
- },
- {
- name: 'subCategory',
- callback: (row, subCategoryId) => row.sub_category_id === subCategoryId,
- },
- {
- name: 'tags',
- callback: (row, tags) => (
- tags.length === 0 || row.tags.some((tag) => tags.includes(tag.name))
- ),
- },
- {
- name: 'onlySelected',
- callback: (row, isOnlySelected) => (
- !isOnlySelected || getQuantity(row) > 0
- ),
- },
- ],
- });
-
- const handleFiltersChanges = (filters) => {
- const onlySelected = showSelectedOnly.value;
- const newFilters = normalizeFilters({ ...filters, onlySelected });
- dataTableRef.value?.setCustomFilters(newFilters);
- };
-
- const getAvailability = (material) => {
- const quantityUsed = getQuantity(material);
-
- const availableQuantity = (material.available_quantity || 0) - quantityUsed;
- return {
- stock: Math.max(availableQuantity, 0),
- surplus: Math.abs(Math.min(0, availableQuantity)),
- };
- };
-
- onMounted(() => {
- MaterialsStore.commit('init', selected.value);
- });
-
- const handleSelectMany = (materialsToAdd) => {
- const shouldDisplayOnlySelected = !selected.value || selected.value.length === 0;
-
- materialsToAdd.forEach(({ pivot, ...material }) => {
- const { quantity } = pivot;
- MaterialsStore.commit('setQuantity', { material, quantity });
- });
-
- handleChanges();
-
- if (shouldDisplayOnlySelected) {
- setSelectedOnly(true);
- }
- };
-
- const handleShowReuseEventModal = () => {
- showModal(root.$modal, ReuseEventMaterials, {
- onClose: ({ params }) => {
- if (params) {
- handleSelectMany(params.materials);
- }
- },
- });
- };
-
- return () => (
-
-
- {error.value &&
}
-
- {isLoading.value && (
-
- {__('loading')}
-
- )}
-
(
- {getQuantity(row) > 0 ? `${getQuantity(row)}\u00a0×` : ''}
- ),
- 'availability': ({ row: material }) => {
- const availability = getAvailability(material, true);
- return (
-
-
- {__('stock-count', { count: availability.stock }, availability.stock)}
-
- {availability.surplus > 0 && (
-
- ({__('surplus-count', { count: availability.surplus }, availability.surplus)})
-
- )}
-
- );
- },
- 'price': ({ row }) => (
-
- {formatAmount(row.rental_price ?? 0)}
-
- ),
- 'quantity': ({ row }) => (
-
- ),
- 'amount': ({ row }) => (
-
- {formatAmount((row.rental_price ?? 0) * getQuantity(row))}
-
- ),
- 'actions': ({ row }) => (
- getQuantity(row) > 0
- ? (
-
-
- );
-};
-
-MaterialsListEditor.props = {
- selected: { type: Array, default: () => [] },
- event: { type: Object, default: undefined },
-};
-
-MaterialsListEditor.emits = ['change'];
-
-export {
- getMaterialsQuantities,
- materialsHasChanged,
-};
-
-export default MaterialsListEditor;
diff --git a/client/src/themes/default/components/MaterialsListEditor/index.scss b/client/src/themes/default/components/MaterialsListEditor/index.scss
index d3d817003..69f28689d 100644
--- a/client/src/themes/default/components/MaterialsListEditor/index.scss
+++ b/client/src/themes/default/components/MaterialsListEditor/index.scss
@@ -57,14 +57,10 @@
&__loading {
position: absolute;
top: 0.5rem;
- right: 0;
display: flex;
align-items: center;
justify-content: center;
-
- .fas {
- margin-right: 0.3rem;
- }
+ width: 100%;
}
/* stylelint-disable selector-max-type, selector-max-compound-selectors */
@@ -109,21 +105,6 @@
color: globals.$text-light-color !important;
}
- &__availability {
- min-width: 205px;
-
- &__stock {
- display: block;
- }
-
- &__surplus {
- display: block;
- margin-top: 3px;
- color: globals.$text-warning-color;
- font-style: italic;
- }
- }
-
&__price {
text-align: right;
diff --git a/client/src/themes/default/components/MaterialsListEditor/index.tsx b/client/src/themes/default/components/MaterialsListEditor/index.tsx
new file mode 100644
index 000000000..ec10b69cd
--- /dev/null
+++ b/client/src/themes/default/components/MaterialsListEditor/index.tsx
@@ -0,0 +1,468 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import Fragment from '@/components/Fragment';
+import config from '@/globals/config';
+import showModal from '@/utils/showModal';
+import formatAmount from '@/utils/formatAmount';
+import { BookingEntity } from '@/stores/api/bookings';
+import apiMaterials from '@/stores/api/materials';
+import CriticalError from '@/themes/default/components/CriticalError';
+import Loading from '@/themes/default/components/Loading';
+import MaterialsFilters from '@/themes/default/components/MaterialsFilters';
+import SwitchToggle from '@/themes/default/components/SwitchToggle';
+import Button from '@/themes/default/components/Button';
+import Icon from '@/themes/default/components/Icon';
+import Dropdown from '@/themes/default/components/Dropdown';
+import MaterialsStore from './_store';
+import ReuseEventMaterials from './modals/ReuseEventMaterials';
+import Availability from './components/Availability';
+import Quantity from './components/Quantity';
+import {
+ normalizeFilters,
+ getEventMaterialsQuantities,
+ materialsHasChanged,
+} from './_utils';
+
+import type { PropType } from '@vue/composition-api';
+import type { Event, EventMaterial } from '@/stores/api/events';
+import type { Material, MaterialWithAvailabilities } from '@/stores/api/materials';
+import type { Tag } from '@/stores/api/tags';
+import type {
+ EventBooking,
+ BookingMaterialQuantity,
+} from '@/stores/api/bookings';
+import type { RawFilters } from './_utils';
+
+export type SelectedQuantities = {
+ id: Material['id'],
+ requested: number,
+ available: number,
+};
+
+export type SelectedMaterial = {
+ id: Material['id'],
+ quantity: number,
+};
+
+type MaterialListEditorStoreValue = {
+ quantity: number,
+};
+
+type Props = {
+ /** La sélection de matériel pour le booking. */
+ selected: SelectedMaterial[],
+
+ /** Le booking (événement). */
+ booking?: EventBooking,
+
+ /** Faut-il utiliser les modèles de liste ? */
+ withTemplates: boolean,
+};
+
+type Data = {
+ isFetched: boolean,
+ criticalError: boolean,
+ materials: MaterialWithAvailabilities[],
+ manualOrder: Array,
+ showSelectedOnly: boolean,
+ columns: string[],
+ tableOptions: any,
+};
+
+const NO_PAGINATION_LIMIT = 100_000;
+
+// @vue/component
+const MaterialsListEditor = defineComponent({
+ name: 'MaterialsListEditor',
+ props: {
+ selected: {
+ type: Array as PropType,
+ default: () => [],
+ },
+ booking: {
+ type: Object as PropType,
+ default: undefined,
+ },
+ withTemplates: {
+ type: Boolean as PropType,
+ default: false,
+ },
+ },
+ data(): Data {
+ const { $t: __, selected, getFilters } = this;
+ const hasMaterials = selected.length > 0;
+
+ return {
+ isFetched: false,
+ criticalError: false,
+ materials: [],
+ manualOrder: [],
+ showSelectedOnly: hasMaterials,
+ columns: [
+ 'child-toggler',
+ 'qty',
+ 'reference',
+ 'name',
+ 'availability',
+ 'price',
+ 'quantity',
+ 'amount',
+ 'actions',
+ ],
+ tableOptions: {
+ columnsDropdown: false,
+ preserveState: false,
+ showChildRowToggler: false,
+ orderBy: { column: 'reference', ascending: true },
+ perPage: hasMaterials ? NO_PAGINATION_LIMIT : config.defaultPaginationLimit,
+ sortable: ['reference', 'name'],
+ columnsClasses: {
+ 'child-toggler': 'MaterialsListEditor__child-toggler ',
+ 'qty': 'MaterialsListEditor__qty ',
+ 'reference': 'MaterialsListEditor__ref ',
+ 'name': 'MaterialsListEditor__name ',
+ 'availability': 'MaterialsListEditor__availability ',
+ 'price': 'MaterialsListEditor__price ',
+ 'quantity': 'MaterialsListEditor__quantity ',
+ 'amount': 'MaterialsListEditor__amount ',
+ 'actions': 'MaterialsListEditor__actions ',
+ },
+ initFilters: getFilters(true, true),
+ headings: {
+ 'child-toggler': '',
+ 'qty': __('qty'),
+ 'reference': __('reference'),
+ 'name': __('name'),
+ 'stock_quantity': __('stock-qty'),
+ 'quantity': '',
+ 'actions': '',
+ },
+ customSorting: {
+ custom: (ascending: boolean) => (a: MaterialWithAvailabilities, b: MaterialWithAvailabilities) => {
+ const { showSelectedOnly, manualOrder } = this;
+ let result = null;
+
+ // - Si on est en mode "sélectionnés uniquement" et qu'au moins l'un
+ // des deux à un ordre manuellement défini, on l'utilise.
+ if (showSelectedOnly) {
+ const aManualOrderIndex = manualOrder?.indexOf(a.id);
+ const bManualOrderIndex = manualOrder?.indexOf(b.id);
+ if (aManualOrderIndex !== -1 || bManualOrderIndex !== -1) {
+ result = aManualOrderIndex > bManualOrderIndex ? -1 : 1;
+ }
+ }
+
+ // - Sinon on fallback sur le tri par reference.
+ if (result === null) {
+ result = a.reference.localeCompare(b.reference, undefined, { ignorePunctuation: true });
+ }
+
+ return ascending || result === 0 ? result : -result;
+ },
+ },
+ customFilters: [
+ {
+ name: 'park',
+ callback: (row: MaterialWithAvailabilities, parkId: number) => (
+ row.park_id === parkId
+ ),
+ },
+ {
+ name: 'category',
+ callback: (row: MaterialWithAvailabilities, categoryId: number | 'uncategorized') => (
+ (row.category_id === null && categoryId === 'uncategorized') ||
+ row.category_id === categoryId
+ ),
+ },
+ {
+ name: 'subCategory',
+ callback: (row: MaterialWithAvailabilities, subCategoryId: number) => (
+ row.sub_category_id === subCategoryId
+ ),
+ },
+ {
+ name: 'tags',
+ callback: (row: MaterialWithAvailabilities, tags: string[]) => (
+ tags.length === 0 || row.tags.some((tag: Tag) => tags.includes(tag.name))
+ ),
+ },
+ {
+ name: 'onlySelected',
+ callback: (row: MaterialWithAvailabilities, isOnlySelected: boolean) => (
+ !isOnlySelected || this.getQuantity(row) > 0
+ ),
+ },
+ ],
+ },
+ };
+ },
+ computed: {
+ hasMaterials() {
+ return (this.selected.length || 0) > 0;
+ },
+
+ isFiltered() {
+ return Object.keys(this.getFilters(false)).length !== 0;
+ },
+ },
+ mounted() {
+ MaterialsStore.commit('init', this.selected);
+
+ // - Actualise la liste du matériel toutes les 30 secondes.
+ this.fetchInterval = setInterval(this.fetchMaterials.bind(this), 30_000);
+ this.fetchMaterials();
+ },
+ beforeDestroy() {
+ if (this.fetchInterval) {
+ clearInterval(this.fetchInterval);
+ }
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleChanges() {
+ const storeMaterials = Object.entries(MaterialsStore.state.materials)
+ .map((
+ [id, { quantity }]: [string, MaterialListEditorStoreValue],
+ ): BookingMaterialQuantity => ({
+ id: parseInt(id, 10),
+ quantity,
+ }));
+
+ if (storeMaterials.every(({ quantity }: SelectedMaterial) => quantity === 0)) {
+ this.setSelectedOnly(false);
+ }
+
+ this.$emit('change', storeMaterials);
+ },
+
+ handleFiltersChanges(filters: RawFilters) {
+ const onlySelected = this.showSelectedOnly;
+ const newFilters = normalizeFilters({ ...filters, onlySelected });
+ this.$refs.dataTableRef.setCustomFilters(newFilters);
+ },
+
+ handleShowReuseEventModal() {
+ showModal(this.$modal, ReuseEventMaterials, {
+ onClose: ({ params }: { params: { event: Event | null } | null }) => {
+ if (params?.event) {
+ this.selectMany(params.event.materials);
+ }
+ },
+ });
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
+ async fetchMaterials() {
+ const { entity, id } = this.booking ?? { entity: null, id: null };
+
+ try {
+ if (entity && entity === BookingEntity.EVENT) {
+ this.materials = await apiMaterials.allWhileEvent(id);
+ } else {
+ this.materials = await apiMaterials.all({ paginated: false });
+ }
+ this.$emit('ready');
+ } catch {
+ this.criticalError = true;
+ } finally {
+ this.isFetched = true;
+ }
+ },
+
+ getFilters(extended: boolean = true, isInit: boolean = false) {
+ const filters: RawFilters = {};
+
+ if (extended) {
+ filters.onlySelected = isInit
+ ? this.selected.length
+ : this.showSelectedOnly;
+ }
+
+ ['park', 'category', 'subCategory'].forEach((key: string) => {
+ if (this.$route?.query && key in this.$route.query) {
+ filters[key] = this.$route?.query[key];
+ }
+ });
+
+ if (this.$route?.query?.tags) {
+ filters.tags = JSON.parse(this.$route.query.tags);
+ }
+
+ return normalizeFilters(filters, extended);
+ },
+
+ getQuantity(material: MaterialWithAvailabilities) {
+ return MaterialsStore.getters.getQuantity(material.id);
+ },
+
+ setQuantity(material: MaterialWithAvailabilities, quantity: number) {
+ MaterialsStore.commit('setQuantity', { material, quantity });
+ this.handleChanges();
+ },
+
+ selectMany(materialsToAdd: EventMaterial[]) {
+ const shouldDisplayOnlySelected = this.selected.length === 0;
+
+ materialsToAdd.forEach(({ pivot, ...material }: EventMaterial) => {
+ const { quantity } = pivot;
+ MaterialsStore.commit('setQuantity', { material, quantity });
+ });
+
+ this.handleChanges();
+
+ if (shouldDisplayOnlySelected) {
+ this.setSelectedOnly(true);
+ }
+ },
+
+ setSelectedOnly(onlySelected: boolean) {
+ this.$refs.dataTableRef.setCustomFilters({ ...this.getFilters(), onlySelected });
+ this.$refs.dataTableRef.setLimit(
+ onlySelected ? NO_PAGINATION_LIMIT : config.defaultPaginationLimit,
+ );
+ this.showSelectedOnly = onlySelected;
+ },
+ },
+ render() {
+ const {
+ $t: __,
+ columns,
+ showSelectedOnly,
+ hasMaterials,
+ handleFiltersChanges,
+ setSelectedOnly,
+ withTemplates,
+ handleShowTemplateUsageModal,
+ handleShowReuseEventModal,
+ criticalError,
+ isFetched,
+ materials,
+ tableOptions,
+ getFilters,
+ getQuantity,
+ setQuantity,
+ } = this;
+
+ if (criticalError) {
+ return
;
+ }
+
+ return (
+
+
+
+ {!isFetched && (
+
+
+
+ )}
+
(
+ getQuantity(row) > 0 ? `${getQuantity(row)}\u00A0×` : null
+ ),
+ 'availability': ({ row }: { row: MaterialWithAvailabilities }) => (
+
+ ),
+ 'price': ({ row }: { row: MaterialWithAvailabilities }) => (
+
+ {formatAmount(row.rental_price ?? 0)}
+
+ ),
+ 'quantity': ({ row }: { row: MaterialWithAvailabilities }) => (
+
+ ),
+ 'amount': ({ row }: { row: MaterialWithAvailabilities }) => (
+ formatAmount((row.rental_price ?? 0) * getQuantity(row))
+ ),
+ 'actions': ({ row }: { row: MaterialWithAvailabilities }) => (
+ getQuantity(row) > 0
+ ? (
+ { setQuantity(row, 0); }}
+ />
+ )
+ : null
+ ),
+ }}
+ />
+ {(isFetched && hasMaterials) && (
+
+ { setSelectedOnly(!showSelectedOnly); }}
+ >
+ {(
+ showSelectedOnly
+ ? __('display-all-materials-to-add-some')
+ : __('display-only-selected-materials')
+ )}
+
+
+ )}
+
+
+ );
+ },
+});
+
+export {
+ getEventMaterialsQuantities,
+ materialsHasChanged,
+};
+
+export default MaterialsListEditor;
diff --git a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/SearchEventResultItem/index.js b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/SearchEventResultItem/index.js
deleted file mode 100644
index 048ae3ae7..000000000
--- a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/SearchEventResultItem/index.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import './index.scss';
-import moment from 'moment';
-import { toRefs, computed } from '@vue/composition-api';
-import useI18n from '@/hooks/useI18n';
-import Button from '@/themes/default/components/Button';
-
-// @vue/component
-const SearchEventResultItem = (props, { emit }) => {
- const __ = useI18n();
- const { data } = toRefs(props);
- const start = computed(() => moment(data.value.start_date));
- const end = computed(() => moment(data.value.end_date));
- const duration = computed(() => end.value.diff(start.value, 'days') + 1);
-
- const handleSelect = () => {
- emit('select', data.value.id);
- };
-
- return () => (
-
-
- {data.value.title} {data.value.location && ({data.value.location})}
-
-
- {duration.value === 1 && __('on-date', { date: start.value.format('LL') })}
- {duration.value > 1 && __(
- 'from-date-to-date',
- { from: start.value.format('L'), to: end.value.format('L') },
- )}
-
-
- {__('choose')}
-
-
- );
-};
-
-SearchEventResultItem.props = {
- data: { type: Object, required: true },
-};
-
-SearchEventResultItem.emits = ['select'];
-
-export default SearchEventResultItem;
diff --git a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/SearchEventResultItem/index.tsx b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/SearchEventResultItem/index.tsx
new file mode 100644
index 000000000..4970ca4e3
--- /dev/null
+++ b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/SearchEventResultItem/index.tsx
@@ -0,0 +1,71 @@
+import './index.scss';
+import moment from 'moment';
+import { defineComponent } from '@vue/composition-api';
+import Button from '@/themes/default/components/Button';
+
+import type { PropType } from '@vue/composition-api';
+import type { EventSummary } from '@/stores/api/events';
+
+type Props = {
+ /** L'événement à afficher. */
+ event: EventSummary,
+};
+
+// @vue/component
+const SearchEventResultItem = defineComponent({
+ props: {
+ event: {
+ type: Object as PropType['event']>,
+ required: true,
+ },
+ },
+ computed: {
+ start() {
+ const { event } = this;
+ return moment(event.start_date);
+ },
+
+ end() {
+ const { event } = this;
+ return moment(event.end_date);
+ },
+
+ duration() {
+ const { start, end } = this;
+ return end.diff(start, 'days') + 1;
+ },
+ },
+ methods: {
+ handleSelect() {
+ const { id } = this.event;
+ this.$emit('select', id);
+ },
+ },
+ render() {
+ const { $t: __, event, duration, start, end, handleSelect } = this;
+
+ return (
+
+
+ {event.title} {event.location && ({event.location})}
+
+
+ {duration === 1 && __('on-date', { date: start.format('LL') })}
+ {duration > 1 && __(
+ 'from-date-to-date',
+ { from: start.format('L'), to: end.format('L') },
+ )}
+
+
+ {__('choose')}
+
+
+ );
+ },
+});
+
+export default SearchEventResultItem;
diff --git a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.js b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.js
deleted file mode 100644
index af59cd179..000000000
--- a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.js
+++ /dev/null
@@ -1,138 +0,0 @@
-import './index.scss';
-import debounce from 'debounce';
-import { ref, toRefs } from '@vue/composition-api';
-import useI18n from '@/hooks/useI18n';
-import apiEvents from '@/stores/api/events';
-import Icon from '@/themes/default/components/Icon';
-import SearchEventResultItem from './SearchEventResultItem';
-
-// type Props = {
-// /** ID d'un événement à ne pas inclure dans la recherche */
-// exclude?: number | null,
-
-// /** Déclenché quand un événement est choisi dans la liste. */
-// onSelect(id: number): void,
-// };
-
-// @vue/component
-const SearchEvents = (props, { root, emit }) => {
- const __ = useI18n();
- const { exclude } = toRefs(props);
- const searchTerm = ref('');
- const totalCount = ref(0);
- const results = ref([]);
- const isLoading = ref(false);
- const isFetched = ref(false);
-
- const handleSearch = async () => {
- const trimmedSearchTerm = searchTerm.value.trim();
- if (trimmedSearchTerm.length < 2) {
- return;
- }
-
- isLoading.value = true;
-
- try {
- const { count, data } = await apiEvents.all({
- search: trimmedSearchTerm,
- exclude: exclude?.value || undefined,
- });
- totalCount.value = count;
- results.value = data;
- isFetched.value = true;
- } catch {
- isFetched.value = false;
- root.$toasted.error(__('errors.unknown'));
- } finally {
- isLoading.value = false;
- }
- };
-
- const handleSearchDebounced = debounce(() => {
- handleSearch();
- }, 400);
-
- const handleKeyUp = (e) => {
- if (e.code === 'Enter' || e.code === 'NumpadEnter') {
- handleSearch();
- return;
- }
-
- handleSearchDebounced();
- };
-
- const handleSelect = (id) => {
- emit('select', id);
- };
-
- return () => {
- const renderResults = () => {
- if (results.value.length === 0) {
- return (
-
- {__('no-result-found-try-another-search')}
-
- );
- }
-
- const otherCount = totalCount.value - results.value.length;
-
- return (
-
-
- {results.value.map((event) => (
-
- ))}
-
- {otherCount > 0 && (
-
- {__('and-count-more-older-events', { count: otherCount }, otherCount)}
-
- )}
-
- );
- };
-
- return (
-
-
-
-
- {(
- isLoading.value
- ?
- :
- )}
-
-
- {isFetched.value && (
-
- {renderResults()}
-
- )}
-
- );
- };
-};
-
-SearchEvents.props = {
- exclude: { type: Number, required: false },
-};
-SearchEvents.emits = ['select'];
-
-export default SearchEvents;
diff --git a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.scss b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.scss
index 6985b1903..7da98be44 100644
--- a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.scss
+++ b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.scss
@@ -14,26 +14,8 @@
}
&__button {
- @extend %reset-button;
-
- display: inline-block;
padding: 0.55rem 1.08rem;
border-radius: 0 $border-radius $border-radius 0;
- background-color: globals.$bg-color-button-info;
- color: #e0e0e0;
- font-size: 1rem;
- line-height: 1.25;
- text-decoration: none;
- transition: all 300ms;
-
- &:hover,
- &:focus {
- background-color: color.adjust(globals.$bg-color-button-info, $lightness: 5%);
- }
-
- &:active {
- background-color: color.adjust(globals.$bg-color-button-info, $lightness: -5%);
- }
}
}
diff --git a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.tsx b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.tsx
new file mode 100644
index 000000000..7731fc50e
--- /dev/null
+++ b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/SearchEvents/index.tsx
@@ -0,0 +1,173 @@
+import './index.scss';
+import { throttle } from 'lodash';
+import { defineComponent } from '@vue/composition-api';
+import apiEvents from '@/stores/api/events';
+import Icon from '@/themes/default/components/Icon';
+import Input from '@/themes/default/components/Input';
+import Button from '@/themes/default/components/Button';
+import SearchEventResultItem from './SearchEventResultItem';
+
+import type { PropType } from '@vue/composition-api';
+import type { EventSummary } from '@/stores/api/events';
+
+type Props = {
+ /** ID d'un événement à ne pas inclure dans la recherche. */
+ exclude?: number | null,
+};
+
+type Data = {
+ searchTerm: string,
+ totalCount: number,
+ results: EventSummary[],
+ isLoading: boolean,
+ isFetched: boolean,
+};
+
+// @vue/component
+const SearchEvents = defineComponent({
+ name: 'SearchEvents',
+ props: {
+ exclude: {
+ type: Number as PropType,
+ required: false,
+ default: null,
+ },
+ },
+ data(): Data {
+ return {
+ searchTerm: '',
+ totalCount: 0,
+ results: [],
+ isLoading: false,
+ isFetched: false,
+ };
+ },
+ mounted() {
+ this.handleSearchDebounced = throttle(this.handleSearch.bind(this), 400);
+ },
+ beforeUnmount() {
+ this.handleSearchDebounced.cancel();
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ async handleSearch() {
+ const { $t: __, searchTerm } = this;
+
+ const trimmedSearchTerm = searchTerm.trim();
+ if (trimmedSearchTerm.length < 2) {
+ return;
+ }
+
+ this.isLoading = true;
+
+ try {
+ const { count, data } = await apiEvents.all({
+ search: trimmedSearchTerm,
+ exclude: this.exclude ?? undefined,
+ });
+ this.totalCount = count;
+ this.results = data;
+ this.isFetched = true;
+ } catch {
+ this.isFetched = false;
+ this.$toasted.error(__('errors.unknown'));
+ } finally {
+ this.isLoading = false;
+ }
+ },
+
+ handleSelect(id: MouseEvent) {
+ this.$emit('select', id);
+ },
+
+ handleKeyup(e: KeyboardEvent) {
+ if (e.code === 'Enter' || e.code === 'NumpadEnter') {
+ this.handleSearch();
+ return;
+ }
+
+ this.handleSearchDebounced();
+ },
+ },
+ render() {
+ const {
+ $t: __,
+ isLoading,
+ isFetched,
+ handleSelect,
+ handleKeyup,
+ handleSearch,
+ } = this;
+
+ const renderResults = (): JSX.Element => {
+ const { totalCount, results } = this;
+
+ if (results.length === 0) {
+ return (
+
+ {__('no-result-found-try-another-search')}
+
+ );
+ }
+
+ const otherCount = totalCount - results.length;
+
+ return (
+
+
+ {results.map((event: EventSummary) => (
+
+ ))}
+
+ {otherCount > 0 && (
+
+ {__('and-count-more-older-events', { count: otherCount }, otherCount)}
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
+ {(
+ isLoading
+ ?
+ :
+ )}
+
+
+ {isFetched && (
+
+ {renderResults()}
+
+ )}
+
+ );
+ },
+});
+
+export default SearchEvents;
diff --git a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/index.js b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/index.tsx
similarity index 52%
rename from client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/index.js
rename to client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/index.tsx
index c2c449c44..79679cdf2 100644
--- a/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/index.js
+++ b/client/src/themes/default/components/MaterialsListEditor/modals/ReuseEventMaterials/index.tsx
@@ -1,7 +1,5 @@
import './index.scss';
-import { ref, computed } from '@vue/composition-api';
-import useI18n from '@/hooks/useI18n';
-import useRouter from '@/hooks/useRouter';
+import { defineComponent } from '@vue/composition-api';
import Icon from '@/themes/default/components/Icon';
import Button from '@/themes/default/components/Button';
import Loading from '@/themes/default/components/Loading';
@@ -10,68 +8,97 @@ import MaterialsSorted from '@/themes/default/components/MaterialsSorted';
import apiEvents from '@/stores/api/events';
import SearchEvents from './SearchEvents';
-// @vue/component
-const ReuseEventMaterials = (props, { emit }) => {
- const __ = useI18n();
- const { route } = useRouter();
- const selected = ref(null);
- const isLoading = ref(false);
- const error = ref(null);
+import type { Event } from '@/stores/api/events';
+
+type Data = {
+ selected: Event | null,
+ isLoading: boolean,
+ error: unknown | null,
+};
- const excludeEvent = computed(() => {
- const { id } = route.value.params;
- return id ? Number.parseInt(id, 10) : null;
- });
+// @vue/component
+const ReuseEventMaterials = defineComponent({
+ name: 'ReuseEventMaterials',
+ modal: {
+ width: 700,
+ draggable: true,
+ clickToClose: true,
+ },
+ data(): Data {
+ return {
+ selected: null,
+ isLoading: false,
+ error: null,
+ };
+ },
+ computed: {
+ excludeEvent() {
+ const { id } = this.$route.params;
+ return id ? Number.parseInt(id, 10) : null;
+ },
- const handleSelectEvent = async (id) => {
- isLoading.value = true;
- error.value = null;
+ },
+ methods: {
+ handleClearSelection() {
+ this.selected = null;
+ },
- try {
- selected.value = await apiEvents.one(id);
- } catch (err) {
- error.value = err;
- selected.value = null;
- } finally {
- isLoading.value = false;
- }
- };
+ handleSubmit() {
+ const { selected } = this;
+ this.$emit('close', { event: selected });
+ },
- const handleClearSelection = () => {
- selected.value = null;
- };
+ handleClose() {
+ this.$emit('close');
+ },
- const handleSubmit = () => {
- if (!selected.value) {
- return;
- }
- const { materials } = selected.value;
- emit('close', { materials });
- };
+ async handleSelectEvent(id: number) {
+ this.isLoading = true;
+ this.error = null;
- const handleClose = () => {
- emit('close');
- };
+ try {
+ this.selected = await apiEvents.one(id);
+ } catch (err) {
+ this.error = err;
+ this.selected = null;
+ } finally {
+ this.isLoading = false;
+ }
+ },
+ },
+ render() {
+ const {
+ $t: __,
+ error,
+ isLoading,
+ selected,
+ excludeEvent,
+ handleClose,
+ handleSelectEvent,
+ handleSubmit,
+ handleClearSelection,
+ } = this;
- return () => {
- const renderSelection = () => {
- if (error.value) {
- return ;
+ const renderSelection = (): JSX.Element | null => {
+ if (error) {
+ return ;
}
- if (isLoading.value) {
+ if (isLoading) {
return ;
}
- if (selected.value === null) {
+ if (selected === null) {
return null;
}
return (
-
{__('event-materials', { name: selected.value.title })}
-
{selected.value.description}
-
+
{__('event-materials', { name: selected.title })}
+
+ {selected.description}
+
+
{' '}
{__('reuse-list-from-event-warning')}
@@ -82,7 +109,7 @@ const ReuseEventMaterials = (props, { emit }) => {
const bodyClassNames = {
'ReuseEventMaterials__body': true,
- 'ReuseEventMaterials__body--has-selected': selected.value !== null,
+ 'ReuseEventMaterials__body--has-selected': selected !== null,
};
return (
@@ -99,12 +126,12 @@ const ReuseEventMaterials = (props, { emit }) => {
{renderSelection()}
- {selected.value !== null && (
+ {selected !== null && (
);
- };
-};
-
-ReuseEventMaterials.modal = {
- width: 700,
- draggable: true,
- clickToClose: true,
-};
+ },
+});
export default ReuseEventMaterials;
diff --git a/client/src/themes/default/components/MaterialsSorted/CategoryItem/Material/index.scss b/client/src/themes/default/components/MaterialsSorted/CategoryItem/Material/index.scss
new file mode 100644
index 000000000..27c8ace70
--- /dev/null
+++ b/client/src/themes/default/components/MaterialsSorted/CategoryItem/Material/index.scss
@@ -0,0 +1,49 @@
+@use '~@/themes/default/style/globals';
+
+.MaterialsCategoryItemMaterial {
+ display: flex;
+ padding: 0.7rem 0;
+ border-bottom: 1px dashed globals.$list-left-border-color;
+ list-style: none;
+
+ &__name {
+ flex: 1;
+
+ &__label {
+ margin-bottom: 3px;
+ }
+
+ &__unit {
+ margin-right: 3px;
+ color: globals.$text-light-color;
+
+ &:not(:last-child)::after {
+ content: ',';
+ }
+ }
+ }
+
+ &__quantity {
+ flex: 0 0 3rem;
+ margin: 0 0.8rem;
+
+ &__icon {
+ margin-right: 3px;
+ color: globals.$text-light-color;
+ font-size: 0.8rem;
+ }
+ }
+
+ &__total {
+ flex: 0 0 5rem;
+ margin-left: 0.8rem;
+ text-align: right;
+ }
+
+ &__price {
+ flex: 0 0 auto;
+ margin-left: 0.8rem;
+ color: globals.$text-light-color;
+ text-align: right;
+ }
+}
diff --git a/client/src/themes/default/components/MaterialsSorted/CategoryItem/Material/index.tsx b/client/src/themes/default/components/MaterialsSorted/CategoryItem/Material/index.tsx
new file mode 100644
index 000000000..361c15e60
--- /dev/null
+++ b/client/src/themes/default/components/MaterialsSorted/CategoryItem/Material/index.tsx
@@ -0,0 +1,59 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import formatAmount from '@/utils/formatAmount';
+import getMaterialUnitPrice from '../../utils/getMaterialUnitPrice';
+import getMaterialQuantity from '../../utils/getMaterialQuantity';
+import Icon from '@/themes/default/components/Icon';
+
+import type { PropType } from '@vue/composition-api';
+import type { BookingMaterial } from '../../utils/_types';
+
+type Props = {
+ material: BookingMaterial,
+ withRentalPrices: boolean,
+};
+
+const MaterialsCategoryItemMaterial = defineComponent({
+ name: 'MaterialsCategoryItemMaterial',
+ props: {
+ material: {
+ type: Object as PropType['material']>,
+ required: true,
+ },
+ withRentalPrices: {
+ type: Boolean as PropType['withRentalPrices']>,
+ default: false,
+ },
+ },
+ render() {
+ const { material, withRentalPrices } = this;
+ return (
+
+
+ {withRentalPrices && (
+
+ {formatAmount(getMaterialUnitPrice(material))}
+
+ )}
+
+
+ {getMaterialQuantity(material)}
+
+ {withRentalPrices && (
+
+ {formatAmount(getMaterialQuantity(material) * getMaterialUnitPrice(material))}
+
+ )}
+
+ );
+ },
+});
+
+export default MaterialsCategoryItemMaterial;
diff --git a/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.js b/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.js
deleted file mode 100644
index c6a87d136..000000000
--- a/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import './index.scss';
-import { computed, toRefs } from '@vue/composition-api';
-import useI18n from '@/hooks/useI18n';
-import getMaterialQuantity from '../utils/getMaterialQuantity';
-import getMaterialUnitPrice from '../utils/getMaterialUnitPrice';
-import formatAmount from '@/utils/formatAmount';
-import Icon from '@/themes/default/components/Icon';
-
-// @vue/component
-const MaterialsCategoryItem = (props) => {
- const __ = useI18n();
- const { data, withRentalPrices } = toRefs(props);
-
- const subTotal = computed(() => {
- if (!withRentalPrices.value) {
- return 0;
- }
-
- return data.value.materials.reduce((total, material) => (
- total + (getMaterialQuantity(material) * getMaterialUnitPrice(material))
- ), 0);
- });
-
- return () => (
-
-
{data.value.name ?? __('not-categorized')}
-
- {data.value.materials.map((material) => (
- -
-
- {material.name}
-
- {withRentalPrices.value && (
-
- {formatAmount(getMaterialUnitPrice(material))}
-
- )}
-
-
- {getMaterialQuantity(material)}
-
- {withRentalPrices.value && (
-
- {formatAmount(getMaterialQuantity(material) * getMaterialUnitPrice(material))}
-
- )}
-
- ))}
-
- {withRentalPrices.value && (
-
-
- {__('sub-total')}
-
-
- {formatAmount(subTotal.value)}
-
-
- )}
-
- );
-};
-
-MaterialsCategoryItem.props = {
- data: { type: Object, required: true },
- withRentalPrices: { type: Boolean, default: false },
-};
-
-export default MaterialsCategoryItem;
diff --git a/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.scss b/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.scss
index 04ce013df..2bae86446 100644
--- a/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.scss
+++ b/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.scss
@@ -13,58 +13,24 @@
padding: 0;
}
- &__material,
&__subtotal {
display: flex;
- list-style: none;
-
- &__name {
- flex: 1;
- }
-
- &__price {
- flex: 0 0 auto;
- margin-left: 0.8rem;
- color: globals.$text-light-color;
- text-align: right;
- }
- }
-
- &__material {
- padding: 0.7rem 0;
- border-bottom: 1px dashed globals.$list-left-border-color;
-
- &__quantity {
- flex: 0 0 3rem;
- margin: 0 0.8rem;
-
- &__icon {
- margin-right: 3px;
- color: globals.$text-light-color;
- font-size: 0.8rem;
- }
- }
-
- &__total {
- flex: 0 0 5rem;
- margin-left: 0.8rem;
- text-align: right;
- }
- }
-
- &__subtotal {
padding: 0.5rem 0 0;
font-size: 1.1rem;
+ list-style: none;
&__name {
+ flex: 1;
color: globals.$text-light-color;
text-align: right;
}
&__price {
flex: 0 0 auto;
+ margin-left: 0.8rem;
color: globals.$text-base-color;
font-weight: 600;
+ text-align: right;
}
}
}
diff --git a/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.tsx b/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.tsx
new file mode 100644
index 000000000..f0ef44f73
--- /dev/null
+++ b/client/src/themes/default/components/MaterialsSorted/CategoryItem/index.tsx
@@ -0,0 +1,74 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import getMaterialQuantity from '../utils/getMaterialQuantity';
+import getMaterialUnitPrice from '../utils/getMaterialUnitPrice';
+import formatAmount from '@/utils/formatAmount';
+import Material from './Material';
+
+import type { PropType } from '@vue/composition-api';
+import type { MaterialsSection } from '../utils/groupByCategories';
+import type { BookingMaterial } from '../utils/_types';
+
+type Props = {
+ data: MaterialsSection,
+ withRentalPrices: boolean,
+};
+
+// @vue/component
+const MaterialsCategoryItem = defineComponent({
+ name: 'MaterialsCategoryItem',
+ props: {
+ data: {
+ type: Object as PropType['data']>,
+ required: true,
+ },
+ withRentalPrices: {
+ type: Boolean as PropType['withRentalPrices']>,
+ default: false,
+ },
+ },
+ computed: {
+ subTotal() {
+ const { data, withRentalPrices } = this;
+ if (!withRentalPrices) {
+ return 0;
+ }
+
+ return data.materials.reduce((total: number, material: BookingMaterial) => (
+ total + (getMaterialQuantity(material) * getMaterialUnitPrice(material))
+ ), 0);
+ },
+ },
+ render() {
+ const { $t: __, data, withRentalPrices, subTotal } = this;
+
+ return (
+
+
+ {data.name ?? __('not-categorized')}
+
+
+ {data.materials.map((material: BookingMaterial) => (
+
+ ))}
+
+ {withRentalPrices && (
+
+
+ {__('sub-total')}
+
+
+ {formatAmount(subTotal)}
+
+
+ )}
+
+ );
+ },
+});
+
+export default MaterialsCategoryItem;
diff --git a/client/src/themes/default/components/MaterialsSorted/index.js b/client/src/themes/default/components/MaterialsSorted/index.js
deleted file mode 100644
index eaa29eaff..000000000
--- a/client/src/themes/default/components/MaterialsSorted/index.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import './index.scss';
-import { toRefs, computed } from '@vue/composition-api';
-import { useQuery, useQueryProvider } from 'vue-query';
-import queryClient from '@/globals/queryClient';
-import apiCategories from '@/stores/api/categories';
-import groupByCategories from './utils/groupByCategories';
-import MaterialsCategoryItem from './CategoryItem';
-
-// @vue/component
-const MaterialsSorted = (props) => {
- const { data, withRentalPrices } = toRefs(props);
-
- // - Obligation d'utiliser ce hook car on peut être dans une modale
- useQueryProvider(queryClient);
- const { data: allCategories } = useQuery({
- queryKey: 'categories',
- queryFn: () => apiCategories.all(),
- });
-
- const byCategories = computed(() => (
- groupByCategories(
- data.value,
- allCategories.value ?? [],
- withRentalPrices.value ? 'price' : 'name',
- )
- ));
-
- return () => (
-
- {byCategories.value.map((category) => (
-
- ))}
-
- );
-};
-
-MaterialsSorted.props = {
- data: { type: Array, required: true },
- withRentalPrices: { type: Boolean, default: true },
-};
-
-export default MaterialsSorted;
diff --git a/client/src/themes/default/components/MaterialsSorted/index.tsx b/client/src/themes/default/components/MaterialsSorted/index.tsx
new file mode 100644
index 000000000..4be219693
--- /dev/null
+++ b/client/src/themes/default/components/MaterialsSorted/index.tsx
@@ -0,0 +1,61 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import groupByCategories from './utils/groupByCategories';
+import MaterialsCategoryItem from './CategoryItem';
+
+import type { PropType } from '@vue/composition-api';
+import type { BookingMaterial } from './utils/_types';
+import type { MaterialsSection } from './utils/groupByCategories';
+
+type Props = {
+ data: BookingMaterial[],
+ withRentalPrices: boolean,
+};
+
+// @vue/component
+const MaterialsSorted = defineComponent({
+ name: 'MaterialsSorted',
+ props: {
+ data: {
+ type: Array as PropType['data']>,
+ required: true,
+ },
+ withRentalPrices: {
+ type: Boolean as PropType['withRentalPrices']>,
+ default: true,
+ },
+ },
+ computed: {
+ byCategories(): MaterialsSection[] {
+ const { data, withRentalPrices } = this;
+ const allCategories = this.$store.state.categories.list;
+
+ return groupByCategories(
+ data,
+ allCategories ?? [],
+ withRentalPrices ? 'price' : 'name',
+ );
+ },
+ },
+ created() {
+ this.$store.dispatch('categories/fetch');
+ },
+ render() {
+ const { byCategories, withRentalPrices } = this;
+
+ return (
+
+ {byCategories.map((category: MaterialsSection) => (
+
+ ))}
+
+ );
+ },
+});
+
+export default MaterialsSorted;
diff --git a/client/src/themes/default/components/MaterialsSorted/utils/_types.ts b/client/src/themes/default/components/MaterialsSorted/utils/_types.ts
index 6365e6eb2..cdbb27f6c 100644
--- a/client/src/themes/default/components/MaterialsSorted/utils/_types.ts
+++ b/client/src/themes/default/components/MaterialsSorted/utils/_types.ts
@@ -1,3 +1,3 @@
-import type { MaterialWithPivot } from '@/stores/api/events';
+import type { EventMaterial } from '@/stores/api/events';
-export type BookingMaterial = MaterialWithPivot;
+export type BookingMaterial = EventMaterial;
diff --git a/client/src/themes/default/components/MaterialsSorted/utils/groupByCategories.ts b/client/src/themes/default/components/MaterialsSorted/utils/groupByCategories.ts
index 08415968e..458c42962 100644
--- a/client/src/themes/default/components/MaterialsSorted/utils/groupByCategories.ts
+++ b/client/src/themes/default/components/MaterialsSorted/utils/groupByCategories.ts
@@ -5,7 +5,7 @@ import getMaterialUnitPrice from './getMaterialUnitPrice';
import type { BookingMaterial } from './_types';
import type { Category } from '@/stores/api/categories';
-type MaterialsSection = {
+export type MaterialsSection = {
id: Category['id'] | null,
name: Category['name'] | null,
materials: BookingMaterial[],
diff --git a/client/src/themes/default/components/MonthCalendar/index.js b/client/src/themes/default/components/MonthCalendar/index.js
index 0003f45c7..ec9ba93d9 100644
--- a/client/src/themes/default/components/MonthCalendar/index.js
+++ b/client/src/themes/default/components/MonthCalendar/index.js
@@ -1,24 +1,70 @@
import './index.scss';
+import clsx from 'clsx';
import moment from 'moment';
+import styleObjectToString from 'style-object-to-css-string';
import { CalendarView } from 'vue-simple-calendar';
+import Color from '@/utils/color';
// @vue/component
export default {
name: 'MonthCalendar',
props: {
- events: { type: Array, required: true },
- showDate: { type: Date, default: () => new Date() },
+ items: {
+ type: Array,
+ default: () => [],
+ },
withTotal: { type: Boolean },
},
data() {
return {
- currentDate: this.showDate,
+ currentDate: new Date(),
};
},
computed: {
currentMonth() {
return moment(this.currentDate).format('MMMM YYYY');
},
+
+ formattedItems() {
+ return (this.items ?? []).map((rawItem) => {
+ const {
+ startDate: rawStartDate,
+ endDate: rawEndDate,
+ style: rawStyle = {},
+ color: rawColor = null,
+ className: rawClassName = [],
+ ...item
+ } = rawItem;
+
+ const startDate = moment(rawStartDate);
+ const endDate = moment(rawEndDate);
+ const style = typeof rawStyle === 'object' ? { ...rawStyle } : {};
+ const className = clsx(rawClassName).split(' ');
+
+ if (rawColor !== null && rawColor !== undefined) {
+ const color = new Color(rawColor);
+
+ if (!('--month-calendar-item-color' in style)) {
+ style['--month-calendar-item-color'] = color.toHexString();
+ }
+
+ const colorType = color.isDark() ? 'dark' : 'light';
+ className.push(
+ `MonthCalendar__item--with-custom-color`,
+ `MonthCalendar__item--with-${colorType}-color`,
+ );
+ }
+
+ className.unshift('MonthCalendar__item');
+ return {
+ ...item,
+ startDate: startDate.format('YYYY-MM-DD HH:mm:ss'),
+ endDate: endDate.format('YYYY-MM-DD HH:mm:ss'),
+ style: styleObjectToString(style),
+ classes: className,
+ };
+ });
+ },
},
methods: {
handlePrevMonthClick() {
@@ -38,9 +84,9 @@ export default {
render() {
const {
$t: __,
- events,
currentDate,
currentMonth,
+ formattedItems,
handlePrevMonthClick,
handleNextMonthClick,
handleClickItem,
@@ -69,7 +115,7 @@ export default {
{withTotal && (
)}
@@ -77,7 +123,7 @@ export default {
class="MonthCalendar__body"
showDate={currentDate}
startingDayOfWeek={1}
- items={events}
+ items={formattedItems}
itemContentHeight="53px"
itemBorderHeight="0px"
// Note: Le camelCase (`onClickItem`) ne fonctionne pas.
diff --git a/client/src/themes/default/components/MonthCalendar/index.scss b/client/src/themes/default/components/MonthCalendar/index.scss
index ac901af2f..382f9340c 100644
--- a/client/src/themes/default/components/MonthCalendar/index.scss
+++ b/client/src/themes/default/components/MonthCalendar/index.scss
@@ -46,7 +46,6 @@
.cv-day,
.cv-header-day,
.cv-header-days,
- .cv-item,
.cv-week,
.cv-weeks {
border-color: globals.$text-muted-color;
@@ -67,8 +66,12 @@
}
}
}
+ }
+
+ &__item {
+ $sub-block: &;
- .cv-item {
+ &.cv-item {
height: 48px;
margin-top: 5px;
margin-left: 5px;
@@ -80,19 +83,6 @@
line-height: 1.5;
white-space: pre-wrap;
- &--not-confirmed {
- background-color:
- rgba(
- globals.$calendar-event-normal-color,
- globals.$calendar-event-not-confirmed-opacity
- );
- }
-
- &--past {
- border-radius: 5px;
- background-color: globals.$calendar-event-past-color;
- }
-
@for $i from 1 through 7 {
&.span#{$i} {
width: calc(((100% / 7) * #{$i}) - 11px);
@@ -109,6 +99,20 @@
border-start-end-radius: 0;
}
}
+
+ &--with-custom-color {
+ &.cv-item {
+ background-color: var(--month-calendar-item-color);
+
+ {$sub-block}--with-dark-color {
+ color: globals.$calendar-event-text-color-light;
+ }
+
+ {$sub-block}--with-light-color {
+ color: globals.$calendar-event-text-color-dark;
+ }
+ }
+ }
}
/* stylelint-enable selector-class-pattern */
}
diff --git a/client/src/themes/default/components/Notepad/_variables.scss b/client/src/themes/default/components/Notepad/_variables.scss
new file mode 100644
index 000000000..7cf7e8b79
--- /dev/null
+++ b/client/src/themes/default/components/Notepad/_variables.scss
@@ -0,0 +1,50 @@
+@use 'sass:color';
+@use '~@/themes/default/style/globals';
+
+/// Couleur de fond du notepad.
+/// @type Color
+$background-color: rgba(51, 51, 51, 0.65) !default;
+
+/// Couleur du texte du notepad.
+/// @type Color
+$color: globals.$color-input !default;
+
+/// Border-radius du notepad.
+/// @type Color
+$border-radius: globals.$input-border-radius !default;
+
+/// Nombre de lignes à afficher par défaut dans le notepad.
+/// @type Number
+$rows: 8 !default;
+
+/// Taille de la police dans le notepad.
+/// @type Number
+$font-size: 15px !default;
+
+/// Hauteur de chaque ligne (en pixels).
+/// @type Number
+$line-height: 33px !default;
+
+/// Épaisseur de la ligne de guide en bas de chaque ligne.
+/// @type Number
+$line-rule-width: 1px !default;
+
+/// Couleur de la ligne de guide en bas de chaque ligne.
+/// @type Color
+$line-rule-color: rgba(255, 255, 255, 0.11) !default;
+
+//
+// - Désactivé.
+//
+
+/// Couleur de fond du notepad lorsqu'il est désactivé.
+/// @type Color
+$disabled-background-color: color.adjust($background-color, $lightness: -8%) !default;
+
+/// Couleur du texte du notepad lorsqu'il est désactivé.
+/// @type Color
+$disabled-color: color.adjust($color, $lightness: -30%, $alpha: -0.2) !default;
+
+/// Couleur de la ligne de guide en bas de chaque ligne lorsque le notepad est désactivé.
+/// @type Color
+$disabled-line-rule-color: color.adjust($line-rule-color, $lightness: -30%, $alpha: -0.05) !default;
diff --git a/client/src/themes/default/components/Notepad/index.scss b/client/src/themes/default/components/Notepad/index.scss
new file mode 100644
index 000000000..0c92e4889
--- /dev/null
+++ b/client/src/themes/default/components/Notepad/index.scss
@@ -0,0 +1,69 @@
+@use './variables' as *;
+@use '~@/themes/default/style/globals';
+
+.Notepad {
+ $block: &;
+
+ //
+ // - Custom properties
+ //
+
+ --notepad-rows: var(--Notepad--rows, #{$rows});
+
+ //
+ // - Règles
+ //
+
+ padding: 15px 15px $line-height;
+ border-radius: $border-radius;
+ background: $background-color;
+ color: $color;
+
+ &__input {
+ width: 100%;
+ min-height: calc(var(--notepad-rows) * #{$line-height});
+ padding: 0 5px;
+ border: none;
+ background-color: transparent;
+ background-size: auto $line-height;
+ background-attachment: local;
+ color: inherit;
+ font-size: $font-size;
+ line-height: $line-height;
+ resize: none;
+
+ // stylelint-disable-next-line declaration-colon-newline-after, order/properties-order
+ background-image: linear-gradient(
+ transparent,
+ transparent calc(#{$line-height} - #{$line-rule-width}),
+ $line-rule-color calc(#{$line-height} - #{$line-rule-width}),
+ );
+
+ &:focus,
+ &:disabled {
+ border-color: none;
+ background-color: transparent;
+ color: inherit;
+ }
+ }
+
+ //
+ // - Désactivé.
+ //
+
+ &--disabled {
+ background-color: $disabled-background-color;
+ color: $disabled-color;
+
+ #{$block}__input {
+ cursor: not-allowed;
+
+ // stylelint-disable-next-line declaration-colon-newline-after, order/properties-order
+ background-image: linear-gradient(
+ transparent,
+ transparent calc(#{$line-height} - #{$line-rule-width}),
+ $disabled-line-rule-color calc(#{$line-height} - #{$line-rule-width}),
+ );
+ }
+ }
+}
diff --git a/client/src/themes/default/components/Notepad/index.tsx b/client/src/themes/default/components/Notepad/index.tsx
new file mode 100644
index 000000000..05888cb10
--- /dev/null
+++ b/client/src/themes/default/components/Notepad/index.tsx
@@ -0,0 +1,105 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+
+import type { PropType } from '@vue/composition-api';
+
+type Props = {
+ /** Le nom du champ (utilisé dans un attribut `name`). */
+ name?: string,
+
+ /** La valeur du champ. */
+ value?: string | number,
+
+ /**
+ * Dois-t'on donner le focus au champ lorsque le component est monté ?
+ * (cette prop. est incompatible avec la prop. `readonly`)
+ */
+ autofocus: boolean,
+
+ /**
+ * Le champ est-il désactivé ?
+ * (cette prop. est incompatible avec la prop. `readonly`)
+ */
+ disabled: boolean,
+};
+
+// @vue/component
+const Notepad = defineComponent({
+ name: 'Notepad',
+ inject: {
+ 'input.disabled': { default: { value: false } },
+ },
+ props: {
+ name: {
+ type: String as PropType['name']>,
+ default: undefined,
+ },
+ value: {
+ type: [String, Number] as PropType['value']>,
+ default: undefined,
+ },
+ autofocus: {
+ type: Boolean as PropType['autofocus']>,
+ default: false,
+ },
+ disabled: {
+ type: Boolean as PropType['disabled']>,
+ default: false,
+ },
+ },
+ computed: {
+ inheritedDisabled() {
+ if (this.disabled !== undefined) {
+ return this.disabled;
+ }
+ return this['input.disabled'].value;
+ },
+ },
+ mounted() {
+ if (this.autofocus && !this.disabled) {
+ this.$nextTick(() => { this.$refs.inputRef.focus(); });
+ }
+ },
+ methods: {
+ handleInput(e: InputEvent) {
+ const { value } = e.target as HTMLTextAreaElement;
+ this.$emit('input', value);
+ },
+
+ handleChange(e: Event) {
+ const { value } = e.target as HTMLTextAreaElement;
+ this.$emit('change', value);
+ },
+ },
+ render() {
+ const {
+ name,
+ value,
+ readOnly,
+ inheritedDisabled: disabled,
+ handleInput,
+ handleChange,
+ } = this;
+
+ const className = ['Notepad', {
+ 'Notepad--disabled': disabled,
+ }];
+
+ return (
+
+
+
+ );
+ },
+});
+
+export default Notepad;
diff --git a/client/src/themes/default/components/Progressbar/index.js b/client/src/themes/default/components/Progressbar/index.js
deleted file mode 100644
index 2b77540b4..000000000
--- a/client/src/themes/default/components/Progressbar/index.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import './index.scss';
-
-// @vue/component
-export default {
- name: 'Progressbar',
- props: {
- percent: { type: Number, required: true },
- },
- computed: {
- humanPercent() {
- return Math.round(this.percent);
- },
- },
- render() {
- const { $t: __, percent, humanPercent } = this;
-
- return (
-
-
- {percent < 100 && {humanPercent}%}
- {percent === 100 && {__('almost-done')}}
-
-
- );
- },
-};
diff --git a/client/src/themes/default/components/Progressbar/index.scss b/client/src/themes/default/components/Progressbar/index.scss
index a170cf366..95340a5e9 100644
--- a/client/src/themes/default/components/Progressbar/index.scss
+++ b/client/src/themes/default/components/Progressbar/index.scss
@@ -4,8 +4,8 @@
position: relative;
width: 100%;
height: 20px;
- border-radius: globals.$input-border-radius;
- background: globals.$bg-color-tooltip;
+ border-radius: 10px;
+ background: #141414;
&__progress {
position: absolute;
@@ -13,8 +13,17 @@
left: 0;
bottom: 0;
overflow: hidden;
- border-radius: globals.$input-border-radius;
+ border-radius: 10px;
background: globals.$bg-color-button-info;
text-align: center;
+ transition: width 200ms linear;
+ }
+
+ //
+ // - Variantes
+ //
+
+ &--minimalist {
+ height: 7px;
}
}
diff --git a/client/src/themes/default/components/Progressbar/index.tsx b/client/src/themes/default/components/Progressbar/index.tsx
new file mode 100644
index 000000000..c90bc4050
--- /dev/null
+++ b/client/src/themes/default/components/Progressbar/index.tsx
@@ -0,0 +1,50 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+
+import type { PropType } from '@vue/composition-api';
+
+export type Props = {
+ /** Le pourcentage actuel de la barre de progression. */
+ percent: number,
+
+ /** Dois-t'on afficher une version minimaliste de la barre de progression ? */
+ minimalist?: boolean,
+};
+
+// @vue/component
+const Progressbar = defineComponent({
+ name: 'Progressbar',
+ props: {
+ percent: {
+ type: Number as PropType['percent']>,
+ required: true,
+ },
+ minimalist: {
+ type: Boolean as PropType['minimalist']>,
+ default: false,
+ },
+ },
+ computed: {
+ humanPercent(): number {
+ return Math.round(this.percent);
+ },
+ },
+ render() {
+ const { $t: __, percent, humanPercent, minimalist } = this;
+
+ const className = ['Progressbar', {
+ 'Progressbar--minimalist': minimalist,
+ }];
+
+ return (
+
+
+ {!minimalist && percent < 100 && {humanPercent}%}
+ {!minimalist && percent === 100 && {__('almost-done')}}
+
+
+ );
+ },
+});
+
+export default Progressbar;
diff --git a/client/src/themes/default/components/QuantityInput/index.js b/client/src/themes/default/components/QuantityInput/index.js
index 84c590734..b678f2383 100644
--- a/client/src/themes/default/components/QuantityInput/index.js
+++ b/client/src/themes/default/components/QuantityInput/index.js
@@ -36,7 +36,7 @@ export default defineComponent({
value = this.min;
}
- if (this.max != null && value > this.limit) {
+ if (![undefined, null].includes(this.max) && value > this.limit) {
value = this.max;
}
@@ -53,7 +53,7 @@ export default defineComponent({
handleIncrement() {
const value = this.value + 1;
- if (this.max != null && value > this.max) {
+ if (![undefined, null].includes(this.max) && value > this.max) {
return;
}
this.$emit('change', value);
@@ -102,9 +102,9 @@ export default defineComponent({
class={[
'QuantityInput__button',
'QuantityInput__button--increment',
- { 'QuantityInput__button--disabled': max != null && value >= max },
+ { 'QuantityInput__button--disabled': ![undefined, null].includes(max) && value >= max },
]}
- disabled={max != null && value >= max}
+ disabled={![undefined, null].includes(max) && value >= max}
onClick={handleIncrement}
>
diff --git a/client/src/themes/default/components/Select/index.js b/client/src/themes/default/components/Select/index.js
index 1d484a764..521cce57b 100644
--- a/client/src/themes/default/components/Select/index.js
+++ b/client/src/themes/default/components/Select/index.js
@@ -100,7 +100,7 @@ export default {
);
return Array.isArray(value)
- ? value.find((item) => isMatching(item)) !== undefined
+ ? value.some((item) => isMatching(item))
: isMatching(value);
});
}
diff --git a/client/src/themes/default/components/Tabs/TabButton/index.js b/client/src/themes/default/components/Tabs/TabButton/index.js
index 42e48c4b1..97572f284 100644
--- a/client/src/themes/default/components/Tabs/TabButton/index.js
+++ b/client/src/themes/default/components/Tabs/TabButton/index.js
@@ -1,8 +1,9 @@
import './index.scss';
+import { defineComponent } from '@vue/composition-api';
import Icon from '@/themes/default/components/Icon/index';
// @vue/component
-export default {
+const TabButton = defineComponent({
name: 'TabButton',
props: {
title: { type: String, required: true },
@@ -12,6 +13,20 @@ export default {
counter: { type: Number, default: null },
active: { type: Boolean, default: false },
},
+ computed: {
+ _icon() {
+ if (!this.icon) {
+ return null;
+ }
+
+ if (!this.icon.includes(':')) {
+ return { name: this.icon };
+ }
+
+ const [iconType, variant] = this.icon.split(':');
+ return { name: iconType, variant };
+ },
+ },
methods: {
handleClick() {
if (this.disabled) {
@@ -21,7 +36,7 @@ export default {
},
},
render() {
- const { title, icon, disabled, warning, counter, active, handleClick } = this;
+ const { title, _icon, disabled, warning, counter, active, handleClick } = this;
const hasCounter = counter !== null && counter > 0;
const className = ['TabButton', {
@@ -33,7 +48,7 @@ export default {
return (
- {icon && }
+ {_icon && }
{title}
{hasCounter && (
@@ -43,4 +58,6 @@ export default {
);
},
-};
+});
+
+export default TabButton;
diff --git a/client/src/themes/default/components/Tabs/index.js b/client/src/themes/default/components/Tabs/index.js
deleted file mode 100644
index b79f8984d..000000000
--- a/client/src/themes/default/components/Tabs/index.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import './index.scss';
-import { defineComponent } from '@vue/composition-api';
-import TabButton from './TabButton';
-import Tab from './Tab';
-
-// @vue/component
-const Tabs = defineComponent({
- name: 'Tabs',
- props: {
- defaultIndex: { type: Number, default: 0 },
- actions: { type: Array, default: undefined },
- },
- data() {
- return {
- selectedIndex: this.defaultIndex,
- };
- },
- methods: {
- handleSelect(index) {
- if (this.selectedIndex === index) {
- return;
- }
- this.selectedIndex = index;
- this.$emit('select', index);
- },
- },
- render() {
- const { actions, selectedIndex, handleSelect } = this;
-
- // - Ceci ne peut pas être placé dans un computed, car sinon
- // on perd la réactivité du contenu du panel.
- const tabs = this.$slots.default.filter((tab) => (
- tab.componentOptions.Ctor.extendOptions.name === 'Tab'
- ));
-
- return (
-
-
-
- {tabs[selectedIndex]}
-
-
- );
- },
-});
-
-export { Tabs, Tab };
diff --git a/client/src/themes/default/components/Tabs/index.tsx b/client/src/themes/default/components/Tabs/index.tsx
new file mode 100644
index 000000000..a84c22aaa
--- /dev/null
+++ b/client/src/themes/default/components/Tabs/index.tsx
@@ -0,0 +1,123 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import TabButton from './TabButton';
+import Tab from './Tab';
+
+import type { VNode } from 'vue';
+import type { PropType } from '@vue/composition-api';
+
+export type TabChangeEvent = {
+ /** L'index du tab qui est sur le point d'être sélectionné. */
+ index: number,
+
+ /** L'index du tab précédemment sélectionné. */
+ prevIndex: number,
+
+ /** Permet d'annuler le comportement par défaut: Le changement de tab. */
+ preventDefault(): void,
+
+ /**
+ * Permet d’exécuter le changement de tab manuellement si le
+ * comportement automatique a été précédemment annulé.
+ */
+ executeDefault(): void,
+};
+
+type Props = {
+ /** L'index du tab sélectionné par défaut. */
+ defaultIndex?: number,
+
+ /**
+ * Les actions contextuelles à afficher, sous la forme d'un tableau d'éléments. *
+ * (e.g. `[1, 2]`)
+ */
+ actions?: VNode[],
+};
+
+// @vue/component
+const Tabs = defineComponent({
+ name: 'Tabs',
+ props: {
+ defaultIndex: {
+ type: Number as PropType['defaultIndex']>,
+ default: 0,
+ },
+ actions: {
+ type: Array as PropType,
+ default: undefined,
+ },
+ },
+ emits: ['change', 'changed'],
+ data() {
+ return {
+ selectedIndex: this.defaultIndex,
+ };
+ },
+ methods: {
+ async handleSelect(index: number) {
+ if (this.selectedIndex === index) {
+ return;
+ }
+
+ let hasChanged = false;
+ const changeTab = (): void => {
+ if (hasChanged) {
+ return;
+ }
+
+ hasChanged = true;
+ this.selectedIndex = index;
+ this.$emit('selected', index);
+ };
+
+ let defaultPrevented = false;
+ const changeEventPayload: TabChangeEvent = {
+ index,
+ prevIndex: this.selectedIndex,
+ preventDefault: () => {
+ if (hasChanged) {
+ throw new Error('Tab has already been changed.');
+ }
+ defaultPrevented = true;
+ },
+ executeDefault: changeTab,
+ };
+ this.$emit('change', changeEventPayload);
+
+ if (!defaultPrevented) {
+ changeTab();
+ }
+ },
+ },
+ render() {
+ const { actions, selectedIndex, handleSelect } = this;
+
+ // - Ceci ne peut pas être placé dans un computed, car sinon
+ // on perd la réactivité du contenu du panel.
+ const tabs = this.$slots.default!.filter((tab: VNode) => (
+ tab.componentOptions!.Ctor.extendOptions.name === 'Tab'
+ ));
+
+ return (
+
+
+
+ {tabs[selectedIndex]}
+
+
+ );
+ },
+});
+
+export { Tabs, Tab };
diff --git a/client/src/themes/default/components/Textarea/index.js b/client/src/themes/default/components/Textarea/index.js
index 460702193..5911b083d 100644
--- a/client/src/themes/default/components/Textarea/index.js
+++ b/client/src/themes/default/components/Textarea/index.js
@@ -1,7 +1,8 @@
import './index.scss';
+import { defineComponent } from '@vue/composition-api';
// @vue/component
-export default {
+const Textarea = defineComponent({
name: 'Textarea',
inject: {
'input.invalid': { default: { value: false } },
@@ -80,4 +81,6 @@ export default {
/>
);
},
-};
+});
+
+export default Textarea;
diff --git a/client/src/themes/default/components/Timeline/_utils.js b/client/src/themes/default/components/Timeline/_utils.js
index 54514a980..5e3b39efc 100644
--- a/client/src/themes/default/components/Timeline/_utils.js
+++ b/client/src/themes/default/components/Timeline/_utils.js
@@ -2,9 +2,9 @@
// @see https://github.com/sjmallon/vue-visjs/blob/v0.4.2/src/utils.js
/* eslint-disable import/prefer-default-export */
-import { DataSet, DataView } from '@robert2/vis-timeline';
+import { DataSet, DataView } from '@loxya/vis-timeline';
-const arrayDiff = (arr1, arr2) => arr1.filter((x) => arr2.indexOf(x) === -1);
+const arrayDiff = (arr1, arr2) => arr1.filter((x) => !arr2.includes(x));
export const mountVisData = (vm, prop, stateProp) => {
if (vm[prop] instanceof DataSet || vm[prop] instanceof DataView) {
diff --git a/client/src/themes/default/components/Timeline/index.js b/client/src/themes/default/components/Timeline/index.js
index 3208680f1..08394c88c 100644
--- a/client/src/themes/default/components/Timeline/index.js
+++ b/client/src/themes/default/components/Timeline/index.js
@@ -1,7 +1,12 @@
-import '@robert2/vis-timeline/index.scss';
+/* eslint-disable import/order */
+import '@loxya/vis-timeline/index.scss';
import './index.scss';
+/* eslint-enable import/order */
+
import moment from 'moment';
-import { Timeline as TimelineCore, DataSet, DataView } from '@robert2/vis-timeline';
+import styleObjectToString from 'style-object-to-css-string';
+import { Timeline as TimelineCore, DataSet, DataView } from '@loxya/vis-timeline';
+import Color from '@/utils/color';
import { getLocale } from '@/globals/lang';
import dateRoundMinutes from '@/utils/dateRoundMinutes';
import Loading from '@/themes/default/components/Loading';
@@ -12,7 +17,7 @@ export default {
name: 'Timeline',
props: {
items: {
- type: [Array, DataSet, DataView],
+ type: Array,
default: () => [],
},
groups: {
@@ -82,7 +87,7 @@ export default {
overrideItems: false,
},
moment: (date) => moment(date),
- ...(this.options || {}),
+ ...this.options,
onMoving: (item, callback) => {
const { minutesGrid } = this.$props;
if (!minutesGrid) {
@@ -101,6 +106,43 @@ export default {
},
};
},
+
+ formattedItems() {
+ return (this.items ?? []).map((rawItem) => {
+ const {
+ style: rawStyle = {},
+ color: rawColor = null,
+ className: rawClassName = [],
+ ...item
+ } = rawItem;
+
+ const style = typeof rawStyle === 'object' ? { ...rawStyle } : {};
+ const className = typeof rawClassName === 'string'
+ ? rawClassName.trim().replaceAll(' ', ' ').split(' ')
+ : rawClassName;
+
+ if (rawColor !== null && rawColor !== undefined) {
+ const color = new Color(rawColor);
+
+ if (!('--timeline-item-color' in style)) {
+ style['--timeline-item-color'] = color.toHexString();
+ }
+
+ const colorType = color.isDark() ? 'dark' : 'light';
+ className.push(
+ `Timeline__item--with-custom-color`,
+ `Timeline__item--with-${colorType}-color`,
+ );
+ }
+
+ className.unshift('Timeline__item');
+ return {
+ ...item,
+ style: styleObjectToString(style),
+ className: className.join(' '),
+ };
+ });
+ },
},
watch: {
fullOptions: {
@@ -115,7 +157,7 @@ export default {
this.timeline = null;
},
mounted() {
- this.data = mountVisData(this, 'items', 'data');
+ this.data = mountVisData(this, 'formattedItems', 'data');
this.groupData = mountVisData(this, 'groups', 'groupData');
this.timeline = new TimelineCore(
diff --git a/client/src/themes/default/components/Timeline/index.scss b/client/src/themes/default/components/Timeline/index.scss
index 930e5d57a..c1094c63f 100644
--- a/client/src/themes/default/components/Timeline/index.scss
+++ b/client/src/themes/default/components/Timeline/index.scss
@@ -23,66 +23,8 @@ $group-item-max-height: 65px !default;
flex: 1;
}
- .vis-timeline {
- border: none;
- background-color: globals.$calendar-main-background-color;
-
- .vis-panel {
- &.vis-bottom,
- &.vis-center,
- &.vis-left,
- &.vis-right,
- &.vis-top {
- border: none;
- }
- }
-
- .vis-time-axis {
- &.vis-foreground {
- background: rgba(#fff, 0.05);
- box-shadow: 0 0 5px rgba(#000, 0.25);
- }
-
- // stylelint-disable-next-line selector-max-compound-selectors
- .vis-text {
- color: globals.$text-soft-color;
- text-align: center;
-
- // stylelint-disable-next-line selector-max-compound-selectors
- &.vis-major {
- margin-left: 0.5rem;
- font-weight: 800;
- text-transform: capitalize;
- }
- }
- }
-
- .vis-grid {
- &.vis-vertical {
- border-color: globals.$calendar-cells-border-color;
- }
-
- &.vis-today {
- background-color: globals.$calendar-current-date-background-color;
- }
-
- &.vis-sunday {
- background: rgba(#fff, 0.1);
- }
- }
-
- .vis-current-time {
- max-height: calc(100% - 42px);
- margin-top: 42px;
- background-color: globals.$calendar-time-cursor-color;
- }
-
- .vis-label {
- color: globals.$text-base-color;
- }
- }
-
- .vis-item {
+ &__item {
+ $sub-block: &;
$border-radius: 10px;
margin: 0;
@@ -91,13 +33,14 @@ $group-item-max-height: 65px !default;
font-size: 1rem;
&.vis-range {
+ border: none;
border-radius: $border-radius;
background-color: globals.$calendar-event-normal-color;
- color: globals.$calendar-event-text-color;
+ color: globals.$calendar-event-normal-text-color;
box-shadow: 1px 2px 3px rgba(#000, 0.5);
&.vis-selected {
- background-color: color.adjust(globals.$calendar-event-normal-color, $lightness: 10%);
+ filter: brightness(110%);
}
}
@@ -107,34 +50,9 @@ $group-item-max-height: 65px !default;
color: rgba(255, 255, 255, 0.5);
}
- .vis-item-overflow {
- display: flex;
- align-items: center;
- }
-
- .vis-item-content {
- overflow: hidden;
- padding: 0;
- text-overflow: ellipsis;
- }
-
- // stylelint-disable-next-line selector-class-pattern
- .vis-onUpdateTime-tooltip {
- border-radius: 3px;
- background-color: globals.$bg-color-tooltip;
- line-height: 1.5;
- white-space: pre-wrap;
- }
-
&:not(.vis-readonly):not(.vis-background) {
cursor: pointer;
- .vis-drag-left,
- .vis-drag-right {
- width: 32px;
- background: rgba(#000, 0.1);
- }
-
.vis-drag-left {
left: 0;
border-radius: $border-radius 0 0 $border-radius;
@@ -152,6 +70,39 @@ $group-item-max-height: 65px !default;
}
}
+ &--with-custom-color {
+ &.vis-range {
+ background-color: var(--timeline-item-color);
+
+ {$sub-block}--with-dark-color {
+ color: globals.$calendar-event-text-color-light;
+ }
+
+ {$sub-block}--with-light-color {
+ color: globals.$calendar-event-text-color-dark;
+ }
+ }
+ }
+
+ .vis-item-overflow {
+ display: flex;
+ align-items: center;
+ }
+
+ .vis-item-content {
+ overflow: hidden;
+ padding: 0;
+ text-overflow: ellipsis;
+ }
+
+ // stylelint-disable-next-line selector-class-pattern
+ .vis-onUpdateTime-tooltip {
+ border-radius: 3px;
+ background-color: globals.$bg-color-tooltip;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ }
+
.vis-delete {
right: -32px;
display: flex;
@@ -178,6 +129,61 @@ $group-item-max-height: 65px !default;
}
}
+ .vis-timeline {
+ border: none;
+ background-color: globals.$calendar-main-background-color;
+
+ .vis-panel {
+ &.vis-bottom,
+ &.vis-center,
+ &.vis-left,
+ &.vis-right,
+ &.vis-top {
+ border: none;
+ }
+ }
+
+ .vis-time-axis {
+ &.vis-foreground {
+ background: rgba(#fff, 0.05);
+ box-shadow: 0 0 5px rgba(#000, 0.25);
+ }
+
+ // stylelint-disable-next-line selector-max-compound-selectors
+ .vis-text {
+ color: globals.$text-soft-color;
+ text-align: center;
+
+ // stylelint-disable-next-line selector-max-compound-selectors
+ &.vis-major {
+ margin-left: 0.5rem;
+ font-weight: 800;
+ text-transform: capitalize;
+ }
+ }
+ }
+
+ .vis-grid {
+ &.vis-vertical {
+ border-color: globals.$calendar-cells-border-color;
+ }
+
+ &.vis-today {
+ background-color: globals.$calendar-current-date-background-color;
+ }
+ }
+
+ .vis-current-time {
+ max-height: calc(100% - 42px);
+ margin-top: 42px;
+ background-color: globals.$calendar-time-cursor-color;
+ }
+
+ .vis-label {
+ color: globals.$text-base-color;
+ }
+ }
+
.vis-tooltip {
overflow: hidden;
max-width: 50%;
diff --git a/client/src/themes/default/globals/router.js b/client/src/themes/default/globals/router.js
index efc328a62..ac6f2840a 100644
--- a/client/src/themes/default/globals/router.js
+++ b/client/src/themes/default/globals/router.js
@@ -14,7 +14,7 @@ router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.reduce(
(currentState, { meta }) => {
// - Non indiqué explicitement => Route publique.
- if (meta.requiresAuth == null) {
+ if ([undefined, null].includes(meta.requiresAuth)) {
return currentState;
}
diff --git a/client/src/themes/default/index.js b/client/src/themes/default/index.js
index 7189522bd..f2777d2ba 100644
--- a/client/src/themes/default/index.js
+++ b/client/src/themes/default/index.js
@@ -2,13 +2,16 @@ import './index.scss';
import Vue from 'vue';
import vuexI18n from 'vuex-i18n';
import vueCompositionApi from '@vue/composition-api';
+import { VueQueryPlugin as vueQueryPlugin } from 'vue-query';
import VueJsModal from 'vue-js-modal/dist/index.nocss';
import { VTooltip } from 'v-tooltip';
-import { ClientTable, ServerTable } from 'vue-tables-2';
+import { ClientTable, ServerTable } from 'vue-tables-2-premium';
import Toasted from 'vue-toasted';
+import Portal from 'portal-vue';
import config from '@/globals/config';
import { getDefaultLang, getLang } from '@/globals/lang';
import initMoment from '@/globals/init/moment';
+import queryClient from '@/globals/queryClient';
import requester from '@/globals/requester';
import store from '@/themes/default/globals/store';
import router from '@/themes/default/globals/router';
@@ -18,10 +21,13 @@ import App from './components/App';
Vue.config.productionTip = false;
-// - Vue Composition API
+// - Vue Composition API.
Vue.use(vueCompositionApi);
-// - HTTP (Ajax) lib
+// - Vue query.
+Vue.use(vueQueryPlugin, { queryClient });
+
+// - HTTP (Ajax) lib.
Vue.prototype.$http = requester;
// - Modal
@@ -107,6 +113,9 @@ Vue.use(Toasted, {
},
});
+// - Portails
+Vue.use(Portal);
+
const boot = async () => {
await store.dispatch('auth/fetch');
diff --git a/client/src/themes/default/layouts/Default/components/Sidebar/Menu/Item/index.tsx b/client/src/themes/default/layouts/Default/components/Sidebar/Menu/Item/index.tsx
index fbfd5272c..14e8882f8 100644
--- a/client/src/themes/default/layouts/Default/components/Sidebar/Menu/Item/index.tsx
+++ b/client/src/themes/default/layouts/Default/components/Sidebar/Menu/Item/index.tsx
@@ -21,7 +21,7 @@ type Props = {
/**
* Si la valeur vaut `true`, le router considèrera
* l'URL comme exacte lors du check du lien actif.
- * */
+ */
exact?: boolean,
};
diff --git a/client/src/themes/default/locale/en/components.js b/client/src/themes/default/locale/en/components.js
new file mode 100644
index 000000000..00226eb13
--- /dev/null
+++ b/client/src/themes/default/locale/en/components.js
@@ -0,0 +1,4 @@
+export default {
+ 'ColorPicker': require('@/themes/default/components/ColorPicker/translations/en.yml'),
+ 'FileManager': require('@/themes/default/components/FileManager/translations/en.yml'),
+};
diff --git a/client/src/themes/default/locale/en/index.js b/client/src/themes/default/locale/en/index.js
index 451661f07..75977e9bf 100644
--- a/client/src/themes/default/locale/en/index.js
+++ b/client/src/themes/default/locale/en/index.js
@@ -2,10 +2,12 @@ import shared from '@/locale/en';
import layout from './layout';
import page from './page';
import modal from './modal';
+import components from './components';
export default {
...shared,
page,
modal,
layout,
+ components,
};
diff --git a/client/src/themes/default/locale/en/page.js b/client/src/themes/default/locale/en/page.js
index 4f23e5102..20f6e08d5 100644
--- a/client/src/themes/default/locale/en/page.js
+++ b/client/src/themes/default/locale/en/page.js
@@ -12,6 +12,7 @@ export default {
'material-edit': require('@/themes/default/pages/Material/translations/en.yml'),
'material-view': require('@/themes/default/pages/MaterialView/translations/en.yml'),
'attributes': require('@/themes/default/pages/Attributes/translations/en.yml'),
+ 'attribute-edit': require('@/themes/default/pages/Attribute/translations/en.yml'),
'categories': require('@/themes/default/pages/Categories/translations/en.yml'),
'technicians': require('@/themes/default/pages/Technicians/translations/en.yml'),
'technician': require('@/themes/default/pages/Technician/translations/en.yml'),
diff --git a/client/src/themes/default/locale/fr/components.js b/client/src/themes/default/locale/fr/components.js
new file mode 100644
index 000000000..60d2c51e4
--- /dev/null
+++ b/client/src/themes/default/locale/fr/components.js
@@ -0,0 +1,4 @@
+export default {
+ 'ColorPicker': require('@/themes/default/components/ColorPicker/translations/fr.yml'),
+ 'FileManager': require('@/themes/default/components/FileManager/translations/fr.yml'),
+};
diff --git a/client/src/themes/default/locale/fr/index.js b/client/src/themes/default/locale/fr/index.js
index 0ed916373..d268039d4 100644
--- a/client/src/themes/default/locale/fr/index.js
+++ b/client/src/themes/default/locale/fr/index.js
@@ -2,10 +2,12 @@ import shared from '@/locale/fr';
import layout from './layout';
import page from './page';
import modal from './modal';
+import components from './components';
export default {
...shared,
page,
modal,
layout,
+ components,
};
diff --git a/client/src/themes/default/locale/fr/page.js b/client/src/themes/default/locale/fr/page.js
index 6a0c8df2b..c1eb3a89d 100644
--- a/client/src/themes/default/locale/fr/page.js
+++ b/client/src/themes/default/locale/fr/page.js
@@ -12,6 +12,7 @@ export default {
'material-edit': require('@/themes/default/pages/Material/translations/fr.yml'),
'material-view': require('@/themes/default/pages/MaterialView/translations/fr.yml'),
'attributes': require('@/themes/default/pages/Attributes/translations/fr.yml'),
+ 'attribute-edit': require('@/themes/default/pages/Attribute/translations/fr.yml'),
'categories': require('@/themes/default/pages/Categories/translations/fr.yml'),
'technicians': require('@/themes/default/pages/Technicians/translations/fr.yml'),
'technician': require('@/themes/default/pages/Technician/translations/fr.yml'),
diff --git a/client/src/themes/default/locale/vendors/vue-tables.js b/client/src/themes/default/locale/vendors/vue-tables.js
index 1c15ee819..8614e06d3 100644
--- a/client/src/themes/default/locale/vendors/vue-tables.js
+++ b/client/src/themes/default/locale/vendors/vue-tables.js
@@ -2,15 +2,16 @@ const fr = {
'count': "Enregistrements {from} à {to} sur {count}|{count} enregistrements|Un enregistrement",
'first': "Première",
'last': "Dernière",
- 'filter': "Filtre\u00a0:",
+ 'filter': "Filtre\u00A0:",
'filterPlaceholder': "Rechercher un nom...",
- 'limit': "Enregistrements\u00a0:",
- 'page': "Page\u00a0:",
+ 'limit': "Enregistrements\u00A0:",
+ 'page': "Page\u00A0:",
'noResults': "Aucun enregistrement trouvé.",
'filterBy': "Filtrer par {column}",
'loading': "Chargement...",
'defaultOption': "Sélection {column}",
'columns': "Col.",
+ 'loadingError': "Une erreur inattendue s'est produite lors de la récupération des données.",
};
const en = {
@@ -26,6 +27,7 @@ const en = {
'loading': "Loading...",
'defaultOption': "Select {column}",
'columns': "Col.",
+ 'loadingError': "An unexpected error occurred while retrieving the data.",
};
export default { fr, en };
diff --git a/client/src/themes/default/modals/AssignTags/index.js b/client/src/themes/default/modals/AssignTags/index.js
index 9148796b5..3e56abc36 100644
--- a/client/src/themes/default/modals/AssignTags/index.js
+++ b/client/src/themes/default/modals/AssignTags/index.js
@@ -55,7 +55,7 @@ export default {
// ------------------------------------------------------
// -
- // - Internal methods
+ // - Méthodes internes
// -
// ------------------------------------------------------
diff --git a/client/src/themes/default/modals/DuplicateEvent/index.js b/client/src/themes/default/modals/DuplicateEvent/index.js
index 6e607318c..e570b269f 100644
--- a/client/src/themes/default/modals/DuplicateEvent/index.js
+++ b/client/src/themes/default/modals/DuplicateEvent/index.js
@@ -71,7 +71,7 @@ export default {
// ------------------------------------------------------
// -
- // - Internal methods
+ // - Méthodes internes
// -
// ------------------------------------------------------
diff --git a/client/src/themes/default/modals/EventDetails/components/Header/Actions/index.js b/client/src/themes/default/modals/EventDetails/components/Header/Actions/index.js
index bc70fb89b..9bf37dee9 100644
--- a/client/src/themes/default/modals/EventDetails/components/Header/Actions/index.js
+++ b/client/src/themes/default/modals/EventDetails/components/Header/Actions/index.js
@@ -76,7 +76,7 @@ const EventDetailsHeaderActions = (props, { root, emit }) => {
try {
const data = await apiEvents.setConfirmed(id, !isConfirmed);
emit('saved', data);
- } catch (error) {
+ } catch {
root.$toasted.error(__('errors.unexpected-while-saving'));
} finally {
isConfirming.value = false;
@@ -135,7 +135,7 @@ const EventDetailsHeaderActions = (props, { root, emit }) => {
try {
await apiEvents.remove(id);
emit('deleted', id);
- } catch (error) {
+ } catch {
root.$toasted.error(__('errors.unexpected-while-deleting'));
} finally {
isDeleting.value = false;
diff --git a/client/src/themes/default/modals/EventDetails/components/Header/index.js b/client/src/themes/default/modals/EventDetails/components/Header/index.js
index fc6c8ea6e..22cd385cb 100644
--- a/client/src/themes/default/modals/EventDetails/components/Header/index.js
+++ b/client/src/themes/default/modals/EventDetails/components/Header/index.js
@@ -28,7 +28,7 @@ export default {
// - Actualise le timestamp courant toutes les minutes.
this.nowTimer = setInterval(() => { this.now = Date.now(); }, 60_000);
},
- beforeUnmount() {
+ beforeDestroy() {
if (this.nowTimer) {
clearInterval(this.nowTimer);
}
diff --git a/client/src/themes/default/modals/EventDetails/index.js b/client/src/themes/default/modals/EventDetails/index.js
index 0561a9792..fb6bd9b54 100644
--- a/client/src/themes/default/modals/EventDetails/index.js
+++ b/client/src/themes/default/modals/EventDetails/index.js
@@ -1,11 +1,11 @@
import './index.scss';
import moment from 'moment';
import { defineComponent } from '@vue/composition-api';
+import { confirm } from '@/utils/alert';
import { Tabs, Tab } from '@/themes/default/components/Tabs';
import apiEvents from '@/stores/api/events';
import CriticalError from '@/themes/default/components/CriticalError';
import Loading from '@/themes/default/components/Loading';
-import Icon from '@/themes/default/components/Icon';
import Button from '@/themes/default/components/Button';
import Header from './components/Header';
import Infos from './tabs/Infos';
@@ -13,6 +13,8 @@ import Technicians from './tabs/Technicians';
import Materials from './tabs/Materials';
import Estimates from './tabs/Estimates';
import Invoices from './tabs/Invoices';
+import Documents from './tabs/Documents';
+import Note from './tabs/Note';
const TABS = [
'infos',
@@ -20,6 +22,8 @@ const TABS = [
'materials',
'estimates',
'invoices',
+ 'documents',
+ 'note',
];
// @vue/component
@@ -42,7 +46,7 @@ const EventDetails = {
}),
computed: {
openedTabIndex() {
- const index = TABS.findIndex((tabName) => tabName === this.openedTab);
+ const index = TABS.indexOf(this.openedTab);
return index < 0 ? 0 : index;
},
@@ -104,7 +108,7 @@ const EventDetails = {
// - Actualise le timestamp courant toutes les minutes.
this.nowTimer = setInterval(() => { this.now = Date.now(); }, 60_000);
},
- beforeUnmount() {
+ beforeDestroy() {
if (this.nowTimer) {
clearInterval(this.nowTimer);
}
@@ -116,6 +120,52 @@ const EventDetails = {
// -
// ------------------------------------------------------
+ async handleTabChange(event) {
+ if (event.prevIndex !== TABS.indexOf('documents')) {
+ return;
+ }
+
+ const { documentsRef } = this.$refs;
+ if (!documentsRef?.isUploading()) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const { $t: __ } = this;
+ const isConfirmed = await confirm({
+ text: __('confirm-cancel-upload-change-tab'),
+ type: 'danger',
+ });
+ if (!isConfirmed) {
+ return;
+ }
+
+ event.executeDefault();
+ },
+
+ handleUpdated(event) {
+ this.event = event;
+
+ const { onUpdated } = this.$props;
+ if (onUpdated) {
+ onUpdated(event);
+ }
+ },
+
+ handleDuplicated(newEvent) {
+ const { onDuplicated } = this.$props;
+ if (onDuplicated) {
+ onDuplicated(newEvent);
+ }
+
+ this.handleClose();
+ },
+
+ handleDeleted() {
+ this.$emit('close');
+ },
+
handleInvoiceCreated(newInvoice) {
if (!this.event || !this.event.is_billable) {
return;
@@ -137,30 +187,13 @@ const EventDetails = {
this.event.estimates = newEstimatesList;
},
- handleUpdateEvent(newEvent) {
- this.event = newEvent;
-
- const { onUpdateEvent } = this.$props;
- if (onUpdateEvent) {
- onUpdateEvent(newEvent);
- }
- },
-
- handleDuplicateEvent(newEvent) {
- const { onDuplicateEvent } = this.$props;
- if (onDuplicateEvent) {
- onDuplicateEvent(newEvent);
- }
- this.handleClose();
- },
-
handleClose() {
this.$emit('close');
},
// ------------------------------------------------------
// -
- // - Internal methods
+ // - Méthodes internes
// -
// ------------------------------------------------------
@@ -185,12 +218,13 @@ const EventDetails = {
hasEventTechnicians,
hasMaterials,
hasMaterialsProblems,
- isEventPast,
+ handleUpdated,
+ handleTabChange,
+ handleDuplicated,
+ handleDeleted,
handleEstimateCreated,
handleEstimateDeleted,
handleInvoiceCreated,
- handleUpdateEvent,
- handleDuplicateEvent,
handleClose,
} = this;
@@ -216,12 +250,15 @@ const EventDetails = {
-
+
@@ -257,24 +294,13 @@ const EventDetails = {
/>
)}
+
+
+
+
+
+
- {!hasMaterials && (
-
-
-
- {__('@event.warning-no-material')}
-
- {!isEventPast && (
-
-
- {__('modal.event-details.edit')}
-
- )}
-
- )}
);
diff --git a/client/src/themes/default/modals/EventDetails/index.scss b/client/src/themes/default/modals/EventDetails/index.scss
index a94c81d1a..ee52c89bd 100644
--- a/client/src/themes/default/modals/EventDetails/index.scss
+++ b/client/src/themes/default/modals/EventDetails/index.scss
@@ -17,17 +17,6 @@
display: flex;
flex-direction: column;
max-height: 100vh;
- min-height: 250px;
padding: 20px;
}
-
- &__no-material {
- color: globals.$text-danger-color;
- font-size: 1.2rem;
- text-align: center;
-
- &__icon {
- margin-right: 5px;
- }
- }
}
diff --git a/client/src/themes/default/modals/EventDetails/tabs/Documents/index.tsx b/client/src/themes/default/modals/EventDetails/tabs/Documents/index.tsx
new file mode 100644
index 000000000..1a9c9edf5
--- /dev/null
+++ b/client/src/themes/default/modals/EventDetails/tabs/Documents/index.tsx
@@ -0,0 +1,146 @@
+import { defineComponent } from '@vue/composition-api';
+import apiEvents from '@/stores/api/events';
+import apiDocuments from '@/stores/api/documents';
+import CriticalError from '@/themes/default/components/CriticalError';
+import FileManager, { FileManagerLayout } from '@/themes/default/components/FileManager';
+import Loading from '@/themes/default/components/Loading';
+import { confirm } from '@/utils/alert';
+
+import type { ProgressCallback } from 'axios';
+import type { PropType } from '@vue/composition-api';
+import type { Document } from '@/stores/api/documents';
+import type { Event } from '@/stores/api/events';
+
+type Props = {
+ /** L'événement dont on veut gérer les documents. */
+ event: Event,
+};
+
+type State = {
+ isFetched: boolean,
+ hasCriticalError: boolean,
+ documents: Document[],
+};
+
+// @vue/component
+const EventDetailsDocuments = defineComponent({
+ name: 'EventDetailsDocuments',
+ props: {
+ event: {
+ type: Object as PropType['event']>,
+ required: true,
+ },
+ },
+ data: (): State => ({
+ hasCriticalError: false,
+ isFetched: false,
+ documents: [],
+ }),
+ mounted() {
+ this.fetchDocuments();
+
+ this.persistDocument.bind(this);
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleDocumentUploaded(document: Document) {
+ this.documents.push(document);
+ },
+
+ async handleDocumentDelete(id: Document['id']) {
+ const index = this.documents.findIndex((document: Document) => document.id === id);
+ if (index === -1) {
+ return;
+ }
+
+ const { $t: __ } = this;
+ const isConfirmed = await confirm({
+ type: 'danger',
+ text: __('modal.event-details.documents.confirm-permanently-delete'),
+ confirmButtonText: __('yes-permanently-delete'),
+ });
+ if (!isConfirmed) {
+ return;
+ }
+
+ this.$delete(this.documents, index);
+ try {
+ await apiDocuments.remove(id);
+ this.$toasted.success(__('modal.event-details.documents.deleted'));
+ } catch {
+ this.$toasted.error(__('errors.unexpected-while-deleting'));
+ this.fetchDocuments();
+ }
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - API Publique
+ // -
+ // ------------------------------------------------------
+
+ isUploading(): boolean {
+ const { fileManagerRef } = this.$refs;
+ return !!fileManagerRef?.isUploading();
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
+ async fetchDocuments(): Promise {
+ try {
+ const data = await apiEvents.documents(this.event.id);
+ this.documents = data;
+ this.isFetched = true;
+ } catch {
+ this.hasCriticalError = true;
+ }
+ },
+
+ persistDocument(file: File, signal: AbortSignal, onProgress: ProgressCallback): Promise {
+ return apiEvents.attachDocument(this.event.id, file, { onProgress, signal });
+ },
+ },
+ render() {
+ const {
+ isFetched,
+ hasCriticalError,
+ persistDocument,
+ documents,
+ handleDocumentUploaded,
+ handleDocumentDelete,
+ } = this;
+
+ if (hasCriticalError || !isFetched) {
+ return (
+
+ {hasCriticalError ? : }
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ },
+});
+
+export default EventDetailsDocuments;
diff --git a/client/src/themes/default/modals/EventDetails/tabs/Infos/index.js b/client/src/themes/default/modals/EventDetails/tabs/Infos/index.js
index 13e9478d7..a6f6ecff3 100644
--- a/client/src/themes/default/modals/EventDetails/tabs/Infos/index.js
+++ b/client/src/themes/default/modals/EventDetails/tabs/Infos/index.js
@@ -2,6 +2,7 @@ import './index.scss';
import moment from 'moment';
import { defineComponent } from '@vue/composition-api';
import Icon from '@/themes/default/components/Icon';
+import Button from '@/themes/default/components/Button';
import EventTechnicians from '@/themes/default/components/EventTechnicians';
import EventTotals from '@/themes/default/components/EventTotals';
import LocationText from '@/themes/default/components/LocationText';
@@ -35,7 +36,7 @@ const EventDetailsInfos = {
// - Actualise le timestamp courant toutes les minutes.
this.nowTimer = setInterval(() => { this.now = Date.now(); }, 60_000);
},
- beforeUnmount() {
+ beforeDestroy() {
if (this.nowTimer) {
clearInterval(this.nowTimer);
}
@@ -92,6 +93,23 @@ const EventDetailsInfos = {
{description}
)}
+ {!hasMaterials && (
+
+
+
+ {__('@event.warning-no-material')}
+
+ {!isPast && (
+
+
+ {__('modal.event-details.edit')}
+
+ )}
+
+ )}
{hasMaterials && !isPast && (
['event']>,
+ required: true,
+ },
+ },
+ data(): State {
+ return {
+ value: this.event.note ?? '',
+ saveMode: SaveMode.AUTOMATIC,
+ saveRetryAttempts: 0,
+ shouldReSave: false,
+ isSaving: false,
+ };
+ },
+ computed: {
+ readOnly() {
+ return this.$store.getters['auth/is'](Group.VISITOR);
+ },
+ },
+ created() {
+ this.throttledSave = throttle(this.save.bind(this), DEBOUNCE_WAIT, { leading: false });
+ },
+ beforeDestroy() {
+ this.throttledSave.flush();
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleInput(newValue: string) {
+ this.value = newValue;
+
+ if (this.saveMode === SaveMode.AUTOMATIC) {
+ this.throttledSave();
+ }
+ },
+
+ handleChange(newValue: string) {
+ if (this.value === newValue) {
+ return;
+ }
+ this.value = newValue;
+
+ if (this.saveMode === SaveMode.AUTOMATIC) {
+ this.throttledSave.cancel();
+ this.save();
+ }
+ },
+
+ handleSubmit(e: SubmitEvent) {
+ e.preventDefault();
+
+ this.throttledSave.cancel();
+ this.save();
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Internal methods
+ // -
+ // ------------------------------------------------------
+
+ async save() {
+ if (this.isSaving) {
+ this.shouldReSave = true;
+ return;
+ }
+
+ const { $t: __, event, value: note } = this;
+ this.shouldReSave = false;
+ this.isSaving = true;
+
+ try {
+ const updatedEvent = await apiEvents.update(event.id, { note });
+
+ this.saveRetryAttempts = 0;
+ this.$emit('updated', updatedEvent);
+ } catch {
+ if (this.saveMode === SaveMode.MANUAL || this.saveRetryAttempts >= MAX_AUTOMATIC_SAVE_ATTEMPTS) {
+ this.$toasted.error(__('errors.unexpected-while-saving'));
+ this.saveRetryAttempts = 0;
+ this.saveMode = SaveMode.MANUAL;
+ } else if (!this.shouldReSave) {
+ this.saveRetryAttempts += 1;
+ this.shouldReSave = true;
+ }
+ } finally {
+ this.isSaving = false;
+
+ if (this.shouldReSave) {
+ this.save();
+ }
+ }
+ },
+ },
+ render() {
+ const {
+ $t: __,
+ value,
+ isSaving,
+ readOnly,
+ saveMode,
+ handleChange,
+ handleInput,
+ handleSubmit,
+ } = this;
+
+ return (
+
+ );
+ },
+});
+
+export default EventDetailsNote;
diff --git a/client/src/themes/default/modals/EventDetails/tabs/Technicians/index.js b/client/src/themes/default/modals/EventDetails/tabs/Technicians/index.js
index 3c908f8ea..1d9de8862 100644
--- a/client/src/themes/default/modals/EventDetails/tabs/Technicians/index.js
+++ b/client/src/themes/default/modals/EventDetails/tabs/Technicians/index.js
@@ -57,12 +57,14 @@ export default {
const { events, groups, timelineOptions } = this;
return (
-
+
+
+
);
},
};
diff --git a/client/src/themes/default/modals/EventDetails/tabs/Technicians/index.scss b/client/src/themes/default/modals/EventDetails/tabs/Technicians/index.scss
index 6f908ac82..a24b5361f 100644
--- a/client/src/themes/default/modals/EventDetails/tabs/Technicians/index.scss
+++ b/client/src/themes/default/modals/EventDetails/tabs/Technicians/index.scss
@@ -1,7 +1,8 @@
.EventDetailsTechnicians {
- .Timeline__loading {
- top: 0;
- min-height: auto;
- background: none;
+ display: flex;
+ flex-direction: column;
+
+ &__timeline {
+ flex: 1;
}
}
diff --git a/client/src/themes/default/modals/EventDetails/translations/en.yml b/client/src/themes/default/modals/EventDetails/translations/en.yml
index 778689abb..921b63df4 100644
--- a/client/src/themes/default/modals/EventDetails/translations/en.yml
+++ b/client/src/themes/default/modals/EventDetails/translations/en.yml
@@ -28,3 +28,7 @@ estimates:
invoice:
regenerate-help: You can regenerate the invoice to change discount, or if the event has been modified.
+
+documents:
+ confirm-permanently-delete: Do you really want to permanently delete this document?
+ deleted: Document deleted.
diff --git a/client/src/themes/default/modals/EventDetails/translations/fr.yml b/client/src/themes/default/modals/EventDetails/translations/fr.yml
index ed7720dd1..f1554ff07 100644
--- a/client/src/themes/default/modals/EventDetails/translations/fr.yml
+++ b/client/src/themes/default/modals/EventDetails/translations/fr.yml
@@ -28,3 +28,7 @@ estimates:
invoice:
regenerate-help: Vous pouvez re-générer la facture pour en changer la remise, ou si l'événement a été modifié.
+
+documents:
+ confirm-permanently-delete: "Voulez-vous vraiment supprimer définitivement ce document\_?"
+ deleted: Document supprimé.
diff --git a/client/src/themes/default/pages/Attribute/components/Form/index.scss b/client/src/themes/default/pages/Attribute/components/Form/index.scss
new file mode 100644
index 000000000..074226345
--- /dev/null
+++ b/client/src/themes/default/pages/Attribute/components/Form/index.scss
@@ -0,0 +1,54 @@
+@use '~@/themes/default/style/globals';
+@use 'sass:color';
+
+.AttributeEditForm {
+ $block: &;
+
+ &__type {
+ max-width: 300px;
+ }
+
+ &__unit,
+ &__max-length {
+ max-width: 180px;
+ margin-top: 1rem;
+ }
+
+ &__is-totalisable-help {
+ margin: 0.5rem 0 0;
+ font-style: italic;
+ }
+
+ &__categories {
+ &__label {
+ margin: 0 0 0.5rem;
+ }
+
+ &__item {
+ display: inline-block;
+ margin-top: 4px;
+ padding: 0.55rem 1.1rem;
+ border-radius: 15px;
+ background-color: color.scale(globals.$bg-color-input-normal, $lightness: 3%);
+ color: globals.$text-light-color;
+ cursor: pointer;
+ transition: all 300ms;
+
+ &--selected,
+ &--selected:hover,
+ &--selected:focus {
+ background-color: globals.$primary-color;
+ color: #fff;
+ }
+
+ & + & {
+ margin-left: 8px;
+ }
+ }
+
+ &__help {
+ margin: 0.5rem 0 0;
+ font-style: italic;
+ }
+ }
+}
diff --git a/client/src/themes/default/pages/Attribute/components/Form/index.tsx b/client/src/themes/default/pages/Attribute/components/Form/index.tsx
new file mode 100644
index 000000000..183edcd30
--- /dev/null
+++ b/client/src/themes/default/pages/Attribute/components/Form/index.tsx
@@ -0,0 +1,265 @@
+import './index.scss';
+import { defineComponent } from '@vue/composition-api';
+import pick from 'lodash/pick';
+import Fragment from '@/components/Fragment';
+import FormField from '@/themes/default/components/FormField';
+import Fieldset from '@/themes/default/components/Fieldset';
+import Button from '@/themes/default/components/Button';
+
+import type { PropType } from '@vue/composition-api';
+import type { Attribute, AttributeEdit, AttributeType } from '@/stores/api/attributes';
+import type { Category } from '@/stores/api/categories';
+
+type Props = {
+ /** Les données de l'attribut actuellement sauvées en BDD. */
+ savedData?: Attribute | null,
+
+ /** Pour indiquer quand la sauvegarde est en cours. */
+ isSaving?: boolean,
+
+ /** Liste des erreurs de validation. */
+ errors?: Record
,
+};
+
+type CategoryOption = {
+ label: string,
+ value: Category['id'],
+};
+
+type Data = {
+ attribute: AttributeEdit,
+ hasCriticalError: boolean,
+};
+
+const DEFAULT_VALUES: AttributeEdit = Object.freeze({
+ name: '',
+ type: 'integer',
+ unit: '',
+ isTotalisable: false,
+ maxLength: '',
+ categories: [],
+});
+
+// @vue/component
+const AttributeEditForm = defineComponent({
+ name: 'AttributeEditForm',
+ provide: {
+ verticalForm: true,
+ },
+ props: {
+ savedData: {
+ type: Object as PropType['savedData']>,
+ default: null,
+ },
+ isSaving: {
+ type: Boolean as PropType['isSaving']>,
+ default: false,
+ },
+ errors: {
+ type: Object as PropType['errors']>,
+ default: null,
+ },
+ },
+ data(): Data {
+ const attribute = {
+ ...DEFAULT_VALUES,
+ ...pick(this.savedData ?? {}, Object.keys(DEFAULT_VALUES)),
+ categories: this.savedData?.categories.map(({ id }: Category) => id) ?? [],
+ };
+
+ return {
+ attribute,
+ hasCriticalError: false,
+ };
+ },
+ computed: {
+ isNew(): boolean {
+ return this.savedData === null;
+ },
+
+ categoriesOptions(): CategoryOption[] {
+ return this.$store.getters['categories/options'] as CategoryOption[];
+ },
+
+ typesOptions(): Array<{ value: AttributeType, label: string }> {
+ const { $t: __ } = this;
+
+ return [
+ { value: 'integer', label: __('page.attribute-edit.type-integer') },
+ { value: 'float', label: __('page.attribute-edit.type-float') },
+ { value: 'date', label: __('page.attribute-edit.type-date') },
+ { value: 'string', label: __('page.attribute-edit.type-string') },
+ { value: 'boolean', label: __('page.attribute-edit.type-boolean') },
+ ];
+ },
+
+ hasMaxLength(): boolean {
+ const { type } = this.attribute;
+ return type === 'string';
+ },
+
+ isNumber(): boolean {
+ const { type } = this.attribute;
+ return !!type && ['integer', 'float'].includes(type);
+ },
+ },
+ mounted() {
+ this.$store.dispatch('categories/fetch');
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleToggleCategory(categoryId: Category['id']) {
+ const { categories } = this.attribute;
+
+ const foundIndex = categories.indexOf(categoryId);
+ if (foundIndex === -1) {
+ categories.push(categoryId);
+ return;
+ }
+ categories.splice(foundIndex, 1);
+ },
+
+ handleSubmit(e: SubmitEvent) {
+ e.preventDefault();
+ const { isNew, attribute, isNumber } = this;
+ const { name, type, categories, unit, isTotalisable, maxLength } = attribute;
+
+ const data: AttributeEdit = { name, categories };
+ if (isNew) {
+ // Ici le type est forcément défini (cf. DEFAULT_VALUES)
+ data.type = type!;
+ }
+
+ if (isNumber && unit) {
+ data.unit = unit;
+ data.isTotalisable = !!isTotalisable;
+ }
+
+ if (type === 'string') {
+ data.maxLength = maxLength || null;
+ }
+
+ this.$emit('submit', data);
+ },
+
+ handleCancel() {
+ this.$emit('cancel');
+ },
+ },
+ render() {
+ const {
+ $t: __,
+ attribute,
+ errors,
+ isNew,
+ typesOptions,
+ isNumber,
+ hasMaxLength,
+ categoriesOptions,
+ handleToggleCategory,
+ handleSubmit,
+ handleCancel,
+ isSaving,
+ } = this;
+
+ return (
+
+ );
+ },
+});
+
+export default AttributeEditForm;
diff --git a/client/src/themes/default/pages/Attribute/index.scss b/client/src/themes/default/pages/Attribute/index.scss
new file mode 100644
index 000000000..e9898d3ed
--- /dev/null
+++ b/client/src/themes/default/pages/Attribute/index.scss
@@ -0,0 +1,16 @@
+.Page--attribute-edit {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 100%;
+
+ .AttributeEditPage {
+ flex: 1;
+ align-self: center;
+ }
+}
+
+.AttributeEditPage {
+ width: 600px;
+ max-width: 100%;
+}
diff --git a/client/src/themes/default/pages/Attribute/index.tsx b/client/src/themes/default/pages/Attribute/index.tsx
new file mode 100644
index 000000000..85dd3cdd7
--- /dev/null
+++ b/client/src/themes/default/pages/Attribute/index.tsx
@@ -0,0 +1,200 @@
+import './index.scss';
+import axios from 'axios';
+import diff from 'lodash/difference';
+import { defineComponent } from '@vue/composition-api';
+import HttpCode from 'status-code-enum';
+import { isRequestErrorStatusCode } from '@/utils/errors';
+import { confirm } from '@/utils/alert';
+import { ApiErrorCode } from '@/stores/api/@codes';
+import apiAttributes from '@/stores/api/attributes';
+import Page from '@/themes/default/components/Page';
+import CriticalError from '@/themes/default/components/CriticalError';
+import Loading from '@/themes/default/components/Loading';
+import Form from './components/Form';
+
+import type { AttributeDetails, AttributeEdit } from '@/stores/api/attributes';
+import type { Category } from '@/stores/api/categories';
+
+type Data = {
+ id: AttributeDetails['id'] | null,
+ isFetched: boolean,
+ isSaving: boolean,
+ attribute: AttributeDetails | null,
+ criticalError: boolean,
+ validationErrors: Record | undefined,
+};
+
+// @vue/component
+const AttributeEditPage = defineComponent({
+ name: 'AttributeEditPage',
+ data(): Data {
+ const id = this.$route.params.id
+ ? parseInt(this.$route.params.id, 10)
+ : null;
+
+ return {
+ id,
+ isFetched: false,
+ isSaving: false,
+ attribute: null,
+ criticalError: false,
+ validationErrors: undefined,
+ };
+ },
+ computed: {
+ isNew() {
+ return this.id === null;
+ },
+
+ pageTitle() {
+ const { $t: __, isNew, isFetched, attribute } = this;
+
+ if (isNew) {
+ return __('page.attribute-edit.title-create');
+ }
+
+ if (!isFetched) {
+ return __('page.attribute-edit.title-simple');
+ }
+
+ return __('page.attribute-edit.title', { name: attribute!.name });
+ },
+ },
+ mounted() {
+ this.fetchData();
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleSubmit(data: AttributeEdit) {
+ this.save(data);
+ },
+
+ handleCancel() {
+ this.$router.back();
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
+ async fetchData() {
+ const { isNew, id } = this;
+ if (isNew) {
+ this.isFetched = true;
+ return;
+ }
+
+ try {
+ this.attribute = await apiAttributes.one(id!);
+ } catch {
+ this.criticalError = true;
+ } finally {
+ this.isFetched = true;
+ }
+ },
+
+ async save(data: AttributeEdit) {
+ if (this.isSaving) {
+ return;
+ }
+
+ const { $t: __, attribute, isNew } = this;
+
+ const savedCategories = attribute?.categories?.map(({ id }: Category) => id) ?? [];
+ const hasCategories = data.categories.length > 0;
+ const hasRemovedCategories = diff(savedCategories, data.categories).length > 0;
+ const hasAddedCategories = diff(data.categories, savedCategories).length > 0;
+
+ if (!isNew && hasCategories && (hasRemovedCategories || hasAddedCategories)) {
+ const isConfirmed = await confirm({
+ title: __('please-confirm'),
+ text: __('page.attribute-edit.confirm-update-categories'),
+ confirmButtonText: __('page.attribute-edit.yes-update'),
+ type: 'warning',
+ });
+
+ if (!isConfirmed) {
+ return;
+ }
+ }
+
+ this.isSaving = true;
+
+ try {
+ if (isNew) {
+ await apiAttributes.create(data);
+ } else {
+ this.attribute = await apiAttributes.update(this.id!, data);
+ }
+
+ this.validationErrors = undefined;
+
+ // - Redirection...
+ this.$toasted.success(__('page.attribute-edit.saved'));
+ this.$router.replace({ name: 'attributes' });
+ } catch (error) {
+ if (axios.isAxiosError(error) && isRequestErrorStatusCode(error, HttpCode.ClientErrorBadRequest)) {
+ const defaultError = { code: ApiErrorCode.UNKNOWN, details: {} };
+ const { code, details } = error.response?.data?.error ?? defaultError;
+ if (code === ApiErrorCode.VALIDATION_FAILED) {
+ this.validationErrors = { ...details };
+ this.$refs.page.scrollToTop();
+ return;
+ }
+ }
+
+ this.$toasted.error(__('errors.unexpected-while-saving'));
+ } finally {
+ this.isSaving = false;
+ }
+ },
+ },
+ render() {
+ const {
+ criticalError,
+ isFetched,
+ pageTitle,
+ validationErrors,
+ attribute,
+ isSaving,
+ handleSubmit,
+ handleCancel,
+ } = this;
+
+ if (criticalError || !isFetched) {
+ return (
+
+ {criticalError ? : }
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+ },
+});
+
+export default AttributeEditPage;
diff --git a/client/src/themes/default/pages/Attribute/translations/en.yml b/client/src/themes/default/pages/Attribute/translations/en.yml
new file mode 100644
index 000000000..a3fd9799b
--- /dev/null
+++ b/client/src/themes/default/pages/Attribute/translations/en.yml
@@ -0,0 +1,27 @@
+title: Modify attribute "{name}"
+title-simple: Modify attribute
+title-create: New attribute
+
+name: Name of the attribute
+type: Attribute type
+type-not-modifiable: The data type cannot be changed.
+unit: displayed unit
+max-length: Maximum text length
+is-totalisable: "Is totalisable\_?"
+totalisable-help: Allows the attribute to be used in the totals under the material list of release sheets.
+limit-to-categories: "Limit attribute to categories:"
+limit-to-categories-help: If no category is selected, there will be no limitation.
+
+type-string: Text
+type-integer: Integer number
+type-float: Decimal number
+type-boolean: Boolean (Yes/No)
+type-date: Date
+
+confirm-update-categories: >-
+ Some categories have been removed. If you save these changes, the values of
+ this attribute for the material in these categories will be removed. Do you
+ really want to continue?
+yes-update: Yes, update the attribute
+
+saved: Attribute saved.
diff --git a/client/src/themes/default/pages/Attribute/translations/fr.yml b/client/src/themes/default/pages/Attribute/translations/fr.yml
new file mode 100644
index 000000000..21402ef9c
--- /dev/null
+++ b/client/src/themes/default/pages/Attribute/translations/fr.yml
@@ -0,0 +1,27 @@
+title: "Modifier la caractéristique «\_{name}\_»"
+title-simple: Modifier la caractéristique
+title-create: Nouvelle caractéristique
+
+name: Nom de la caractéristique
+type: Type de donnée
+type-not-modifiable: Le type de donnée ne peut pas être modifié.
+unit: Unité à afficher
+max-length: Taille maximum du texte
+is-totalisable: "Est totalisable\_?"
+totalisable-help: Permet d'utiliser la caractéristique dans les totaux sous la liste du matériel des fiches de sortie.
+limit-to-categories: "Limiter la caractéristique aux catégories\_:"
+limit-to-categories-help: Si aucune catégorie n'est sélectionnée, il n'y aura pas de limitation.
+
+type-string: Texte
+type-integer: Nombre entier
+type-float: Nombre décimal
+type-boolean: Booléen (Oui / Non)
+type-date: Date
+
+confirm-update-categories: >-
+ Certaines catégories ont été enlevées. Si vous sauvegardez ces modifications, les valeurs
+ de cette caractéristique pour le matériel de ces catégories seront supprimées. Souhaitez-vous
+ vraiment continuer ?
+yes-update: Oui, mettre à jour la caractéristique
+
+saved: Caractéristique sauvegardée.
diff --git a/client/src/themes/default/pages/Attributes/components/AddItem/Form/index.js b/client/src/themes/default/pages/Attributes/components/AddItem/Form/index.js
deleted file mode 100644
index 0f37d2fe4..000000000
--- a/client/src/themes/default/pages/Attributes/components/AddItem/Form/index.js
+++ /dev/null
@@ -1,200 +0,0 @@
-import './index.scss';
-import cloneDeep from 'lodash/cloneDeep';
-import Fragment from '@/components/Fragment';
-import Input from '@/themes/default/components/Input';
-import Select from '@/themes/default/components/Select';
-
-const DEFAULT_VALUES = Object.freeze({
- name: '',
- type: 'integer',
- unit: null,
- maxLength: null,
- categories: [],
-});
-
-// @vue/component
-export default {
- name: 'AttributesAddItemForm',
- props: {
- errors: { type: Object, default: null },
- },
- data: () => ({
- data: cloneDeep(DEFAULT_VALUES),
- }),
- computed: {
- categoriesOptions() {
- return this.$store.getters['categories/options'];
- },
-
- typesOptions() {
- const { $t: __ } = this;
-
- return [
- { value: 'integer', label: __('page.attributes.type-integer') },
- { value: 'float', label: __('page.attributes.type-float') },
- { value: 'date', label: __('page.attributes.type-date') },
- { value: 'string', label: __('page.attributes.type-string') },
- { value: 'boolean', label: __('page.attributes.type-boolean') },
- ];
- },
-
- hasMaxLength() {
- const { type } = this.data;
- return type === 'string';
- },
-
- hasUnit() {
- const { type } = this.data;
- return ['integer', 'float'].includes(type);
- },
- },
- mounted() {
- this.$store.dispatch('categories/fetch');
- },
- methods: {
- // ------------------------------------------------------
- // -
- // - Handlers
- // -
- // ------------------------------------------------------
-
- handleToggleCategory(categoryId) {
- return () => {
- const { categories } = this.data;
-
- const foundIndex = categories.indexOf(categoryId);
- if (foundIndex === -1) {
- categories.push(categoryId);
- return;
- }
- categories.splice(foundIndex, 1);
- };
- },
-
- // ------------------------------------------------------
- // -
- // - Public API
- // -
- // ------------------------------------------------------
-
- focus() {
- this.$refs.nameInput.focus();
- },
-
- getValues() {
- const { data: rawData, hasUnit, hasMaxLength } = this;
-
- const data = cloneDeep(rawData);
-
- if (!hasMaxLength) {
- delete data.maxLength;
- }
-
- if (!hasUnit) {
- delete data.unit;
- }
-
- return data;
- },
-
- reset() {
- this.data = cloneDeep(DEFAULT_VALUES);
- },
- },
- render() {
- const {
- $t: __,
- data,
- errors: validationErrors,
- categoriesOptions,
- typesOptions,
- hasUnit,
- hasMaxLength,
- handleToggleCategory,
- } = this;
-
- return (
-
- );
- },
-};
diff --git a/client/src/themes/default/pages/Attributes/components/AddItem/Form/index.scss b/client/src/themes/default/pages/Attributes/components/AddItem/Form/index.scss
deleted file mode 100644
index 9610aac12..000000000
--- a/client/src/themes/default/pages/Attributes/components/AddItem/Form/index.scss
+++ /dev/null
@@ -1,85 +0,0 @@
-@use '~@/themes/default/style/globals';
-@use 'sass:color';
-
-.AttributesAddItemForm {
- width: 100%;
- min-width: 800px;
- margin: 0;
- background-color: globals.$bg-color-table-td;
- color: globals.$text-base-color;
- border-collapse: collapse;
-
- &__name,
- &__type,
- &__unit,
- &__max-length,
- &__categories {
- padding: 0.8rem 0.5rem;
- vertical-align: top;
- }
-
- &__name {
- width: 300px;
- }
-
- &__type {
- width: 150px;
- }
-
- &__unit,
- &__max-length {
- width: 120px;
- text-align: center;
- }
-
- &__max-length {
- display: none;
-
- @media (min-width: globals.$screen-big-desktop) {
- display: table-cell;
- }
- }
-
- &__categories {
- width: auto;
-
- &__item {
- display: inline-block;
- margin-top: 4px;
- padding: 0.55rem 1.1rem;
- border-radius: 15px;
- background-color: color.scale(globals.$bg-color-input-normal, $lightness: 3%);
- color: globals.$text-light-color;
- cursor: pointer;
- transition: all 300ms;
-
- &--selected,
- &--selected:hover,
- &--selected:focus {
- background-color: globals.$bg-color-button-info;
- color: #fff;
- }
-
- & + & {
- margin-left: 8px;
- }
- }
- }
-
- &__input,
- &__select {
- width: 100%;
- }
-
- &__error {
- margin: 5px 0 0;
- padding: 0;
- color: globals.$text-danger-color;
- word-break: break-all;
-
- // stylelint-disable-next-line selector-max-type
- li {
- list-style: inside;
- }
- }
-}
diff --git a/client/src/themes/default/pages/Attributes/components/AddItem/index.js b/client/src/themes/default/pages/Attributes/components/AddItem/index.js
deleted file mode 100644
index 55ac54291..000000000
--- a/client/src/themes/default/pages/Attributes/components/AddItem/index.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import './index.scss';
-import apiAttributes from '@/stores/api/attributes';
-import Button from '@/themes/default/components/Button';
-import Form from './Form';
-import { ApiErrorCode } from '@/stores/api/@codes';
-
-// @vue/component
-export default {
- name: 'AttributesAdd',
- data() {
- return {
- isSaving: false,
- isCancelled: false,
- validationErrors: null,
- };
- },
- methods: {
- // ------------------------------------------------------
- // -
- // - Public API
- // -
- // ------------------------------------------------------
-
- focus() {
- this.$refs.container.scrollIntoView();
- this.$refs.form.focus();
- },
-
- // ------------------------------------------------------
- // -
- // - Handlers
- // -
- // ------------------------------------------------------
-
- handleSave(e) {
- e.preventDefault();
-
- this.save();
- },
-
- handleCancel() {
- if (this.isSaving) {
- return;
- }
-
- this.isCancelled = true;
- this.validationErrors = null;
- this.$refs.form.reset();
-
- this.$emit('cancelled');
- },
-
- // ------------------------------------------------------
- // -
- // - Internal methods
- // -
- // ------------------------------------------------------
-
- async save() {
- if (this.isCancelled || this.isSaving) {
- return;
- }
-
- const { $t: __ } = this;
-
- this.validationErrors = null;
- this.isSaving = true;
-
- try {
- const data = this.$refs.form.getValues();
- const attribute = await apiAttributes.create(data);
-
- this.$refs.form.reset();
- this.$emit('finished', attribute);
- } catch (error) {
- const { code, details } = error.response?.data?.error || { code: ApiErrorCode.UNKNOWN, details: {} };
- if (code === ApiErrorCode.VALIDATION_FAILED) {
- this.validationErrors = { ...details };
- } else {
- this.$toasted.error(__('errors.unexpected-while-saving'));
- }
- } finally {
- this.isSaving = false;
- }
- },
- },
- render() {
- const {
- $t: __,
- isSaving,
- isCancelled,
- handleSave,
- handleCancel,
- validationErrors,
- } = this;
-
- if (isCancelled) {
- return null;
- }
-
- return (
-
-
-
- {__('save')}
-
-
- {__('cancel')}
-
-
-
- );
- },
-};
diff --git a/client/src/themes/default/pages/Attributes/components/AddItem/index.scss b/client/src/themes/default/pages/Attributes/components/AddItem/index.scss
deleted file mode 100644
index bbfcfdc6f..000000000
--- a/client/src/themes/default/pages/Attributes/components/AddItem/index.scss
+++ /dev/null
@@ -1,7 +0,0 @@
-.AttributesAdd {
- &__actions {
- display: flex;
- justify-content: flex-end;
- margin-top: 1rem;
- }
-}
diff --git a/client/src/themes/default/pages/Attributes/components/Item/index.js b/client/src/themes/default/pages/Attributes/components/Item/index.js
index 0290c15b1..9f014a5ea 100644
--- a/client/src/themes/default/pages/Attributes/components/Item/index.js
+++ b/client/src/themes/default/pages/Attributes/components/Item/index.js
@@ -1,10 +1,7 @@
import './index.scss';
-import Fragment from '@/components/Fragment';
import { confirm } from '@/utils/alert';
import apiAttributes from '@/stores/api/attributes';
-import Input from '@/themes/default/components/Input';
import Button from '@/themes/default/components/Button';
-import { ApiErrorCode } from '@/stores/api/@codes';
// @vue/component
export default {
@@ -14,7 +11,6 @@ export default {
},
data() {
return {
- isSaving: false,
isEditing: false,
isDeleting: false,
isDeleted: false,
@@ -26,7 +22,7 @@ export default {
formattedMaxLength() {
const { $t: __, attribute } = this;
- if (attribute.maxLength != null) {
+ if (![undefined, null].includes(attribute.maxLength)) {
return attribute.maxLength;
}
@@ -34,75 +30,33 @@ export default {
? __('page.attributes.no-limit')
: '';
},
- },
- methods: {
- // ------------------------------------------------------
- // -
- // - Handlers
- // -
- // ------------------------------------------------------
- handleSave(e) {
- e.preventDefault();
+ formattedTotalisable() {
+ const { $t: __, attribute } = this;
- this.save();
- },
+ if (!['integer', 'float'].includes(attribute.type)) {
+ return null;
+ }
- handleDelete() {
- this.delete();
+ return attribute.isTotalisable ? __('yes') : __('no');
},
- handleStartEditing() {
- this.newName = this.attribute.name;
- this.isEditing = true;
- },
+ formattedCategories() {
+ const { $t: __, attribute: { categories } } = this;
- handleCancelEditing() {
- if (this.isSaving) {
- return;
- }
-
- this.isEditing = false;
- this.newName = null;
- this.validationErrors = null;
+ return categories.length > 0
+ ? categories.map(({ name: _name }) => _name).join(', ')
+ : __('page.attributes.all-categories-not-limited');
},
-
+ },
+ methods: {
// ------------------------------------------------------
// -
- // - Internal methods
+ // - Handlers
// -
// ------------------------------------------------------
- async save() {
- if (this.isDeleting || this.isDeleted || this.isSaving) {
- return;
- }
-
- const { $t: __, attribute: { id } } = this;
-
- this.isSaving = true;
- this.validationErrors = null;
-
- try {
- const attribute = await apiAttributes.update(id, { name: this.newName });
-
- this.newName = null;
- this.isEditing = false;
-
- this.$emit('updated', attribute);
- } catch (error) {
- const { code, details } = error.response?.data?.error || { code: ApiErrorCode.UNKNOWN, details: {} };
- if (code === ApiErrorCode.VALIDATION_FAILED) {
- this.validationErrors = { ...details };
- } else {
- this.$toasted.error(__('errors.unexpected-while-saving'));
- }
- } finally {
- this.isSaving = false;
- }
- },
-
- async delete() {
+ async handleDelete() {
if (this.isDeleting || this.isDeleted) {
return;
}
@@ -139,85 +93,28 @@ export default {
const {
$t: __,
isDeleted,
- isSaving,
- isEditing,
- isDeleting,
- handleSave,
- handleDelete,
- handleStartEditing,
- handleCancelEditing,
- validationErrors,
- formattedMaxLength,
attribute: {
+ id,
name,
type,
unit,
categories,
},
+ formattedTotalisable,
+ formattedMaxLength,
+ formattedCategories,
+ handleDelete,
+ isDeleting,
} = this;
- if (isDeleted || isDeleting) {
+ if (isDeleted) {
return null;
}
- const renderName = () => {
- if (isEditing) {
- return (
-
- );
- }
-
- return (
-
- {name}
- {!isDeleting && (
-
- )}
-
- );
- };
-
- const renderCategories = () => (
- categories.length > 0
- ? categories.map(({ name: _name }) => _name).join(', ')
- : {__('all-categories')} ({__('not-limited')})
- );
-
return (
- {renderName()}
+ {name}
|
{__(`page.attributes.type-${type}`)}
@@ -225,6 +122,9 @@ export default {
|
{unit}
|
+
+ {formattedTotalisable}
+ |
{formattedMaxLength}
|
@@ -233,20 +133,18 @@ export default {
'AttributesItem__cell--empty': categories.length === 0,
}]}
>
- {renderCategories()}
+ {formattedCategories}
- {!isEditing && (
-
- )}
+
+
|
);
diff --git a/client/src/themes/default/pages/Attributes/components/Item/index.scss b/client/src/themes/default/pages/Attributes/components/Item/index.scss
index 662efe86d..d7725004b 100644
--- a/client/src/themes/default/pages/Attributes/components/Item/index.scss
+++ b/client/src/themes/default/pages/Attributes/components/Item/index.scss
@@ -4,7 +4,6 @@
$block: &;
&__cell {
- height: 60px;
padding: 0.8rem 0.5rem;
border-bottom: 1px solid globals.$bg-color-body;
@@ -21,7 +20,8 @@
font-weight: 700;
}
- &--unit {
+ &--unit,
+ &--is-totalisable {
text-align: center;
}
@@ -33,6 +33,11 @@
display: table-cell;
}
}
+
+ &--actions {
+ width: 1%;
+ white-space: nowrap;
+ }
}
&__name {
diff --git a/client/src/themes/default/pages/Attributes/index.js b/client/src/themes/default/pages/Attributes/index.js
index ca94ac978..c293a09a4 100644
--- a/client/src/themes/default/pages/Attributes/index.js
+++ b/client/src/themes/default/pages/Attributes/index.js
@@ -1,21 +1,19 @@
import './index.scss';
+import { defineComponent } from '@vue/composition-api';
import apiAttributes from '@/stores/api/attributes';
import Page from '@/themes/default/components/Page';
import CriticalError from '@/themes/default/components/CriticalError';
import Loading from '@/themes/default/components/Loading';
import Button from '@/themes/default/components/Button';
import Item from './components/Item';
-import AddItem from './components/AddItem';
// @vue/component
-export default {
+const Attributes = defineComponent({
name: 'Attributes',
data() {
return {
attributes: [],
- isAdding: false,
isFetched: false,
- isLoading: false,
hasCriticalError: false,
};
},
@@ -29,31 +27,6 @@ export default {
// -
// ------------------------------------------------------
- handleAddItem() {
- if (this.isAdding) {
- return;
- }
-
- this.isAdding = true;
- this.$nextTick(() => this.$refs.addItem.focus());
- },
-
- handleItemAdded(attribute) {
- this.isAdding = false;
- this.attributes.push(attribute);
- },
-
- handleItemUpdated(attribute) {
- const index = this.attributes.findIndex(
- ({ id }) => id === attribute.id,
- );
- if (index === -1) {
- this.fetchData();
- return;
- }
- this.$set(this.attributes, index, attribute);
- },
-
handleItemDeleted(attribute) {
const index = this.attributes.findIndex(
({ id }) => id === attribute.id,
@@ -65,26 +38,19 @@ export default {
this.attributes.splice(index, 1);
},
- handleCancelAdding() {
- this.isAdding = false;
- },
-
// ------------------------------------------------------
// -
- // - Internal methods
+ // - Méthodes internes
// -
// ------------------------------------------------------
async fetchData() {
- this.isLoading = true;
-
try {
this.attributes = await apiAttributes.all();
- this.isFetched = true;
} catch {
this.hasCriticalError = true;
} finally {
- this.isLoading = false;
+ this.isFetched = true;
}
},
},
@@ -95,12 +61,7 @@ export default {
hasCriticalError,
isAdding,
isFetched,
- isLoading,
- handleAddItem,
- handleItemAdded,
- handleItemUpdated,
handleItemDeleted,
- handleCancelAdding,
} = this;
if (hasCriticalError || !isFetched) {
@@ -117,7 +78,7 @@ export default {
{__('page.attributes.add-btn')}
,
@@ -128,7 +89,6 @@ export default {
name="attributes"
title={__('page.attributes.title')}
help={__('page.attributes.help')}
- isLoading={isLoading}
actions={headerActions}
>
@@ -144,6 +104,9 @@ export default {
{__('page.attributes.unit')}
|
+
+ {__('page.attributes.is-totalisable')}
+ |
{__('page.attributes.max-length')}
|
@@ -158,7 +121,6 @@ export default {
))}
@@ -169,15 +131,10 @@ export default {
{__('page.attributes.no-attribute-yet')}
)}
- {isAdding && (
-
- )}
);
},
-};
+});
+
+export default Attributes;
diff --git a/client/src/themes/default/pages/Attributes/index.scss b/client/src/themes/default/pages/Attributes/index.scss
index a84c5e02b..7462226d2 100644
--- a/client/src/themes/default/pages/Attributes/index.scss
+++ b/client/src/themes/default/pages/Attributes/index.scss
@@ -40,7 +40,8 @@
}
&--unit,
- &--max-length {
+ &--max-length,
+ &--is-totalisable {
width: 120px;
text-align: center;
}
@@ -52,11 +53,6 @@
display: table-cell;
}
}
-
- &--actions {
- width: 1%;
- white-space: nowrap;
- }
}
}
diff --git a/client/src/themes/default/pages/Attributes/translations/en.yml b/client/src/themes/default/pages/Attributes/translations/en.yml
index 2b52df870..296c3dd20 100644
--- a/client/src/themes/default/pages/Attributes/translations/en.yml
+++ b/client/src/themes/default/pages/Attributes/translations/en.yml
@@ -1,23 +1,24 @@
title: Material special attributes
-help: |-
- Here you can add fields that allows you to describe your material according to your own criteria.
- Once created, a special attribute cannot be modified (except for its name).
+help: The special attributes allow you to describe your material according to your own criteria.
-go-back-to-material: Back to material
name: Name of the attribute
type: Attribute type
unit: Unit
max-length: Max. length
-type-string: Text
-type-integer: Integer number
-type-float: Decimal number
-type-boolean: Boolean (Yes/No)
-type-date: Date
+is-totalisable: "Totalisable\_?"
+
no-limit: No limit
add-attributes: Add attributes
no-attribute-yet: No attribute yet.
add-btn: Add an attribute
limited-to-categories: Limited to categories
+all-categories-not-limited: All categories (not limited)
+
+type-string: Text
+type-integer: Integer number
+type-float: Decimal number
+type-boolean: Boolean (Yes/No)
+type-date: Date
confirm-permanently-delete:
'1': |-
diff --git a/client/src/themes/default/pages/Attributes/translations/fr.yml b/client/src/themes/default/pages/Attributes/translations/fr.yml
index 448072b3e..8d341d1aa 100644
--- a/client/src/themes/default/pages/Attributes/translations/fr.yml
+++ b/client/src/themes/default/pages/Attributes/translations/fr.yml
@@ -1,23 +1,24 @@
title: Caractéristiques spéciales du matériel
-help: |-
- Ici vous pouvez ajouter les champs qui permettent de décrire votre matériel selon vos propres critères.
- Une fois créée, une caractéristique spéciale ne pourra plus être modifiée (sauf son nom).
+help: Les caractéristiques spéciales permettent de décrire votre matériel selon vos propres critères.
-go-back-to-material: Retourner au matériel
name: Nom de la caractéristique
type: Type de donnée
unit: Unité
max-length: Taille max.
-type-string: Texte
-type-integer: Nombre entier
-type-float: Nombre décimal
-type-boolean: Booléen (Oui / Non)
-type-date: Date
+is-totalisable: "Totalisable\_?"
+
no-limit: Sans limite
add-attributes: Ajouter des caractéristiques
no-attribute-yet: Aucune caractéristique spéciale pour le moment.
add-btn: Ajouter une caractéristique
limited-to-categories: Limitée aux catégories
+all-categories-not-limited: Toutes catégories (non limité)
+
+type-string: Texte
+type-integer: Nombre entier
+type-float: Nombre décimal
+type-boolean: Booléen (Oui / Non)
+type-date: Date
confirm-permanently-delete:
'1': "Voulez-vous vraiment supprimer définitivement cette caractéristique spéciale\_?\n\n ATTENTION\_: Toutes les données relatives à cette caractéristique spéciale seront supprimées DÉFINITIVEMENT\_!!"
diff --git a/client/src/themes/default/pages/Beneficiaries/index.js b/client/src/themes/default/pages/Beneficiaries/index.js
index 86637c538..2ba3c69ec 100644
--- a/client/src/themes/default/pages/Beneficiaries/index.js
+++ b/client/src/themes/default/pages/Beneficiaries/index.js
@@ -1,4 +1,6 @@
import './index.scss';
+import HttpCode from 'status-code-enum';
+import { isRequestErrorStatusCode } from '@/utils/errors';
import Fragment from '@/components/Fragment';
import Page from '@/themes/default/components/Page';
import CriticalError from '@/themes/default/components/CriticalError';
@@ -200,7 +202,6 @@ export default {
handleShowTrashed() {
this.shouldDisplayTrashed = !this.shouldDisplayTrashed;
this.$refs.table.setPage(1);
- this.$refs.table.refresh();
},
// ------------------------------------------------------
@@ -219,7 +220,14 @@ export default {
});
this.isTrashDisplayed = this.shouldDisplayTrashed;
return data;
- } catch {
+ } catch (error) {
+ if (isRequestErrorStatusCode(error, HttpCode.ClientErrorRangeNotSatisfiable)) {
+ this.$refs.table.setPage(1);
+ return undefined;
+ }
+
+ // eslint-disable-next-line no-console
+ console.error(`Error ocurred while retrieving beneficiaries:`, error);
this.hasCriticalError = true;
} finally {
this.isLoading = false;
diff --git a/client/src/themes/default/pages/Beneficiary/components/Form/CompanySelect/index.js b/client/src/themes/default/pages/Beneficiary/components/Form/CompanySelect/index.js
index 6b478749f..4b10c6e0f 100644
--- a/client/src/themes/default/pages/Beneficiary/components/Form/CompanySelect/index.js
+++ b/client/src/themes/default/pages/Beneficiary/components/Form/CompanySelect/index.js
@@ -1,6 +1,6 @@
import './index.scss';
import VueSelect from 'vue-select';
-import { debounce } from 'debounce';
+import debounce from 'lodash/debounce';
import { DEBOUNCE_WAIT } from '@/globals/constants';
import formatOptions from '@/utils/formatOptions';
import Button from '@/themes/default/components/Button';
@@ -30,8 +30,8 @@ export default {
created() {
this.debouncedSearch = debounce(this.search.bind(this), DEBOUNCE_WAIT);
},
- beforeUnmount() {
- this.debouncedSearch.clear();
+ beforeDestroy() {
+ this.debouncedSearch.cancel();
},
methods: {
async search(loading, search) {
diff --git a/client/src/themes/default/pages/Calendar/components/Caption/index.scss b/client/src/themes/default/pages/Calendar/components/Caption/index.scss
index 2eb9ef6d4..9c0af4ce1 100644
--- a/client/src/themes/default/pages/Calendar/components/Caption/index.scss
+++ b/client/src/themes/default/pages/Calendar/components/Caption/index.scss
@@ -23,7 +23,7 @@
padding: 7px 10px;
border-radius: 5px;
background-color: globals.$calendar-event-normal-color;
- color: globals.$calendar-event-text-color;
+ color: globals.$calendar-event-normal-text-color;
font-size: 0.9rem;
box-shadow: 1px 2px 3px rgba(#000, 0.5);
}
diff --git a/client/src/themes/default/pages/Calendar/components/Header/index.js b/client/src/themes/default/pages/Calendar/components/Header/index.js
index e98dbb4f1..0b03fcec7 100644
--- a/client/src/themes/default/pages/Calendar/components/Header/index.js
+++ b/client/src/themes/default/pages/Calendar/components/Header/index.js
@@ -1,4 +1,5 @@
import './index.scss';
+import { defineComponent } from '@vue/composition-api';
import moment from 'moment';
import Button from '@/themes/default/components/Button';
import Datepicker from '@/themes/default/components/Datepicker';
@@ -8,11 +9,18 @@ import Loading from '@/themes/default/components/Loading';
import { Group } from '@/stores/api/groups';
// @vue/component
-export default {
+const CalendarHeader = defineComponent({
name: 'CalendarHeader',
props: {
isLoading: Boolean,
},
+ emits: [
+ 'refresh',
+ 'setCenterDate',
+ 'setCenterDate',
+ 'filterByPark',
+ 'filterMissingMaterials',
+ ],
data() {
return {
centerDate: null,
@@ -35,7 +43,7 @@ export default {
return this.$store.getters['auth/is']([Group.ADMIN, Group.MEMBER]);
},
},
- mounted() {
+ created() {
this.$store.dispatch('parks/fetch');
},
methods: {
@@ -139,7 +147,7 @@ export default {
{isLoading && }
);
},
-};
+});
+
+export default CalendarHeader;
diff --git a/client/src/themes/default/pages/Calendar/index.js b/client/src/themes/default/pages/Calendar/index.js
index 724841464..86269d875 100644
--- a/client/src/themes/default/pages/Calendar/index.js
+++ b/client/src/themes/default/pages/Calendar/index.js
@@ -3,7 +3,6 @@ import moment from 'moment';
import HttpCode from 'status-code-enum';
import { Group } from '@/stores/api/groups';
import { DATE_DB_FORMAT } from '@/globals/constants';
-import queryClient from '@/globals/queryClient';
import apiBookings, { BookingEntity } from '@/stores/api/bookings';
import apiEvents from '@/stores/api/events';
import EventDetails from '@/themes/default/modals/EventDetails';
@@ -109,7 +108,7 @@ export default {
// - Actualise le timestamp courant toutes les minutes.
this.nowTimer = setInterval(() => { this.now = Date.now(); }, 60_000);
},
- beforeUnmount() {
+ beforeDestroy() {
if (this.nowTimer) {
clearInterval(this.nowTimer);
}
@@ -184,8 +183,8 @@ export default {
const {
isTeamMember,
- handleUpdateBooking,
- handleDuplicateBooking,
+ handleUpdatedBooking,
+ handleDuplicatedBooking,
} = this;
// - Si c'est un double-clic sur une partie vide (= sans booking) du calendrier...
@@ -232,11 +231,11 @@ export default {
case BookingEntity.EVENT:
_showModal(EventDetails, {
id: booking.id,
- onUpdateEvent(event) {
- handleUpdateBooking({ entity: BookingEntity.EVENT, ...event });
+ onUpdated(event) {
+ handleUpdatedBooking({ entity: BookingEntity.EVENT, ...event });
},
- onDuplicateEvent(event) {
- handleDuplicateBooking({ entity: BookingEntity.EVENT, ...event });
+ onDuplicateEvent(newEvent) {
+ handleDuplicatedBooking({ entity: BookingEntity.EVENT, ...newEvent });
},
});
break;
@@ -278,7 +277,6 @@ export default {
callback(item);
this.$toasted.success(__('page.calendar.event-saved'));
- queryClient.invalidateQueries('materials-while-event');
this.fetchData();
} catch {
this.$toasted.error(__('errors.unexpected-while-saving'));
@@ -290,9 +288,7 @@ export default {
}
},
- handleUpdateBooking(booking) {
- queryClient.invalidateQueries('materials-while-event');
-
+ handleUpdatedBooking(booking) {
const originalBookingIndex = this.bookings.findIndex(
({ entity, id }) => entity === booking.entity && id === booking.id,
);
@@ -303,7 +299,7 @@ export default {
this.$set(this.bookings, originalBookingIndex, booking);
},
- handleDuplicateBooking(booking) {
+ handleDuplicatedBooking(booking) {
const date = moment(booking.start_date).toDate();
this.$refs.timelineRef.moveTo(date);
},
diff --git a/client/src/themes/default/pages/Company/index.js b/client/src/themes/default/pages/Company/index.js
index fa5d207e0..72bc29716 100644
--- a/client/src/themes/default/pages/Company/index.js
+++ b/client/src/themes/default/pages/Company/index.js
@@ -61,7 +61,7 @@ export default {
// ------------------------------------------------------
// -
- // - Internal methods
+ // - Méthodes internes
// -
// ------------------------------------------------------
diff --git a/client/src/themes/default/pages/Event/index.js b/client/src/themes/default/pages/Event/index.js
index 7adccc178..891e14cef 100644
--- a/client/src/themes/default/pages/Event/index.js
+++ b/client/src/themes/default/pages/Event/index.js
@@ -203,6 +203,7 @@ const EventPage = defineComponent({
event={event}
onError={handleError}
onUpdateEvent={handleUpdateEvent}
+ onGotoStep={handleOpenStep}
/>
);
default:
@@ -211,13 +212,9 @@ const EventPage = defineComponent({
};
return (
-
+
-
+
);
diff --git a/client/src/themes/default/pages/Event/index.scss b/client/src/themes/default/pages/Event/index.scss
index 5fbb34e55..fc2c3ffd6 100644
--- a/client/src/themes/default/pages/Event/index.scss
+++ b/client/src/themes/default/pages/Event/index.scss
@@ -5,8 +5,9 @@
flex-wrap: wrap;
height: 100%;
- &__panel {
+ &__sidebar {
position: fixed;
+ display: none;
width: 160px;
padding: 7px 0;
text-align: right;
@@ -30,43 +31,37 @@
}
}
+ &__body {
+ flex: 1;
+ overflow: auto;
+ padding-top: 10px;
+ }
+
.EventMiniSummary {
width: 100%;
margin-top: 1rem;
}
- .EventStep1,
- .EventStep2,
- .EventStep3,
- .EventStep4,
- .EventStep5 {
- width: 100%;
- max-width: 600px;
- min-width: 250px;
- margin-left: 180px;
- }
+ //
+ // - Responsive
+ //
- .EventStep3,
- .EventStep4 {
- max-width: none;
- }
+ @media (min-width: globals.$screen-tablet) {
+ &__sidebar {
+ display: block;
+ }
- .EventStep5 {
- max-width: 900px;
+ &__body {
+ margin-left: 180px;
+ }
}
-}
-@media (min-width: globals.$screen-desktop) {
- .Event {
- &__panel {
+ @media (min-width: globals.$screen-desktop) {
+ &__sidebar {
width: 200px;
}
- .EventStep1,
- .EventStep2,
- .EventStep3,
- .EventStep4,
- .EventStep5 {
+ &__body {
margin-left: 220px;
}
}
diff --git a/client/src/themes/default/pages/Event/steps/1/index.js b/client/src/themes/default/pages/Event/steps/1/index.js
index c56f6c795..95b466dd0 100644
--- a/client/src/themes/default/pages/Event/steps/1/index.js
+++ b/client/src/themes/default/pages/Event/steps/1/index.js
@@ -6,157 +6,129 @@ import { DATE_DB_FORMAT } from '@/globals/constants';
import apiEvents from '@/stores/api/events';
import FormField from '@/themes/default/components/FormField';
import Fieldset from '@/themes/default/components/Fieldset';
-import EventStore from '../../EventStore';
import { ApiErrorCode } from '@/stores/api/@codes';
+import getCSSProperty from '@/utils/getCSSProperty';
+import EventStore from '../../EventStore';
+
+const DEFAULT_VALUES = Object.freeze({
+ title: '',
+ start_date: null,
+ end_date: null,
+ location: '',
+ description: '',
+ color: null,
+ is_billable: config.billingMode !== 'none',
+ is_confirmed: false,
+});
// @vue/component
export default {
name: 'EventStep1',
- components: { FormField, Fieldset },
+ provide: {
+ verticalForm: true,
+ },
props: {
event: { type: Object, required: true },
},
data() {
return {
- datepickerOptions: {
- disabled: { from: null, to: null },
- range: true,
- },
- dates: null,
- duration: 0,
- showIsBillable: config.billingMode === 'partial',
- eventData: {
- title: '',
- start_date: '',
- end_date: '',
- location: '',
- description: '',
- is_billable: config.billingMode !== 'none',
- is_confirmed: false,
- },
- errors: {
- title: null,
- start_date: null,
- end_date: null,
- location: null,
- description: null,
+ data: {
+ ...DEFAULT_VALUES,
+ ...pick(this.event ?? {}, Object.keys(DEFAULT_VALUES)),
},
+ errors: null,
};
},
computed: {
isNew() {
return this.event.id === null;
},
- },
- watch: {
- event() {
- this.initValuesFromEvent();
- this.initDatesFromEvent();
- this.calcDuration();
- this.checkIsSavedEvent();
- },
- },
- mounted() {
- this.initValuesFromEvent();
- this.initDatesFromEvent();
- this.calcDuration();
- },
- methods: {
- initValuesFromEvent() {
- if (!this.event) {
- return;
- }
- this.eventData = {
- title: this.event.title || '',
- start_date: this.event.start_date || '',
- end_date: this.event.end_date || '',
- location: this.event.location || '',
- description: this.event.description || '',
- is_billable: this.event.is_billable,
- is_confirmed: this.event.is_confirmed,
- };
+ allowBillingToggling() {
+ return config.billingMode === 'partial';
},
- initDatesFromEvent() {
- if (this.dates) {
- return;
- }
-
- const {
- start_date: startDate = null,
- end_date: endDate = null,
- } = this.event;
+ dates() {
+ const { data } = this;
+ return [data.start_date, data.end_date];
+ },
+ duration() {
+ const [startDate, endDate] = this.dates;
if (!startDate || !endDate) {
- return;
+ return 0;
}
-
- this.dates = [startDate, endDate];
+ return moment(endDate).diff(startDate, 'days') + 1;
},
- setEventDates() {
- const [startDate, endDate] = this.dates;
-
- this.eventData.start_date = startDate;
- this.eventData.end_date = endDate;
+ defaultColor() {
+ return getCSSProperty('calendar-event-default-color');
+ },
+ datepickerOptions: () => ({
+ range: true,
+ disabled: { from: null, to: null },
+ }),
+ },
+ watch: {
+ event() {
+ this.setValuesFromEvent();
this.checkIsSavedEvent();
- this.calcDuration();
},
+ },
+ methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
- calcDuration() {
- if (!this.dates) {
- return;
- }
-
- const [startDate, endDate] = this.dates;
- if (startDate && endDate) {
- this.duration = moment(endDate).diff(startDate, 'days') + 1;
- }
+ handleBasicChange() {
+ this.checkIsSavedEvent();
},
- checkIsSavedEvent() {
- EventStore.dispatch('checkIsSaved', { ...this.event || {}, ...this.eventData });
+ handleDatesChange([startDate, endDate]) {
+ this.data.start_date = startDate;
+ this.data.end_date = endDate;
+ this.checkIsSavedEvent();
},
- saveAndBack(e) {
+ handleSubmit(e) {
e.preventDefault();
- this.save({ gotoStep: false });
- },
- saveAndNext(e) {
- e.preventDefault();
- this.save({ gotoStep: 2 });
+ this.save();
},
- displayError(error) {
- this.$emit('error', error);
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
- const { code, details } = error.response?.data?.error || { code: ApiErrorCode.UNKNOWN, details: {} };
- if (code === ApiErrorCode.VALIDATION_FAILED) {
- this.errors = { ...details };
+ setValuesFromEvent() {
+ if (!this.event) {
+ return;
}
+
+ this.data = {
+ ...DEFAULT_VALUES,
+ ...this.data,
+ ...pick(this.event ?? {}, Object.keys(DEFAULT_VALUES)),
+ };
+ },
+
+ checkIsSavedEvent() {
+ EventStore.dispatch('checkIsSaved', { ...this.event, ...this.data });
},
- async save(options) {
+ async save() {
this.$emit('loading');
const { isNew } = this;
- const saveData = pick(this.eventData, [
- 'title',
- 'start_date',
- 'end_date',
- 'location',
- 'description',
- 'is_billable',
- 'is_confirmed',
- ]);
-
const postData = {
- ...saveData,
- start_date: moment(this.eventData.start_date).startOf('day').format(DATE_DB_FORMAT),
- end_date: moment(this.eventData.end_date).endOf('day').format(DATE_DB_FORMAT),
+ ...this.data,
+ start_date: moment(this.data.start_date).startOf('day').format(DATE_DB_FORMAT),
+ end_date: moment(this.data.end_date).endOf('day').format(DATE_DB_FORMAT),
};
const doRequest = () => (
@@ -167,21 +139,115 @@ export default {
try {
const data = await doRequest();
-
- const { gotoStep } = options;
- if (!gotoStep) {
- this.$router.push('/');
- return;
- }
-
EventStore.commit('setIsSaved', true);
this.$emit('updateEvent', data);
- this.$emit('gotoStep', gotoStep);
+ this.$emit('gotoStep', 2);
} catch (error) {
- this.displayError(error);
+ this.$emit('error', error);
+
+ const { code, details } = error.response?.data?.error || { code: ApiErrorCode.UNKNOWN, details: {} };
+ if (code === ApiErrorCode.VALIDATION_FAILED) {
+ this.errors = { ...details };
+ }
} finally {
this.$emit('stopLoading');
}
},
},
+ render() {
+ const {
+ $t: __,
+ duration,
+ dates,
+ data,
+ errors,
+ defaultColor,
+ datepickerOptions,
+ allowBillingToggling,
+ handleBasicChange,
+ handleDatesChange,
+ handleSubmit,
+ } = this;
+
+ return (
+
+ );
+ },
};
diff --git a/client/src/themes/default/pages/Event/steps/1/index.scss b/client/src/themes/default/pages/Event/steps/1/index.scss
index b7a98bcfb..2379a710e 100644
--- a/client/src/themes/default/pages/Event/steps/1/index.scss
+++ b/client/src/themes/default/pages/Event/steps/1/index.scss
@@ -1,12 +1,15 @@
+@use '~@/themes/default/style/globals';
+
.EventStep1 {
- &__save-btn {
- margin-bottom: 1rem;
- }
+ max-width: 800px;
+ min-width: 250px;
+ margin: 0 auto;
&__dates {
display: flex;
- align-items: center;
+ align-items: flex-end;
margin-top: 1rem;
+ gap: 10px;
&__fields {
flex: 1;
@@ -14,26 +17,43 @@
&__duration {
flex: 0 0 150px;
- text-align: center;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 39px;
}
}
- &__location,
- &__description {
- --input-width: 500px;
- }
-
&__is-billable {
display: flex;
align-items: center;
margin-top: 1rem;
+ gap: 10px;
- .FormField {
+ &__input {
flex: 1;
}
+ }
- &__help {
- flex: 1;
+ &__color {
+ --input-width: 160px;
+ }
+
+ &__actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ padding: globals.$content-padding-large-vertical 0;
+ gap: 10px;
+ }
+
+ //
+ // - Responsive
+ //
+
+ @media (min-width: globals.$screen-tablet) {
+ &__actions {
+ padding-left: globals.$form-label-width;
}
}
}
diff --git a/client/src/themes/default/pages/Event/steps/1/index.vue b/client/src/themes/default/pages/Event/steps/1/index.vue
deleted file mode 100644
index 5d59d3ad8..000000000
--- a/client/src/themes/default/pages/Event/steps/1/index.vue
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
diff --git a/client/src/themes/default/pages/Event/steps/2/MultipleItem/index.js b/client/src/themes/default/pages/Event/steps/2/MultipleItem/index.js
index f4a838898..39ee6e3e3 100644
--- a/client/src/themes/default/pages/Event/steps/2/MultipleItem/index.js
+++ b/client/src/themes/default/pages/Event/steps/2/MultipleItem/index.js
@@ -1,6 +1,6 @@
import './index.scss';
import VueSelect from 'vue-select';
-import { debounce } from 'debounce';
+import debounce from 'lodash/debounce';
import formatOptions from '@/utils/formatOptions';
import { DEBOUNCE_WAIT } from '@/globals/constants';
import Button from '@/themes/default/components/Button';
@@ -48,8 +48,8 @@ export default {
created() {
this.debouncedSearch = debounce(this.search.bind(this), DEBOUNCE_WAIT);
},
- beforeUnmount() {
- this.debouncedSearch.clear();
+ beforeDestroy() {
+ this.debouncedSearch.cancel();
},
methods: {
handleSearch(searchTerm, loading) {
diff --git a/client/src/themes/default/pages/Event/steps/2/index.js b/client/src/themes/default/pages/Event/steps/2/index.js
index 4cea0bfaa..b78cf051d 100644
--- a/client/src/themes/default/pages/Event/steps/2/index.js
+++ b/client/src/themes/default/pages/Event/steps/2/index.js
@@ -22,6 +22,36 @@ export default {
EventStore.commit('setIsSaved', true);
},
methods: {
+ // ------------------------------------------------------
+ // -
+ // - Handlers
+ // -
+ // ------------------------------------------------------
+
+ handleSubmit(e) {
+ e.preventDefault();
+
+ this.saveAndGoToStep(3);
+ },
+
+ handlePrevClick(e) {
+ e.preventDefault();
+
+ this.saveAndGoToStep(1);
+ },
+
+ handleNextClick(e) {
+ e.preventDefault();
+
+ this.saveAndGoToStep(3);
+ },
+
+ // ------------------------------------------------------
+ // -
+ // - Méthodes internes
+ // -
+ // ------------------------------------------------------
+
updateItems(ids) {
this.beneficiariesIds = ids;
@@ -47,26 +77,7 @@ export default {
return label;
},
- saveAndBack(e) {
- e.preventDefault();
- this.save({ gotoStep: false });
- },
-
- saveAndNext(e) {
- e.preventDefault();
- this.save({ gotoStep: 3 });
- },
-
- displayError(error) {
- this.$emit('error', error);
-
- const { code, details } = error.response?.data?.error || { code: ApiErrorCode.UNKNOWN, details: {} };
- if (code === ApiErrorCode.VALIDATION_FAILED) {
- this.errors = { ...details };
- }
- },
-
- async save(options) {
+ async saveAndGoToStep(nextStep) {
this.$emit('loading');
const { id } = this.event;
@@ -74,18 +85,16 @@ export default {
try {
const data = await apiEvents.update(id, postData);
-
- const { gotoStep } = options;
- if (!gotoStep) {
- this.$router.push('/');
- return;
- }
-
EventStore.commit('setIsSaved', true);
this.$emit('updateEvent', data);
- this.$emit('gotoStep', gotoStep);
+ this.$emit('gotoStep', nextStep);
} catch (error) {
- this.displayError(error);
+ this.$emit('error', error);
+
+ const { code, details } = error.response?.data?.error || { code: ApiErrorCode.UNKNOWN, details: {} };
+ if (code === ApiErrorCode.VALIDATION_FAILED) {
+ this.errors = { ...details };
+ }
} finally {
this.$emit('stopLoading');
}
@@ -95,17 +104,25 @@ export default {
const {
$t: __,
event,
- saveAndNext,
- saveAndBack,
showBillingHelp,
updateItems,
getItemLabel,
+
+ handleSubmit,
+ handlePrevClick,
+ handleNextClick,
} = this;
return (
-
)}
-
-
- {__('page.event-edit.back-to-calendar')}
-
- {materials.length > 0 && beneficiaries.length > 0 && (
- // eslint-disable-next-line react/jsx-no-target-blank
-
- {__('print-summary')}
-
- )}
+
+
+
+ {__('page.event-edit.go-to-prev-step')}
+
+
+ {materials.length > 0 && beneficiaries.length > 0 && (
+ // eslint-disable-next-line react/jsx-no-target-blank
+
+ {__('print-summary')}
+
+ )}
+
+ {__('page.event-edit.back-to-calendar')}
+
+
);
diff --git a/client/src/themes/default/pages/Event/steps/5/index.scss b/client/src/themes/default/pages/Event/steps/5/index.scss
index 5494db4a8..89b97f337 100644
--- a/client/src/themes/default/pages/Event/steps/5/index.scss
+++ b/client/src/themes/default/pages/Event/steps/5/index.scss
@@ -3,6 +3,10 @@
.EventStep5 {
$spacing: 1.5rem;
+ max-width: 1000px;
+ min-width: 500px;
+ margin: 0 auto;
+
.EventStep5Overview {
margin-bottom: $spacing;
}
@@ -36,4 +40,9 @@
}
}
}
+
+ &__actions {
+ display: flex;
+ justify-content: space-between;
+ }
}
diff --git a/client/src/themes/default/pages/Event/translations/en.yml b/client/src/themes/default/pages/Event/translations/en.yml
index a1d166daa..c4cad2b2a 100644
--- a/client/src/themes/default/pages/Event/translations/en.yml
+++ b/client/src/themes/default/pages/Event/translations/en.yml
@@ -3,8 +3,9 @@ title-simple: Modify event
title: 'Modify event "{title}"'
help-edit: ''
back-to-calendar: Back to calendar
-save-and-back-to-calendar: Save and back to calendar
-save-and-continue: Save and continue
+save-and-go-to-prev-step: Save and back to previous step
+save-and-go-to-next-step: Save and continue
+go-to-prev-step: Back to previous step
continue: Continue
step: Step
event-informations: Informations
diff --git a/client/src/themes/default/pages/Event/translations/fr.yml b/client/src/themes/default/pages/Event/translations/fr.yml
index f51cc0dfe..208c94354 100644
--- a/client/src/themes/default/pages/Event/translations/fr.yml
+++ b/client/src/themes/default/pages/Event/translations/fr.yml
@@ -3,8 +3,9 @@ title-simple: Modifier l'événement
title: "Modifier l'événement «\_{title}\_»"
help-edit: ''
back-to-calendar: Retour au calendrier
-save-and-back-to-calendar: Sauvegarder et retour au calendrier
-save-and-continue: Sauvegarder et continuer
+save-and-go-to-prev-step: Sauvegarder et retour à l'étape précédente
+save-and-go-to-next-step: Sauvegarder et continuer
+go-to-prev-step: Retour à l'étape précédente
continue: Continuer
step: Étape
event-informations: Informations
diff --git a/client/src/themes/default/pages/EventReturn/components/Footer/index.js b/client/src/themes/default/pages/EventReturn/components/Footer/index.js
index a9c591731..6d8f6dfce 100644
--- a/client/src/themes/default/pages/EventReturn/components/Footer/index.js
+++ b/client/src/themes/default/pages/EventReturn/components/Footer/index.js
@@ -1,6 +1,6 @@
import './index.scss';
import Fragment from '@/components/Fragment';
-import Icon from '@/themes/default/components/Icon';
+import IconMessage from '@/themes/default/components/IconMessage';
import Button from '@/themes/default/components/Button';
// @vue/component
@@ -11,6 +11,7 @@ export default {
isSaving: Boolean,
hasEnded: Boolean,
},
+ emits: ['save', 'terminate'],
methods: {
// ------------------------------------------------------
// -
@@ -68,10 +69,12 @@ export default {
)}
{!hasEnded && (
-
+
)}
)}
diff --git a/client/src/themes/default/pages/EventReturn/components/Footer/index.scss b/client/src/themes/default/pages/EventReturn/components/Footer/index.scss
index 918f6f9db..e3a76a32a 100644
--- a/client/src/themes/default/pages/EventReturn/components/Footer/index.scss
+++ b/client/src/themes/default/pages/EventReturn/components/Footer/index.scss
@@ -16,11 +16,8 @@
}
&__warning {
+ margin-top: globals.$content-padding-large-horizontal;
color: globals.$text-warning-color;
font-size: 1.1rem;
-
- > .Icon {
- margin-right: 0.5rem;
- }
}
}
diff --git a/client/src/themes/default/pages/EventReturn/components/Header/index.js b/client/src/themes/default/pages/EventReturn/components/Header/index.js
index 2bb1f78f8..262eb68b7 100644
--- a/client/src/themes/default/pages/EventReturn/components/Header/index.js
+++ b/client/src/themes/default/pages/EventReturn/components/Header/index.js
@@ -10,6 +10,7 @@ export default {
event: { type: Object, required: true },
hasStarted: Boolean,
},
+ emits: ['displayGroupChange'],
data() {
return {
displayGroup: DisplayGroup.CATEGORIES,
diff --git a/client/src/themes/default/pages/EventReturn/components/Inventory/index.js b/client/src/themes/default/pages/EventReturn/components/Inventory/index.js
index 1e02c3f3f..0a9448fb0 100644
--- a/client/src/themes/default/pages/EventReturn/components/Inventory/index.js
+++ b/client/src/themes/default/pages/EventReturn/components/Inventory/index.js
@@ -1,13 +1,15 @@
import './index.scss';
+import { defineComponent } from '@vue/composition-api';
import Inventory, { DisplayGroup } from '@/themes/default/components/Inventory';
+import IconMessage from '@/themes/default/components/IconMessage';
export { DisplayGroup };
// @vue/component
-export default {
+const EventReturnInventory = defineComponent({
name: 'EventReturnInventory',
props: {
- materials: { type: Array, required: true },
+ event: { type: Object, required: true },
inventory: { type: Array, required: true },
errors: { type: Array, default: null },
isLocked: Boolean,
@@ -20,7 +22,12 @@ export default {
),
},
},
+ emits: ['change'],
computed: {
+ hasStarted() {
+ return !!this.event.is_return_inventory_started;
+ },
+
awaitedMaterials() {
return this.materials.map(({ pivot, ...material }) => ({
...material,
@@ -60,6 +67,7 @@ export default {
awaitedMaterials,
isLocked,
errors,
+ hasStarted,
isAllReturned,
displayGroup,
hasBroken,
@@ -82,18 +90,30 @@ export default {
locked={isLocked}
strict
/>
- {isAllReturned && (
-
- {__('page.event-return.all-material-returned')}
+ {!hasStarted && (
+
+
)}
{hasBroken && (
- {' '}
- {__('page.event-return.some-material-came-back-broken')}
+
+
+ )}
+ {(hasStarted && isAllReturned) && (
+
+ {__('page.event-return.all-material-returned')}
)}
);
},
-};
+});
+
+export default EventReturnInventory;
diff --git a/client/src/themes/default/pages/EventReturn/components/Inventory/index.scss b/client/src/themes/default/pages/EventReturn/components/Inventory/index.scss
index d43e1762c..a7863390b 100644
--- a/client/src/themes/default/pages/EventReturn/components/Inventory/index.scss
+++ b/client/src/themes/default/pages/EventReturn/components/Inventory/index.scss
@@ -2,6 +2,7 @@
.EventReturnInventory {
&__missing,
+ &__not-saved,
&__all-returned,
&__has-broken {
margin-bottom: globals.$content-padding-large-horizontal;
@@ -9,13 +10,19 @@
text-align: center;
}
+ &__not-saved,
&__all-returned,
&__has-broken {
margin-top: globals.$content-padding-large-vertical;
}
+ &__not-saved {
+ color: globals.$text-warning-color;
+ }
+
&__all-returned {
color: globals.$text-success-color;
+ white-space: pre-line;
}
&__missing {
diff --git a/client/src/themes/default/pages/EventReturn/components/NotStarted/index.scss b/client/src/themes/default/pages/EventReturn/components/NotStarted/index.scss
index 9292e0b0c..901a4615f 100644
--- a/client/src/themes/default/pages/EventReturn/components/NotStarted/index.scss
+++ b/client/src/themes/default/pages/EventReturn/components/NotStarted/index.scss
@@ -13,7 +13,7 @@
&__message {
display: block;
- margin: 0 0 20px;
+ margin-bottom: 20px;
color: globals.$text-warning-color;
font-size: 1.2rem;
}
diff --git a/client/src/themes/default/pages/EventReturn/components/NotStarted/index.tsx b/client/src/themes/default/pages/EventReturn/components/NotStarted/index.tsx
index 115b9fa53..f24eadbec 100644
--- a/client/src/themes/default/pages/EventReturn/components/NotStarted/index.tsx
+++ b/client/src/themes/default/pages/EventReturn/components/NotStarted/index.tsx
@@ -1,7 +1,7 @@
import './index.scss';
import { defineComponent } from '@vue/composition-api';
import Button from '@/themes/default/components/Button';
-import Icon from '@/themes/default/components/Icon';
+import IconMessage from '@/themes/default/components/IconMessage';
// @vue/component
const EventReturnNotStarted = defineComponent({
@@ -11,15 +11,16 @@ const EventReturnNotStarted = defineComponent({
return (
-
-
- {' '}
- {__('page.event-return.not-started-yet-alert')}
-
+
+
{__('back-to-calendar')}
-
+
);
},
diff --git a/client/src/themes/default/pages/EventReturn/index.js b/client/src/themes/default/pages/EventReturn/index.js
index 6240b2c02..881532914 100644
--- a/client/src/themes/default/pages/EventReturn/index.js
+++ b/client/src/themes/default/pages/EventReturn/index.js
@@ -1,8 +1,10 @@
import './index.scss';
+import { defineComponent } from '@vue/composition-api';
import axios from 'axios';
import moment from 'moment';
import HttpCode from 'status-code-enum';
import { ApiErrorCode } from '@/stores/api/@codes';
+import { ReturnInventoryMode } from '@/stores/api/settings';
import Fragment from '@/components/Fragment';
import Loading from '@/themes/default/components/Loading';
import CriticalError, { ERROR } from '@/themes/default/components/CriticalError';
@@ -15,7 +17,7 @@ import NotStarted from './components/NotStarted';
import Inventory, { DisplayGroup } from './components/Inventory';
// @vue/component
-export default {
+const EventReturn = defineComponent({
name: 'EventReturn',
data() {
return {
@@ -32,6 +34,10 @@ export default {
};
},
computed: {
+ mode() {
+ return this.$store.state.settings.returnInventory.mode;
+ },
+
pageTitle() {
const { $t: __, isFetched, event } = this;
@@ -69,7 +75,7 @@ export default {
// - Actualise le timestamp courant toutes les minutes.
this.nowTimer = setInterval(() => { this.now = Date.now(); }, 60_000);
},
- beforeUnmount() {
+ beforeDestroy() {
if (this.nowTimer) {
clearInterval(this.nowTimer);
}
@@ -129,8 +135,6 @@ export default {
this.isFetched = true;
} catch (error) {
if (!axios.isAxiosError(error)) {
- // eslint-disable-next-line no-console
- console.error(`Error ocurred while retrieving event #${this.id} data`, error);
this.criticalError = ERROR.UNKNOWN;
} else {
const { status = HttpCode.ServerErrorInternal } = error.response ?? {};
@@ -176,11 +180,20 @@ export default {
setEvent(event) {
this.event = event;
+
+ const { is_return_inventory_started: isReturnInventoryStarted } = event;
+
+ const getActualQuantity = (eventMaterial) => (
+ (!isReturnInventoryStarted && this.mode === ReturnInventoryMode.START_FULL)
+ ? eventMaterial.quantity
+ : eventMaterial.quantity_returned ?? 0
+ );
+
this.inventory = event.materials.map(
({ id, pivot }) => ({
id,
- actual: pivot.quantity_returned || 0,
- broken: pivot.quantity_returned_broken || 0,
+ actual: getActualQuantity(pivot),
+ broken: pivot.quantity_returned_broken ?? 0,
}),
);
},
@@ -230,7 +243,7 @@ export default {
);
},
-};
+});
+
+export default EventReturn;
diff --git a/client/src/themes/default/pages/EventReturn/translations/en.yml b/client/src/themes/default/pages/EventReturn/translations/en.yml
index 73ca51b52..34fd80307 100644
--- a/client/src/themes/default/pages/EventReturn/translations/en.yml
+++ b/client/src/themes/default/pages/EventReturn/translations/en.yml
@@ -9,7 +9,10 @@ not-finished-yet-alert: >-
This event is not yet finished, you can start its inventory,
but you cannot terminate it.
-all-material-returned: Congratulations! All materials were returned for this event.
+not-saved-yet: Please note that this return inventory has not yet been saved!
+all-material-returned:
+ "Congratulations! All materials were returned for this event.\n
+ You can now click on \"Terminate inventory\" button."
some-material-is-missing: Some materials did not return from this event!
some-material-came-back-broken: Some materials came back broken.
diff --git a/client/src/themes/default/pages/EventReturn/translations/fr.yml b/client/src/themes/default/pages/EventReturn/translations/fr.yml
index e2f99899e..eff224698 100644
--- a/client/src/themes/default/pages/EventReturn/translations/fr.yml
+++ b/client/src/themes/default/pages/EventReturn/translations/fr.yml
@@ -9,7 +9,10 @@ not-finished-yet-alert: >-
Cet événement n'est pas encore terminé, vous pouvez commencer son inventaire
de retour, mais pas le terminer.
-all-material-returned: "Félicitations\_! Tout le matériel a bien été retourné pour cet événement."
+not-saved-yet: "Attention, cet inventaire de retour n'a pas encore été sauvegardé\_!"
+all-material-returned:
+ "Félicitations\_! Tout le matériel a bien été retourné pour cet événement.\n
+ Vous pouvez maintenant cliquer sur le bouton \"Terminer l'inventaire\"."
some-material-is-missing: "Du matériel n'est pas revenu de cet événement\_!"
some-material-came-back-broken: Du matériel est revenu en panne.
diff --git a/client/src/themes/default/pages/Material/components/Form/index.js b/client/src/themes/default/pages/Material/components/Form/index.js
index b727543b6..d2753e9ba 100644
--- a/client/src/themes/default/pages/Material/components/Form/index.js
+++ b/client/src/themes/default/pages/Material/components/Form/index.js
@@ -188,7 +188,7 @@ const MaterialEditForm = {
// - Supprime les données d'attributs obsolètes dans les données du formulaire.
Object.keys(this.data.attributes).forEach((id) => {
const stillExists = this.extraAttributes
- .find((attr) => attr.id === parseInt(id, 10)) !== undefined;
+ .some((attr) => attr.id === parseInt(id, 10));
if (!stillExists) {
this.$delete(this.data.attributes, id);
@@ -200,7 +200,7 @@ const MaterialEditForm = {
}
},
- getAttributeType(attributeType) {
+ getAttributeInputType(attributeType) {
switch (attributeType) {
case 'integer':
case 'float':
@@ -213,6 +213,17 @@ const MaterialEditForm = {
return 'text';
}
},
+
+ getAttributeInputStep(attributeType) {
+ switch (attributeType) {
+ case 'integer':
+ return 1;
+ case 'float':
+ return 0.001;
+ default:
+ return undefined;
+ }
+ },
},
render() {
const {
@@ -233,7 +244,8 @@ const MaterialEditForm = {
handleCategoryChange,
handleUpdateRentalPrice,
handleAttributeChange,
- getAttributeType,
+ getAttributeInputType,
+ getAttributeInputStep,
} = this;
if (criticalError !== null) {
@@ -368,7 +380,8 @@ const MaterialEditForm = {
{
diff --git a/client/src/themes/default/pages/Material/index.js b/client/src/themes/default/pages/Material/index.js
index c4db989b9..ddadda36e 100644
--- a/client/src/themes/default/pages/Material/index.js
+++ b/client/src/themes/default/pages/Material/index.js
@@ -2,7 +2,6 @@ import './index.scss';
import axios from 'axios';
import HttpCode from 'status-code-enum';
import { ApiErrorCode } from '@/stores/api/@codes';
-import queryClient from '@/globals/queryClient';
import apiMaterials from '@/stores/api/materials';
import FormField from '@/themes/default/components/FormField';
import InputImage from '@/themes/default/components/InputImage';
@@ -155,7 +154,6 @@ export default {
}
this.validationErrors = null;
- queryClient.invalidateQueries('materials-while-event');
// - Redirection...
this.$toasted.success(__('page.material-edit.saved'));
diff --git a/client/src/themes/default/pages/MaterialView/index.js b/client/src/themes/default/pages/MaterialView/index.js
index c59b2b455..f2eeaef3a 100644
--- a/client/src/themes/default/pages/MaterialView/index.js
+++ b/client/src/themes/default/pages/MaterialView/index.js
@@ -2,15 +2,23 @@ import './index.scss';
import axios from 'axios';
import HttpCode from 'status-code-enum';
import { defineComponent } from '@vue/composition-api';
+import apiMaterials from '@/stores/api/materials';
+import { confirm } from '@/utils/alert';
import Page from '@/themes/default/components/Page';
import CriticalError, { ERROR } from '@/themes/default/components/CriticalError';
import Loading from '@/themes/default/components/Loading';
import { Tabs, Tab } from '@/themes/default/components/Tabs';
import Button from '@/themes/default/components/Button';
-import apiMaterials from '@/stores/api/materials';
import Infos from './tabs/Infos';
import Documents from './tabs/Documents';
+const TABS = [
+ 'infos',
+ 'units',
+ 'documents',
+ 'availabilities',
+];
+
// @vue/component
const MaterialView = {
name: 'MaterialView',
@@ -73,21 +81,45 @@ const MaterialView = {
// -
// ------------------------------------------------------
- handleSelectTab(index) {
+ async handleTabChange(event) {
+ if (event.prevIndex !== TABS.indexOf('documents')) {
+ return;
+ }
+
+ const { documentsRef } = this.$refs;
+ if (!documentsRef?.isUploading()) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const { $t: __ } = this;
+ const isConfirmed = await confirm({
+ text: __('confirm-cancel-upload-change-tab'),
+ type: 'danger',
+ });
+ if (!isConfirmed) {
+ return;
+ }
+
+ event.executeDefault();
+ },
+
+ handleTabChanged(index) {
this.selectedTabIndex = index;
this.$router.replace(this.tabsIndexes[index]);
},
// ------------------------------------------------------
// -
- // - Internal methods
+ // - Méthodes internes
// -
// ------------------------------------------------------
selectTabFromRouting() {
const { hash } = this.$route;
if (hash && this.tabsIndexes.includes(hash)) {
- this.selectedTabIndex = this.tabsIndexes.findIndex((tab) => tab === hash);
+ this.selectedTabIndex = this.tabsIndexes.indexOf(hash);
}
},
@@ -123,7 +155,8 @@ const MaterialView = {
isFetched,
criticalError,
material,
- handleSelectTab,
+ handleTabChange,
+ handleTabChanged,
selectedTabIndex,
} = this;
@@ -140,14 +173,15 @@ const MaterialView = {
-
+
diff --git a/client/src/themes/default/pages/MaterialView/tabs/Documents/Item/index.js b/client/src/themes/default/pages/MaterialView/tabs/Documents/Item/index.js
deleted file mode 100644
index b12b50c36..000000000
--- a/client/src/themes/default/pages/MaterialView/tabs/Documents/Item/index.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import './index.scss';
-import config from '@/globals/config';
-import formatBytes from '@/utils/formatBytes';
-import hasIncludes from '@/utils/hasIncludes';
-import Button from '@/themes/default/components/Button';
-import Icon from '@/themes/default/components/Icon';
-
-// @vue/component
-export default {
- name: 'MaterialViewDocumentsItem',
- props: {
- file: { type: [File, Object], required: true },
- },
- computed: {
- fileSize() {
- return formatBytes(this.file.size);
- },
-
- fileUrl() {
- const { id } = this.file;
-
- return id
- ? `${config.baseUrl}/documents/${id}/download`
- // - L'absence d'ID signifie que `this.file` est bien du type `File`
- : URL.createObjectURL(this.file);
- },
-
- iconName() {
- const { type } = this.file;
- if (type === 'application/pdf') {
- return 'file-pdf';
- }
- if (type.startsWith('image/')) {
- return 'file-image';
- }
- if (type.startsWith('video/')) {
- return 'file-video';
- }
- if (type.startsWith('audio/')) {
- return 'file-audio';
- }
- if (type.startsWith('text/')) {
- return 'file-alt';
- }
- if (hasIncludes(type, ['zip', 'octet-stream', 'x-rar', 'x-tar', 'x-7z'])) {
- return 'file-archive';
- }
- if (hasIncludes(type, ['sheet', 'excel'])) {
- return 'file-excel';
- }
- if (hasIncludes(type, ['wordprocessingml.document', 'msword'])) {
- return 'file-word';
- }
- if (hasIncludes(type, ['presentation', 'powerpoint'])) {
- return 'file-powerpoint';
- }
- return 'file';
- },
- },
- methods: {
- // ------------------------------------------------------
- // -
- // - Handlers
- // -
- // ------------------------------------------------------
-
- handleClickRemove() {
- this.$emit('remove', this.file);
- },
- },
- render() {
- const { $t: __, file: { name }, fileUrl, iconName, fileSize, handleClickRemove } = this;
-
- return (
-
-
-
- {name}
-
-
- {fileSize}
-
-
-
-
-
- );
- },
-};
diff --git a/client/src/themes/default/pages/MaterialView/tabs/Documents/Upload/index.js b/client/src/themes/default/pages/MaterialView/tabs/Documents/Upload/index.js
deleted file mode 100644
index 868f1416b..000000000
--- a/client/src/themes/default/pages/MaterialView/tabs/Documents/Upload/index.js
+++ /dev/null
@@ -1,221 +0,0 @@
-import './index.scss';
-import config from '@/globals/config';
-import getFileError from '@/utils/getFileError';
-import formatBytes from '@/utils/formatBytes';
-import apiMaterials from '@/stores/api/materials';
-import Button from '@/themes/default/components/Button';
-import Icon from '@/themes/default/components/Icon';
-import Loading from '@/themes/default/components/Loading';
-import Progressbar from '@/themes/default/components/Progressbar';
-import DocumentItem from '../Item';
-
-// @vue/component
-export default {
- name: 'MaterialViewDocumentsUpload',
- props: {
- materialId: { type: Number, required: true },
- },
- data() {
- return {
- isDragging: false,
- isLoading: false,
- files: [],
- fileErrors: [],
- uploadProgress: 0,
- };
- },
- computed: {
- maxSize: () => formatBytes(config.maxFileUploadSize),
- },
- methods: {
- // ------------------------------------------------------
- // -
- // - Handlers
- // -
- // ------------------------------------------------------
-
- handleClickOpenFileBrowser() {
- const fileInput = this.$refs.chooseFilesButton;
- fileInput.click();
- },
-
- handleDragover(e) {
- e.preventDefault();
- this.isDragging = true;
- },
-
- handleDragleave(e) {
- e.preventDefault();
- this.isDragging = false;
- },
-
- handleAddFiles(event) {
- event.preventDefault();
- this.isDragging = false;
- this.fileErrors = [];
-
- const files = event.dataTransfer ? event.dataTransfer.files : event.target.files;
- if (!files || files.length === 0) {
- return;
- }
-
- const newFiles = [...files].filter(this.checkFile);
-
- this.files = [...this.files, ...newFiles].sort(
- ({ name: name1 }, { name: name2 }) => name1.localeCompare(name2),
- );
- },
-
- handleRemoveFile(file) {
- this.fileErrors = [];
- this.files = this.files.filter(({ name }) => name !== file.name);
- },
-
- async handleUploadFiles() {
- this.fileErrors = [];
- this.isLoading = true;
- this.uploadProgress = 0;
-
- const onProgress = (percent) => {
- this.uploadProgress = percent;
- };
-
- try {
- await apiMaterials.attachDocuments(this.materialId, this.files, onProgress);
- this.files = [];
- this.$emit('fileUploaded');
- } catch {
- const { $t: __ } = this;
- this.$toasted.error(__('errors.unexpected-while-uploading'));
- } finally {
- this.isLoading = false;
- this.uploadProgress = 0;
- }
- },
-
- // ------------------------------------------------------
- // -
- // - Internal methods
- // -
- // ------------------------------------------------------
-
- checkFile(file) {
- const fileError = getFileError(file, this.files);
- if (!fileError) {
- return true;
- }
-
- const { type, name } = file;
- const { $t: __ } = this;
-
- let message = '';
- switch (fileError) {
- case 'type-not-allowed':
- message = __('errors.file-type-not-allowed', { type });
- break;
- case 'size-exceeded':
- message = __('errors.file-size-exceeded', {
- max: formatBytes(config.maxFileUploadSize),
- });
- break;
- case 'already-exists':
- message = __('errors.file-already-exists');
- break;
- default:
- return false;
- }
-
- this.fileErrors.push({ fileName: name, message });
- return false;
- },
- },
- render() {
- const {
- $t: __,
- isDragging,
- isLoading,
- maxSize,
- files,
- fileErrors,
- uploadProgress,
- handleAddFiles,
- handleDragover,
- handleDragleave,
- handleClickOpenFileBrowser,
- handleRemoveFile,
- handleUploadFiles,
- } = this;
-
- const className = {
- 'MaterialViewDocumentsUpload': true,
- 'MaterialViewDocumentsUpload--drag-over': isDragging,
- };
-
- return (
-
- );
- },
-};
diff --git a/client/src/themes/default/pages/MaterialView/tabs/Documents/Upload/index.scss b/client/src/themes/default/pages/MaterialView/tabs/Documents/Upload/index.scss
deleted file mode 100644
index af07ab332..000000000
--- a/client/src/themes/default/pages/MaterialView/tabs/Documents/Upload/index.scss
+++ /dev/null
@@ -1,62 +0,0 @@
-@use '~@/themes/default/style/globals';
-
-.MaterialViewDocumentsUpload {
- display: flex;
- flex-direction: column;
- align-items: center;
- min-width: 300px;
- border-left: 1px solid globals.$divider-color;
- background-color: transparent;
- transition: background-color 300ms ease-out;
-
- &__title {
- flex: 0 0 auto;
- margin: 30px 0 0;
- font-size: 1.2rem;
- }
-
- &__choose-files {
- margin: 15px 0;
- }
-
- &__send-list {
- flex: 1;
- width: 100%;
- margin: 0;
- padding: globals.$content-padding-small-horizontal globals.$content-padding-small-vertical;
- }
-
- &__file-errors {
- flex: 1;
- margin: globals.$content-padding-large-horizontal 0;
- padding: 0 globals.$content-padding-small-vertical;
-
- &__item {
- padding: globals.$content-padding-small-horizontal 0;
- color: globals.$text-danger-color;
- list-style: none;
- }
- }
-
- &__actions {
- flex: 1;
- margin: 20px 0;
- text-align: center;
-
- &__file-input {
- display: none;
- }
-
- .Help {
- margin: globals.$content-padding-large-horizontal 0;
- }
- }
-
- .Progressbar {
- margin-left: globals.$content-padding-large-vertical;
- }
-
- &--drag-over {
- background-color: rgba(globals.$color-active-button, 0.15);
- }
-}
diff --git a/client/src/themes/default/pages/MaterialView/tabs/Documents/index.js b/client/src/themes/default/pages/MaterialView/tabs/Documents/index.js
deleted file mode 100644
index 70eb052af..000000000
--- a/client/src/themes/default/pages/MaterialView/tabs/Documents/index.js
+++ /dev/null
@@ -1,142 +0,0 @@
-import './index.scss';
-import { confirm } from '@/utils/alert';
-import apiMaterials from '@/stores/api/materials';
-import apiDocuments from '@/stores/api/documents';
-import CriticalError from '@/themes/default/components/CriticalError';
-import Loading from '@/themes/default/components/Loading';
-import DocumentItem from './Item';
-import DocumentUpload from './Upload';
-
-// @vue/component
-export default {
- name: 'MaterialViewDocuments',
- props: {
- material: { required: true, type: Object },
- },
- data() {
- return {
- isFetched: false,
- isDeleting: false,
- hasCriticalError: false,
- documents: [],
- };
- },
- mounted() {
- this.fetchData();
- },
- methods: {
- // ------------------------------------------------------
- // -
- // - Handlers
- // -
- // ------------------------------------------------------
-
- handleFileUploaded() {
- const { $t: __ } = this;
- this.$toasted.success(__('page.material-view.documents.saved'));
- this.fetchData();
- },
-
- async handleDeleteDocument(file) {
- const { $t: __ } = this;
-
- const isConfirmed = await confirm({
- type: 'danger',
- text: __('page.material-view.documents.confirm-permanently-delete'),
- confirmButtonText: __('yes-permanently-delete'),
- });
- if (!isConfirmed) {
- return;
- }
-
- this.isDeleting = true;
- this.removeDocumentFromList(file.id);
-
- try {
- await apiDocuments.remove(file.id);
- this.$toasted.success(__('page.material-view.documents.deleted'));
- } catch {
- this.$toasted.error(__('errors.unexpected-while-deleting'));
- this.fetchData();
- } finally {
- this.isDeleting = false;
- }
- },
-
- // ------------------------------------------------------
- // -
- // - Internal methods
- // -
- // ------------------------------------------------------
-
- async fetchData() {
- const { material } = this;
-
- try {
- const data = await apiMaterials.documents(material.id);
- this.documents = data;
- this.isFetched = true;
- } catch {
- // TODO: Lever une exception qui doit être catchée dans la page plutôt que de gérer ça dans l'onglet.
- // Cf. la page d'edition du matériel (`errorCaptured`) + State `criticalError` dans le