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 && ( +
    + ); + }, +}); + +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 ( +
    +
    +
    + {(!!name && !disabled) && ( + + )} +
    + {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 () => ( -
    -
    - -
    - {hasMaterials.value && ( -
    - {__('display-only-selected-materials')} - -
    - )} - - - - -
    -
    - {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 ( +
    +
    + +
    + {hasMaterials && ( +
    + {__('display-only-selected-materials')} + +
    + )} + + + + +
    +
    +
    + {!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 + ? ( + +
    + )} +
    +
    + ); + }, +}); + +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') }, - )} - - -
  • - ); -}; - -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') }, + )} + + +
  • + ); + }, +}); + +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 ( -
    - - {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 ( +
    + + {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 ( +
  • +
    +
    + {material.name} +
    +
    + {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 && (

    - {__('events-count-total', { count: events.length }, events.length)} + {__('events-count-total', { count: formattedItems.length }, formattedItems.length)}

    )} @@ -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 ( +
    +