From fcb889beccc9d7bd1f343800c56fde0db995761e Mon Sep 17 00:00:00 2001 From: Paul Maillardet Date: Thu, 9 May 2024 10:08:42 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Premium=20=E2=87=92=20OSS=20(#407)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 42 +- .resources/logo-dark.svg | 29 + .resources/logo-light.svg | 29 + .resources/logo.svg | 9 - bin/release | 2 +- client/.eslintrc.js | 28 +- client/.gitignore | 4 +- client/babel.config.js | 3 +- client/jest.config.js | 6 + client/jsconfig.json | 3 +- client/package.json | 82 +- client/patches/vue2-datepicker+3.11.1.patch | 395 + client/src/components/Fragment/index.tsx | 2 +- client/src/globals/{config.js => config.ts} | 4 +- client/src/globals/constants.js | 17 - client/src/globals/constants.ts | 12 + client/src/globals/queryClient.ts | 19 - client/src/globals/rawDatetime.ts | 11 + client/src/globals/requester.js | 5 + client/src/globals/types/globals.d.ts | 35 + client/src/globals/types/utils.d.ts | 4 + client/src/globals/types/vendors/dayjs.d.ts | 18 + .../globals/types/vendors/vue-js-modal.d.ts | 2 +- .../types/vendors/vue-simple-calendar.d.ts | 40 + .../globals/types/vendors/vue-tables-2.d.ts | 64 +- .../globals/types/vendors/vue-toasted.d.ts | 7 + .../types/vendors/vue2-datepicker.d.ts | 14 +- client/src/globals/types/vendors/vuex.d.ts | 3 - client/src/hooks/useI18n.ts | 10 - client/src/hooks/useRouteId.js | 14 - client/src/hooks/useRoutePage.ts | 20 - client/src/hooks/useRouter.ts | 23 - client/src/locale/en/common.js | 68 +- client/src/locale/en/date.js | 11 + client/src/locale/en/errors.js | 16 +- client/src/locale/en/index.js | 2 + client/src/locale/fr/common.js | 85 +- client/src/locale/fr/date.js | 11 + client/src/locale/fr/errors.js | 16 +- client/src/locale/fr/index.js | 2 + client/src/stores/api/@codes.ts | 10 - client/src/stores/api/@schema.ts | 45 + client/src/stores/api/@types.ts | 68 +- client/src/stores/api/attributes.ts | 166 +- client/src/stores/api/beneficiaries.ts | 174 +- client/src/stores/api/bookings.ts | 217 +- client/src/stores/api/categories.ts | 69 +- client/src/stores/api/companies.ts | 88 +- client/src/stores/api/countries.ts | 44 +- client/src/stores/api/documents.ts | 45 +- client/src/stores/api/estimates.ts | 57 +- client/src/stores/api/events.ts | 565 +- client/src/stores/api/groups.ts | 31 +- client/src/stores/api/invoices.ts | 47 +- client/src/stores/api/materials.ts | 296 +- client/src/stores/api/parks.ts | 145 +- client/src/stores/api/persons.ts | 69 +- client/src/stores/api/session.ts | 60 +- client/src/stores/api/settings.ts | 134 +- client/src/stores/api/subcategories.ts | 61 +- client/src/stores/api/tags.ts | 69 +- client/src/stores/api/technicians.ts | 184 +- client/src/stores/api/users.ts | 166 +- client/src/stores/auth.ts | 13 +- client/src/stores/settings.ts | 53 +- .../default/components/Alert/_variables.scss | 15 + .../default/components/Alert/index.scss | 39 + .../themes/default/components/Alert/index.tsx | 44 + .../themes/default/components/App/index.js | 2 +- .../default/components/Button/_variables.scss | 30 +- .../default/components/Button/index.scss | 68 +- .../default/components/Button/index.tsx | 30 +- .../components/ButtonDropdown/index.scss | 28 +- .../components/ButtonDropdown/index.tsx | 23 +- .../components/Gradient/index.scss | 2 +- .../ColorPicker/components/Gradient/index.tsx | 1 + .../default/components/ColorPicker/index.tsx | 21 +- .../default/components/CriticalError/index.js | 4 +- .../components/CriticalError/index.scss | 8 +- .../__tests__/utils/normalizer.spec.ts | 341 + .../default/components/DatePicker/_types.ts | 59 + .../{Datepicker => DatePicker}/index.scss | 17 +- .../{Datepicker => DatePicker}/index.tsx | 355 +- .../translations/en.yml | 0 .../translations/fr.yml | 0 .../components/DatePicker/utils/normalizer.ts | 204 + .../components/DatePicker/utils/snippets.ts | 159 + .../default/components/Datepicker/_types.ts | 54 - .../default/components/Datepicker/_utils.ts | 239 - .../default/components/Datepicker/index.js | 190 - .../components/Datepicker/locale/index.js | 4 - .../default/components/DropZone/index.tsx | 2 +- .../components/DropZone/translations/en.yml | 4 +- .../components/DropZone/translations/fr.yml | 4 +- .../Dropdown/components/Transition/index.scss | 15 + .../Dropdown/components/Transition/index.tsx | 27 + .../default/components/Dropdown/index.scss | 22 +- .../default/components/Dropdown/index.tsx | 115 +- .../default/components/Embed/index.scss | 19 + .../themes/default/components/Embed/index.tsx | 64 + .../components/EmptyMessage/_variables.scss | 5 - .../EmptyMessage/illustrations/general.svg | 7 - .../EmptyMessage/illustrations/search.svg | 19 - .../components/EmptyMessage/index.scss | 54 - .../default/components/ErrorMessage/index.js | 2 +- .../components/EventMissingMaterials/index.js | 103 +- .../EventMissingMaterials/index.scss | 75 +- .../default/components/EventTotals/index.scss | 75 +- .../default/components/EventTotals/index.tsx | 147 +- .../components/Document/index.scss | 2 + .../default/components/FileManager/index.tsx | 2 +- .../default/components/FormField/index.js | 70 +- .../themes/default/components/Help/index.js | 4 +- .../themes/default/components/Icon/index.tsx | 23 +- .../default/components/IconMessage/index.scss | 6 +- .../default/components/IconMessage/index.tsx | 42 +- .../themes/default/components/Input/index.js | 2 + .../default/components/Input/index.scss | 48 +- .../default/components/InputColor/index.scss | 8 +- .../default/components/InputColor/index.tsx | 2 +- .../default/components/InputCopy/index.scss | 8 +- .../default/components/InputImage/index.js | 8 +- .../default/components/InputImage/index.scss | 4 +- .../Inventory/Item/Material/index.js | 229 - .../Inventory/Item/Material/index.scss | 100 - .../components/Inventory/Item/index.js | 56 - .../Inventory/__tests__/_utils.spec.js | 59 +- .../default/components/Inventory/_types.ts | 5 +- .../default/components/Inventory/_utils.ts | 43 - .../components/Item/Material/index.scss | 12 + .../components/Item/Material/index.tsx | 8 +- .../Inventory/components/Item/index.scss | 5 + .../Inventory/components/Item/index.tsx | 1 + .../default/components/Inventory/index.tsx | 11 +- .../modals/CommentEdition/index.scss | 3 +- .../Inventory/modals/CommentEdition/index.tsx | 1 + .../components/Inventory/translations/en.yml | 2 +- .../components/Inventory/translations/fr.yml | 2 +- .../themes/default/components/Link/index.tsx | 1 + .../default/components/Loading/index.js | 44 - .../default/components/Loading/index.scss | 7 +- .../default/components/Loading/index.tsx | 76 + .../default/components/Logo/assets/logo-R.svg | 10 - .../default/components/Logo/assets/logo.svg | 18 - .../themes/default/components/Logo/index.tsx | 113 +- .../components/MaterialsFilters/index.scss | 8 +- .../components/MaterialsFilters/index.tsx | 4 +- .../components/MaterialsSelector/_types.ts | 10 +- .../components/Filters/index.scss | 1 + .../components/Filters/index.tsx | 7 +- .../components/List/Availability/index.tsx | 2 +- .../components/List/Quantity/index.tsx | 9 +- .../components/List/index.scss | 5 + .../components/List/index.tsx | 93 +- .../components/MaterialsSelector/index.scss | 4 - .../components/MaterialsSelector/index.tsx | 80 +- .../SearchEventResultItem/index.scss | 2 +- .../SearchEventResultItem/index.tsx | 31 +- .../SearchEvents/index.tsx | 1 + .../modals/ReuseEventMaterials/index.scss | 3 +- .../modals/ReuseEventMaterials/index.tsx | 39 +- .../__tests__/mutations/decrement.spec.ts | 23 +- .../__tests__/mutations/increment.spec.ts | 19 +- .../__tests__/mutations/setQuantity.spec.ts | 34 +- .../MaterialsSelector/store/_types.ts | 6 +- .../MaterialsSelector/store/getters/export.ts | 4 +- .../store/getters/getQuantity.ts | 6 +- .../MaterialsSelector/store/index.ts | 4 +- .../store/mutations/decrement.ts | 2 +- .../store/mutations/increment.ts | 2 +- .../MaterialsSelector/store/mutations/init.ts | 2 +- .../store/mutations/setQuantity.ts | 2 +- .../MaterialsSelector/translations/en.yml | 12 +- .../MaterialsSelector/translations/fr.yml | 8 + .../getEventMaterialsQuantities.spec.js | 14 +- .../utils/getEventMaterialsQuantities.ts | 8 +- .../CategoryItem/Material/index.scss | 8 +- .../CategoryItem/Material/index.tsx | 40 +- .../{ => components}/CategoryItem/index.scss | 0 .../{ => components}/CategoryItem/index.tsx | 26 +- .../components/MaterialsSorted/index.tsx | 10 +- .../MaterialsSorted/translations/en.yml | 2 - .../MaterialsSorted/translations/fr.yml | 2 - .../utils/__tests__/groupByCategories.test.ts | 48 +- .../__tests__/testGetMaterialPrice.test.ts | 9 +- .../__tests__/testGetMaterialQuantity.test.ts | 8 +- .../utils/getMaterialUnitPrice.ts | 6 +- .../utils/groupByCategories.ts | 33 +- .../default/components/MonthCalendar/index.js | 135 - .../components/MonthCalendar/index.scss | 12 +- .../components/MonthCalendar/index.tsx | 186 + .../components/MultiSwitch/Option/index.js | 43 - .../components/MultiSwitch/Option/index.scss | 47 +- .../components/MultiSwitch/Option/index.tsx | 89 + .../default/components/MultiSwitch/index.js | 47 - .../default/components/MultiSwitch/index.scss | 1 - .../default/components/MultiSwitch/index.tsx | 69 + .../components/Notepad/_variables.scss | 2 +- .../default/components/Notepad/index.tsx | 2 - .../themes/default/components/Page/index.scss | 91 +- .../themes/default/components/Page/index.tsx | 71 +- .../Material/components/Popup/index.scss | 174 + .../Material/components/Popup/index.tsx | 137 + .../Material/components/Transition/index.scss | 15 + .../Material/components/Transition/index.tsx | 27 + .../components/Popover/Material/index.scss | 8 + .../components/Popover/Material/index.tsx | 248 + .../Popover/Material/translations/en.yml | 1 + .../Popover/Material/translations/fr.yml | 1 + .../components/Progressbar/_variables.scss | 4 + .../default/components/Progressbar/index.scss | 10 +- .../default/components/QuantityInput/index.js | 1 + .../components/QuantityInput/index.scss | 10 +- .../themes/default/components/Radio/index.tsx | 1 + .../components/RelativeTime/index.scss | 3 + .../default/components/RelativeTime/index.tsx | 65 + .../themes/default/components/Select/index.js | 1 + .../default/components/Select/index.scss | 2 + .../default/components/SwitchToggle/index.js | 82 - .../components/SwitchToggle/index.scss | 90 +- .../default/components/SwitchToggle/index.tsx | 244 + .../themes/default/components/Table/@types.ts | 11 +- .../default/components/Table/Client/_types.ts | 21 + .../default/components/Table/Client/index.tsx | 341 + .../default/components/Table/Server/index.tsx | 96 +- .../default/components/Table/index.scss | 18 + .../themes/default/components/Table/index.ts | 5 + .../default/components/Tabs/Tab/index.js | 2 + .../components/Tabs/TabButton/index.js | 1 + .../default/components/Tabs/_variables.scss | 8 +- .../default/components/Textarea/index.js | 86 - .../default/components/Textarea/index.scss | 2 + .../default/components/Timeline/_types.ts | 82 + .../default/components/Timeline/_utils.js | 36 - .../default/components/Timeline/_utils.ts | 44 + .../components/Timeline/_variables.scss | 39 + .../default/components/Timeline/index.js | 249 - .../default/components/Timeline/index.scss | 134 +- .../default/components/Timeline/index.tsx | 716 + client/src/themes/default/globals/store.js | 17 - client/src/themes/default/globals/store.ts | 4 +- client/src/themes/default/index.js | 11 +- .../Header/Menu/Dropdown/index.scss | 10 +- .../Default/components/Header/Menu/index.js | 11 +- .../Default/components/Header/Menu/index.scss | 4 +- .../Default/components/Header/index.scss | 11 +- .../Default/components/Header/index.tsx | 7 +- .../components/Sidebar/Menu/Item/index.scss | 7 +- .../components/Sidebar/Menu/Item/index.tsx | 1 + .../Sidebar/Menu/{index.js => index.tsx} | 30 +- .../Default/components/Sidebar/index.js | 3 +- .../Default/components/Sidebar/index.scss | 13 +- .../themes/default/layouts/Default/index.js | 4 +- .../themes/default/layouts/Default/index.scss | 4 + .../layouts/Default/translations/en.yml | 4 +- .../layouts/Default/translations/fr.yml | 4 +- .../default/layouts/Minimalist/index.scss | 37 +- .../default/layouts/Minimalist/index.tsx | 26 +- .../layouts/Minimalist/translations/en.yml | 4 +- .../layouts/Minimalist/translations/fr.yml | 4 +- .../themes/default/locale/en/components.js | 6 +- client/src/themes/default/locale/en/page.js | 2 +- .../themes/default/locale/fr/components.js | 6 +- client/src/themes/default/locale/fr/page.js | 2 +- .../themes/default/modals/AssignTags/index.js | 1 + .../default/modals/AssignTags/index.scss | 3 +- .../default/modals/DuplicateEvent/index.scss | 39 +- .../default/modals/DuplicateEvent/index.tsx | 212 +- .../modals/DuplicateEvent/translations/en.yml | 14 +- .../modals/DuplicateEvent/translations/fr.yml | 14 +- .../components/BillingForm/index.scss | 4 +- .../components/BillingForm/index.tsx | 21 +- .../components/Header/Actions/index.js | 479 - .../components/Header/Actions/index.scss | 10 - .../EventDetails/components/Header/index.js | 83 - .../EventDetails/components/Header/index.scss | 31 +- .../EventDetails/components/Header/index.tsx | 586 + .../default/modals/EventDetails/index.scss | 2 +- .../EventDetails/{index.js => index.tsx} | 196 +- .../EventDetails/tabs/Documents/index.tsx | 4 +- .../tabs/Estimates/Estimate/index.scss | 3 + .../Estimate/{index.js => index.tsx} | 53 +- .../tabs/Estimates/{index.js => index.tsx} | 74 +- .../MainBeneficiary/{index.js => index.tsx} | 39 +- .../tabs/Infos/{index.js => index.tsx} | 52 +- .../tabs/Invoices/Invoice/index.tsx | 25 +- .../tabs/Invoices/{index.js => index.tsx} | 77 +- .../Materials/ReturnSummary/Item/index.js | 7 +- .../Materials/ReturnSummary/Item/index.scss | 8 +- .../EventDetails/tabs/Materials/index.scss | 4 + .../tabs/Materials/{index.js => index.tsx} | 37 +- .../modals/EventDetails/tabs/Note/index.tsx | 13 +- .../EventDetails/tabs/Technicians/index.js | 70 - .../EventDetails/tabs/Technicians/index.tsx | 77 + .../modals/EventDetails/translations/en.yml | 37 +- .../modals/EventDetails/translations/fr.yml | 35 + .../modals/UpdateBookingMaterials/index.scss | 3 +- .../modals/UpdateBookingMaterials/index.tsx | 23 +- .../AttributeEdit/components/Form/index.scss | 2 +- .../AttributeEdit/components/Form/index.tsx | 90 +- .../default/pages/AttributeEdit/index.scss | 17 +- .../default/pages/AttributeEdit/index.tsx | 8 +- .../pages/Attributes/components/Item/index.js | 152 - .../Attributes/components/Item/index.scss | 100 - .../themes/default/pages/Attributes/index.js | 138 - .../default/pages/Attributes/index.scss | 52 +- .../themes/default/pages/Attributes/index.tsx | 273 + .../pages/Attributes/translations/en.yml | 1 - .../pages/Attributes/translations/fr.yml | 1 - .../default/pages/Beneficiaries/index.scss | 24 +- .../Beneficiaries/{index.js => index.tsx} | 169 +- .../pages/Beneficiaries/translations/en.yml | 3 + .../pages/Beneficiaries/translations/fr.yml | 3 + .../components/Form/CompanySelect/index.js | 111 - .../components/Form/CompanySelect/index.scss | 7 +- .../components/Form/CompanySelect/index.tsx | 191 + .../BeneficiaryEdit/components/Form/index.js | 175 - .../components/Form/index.scss | 51 + .../BeneficiaryEdit/components/Form/index.tsx | 279 + .../default/pages/BeneficiaryEdit/index.js | 2 +- .../default/pages/BeneficiaryEdit/index.scss | 17 +- .../default/pages/BeneficiaryView/_types.ts | 7 + .../components/BookingsItem/index.scss | 55 +- .../components/BookingsItem/index.tsx | 171 +- .../default/pages/BeneficiaryView/index.scss | 9 +- .../default/pages/BeneficiaryView/index.tsx | 17 +- .../tabs/Billing/Estimates/index.scss | 6 - .../tabs/Billing/Estimates/index.tsx | 152 +- .../tabs/Billing/Invoices/index.scss | 6 - .../tabs/Billing/Invoices/index.tsx | 171 +- .../BeneficiaryView/tabs/Billing/index.tsx | 6 +- .../tabs/Borrowings/index.scss | 4 + .../BeneficiaryView/tabs/Borrowings/index.tsx | 310 +- .../tabs/Infos/NextBookings/index.tsx | 165 +- .../BeneficiaryView/tabs/Infos/index.scss | 10 + .../BeneficiaryView/tabs/Infos/index.tsx | 31 +- .../pages/BeneficiaryView/translations/en.yml | 8 +- .../pages/BeneficiaryView/translations/fr.yml | 8 +- .../themes/default/pages/Calendar/_utils.js | 15 - .../pages/Calendar/components/Header/index.js | 218 - .../Calendar/components/Header/index.scss | 98 - .../themes/default/pages/Calendar/index.js | 455 - .../pages/Calendar/translations/en.yml | 31 - .../pages/Calendar/translations/fr.yml | 31 - .../pages/Categories/components/Item/index.js | 43 +- .../Categories/components/Item/index.scss | 4 - .../themes/default/pages/Categories/index.js | 16 +- .../default/pages/Categories/index.scss | 21 +- .../CompanyEdit/components/Form/index.js | 125 - .../CompanyEdit/components/Form/index.tsx | 179 + .../default/pages/CompanyEdit/index.scss | 17 +- .../pages/CompanyEdit/{index.js => index.tsx} | 69 +- .../themes/default/pages/Event/EventStore.js | 66 - .../Event/components/Breadcrumb/index.js | 13 +- .../Event/components/MiniSummary/index.js | 95 - .../Event/components/MiniSummary/index.scss | 16 +- .../Event/components/MiniSummary/index.tsx | 133 + .../src/themes/default/pages/Event/index.js | 204 +- .../default/pages/Event/steps/1/index.js | 259 - .../default/pages/Event/steps/1/index.scss | 25 +- .../default/pages/Event/steps/1/index.tsx | 582 + .../steps/2/BeneficiariesSelect/index.scss | 43 + .../steps/2/BeneficiariesSelect/index.tsx | 279 + .../pages/Event/steps/2/MultipleItem/index.js | 213 - .../Event/steps/2/MultipleItem/index.scss | 53 - .../default/pages/Event/steps/2/index.js | 148 - .../default/pages/Event/steps/2/index.scss | 10 +- .../default/pages/Event/steps/2/index.tsx | 164 + .../pages/Event/steps/3/Modal/index.js | 308 - .../default/pages/Event/steps/3/index.js | 385 - .../default/pages/Event/steps/3/index.scss | 40 +- .../default/pages/Event/steps/3/index.tsx | 486 + .../3/modals/AssignmentCreation/index.scss | 45 + .../3/modals/AssignmentCreation/index.tsx | 207 + .../AssignmentEdition}/index.scss | 5 +- .../3/modals/AssignmentEdition/index.tsx | 231 + .../default/pages/Event/steps/4/index.js | 132 - .../default/pages/Event/steps/4/index.scss | 9 +- .../default/pages/Event/steps/4/index.tsx | 181 + .../pages/Event/steps/5/Overview/index.js | 187 - .../pages/Event/steps/5/Overview/index.scss | 253 +- .../pages/Event/steps/5/Overview/index.tsx | 260 + .../default/pages/Event/steps/5/index.js | 150 - .../default/pages/Event/steps/5/index.scss | 35 +- .../default/pages/Event/steps/5/index.tsx | 166 + .../default/pages/Event/translations/en.yml | 93 +- .../default/pages/Event/translations/fr.yml | 94 +- .../components/Footer/index.scss | 4 - .../components/Footer/index.tsx | 4 +- .../components/Header/index.tsx | 25 +- .../components/Inventory/index.scss | 20 +- .../components/Inventory/index.tsx | 56 +- .../components/Unavailable/index.tsx | 18 +- .../default/pages/EventDeparture/index.scss | 12 +- .../default/pages/EventDeparture/index.tsx | 186 +- .../pages/EventDeparture/translations/en.yml | 35 +- .../pages/EventDeparture/translations/fr.yml | 37 +- .../EventReturn/components/Footer/index.js | 87 - .../EventReturn/components/Footer/index.scss | 5 +- .../EventReturn/components/Footer/index.tsx | 7 +- .../EventReturn/components/Header/index.js | 100 - .../EventReturn/components/Header/index.tsx | 31 +- .../EventReturn/components/Inventory/index.js | 122 - .../components/Inventory/index.scss | 22 +- .../components/Inventory/index.tsx | 69 +- .../components/NotStarted/index.scss | 20 - .../components/NotStarted/index.tsx | 29 - .../components/Unavailable/index.tsx | 8 +- .../themes/default/pages/EventReturn/index.js | 275 - .../default/pages/EventReturn/index.scss | 12 +- .../default/pages/EventReturn/index.tsx | 143 +- .../pages/EventReturn/translations/en.yml | 11 + .../pages/EventReturn/translations/fr.yml | 11 + .../src/themes/default/pages/Login/index.js | 31 +- .../src/themes/default/pages/Login/index.scss | 86 +- .../MaterialEdit/components/Form/index.js | 112 +- .../MaterialEdit/components/Form/index.scss | 5 + .../default/pages/MaterialEdit/index.scss | 19 +- .../pages/MaterialEdit/translations/en.yml | 3 +- .../pages/MaterialEdit/translations/fr.yml | 3 +- .../default/pages/MaterialView/index.js | 22 +- .../default/pages/MaterialView/index.scss | 10 +- .../MaterialView/tabs/Documents/index.tsx | 2 +- .../tabs/Infos/Attributes/index.js | 40 - .../tabs/Infos/Attributes/index.scss | 20 - .../pages/MaterialView/tabs/Infos/index.scss | 37 +- .../tabs/Infos/{index.js => index.tsx} | 150 +- .../Materials/components/Quantities/index.tsx | 10 +- .../themes/default/pages/Materials/index.scss | 39 +- .../themes/default/pages/Materials/index.tsx | 149 +- .../pages/Materials/translations/en.yml | 2 +- .../pages/Materials/translations/fr.yml | 2 +- .../pages/ParkEdit/components/Form/index.js | 122 - .../pages/ParkEdit/components/Form/index.tsx | 176 + .../themes/default/pages/ParkEdit/index.js | 1 + .../themes/default/pages/ParkEdit/index.scss | 19 +- .../Parks/components/TotalAmount/index.js | 2 +- .../src/themes/default/pages/Parks/index.js | 53 +- .../src/themes/default/pages/Parks/index.scss | 21 +- .../components/BookingsViewToggle/index.tsx | 59 + .../themes/default/pages/Schedule/index.tsx | 12 + .../Schedule/pages/Calendar/_constants.ts | 37 + .../pages/Schedule/pages/Calendar/_utils.ts | 25 + .../Calendar/components/Caption/index.scss | 9 +- .../Calendar/components/Caption/index.tsx} | 43 +- .../Calendar/components/Header/index.scss | 120 + .../Calendar/components/Header/index.tsx | 261 + .../{ => Schedule/pages}/Calendar/index.scss | 35 +- .../pages/Schedule/pages/Calendar/index.tsx | 494 + .../Listing/components/Filters/index.scss | 34 + .../Listing/components/Filters/index.tsx | 211 + .../pages/Schedule/pages/Listing/index.scss | 139 + .../pages/Schedule/pages/Listing/index.tsx | 517 + .../default/pages/Schedule/pages/index.ts | 28 + .../pages/Schedule/translations/en.yml | 65 + .../pages/Schedule/translations/fr.yml | 69 + .../default/pages/Settings/Global/index.js | 33 - .../default/pages/Settings/Global/index.scss | 10 +- .../Settings/Global/pages/Calendar/index.tsx | 15 +- .../Global/pages/EventSummary/index.tsx | 37 +- .../Global/pages/Inventories/index.tsx | 9 +- .../Settings/Global/tabs/Calendar/index.js | 187 - .../Settings/Global/tabs/Calendar/index.scss | 36 - .../Global/tabs/EventSummary/index.js | 139 - .../Global/tabs/EventSummary/index.scss | 16 - .../Global/tabs/ReturnInventory/index.scss | 46 - .../Global/tabs/ReturnInventory/index.tsx | 142 - .../pages/Settings/Global/translations/en.yml | 5 + .../pages/Settings/Global/translations/fr.yml | 5 + .../default/pages/Settings/User/index.js | 18 +- .../default/pages/Settings/User/index.scss | 8 +- .../Settings/User/tabs/Interface/index.scss | 4 + .../Settings/User/tabs/Interface/index.tsx | 90 +- .../pages/Settings/User/tabs/Profile/index.js | 3 +- .../pages/Settings/User/translations/en.yml | 9 + .../pages/Settings/User/translations/fr.yml | 9 + client/src/themes/default/pages/Tags/index.js | 9 +- .../src/themes/default/pages/Tags/index.scss | 13 +- .../TechnicianEdit/components/Form/index.js | 154 - .../TechnicianEdit/components/Form/index.tsx | 206 + .../default/pages/TechnicianEdit/index.js | 3 +- .../default/pages/TechnicianEdit/index.scss | 17 +- .../pages/TechnicianEdit/translations/en.yml | 2 + .../pages/TechnicianEdit/translations/fr.yml | 2 + .../default/pages/TechnicianView/index.js | 10 +- .../default/pages/TechnicianView/index.scss | 12 +- .../TechnicianView/tabs/Documents/index.tsx | 5 +- .../TechnicianView/tabs/Infos/index.scss | 6 + .../TechnicianView/tabs/Schedule/index.js | 123 - .../TechnicianView/tabs/Schedule/index.tsx | 175 + .../pages/TechnicianView/translations/en.yml | 2 + .../pages/TechnicianView/translations/fr.yml | 2 + .../default/pages/Technicians/index.scss | 35 +- .../pages/Technicians/{index.js => index.tsx} | 171 +- .../pages/Technicians/translations/en.yml | 1 + .../pages/Technicians/translations/fr.yml | 1 + .../components/Form/ParkChooser/index.js | 64 + .../components/Form/ParkChooser/index.scss | 32 + .../pages/UserEdit/components/Form/index.js | 1 + .../themes/default/pages/UserEdit/index.js | 3 +- .../themes/default/pages/UserEdit/index.scss | 17 +- .../src/themes/default/pages/Users/index.js | 28 +- .../src/themes/default/pages/Users/index.scss | 23 +- client/src/themes/default/pages/index.js | 20 +- .../src/themes/default/stores/categories.js | 51 +- client/src/themes/default/stores/countries.js | 6 + client/src/themes/default/stores/index.js | 2 + client/src/themes/default/stores/parks.js | 67 +- client/src/themes/default/stores/tags.js | 38 +- .../default/style/components/_content.scss | 42 - .../style/components/_header-page.scss | 76 - .../style/components/_notification.scss | 6 +- .../style/components/_timeline-event.scss | 73 +- .../style/globals/variables/_base.scss | 1 + .../style/globals/variables/_colors.scss | 100 +- .../style/globals/variables/_dimensions.scss | 21 - .../style/globals/variables/index.scss | 11 +- .../globals/variables/shared/_badge.scss | 2 + .../globals/variables/shared/_calendar.scss | 29 + .../globals/variables/shared/_dropdown.scss | 8 + .../style/globals/variables/shared/_form.scss | 3 + .../globals/variables/shared/_input.scss | 48 + client/src/themes/default/style/index.scss | 5 - .../themes/default/style/parts/_button.scss | 115 - .../src/themes/default/style/parts/_form.scss | 4 + .../themes/default/style/parts/_input.scss | 32 +- .../src/themes/default/style/parts/_root.scss | 2 +- .../themes/default/style/parts/_select.scss | 25 - .../src/themes/default/style/parts/_text.scss | 3 - .../default/style/vendors/_sweetalert2.scss | 47 +- .../default/style/vendors/_v-select.scss | 27 +- .../style/vendors/_vue-pagination.scss | 6 +- .../default/style/vendors/_vue-tables.scss | 31 +- .../style/vendors/_vue2-datepicker.scss | 102 +- client/src/utils/@types.ts | 12 + client/src/utils/computeFullDurations.ts | 50 - client/src/utils/createEntityStore.js | 77 + client/src/utils/dateRoundMinutes.js | 37 - client/src/utils/datetime/_constants.ts | 61 + client/src/utils/datetime/index.ts | 1732 ++ client/src/utils/day/_constants.ts | 52 + client/src/utils/day/index.ts | 893 + client/src/utils/flattenObject.ts | 2 +- client/src/utils/formatAttributeValue.ts | 37 + .../utils/{formatBytes.js => formatBytes.ts} | 9 +- client/src/utils/formatEventTechnician.js | 46 - .../src/utils/formatEventTechniciansList.js | 31 - .../src/utils/formatEventTechniciansList.ts | 40 + client/src/utils/formatOptions.ts | 30 +- .../src/utils/formatTimelineBooking/_types.ts | 22 + .../src/utils/formatTimelineBooking/index.js | 142 - .../src/utils/formatTimelineBooking/index.ts | 215 + .../utils/getClassNames.ts | 21 +- .../utils/getStatuses.ts | 34 +- client/src/utils/getBookingIcon.ts | 112 +- client/src/utils/getEventDiscountRate.js | 15 - client/src/utils/getEventDiscountRate.ts | 20 + .../src/utils/getEventMaterialItemsCount.js | 9 - client/src/utils/getRuntimeVm.js | 13 - client/src/utils/getRuntimeVm.ts | 15 - client/src/utils/initColumnsDisplay.ts | 46 - client/src/utils/isSameDate.js | 25 - client/src/utils/parseInteger.ts | 12 + client/src/utils/period/_constants.ts | 96 + client/src/utils/period/index.ts | 682 + client/src/utils/rawDatetime/index.ts | 37 + .../src/utils/rawDatetime/plugins/explicit.ts | 28 + client/src/utils/validation.ts | 140 + client/tests/fixtures/@utils.ts | 68 + client/tests/fixtures/attributes.js | 65 + client/tests/fixtures/beneficiaries.js | 117 + client/tests/fixtures/bookings.js | 31 + client/tests/fixtures/categories.js | 49 + client/tests/fixtures/companies.js | 38 + client/tests/fixtures/countries.js | 28 + client/tests/fixtures/documents.js | 61 + client/tests/fixtures/estimates.js | 22 + client/tests/fixtures/event-materials.ts | 62 - .../tests/fixtures/event-return-materials.js | 7 - client/tests/fixtures/event-technicians.js | 53 - client/tests/fixtures/events.js | 666 + client/tests/fixtures/inventories.js | 220 + client/tests/fixtures/invoices.js | 23 + client/tests/fixtures/materials.js | 250 + client/tests/fixtures/materials.ts | 106 - client/tests/fixtures/parks.js | 57 + client/tests/fixtures/parsed/bookings.ts | 32 + client/tests/fixtures/parsed/categories.ts | 14 + client/tests/fixtures/parsed/countries.ts | 14 + client/tests/fixtures/parsed/events.ts | 14 + client/tests/fixtures/parsed/materials.ts | 19 + client/tests/fixtures/persons.js | 119 + client/tests/fixtures/settings.js | 47 + client/tests/fixtures/subcategories.js | 33 + client/tests/fixtures/tags.js | 21 + client/tests/fixtures/technicians.js | 103 + client/tests/fixtures/users.js | 102 + client/tests/serializers/datetime.ts | 7 + client/tests/serializers/day.ts | 7 + client/tests/serializers/decimal.ts | 7 + client/tests/serializers/period.ts | 25 + .../api/__snapshots__/attributes.ts.snap | 263 + .../api/__snapshots__/beneficiaries.ts.snap | 1151 + .../stores/api/__snapshots__/bookings.ts.snap | 2709 +++ .../api/__snapshots__/categories.ts.snap | 156 + .../api/__snapshots__/companies.ts.snap | 150 + .../api/__snapshots__/countries.ts.snap | 21 + .../api/__snapshots__/documents.ts.snap | 67 + .../api/__snapshots__/estimates.ts.snap | 13 + .../stores/api/__snapshots__/events.ts.snap | 18586 ++++++++++++++++ .../stores/api/__snapshots__/invoices.ts.snap | 14 + .../api/__snapshots__/materials.ts.snap | 3001 +++ .../stores/api/__snapshots__/parks.ts.snap | 465 + .../stores/api/__snapshots__/persons.ts.snap | 136 + .../stores/api/__snapshots__/session.ts.snap | 156 + .../stores/api/__snapshots__/settings.ts.snap | 196 + .../api/__snapshots__/subcategories.ts.snap | 65 + .../stores/api/__snapshots__/tags.ts.snap | 56 + .../api/__snapshots__/technicians.ts.snap | 628 + .../stores/api/__snapshots__/users.ts.snap | 423 + client/tests/specs/stores/api/attributes.ts | 33 + .../tests/specs/stores/api/beneficiaries.ts | 67 + client/tests/specs/stores/api/bookings.ts | 37 + client/tests/specs/stores/api/categories.ts | 26 + client/tests/specs/stores/api/companies.ts | 35 + client/tests/specs/stores/api/countries.ts | 12 + client/tests/specs/stores/api/documents.ts | 14 + client/tests/specs/stores/api/estimates.ts | 14 + client/tests/specs/stores/api/events.ts | 191 + client/tests/specs/stores/api/invoices.ts | 14 + client/tests/specs/stores/api/materials.ts | 92 + client/tests/specs/stores/api/parks.ts | 64 + client/tests/specs/stores/api/persons.ts | 14 + client/tests/specs/stores/api/session.ts | 21 + client/tests/specs/stores/api/settings.ts | 26 + .../tests/specs/stores/api/subcategories.ts | 19 + client/tests/specs/stores/api/tags.ts | 33 + client/tests/specs/stores/api/technicians.ts | 72 + client/tests/specs/stores/api/users.ts | 98 + client/tests/specs/utils/dateRound.js | 99 - client/tests/specs/utils/datetime.ts | 208 + client/tests/specs/utils/day.ts | 107 + .../{formatAddress.js => formatAddress.ts} | 9 +- .../tests/specs/utils/formatAttributeValue.ts | 31 + .../utils/{formatBytes.js => formatBytes.ts} | 0 .../specs/utils/formatEventTechnician.js | 22 - .../specs/utils/formatEventTechniciansList.js | 50 - .../specs/utils/formatEventTechniciansList.ts | 40 + .../tests/specs/utils/getEventDiscountRate.js | 37 - .../tests/specs/utils/getEventDiscountRate.ts | 26 + .../specs/utils/getEventMaterialItemsCount.js | 14 - .../utils/{hasIncludes.js => hasIncludes.ts} | 0 client/tests/specs/utils/isSameDate.js | 45 - .../{isValidInteger.js => isValidInteger.ts} | 0 client/tests/specs/utils/period.ts | 877 + client/tsconfig.json | 6 +- client/vendors/.eslintrc.js | 19 + client/vendors/.eslintrc.json | 7 - client/vendors/vis-timeline/index.d.ts | 63 + client/vendors/vis-timeline/package.json | 11 +- client/vue.config.js | 48 +- client/yarn.lock | 12275 +++++----- server/.htaccess | 4 +- server/bin/console | 4 +- server/composer.json | 74 +- server/composer.lock | 3238 +-- server/phpcs.xml | 337 +- server/phpstan.neon | 17 +- server/phpunit.xml | 25 +- server/src/App/App.php | 30 +- server/src/App/Config/Acl.php | 15 +- server/src/App/Config/Config.php | 60 +- server/src/App/Config/definitions.php | 24 +- server/src/App/Config/functions.php | 6 +- server/src/App/Config/routes.php | 25 +- server/src/App/Console/App.php | 58 +- .../App/Console/Command/CleanupCommand.php | 113 - .../Command/Migrations/ConfigurationTrait.php | 8 +- .../Command/Migrations/CreateCommand.php | 4 +- .../Command/Migrations/MigrateCommand.php | 2 +- .../Command/Migrations/RollbackCommand.php | 4 +- .../Command/Migrations/StatusCommand.php | 2 +- .../App/Console/Command/Test/EmailCommand.php | 56 + server/src/App/Contracts/Bookable.php | 5 +- server/src/App/Contracts/PeriodInterface.php | 13 + server/src/App/Contracts/Serializable.php | 4 +- .../App/Controllers/AttributeController.php | 38 +- server/src/App/Controllers/AuthController.php | 16 +- server/src/App/Controllers/BaseController.php | 25 +- .../App/Controllers/BeneficiaryController.php | 119 +- .../src/App/Controllers/BookingController.php | 216 +- .../App/Controllers/CalendarController.php | 168 +- .../App/Controllers/CategoryController.php | 12 +- .../src/App/Controllers/CompanyController.php | 2 +- .../src/App/Controllers/CountryController.php | 2 +- .../App/Controllers/DocumentController.php | 8 +- .../src/App/Controllers/EntryController.php | 6 +- .../App/Controllers/EstimateController.php | 2 +- .../src/App/Controllers/EventController.php | 396 +- .../Controllers/EventTechnicianController.php | 2 +- .../src/App/Controllers/InvoiceController.php | 2 +- .../App/Controllers/MaterialController.php | 189 +- server/src/App/Controllers/ParkController.php | 111 +- .../src/App/Controllers/PersonController.php | 2 +- .../src/App/Controllers/SettingController.php | 28 +- .../src/App/Controllers/SetupController.php | 46 +- .../App/Controllers/SubCategoryController.php | 2 +- server/src/App/Controllers/TagController.php | 18 +- .../App/Controllers/TechnicianController.php | 120 +- .../App/Controllers/Traits/Crud/Create.php | 2 + .../App/Controllers/Traits/Crud/GetAll.php | 43 +- .../App/Controllers/Traits/Crud/GetOne.php | 3 +- .../Controllers/Traits/Crud/HardDelete.php | 4 +- .../Controllers/Traits/Crud/SoftDelete.php | 8 +- .../App/Controllers/Traits/Crud/Update.php | 4 +- .../src/App/Controllers/Traits/WithModel.php | 4 +- server/src/App/Controllers/Traits/WithPdf.php | 2 +- server/src/App/Controllers/UserController.php | 119 +- server/src/App/Errors/Enums/ApiErrorCode.php | 41 +- .../Exception/ApiBadRequestException.php | 3 +- .../Errors/Exception/ApiConflictException.php | 3 +- .../src/App/Errors/Exception/ApiException.php | 25 +- .../Exception/ApiForbiddenException.php | 3 +- .../ApiInternalServerErrorException.php | 3 +- .../Errors/Exception/ApiNotFoundException.php | 3 +- .../Errors/Exception/ConflictException.php | 16 - .../Exception/HttpConflictException.php | 2 +- .../HttpRangeNotSatisfiableException.php | 2 +- .../HttpServiceUnavailableException.php | 2 +- .../HttpUnprocessableEntityException.php | 2 +- .../App/Errors/Renderer/HtmlErrorRenderer.php | 2 +- .../App/Errors/Renderer/JsonErrorRenderer.php | 12 +- server/src/App/Http/Enums/AppContext.php | 11 +- server/src/App/Http/Enums/FlashType.php | 13 +- server/src/App/Http/Request.php | 261 +- server/src/App/Kernel.php | 40 +- server/src/App/Middlewares/Acl.php | 6 +- server/src/App/Middlewares/BodyParser.php | 8 +- server/src/App/Middlewares/Pagination.php | 4 +- server/src/App/Middlewares/SessionStart.php | 2 +- server/src/App/Models/Attribute.php | 85 +- server/src/App/Models/BaseModel.php | 168 +- server/src/App/Models/Beneficiary.php | 156 +- server/src/App/Models/Category.php | 45 +- server/src/App/Models/Company.php | 51 +- server/src/App/Models/Country.php | 51 +- server/src/App/Models/Document.php | 38 +- .../src/App/Models/Enums/BookingViewMode.php | 16 + server/src/App/Models/Enums/Group.php | 9 +- .../Enums/PublicCalendarPeriodDisplay.php | 21 + server/src/App/Models/Estimate.php | 108 +- server/src/App/Models/EstimateMaterial.php | 2 +- server/src/App/Models/Event.php | 1237 +- server/src/App/Models/EventMaterial.php | 50 +- server/src/App/Models/EventTechnician.php | 271 +- server/src/App/Models/Invoice.php | 142 +- server/src/App/Models/InvoiceMaterial.php | 4 +- server/src/App/Models/Material.php | 174 +- server/src/App/Models/OpeningHour.php | 229 + server/src/App/Models/Park.php | 110 +- server/src/App/Models/Person.php | 46 +- server/src/App/Models/Setting.php | 55 +- server/src/App/Models/SubCategory.php | 42 +- server/src/App/Models/Tag.php | 40 +- server/src/App/Models/Technician.php | 106 +- server/src/App/Models/Traits/Cache.php | 8 +- server/src/App/Models/Traits/Pdfable.php | 2 +- server/src/App/Models/Traits/Prunable.php | 6 +- server/src/App/Models/Traits/Serializer.php | 2 +- server/src/App/Models/User.php | 155 +- .../Observers/AttributeCategoryObserver.php | 8 +- .../App/Observers/EventMaterialObserver.php | 8 +- server/src/App/Observers/EventObserver.php | 65 +- server/src/App/Observers/ParkObserver.php | 2 +- server/src/App/Services/Auth.php | 14 +- server/src/App/Services/Auth/JWT.php | 21 +- .../Services/Auth/Traits/WithUserCreation.php | 31 +- server/src/App/Services/I18n.php | 10 +- server/src/App/Services/I18n/Loader.php | 2 +- .../src/App/Services/I18n/LoaderInterface.php | 2 +- server/src/App/Services/License.php | 116 + server/src/App/Services/Logger.php | 17 +- server/src/App/Services/Mailer.php | 290 + server/src/App/Services/View.php | 164 +- server/src/App/Support/Assert.php | 49 + server/src/App/Support/BaseUri.php | 46 +- .../Collections/MaterialsCollection.php | 14 +- .../App/Support/Database/QueryAggregator.php | 28 +- .../src/App/Support/Filesystem/Filesystem.php | 8 +- .../Support/Filesystem/FilesystemFactory.php | 8 +- .../App/Support/Filesystem/UploadedFile.php | 16 +- server/src/App/Support/FullDuration.php | 49 - .../App/Support/Paginator/CursorPaginator.php | 10 + .../Paginator/LengthAwarePaginator.php | 32 + .../src/App/Support/Paginator/Paginator.php | 10 + server/src/App/Support/Pdf.php | 4 +- server/src/App/Support/Period.php | 301 +- server/src/App/Support/Str.php | 2 +- server/src/install/Install.php | 65 +- server/src/install/data/currencies.php | 8 +- server/src/locales/en/common.php | 298 - server/src/locales/en/common.yml | 119 + server/src/locales/en/date.yml | 5 + server/src/locales/en/emails.yml | 8 + server/src/locales/en/flash.php | 8 - server/src/locales/en/flash.yml | 3 + server/src/locales/en/install.php | 34 - server/src/locales/en/install.yml | 10 + server/src/locales/en/messages.php | 14 +- server/src/locales/en/validation.php | 1299 -- server/src/locales/en/validation.yml | 1333 ++ server/src/locales/fr/common.php | 298 - server/src/locales/fr/common.yml | 119 + server/src/locales/fr/date.yml | 5 + server/src/locales/fr/emails.yml | 8 + server/src/locales/fr/flash.php | 8 - server/src/locales/fr/flash.yml | 3 + server/src/locales/fr/install.php | 34 - server/src/locales/fr/install.yml | 12 + server/src/locales/fr/messages.php | 14 +- server/src/locales/fr/validation.php | 1302 -- server/src/locales/fr/validation.yml | 1336 ++ .../20181130192516_create_countries.php | 2 +- .../20181130202253_create_parks.php | 2 +- .../20181229162651_create_groups_of_users.php | 10 +- .../20190103182529_create_user_settings.php | 2 +- ...163111_fix_events_location_nullability.php | 6 +- ...0128165952_add_date_type_to_attributes.php | 2 +- ..._add_state_to_inventory_material_units.php | 6 +- ..._change_event_assignees_to_technicians.php | 10 +- .../20211204115222_add_new_settings.php | 16 +- .../20211204131448_improves_settings.php | 14 +- .../20211214140547_add_new_setting.php | 10 +- ...212093314_add_public_calendar_settings.php | 18 +- ...0521083508_removes_tags_from_companies.php | 29 +- ...invalid_dates_from_material_attributes.php | 11 +- ...14143500_allow_uncategorized_materials.php | 11 +- .../20220624221439_move_uploaded_files.php | 38 +- ...0220719071824_allow_empty_rental_price.php | 10 +- .../20220826113248_create_groups_profile.php | 94 +- .../20220826113250_create_carts.php | 18 +- .../20230108135138_re_order_unit_states.php | 10 +- ...0202140123_fix_out_of_order_quantities.php | 6 +- .../20230212091739_improve_bills_table.php | 50 +- ..._settings_and_add_notifications_toggle.php | 13 +- ...set_events_is_billable_when_no_billing.php | 10 +- ...71315_add_is_totalisable_to_attributes.php | 8 +- ...230501124722_normalize_polymorph_types.php | 14 +- ...20230501124723_improve_documents_table.php | 16 +- ...05064750_add_return_inventory_settings.php | 10 +- ...urn_quantity_column_of_event_materials.php | 27 +- ...20230517234638_improve_event_materials.php | 20 +- .../20230811194513_fix_created_at_columns.php | 8 +- ...move_computed_prices_from_reservations.php | 16 +- ..._event_technicians_dates_in_local_time.php | 12 +- ...231218174700_create_beneficiary_emails.php | 49 + .../20231218174800_create_reminders.php | 108 + ...5653_normalize_event_technician_period.php | 25 + ...estimates_and_invoices_discount_fields.php | 2 +- ...0240118115611_add_hourly_event_support.php | 431 + ...612_add_mobilization_dates_to_bookable.php | 175 + ...ooking_list_type_to_beneficiary_emails.php | 41 + ...240227215604_create_opening_hour_table.php | 46 + ...626_add_event_summary_display_settings.php | 58 + ...sheet_for_reservation_approved_setting.php | 31 + ...add_default_bookings_view_mode_to_user.php | 27 + ...40426070434_fix_technician_assignments.php | 27 + .../20240429093735_create_permalinks.php | 32 + server/src/public/.htaccess | 5 +- server/src/public/favicon.ico | Bin 4286 -> 15406 bytes server/src/public/icons/apple-touch-icon.png | Bin 5834 -> 4917 bytes server/src/public/icons/favicon-16x16.png | Bin 503 -> 0 bytes server/src/public/icons/favicon-32x32.png | Bin 1004 -> 0 bytes server/src/public/icons/icon-16x16.png | Bin 0 -> 625 bytes server/src/public/icons/icon-192x192.png | Bin 0 -> 7653 bytes server/src/public/icons/icon-32x32.png | Bin 0 -> 1271 bytes server/src/public/icons/icon-512x512.png | Bin 0 -> 21615 bytes server/src/public/icons/icon.svg | 25 + .../icons/msapplication-icon-144x144.png | Bin 4669 -> 0 bytes server/src/public/icons/pwa-192x192.png | Bin 7780 -> 0 bytes server/src/public/icons/pwa-512x512.png | Bin 21031 -> 0 bytes server/src/public/icons/safari-pinned-tab.svg | 10 - .../img/material-picture-placeholder.png | Bin 0 -> 1076 bytes server/src/public/index.php | 4 +- .../installer/css/components/install.css | 17 +- .../public/installer/css/layout/header.css | 24 +- server/src/public/manifest.json | 23 +- server/src/views/base.twig | 33 +- server/src/views/blocks/beneficiary.twig | 18 +- server/src/views/blocks/company-address.twig | 46 +- .../src/views/blocks/materials/attribute.twig | 12 +- .../materials/event-summary-by-lists.twig | 71 +- .../materials/event-summary-by-parks.twig | 34 +- .../blocks/materials/line-event-summary.twig | 33 +- .../src/views/blocks/materials/line-list.twig | 4 +- server/src/views/components/logo.svg.twig | 49 +- server/src/views/entries/default.twig | 6 +- server/src/views/errors/base.twig | 217 +- server/src/views/install.twig | 8 +- server/src/views/install/en/adminUser.twig | 6 +- server/src/views/install/en/categories.twig | 4 +- server/src/views/install/en/company.twig | 8 +- server/src/views/install/en/coreSettings.twig | 2 +- server/src/views/install/en/database.twig | 8 +- server/src/views/install/en/settings.twig | 4 +- server/src/views/install/en/welcome.twig | 50 +- server/src/views/install/fr/adminUser.twig | 6 +- server/src/views/install/fr/categories.twig | 4 +- server/src/views/install/fr/company.twig | 8 +- server/src/views/install/fr/coreSettings.twig | 2 +- server/src/views/install/fr/database.twig | 8 +- server/src/views/install/fr/settings.twig | 4 +- server/src/views/install/fr/welcome.twig | 48 +- server/src/views/pdf/base.twig | 3 + server/src/views/pdf/estimate-default.twig | 120 +- .../src/views/pdf/event-summary-default.twig | 92 +- server/src/views/pdf/invoice-default.twig | 114 +- .../src/views/pdf/materials-list-default.twig | 44 +- server/tests/ApiTestCase.php | 112 +- server/tests/ApiTestClient.php | 82 +- server/tests/TestCase.php | 13 +- server/tests/bootstrap.php | 20 +- server/tests/constants.php | 4 +- server/tests/endpoints/AttributesTest.php | 74 +- server/tests/endpoints/BeneficiariesTest.php | 62 +- server/tests/endpoints/BookingsTest.php | 133 +- server/tests/endpoints/CalendarTest.php | 22 +- server/tests/endpoints/CategoriesTest.php | 37 +- server/tests/endpoints/CompaniesTest.php | 4 +- server/tests/endpoints/CountriesTest.php | 4 +- server/tests/endpoints/DocumentsTest.php | 14 +- server/tests/endpoints/EstimatesTest.php | 6 +- .../tests/endpoints/EventTechniciansTest.php | 67 +- server/tests/endpoints/EventsTest.php | 958 +- server/tests/endpoints/InvoicesTest.php | 6 +- server/tests/endpoints/MaterialsTest.php | 339 +- server/tests/endpoints/ParksTest.php | 58 +- server/tests/endpoints/PersonsTest.php | 4 +- server/tests/endpoints/SessionTest.php | 21 +- server/tests/endpoints/SettingsTest.php | 70 +- server/tests/endpoints/SubCategoriesTest.php | 6 +- server/tests/endpoints/TagsTest.php | 4 +- server/tests/endpoints/TechniciansTest.php | 58 +- server/tests/endpoints/UsersTest.php | 124 +- server/tests/events/AttributeCategoryTest.php | 40 +- server/tests/events/EventMaterialTest.php | 2 +- server/tests/events/EventTest.php | 42 +- server/tests/fixtures/Dataseed.php | 12 +- server/tests/fixtures/Fixtures.php | 23 +- .../files/imports/beneficiaries-invalid.csv | 4 + .../fixtures/files/imports/beneficiaries.csv | 11 + server/tests/fixtures/seed/beneficiaries.json | 6 +- .../fixtures/seed/beneficiary_emails.json | 2 + server/tests/fixtures/seed/estimates.json | 3 +- .../seed/event_reminder_recipients.json | 2 + .../tests/fixtures/seed/event_reminders.json | 2 + .../fixtures/seed/event_technicians.json | 12 +- server/tests/fixtures/seed/events.json | 57 +- server/tests/fixtures/seed/invoices.json | 3 +- server/tests/fixtures/seed/opening_hours.json | 44 + server/tests/fixtures/seed/permalinks.json | 2 + .../seed/reservation_reminder_recipients.json | 2 + .../fixtures/seed/reservation_reminders.json | 2 + server/tests/fixtures/seed/settings.json | 20 + server/tests/fixtures/seed/users.json | 7 +- .../snapshots/CalendarTest__testPublic__1.txt | 25 +- .../snapshots/CalendarTest__testPublic__2.txt | 63 + .../snapshots/CalendarTest__testPublic__3.txt | 110 + .../snapshots/EstimateTest__testToPdf__1.html | 34 +- .../snapshots/EstimateTest__testToPdf__2.html | 167 +- .../snapshots/EstimateTest__testToPdf__3.html | 384 + .../EstimatesTest__testDownloadPdf__1.html | 34 +- .../snapshots/EventTest__testToPdf__1.html | 30 +- .../snapshots/EventTest__testToPdf__2.html | 22 +- .../EventsTest__testDownloadPdf__1.html | 30 +- .../EventsTest__testDownloadPdf__2.html | 22 +- .../EventsTest__testDownloadPdf__3.html | 28 +- .../EventsTest__testDownloadPdf__4.html | 38 +- .../EventsTest__testDownloadPdf__5.html | 306 + ...Test__testSendSummaryToTechnicians__1.html | 29 + ...Test__testSendSummaryToTechnicians__2.html | 29 + .../snapshots/InvoiceTest__testToPdf__1.html | 34 +- .../snapshots/InvoiceTest__testToPdf__2.html | 34 +- .../snapshots/InvoiceTest__testToPdf__3.html | 161 +- .../snapshots/InvoiceTest__testToPdf__4.html | 378 + .../InvoicesTest__testDownloadPdf__1.html | 32 +- .../MaterialsTest__testGetAllPdf__1.html | 12 +- server/tests/models/AttributeTest.php | 6 +- server/tests/models/BeneficiaryTest.php | 38 +- server/tests/models/EstimateTest.php | 113 +- server/tests/models/EventMaterialTest.php | 2 +- server/tests/models/EventTechnicianTest.php | 296 +- server/tests/models/EventTest.php | 515 +- server/tests/models/InvoiceTest.php | 114 +- server/tests/models/MaterialTest.php | 17 +- server/tests/models/OpeningHourTest.php | 128 + server/tests/models/ParkTest.php | 8 +- server/tests/models/PersonTest.php | 2 +- server/tests/models/SettingTest.php | 52 +- server/tests/models/UserTest.php | 17 +- server/tests/supports/AssertTest.php | 63 + .../tests/{libs => supports}/BaseUriTest.php | 0 server/tests/{libs => supports}/PdfTest.php | 2 +- server/tests/supports/PeriodTest.php | 520 + .../collections/MaterialsCollectionTest.php | 0 1005 files changed, 78312 insertions(+), 31027 deletions(-) create mode 100644 .resources/logo-dark.svg create mode 100644 .resources/logo-light.svg delete mode 100644 .resources/logo.svg create mode 100644 client/patches/vue2-datepicker+3.11.1.patch rename client/src/globals/{config.js => config.ts} (96%) delete mode 100644 client/src/globals/constants.js create mode 100644 client/src/globals/constants.ts delete mode 100644 client/src/globals/queryClient.ts create mode 100644 client/src/globals/rawDatetime.ts create mode 100644 client/src/globals/types/globals.d.ts create mode 100644 client/src/globals/types/vendors/dayjs.d.ts create mode 100644 client/src/globals/types/vendors/vue-simple-calendar.d.ts create mode 100644 client/src/globals/types/vendors/vue-toasted.d.ts delete mode 100644 client/src/hooks/useI18n.ts delete mode 100644 client/src/hooks/useRouteId.js delete mode 100644 client/src/hooks/useRoutePage.ts delete mode 100644 client/src/hooks/useRouter.ts create mode 100644 client/src/locale/en/date.js create mode 100644 client/src/locale/fr/date.js create mode 100644 client/src/stores/api/@schema.ts create mode 100644 client/src/themes/default/components/Alert/_variables.scss create mode 100644 client/src/themes/default/components/Alert/index.scss create mode 100644 client/src/themes/default/components/Alert/index.tsx create mode 100644 client/src/themes/default/components/DatePicker/__tests__/utils/normalizer.spec.ts create mode 100644 client/src/themes/default/components/DatePicker/_types.ts rename client/src/themes/default/components/{Datepicker => DatePicker}/index.scss (73%) rename client/src/themes/default/components/{Datepicker => DatePicker}/index.tsx (68%) rename client/src/themes/default/components/{Datepicker => DatePicker}/translations/en.yml (100%) rename client/src/themes/default/components/{Datepicker => DatePicker}/translations/fr.yml (100%) create mode 100644 client/src/themes/default/components/DatePicker/utils/normalizer.ts create mode 100644 client/src/themes/default/components/DatePicker/utils/snippets.ts delete mode 100644 client/src/themes/default/components/Datepicker/_types.ts delete mode 100644 client/src/themes/default/components/Datepicker/_utils.ts delete mode 100644 client/src/themes/default/components/Datepicker/index.js delete mode 100644 client/src/themes/default/components/Datepicker/locale/index.js create mode 100644 client/src/themes/default/components/Dropdown/components/Transition/index.scss create mode 100644 client/src/themes/default/components/Dropdown/components/Transition/index.tsx create mode 100644 client/src/themes/default/components/Embed/index.scss create mode 100644 client/src/themes/default/components/Embed/index.tsx delete mode 100644 client/src/themes/default/components/EmptyMessage/_variables.scss delete mode 100644 client/src/themes/default/components/EmptyMessage/illustrations/general.svg delete mode 100644 client/src/themes/default/components/EmptyMessage/illustrations/search.svg delete mode 100644 client/src/themes/default/components/EmptyMessage/index.scss delete mode 100644 client/src/themes/default/components/Inventory/Item/Material/index.js delete mode 100644 client/src/themes/default/components/Inventory/Item/Material/index.scss delete mode 100644 client/src/themes/default/components/Inventory/Item/index.js create mode 100644 client/src/themes/default/components/Inventory/components/Item/index.scss delete mode 100644 client/src/themes/default/components/Loading/index.js create mode 100644 client/src/themes/default/components/Loading/index.tsx delete mode 100644 client/src/themes/default/components/Logo/assets/logo-R.svg delete mode 100644 client/src/themes/default/components/Logo/assets/logo.svg rename client/src/themes/default/components/MaterialsSorted/{ => components}/CategoryItem/Material/index.scss (96%) rename client/src/themes/default/components/MaterialsSorted/{ => components}/CategoryItem/Material/index.tsx (64%) rename client/src/themes/default/components/MaterialsSorted/{ => components}/CategoryItem/index.scss (100%) rename client/src/themes/default/components/MaterialsSorted/{ => components}/CategoryItem/index.tsx (73%) delete mode 100644 client/src/themes/default/components/MaterialsSorted/translations/en.yml delete mode 100644 client/src/themes/default/components/MaterialsSorted/translations/fr.yml delete mode 100644 client/src/themes/default/components/MonthCalendar/index.js create mode 100644 client/src/themes/default/components/MonthCalendar/index.tsx delete mode 100644 client/src/themes/default/components/MultiSwitch/Option/index.js create mode 100644 client/src/themes/default/components/MultiSwitch/Option/index.tsx delete mode 100644 client/src/themes/default/components/MultiSwitch/index.js create mode 100644 client/src/themes/default/components/MultiSwitch/index.tsx create mode 100644 client/src/themes/default/components/Popover/Material/components/Popup/index.scss create mode 100644 client/src/themes/default/components/Popover/Material/components/Popup/index.tsx create mode 100644 client/src/themes/default/components/Popover/Material/components/Transition/index.scss create mode 100644 client/src/themes/default/components/Popover/Material/components/Transition/index.tsx create mode 100644 client/src/themes/default/components/Popover/Material/index.scss create mode 100644 client/src/themes/default/components/Popover/Material/index.tsx create mode 100644 client/src/themes/default/components/Popover/Material/translations/en.yml create mode 100644 client/src/themes/default/components/Popover/Material/translations/fr.yml create mode 100644 client/src/themes/default/components/Progressbar/_variables.scss create mode 100644 client/src/themes/default/components/RelativeTime/index.scss create mode 100644 client/src/themes/default/components/RelativeTime/index.tsx delete mode 100644 client/src/themes/default/components/SwitchToggle/index.js create mode 100644 client/src/themes/default/components/SwitchToggle/index.tsx create mode 100644 client/src/themes/default/components/Table/Client/_types.ts create mode 100644 client/src/themes/default/components/Table/Client/index.tsx create mode 100644 client/src/themes/default/components/Table/index.scss delete mode 100644 client/src/themes/default/components/Textarea/index.js create mode 100644 client/src/themes/default/components/Timeline/_types.ts delete mode 100644 client/src/themes/default/components/Timeline/_utils.js create mode 100644 client/src/themes/default/components/Timeline/_utils.ts create mode 100644 client/src/themes/default/components/Timeline/_variables.scss delete mode 100644 client/src/themes/default/components/Timeline/index.js create mode 100644 client/src/themes/default/components/Timeline/index.tsx delete mode 100644 client/src/themes/default/globals/store.js rename client/src/themes/default/layouts/Default/components/Sidebar/Menu/{index.js => index.tsx} (68%) delete mode 100644 client/src/themes/default/modals/EventDetails/components/Header/Actions/index.js delete mode 100644 client/src/themes/default/modals/EventDetails/components/Header/Actions/index.scss delete mode 100644 client/src/themes/default/modals/EventDetails/components/Header/index.js create mode 100644 client/src/themes/default/modals/EventDetails/components/Header/index.tsx rename client/src/themes/default/modals/EventDetails/{index.js => index.tsx} (67%) rename client/src/themes/default/modals/EventDetails/tabs/Estimates/Estimate/{index.js => index.tsx} (80%) rename client/src/themes/default/modals/EventDetails/tabs/Estimates/{index.js => index.tsx} (83%) rename client/src/themes/default/modals/EventDetails/tabs/Infos/components/MainBeneficiary/{index.js => index.tsx} (66%) rename client/src/themes/default/modals/EventDetails/tabs/Infos/{index.js => index.tsx} (80%) rename client/src/themes/default/modals/EventDetails/tabs/Invoices/{index.js => index.tsx} (84%) rename client/src/themes/default/modals/EventDetails/tabs/Materials/{index.js => index.tsx} (56%) delete mode 100644 client/src/themes/default/modals/EventDetails/tabs/Technicians/index.js create mode 100644 client/src/themes/default/modals/EventDetails/tabs/Technicians/index.tsx delete mode 100644 client/src/themes/default/pages/Attributes/components/Item/index.js delete mode 100644 client/src/themes/default/pages/Attributes/components/Item/index.scss delete mode 100644 client/src/themes/default/pages/Attributes/index.js create mode 100644 client/src/themes/default/pages/Attributes/index.tsx rename client/src/themes/default/pages/Beneficiaries/{index.js => index.tsx} (62%) delete mode 100644 client/src/themes/default/pages/BeneficiaryEdit/components/Form/CompanySelect/index.js create mode 100644 client/src/themes/default/pages/BeneficiaryEdit/components/Form/CompanySelect/index.tsx delete mode 100644 client/src/themes/default/pages/BeneficiaryEdit/components/Form/index.js create mode 100644 client/src/themes/default/pages/BeneficiaryEdit/components/Form/index.tsx create mode 100644 client/src/themes/default/pages/BeneficiaryView/_types.ts delete mode 100644 client/src/themes/default/pages/Calendar/_utils.js delete mode 100644 client/src/themes/default/pages/Calendar/components/Header/index.js delete mode 100644 client/src/themes/default/pages/Calendar/components/Header/index.scss delete mode 100644 client/src/themes/default/pages/Calendar/index.js delete mode 100644 client/src/themes/default/pages/Calendar/translations/en.yml delete mode 100644 client/src/themes/default/pages/Calendar/translations/fr.yml delete mode 100644 client/src/themes/default/pages/CompanyEdit/components/Form/index.js create mode 100644 client/src/themes/default/pages/CompanyEdit/components/Form/index.tsx rename client/src/themes/default/pages/CompanyEdit/{index.js => index.tsx} (71%) delete mode 100644 client/src/themes/default/pages/Event/EventStore.js delete mode 100644 client/src/themes/default/pages/Event/components/MiniSummary/index.js create mode 100644 client/src/themes/default/pages/Event/components/MiniSummary/index.tsx delete mode 100644 client/src/themes/default/pages/Event/steps/1/index.js create mode 100644 client/src/themes/default/pages/Event/steps/1/index.tsx create mode 100644 client/src/themes/default/pages/Event/steps/2/BeneficiariesSelect/index.scss create mode 100644 client/src/themes/default/pages/Event/steps/2/BeneficiariesSelect/index.tsx delete mode 100644 client/src/themes/default/pages/Event/steps/2/MultipleItem/index.js delete mode 100644 client/src/themes/default/pages/Event/steps/2/MultipleItem/index.scss delete mode 100644 client/src/themes/default/pages/Event/steps/2/index.js create mode 100644 client/src/themes/default/pages/Event/steps/2/index.tsx delete mode 100644 client/src/themes/default/pages/Event/steps/3/Modal/index.js delete mode 100644 client/src/themes/default/pages/Event/steps/3/index.js create mode 100644 client/src/themes/default/pages/Event/steps/3/index.tsx create mode 100644 client/src/themes/default/pages/Event/steps/3/modals/AssignmentCreation/index.scss create mode 100644 client/src/themes/default/pages/Event/steps/3/modals/AssignmentCreation/index.tsx rename client/src/themes/default/pages/Event/steps/3/{Modal => modals/AssignmentEdition}/index.scss (87%) create mode 100644 client/src/themes/default/pages/Event/steps/3/modals/AssignmentEdition/index.tsx delete mode 100644 client/src/themes/default/pages/Event/steps/4/index.js create mode 100644 client/src/themes/default/pages/Event/steps/4/index.tsx delete mode 100644 client/src/themes/default/pages/Event/steps/5/Overview/index.js create mode 100644 client/src/themes/default/pages/Event/steps/5/Overview/index.tsx delete mode 100644 client/src/themes/default/pages/Event/steps/5/index.js create mode 100644 client/src/themes/default/pages/Event/steps/5/index.tsx delete mode 100644 client/src/themes/default/pages/EventReturn/components/Footer/index.js delete mode 100644 client/src/themes/default/pages/EventReturn/components/Header/index.js delete mode 100644 client/src/themes/default/pages/EventReturn/components/Inventory/index.js delete mode 100644 client/src/themes/default/pages/EventReturn/components/NotStarted/index.scss delete mode 100644 client/src/themes/default/pages/EventReturn/components/NotStarted/index.tsx delete mode 100644 client/src/themes/default/pages/EventReturn/index.js delete mode 100644 client/src/themes/default/pages/MaterialView/tabs/Infos/Attributes/index.js delete mode 100644 client/src/themes/default/pages/MaterialView/tabs/Infos/Attributes/index.scss rename client/src/themes/default/pages/MaterialView/tabs/Infos/{index.js => index.tsx} (65%) delete mode 100644 client/src/themes/default/pages/ParkEdit/components/Form/index.js create mode 100644 client/src/themes/default/pages/ParkEdit/components/Form/index.tsx create mode 100644 client/src/themes/default/pages/Schedule/components/BookingsViewToggle/index.tsx create mode 100644 client/src/themes/default/pages/Schedule/index.tsx create mode 100644 client/src/themes/default/pages/Schedule/pages/Calendar/_constants.ts create mode 100644 client/src/themes/default/pages/Schedule/pages/Calendar/_utils.ts rename client/src/themes/default/pages/{ => Schedule/pages}/Calendar/components/Caption/index.scss (69%) rename client/src/themes/default/pages/{Calendar/components/Caption/index.js => Schedule/pages/Calendar/components/Caption/index.tsx} (55%) create mode 100644 client/src/themes/default/pages/Schedule/pages/Calendar/components/Header/index.scss create mode 100644 client/src/themes/default/pages/Schedule/pages/Calendar/components/Header/index.tsx rename client/src/themes/default/pages/{ => Schedule/pages}/Calendar/index.scss (52%) create mode 100644 client/src/themes/default/pages/Schedule/pages/Calendar/index.tsx create mode 100644 client/src/themes/default/pages/Schedule/pages/Listing/components/Filters/index.scss create mode 100644 client/src/themes/default/pages/Schedule/pages/Listing/components/Filters/index.tsx create mode 100644 client/src/themes/default/pages/Schedule/pages/Listing/index.scss create mode 100644 client/src/themes/default/pages/Schedule/pages/Listing/index.tsx create mode 100644 client/src/themes/default/pages/Schedule/pages/index.ts create mode 100644 client/src/themes/default/pages/Schedule/translations/en.yml create mode 100644 client/src/themes/default/pages/Schedule/translations/fr.yml delete mode 100644 client/src/themes/default/pages/Settings/Global/index.js delete mode 100644 client/src/themes/default/pages/Settings/Global/tabs/Calendar/index.js delete mode 100644 client/src/themes/default/pages/Settings/Global/tabs/Calendar/index.scss delete mode 100644 client/src/themes/default/pages/Settings/Global/tabs/EventSummary/index.js delete mode 100644 client/src/themes/default/pages/Settings/Global/tabs/EventSummary/index.scss delete mode 100644 client/src/themes/default/pages/Settings/Global/tabs/ReturnInventory/index.scss delete mode 100644 client/src/themes/default/pages/Settings/Global/tabs/ReturnInventory/index.tsx delete mode 100644 client/src/themes/default/pages/TechnicianEdit/components/Form/index.js create mode 100644 client/src/themes/default/pages/TechnicianEdit/components/Form/index.tsx delete mode 100644 client/src/themes/default/pages/TechnicianView/tabs/Schedule/index.js create mode 100644 client/src/themes/default/pages/TechnicianView/tabs/Schedule/index.tsx rename client/src/themes/default/pages/Technicians/{index.js => index.tsx} (68%) create mode 100644 client/src/themes/default/pages/UserEdit/components/Form/ParkChooser/index.js create mode 100644 client/src/themes/default/pages/UserEdit/components/Form/ParkChooser/index.scss create mode 100644 client/src/themes/default/stores/countries.js delete mode 100644 client/src/themes/default/style/components/_content.scss delete mode 100644 client/src/themes/default/style/components/_header-page.scss create mode 100644 client/src/themes/default/style/globals/variables/_base.scss create mode 100644 client/src/themes/default/style/globals/variables/shared/_badge.scss create mode 100644 client/src/themes/default/style/globals/variables/shared/_calendar.scss create mode 100644 client/src/themes/default/style/globals/variables/shared/_dropdown.scss create mode 100644 client/src/themes/default/style/globals/variables/shared/_form.scss create mode 100644 client/src/themes/default/style/globals/variables/shared/_input.scss delete mode 100644 client/src/themes/default/style/parts/_button.scss delete mode 100644 client/src/themes/default/style/parts/_select.scss delete mode 100644 client/src/themes/default/style/parts/_text.scss create mode 100644 client/src/utils/@types.ts delete mode 100644 client/src/utils/computeFullDurations.ts create mode 100644 client/src/utils/createEntityStore.js delete mode 100644 client/src/utils/dateRoundMinutes.js create mode 100644 client/src/utils/datetime/_constants.ts create mode 100644 client/src/utils/datetime/index.ts create mode 100644 client/src/utils/day/_constants.ts create mode 100644 client/src/utils/day/index.ts create mode 100644 client/src/utils/formatAttributeValue.ts rename client/src/utils/{formatBytes.js => formatBytes.ts} (57%) delete mode 100644 client/src/utils/formatEventTechnician.js delete mode 100644 client/src/utils/formatEventTechniciansList.js create mode 100644 client/src/utils/formatEventTechniciansList.ts create mode 100644 client/src/utils/formatTimelineBooking/_types.ts delete mode 100644 client/src/utils/formatTimelineBooking/index.js create mode 100644 client/src/utils/formatTimelineBooking/index.ts delete mode 100644 client/src/utils/getEventDiscountRate.js create mode 100644 client/src/utils/getEventDiscountRate.ts delete mode 100644 client/src/utils/getEventMaterialItemsCount.js delete mode 100644 client/src/utils/getRuntimeVm.js delete mode 100644 client/src/utils/getRuntimeVm.ts delete mode 100644 client/src/utils/initColumnsDisplay.ts delete mode 100644 client/src/utils/isSameDate.js create mode 100644 client/src/utils/parseInteger.ts create mode 100644 client/src/utils/period/_constants.ts create mode 100644 client/src/utils/period/index.ts create mode 100644 client/src/utils/rawDatetime/index.ts create mode 100644 client/src/utils/rawDatetime/plugins/explicit.ts create mode 100644 client/src/utils/validation.ts create mode 100644 client/tests/fixtures/@utils.ts create mode 100644 client/tests/fixtures/attributes.js create mode 100644 client/tests/fixtures/beneficiaries.js create mode 100644 client/tests/fixtures/bookings.js create mode 100644 client/tests/fixtures/categories.js create mode 100644 client/tests/fixtures/companies.js create mode 100644 client/tests/fixtures/countries.js create mode 100644 client/tests/fixtures/documents.js create mode 100644 client/tests/fixtures/estimates.js delete mode 100644 client/tests/fixtures/event-materials.ts delete mode 100644 client/tests/fixtures/event-return-materials.js delete mode 100644 client/tests/fixtures/event-technicians.js create mode 100644 client/tests/fixtures/events.js create mode 100644 client/tests/fixtures/inventories.js create mode 100644 client/tests/fixtures/invoices.js create mode 100644 client/tests/fixtures/materials.js delete mode 100644 client/tests/fixtures/materials.ts create mode 100644 client/tests/fixtures/parks.js create mode 100644 client/tests/fixtures/parsed/bookings.ts create mode 100644 client/tests/fixtures/parsed/categories.ts create mode 100644 client/tests/fixtures/parsed/countries.ts create mode 100644 client/tests/fixtures/parsed/events.ts create mode 100644 client/tests/fixtures/parsed/materials.ts create mode 100644 client/tests/fixtures/persons.js create mode 100644 client/tests/fixtures/settings.js create mode 100644 client/tests/fixtures/subcategories.js create mode 100644 client/tests/fixtures/tags.js create mode 100644 client/tests/fixtures/technicians.js create mode 100644 client/tests/fixtures/users.js create mode 100644 client/tests/serializers/datetime.ts create mode 100644 client/tests/serializers/day.ts create mode 100644 client/tests/serializers/decimal.ts create mode 100644 client/tests/serializers/period.ts create mode 100644 client/tests/specs/stores/api/__snapshots__/attributes.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/beneficiaries.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/bookings.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/categories.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/companies.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/countries.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/documents.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/estimates.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/events.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/invoices.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/materials.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/parks.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/persons.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/session.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/settings.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/subcategories.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/tags.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/technicians.ts.snap create mode 100644 client/tests/specs/stores/api/__snapshots__/users.ts.snap create mode 100644 client/tests/specs/stores/api/attributes.ts create mode 100644 client/tests/specs/stores/api/beneficiaries.ts create mode 100644 client/tests/specs/stores/api/bookings.ts create mode 100644 client/tests/specs/stores/api/categories.ts create mode 100644 client/tests/specs/stores/api/companies.ts create mode 100644 client/tests/specs/stores/api/countries.ts create mode 100644 client/tests/specs/stores/api/documents.ts create mode 100644 client/tests/specs/stores/api/estimates.ts create mode 100644 client/tests/specs/stores/api/events.ts create mode 100644 client/tests/specs/stores/api/invoices.ts create mode 100644 client/tests/specs/stores/api/materials.ts create mode 100644 client/tests/specs/stores/api/parks.ts create mode 100644 client/tests/specs/stores/api/persons.ts create mode 100644 client/tests/specs/stores/api/session.ts create mode 100644 client/tests/specs/stores/api/settings.ts create mode 100644 client/tests/specs/stores/api/subcategories.ts create mode 100644 client/tests/specs/stores/api/tags.ts create mode 100644 client/tests/specs/stores/api/technicians.ts create mode 100644 client/tests/specs/stores/api/users.ts delete mode 100644 client/tests/specs/utils/dateRound.js create mode 100644 client/tests/specs/utils/datetime.ts create mode 100644 client/tests/specs/utils/day.ts rename client/tests/specs/utils/{formatAddress.js => formatAddress.ts} (90%) create mode 100644 client/tests/specs/utils/formatAttributeValue.ts rename client/tests/specs/utils/{formatBytes.js => formatBytes.ts} (100%) delete mode 100644 client/tests/specs/utils/formatEventTechnician.js delete mode 100644 client/tests/specs/utils/formatEventTechniciansList.js create mode 100644 client/tests/specs/utils/formatEventTechniciansList.ts delete mode 100644 client/tests/specs/utils/getEventDiscountRate.js create mode 100644 client/tests/specs/utils/getEventDiscountRate.ts delete mode 100644 client/tests/specs/utils/getEventMaterialItemsCount.js rename client/tests/specs/utils/{hasIncludes.js => hasIncludes.ts} (100%) delete mode 100644 client/tests/specs/utils/isSameDate.js rename client/tests/specs/utils/{isValidInteger.js => isValidInteger.ts} (100%) create mode 100644 client/tests/specs/utils/period.ts create mode 100644 client/vendors/.eslintrc.js delete mode 100644 client/vendors/.eslintrc.json create mode 100644 client/vendors/vis-timeline/index.d.ts delete mode 100644 server/src/App/Console/Command/CleanupCommand.php create mode 100644 server/src/App/Console/Command/Test/EmailCommand.php delete mode 100644 server/src/App/Errors/Exception/ConflictException.php create mode 100644 server/src/App/Models/Enums/BookingViewMode.php create mode 100644 server/src/App/Models/Enums/PublicCalendarPeriodDisplay.php create mode 100644 server/src/App/Models/OpeningHour.php create mode 100644 server/src/App/Services/License.php create mode 100644 server/src/App/Services/Mailer.php create mode 100644 server/src/App/Support/Assert.php delete mode 100644 server/src/App/Support/FullDuration.php create mode 100644 server/src/App/Support/Paginator/CursorPaginator.php create mode 100644 server/src/App/Support/Paginator/LengthAwarePaginator.php create mode 100644 server/src/App/Support/Paginator/Paginator.php delete mode 100644 server/src/locales/en/common.php create mode 100644 server/src/locales/en/common.yml create mode 100644 server/src/locales/en/date.yml create mode 100644 server/src/locales/en/emails.yml delete mode 100644 server/src/locales/en/flash.php create mode 100644 server/src/locales/en/flash.yml delete mode 100644 server/src/locales/en/install.php create mode 100644 server/src/locales/en/install.yml delete mode 100644 server/src/locales/en/validation.php create mode 100644 server/src/locales/en/validation.yml delete mode 100644 server/src/locales/fr/common.php create mode 100644 server/src/locales/fr/common.yml create mode 100644 server/src/locales/fr/date.yml create mode 100644 server/src/locales/fr/emails.yml delete mode 100644 server/src/locales/fr/flash.php create mode 100644 server/src/locales/fr/flash.yml delete mode 100644 server/src/locales/fr/install.php create mode 100644 server/src/locales/fr/install.yml delete mode 100644 server/src/locales/fr/validation.php create mode 100644 server/src/locales/fr/validation.yml create mode 100644 server/src/migrations/20231218174700_create_beneficiary_emails.php create mode 100644 server/src/migrations/20231218174800_create_reminders.php create mode 100644 server/src/migrations/20240108205653_normalize_event_technician_period.php create mode 100644 server/src/migrations/20240118115611_add_hourly_event_support.php create mode 100644 server/src/migrations/20240118115612_add_mobilization_dates_to_bookable.php create mode 100644 server/src/migrations/20240205170739_add_booking_list_type_to_beneficiary_emails.php create mode 100644 server/src/migrations/20240227215604_create_opening_hour_table.php create mode 100644 server/src/migrations/20240229101626_add_event_summary_display_settings.php create mode 100644 server/src/migrations/20240312211528_add_attach_release_sheet_for_reservation_approved_setting.php create mode 100644 server/src/migrations/20240418094118_add_default_bookings_view_mode_to_user.php create mode 100644 server/src/migrations/20240426070434_fix_technician_assignments.php create mode 100644 server/src/migrations/20240429093735_create_permalinks.php delete mode 100644 server/src/public/icons/favicon-16x16.png delete mode 100644 server/src/public/icons/favicon-32x32.png create mode 100644 server/src/public/icons/icon-16x16.png create mode 100644 server/src/public/icons/icon-192x192.png create mode 100644 server/src/public/icons/icon-32x32.png create mode 100644 server/src/public/icons/icon-512x512.png create mode 100644 server/src/public/icons/icon.svg delete mode 100644 server/src/public/icons/msapplication-icon-144x144.png delete mode 100644 server/src/public/icons/pwa-192x192.png delete mode 100644 server/src/public/icons/pwa-512x512.png delete mode 100644 server/src/public/icons/safari-pinned-tab.svg create mode 100644 server/src/public/img/material-picture-placeholder.png create mode 100644 server/tests/fixtures/files/imports/beneficiaries-invalid.csv create mode 100644 server/tests/fixtures/files/imports/beneficiaries.csv create mode 100644 server/tests/fixtures/seed/beneficiary_emails.json create mode 100644 server/tests/fixtures/seed/event_reminder_recipients.json create mode 100644 server/tests/fixtures/seed/event_reminders.json create mode 100644 server/tests/fixtures/seed/opening_hours.json create mode 100644 server/tests/fixtures/seed/permalinks.json create mode 100644 server/tests/fixtures/seed/reservation_reminder_recipients.json create mode 100644 server/tests/fixtures/seed/reservation_reminders.json create mode 100644 server/tests/fixtures/snapshots/CalendarTest__testPublic__2.txt create mode 100644 server/tests/fixtures/snapshots/CalendarTest__testPublic__3.txt create mode 100644 server/tests/fixtures/snapshots/EstimateTest__testToPdf__3.html create mode 100644 server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__5.html create mode 100644 server/tests/fixtures/snapshots/EventsTest__testSendSummaryToTechnicians__1.html create mode 100644 server/tests/fixtures/snapshots/EventsTest__testSendSummaryToTechnicians__2.html create mode 100644 server/tests/fixtures/snapshots/InvoiceTest__testToPdf__4.html create mode 100644 server/tests/models/OpeningHourTest.php create mode 100644 server/tests/supports/AssertTest.php rename server/tests/{libs => supports}/BaseUriTest.php (100%) rename server/tests/{libs => supports}/PdfTest.php (87%) create mode 100644 server/tests/supports/PeriodTest.php rename server/tests/{libs => supports}/collections/MaterialsCollectionTest.php (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af2c17b1e..c097f6968 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,9 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./client }} steps : - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '18' } - run: yarn install --non-interactive --pure-lockfile - run: yarn lint:js --max-warnings 0 --color @@ -21,9 +21,9 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./client }} steps : - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '18' } - run: yarn install --non-interactive --pure-lockfile - run: yarn lint:scss --max-warnings 0 --color @@ -32,9 +32,9 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./client }} steps : - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '18' } - run: yarn install --non-interactive --pure-lockfile - run: yarn check-types @@ -43,9 +43,9 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./client }} steps : - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '18' } - run: yarn install --non-interactive --pure-lockfile - run: yarn test --colors --coverage --passWithNoTests @@ -54,11 +54,11 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./server }} steps : - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version : 8.0 - extensions : dom, mbstring, pdo, intl, simplexml + php-version : 8.1 + extensions : dom, mbstring, pdo, intl-74.2, simplexml coverage : none - run: composer install --no-interaction --no-progress - run: composer lint @@ -68,11 +68,11 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./server }} steps : - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version : 8.0 - extensions : dom, mbstring, pdo, intl, simplexml + php-version : 8.1 + extensions : dom, mbstring, pdo, intl-74.2, simplexml coverage : none - run: composer install --no-interaction --no-progress - run: composer phpstan @@ -88,7 +88,7 @@ jobs: DB_PORT: 3306 strategy: matrix: - php: [8.0, 8.1] + php: [8.1, 8.2, 8.3] defaults: { run: { working-directory: ./server }} services: mysql: @@ -100,11 +100,11 @@ jobs: - 3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version : ${{ matrix.php }} - extensions : dom, mbstring, pdo, intl, simplexml + extensions : dom, mbstring, pdo, intl-74.2, simplexml coverage : xdebug - run: composer install --no-interaction --no-progress - run: ./vendors/bin/phpunit -d --without-creating-snapshots diff --git a/.resources/logo-dark.svg b/.resources/logo-dark.svg new file mode 100644 index 000000000..268844e2b --- /dev/null +++ b/.resources/logo-dark.svg @@ -0,0 +1,29 @@ + + Loxya + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.resources/logo-light.svg b/.resources/logo-light.svg new file mode 100644 index 000000000..4454d1a50 --- /dev/null +++ b/.resources/logo-light.svg @@ -0,0 +1,29 @@ + + Loxya + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.resources/logo.svg b/.resources/logo.svg deleted file mode 100644 index 2053398c5..000000000 --- a/.resources/logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/bin/release b/bin/release index 0013f7046..e5f532a61 100755 --- a/bin/release +++ b/bin/release @@ -13,7 +13,7 @@ done VERSION="${VERSION_NUMBER}" -releaseName="Robert2-${VERSION}" +releaseName="Loxya-${VERSION}" distFolder="./dist/${releaseName}" # - Header message diff --git a/client/.eslintrc.js b/client/.eslintrc.js index cb8b872f7..f478f0caf 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -14,7 +14,7 @@ module.exports = { project: './tsconfig.json', tsconfigRootDir: __dirname, babelOptions: { - configFile: './babel.config.js', + configFile: require.resolve('./babel.config.js'), }, }, @@ -32,12 +32,17 @@ module.exports = { { files: ['**/locale/**/*'], rules: { - 'quotes': ['off'], + '@stylistic/js/quotes': ['off'], + '@stylistic/ts/quotes': ['off'], 'global-require': ['off'], }, }, { - files: ['**/tests/**/*', '**/__tests__/*', '**/*.spec.*'], + files: [ + '**/tests/**/*', + '**/__tests__/*', + '**/*.spec.*', + ], env: { jest: true }, settings: { 'import/resolver': { @@ -73,10 +78,25 @@ module.exports = { ], extends: '@pulsanova/node', }, + // - Autorise les imports cycliques dans les stores / fixtures (schemas Zod). + { + files: [ + '**/stores/api/*', + '**/stores/api/**/*', + '**/tests/fixtures/**/*', + ], + rules: { + 'import/no-cycle': ['off'], + }, + }, // - Autorise le `snake_case` dans les types d'API vu que pour le moment // celle-ci accepte et retourne uniquement sous ce format. { - files: ['**/stores/api/*.ts', '**/stores/api/**/*.ts'], + files: [ + '**/stores/api/*.ts', + '**/stores/api/**/*.ts', + '**/tests/fixtures/**/*.ts', + ], rules: { '@typescript-eslint/naming-convention': [ 'error', diff --git a/client/.gitignore b/client/.gitignore index ecaf3cc66..cebaa9e12 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -3,11 +3,11 @@ node_modules/ /dist/ /tests/coverage -# - Logs +# - Logs. npm-debug.log* yarn-debug.log* yarn-error.log* -# - Fichiers locaux d'environnement +# - Fichiers locaux d'environnement. .env.local .env.*.local diff --git a/client/babel.config.js b/client/babel.config.js index 1c6487677..2f640cd10 100644 --- a/client/babel.config.js +++ b/client/babel.config.js @@ -1,9 +1,10 @@ 'use strict'; module.exports = { + compact: false, presets: [ 'vca-jsx', - '@vue/babel-preset-app', + '@vue/cli-plugin-babel/preset', '@babel/preset-typescript', ], }; diff --git a/client/jest.config.js b/client/jest.config.js index d9f1f0825..6a20bf461 100644 --- a/client/jest.config.js +++ b/client/jest.config.js @@ -24,6 +24,12 @@ module.exports = { '^@/(.*)$': '/src/$1', '^@fixtures/(.*)$': '/tests/fixtures/$1', }, + snapshotSerializers: [ + '/tests/serializers/day.ts', + '/tests/serializers/datetime.ts', + '/tests/serializers/decimal.ts', + '/tests/serializers/period.ts', + ], transform: { '^.+\\.(js|mjs|cjs|jsx|ts|mts|cts|tsx)$': 'babel-jest', '^(?!.*\\.(js|mjs|cjs|ts|mts|cts|tsx|json)$)': 'jest-transform-stub', diff --git a/client/jsconfig.json b/client/jsconfig.json index 23be039da..512ba4f74 100644 --- a/client/jsconfig.json +++ b/client/jsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@fixtures/*": ["./tests/fixtures/*"] } } } diff --git a/client/package.json b/client/package.json index c1b9eb0fc..d3034a059 100644 --- a/client/package.json +++ b/client/package.json @@ -1,40 +1,44 @@ { "private": true, "scripts": { - "start": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service serve", - "build": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build", + "serve": "vue-cli-service serve --no-module", + "build": "vue-cli-service build --no-module", "test": "npx cross-env TZ=UTC jest", "check-types": "tsc", "lint:js": "eslint './**/*.{js,vue,ts,tsx}' --report-unused-disable-directives", - "lint:scss": "stylelint 'src/**/*.scss'" + "lint:scss": "stylelint 'src/**/*.scss'", + "postinstall": "patch-package" }, "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", - "@vue/composition-api": "1.7.1", + "@vue/composition-api": "1.7.2", "axios": "0.24.0", - "clsx": "1.2.1", + "clsx": "2.0.0", + "collect.js": "4.36.1", + "core-js": "3.34.0", + "dayjs": "1.11.10", "decimal.js": "10.4.3", "deep-freeze-strict": "1.1.1", "invariant": "2.2.4", - "js-cookie": "3.0.4", + "js-cookie": "3.0.5", "lodash": "^4.17.21", "moment": "2.29.4", "p-defer": "3.0.0", "p-queue": "6.6.2", "papaparse": "5.4.1", "portal-vue": "2.1.7", - "status-code-enum": "~1.0.0", - "style-object-to-css-string": "1.0.1", + "status-code-enum": "1.0.0", + "style-object-to-css-string": "1.1.3", "sweetalert2": "11.0.20", "tinycolor2": "1.6.0", + "type-fest": "4.12.0", "v-tooltip": "2.1.3", "vue": "2.6.14", "vue-click-outside": "1.1.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", @@ -44,44 +48,56 @@ "vuex": "3.6.2", "vuex-i18n": "1.13.1", "warning": "4.0.3", - "zod": "3.22.4" + "zod": "3.21.1" }, "devDependencies": { - "@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", + "@babel/core": "7.23.6", + "@babel/preset-typescript": "7.23.3", + "@pulsanova/eslint-config-esnext": "2.5.0", + "@pulsanova/eslint-config-node": "2.5.0", + "@pulsanova/eslint-config-vue": "2.5.0", "@pulsanova/stylelint-config-scss": "2.0.1", "@types/debounce": "1.2", + "@types/deep-freeze-strict": "1.1.2", "@types/invariant": "2.2", "@types/jest": "29", "@types/js-cookie": "3.0", "@types/lodash": "4", - "@types/papaparse": "5.3.7", + "@types/papaparse": "5.3.14", "@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-service": ">=4.5.13", + "@types/warning": "3", + "@types/webpack-env": "*", + "@vue/cli-plugin-babel": "5.0.8", + "@vue/cli-plugin-typescript": "5.0.8", + "@vue/cli-service": "5.0.8", "@vue/test-utils": "1.2.2", "babel-core": "7.0.0-bridge.0", - "babel-jest": "29.5.0", - "babel-loader": "8.2.2", + "babel-jest": "29.7.0", + "babel-loader": "9.1.3", "babel-preset-vca-jsx": "0.3.6", - "eslint": "8.39.0", - "eslint-import-resolver-custom-alias": "1.3.0", - "eslint-import-resolver-webpack": "0.13.2", - "jest": "29.5.0", - "jest-environment-jsdom": "29.5.0", + "eslint": "8.57.0", + "eslint-import-resolver-custom-alias": "1.3.2", + "eslint-import-resolver-webpack": "0.13.8", + "file-loader": "6.2.0", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", "jest-transform-stub": "2.0.0", "jest-watch-typeahead": "2.2.2", - "sass": "1.62.0", - "sass-loader": "10.2.0", + "patch-package": "^8.0.0", + "postinstall-postinstall": "^2.1.0", + "sass": "1.69.5", + "sass-loader": "13.3.2", "stylelint": "14.1.0", - "type-fest": "4.4.0", - "typescript": "5.0.4", - "vue-cli-plugin-svg": "0.1.3", - "vue-cli-plugin-yaml": "1.0.2", - "vue-template-compiler": "2.6.14" + "typescript": "5.4.5", + "vue-svg-loader-2": "0.17.1", + "vue-template-compiler": "2.6.14", + "yaml-loader": "0.8.0" + }, + "engines": { + "node": ">= 18" + }, + "resolutions": { + "vue": "2.6.14", + "vuex": "3.6.2" } } diff --git a/client/patches/vue2-datepicker+3.11.1.patch b/client/patches/vue2-datepicker+3.11.1.patch new file mode 100644 index 000000000..ff3ee6832 --- /dev/null +++ b/client/patches/vue2-datepicker+3.11.1.patch @@ -0,0 +1,395 @@ +diff --git a/node_modules/vue2-datepicker/index.esm.js b/node_modules/vue2-datepicker/index.esm.js +index ad89f7b..ce14f05 100644 +--- a/node_modules/vue2-datepicker/index.esm.js ++++ b/node_modules/vue2-datepicker/index.esm.js +@@ -2077,7 +2077,16 @@ var CalendarRange = { + default: 'mx' + } + }, +- props: _objectSpread2({}, CalendarPanel.props), ++ props: _objectSpread2({}, CalendarPanel.props, { ++ readonly: { ++ type: [Boolean, String], ++ default: false, ++ validator: (value) => ( ++ typeof value === 'boolean' || ++ ['start', 'end'].includes(value) ++ ), ++ }, ++ }), + data: function data() { + return { + innerValue: [], +@@ -2125,7 +2134,29 @@ var CalendarRange = { + startValue = _this$innerValue[0], + endValue = _this$innerValue[1]; + +- if (isValidDate(startValue) && !isValidDate(endValue)) { ++ const isValidStart = isValidDate(startValue); ++ const isValidEnd = isValidDate(endValue); ++ ++ if (this.readonly !== false) { ++ if (isValidStart || isValidEnd) { ++ if (this.readonly === 'start') { ++ if (!isValidStart || startValue.getTime() > date.getTime()) { ++ return; ++ } ++ this.innerValue = [startValue, date]; ++ } ++ if (this.readonly === 'end') { ++ if (!isValidEnd || date.getTime() > endValue.getTime()) { ++ return; ++ } ++ this.innerValue = [date, endValue]; ++ } ++ this.emitDate(this.innerValue, type); ++ } ++ return; ++ } ++ ++ if (isValidStart && !isValidEnd) { + if (startValue.getTime() > date.getTime()) { + this.innerValue = [date, startValue]; + } else { +@@ -2133,9 +2164,10 @@ var CalendarRange = { + } + + this.emitDate(this.innerValue, type); +- } else { +- this.innerValue = [date, new Date(NaN)]; ++ return; + } ++ ++ this.innerValue = [date, new Date(NaN)]; + }, + onDateMouseEnter: function onDateMouseEnter(cell) { + this.hoveredValue = cell; +@@ -2187,6 +2219,30 @@ var CalendarRange = { + }, + getRangeClasses: function getRangeClasses(cellDate, currentDates, classnames) { + var classes = [].concat(this.getClasses(cellDate, currentDates, classnames)); ++ ++ if (classnames.includes('active') && this.readonly !== false) { ++ if (this.readonly === true) { ++ classes = classes.concat('readonly'); ++ } ++ if (this.readonly === 'start' || this.readonly === 'end') { ++ const normalizedInnerValues = this.innerValue.map((value) => ( ++ isValidDate(value) ? new Date(value).setHours(0, 0, 0, 0) : null ++ )); ++ ++ const readonlyValue = normalizedInnerValues[this.readonly === 'start' ? 0 : 1]; ++ const otherValue = normalizedInnerValues[this.readonly === 'start' ? 1 : 0]; ++ const cellValue = cellDate.getTime(); ++ ++ if ( ++ readonlyValue !== null && ++ cellValue === readonlyValue && ++ (otherValue === null || cellValue !== otherValue) ++ ) { ++ classes = classes.concat('readonly'); ++ } ++ } ++ } ++ + if (/disabled|active/.test(classnames)) return classes; + + var inRange = function inRange(data, range) { +@@ -2210,6 +2266,9 @@ var CalendarRange = { + }; + + if (currentDates.length === 2 && inRange(cellDate, currentDates)) { ++ if (this.readonly === true) { ++ classes = classes.concat('readonly-in-range'); ++ } + return classes.concat('in-range'); + } + +@@ -2730,6 +2789,10 @@ var script$7 = { + }, + props: { + date: Date, ++ type: { ++ type: String, ++ default: undefined, ++ }, + options: { + type: [Object, Function], + default: function _default() { +@@ -2753,7 +2816,12 @@ var script$7 = { + var options = this.options; + + if (typeof options === 'function') { +- return options() || []; ++ var selectedDate = new Date(this.date); ++ return ( ++ this.type !== undefined ++ ? options(selectedDate, this.type) ++ : options(selectedDate) ++ ) || []; + } + + var start = parseOption(options.start); +@@ -2879,6 +2947,10 @@ var script$8 = { + return date; + } + }, ++ type: { ++ type: String, ++ default: undefined, ++ }, + format: { + default: 'HH:mm:ss' + }, +@@ -2936,7 +3008,11 @@ var script$8 = { + scrollDuration: { + type: Number, + default: 100 +- } ++ }, ++ readonly: { ++ type: Boolean, ++ default: false, ++ }, + }, + data: function data() { + return { +@@ -3001,6 +3077,10 @@ var script$8 = { + return this.isDisabledTime(value) && this.isDisabledTime(value.setHours(minHour, 0, 0, 0)) && this.isDisabledTime(value.setHours(maxHour, 59, 59, 999)); + }, + isDisabled: function isDisabled(date, type) { ++ if (this.readonly) { ++ return new Date(date).getTime() !== this.innerValue.getTime(); ++ } ++ + if (type === 'hour') { + return this.isDisabledHour(date); + } +@@ -3016,6 +3096,10 @@ var script$8 = { + return this.isDisabledTime(date); + }, + handleSelect: function handleSelect(value, type) { ++ if (this.readonly) { ++ return; ++ } ++ + var date = new Date(value); + + if (!this.isDisabled(value, type)) { +@@ -3037,7 +3121,13 @@ var script$8 = { + } + + if (cellDate.getTime() === this.innerValue.getTime()) { +- return 'active'; ++ const classes = ['active']; ++ ++ if (this.readonly) { ++ classes.push('readonly'); ++ } ++ ++ return classes.join(' '); + } + + return ''; +@@ -3073,6 +3163,7 @@ var __vue_render__$b = function __vue_render__() { + }, [_vm.timePickerOptions ? _c('list-options', { + attrs: { + "date": _vm.innerValue, ++ "type": _vm.type, + "get-classes": _vm.getClasses, + "options": _vm.timePickerOptions, + "format": _vm.innerForamt +@@ -3129,7 +3220,16 @@ var TimeRange = { + default: 'mx' + } + }, +- props: _objectSpread2({}, __vue_component__$b.props), ++ props: _objectSpread2({}, __vue_component__$b.props, { ++ readonly: { ++ type: [Boolean, String], ++ default: false, ++ validator: (value) => ( ++ typeof value === 'boolean' || ++ ['start', 'end'].includes(value) ++ ), ++ }, ++ }), + data: function data() { + return { + startValue: new Date(NaN), +@@ -3160,6 +3260,10 @@ var TimeRange = { + this.$emit('select', date, type === 'time' ? 'time-range' : type, index); + }, + handleSelectStart: function handleSelectStart(date, type) { ++ if ([true, 'start'].includes(this.readonly)) { ++ return; ++ } ++ + this.startValue = date; // check the NaN + + if (!(this.endValue.getTime() >= date.getTime())) { +@@ -3169,6 +3273,10 @@ var TimeRange = { + this.emitChange(type, 0); + }, + handleSelectEnd: function handleSelectEnd(date, type) { ++ if ([true, 'end'].includes(this.readonly)) { ++ return; ++ } ++ + // check the NaN + this.endValue = date; + +@@ -3179,9 +3287,15 @@ var TimeRange = { + this.emitChange(type, 1); + }, + disabledStartTime: function disabledStartTime(date) { ++ if ([true, 'start'].includes(this.readonly)) { ++ return date.getTime() !== this.startValue.getTime(); ++ } + return this.disabledTime(date, 0); + }, + disabledEndTime: function disabledEndTime(date) { ++ if ([true, 'end'].includes(this.readonly)) { ++ return date.getTime() !== this.endValue.getTime(); ++ } + return date.getTime() < this.startValue.getTime() || this.disabledTime(date, 1); + } + }, +@@ -3189,10 +3303,13 @@ var TimeRange = { + var h = arguments[0]; + var defaultValues = Array.isArray(this.defaultValue) ? this.defaultValue : [this.defaultValue, this.defaultValue]; + var prefixClass = this.prefixClass; ++ + return h("div", { + "class": "".concat(prefixClass, "-range-wrapper") + }, [h(__vue_component__$b, { + "props": _objectSpread2({}, _objectSpread2({}, this.$props, { ++ type: 'start', ++ readonly: [true, 'start'].includes(this.readonly), + value: this.startValue, + defaultValue: defaultValues[0], + disabledTime: this.disabledStartTime +@@ -3202,6 +3319,8 @@ var TimeRange = { + })) + }), h(__vue_component__$b, { + "props": _objectSpread2({}, _objectSpread2({}, this.$props, { ++ type: 'end', ++ readonly: [true, 'end'].includes(this.readonly), + value: this.endValue, + defaultValue: defaultValues[1], + disabledTime: this.disabledEndTime +@@ -3474,6 +3593,14 @@ var DatePicker = { + type: Boolean, + default: false + }, ++ readonly: { ++ type: [Boolean, String], ++ default: false, ++ validator: (value) => ( ++ typeof value === 'boolean' || ++ ['start', 'end'].includes(value) ++ ) ++ }, + clearable: { + type: Boolean, + default: true +@@ -3541,8 +3668,15 @@ var DatePicker = { + }; + }, + computed: { ++ normalizedReadonly() { ++ if (typeof this.readonly === 'boolean') { ++ return this.readonly; ++ } ++ return this.range ? this.readonly : true; ++ }, + popupVisible: function popupVisible() { +- return !this.disabled && (typeof this.open === 'boolean' ? this.open : this.defaultOpen); ++ const isDisabled = this.disabled || this.normalizedReadonly === true; ++ return !isDisabled && (typeof this.open === 'boolean' ? this.open : this.defaultOpen); + }, + innerRangeSeparator: function innerRangeSeparator() { + return this.rangeSeparator || (this.multiple ? ',' : ' ~ '); +@@ -3597,7 +3731,13 @@ var DatePicker = { + return this.formatDate(this.innerValue); + }, + showClearIcon: function showClearIcon() { +- return !this.disabled && this.clearable && this.text && this.mouseInInput; ++ return ( ++ !this.disabled && ++ !this.normalizedReadonly && ++ this.clearable && ++ this.text && ++ this.mouseInInput ++ ); + }, + locale: function locale() { + if (isObject(this.lang)) { +@@ -3630,6 +3770,9 @@ var DatePicker = { + if (_typeof(this.format) === 'object') { + console.warn("[vue2-datepicker]: The prop `format` don't support Object any more. You can use the new prop `formatter` to replace it"); + } ++ if (typeof this.readonly !== 'boolean' && !this.range) { ++ console.warn("[vue2-datepicker]: The prop `readonly` should be passed as boolean when used with a non-range datepicker."); ++ } + }, + methods: { + handleMouseEnter: function handleMouseEnter() { +@@ -3784,6 +3927,10 @@ var DatePicker = { + } + }, + clear: function clear() { ++ if (this.normalizedReadonly !== false) { ++ return; ++ } ++ + this.emitValue(this.range ? [null, null] : null); + this.$emit('clear'); + }, +@@ -3808,7 +3955,10 @@ var DatePicker = { + } + }, + openPopup: function openPopup(evt) { +- if (this.popupVisible || this.disabled) return; ++ const isDisabled = this.disabled || this.normalizedReadonly === true; ++ if (this.popupVisible || isDisabled) { ++ return; ++ } + this.defaultOpen = true; + this.$emit('open', evt); + this.$emit('update:open', true); +@@ -3914,8 +4064,8 @@ var DatePicker = { + autocomplete: 'off', + value: this.text, + class: this.inputClass || "".concat(this.prefixClass, "-input"), +- readonly: !this.editable, +- disabled: this.disabled, ++ readonly: !this.editable || this.normalizedReadonly !== false, ++ disabled: this.disabled || this.normalizedReadonly === true, + placeholder: this.placeholder + }, this.inputAttr); + +@@ -4044,13 +4194,21 @@ var DatePicker = { + var h = arguments[0]; + var prefixClass = this.prefixClass, + inline = this.inline, +- disabled = this.disabled; ++ disabled = this.disabled, ++ readonly = this.normalizedReadonly; + var sidedar = this.hasSlot('sidebar') || this.shortcuts.length ? this.renderSidebar() : null; + var content = h("div", { + "class": "".concat(prefixClass, "-datepicker-content") + }, [this.hasSlot('header') ? this.renderHeader() : null, this.renderContent(), this.hasSlot('footer') || this.confirm ? this.renderFooter() : null]); + return h("div", { +- "class": (_class = {}, _defineProperty(_class, "".concat(prefixClass, "-datepicker"), true), _defineProperty(_class, "".concat(prefixClass, "-datepicker-range"), this.range), _defineProperty(_class, "".concat(prefixClass, "-datepicker-inline"), inline), _defineProperty(_class, "disabled", disabled), _class) ++ "class": ( ++ _class = {}, ++ _defineProperty(_class, "".concat(prefixClass, "-datepicker"), true), ++ _defineProperty(_class, "".concat(prefixClass, "-datepicker-range"), this.range), ++ _defineProperty(_class, "".concat(prefixClass, "-datepicker-inline"), inline), ++ _defineProperty(_class, "disabled", disabled || readonly === true), ++ _class ++ ) + }, [!inline ? this.renderInput() : null, !inline ? h(__vue_component__, { + "ref": "popup", + "class": this.popupClass, diff --git a/client/src/components/Fragment/index.tsx b/client/src/components/Fragment/index.tsx index 092a9f837..e4014ac00 100644 --- a/client/src/components/Fragment/index.tsx +++ b/client/src/components/Fragment/index.tsx @@ -1,7 +1,7 @@ import './index.scss'; import { defineComponent } from '@vue/composition-api'; -// @vue/component +/** Un élément permettant d'en grouper d'autres sans élément racine. */ const Fragment = defineComponent({ name: 'Fragment', render() { diff --git a/client/src/globals/config.js b/client/src/globals/config.ts similarity index 96% rename from client/src/globals/config.js rename to client/src/globals/config.ts index 82c086470..fa12349ce 100644 --- a/client/src/globals/config.js +++ b/client/src/globals/config.ts @@ -5,12 +5,12 @@ if (window.__SERVER_CONFIG__ && window.__SERVER_CONFIG__.baseUrl) { baseUrl = window.__SERVER_CONFIG__.baseUrl; } -const defaultConfig = { +const defaultConfig: GlobalConfig = { baseUrl, + version: '__DEV__', api: { url: `${baseUrl}/api`, headers: { Accept: 'application/json' }, - version: '_dev mode_', }, defaultLang: 'fr', currency: { diff --git a/client/src/globals/constants.js b/client/src/globals/constants.js deleted file mode 100644 index bf498adf2..000000000 --- a/client/src/globals/constants.js +++ /dev/null @@ -1,17 +0,0 @@ -import moment from 'moment'; - -const APP_NAME = 'Loxya (Robert2)'; - -const DATE_DB_FORMAT = 'YYYY-MM-DD HH:mm:ss'; -const DEBOUNCE_WAIT = 500; // - En millisecondes - -const TECHNICIAN_EVENT_STEP = moment.duration(15, 'minutes'); -const TECHNICIAN_EVENT_MIN_DURATION = moment.duration(15, 'minutes'); - -export { - APP_NAME, - DATE_DB_FORMAT, - DEBOUNCE_WAIT, - TECHNICIAN_EVENT_STEP, - TECHNICIAN_EVENT_MIN_DURATION, -}; diff --git a/client/src/globals/constants.ts b/client/src/globals/constants.ts new file mode 100644 index 000000000..4ed31e408 --- /dev/null +++ b/client/src/globals/constants.ts @@ -0,0 +1,12 @@ +import DateTime from '@/utils/datetime'; + +/** Durée de temporisation de base (e.g. entres deux requêtes répétitives). */ +const DEBOUNCE_WAIT_DURATION = DateTime.duration(500, 'milliseconds'); + +/** Durée minimum d'assignation d'un technicien dans un événement. */ +const MIN_TECHNICIAN_ASSIGNMENT_DURATION = DateTime.duration(15, 'minutes'); + +export { + DEBOUNCE_WAIT_DURATION, + MIN_TECHNICIAN_ASSIGNMENT_DURATION, +}; diff --git a/client/src/globals/queryClient.ts b/client/src/globals/queryClient.ts deleted file mode 100644 index e238b1e95..000000000 --- a/client/src/globals/queryClient.ts +++ /dev/null @@ -1,19 +0,0 @@ -import HttpCode from 'status-code-enum'; -import { QueryClient } from 'vue-query'; -import { isRequestErrorStatusCode } from '@/utils/errors'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 10 * 60_000, // - 10 minutes - retry: (failureCount: number, error: unknown) => { - if (isRequestErrorStatusCode(error, HttpCode.ClientErrorNotFound)) { - return false; - } - return failureCount < 2; - }, - }, - }, -}); - -export default queryClient; diff --git a/client/src/globals/rawDatetime.ts b/client/src/globals/rawDatetime.ts new file mode 100644 index 000000000..277bb46c1 --- /dev/null +++ b/client/src/globals/rawDatetime.ts @@ -0,0 +1,11 @@ +import RawDateTime from '@/utils/rawDatetime'; +import { getLocale } from '@/globals/lang'; + +import 'dayjs/locale/fr'; +import 'dayjs/locale/en-gb'; + +export const init = (): void => { + RawDateTime.locale(getLocale()); +}; + +export default RawDateTime; diff --git a/client/src/globals/requester.js b/client/src/globals/requester.js index 3f0a36047..4d8e30ae9 100644 --- a/client/src/globals/requester.js +++ b/client/src/globals/requester.js @@ -4,6 +4,8 @@ import isPlainObject from 'lodash/isPlainObject'; import flattenObject from '@/utils/flattenObject'; import config from '@/globals/config'; import cookies from '@/utils/cookies'; +import DateTime from '@/utils/datetime'; +import Day from '@/utils/day'; const requester = axios.create({ baseURL: config.api.url, @@ -69,6 +71,9 @@ requester.interceptors.request.use( if (params[name] === false) { params[name] = '0'; } + if (params[name] instanceof DateTime || params[name] instanceof Day) { + params[name] = params[name].toString(); + } }); request.params = params; } diff --git a/client/src/globals/types/globals.d.ts b/client/src/globals/types/globals.d.ts new file mode 100644 index 000000000..278f7d798 --- /dev/null +++ b/client/src/globals/types/globals.d.ts @@ -0,0 +1,35 @@ +type GlobalConfig = { + baseUrl: string, + version: string, + billingMode: 'all' | 'partial' | 'none', + defaultLang: string, + api: { + url: string, + headers: Record, + }, + auth: { + cookie: string, + timeout: number | null, + }, + currency: { + symbol: string, + name: string, + iso: string, + }, + companyName: string | null, + defaultPaginationLimit: number, + maxConcurrentFetches: number, + maxFileUploadSize: number, + authorizedFileTypes: string[], + authorizedImageTypes: string[], + colorSwatches: string[] | null, +}; + +declare var __SERVER_CONFIG__: GlobalConfig | undefined; + +type ServerMessage = { + type: 'success' | 'info' | 'error', + message: string, +}; + +declare var __SERVER_MESSAGES__: ServerMessage[] | undefined; diff --git a/client/src/globals/types/utils.d.ts b/client/src/globals/types/utils.d.ts index 713b044bc..69c156b66 100644 --- a/client/src/globals/types/utils.d.ts +++ b/client/src/globals/types/utils.d.ts @@ -1,2 +1,6 @@ /** Représente les coordonnées d'une position. */ type Position = { x: number, y: number }; + +type AnyLiteralObject = Record; + +type Nullable = { [K in keyof T]: T[K] | null }; diff --git a/client/src/globals/types/vendors/dayjs.d.ts b/client/src/globals/types/vendors/dayjs.d.ts new file mode 100644 index 000000000..68f7b8c14 --- /dev/null +++ b/client/src/globals/types/vendors/dayjs.d.ts @@ -0,0 +1,18 @@ +import 'dayjs'; + +declare module 'dayjs' { + export type DayjsInput = ConfigType; + + export type TimeUnitTypeLongPlural = 'hours' | 'minutes' | 'seconds' | 'milliseconds'; + export type TimeUnitTypeLong = 'hour' | 'minute' | 'second' | 'millisecond'; + export type TimeUnitTypeShort = 'h' | 'm' | 's' | 'ms'; + export type TimeUnitType = TimeUnitTypeLongPlural | TimeUnitTypeLong | TimeUnitTypeShort; + + // + // - Plugin `explicit`. + // + + export function now(): Dayjs; + export function from(input: DayjsInput): Dayjs; + export function fromFormat(input: string, format: string): Dayjs; +} diff --git a/client/src/globals/types/vendors/vue-js-modal.d.ts b/client/src/globals/types/vendors/vue-js-modal.d.ts index 9b7c0baaa..4ea2e87c8 100644 --- a/client/src/globals/types/vendors/vue-js-modal.d.ts +++ b/client/src/globals/types/vendors/vue-js-modal.d.ts @@ -1,7 +1,7 @@ import 'vue-js-modal'; declare module 'vue-js-modal' { - export type OnCloseEvent> = { + export type OnCloseEvent = { /** Le nom de la modale (créé dynamiquement par vue-js-modal). */ name: string, diff --git a/client/src/globals/types/vendors/vue-simple-calendar.d.ts b/client/src/globals/types/vendors/vue-simple-calendar.d.ts new file mode 100644 index 000000000..a79087b04 --- /dev/null +++ b/client/src/globals/types/vendors/vue-simple-calendar.d.ts @@ -0,0 +1,40 @@ +declare module 'vue-simple-calendar' { + import type { RawComponent } from 'vue'; + + export type CalendarItem = { + id: string | number, + startDate: Date | string, + endDate?: Date | string, + title: string, + classes?: string, + style?: string, + }; + + export const CalendarView: RawComponent<{ + showDate?: Date, + displayPeriodUom?: 'month' | 'year' | 'week', + displayPeriodCount?: number, + startingDayOfWeek?: number, + displayWeekNumbers?: boolean, + showTimes?: boolean, + locale?: string, + dateClasses?: Record, + monthNameFormat?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow', + weekdayNameFormat?: 'long' | 'short' | 'narrow', + timeFormatOptions?: Intl.DateTimeFormatOptions, + disablePast?: boolean, + disableFuture?: boolean, + enableDateSelection?: boolean, + selectionStart?: Date, + selectionEnd?: Date, + items?: CalendarItem[], + enableDragDrop?: boolean, + itemTop?: string, + itemContentHeight?: string, + itemBorderHeight?: string, + periodChangedCallback?: Function, + currentPeriodLabel?: string, + currentPeriodLabelIcons?: string, + doEmitItemMouseEvents?: boolean, + }>; +} 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 363bca6a2..f186409a5 100644 --- a/client/src/globals/types/vendors/vue-tables-2.d.ts +++ b/client/src/globals/types/vendors/vue-tables-2.d.ts @@ -19,13 +19,13 @@ declare module 'vue-tables-2-premium' { type ColumnsVisibility = Record; - type TemplateRenderFunction = ( - (h: CreateElement, row: Data, index: number) => JSX.Element | string | null + type TemplateRenderFunction = ( + (h: CreateElement, row: Datum, index: number) => JSX.Element | JSX.Element[] | string | null ); - type RowClickEventPayload = { row: Data, event: PointerEvent, index: number }; + type RowClickEventPayload = { row: Datum, event: PointerEvent, index: number }; - type BaseTableOptions = { + type BaseTableOptions = { headings?: Record, initialPage?: number, perPage?: number, @@ -33,19 +33,20 @@ declare module 'vue-tables-2-premium' { sortable?: string[], multiSorting?: Record>, filterByColumn?: boolean, + filterable?: boolean, columnsDropdown?: boolean, preserveState?: boolean, saveState?: boolean, columnsDisplay?: ColumnsVisibility, columnsClasses?: Record, - templates?: Record>, - rowClassCallback(row: Data): VNodeClass, + templates?: Record>, + rowClassCallback?(row: Datum): VNodeClass, }; - interface BaseTableInstance { + interface BaseTableInstance { name: string; columns: string[]; - data: Data[]; + data: Datum[]; filtersCount: number; openChildRows: number[]; selectedRows: number[] | undefined; @@ -61,7 +62,7 @@ declare module 'vue-tables-2-premium' { resetCustomFilters(): void; setLoadingState(): void; $refs: { - table: BaseTableInstance, + table: BaseTableInstance, }; } @@ -75,22 +76,39 @@ declare module 'vue-tables-2-premium' { // - Client component specific types // - export type ClientCustomFilter = { + export type ClientCustomFilter = { name: string, - callback(item: Data, identifier: number | string | boolean): boolean, + callback(item: Datum, identifier: number | string | boolean): boolean, }; - export type CustomSortFunction = (ascending: boolean) => (a: Data, b: Data) => number; + /** + * Fonction personnalisé de tri de la colonne. + * + * Cette fonction, à qui la direction de tri souhaité est passé (via `ascending`), + * doit renvoyer une autre fonction qui s'occupera de comparer deux éléments de + * la colonne et devra renvoyé si le premier élément (`a`) arrive avant (= `-1`) ou + * après (= `1`) le deuxième (`b`) (ou s'ils sont égaux (= `0`)). + * + * Si non spécifié, le tri consistera en une simple comparaison des valeurs + * (e.g si ascendant: `a > b ? 1 : -1`) en ayant au préalable mis les chaînes + * de caractères en minuscules (si ce sont des chaînes qui sont comparés). + * + * @param ascending - Spécifie si le tri doit être effectué de manière + * ascendante ou descendante. + */ + export type ColumnSorter = (ascending: boolean) => ( + (a: Datum, b: Datum) => number + ); - export type ClientTableOptions = BaseTableOptions & { - initFilters: Filters, - customSorting?: Record>, - customFilters?: Array>, + export type ClientTableOptions = BaseTableOptions & { + initFilters?: Filters, + customSorting?: Record>, + customFilters?: Array>, }; - export interface ClientTableInstance extends BaseTableInstance { - filteredData: Data[]; - allFilteredData: Data[]; + export interface ClientTableInstance extends BaseTableInstance { + filteredData: Datum[]; + allFilteredData: Datum[]; } // @@ -101,14 +119,14 @@ declare module 'vue-tables-2-premium' { Promise<{ data: Data } | undefined> ); - export type ServerTableOptions = BaseTableOptions & { + export type ServerTableOptions = BaseTableOptions & { customFilters?: string[], - requestFunction?: RequestFunction, + requestFunction?: RequestFunction, }; - export interface ServerTableInstance extends BaseTableInstance { + export interface ServerTableInstance extends BaseTableInstance { setRequestParams(params: Record): void; - geData(): Data[]; + getData(): Datum[]; getQueryParams(): Record; } } diff --git a/client/src/globals/types/vendors/vue-toasted.d.ts b/client/src/globals/types/vendors/vue-toasted.d.ts new file mode 100644 index 000000000..059a309ed --- /dev/null +++ b/client/src/globals/types/vendors/vue-toasted.d.ts @@ -0,0 +1,7 @@ +import 'vue-toasted'; + +declare module 'vue-toasted' { + interface ToastOptions { + duration?: number | null; + } +} diff --git a/client/src/globals/types/vendors/vue2-datepicker.d.ts b/client/src/globals/types/vendors/vue2-datepicker.d.ts index 5ed213aa8..09b8579a7 100644 --- a/client/src/globals/types/vendors/vue2-datepicker.d.ts +++ b/client/src/globals/types/vendors/vue2-datepicker.d.ts @@ -26,6 +26,11 @@ declare module 'vue2-datepicker' { onClick(): any, }; + export type TimePickerValue = { + value: number, + text: string, + }; + export type TimePickerOptions = { start: string, step: string, @@ -57,8 +62,8 @@ declare module 'vue2-datepicker' { confirmText?: string, multiple?: boolean, disabled?: boolean, - disabledDate?(date: Date, currentValue: Date[]): boolean, - disabledTime?(date: Date): boolean, + disabledDate?(date: Date, currentValue: [?Date, ?Date]): boolean, + disabledTime?(date: Date, side: 0 | 1): boolean, appendToBody?: boolean, inline?: boolean, inputClass?: string, @@ -84,7 +89,10 @@ declare module 'vue2-datepicker' { use12h?: boolean, showTimeHeader?: boolean, timeTitleFormat?: string, - timePickerOptions?: TimePickerOptions, + timePickerOptions?: ( + | TimePickerOptions + | ((selectedDate: Date, type?: 'start' | 'end') => TimePickerOptionValue[]) + ), prefixClass?: string, scrollDuration?: number, }>; diff --git a/client/src/globals/types/vendors/vuex.d.ts b/client/src/globals/types/vendors/vuex.d.ts index a9f839616..2e022b107 100644 --- a/client/src/globals/types/vendors/vuex.d.ts +++ b/client/src/globals/types/vendors/vuex.d.ts @@ -2,7 +2,4 @@ declare module 'vuex' { export * from 'vuex/types/index.d.ts'; - export * from 'vuex/types/helpers.d.ts'; - export * from 'vuex/types/logger.d.ts'; - export * from 'vuex/types/vue.d.ts'; } diff --git a/client/src/hooks/useI18n.ts b/client/src/hooks/useI18n.ts deleted file mode 100644 index 638249ebe..000000000 --- a/client/src/hooks/useI18n.ts +++ /dev/null @@ -1,10 +0,0 @@ -import getRuntimeVm from '@/utils/getRuntimeVm'; - -import type { I18nTranslate } from 'vuex-i18n'; - -const useI18n = (): I18nTranslate => { - const vm = getRuntimeVm(); - return getRuntimeVm().$t.bind(vm); -}; - -export default useI18n; diff --git a/client/src/hooks/useRouteId.js b/client/src/hooks/useRouteId.js deleted file mode 100644 index a7002ce1e..000000000 --- a/client/src/hooks/useRouteId.js +++ /dev/null @@ -1,14 +0,0 @@ -import { computed } from '@vue/composition-api'; -import useRouter from '@/hooks/useRouter'; - -const useRouteId = () => { - const { route } = useRouter(); - - return computed(() => ( - route.value.params.id && route.value.params.id !== 'new' - ? route.value.params.id - : null - )); -}; - -export default useRouteId; diff --git a/client/src/hooks/useRoutePage.ts b/client/src/hooks/useRoutePage.ts deleted file mode 100644 index 8b4c76270..000000000 --- a/client/src/hooks/useRoutePage.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { computed } from '@vue/composition-api'; -import useRouter from '@/hooks/useRouter'; - -import type { Ref } from '@vue/composition-api'; - -const useRoutePage = (): Ref => { - const { route } = useRouter(); - - const page = computed(() => { - const { query } = route.value; - if (!query || !query.page) { - return 1; - } - return Number.parseInt(query.page as string, 10); - }); - - return page; -}; - -export default useRoutePage; diff --git a/client/src/hooks/useRouter.ts b/client/src/hooks/useRouter.ts deleted file mode 100644 index aca3ba7ef..000000000 --- a/client/src/hooks/useRouter.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ref, watch } from '@vue/composition-api'; -import getRuntimeVm from '@/utils/getRuntimeVm'; - -import type { Ref } from '@vue/composition-api'; -import type { Route } from 'vue-router'; - -type ReturnType = { - route: Ref, - router: any, -}; - -const useRouter = (): ReturnType => { - const vm = getRuntimeVm(); - const route = ref(vm.$route); - - watch(() => vm.$route, (newRoute: Route) => { - route.value = newRoute; - }); - - return { route, router: vm.$router }; -}; - -export default useRouter; diff --git a/client/src/locale/en/common.js b/client/src/locale/en/common.js index ec62cb5de..b52d52975 100644 --- a/client/src/locale/en/common.js +++ b/client/src/locale/en/common.js @@ -1,7 +1,7 @@ export default { 'hello-name': "Hello {name}!", 'your-settings': "Your settings", - 'logout-quit': "Quit Loxya (Robert2)", + 'logout-quit': "Quit Loxya", 'action-add': "Add", 'action-edit': "Edit", 'action-view': "Display details", @@ -31,7 +31,7 @@ export default { 'close': "Close", 'confirm': "Confirm", 'copy-to-clipboard': "Copy to clipboard", - 'copied-in-clipboard': "Copied in clipboard!", + 'copied-to-clipboard': "Copied to clipboard!", 'copy': "Copy", 'copied': "Copied!", 'almost-done': "Almost done...", @@ -46,19 +46,15 @@ export default { "Type again, at least {count} character to search...", "Type again, at least {count} characters to search...", ], + 'count-chars': ["{count} char", "{count} chars"], 'empty-state': "There are no records to display at this time.", 'search-term': "Search", 'no-result-found-try-another-search': "No results. Try another search term.", - 'create-select-item-label': "Create a {label}", - 'add-item': "Add a {item}", - 'remove-item': "Remove this {item}", - 'cancel-add-item': "Cancel adding {item}", - 'item-not-found': "{item} not found. Element may have been deleted.", 'locked': "locked", 'clear-filters': "Clear filters", 'optional': "Optional", 'n-persons': ["{count} person", "{count} persons"], - 'and-n-others': ["and {count} other", "and {count} others"], + 'name-and-n-others': ["{name} and {count} other", "{name} and {count} others"], 'add-comment': "Add a comment", 'modify-comment': "Modify comment", 'save': "Save", @@ -71,15 +67,17 @@ export default { 'reset-date': "Reset date", 'reset-period': "Reset period", 'actions': "Actions", - 'informations': "Informations", + 'informations': "Information", 'connexion-infos': "Credentials", - 'personal-infos': "Personal informations", - 'minimal-infos': "Minimal informations", - 'extra-infos': "Additional informations", - 'stock-infos': "Stock informations", - 'billing-infos': "Billing informations", + 'personal-infos': "Personal information", + 'minimal-infos': "Minimal information", + 'extra-infos': "Additional information", + 'stock-infos': "Stock information", + 'billing-infos': "Billing information", + 'other-infos': "Other information", 'documents': "Documents", 'billing': "Billing", + 'history': "History", 'special-attributes': "Special attributes", 'schedule': "Schedule", 'pseudo': "Pseudo", @@ -93,7 +91,6 @@ export default { 'person': "Person", 'legal-name': "Legal name", 'contact-details': "Contact details", - 'other-infos': "Other informations", 'email': "E-mail", 'phone': "Phone", 'address': "Address", @@ -112,11 +109,13 @@ export default { 'minutes': "minutes", 'notes': "Notes", 'description': "Description", + 'label-colon': "{label}:", 'ref': "Ref.", 'ref-ref': "Ref.: {reference}", 'reference': "Reference", 'number': "Number", 'park': "Park", + 'park-name': "Park \"{name}\"", 'prices': "Prices", 'rental-price': "Rental price", 'replacement-price': "Replacement price", @@ -157,8 +156,8 @@ export default { 'not-limited': "not limited", 'open-trash-bin': "Display trash bin", 'display-not-deleted-items': "Display not deleted items", - 'created-at': "Created at:", - 'updated-at': "Updated at:", + 'created-at': "Created at: {date}", + 'updated-at': "Updated at: {date}", 'state': "State", 'picture': "Picture", 'add-a-picture': "Add a picture", @@ -181,7 +180,6 @@ 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.", @@ -193,11 +191,8 @@ export default { 'print-summary': "Print this summary", 'open': "Open", 'in': "In {location}", + 'mobilization-period': "Mobilization {period}", '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}", 'or': "or", 'for': "For", 'with': "With", @@ -254,6 +249,10 @@ export default { "{count} item", "{count} items", ], + 'items-count-total': [ + "{count} item in total", + "{count} items in total", + ], 'used-count': [ "{count} used", "{count} used", @@ -269,7 +268,7 @@ export default { 'total-amount': "Total amount", 'total-amount-without-taxes': "Total amount excl. tax:\u00A0{amount}", 'total-amount-after-discount': "Total excl. tax after discount:\u00A0{amount}", - 'total-replacement': "Total replacement price:", + 'total-replacement': "Total replacement price: {total}", 'total-value': "Total value", 'total-quantity': "Total quantity: {total}", 'daily-amount': "Daily amount: {amount}", @@ -305,8 +304,9 @@ export default { 'grouped-by': "Display grouped by:", 'not-grouped': "Not grouped", 'start-on': "Start on", - 'return-scheduled-on': "Return scheduled on", - 'back-to-calendar': "Back to calendar", + 'expected-end-on': "Expected end on", + 'back-to-home': "Back to homepage", + 'back-to-schedule': "Back to schedule", 'previous-month': "Previous month", 'next-month': "Next month", @@ -314,8 +314,6 @@ export default { 'events-count': ['{count} event', '{count} events'], 'use': "Use", - 'use-this-template': "Use this list template", - 'create-company': "Add a new company", 'inventories': "Inventories", @@ -349,7 +347,6 @@ export default { 'categories': "Categories", 'parks': "Parks", 'technician': "Technician", - 'online-reservations': "Online reservations", 'this-feature-is-coming-soon': "This feature implementation is in progress.", @@ -369,8 +366,8 @@ export default { 'confirm-delete': "Move this event in trash bin?", 'event-missing-materials': "Missing materials", - 'event-missing-materials-help': "These are the missing materials for the period of the event, because it is used in another event, the number needed is too high, or there are some out of order. These materials must therefore be added to the park, or rented from another company.", - 'missing-material-count': "Need {quantity}, missing\u00A0{missing}!", + 'event-missing-materials-help': "These are the missing materials for the period of the event, because it is used in another event, the number needed is too high, or there are some out of order.", + 'missing-material-count': "Need {quantity}, missing\u00A0{missing}", 'warning-no-beneficiary': "Warning: this event has no beneficiaries!", 'warning-no-material': "Warning: this event is empty, there is no material at the moment!", @@ -390,15 +387,4 @@ export default { 'has-not-returned-materials': "This event has some not-returned materials.", }, }, - - '@reservation': { - 'statuses': { - 'is-past': "This reservation is past.", - 'is-currently-running': "This reservation is currently running.", - 'is-archived': "This reservation is archived.", - 'has-missing-materials': "This reservation has missing materials.", - 'needs-its-return-inventory': "It's necessary to make the return inventory of this reservation!", - 'has-not-returned-materials': "This reservation has some not-returned materials.", - }, - }, }; diff --git a/client/src/locale/en/date.js b/client/src/locale/en/date.js new file mode 100644 index 000000000..b8123331d --- /dev/null +++ b/client/src/locale/en/date.js @@ -0,0 +1,11 @@ +export default { + // - Formats simples. + 'on-date': "on {date}", + 'from-date': "from\u00A0{date}", + 'to-date': "to\u00A0{date}", + 'from-date-to-date': "from\u00A0{from} to\u00A0{to}", + + // - Formats "dans une phrase". + 'date-in-sentence': "the {date}", + 'period-in-sentence': "the period from {from} to {to}", +}; diff --git a/client/src/locale/en/errors.js b/client/src/locale/en/errors.js index 19568f0bc..78c9daf4f 100644 --- a/client/src/locale/en/errors.js +++ b/client/src/locale/en/errors.js @@ -1,27 +1,17 @@ export default { errors: { + 'unexpected': "An unexpected error occurred, please try again.", 'unexpected-while-saving': "An unexpected error occurred while saving, please try again.", 'unexpected-while-deleting': "An unexpected error occurred while deleting, please try again.", 'unexpected-while-restoring': "An unexpected error occurred while restoring, please try again.", 'unexpected-while-calculating': "An unexpected error occurred while calculating, please try again.", 'unexpected-while-fetching': "An unexpected error occurred while retrieving the data.", - 'api-unreachable': "Sorry, but the API is unreachable... Please check your access to network.", + 'api-unreachable': "The service is currently unreachable. Please check your internet connection and try again.", 'record-not-found': "This record does not exist.", 'page-not-found': "The requested page does not exist.", 'unknown': "Unknown error.", - 'validation': "Please check form informations.", + 'validation': "Please check the information provided in the form.", 'already-exists': "This record already exists.", - 'show-details': "Show error details", - 'details-title': "Details of error", - 'details-intro1': "You can copy and paste the following to get help from the community.", - 'details-intro2': "Please copy it as is, because it's written in markdown to help reading on", - 'details-intro-forum': "the forum", - 'details-intro3': "or on", - 'details-intro-not-detailed': "To get more details about the error, you can modify the parameter `displayErrorDetails` to 'true' in file 'src/App/Config/settings.json'.", - 'details-request': "API request:", - 'details-message': "Error message", - 'details-file': "File:", - 'details-stacktrace': "Stack trace:", 'critical': [ "A critical error has occurred, please refresh the page.", "If the problem persists, please contact an administrator.", diff --git a/client/src/locale/en/index.js b/client/src/locale/en/index.js index 37662e4ef..a991bd892 100644 --- a/client/src/locale/en/index.js +++ b/client/src/locale/en/index.js @@ -1,7 +1,9 @@ import common from './common'; +import date from './date'; import errors from './errors'; export default { ...common, + ...date, ...errors, }; diff --git a/client/src/locale/fr/common.js b/client/src/locale/fr/common.js index d859a04ca..4be906bfe 100644 --- a/client/src/locale/fr/common.js +++ b/client/src/locale/fr/common.js @@ -1,7 +1,7 @@ export default { 'hello-name': "Bonjour {name}\u00A0!", 'your-settings': "Vos paramètres", - 'logout-quit': "Quitter Loxya (Robert2)", + 'logout-quit': "Quitter Loxya", 'action-add': "Ajouter", 'action-edit': "Modifier", 'action-view': "Afficher en détail", @@ -25,13 +25,13 @@ export default { '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 modifications n'ont pas été enregistrées. Êtes-vous sûr de vouloir quitter cette page\u00A0?", 'yes-leave-page': "Oui, quitter la page", 'cancel': "Annuler", 'close': "Fermer", 'confirm': "Confirmer", 'copy-to-clipboard': "Copier dans le presse-papier", - 'copied-in-clipboard': "Copié dans le presse-papier\u00A0!", + 'copied-to-clipboard': "Copié dans le presse-papiers\u00A0!", 'copy': "Copier", 'copied': "Copié\u00A0!", 'almost-done': "Presque terminé...", @@ -43,22 +43,18 @@ export default { 'please-choose': "Veuillez choisir...", 'start-typing-to-search': "Commencez à écrire pour rechercher...", 'type-at-least-count-chars-to-search': [ - "Entrez encore au moins {count} lettre pour rechercher...", - "Entrez encore au moins {count} lettres pour rechercher...", + "Entrez au moins {count} lettre de plus pour rechercher...", + "Entrez au moins {count} lettres de plus pour rechercher...", ], + 'count-chars': ["{count} caractère", "{count} caractères"], 'empty-state': "Il n'y a aucun enregistrement à afficher pour le moment.", 'search-term': "Recherche", 'no-result-found-try-another-search': "Aucun résultat. Essayez avec une autre recherche.", - 'create-select-item-label': "Créer un {label}", - '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?", 'locked': "verrouillé", 'clear-filters': "Réinitialiser les filtres", 'optional': "Optionnel", 'n-persons': ["{count} personne", "{count} personnes"], - 'and-n-others': ["et {count} autre", "et {count} autres"], + 'name-and-n-others': ["{name} et {count} autre", "{name} et {count} autres"], 'add-comment': "Ajouter un commentaire", 'modify-comment': "Modifier le commentaire", 'save': "Sauvegarder", @@ -78,12 +74,14 @@ export default { 'extra-infos': "Informations additionnelles", 'stock-infos': "Informations liées au stock", 'billing-infos': "Informations de facturation", + 'other-infos': "Autres informations", 'documents': "Documents", 'billing': "Facturation", + 'history': "Historique", 'special-attributes': "Caractéristiques spéciales", 'schedule': "Agenda", - 'pseudo': "Pseudo", - 'email-address-or-pseudo': "Adresse e-mail ou Pseudo", + 'pseudo': "Identifiant", + 'email-address-or-pseudo': "Adresse e-mail / Identifiant", 'password': "Mot de passe", 'first-name': "Prénom", 'last-name': "Nom", @@ -93,7 +91,6 @@ export default { 'person': "Personne", 'legal-name': "Raison sociale", 'contact-details': "Coordonnées", - 'other-infos': "Autres informations", 'email': "E-mail", 'phone': "Téléphone", 'address': "Adresse", @@ -112,11 +109,13 @@ export default { 'minutes': "minutes", 'notes': "Notes", 'description': "Description", + 'label-colon': "{label}\u00A0:", 'ref': "Réf.", 'ref-ref': "Ref.\u00A0: {reference}", 'reference': "Référence", 'number': "Numéro", 'park': "Parc", + 'park-name': "Parc «\u00A0{name}\u00A0»", 'prices': "Tarifs", 'rental-price': "Tarif location", 'replacement-price': "Prix de remplacement", @@ -125,7 +124,7 @@ export default { 'value-per-day': '{value}\u00A0/\u00A0jour', 'serial-number': "N° de série", 'examples-list': "Exemples\u00A0: {list}, etc.", - 'not-specified': "Non renseigné", + 'not-specified': "Non renseigné(e)", 'qty': "Qté", 'stock-qty': "Qté stock", @@ -156,8 +155,8 @@ 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:", + 'created-at': "Créé le\u00A0: {date}", + 'updated-at': "Modifié le\u00A0: {date}", 'state': "État", 'picture': "Photo", 'add-a-picture': "Ajouter une photo", @@ -181,9 +180,8 @@ export default { 'not-confirmed': "Non confirmé", 'is-billable': "Est facturable\u00A0?", 'event-is-now-billable': "Cet événement est maintenant facturable.", - 'is-not-billable-help': "Mode «\u00A0prêt\u00A0»\u00A0: pas de facturation.", - 'is-billable-help': "Mode «\u00A0location\u00A0»\u00A0: facturation possible.", - 'color-on-calendar': "Couleur sur le calendrier", + 'is-not-billable-help': "Mode «\u00A0Prêt\u00A0»\u00A0: pas de facturation.", + 'is-billable-help': "Mode «\u00A0Location\u00A0»\u00A0: facturation possible.", 'confirm-event': "Confirmer l'événement", 'unconfirm-event': "Remettre l'événement en attente", 'delete-event': "Supprimer l'événement", @@ -192,11 +190,8 @@ export default { 'print-summary': "Imprimer ce récapitulatif", 'open': "Ouvrir", 'in': "À {location}", + 'mobilization-period': "Mobilisation {period}", '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}", 'or': "ou", 'for': "Pour", 'with': "Avec", @@ -237,9 +232,9 @@ export default { 'download-pdf': "Télécharger au format PDF", 'download-barcode': "Télécharger le code-barre", 'download-invoice': "Télécharger la facture", - 'click-here-to-create-estimate': "Cliquez ici pour pour créer un devis", - 'click-here-to-create-invoice': "Cliquez ici pour pour créer une facture", - 'click-here-to-generate-invoice': "Cliquez ici pour pour générer une facture", + 'click-here-to-create-estimate': "Cliquez ici pour créer un devis", + 'click-here-to-create-invoice': "Cliquez ici pour créer une facture", + 'click-here-to-generate-invoice': "Cliquez ici pour générer une facture", 'click-here-to-regenerate-invoice': "Cliquez ici pour refaire une facture", 'create-new-estimate': "Créer un nouveau devis", 'estimate-created': "Le devis a bien été créé.", @@ -253,6 +248,10 @@ export default { "{count} article", "{count} articles", ], + 'items-count-total': [ + "{count} article au total", + "{count} articles au total", + ], 'used-count': [ "{count} utilisé", "{count} utilisés", @@ -268,7 +267,7 @@ export default { '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-replacement': "Valeur de remplacement totale\u00A0: {total}", 'total-value': "Valeur totale", 'total-quantity': "Quantité totale\u00A0: {total}", 'daily-amount': "Montant journalier\u00A0: {amount}", @@ -304,18 +303,16 @@ export default { 'grouped-by': "Voir groupé par\u00A0:", 'not-grouped': "Non groupé", 'start-on': "Débute le", - 'return-scheduled-on': "Retour prévu le", - 'back-to-calendar': "Retour au calendrier", + 'expected-end-on': "Fin prévue le", + 'back-to-home': "Retour à l'accueil", + 'back-to-schedule': "Retour au planning", 'previous-month': "Mois précédent", 'next-month': "Mois suivant", 'used-by': "Utilisé dans", 'events-count': ['{count} événement', '{count} événements'], - 'reservations-count': ['{count} réservation', '{count} réservations'], 'use': "Utiliser", - 'use-this-template': "Utiliser ce modèle de liste", - 'create-company': "Ajouter une nouvelle société", 'inventories': "Inventaires", @@ -350,9 +347,8 @@ export default { 'not-categorized': "Non catégorisé", 'parks': "Parcs", 'technician': "Technicien", - 'online-reservations': "Réservations en ligne", - 'this-feature-is-coming-soon': "Cette fonctionnalité est en cours de développement.", + 'this-feature-is-coming-soon': "Cette fonctionnalité est actuellement en développement.", 'external-links': { 'official-website': "Site web officiel", @@ -363,15 +359,15 @@ 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?", + 'confirm-cancel-upload-change-tab': "Attention, un envoi de fichier est en cours. Si vous quittez cet onglet, l'envoi sera annulé. Êtes-vous sûr de vouloir continuer\u00A0?", + 'confirm-cancel-upload-close-modal': "Attention, un envoi de fichier est en cours. Si vous fermez cette fenêtre, l'envoi sera annulé. Êtes-vous sûr de vouloir continuer\u00A0?", '@event': { 'confirm-delete': "Mettre cet événement à la corbeille\u00A0?", 'event-missing-materials': "Matériel manquant", - 'event-missing-materials-help': "Il s'agit du matériel manquant pour la période de l'événement, car il est utilisé dans un autre événement, le nombre voulu est trop important, ou quelques uns sont en panne. 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!", + 'event-missing-materials-help': "Il s'agit du matériel manquant pour la période de l'événement, car il est utilisé dans un autre événement, le nombre voulu est trop important, ou quelques uns sont en panne.", + 'missing-material-count': "Besoin de {quantity}, il en manque\u00A0{missing}", 'warning-no-beneficiary': "Attention, cet événement n'a aucun bénéficiaire\u00A0!", 'warning-no-material': "Attention, cet événement est vide, il ne contient aucun matériel pour le moment\u00A0!", @@ -391,15 +387,4 @@ export default { 'has-not-returned-materials': "Cet événement a du matériel qui n'a pas été retourné.", }, }, - - '@reservation': { - 'statuses': { - 'is-past': "Cette réservation est passée.", - '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!", - 'has-not-returned-materials': "Cette réservation a du matériel qui n'a pas été retourné.", - }, - }, }; diff --git a/client/src/locale/fr/date.js b/client/src/locale/fr/date.js new file mode 100644 index 000000000..3c547032b --- /dev/null +++ b/client/src/locale/fr/date.js @@ -0,0 +1,11 @@ +export default { + // - Formats simples. + 'on-date': "le {date}", + 'from-date': "du\u00A0{date}", + 'to-date': "au\u00A0{date}", + 'from-date-to-date': "du\u00A0{from} au\u00A0{to}", + + // - Formats "dans une phrase". + 'date-in-sentence': "le {date}", + 'period-in-sentence': "la période du {from} au {to}", +}; diff --git a/client/src/locale/fr/errors.js b/client/src/locale/fr/errors.js index 5995c652e..cc4cb5cf4 100644 --- a/client/src/locale/fr/errors.js +++ b/client/src/locale/fr/errors.js @@ -1,27 +1,17 @@ export default { errors: { + 'unexpected': "Une erreur inattendue s'est produite, veuillez ré-essayer.", 'unexpected-while-saving': "Une erreur inattendue s'est produite lors de l'enregistrement, veuillez ré-essayer.", 'unexpected-while-deleting': "Une erreur inattendue s'est produite lors de la suppression, veuillez ré-essayer.", 'unexpected-while-restoring': "Une erreur inattendue s'est produite lors de la restauration, veuillez ré-essayer.", 'unexpected-while-calculating': "Une erreur inattendue s'est produite lors du calcul, veuillez ré-essayer.", 'unexpected-while-fetching': "Une erreur inattendue s'est produite lors de la récupération des données.", - 'api-unreachable': "Désolé, mais l'API est inaccessible... Veuillez vérifier votre accès au réseau.", + 'api-unreachable': "Le service est actuellement injoignable. Veuillez vérifier votre connexion réseau et réessayer.", 'record-not-found': "Cet enregistrement n'existe pas.", 'page-not-found': "La page demandée n'existe pas ou plus.", - 'validation': "Veuillez vérifier les informations du formulaire.", + 'validation': "Veuillez vérifier les données saisies dans le formulaire.", 'unknown': "Erreur inconnue.", 'already-exists': "Cet enregistrement existe déjà.", - 'show-details': "Voir le détail de l'erreur", - 'details-title': "Détails de l'erreur", - 'details-intro1': "Vous pouvez copier ce qui suit, pour obtenir de l'aide de la part de la communauté.", - 'details-intro2': "Merci de le copier tel quel, car c'est écrit en markdown pour faciliter la lecture sur", - '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-message': "Message de l'erreur", - '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.", diff --git a/client/src/locale/fr/index.js b/client/src/locale/fr/index.js index 37662e4ef..a991bd892 100644 --- a/client/src/locale/fr/index.js +++ b/client/src/locale/fr/index.js @@ -1,7 +1,9 @@ import common from './common'; +import date from './date'; import errors from './errors'; export default { ...common, + ...date, ...errors, }; diff --git a/client/src/stores/api/@codes.ts b/client/src/stores/api/@codes.ts index 02d9bed3e..f5ec2c6fb 100644 --- a/client/src/stores/api/@codes.ts +++ b/client/src/stores/api/@codes.ts @@ -20,14 +20,4 @@ export enum ApiErrorCode { /** Le payload fourni dans la requête ne doit pas être vide. */ EMPTY_PAYLOAD = 401, - - // - // - Conflits. - // - - /** - * Un conflit dû au fait qu'une tentative d'assignation d'un - * technicien a échoué vu qu'il est déjà mobilisé à ce moment. - */ - TECHNICIAN_ALREADY_BUSY = 201, } diff --git a/client/src/stores/api/@schema.ts b/client/src/stores/api/@schema.ts new file mode 100644 index 000000000..ebe37fff3 --- /dev/null +++ b/client/src/stores/api/@schema.ts @@ -0,0 +1,45 @@ +import { z } from '@/utils/validation'; + +import type { UnionToTupleString } from '@/utils/@types'; +import type { AnyZodObject, ZodEnum, ZodTypeAny } from 'zod'; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const withPaginationEnvelope = (dataSchema: T) => ( + z.object({ + data: dataSchema.array(), + pagination: z.object({ + perPage: z.number().positive(), + currentPage: z.number().nonnegative(), + total: z.object({ + items: z.number().nonnegative(), + pages: z.number().nonnegative(), + }), + }), + }) +); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const withCountedEnvelope = (dataSchema: T) => ( + z.object({ + data: dataSchema.array(), + count: z.number().nonnegative(), + }) +); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const withCsvImportResult = (mapping: T) => ( + z.strictObject({ + total: z.number().nonnegative(), + success: z.number().nonnegative(), + errors: z.array(z.strictObject({ + line: z.number().nonnegative(), + message: z.string(), + errors: z.array(z.strictObject({ + // - Requis pour patcher le `(T: AnyZodObject).keyof()` qui renvoi un `ZodEnum` sinon. + field: z.union([mapping.keyof() as any as ZodEnum>, z.string()]), + value: z.string().nullable(), + error: z.string(), + })), + })), + }) +); diff --git a/client/src/stores/api/@types.ts b/client/src/stores/api/@types.ts index 5f2bb769b..2e7b980e4 100644 --- a/client/src/stores/api/@types.ts +++ b/client/src/stores/api/@types.ts @@ -1,3 +1,7 @@ +// +// - Types liés à la pagination / tri. +// + /** Sens de tri. */ export enum Direction { /** Direction ascendante. */ @@ -49,47 +53,55 @@ export type ListingParams = ( & PaginationParams ); -export type PaginatedData = { - data: T, - pagination: { - perPage: number, - currentPage: number, - total: { - items: number, - pages: number, - }, - }, -}; - -export type WithCount = { - count: number, - data: T, -}; - -// - Types liés aux imports +// +// - Types liés aux imports. +// export type CsvDelimiter = ',' | ';' | ':' | `\t`; -export type CsvImport> = { - mapping: Mapping, - delimiter: CsvDelimiter, +export type CsvMapping = Record; + +export type CsvImport = { + mapping: T, file: File, + delimiter: CsvDelimiter, }; -export type CsvColumnError> = { - field: keyof Mapping, - value: string, +export type CsvColumnError = { + field: keyof T | string, + value: string | null, error: string, }; -export type CsvImportError> = { +export type CsvImportError = { line: number, message: string, - errors: Array>, + errors: Array>, }; -export type CsvImportResults> = { +export type CsvImportResults = { total: number, success: number, - errors: Array>, + errors: Array>, +}; + +// +// - Enveloppes. +// + +export type PaginatedData = { + data: T, + pagination: { + perPage: number, + currentPage: number, + total: { + items: number, + pages: number, + }, + }, +}; + +export type CountedData = { + data: T, + count: number, }; diff --git a/client/src/stores/api/attributes.ts b/client/src/stores/api/attributes.ts index 1ae64aed8..ac44ae11e 100644 --- a/client/src/stores/api/attributes.ts +++ b/client/src/stores/api/attributes.ts @@ -1,61 +1,155 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { CategorySchema } from './categories'; import type { Category } from './categories'; +import type { SchemaInfer } from '@/utils/validation'; -// -// - Types -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ -export type AttributeType = 'string' | 'integer' | 'float' | 'boolean' | 'date'; +export enum AttributeType { + STRING = 'string', + INTEGER = 'integer', + FLOAT = 'float', + BOOLEAN = 'boolean', + DATE = 'date', +} -type AttributeBase = { - id: number, - name: string, -}; +const AttributeBaseSchema = z.strictObject({ + id: z.number(), + name: z.string(), +}); -export type Attribute = AttributeBase & ( - | { type: 'string', maxLength: number | null } - | { type: 'integer' | 'float', unit: string | null, isTotalisable: boolean } - | { type: 'boolean' | 'date' } -); +// NOTE: Pour le moment, pas moyen de faire ça mieux en gardant l'objet `strict`. +// @see https://github.com/colinhacks/zod/discussions/3011#discussioncomment-7718731 +export const AttributeSchema = z.discriminatedUnion('type', [ + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.STRING), + max_length: z.number().nullable(), + }), + AttributeBaseSchema.extend({ + type: z.enum([AttributeType.INTEGER, AttributeType.FLOAT]), + unit: z.string().nullable(), + is_totalisable: z.boolean().nullable().transform( + (value: boolean | null) => value ?? false, + ), + }), + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.BOOLEAN), + }), + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.DATE), + }), +]); -export type AttributeDetails = Attribute & { - categories: Category[], -}; +// NOTE: Pour le moment, pas moyen de faire ça mieux en gardant l'objet `strict`. +// @see https://github.com/colinhacks/zod/discussions/3011#discussioncomment-7718731 +export const AttributeWithValueSchema = z.discriminatedUnion('type', [ + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.STRING), + max_length: z.number().nullable(), + value: z.string().nullable(), + }), + AttributeBaseSchema.extend({ + type: z.enum([AttributeType.INTEGER, AttributeType.FLOAT]), + unit: z.string().nullable(), + is_totalisable: z.boolean().nullable().transform( + (value: boolean | null) => value ?? false, + ), + value: z.number().nullable(), + }), + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.BOOLEAN), + value: z.boolean().nullable(), + }), + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.DATE), + value: z.day().nullable(), + }), +]); + +// NOTE: Pour le moment, pas moyen de faire ça mieux en gardant l'objet `strict`. +// @see https://github.com/colinhacks/zod/discussions/3011#discussioncomment-7718731 +export const AttributeDetailsSchema = (() => { + const baseSchema = AttributeBaseSchema.extend({ + categories: z.lazy(() => CategorySchema.array()), + }); + + return z.discriminatedUnion('type', [ + baseSchema.extend({ + type: z.literal(AttributeType.STRING), + max_length: z.number().nullable(), + }), + baseSchema.extend({ + type: z.enum([AttributeType.INTEGER, AttributeType.FLOAT]), + unit: z.string().nullable(), + is_totalisable: z.boolean().nullable().transform( + (value: boolean | null) => value ?? false, + ), + }), + baseSchema.extend({ + type: z.enum([AttributeType.BOOLEAN, AttributeType.DATE]), + }), + ]); +})(); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Attribute = SchemaInfer; + +export type AttributeWithValue = SchemaInfer; -export type AttributeEdit = { +export type AttributeDetails = SchemaInfer; + +// +// - Edition +// + +export type AttributeCreate = { name: string, type?: AttributeType, unit?: string, - isTotalisable?: boolean, - maxLength?: string | null, + max_length?: string | number | null, + is_totalisable?: boolean, categories: Array, }; -export type AttributePut = Omit; +export type AttributeEdit = Omit; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ const all = async (categoryId?: Category['id'] | 'none'): Promise => { - const { data } = await requester.get('/attributes', { - params: { category: categoryId }, - }); - return data; + const params = { ...(categoryId !== undefined ? { category: categoryId } : {}) }; + const response = await requester.get('/attributes', { params }); + return AttributeDetailsSchema.array().parse(response.data); }; -const one = async (id: Attribute['id']): Promise => ( - (await requester.get(`/attributes/${id}`)).data -); +const one = async (id: Attribute['id']): Promise => { + const response = await requester.get(`/attributes/${id}`); + return AttributeDetailsSchema.parse(response.data); +}; -const create = async (data: AttributeEdit): Promise => ( - (await requester.post('/attributes', data)).data -); +const create = async (data: AttributeCreate): Promise => { + const response = await requester.post('/attributes', data); + return AttributeDetailsSchema.parse(response.data); +}; -const update = async (id: Attribute['id'], data: AttributePut): Promise => ( - (await requester.put(`/attributes/${id}`, data)).data -); +const update = async (id: Attribute['id'], data: AttributeEdit): Promise => { + const response = await requester.put(`/attributes/${id}`, data); + return AttributeDetailsSchema.parse(response.data); +}; const remove = async (id: Attribute['id']): Promise => { await requester.delete(`/attributes/${id}`); diff --git a/client/src/stores/api/beneficiaries.ts b/client/src/stores/api/beneficiaries.ts index 0795fdb72..551b6f22b 100644 --- a/client/src/stores/api/beneficiaries.ts +++ b/client/src/stores/api/beneficiaries.ts @@ -1,15 +1,20 @@ -import moment from 'moment'; +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -import { normalize as normalizeEstimate } from '@/stores/api/estimates'; -import { normalize as normalizeInvoice } from '@/stores/api/invoices'; - -import type { MomentInput } from 'moment'; -import type { Company } from '@/stores/api/companies'; -import type { Country } from '@/stores/api/countries'; -import type { User } from '@/stores/api/users'; -import type { RawEstimate, Estimate } from '@/stores/api/estimates'; -import type { RawInvoice, Invoice } from '@/stores/api/invoices'; -import type { BookingSummary } from '@/stores/api/bookings'; +import { UserSchema } from './users'; +import { CompanySchema } from './companies'; +import { CountrySchema } from './countries'; +import { EstimateSchema } from './estimates'; +import { InvoiceSchema } from './invoices'; +import { BookingExcerptSchema } from './bookings'; +import { + withPaginationEnvelope, +} from './@schema'; + +import type { SchemaInfer } from '@/utils/validation'; +import type DateTime from '@/utils/datetime'; +import type { Estimate } from './estimates'; +import type { Invoice } from './invoices'; +import type { BookingExcerpt } from './bookings'; import type { Direction, ListingParams, @@ -17,40 +22,54 @@ import type { PaginationParams, } from './@types'; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const BeneficiarySchema = z.strictObject({ + id: z.number(), + user_id: z.number().nullable(), + first_name: z.string(), + last_name: z.string(), + full_name: z.string(), + reference: z.string().nullable(), + // TODO [zod@>3.22.4]: Remettre `email()`. + email: z.string().nullable(), + phone: z.string().nullable(), + company_id: z.number().nullable(), + company: z.lazy(() => CompanySchema).nullable(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + country: z.lazy(() => CountrySchema).nullable(), + full_address: z.string().nullable(), + note: z.string().nullable(), +}); + +export const BeneficiaryDetailsSchema = BeneficiarySchema.extend({ + user: z.lazy(() => UserSchema).nullable(), + stats: z.strictObject({ + borrowings: z.number().nonnegative(), + }), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Beneficiary = SchemaInfer; + +export type BeneficiaryDetails = SchemaInfer; + // -// - Types +// - Edition // -export type BeneficiaryStats = { - borrowings: number, -}; - -export type Beneficiary = { - id: number, - first_name: string, - full_name: string, - last_name: string, - reference: string | null, - email: string | null, - phone: string | null, - company_id: number | null, - company: Company | null, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: number | null, - country: Country | null, - full_address: string | null, - note: string | null, - user_id: number | null, - can_make_reservation: boolean, - stats: BeneficiaryStats, -}; - -export type BeneficiaryDetails = Beneficiary & { - user: User | null, -}; - export type BeneficiaryEdit = { first_name: string, last_name: string, @@ -62,9 +81,15 @@ export type BeneficiaryEdit = { postal_code: string | null, locality: string | null, country_id: number | null, + pseudo?: string, + password?: string, note: string | null, }; +// +// - Récupération +// + type GetAllParams = ListingParams & { /** * Permet de ne récupérer que les bénéficiaires dans la "corbeille". @@ -81,7 +106,7 @@ type GetBookingsParams = PaginationParams & { * * @default undefined */ - after?: MomentInput, + after?: DateTime, /** * Le sens dans lequel on veut récupérer les bookings : @@ -93,53 +118,54 @@ type GetBookingsParams = PaginationParams & { direction?: Direction, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (params: GetAllParams): Promise> => ( - (await requester.get('/beneficiaries', { params })).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = await requester.get('/beneficiaries', { params }); + return withPaginationEnvelope(BeneficiarySchema).parse(response.data); +}; -const one = async (id: Beneficiary['id']): Promise => ( - (await requester.get(`/beneficiaries/${id}`)).data -); +const one = async (id: Beneficiary['id']): Promise => { + const response = await requester.get(`/beneficiaries/${id}`); + return BeneficiaryDetailsSchema.parse(response.data); +}; -const create = async (data: BeneficiaryEdit): Promise => ( - (await requester.post('/beneficiaries', data)).data -); +const create = async (data: BeneficiaryEdit): Promise => { + const response = await requester.post('/beneficiaries', data); + return BeneficiaryDetailsSchema.parse(response.data); +}; -const update = async (id: Beneficiary['id'], data: BeneficiaryEdit): Promise => ( - (await requester.put(`/beneficiaries/${id}`, data)).data -); +const update = async (id: Beneficiary['id'], data: BeneficiaryEdit): Promise => { + const response = await requester.put(`/beneficiaries/${id}`, data); + return BeneficiaryDetailsSchema.parse(response.data); +}; -const restore = async (id: Beneficiary['id']): Promise => ( - (await requester.put(`/beneficiaries/restore/${id}`)).data -); +const restore = async (id: Beneficiary['id']): Promise => { + const response = await requester.put(`/beneficiaries/restore/${id}`); + return BeneficiaryDetailsSchema.parse(response.data); +}; const remove = async (id: Beneficiary['id']): Promise => { await requester.delete(`/beneficiaries/${id}`); }; -const bookings = async ( - id: Beneficiary['id'], - { after, ...otherParams }: GetBookingsParams = {}, -): Promise> => { - const params: Record = { ...otherParams }; - if (after !== undefined) { - params.after = moment(after).format(); - } - return (await requester.get(`/beneficiaries/${id}/bookings`, { params })).data; +const bookings = async (id: Beneficiary['id'], params: GetBookingsParams = {}): Promise> => { + const response = await requester.get(`/beneficiaries/${id}/bookings`, { params }); + return withPaginationEnvelope(BookingExcerptSchema).parse(response.data); }; const estimates = async (id: Beneficiary['id']): Promise => { - const rawEstimates: RawEstimate[] = (await requester.get(`/beneficiaries/${id}/estimates`)).data; - return rawEstimates.map(normalizeEstimate); + const response = await requester.get(`/beneficiaries/${id}/estimates`); + return EstimateSchema.array().parse(response.data); }; const invoices = async (id: Beneficiary['id']): Promise => { - const rawInvoices: RawInvoice[] = (await requester.get(`/beneficiaries/${id}/invoices`)).data; - return rawInvoices.map(normalizeInvoice); + const response = await requester.get(`/beneficiaries/${id}/invoices`); + return InvoiceSchema.array().parse(response.data); }; export default { diff --git a/client/src/stores/api/bookings.ts b/client/src/stores/api/bookings.ts index 8318a1031..7c4679e73 100644 --- a/client/src/stores/api/bookings.ts +++ b/client/src/stores/api/bookings.ts @@ -1,105 +1,184 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -import { normalize as normalizeEvent } from '@/stores/api/events'; - -import type { Moment } from 'moment'; -import type { Event, RawEvent } from '@/stores/api/events'; -import type { Park } from '@/stores/api/parks'; -import type { Material } from '@/stores/api/materials'; +import { BeneficiarySchema } from './beneficiaries'; +import { withPaginationEnvelope } from './@schema'; +import { + EventTechnicianSchema, + createEventDetailsSchema, +} from './events'; + +import type Period from '@/utils/period'; +import type { Material, UNCATEGORIZED } from '@/stores/api/materials'; import type { Category } from '@/stores/api/categories'; - -// -// - Constants -// +import type { Park } from '@/stores/api/parks'; +import type { SchemaInfer } from '@/utils/validation'; +import type { ZodRawShape } from 'zod'; +import type { + PaginatedData, + SortableParams, + PaginationParams, +} from './@types'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ export enum BookingEntity { EVENT = 'event', } // -// - Types +// - Schemas principaux // -type EventBookingSummary = ( - & Pick - & { - entity: BookingEntity.EVENT, - parks: Array, - categories: Array, - } +// - Booking excerpt schema. +export const BookingExcerptSchema = z.strictObject({ + id: z.number(), + entity: z.literal(BookingEntity.EVENT), + title: z.string(), + location: z.string().nullable(), + color: z.string().nullable(), + mobilization_period: z.period(), + operation_period: z.period(), + beneficiaries: z.lazy(() => BeneficiarySchema.array()), + technicians: z.lazy(() => EventTechnicianSchema.array()), + is_confirmed: z.boolean(), + is_archived: z.boolean(), + is_departure_inventory_done: z.boolean(), + is_return_inventory_done: z.boolean(), + has_not_returned_materials: z.boolean().nullable(), + categories: z.number().array(), // - Ids des catégories liés. + parks: z.number().array(), // - Ids des parcs liés. + created_at: z.datetime(), +}); + +// - Booking summary schema. +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const createBookingSummarySchema = (augmentation: T) => ( + z + .strictObject({ + id: z.number(), + entity: z.literal(BookingEntity.EVENT), + title: z.string(), + reference: z.string().nullable(), + description: z.string().nullable(), + location: z.string().nullable(), + color: z.string().nullable(), + mobilization_period: z.period(), + operation_period: z.period(), + beneficiaries: z.lazy(() => BeneficiarySchema.array()), + technicians: z.lazy(() => EventTechnicianSchema.array()), + is_confirmed: z.boolean(), + is_billable: z.boolean(), + is_archived: z.boolean(), + is_departure_inventory_done: z.boolean(), + is_return_inventory_done: z.boolean(), + has_missing_materials: z.boolean().nullable(), + has_not_returned_materials: z.boolean().nullable(), + categories: z.number().array(), // - Ids des catégories liés. + parks: z.number().array(), // - Ids des parcs liés. + created_at: z.datetime(), + }) + .extend(augmentation) +); +export const BookingSummarySchema = createBookingSummarySchema({}); + +// - Booking schema. +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const createBookingSchema = (augmentation: T) => ( + z.lazy(() => ( + createEventDetailsSchema({ + entity: z.literal(BookingEntity.EVENT), + ...augmentation, + }) + )) ); +export const BookingSchema = createBookingSchema({}); -export type BookingSummary = EventBookingSummary; +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -type RawBooking = ( - | (RawEvent & { entity: BookingEntity.EVENT }) +// +// - Main Types +// + +type NarrowBooking = ( + Extract ); -export type Booking = ( - | (Event & { entity: BookingEntity.EVENT }) +export type BookingExcerpt = ( + NarrowBooking, Entity> ); +export type BookingSummary = ( + NarrowBooking, Entity> +); + +export type Booking = ( + NarrowBooking, Entity> +); + +// +// - Édition +// + export type MaterialQuantity = { id: Material['id'], quantity: number, }; // -// - Normalizer +// - Récupération // -const normalize = (rawBooking: RawBooking): Booking => { - const { entity, ...booking } = rawBooking; +export type BookingListFilters = { + period?: Period, + search?: string, + category?: Category['id'] | typeof UNCATEGORIZED, + park?: Park['id'], + endingToday?: boolean, + returnInventoryTodo?: boolean, + archived?: boolean, + notConfirmed?: boolean, +}; - switch (entity) { - case BookingEntity.EVENT: - return { - entity: BookingEntity.EVENT, - ...normalizeEvent(booking as RawEvent), - }; +type GetAllParamsPaginated = BookingListFilters & SortableParams & PaginationParams & { paginated?: true }; +type GetAllInPeriodParams = { paginated: false, period: Period }; - default: - throw new Error(`Entity '${entity}' not recognized.`); - } -}; +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -// -// - Fonctions -// +async function all(params: GetAllInPeriodParams): Promise; +async function all(params?: GetAllParamsPaginated): Promise>; +async function all({ period, ...params }: GetAllParamsPaginated | GetAllInPeriodParams = {}): Promise { + const normalizedParams = { paginated: true, ...params, ...period?.toQueryParams('period') }; + const response = await requester.get('/bookings', { params: normalizedParams }); + + return normalizedParams.paginated + ? withPaginationEnvelope(BookingExcerptSchema).parse(response.data) + : BookingExcerptSchema.array().parse(response.data); +} -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'), - }; - return (await requester.get('/bookings', { params })).data; +const oneSummary = async (entity: BookingEntity, id: Booking['id']): Promise => { + const response = await requester.get(`/bookings/${entity}/${id}/summary`); + return BookingSummarySchema.parse(response.data); }; -const updateMaterials = async (entity: BookingEntity, id: BookingSummary['id'], materials: MaterialQuantity[]): Promise => ( - normalize((await requester.put(`/bookings/${entity}/${id}/materials`, materials)).data) -); +const updateMaterials = async (entity: BookingEntity, id: Booking['id'], materials: MaterialQuantity[]): Promise => { + const response = await requester.put(`/bookings/${entity}/${id}/materials`, materials); + return BookingSchema.parse(response.data); +}; export default { all, + oneSummary, updateMaterials, }; diff --git a/client/src/stores/api/categories.ts b/client/src/stores/api/categories.ts index d36ee867e..9c4333e43 100644 --- a/client/src/stores/api/categories.ts +++ b/client/src/stores/api/categories.ts @@ -1,39 +1,62 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { SubCategorySchema } from './subcategories'; -import type { Subcategory } from '@/stores/api/subcategories'; +import type { SchemaInfer } from '@/utils/validation'; -// -// - Types -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ -export type Category = { - id: number, - name: string, -}; +export const CategorySchema = z.object({ + id: z.number(), + name: z.string(), +}); -export type CategoryDetails = Category & { - sub_categories: Subcategory[], -}; +export const CategoryDetailsSchema = CategorySchema.extend({ + sub_categories: z.lazy(() => SubCategorySchema.array()), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Category = SchemaInfer; + +export type CategoryDetails = SchemaInfer; + +// +// - Edition +// export type CategoryEdit = { name: string, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (): Promise => ( - (await requester.get('/categories')).data -); +const all = async (): Promise => { + const response = await requester.get('/categories'); + return CategoryDetailsSchema.array().parse(response.data); +}; -const create = async (data: CategoryEdit): Promise => ( - (await requester.post('/categories', data)).data -); +const create = async (data: CategoryEdit): Promise => { + const response = await requester.post('/categories', data); + return CategoryDetailsSchema.parse(response.data); +}; -const update = async (id: Category['id'], data: CategoryEdit): Promise => ( - (await requester.put(`/categories/${id}`, data)).data -); +const update = async (id: Category['id'], data: Partial): Promise => { + const response = await requester.put(`/categories/${id}`, data); + return CategoryDetailsSchema.parse(response.data); +}; const remove = async (id: Category['id']): Promise => { await requester.delete(`/categories/${id}`); diff --git a/client/src/stores/api/companies.ts b/client/src/stores/api/companies.ts index c7d3d50f4..e3bce54dc 100644 --- a/client/src/stores/api/companies.ts +++ b/client/src/stores/api/companies.ts @@ -1,25 +1,43 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { withPaginationEnvelope } from './@schema'; +import { CountrySchema } from './countries'; -import type { Country } from '@/stores/api/countries'; +import type { SchemaInfer } from '@/utils/validation'; +import type { Country } from './countries'; import type { PaginatedData, ListingParams } from './@types'; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const CompanySchema = z.strictObject({ + id: z.number(), + legal_name: z.string(), + phone: z.string().nullable(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + country: z.lazy(() => CountrySchema).nullable(), + full_address: z.string().nullable(), + note: z.string().nullable(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Company = SchemaInfer; + // -// - Types +// - Edition // -export type Company = { - id: number, - legal_name: string | null, - phone: string | null, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: Country['id'] | null, - country: Country | null, - full_address: string | null, - note: string | null, -}; - export type CompanyEdit = { legal_name: string, phone: string | null, @@ -30,26 +48,36 @@ export type CompanyEdit = { note: string | null, }; -type GetAllParams = ListingParams & { deleted?: boolean }; - // -// - Fonctions +// - Récupération // -const all = async (params: GetAllParams): Promise> => ( - (await requester.get('/companies', { params })).data -); +type GetAllParams = ListingParams & { deleted?: boolean }; + +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const one = async (id: Company['id']): Promise => ( - (await requester.get(`/companies/${id}`)).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = await requester.get('/companies', { params }); + return withPaginationEnvelope(CompanySchema).parse(response.data); +}; -const create = async (data: CompanyEdit): Promise => ( - (await requester.post('/companies', data)).data -); +const one = async (id: Company['id']): Promise => { + const response = await requester.get(`/companies/${id}`); + return CompanySchema.parse(response.data); +}; -const update = async (id: Company['id'], data: CompanyEdit): Promise => ( - (await requester.put(`/companies/${id}`, data)).data -); +const create = async (data: CompanyEdit): Promise => { + const response = await requester.post('/companies', data); + return CompanySchema.parse(response.data); +}; + +const update = async (id: Company['id'], data: CompanyEdit): Promise => { + const response = await requester.put(`/companies/${id}`, data); + return CompanySchema.parse(response.data); +}; export default { all, one, create, update }; diff --git a/client/src/stores/api/countries.ts b/client/src/stores/api/countries.ts index 6ca3fa5e2..90ae8d6b9 100644 --- a/client/src/stores/api/countries.ts +++ b/client/src/stores/api/countries.ts @@ -1,21 +1,37 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -// -// - Types -// +import type { SchemaInfer } from '@/utils/validation'; -export type Country = { - id: number, - name: string, - code: string, -}; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const CountrySchema = z.strictObject({ + id: z.number(), + name: z.string(), + code: z.string(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -// -// - Fonctions -// +export type Country = SchemaInfer; -const all = async (): Promise => ( - (await requester.get('/countries')).data -); +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async (): Promise => { + const response = await requester.get('/countries'); + return CountrySchema.array().parse(response.data); +}; export default { all }; diff --git a/client/src/stores/api/documents.ts b/client/src/stores/api/documents.ts index 28670b6c2..af805de51 100644 --- a/client/src/stores/api/documents.ts +++ b/client/src/stores/api/documents.ts @@ -1,21 +1,36 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -// -// - Types -// - -export type Document = { - id: number, - name: string, - type: string, - size: number, - url: string, - created_at: string, -}; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const DocumentSchema = z.strictObject({ + id: z.number(), + name: z.string(), + type: z.string(), + size: z.number(), + url: z.string(), + created_at: z.datetime(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Document = SchemaInfer; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ const remove = async (id: Document['id']): Promise => { await requester.delete(`/documents/${id}`); diff --git a/client/src/stores/api/estimates.ts b/client/src/stores/api/estimates.ts index 6bc73ed7c..5ee459358 100644 --- a/client/src/stores/api/estimates.ts +++ b/client/src/stores/api/estimates.ts @@ -1,36 +1,37 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -import Decimal from 'decimal.js'; - -// -// - Types -// - -export type RawEstimate = { - id: number, - date: string, - url: string, - discount_rate: DecimalType, - total_without_taxes: DecimalType, - total_with_taxes: DecimalType, - currency: string, -}; -export type Estimate = RawEstimate; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const EstimateSchema = z.strictObject({ + id: z.number(), + date: z.datetime(), + url: z.string(), + discount_rate: z.decimal(), + total_without_taxes: z.decimal(), + total_with_taxes: z.decimal(), + currency: z.string(), +}); -// -// - Normalizer -// +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -export const normalize = (estimate: RawEstimate): Estimate => ({ - ...estimate, - discount_rate: new Decimal(estimate.discount_rate), - total_without_taxes: new Decimal(estimate.total_without_taxes), - total_with_taxes: new Decimal(estimate.total_with_taxes), -}); +export type Estimate = SchemaInfer; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ const remove = async (id: Estimate['id']): Promise => { await requester.delete(`/estimates/${id}`); diff --git a/client/src/stores/api/events.ts b/client/src/stores/api/events.ts index 08608ab08..3c7375ba1 100644 --- a/client/src/stores/api/events.ts +++ b/client/src/stores/api/events.ts @@ -1,148 +1,240 @@ -import requester from '@/globals/requester'; +import { z } from '@/utils/validation'; import Decimal from 'decimal.js'; -import { normalize as normalizeEstimate } from '@/stores/api/estimates'; -import { normalize as normalizeInvoice } from '@/stores/api/invoices'; - +import requester from '@/globals/requester'; +import { UserSchema } from './users'; +import { DocumentSchema } from './documents'; +import { createMaterialSchema } from './materials'; +import { TechnicianSchema } from './technicians'; +import { withCountedEnvelope } from './@schema'; +import { EstimateSchema } from './estimates'; +import { InvoiceSchema } from './invoices'; +import { + BeneficiarySchema, +} from './beneficiaries'; + +import type Period from '@/utils/period'; +import type { CountedData } from './@types'; +import type { SchemaInfer } from '@/utils/validation'; +import type { Document } from './documents'; +import type { Estimate } from './estimates'; +import type { Invoice } from './invoices'; +import type { Material } from './materials'; +import type { Technician } from './technicians'; +import type { Beneficiary } from './beneficiaries'; 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'; +import type { ZodRawShape } from 'zod'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ // -// - Types +// - Schemas secondaires // -export type EventMaterial = ( - & Material - & { - pivot: { - quantity: number, - quantity_missing: number, - quantity_departed: number | null, - quantity_returned: number | null, - quantity_returned_broken: number | null, - departure_comment: string | null, - }, - } -); +export const EventTechnicianSchema = z.strictObject({ + id: z.number(), + event_id: z.number(), + technician_id: z.number(), + period: z.period(), + position: z.string().nullable(), + technician: z.lazy(() => TechnicianSchema), +}); + +// +// -- Event material schemas / factory +// + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const createEventMaterialSchema = (augmentation: T) => { + const pivotSchema = z + .strictObject({ + quantity: z.number().positive(), + quantity_departed: z.number().nonnegative().nullable(), + quantity_returned: z.number().nonnegative().nullable(), + quantity_returned_broken: z.number().nonnegative().nullable(), + departure_comment: z.string().nullable(), + }) + .extend(augmentation); + + return z.lazy(() => createMaterialSchema({ pivot: pivotSchema })); +}; + +const EventMaterialSchema = createEventMaterialSchema({}); + +const EventMaterialWithQuantityMissingSchema = createEventMaterialSchema({ + quantity_missing: z.number().nonnegative(), +}); -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: { - days: number, - hours: number, - }, - color: string | null, - location: string | null, - total_replacement: DecimalType, - currency: string, - beneficiaries: Beneficiary[], - technicians: Technician[], - materials: EventMaterial[], - is_confirmed: boolean, - note: string | null, - author: User | null, - created_at: string, - updated_at: string, - } - & ( - | { +// +// - Schemas principaux +// + +export const EventSummarySchema = z.strictObject({ + id: z.number(), + title: z.string(), + mobilization_period: z.period(), + operation_period: z.period(), + location: z.string().nullable(), +}); + +export const EventSchema = EventSummarySchema.extend({ + reference: z.string().nullable(), + description: z.string().nullable(), + color: z.string().nullable(), + is_confirmed: z.boolean(), + is_billable: z.boolean(), + is_archived: z.boolean(), + is_departure_inventory_done: z.boolean(), + is_return_inventory_done: z.boolean(), + note: z.string().nullable(), + created_at: z.datetime(), + updated_at: z.datetime().nullable(), +}); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const createEventDetailsSchema = (augmentation: T) => ( + EventSchema + .omit({ + is_billable: true, + is_archived: true, is_departure_inventory_done: true, - departure_inventory_datetime: string | null, - departure_inventory_author: User | null, - } - | { - is_departure_inventory_done: false, - departure_inventory_datetime: null, - departure_inventory_author: null, - } - ) - & ( - | { is_return_inventory_done: true, - is_return_inventory_started: true, - return_inventory_datetime: string | null, - return_inventory_author: User | null, - } - | { - is_return_inventory_done: false, - is_return_inventory_started: boolean, - return_inventory_datetime: null, - return_inventory_author: null, - } - ) - & ( - | { - is_archived: true, - has_missing_materials: null, - has_not_returned_materials: null, - } - | { - is_archived: false, - has_missing_materials: boolean | null, - has_not_returned_materials: boolean | null, - } - ) - & ( - IsBillable extends true - ? { - is_billable: true, - estimates: Array>, - invoices: Array>, - degressive_rate: DecimalType, - discount_rate: DecimalType, - vat_rate: DecimalType, - daily_total: DecimalType, - total_without_discount: DecimalType, - total_discountable: DecimalType, - total_discount: DecimalType, - total_without_taxes: DecimalType, - total_taxes: DecimalType, - total_with_taxes: DecimalType, - } - : { - is_billable: false, - } - ) + }) + .extend({ + total_replacement: z.decimal(), + currency: z.string(), + beneficiaries: z.lazy(() => BeneficiarySchema.array()), + technicians: z.lazy(() => EventTechnicianSchema.array()), + materials: z.lazy(() => EventMaterialSchema.array()), + note: z.string().nullable(), + author: z.lazy(() => UserSchema).nullable(), + }) + .extend(augmentation) + .strip() // TODO: À enlever lorsqu'on pourra garder les objets stricts avec les intersections. + .and(z.discriminatedUnion('is_departure_inventory_done', [ + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_departure_inventory_done: z.literal(true), + departure_inventory_datetime: z.datetime().nullable(), + departure_inventory_author: z.lazy(() => UserSchema).nullable(), + }), + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_departure_inventory_done: z.literal(false), + departure_inventory_datetime: z.null(), + departure_inventory_author: z.null(), + }), + ])) + .and(z.discriminatedUnion('is_return_inventory_done', [ + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_return_inventory_done: z.literal(true), + is_return_inventory_started: z.literal(true), + return_inventory_datetime: z.datetime().nullable(), + return_inventory_author: z.lazy(() => UserSchema).nullable(), + }), + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_return_inventory_done: z.literal(false), + is_return_inventory_started: z.boolean(), + return_inventory_datetime: z.null(), + return_inventory_author: z.null(), + }), + ])) + .and(z.discriminatedUnion('is_archived', [ + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_archived: z.literal(true), + has_missing_materials: z.null(), + has_not_returned_materials: z.null(), + }), + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_archived: z.literal(false), + has_missing_materials: z.boolean().nullable(), + has_not_returned_materials: z.boolean().nullable(), + }), + ])) + .and(z.discriminatedUnion('is_billable', [ + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_billable: z.literal(true), + estimates: z.lazy(() => EstimateSchema.array()), + invoices: z.lazy(() => InvoiceSchema.array()), + degressive_rate: z.decimal(), + discount_rate: z.decimal(), + vat_rate: z.decimal(), + daily_total: z.decimal(), + total_without_discount: z.decimal(), + total_discountable: z.decimal(), + total_discount: z.decimal(), + total_without_taxes: z.decimal(), + total_taxes: z.decimal(), + total_with_taxes: z.decimal(), + }), + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_billable: z.literal(false), + }), + ])) ); -export type Event< - IsBillable extends boolean = boolean, -> = RawEvent; +export const EventDetailsSchema = createEventDetailsSchema({}); -export type EventSummary = Pick; +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -type SearchParams = { - search?: string, - exclude?: number | undefined, +// +// - Main Types +// + +type NarrowEvent = + IsBillable extends true + ? Extract + : Extract; + +export type Event = SchemaInfer; + +export type EventDetails = + NarrowEvent, IsBillable>; + +export type EventSummary = SchemaInfer; + +// +// - Secondary Types +// + +export type EventMaterial = SchemaInfer; +export type EventMaterialWithQuantityMissing = SchemaInfer; + +export type EventTechnician = SchemaInfer; + +// +// - Edition +// + +// FIXME: À compléter. +export type EventEdit = { + title: string, + operation_period: Period | null, + mobilization_period: Period | null, + location: string | null, + description: string | null, + color: string | null, + is_billable: boolean, + is_confirmed: boolean, + beneficiaries?: Array, + note?: string | null, }; +type EventDuplicatePayload = Nullable<{ + operation_period: Period, + mobilization_period: Period, +}>; + type EventReturnInventoryMaterial = { id: Material['id'], actual: number, broken: number, }; - type EventReturnInventory = EventReturnInventoryMaterial[]; type EventDepartureInventoryMaterial = { @@ -150,138 +242,153 @@ type EventDepartureInventoryMaterial = { actual: number, comment?: string | null, }; - type EventDepartureInventory = EventDepartureInventoryMaterial[]; -type EventDuplicatePayload = { - start_date: string, - end_date: string, +export type EventTechnicianEdit = { + period: Period | null, + position: string | null, }; // -// - Normalizer +// - Récupération // -export const normalize = (rawEvent: RawEvent): Event => { - if (!rawEvent.is_billable) { - return { - ...rawEvent, - total_replacement: new Decimal(rawEvent.total_replacement), - }; - } - - const { - estimates: rawEstimates, - invoices: rawInvoices, - ...event - } = rawEvent; - - const invoices = rawInvoices !== undefined - ? rawInvoices.map(normalizeInvoice) - : undefined; - - const estimates = rawEstimates !== undefined - ? rawEstimates.map(normalizeEstimate) - : undefined; - - return { - ...event, - ...(estimates ? { estimates } : undefined), - ...(invoices ? { invoices } : undefined), - vat_rate: new Decimal(event.vat_rate), - degressive_rate: new Decimal(event.degressive_rate), - discount_rate: new Decimal(event.discount_rate), - daily_total: new Decimal(event.daily_total), - total_without_discount: new Decimal(event.total_without_discount), - total_discountable: new Decimal(event.total_discountable), - total_discount: new Decimal(event.total_discount), - total_without_taxes: new Decimal(event.total_without_taxes), - total_taxes: new Decimal(event.total_taxes), - total_with_taxes: new Decimal(event.total_with_taxes), - total_replacement: new Decimal(event.total_replacement), - } as Event; +type GetAllParams = { + search?: string, + exclude?: number | undefined, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (params: SearchParams): Promise> => ( - (await requester.get('/events', { params })).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = await requester.get('/events', { params }); + return withCountedEnvelope(EventSummarySchema).parse(response.data); +}; -const one = async (id: Event['id']): Promise => ( - normalize((await requester.get(`/events/${id}`)).data) -); +const one = async (id: Event['id']): Promise => { + const response = await requester.get(`/events/${id}`); + return EventDetailsSchema.parse(response.data); +}; -const missingMaterials = async (id: Event['id']): Promise => ( - (await requester.get(`/events/${id}/missing-materials`)).data -); +const missingMaterials = async (id: Event['id']): Promise => { + const response = await requester.get(`/events/${id}/missing-materials`); + return EventMaterialWithQuantityMissingSchema.array().parse(response.data); +}; -const setConfirmed = async (id: Event['id'], isConfirmed: boolean): Promise => ( - normalize((await requester.put(`/events/${id}`, { is_confirmed: isConfirmed })).data) -); +const setConfirmed = async (id: Event['id'], isConfirmed: boolean): Promise => { + const response = await requester.put(`/events/${id}`, { is_confirmed: isConfirmed }); + return EventDetailsSchema.parse(response.data); +}; -const archive = async (id: Event['id']): Promise => ( - normalize((await requester.put(`/events/${id}/archive`)).data) -); +const archive = async (id: Event['id']): Promise => { + const response = await requester.put(`/events/${id}/archive`); + return EventDetailsSchema.parse(response.data); +}; -const unarchive = async (id: Event['id']): Promise => ( - normalize((await requester.put(`/events/${id}/unarchive`)).data) -); +const unarchive = async (id: Event['id']): Promise => { + const response = await requester.put(`/events/${id}/unarchive`); + return EventDetailsSchema.parse(response.data); +}; -const updateReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => ( - normalize((await requester.put(`/events/${id}/return`, inventory)).data) -); +const updateDepartureInventory = async (id: Event['id'], inventory: EventDepartureInventory): Promise => { + const response = await requester.put(`/events/${id}/departure`, inventory); + return EventDetailsSchema.parse(response.data); +}; -const finishReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => ( - normalize((await requester.put(`/events/${id}/return/finish`, inventory)).data) -); +const finishDepartureInventory = async (id: Event['id'], inventory: EventDepartureInventory): Promise => { + const response = await requester.put(`/events/${id}/departure/finish`, inventory); + return EventDetailsSchema.parse(response.data); +}; -const updateDepartureInventory = async (id: Event['id'], inventory: EventDepartureInventory): Promise => ( - normalize((await requester.put(`/events/${id}/departure`, inventory)).data) -); +const cancelDepartureInventory = async (id: Event['id']): Promise => { + const response = await requester.delete(`/events/${id}/departure`); + return EventDetailsSchema.parse(response.data); +}; -const finishDepartureInventory = async (id: Event['id'], inventory: EventDepartureInventory): Promise => ( - normalize((await requester.put(`/events/${id}/departure/finish`, inventory)).data) -); +const updateReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => { + const response = await requester.put(`/events/${id}/return`, inventory); + return EventDetailsSchema.parse(response.data); +}; -const createInvoice = async (id: Event['id'], discountRate: number = 0): Promise => ( - normalizeInvoice((await requester.post(`/events/${id}/invoices`, { discountRate })).data) -); +const finishReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => { + const response = await requester.put(`/events/${id}/return/finish`, inventory); + return EventDetailsSchema.parse(response.data); +}; -const createEstimate = async (id: Event['id'], discountRate: number = 0): Promise => ( - normalizeEstimate((await requester.post(`/events/${id}/estimates`, { discountRate })).data) -); +const cancelReturnInventory = async (id: Event['id']): Promise => { + const response = await requester.delete(`/events/${id}/return`); + return EventDetailsSchema.parse(response.data); +}; -const create = async (params: any): Promise => ( - normalize((await requester.post(`/events`, params)).data) -); +const createInvoice = async (id: Event['id'], discountRate: Decimal = new Decimal(0)): Promise => { + const response = await requester.post(`/events/${id}/invoices`, { discountRate }); + return InvoiceSchema.parse(response.data); +}; -const update = async (id: Event['id'], params: any): Promise => ( - normalize((await requester.put(`/events/${id}`, params)).data) -); +const createEstimate = async (id: Event['id'], discountRate: Decimal = new Decimal(0)): Promise => { + const response = await requester.post(`/events/${id}/estimates`, { discountRate }); + return EstimateSchema.parse(response.data); +}; + +const create = async (data: EventEdit): Promise => { + const response = await requester.post(`/events`, data); + return EventDetailsSchema.parse(response.data); +}; + +const update = async (id: Event['id'], data: Partial): Promise => { + const response = await requester.put(`/events/${id}`, data); + return EventDetailsSchema.parse(response.data); +}; -const duplicate = async ( +const getTechnicianAssignment = async (eventTechnicianId: EventTechnician['id']): Promise => { + const response = await requester.get(`/event-technicians/${eventTechnicianId}`); + return EventTechnicianSchema.parse(response.data); +}; + +const addTechnicianAssignment = async ( id: Event['id'], - data: EventDuplicatePayload, - force: boolean = false, -): Promise => { - const params = { force: force || undefined }; - return normalize((await requester.post(`/events/${id}/duplicate`, data, { params })).data); + technicianId: Technician['id'], + data: EventTechnicianEdit, +): Promise => { + const payload = { ...data, event_id: id, technician_id: technicianId }; + const response = await requester.post(`/event-technicians`, payload); + return EventTechnicianSchema.parse(response.data); +}; + +const updateTechnicianAssignment = async ( + eventTechnicianId: EventTechnician['id'], + data: Partial, +): Promise => { + const response = await requester.put(`/event-technicians/${eventTechnicianId}`, data); + return EventTechnicianSchema.parse(response.data); +}; + +const deleteTechnicianAssignment = async (eventTechnicianId: EventTechnician['id']): Promise => { + await requester.delete(`/event-technicians/${eventTechnicianId}`); +}; + +const duplicate = async (id: Event['id'], data: EventDuplicatePayload): Promise => { + const response = await requester.post(`/events/${id}/duplicate`, data); + return EventDetailsSchema.parse(response.data); }; 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 documents = async (id: Event['id']): Promise => { + const response = await requester.get(`/events/${id}/documents`); + return DocumentSchema.array().parse(response.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; + const response = await requester.post(`/events/${id}/documents`, formData, options); + return DocumentSchema.parse(response.data); }; export default { @@ -291,15 +398,21 @@ export default { setConfirmed, archive, unarchive, - updateReturnInventory, - finishReturnInventory, updateDepartureInventory, finishDepartureInventory, + cancelDepartureInventory, + updateReturnInventory, + finishReturnInventory, + cancelReturnInventory, createInvoice, createEstimate, - create, duplicate, + create, update, + getTechnicianAssignment, + addTechnicianAssignment, + updateTechnicianAssignment, + deleteTechnicianAssignment, remove, documents, attachDocument, diff --git a/client/src/stores/api/groups.ts b/client/src/stores/api/groups.ts index cf24523b1..8b1e36b3c 100644 --- a/client/src/stores/api/groups.ts +++ b/client/src/stores/api/groups.ts @@ -1,8 +1,10 @@ import Vue from 'vue'; -// -// - Enums -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ export enum Group { /** Représente le groupe des administrateurs. */ @@ -15,18 +17,22 @@ export enum Group { VISITOR = 'visitor', } -// -// - Types -// +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ export type GroupDetails = { id: Group, name: string, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ const all = (): GroupDetails[] => { const { translate: __ } = (Vue as any).i18n; @@ -38,9 +44,8 @@ const all = (): GroupDetails[] => { ]; }; -const one = (group: Group): GroupDetails | undefined => { - const allGroups = all(); - return allGroups.find(({ id }: GroupDetails) => id === group); -}; +const one = (group: Group): GroupDetails | undefined => ( + all().find(({ id }: GroupDetails) => id === group) +); export default { all, one }; diff --git a/client/src/stores/api/invoices.ts b/client/src/stores/api/invoices.ts index 07e32fed0..9731374c4 100644 --- a/client/src/stores/api/invoices.ts +++ b/client/src/stores/api/invoices.ts @@ -1,29 +1,28 @@ -import Decimal from 'decimal.js'; +import { z } from '@/utils/validation'; -// -// - Types -// +import type { SchemaInfer } from '@/utils/validation'; -export type RawInvoice = { - id: number, - number: string, - date: string, - url: string, - discount_rate: DecimalType, - total_without_taxes: DecimalType, - total_with_taxes: DecimalType, - currency: string, -}; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ -export type Invoice = RawInvoice; +export const InvoiceSchema = z.strictObject({ + id: z.number(), + number: z.string(), + date: z.datetime(), + url: z.string(), + discount_rate: z.decimal(), + total_without_taxes: z.decimal(), + total_with_taxes: z.decimal(), + currency: z.string(), +}); -// -// - Normalizer -// +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -export const normalize = (invoice: RawInvoice): Invoice => ({ - ...invoice, - discount_rate: new Decimal(invoice.discount_rate), - total_without_taxes: new Decimal(invoice.total_without_taxes), - total_with_taxes: new Decimal(invoice.total_with_taxes), -}); +export type Invoice = SchemaInfer; diff --git a/client/src/stores/api/materials.ts b/client/src/stores/api/materials.ts index f7598ea95..48df82820 100644 --- a/client/src/stores/api/materials.ts +++ b/client/src/stores/api/materials.ts @@ -1,67 +1,140 @@ import requester from '@/globals/requester'; +import { z } from '@/utils/validation'; +import { TagSchema } from './tags'; +import { DocumentSchema } from './documents'; +import { AttributeWithValueSchema } from './attributes'; +import { createBookingSummarySchema } from './bookings'; +import { withPaginationEnvelope } from './@schema'; +import type Period from '@/utils/period'; import type { ProgressCallback, AxiosRequestConfig as RequestConfig } from 'axios'; -import type { PaginatedData, SortableParams, PaginationParams } from '@/stores/api/@types'; -import type { Event } from '@/stores/api/events'; -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'; -import type { Tag } from '@/stores/api/tags'; -import type { Document } from '@/stores/api/documents'; +import type { PaginatedData, SortableParams, PaginationParams } from './@types'; +import type { Park } from './parks'; +import type { Category } from './categories'; +import type { Event } from './events'; +import type { SubCategory } from './subcategories'; +import type { Attribute } from './attributes'; +import type { Tag } from './tags'; +import type { Document } from './documents'; +import type { SchemaInfer } from '@/utils/validation'; +import type { ZodRawShape } from 'zod'; +import type { Simplify } from 'type-fest'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ /** Représente le matériel non catégorisés. */ export const UNCATEGORIZED = 'uncategorized'; // -// - Types +// - Schemas secondaires // -export type MaterialAttribute = { - id: number, - name: string, - type: 'boolean' | 'string' | 'number' | 'date', - unit: string | null, - value: boolean | string | number | null, -}; +const MaterialBaseSchema = z.strictObject({ + id: z.number(), + name: z.string(), + reference: z.string(), + picture: z.string().nullable(), + description: z.string().nullable(), + category_id: z.number().nullable(), + sub_category_id: z.number().nullable(), + rental_price: z.decimal().nullable().optional(), + replacement_price: z.decimal().nullable(), + stock_quantity: z.number().nullable().transform( + (value: number | null): number => value ?? 0, + ), + out_of_order_quantity: z.number().nullable().transform( + (value: number | null): number => value ?? 0, + ), + park_id: z.number(), + is_hidden_on_bill: z.boolean().optional(), + is_discountable: z.boolean().optional(), + is_reservable: z.boolean(), + attributes: z.lazy(() => AttributeWithValueSchema.array()), + tags: z.lazy(() => TagSchema.array()), + note: z.string().nullable(), + created_at: z.datetime(), + updated_at: z.datetime().nullable(), +}); -export type Material = ( - { - id: number, - name: string, - description: string | null, - reference: string, - category_id: Category['id'] | null, - sub_category_id: Subcategory['id'] | null, - rental_price: number, - stock_quantity: number, - out_of_order_quantity: number | null, - replacement_price: number, - is_hidden_on_bill: boolean, - is_discountable: boolean, - is_reservable: boolean, - tags: [], - attributes: MaterialAttribute[], - created_at: string, - updated_at: string, - park_id: Park['id'], - } +const createMaterialSchemaFactory = (augmentation: T) => ( + (innerAugmentation: InnerT) => ( + MaterialBaseSchema + .extend(augmentation) + .extend(innerAugmentation) + ) ); -export type MaterialDetails = Material; +export const createMaterialSchema = createMaterialSchemaFactory({}); -export type MaterialWithAvailabilities = Material & { - available_quantity?: number, -}; +export const createMaterialDetailsSchema = createMaterialSchemaFactory({}); -export type MaterialBookingSummary = BookingSummary & { - pivot: { - quantity: number, - }, -}; +export const createMaterialWithAvailabilitySchema = createMaterialSchemaFactory( + { available_quantity: z.number() }, +); + +const MaterialBookingSummarySchema = z.lazy(() => ( + createBookingSummarySchema({ + pivot: z.strictObject({ + quantity: z.number().positive(), + }), + }) +)); + +// +// - Schemas principaux +// + +export const MaterialSchema = createMaterialSchema({}); + +export const MaterialDetailsSchema = createMaterialDetailsSchema({}); + +export const MaterialWithAvailabilitySchema = createMaterialWithAvailabilitySchema({}); + +export const MaterialPublicSchema = (() => { + const baseSchema = MaterialBaseSchema.extend({ + available_quantity: z.number(), + }); + + return baseSchema.pick({ + id: true, + name: true, + description: true, + picture: true, + available_quantity: true, + rental_price: true, + }); +})(); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Material = SchemaInfer; + +export type MaterialDetails = SchemaInfer; + +export type MaterialWithAvailability = SchemaInfer; + +export type MaterialPublic = SchemaInfer; + +// +// - Secondary types. +// + +export type MaterialBookingSummary = SchemaInfer; + +// +// - Edition +// type MaterialEditAttribute = { - id: MaterialAttribute['id'], + id: Attribute['id'], value: string, }; @@ -70,9 +143,10 @@ export type MaterialEdit = { picture?: File | null, reference: string, description: string, + is_unitary: boolean, park_id: Park['id'], category_id: Category['id'], - sub_category_id: Subcategory['id'] | null, + sub_category_id: SubCategory['id'] | null, rental_price: string, stock_quantity: string, out_of_order_quantity: string, @@ -84,88 +158,104 @@ export type MaterialEdit = { attributes?: MaterialEditAttribute[], }; -type BaseFilters = { +// +// - Récupération +// + +export type BaseFilters = Nullable<{ search?: string, category?: Category['id'], - subCategory?: Subcategory['id'], -}; + subCategory?: SubCategory['id'], +}>; -export type Filters = Omit & { - quantitiesPeriod?: { start: string, end: string }, - category?: Category['id'] | typeof UNCATEGORIZED, - park?: Park['id'], - tags?: Array, -}; +export type Filters = Simplify<( + & Omit + & Nullable<{ + quantitiesPeriod?: Period, + category?: Category['id'] | typeof UNCATEGORIZED, + park?: Park['id'], + tags?: Array, + }> +)>; -type GetAllBase = Filters & SortableParams & { deleted?: boolean }; -type GetAllPaginated = GetAllBase & PaginationParams & { paginated?: true }; -type GetAllRaw = GetAllBase & { paginated: false }; +type GetAllParamsBase = Filters & SortableParams & { deleted?: boolean }; +type GetAllParamsPaginated = GetAllParamsBase & PaginationParams & { paginated?: true }; +type GetAllParamsRaw = GetAllParamsBase & { paginated: false }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -async function all(params: GetAllRaw): Promise; -async function all(params: GetAllPaginated): Promise>; -async function all({ quantitiesPeriod, ...otherParams }: GetAllPaginated | GetAllRaw): Promise { - const params: Record = otherParams; - if (quantitiesPeriod !== undefined) { - const isValidPeriod = ( - typeof quantitiesPeriod === 'object' && - 'start' in quantitiesPeriod && - 'end' in quantitiesPeriod - ); - if (!isValidPeriod) { - throw new Error('Invalid quantities period.'); - } - params['quantitiesPeriod[start]'] = quantitiesPeriod.start; - params['quantitiesPeriod[end]'] = quantitiesPeriod.end; - } - return (await requester.get('/materials', { params })).data; +async function all(params: GetAllParamsRaw): Promise; +async function all(params?: GetAllParamsPaginated): Promise>; +async function all({ quantitiesPeriod, ...otherParams }: GetAllParamsPaginated | GetAllParamsRaw = {}): Promise { + const normalizedParams = { + paginated: true, + ...otherParams, + ...quantitiesPeriod?.toQueryParams('quantitiesPeriod'), + }; + + const response = await requester.get('/materials', { + params: normalizedParams, + }); + + const schema = normalizedParams.paginated + ? withPaginationEnvelope(MaterialWithAvailabilitySchema) + : MaterialWithAvailabilitySchema.array(); + + return schema.parse(response.data); } -/* eslint-enable func-style */ -const allWhileEvent = async (eventId: Event['id']): Promise => ( - (await requester.get(`/materials/while-event/${eventId}`)).data -); +const allWhileEvent = async (eventId: Event['id']): Promise => { + const response = await requester.get(`/materials/while-event/${eventId}`); + return MaterialWithAvailabilitySchema.array().parse(response.data); +}; -const one = async (id: Material['id']): Promise => ( - (await requester.get(`/materials/${id}`)).data -); +const one = async (id: Material['id']): Promise => { + const response = await requester.get(`/materials/${id}`); + return MaterialDetailsSchema.parse(response.data); +}; const create = async (data: MaterialEdit, onProgress?: ProgressCallback): Promise => { - const options = { ...(onProgress ? { onProgress } : {}) }; - return (await requester.post('/materials', data, options)).data; + const response = await requester.post('/materials', data, { + ...(onProgress ? { onProgress } : {}), + }); + return MaterialDetailsSchema.parse(response.data); }; const update = async (id: Material['id'], data: Partial, onProgress?: ProgressCallback): Promise => { - const options = { ...(onProgress ? { onProgress } : {}) }; - return (await requester.put(`/materials/${id}`, data, options)).data; + const response = await requester.put(`/materials/${id}`, data, { + ...(onProgress ? { onProgress } : {}), + }); + return MaterialDetailsSchema.parse(response.data); }; -const restore = async (id: Material['id']): Promise => ( - (await requester.put(`/materials/${id}/restore`)).data -); +const restore = async (id: Material['id']): Promise => { + const response = await requester.put(`/materials/${id}/restore`); + return MaterialDetailsSchema.parse(response.data); +}; const remove = async (id: Material['id']): Promise => { await requester.delete(`/materials/${id}`); }; -const bookings = async ( - id: Material['id'], - params?: PaginationParams, -): Promise> => { +const bookings = async (id: Material['id'], params?: PaginationParams): Promise> => { const config = { ...(params ? { params } : {}) }; - return (await requester.get(`/materials/${id}/bookings`, config)).data; + const response = await requester.get(`/materials/${id}/bookings`, config); + return withPaginationEnvelope(MaterialBookingSummarySchema).parse(response.data); }; -const documents = async (id: Material['id']): Promise => ( - (await requester.get(`/materials/${id}/documents`)).data -); +const documents = async (id: Material['id']): Promise => { + const response = await requester.get(`/materials/${id}/documents`); + return DocumentSchema.array().parse(response.data); +}; 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; + const response = await requester.post(`/materials/${id}/documents`, formData, options); + return DocumentSchema.parse(response.data); }; export default { diff --git a/client/src/stores/api/parks.ts b/client/src/stores/api/parks.ts index 0c71752d2..92db4880d 100644 --- a/client/src/stores/api/parks.ts +++ b/client/src/stores/api/parks.ts @@ -1,24 +1,56 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; - -import type { PaginatedData, ListingParams } from '@/stores/api/@types'; +import { withPaginationEnvelope } from './@schema'; +import { MaterialSchema } from './materials'; + +import type { Material } from './materials'; +import type { SchemaInfer } from '@/utils/validation'; +import type { PaginatedData, ListingParams } from './@types'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const ParkSchema = z.strictObject({ + id: z.number(), + name: z.string(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + opening_hours: z.string().nullable(), + total_items: z.number().nonnegative(), + total_stock_quantity: z.number().nonnegative(), + note: z.string().nullable(), +}); + +export const ParkSummarySchema = ParkSchema.pick({ + id: true, + name: true, +}); + +export const ParkDetailsSchema = ParkSchema.extend({ + has_ongoing_booking: z.boolean(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Park = SchemaInfer; + +export type ParkSummary = SchemaInfer; + +export type ParkDetails = SchemaInfer; // -// - Types +// - Edition // -export type Park = { - id: number, - name: string, - opening_hours: string | null, - total_items: number, - total_stock_quantity: number, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: number | null, - note: string | null, -}; - export type ParkEdit = { name: string, street: string | null, @@ -29,55 +61,57 @@ export type ParkEdit = { note: string | null, }; -export type ParkDetails = Park & { - has_ongoing_inventory: boolean, - has_ongoing_booking: boolean, -}; - -export type ParkSummary = { - id: Park['id'], - name: Park['name'], -}; - -export type ParkTotalAmountResult = { - id: Park['id'], - totalAmount: number, -}; +// +// - Récupération +// type GetAllParams = ListingParams & { deleted?: boolean }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (params: GetAllParams): Promise> => ( - (await requester.get('/parks', { params })).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = (await requester.get('/parks', { params })); + return withPaginationEnvelope(ParkSchema).parse(response.data); +}; -const list = async (): Promise => ( - (await requester.get('/parks/list')).data -); +const list = async (): Promise => { + const response = await requester.get('/parks/list'); + return ParkSummarySchema.array().parse(response.data); +}; -const one = async (id: Park['id']): Promise => ( - (await requester.get(`/parks/${id}`)).data -); +const one = async (id: Park['id']): Promise => { + const response = await requester.get(`/parks/${id}`); + return ParkDetailsSchema.parse(response.data); +}; + +const oneTotalAmount = async (id: Park['id']): Promise => { + const response = await requester.get(`/parks/${id}/total-amount`); + return z.number().nonnegative().parse(response.data); +}; -const totalAmount = async (id: Park['id']): Promise => { - const { data } = (await requester.get(`/parks/${id}/total-amount`)); - return data.totalAmount; +const materials = async (id: Park['id']): Promise => { + const response = await requester.get(`/parks/${id}/materials`); + return MaterialSchema.array().parse(response.data); }; -const create = async (data: ParkEdit): Promise => ( - (await requester.post('/parks', data)).data -); +const create = async (data: ParkEdit): Promise => { + const response = await requester.post('/parks', data); + return ParkDetailsSchema.parse(response.data); +}; -const update = async (id: Park['id'], data: ParkEdit): Promise => ( - (await requester.put(`/parks/${id}`, data)).data -); +const update = async (id: Park['id'], data: ParkEdit): Promise => { + const response = await requester.put(`/parks/${id}`, data); + return ParkDetailsSchema.parse(response.data); +}; -const restore = async (id: Park['id']): Promise => ( - (await requester.put(`/parks/restore/${id}`)).data -); +const restore = async (id: Park['id']): Promise => { + const response = await requester.put(`/parks/restore/${id}`); + return ParkDetailsSchema.parse(response.data); +}; const remove = async (id: Park['id']): Promise => { await requester.delete(`/parks/${id}`); @@ -87,9 +121,10 @@ export default { all, list, one, + oneTotalAmount, + materials, create, update, - totalAmount, restore, remove, }; diff --git a/client/src/stores/api/persons.ts b/client/src/stores/api/persons.ts index d83a7fb49..d0b10e47f 100644 --- a/client/src/stores/api/persons.ts +++ b/client/src/stores/api/persons.ts @@ -1,34 +1,51 @@ +import { z } from '@/utils/validation'; +import { withPaginationEnvelope } from './@schema'; +import { CountrySchema } from './countries'; import requester from '@/globals/requester'; -import type { Country } from '@/stores/api/countries'; +import type { SchemaInfer } from '@/utils/validation'; import type { PaginatedData, ListingParams } from './@types'; -// -// - Types -// - -export type Person = { - id: number, - first_name: string, - last_name: string, - full_name: string, - email: string | null, - phone: string | null, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: number | null, - country: Country | null, - full_address: string | null, - user_id: number | null, -}; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const PersonSchema = z.strictObject({ + id: z.number(), + user_id: z.number().nullable(), + first_name: z.string(), + last_name: z.string(), + full_name: z.string(), + // TODO [zod@>3.22.4]: Remettre `email()`. + email: z.string().nullable(), + phone: z.string().nullable(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + country: z.lazy(() => CountrySchema).nullable(), + full_address: z.string().nullable(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -// -// - Fonctions -// +export type Person = SchemaInfer; -const all = async (params: ListingParams): Promise> => ( - (await requester.get('/persons', { params })).data -); +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async (params: ListingParams = {}): Promise> => { + const response = await requester.get('/persons', { params }); + return withPaginationEnvelope(PersonSchema).parse(response.data); +}; export default { all }; diff --git a/client/src/stores/api/session.ts b/client/src/stores/api/session.ts index 6c37b7244..617ee2701 100644 --- a/client/src/stores/api/session.ts +++ b/client/src/stores/api/session.ts @@ -1,10 +1,14 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { UserDetailsSchema, UserSettingsSchema } from './users'; -import type { UserDetails } from '@/stores/api/users'; +import type { SchemaInfer } from '@/utils/validation'; -// -// - Constants -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ /** Contextes de l'application (Où se trouve l'utilisateur ?). */ export enum AppContext { @@ -15,17 +19,25 @@ export enum AppContext { INTERNAL = 'internal', } -// -// - Types -// +const SessionSchema = UserDetailsSchema.merge(UserSettingsSchema); -export type Session = UserDetails & { - language: string, -}; +const NewSessionSchema = SessionSchema.extend({ + token: z.string(), +}); -type NewSession = Session & { - token: string, -}; +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Session = SchemaInfer; + +type NewSession = SchemaInfer; + +// +// - Edition +// export type Credentials = { identifier: string, @@ -33,16 +45,20 @@ export type Credentials = { context?: AppContext, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const get = async (): Promise => ( - (await requester.get('/session')).data -); +const get = async (): Promise => { + const response = await requester.get('/session'); + return SessionSchema.parse(response.data); +}; -const create = async (credentials: Credentials): Promise => ( - (await requester.post('/session', credentials)).data -); +const create = async (credentials: Credentials): Promise => { + const response = await requester.post('/session', credentials); + return NewSessionSchema.parse(response.data); +}; export default { get, create }; diff --git a/client/src/stores/api/settings.ts b/client/src/stores/api/settings.ts index cf023d93b..9039e1b15 100644 --- a/client/src/stores/api/settings.ts +++ b/client/src/stores/api/settings.ts @@ -1,10 +1,14 @@ import requester from '@/globals/requester'; +import { z } from '@/utils/validation'; -import type { Merge } from 'type-fest'; +import type { OmitDeep, PartialDeep } from 'type-fest'; +import type { SchemaInfer } from '@/utils/validation'; -// -// - Types -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ export enum MaterialDisplayMode { CATEGORIES = 'categories', @@ -18,51 +22,95 @@ export enum ReturnInventoryMode { START_FULL = 'start-full', } -export type Settings = { - eventSummary: { - customText: { - title: string | null, - content: string | null, - }, - materialDisplayMode: MaterialDisplayMode, - showLegalNumbers: boolean, - }, - calendar: { - event: { - showLocation: boolean, - showBorrower: boolean, - }, - public: ( - | { enabled: true, url: string } - | { enabled: false } - ), - }, - returnInventory: { - mode: ReturnInventoryMode, - }, -}; +export enum PublicCalendarPeriodDisplay { + /** Les périodes d'opération uniquement sont affichées. */ + OPERATION = 'operation', + + /** Les périodes de mobilisation uniquement sont affichées. */ + MOBILIZATION = 'mobilization', + + /** Les périodes de mobilisation et d'opération sont affichées. */ + BOTH = 'both', +} + +const OpeningDaySchema = z.strictObject({ + weekday: z.number().int().min(0).max(6), + start_time: z.string().regex(/^(?:0[0-9]|1[0-9]|2[0-4]):[0-5][0-9]:[0-5][0-9]$/), + end_time: z.string().regex(/^(?:0[0-9]|1[0-9]|2[0-4]):[0-5][0-9]:[0-5][0-9]$/), +}); + +const SettingsSchema = z.strictObject({ + general: z.strictObject({ + openingHours: OpeningDaySchema.array(), + }), + eventSummary: z.strictObject({ + customText: z.strictObject({ + title: z.string().nullable(), + content: z.string().nullable(), + }), + materialDisplayMode: z.nativeEnum(MaterialDisplayMode), + showLegalNumbers: z.boolean(), + showReplacementPrices: z.boolean(), + showDescriptions: z.boolean(), + showTags: z.boolean(), + showPictures: z.boolean(), + }), + calendar: z.strictObject({ + event: z.strictObject({ + showLocation: z.boolean(), + showBorrower: z.boolean(), + }), + public: z.discriminatedUnion('enabled', [ + z.strictObject({ + enabled: z.literal(true), + url: z.string().nullable().optional(), + displayedPeriod: z.nativeEnum(PublicCalendarPeriodDisplay), + }), + z.strictObject({ + enabled: z.literal(false), + }), + ]), + }), + returnInventory: z.strictObject({ + mode: z.nativeEnum(ReturnInventoryMode), + }), +}); -type SettingsEdit = Partial - & { public: { enabled: boolean } } - ), -}>>; +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type OpeningDay = SchemaInfer; + +export type Settings = SchemaInfer; // -// - Fonctions +// - Edition // -const all = async (): Promise => ( - (await requester.get('/settings')).data -); +export type SettingsEdit = PartialDeep>; -const update = async (data: SettingsEdit): Promise => ( - (await requester.put('/settings', data)).data -); +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async (): Promise => { + const response = await requester.get('/settings'); + return SettingsSchema.parse(response.data); +}; -const reset = async (key: string): Promise => ( - (await requester.delete(`/settings/${key}`)).data -); +const update = async (data: SettingsEdit): Promise => { + const response = await requester.put('/settings', data); + return SettingsSchema.parse(response.data); +}; + +const reset = async (key: string): Promise => { + const response = await requester.delete(`/settings/${key}`); + return SettingsSchema.parse(response.data); +}; export default { all, update, reset }; diff --git a/client/src/stores/api/subcategories.ts b/client/src/stores/api/subcategories.ts index 6b7bf9709..5e8c77156 100644 --- a/client/src/stores/api/subcategories.ts +++ b/client/src/stores/api/subcategories.ts @@ -1,38 +1,57 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -import type { Category } from '@/stores/api/categories'; +import type { Category } from './categories'; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const SubCategorySchema = z.object({ + id: z.number(), + name: z.string(), + category_id: z.number(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type SubCategory = SchemaInfer; // -// - Types +// - Edition // -export type Subcategory = { - id: number, +export type SubCategoryCreate = { name: string, category_id: Category['id'], }; -export type SubcategoryEdit = { - name: string, -}; - -export type SubcategoryCreate = SubcategoryEdit & { - categoryId: Category['id'], -}; +export type SubCategoryEdit = Omit; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const create = async ({ name, categoryId }: SubcategoryCreate): Promise => ( - (await requester.post('/subcategories', { name, category_id: categoryId })).data -); +const create = async (data: SubCategoryCreate): Promise => { + const response = await requester.post('/subcategories', data); + return SubCategorySchema.parse(response.data); +}; -const update = async (id: Subcategory['id'], data: SubcategoryEdit): Promise => ( - (await requester.put(`/subcategories/${id}`, data)).data -); +const update = async (id: SubCategory['id'], data: SubCategoryEdit): Promise => { + const response = await requester.put(`/subcategories/${id}`, data); + return SubCategorySchema.parse(response.data); +}; -const remove = async (id: Subcategory['id']): Promise => { +const remove = async (id: SubCategory['id']): Promise => { await requester.delete(`/subcategories/${id}`); }; diff --git a/client/src/stores/api/tags.ts b/client/src/stores/api/tags.ts index 76608757b..fa15d9748 100644 --- a/client/src/stores/api/tags.ts +++ b/client/src/stores/api/tags.ts @@ -1,39 +1,66 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const TagSchema = z.strictObject({ + id: z.number(), + name: z.string(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Tag = SchemaInfer; + // -// - Types +// - Edition // -export type Tag = { - id: number, - name: string, -}; - export type TagEdit = { name: string, }; -type GetAllParams = { deleted?: boolean }; - // -// - Fonctions +// - Récupération // -const all = async (params: GetAllParams): Promise => ( - (await requester.get('/tags', { params })).data -); +type GetAllParams = { deleted?: boolean }; + +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async (params: GetAllParams = {}): Promise => { + const response = await requester.get('/tags', { params }); + return TagSchema.array().parse(response.data); +}; -const create = async (data: TagEdit): Promise => ( - (await requester.post('/tags', data)).data -); +const create = async (data: TagEdit): Promise => { + const response = await requester.post('/tags', data); + return TagSchema.parse(response.data); +}; -const update = async (id: Tag['id'], data: TagEdit): Promise => ( - (await requester.put(`/tags/${id}`, data)).data -); +const update = async (id: Tag['id'], data: TagEdit): Promise => { + const response = await requester.put(`/tags/${id}`, data); + return TagSchema.parse(response.data); +}; -const restore = async (id: Tag['id']): Promise => ( - (await requester.put(`/tags/restore/${id}`)).data -); +const restore = async (id: Tag['id']): Promise => { + const response = await requester.put(`/tags/restore/${id}`); + return TagSchema.parse(response.data); +}; const remove = async (id: Tag['id']): Promise => { await requester.delete(`/tags/${id}`); diff --git a/client/src/stores/api/technicians.ts b/client/src/stores/api/technicians.ts index 3bb4d832b..094b1c908 100644 --- a/client/src/stores/api/technicians.ts +++ b/client/src/stores/api/technicians.ts @@ -1,32 +1,77 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { CountrySchema } from './countries'; +import { DocumentSchema } from './documents'; +import { EventSchema } from './events'; +import { withPaginationEnvelope } from './@schema'; -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 Period from '@/utils/period'; import type { Event } from './events'; +import type { SchemaInfer } from '@/utils/validation'; +import type { AxiosRequestConfig as RequestConfig } from 'axios'; +import type { PaginatedData, SortableParams, PaginationParams } from './@types'; +import type { Document } from './documents'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ // -// - Types +// - Schemas secondaires // -export type Technician = { - id: number, - first_name: string, - full_name: string, - last_name: string, - nickname: string | null, - email: string | null, - phone: string | null, - full_address: string | null, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: number | null, - country: Country | null, - note: string | null, - user_id: number | null, // => user.id, etc. -}; +export const TechnicianEventSchema = z.strictObject({ + id: z.number(), + event_id: z.number(), + technician_id: z.number(), + period: z.period(), // FIXME + position: z.string().nullable(), + event: z.lazy(() => EventSchema), +}); + +// +// - Schemas principaux +// + +export const TechnicianSchema = z.strictObject({ + id: z.number(), + user_id: z.number().nullable(), + first_name: z.string(), + last_name: z.string(), + full_name: z.string(), + nickname: z.string().nullable(), + // TODO [zod@>3.22.4]: Remettre `email()`. + email: z.string().nullable(), + phone: z.string().nullable(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + country: z.lazy(() => CountrySchema).nullable(), + full_address: z.string().nullable(), + note: z.string().nullable(), +}); + +export const TechnicianWithEventsSchema = TechnicianSchema.extend({ + events: TechnicianEventSchema.array(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Technician = SchemaInfer; + +export type TechnicianEvent = SchemaInfer; +export type TechnicianWithEvents = SchemaInfer; + +// +// - Edition +// export type TechnicianEdit = { first_name: string, @@ -41,63 +86,79 @@ export type TechnicianEdit = { note: string | null, }; -type GetAllParams = ListingParams & { - availabilityPeriod?: { start: string, end: string }, - deleted?: boolean, -}; - // -// - Fonctions +// - Récupération // -const all = async ({ availabilityPeriod, ...otherParams }: GetAllParams): Promise> => { - const params: Record = otherParams; - if (availabilityPeriod !== undefined) { - const isValidPeriod = ( - typeof availabilityPeriod === 'object' && - 'start' in availabilityPeriod && - 'end' in availabilityPeriod - ); - if (!isValidPeriod) { - throw new Error('Invalid quantities period.'); - } - params['availabilityPeriod[start]'] = availabilityPeriod.start; - params['availabilityPeriod[end]'] = availabilityPeriod.end; - } - return (await requester.get('/technicians', { params })).data; +export type Filters = { + search?: string, + availabilityPeriod?: Period, }; -const allWhileEvent = async (eventId: Event['id']): Promise => ( - (await requester.get(`/technicians/while-event/${eventId}`)).data +type GetAllParams = ( + & Filters + & SortableParams + & PaginationParams + & { deleted?: boolean } ); -const one = async (id: Technician['id']): Promise => ( - (await requester.get(`/technicians/${id}`)).data -); +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async ({ availabilityPeriod, ...otherParams }: GetAllParams = {}): Promise> => { + const params: Record = Object.assign(otherParams, { + ...availabilityPeriod?.toQueryParams('availabilityPeriod'), + }); + const response = await requester.get('/technicians', { params }); + return withPaginationEnvelope(TechnicianSchema).parse(response.data); +}; -const create = async (data: TechnicianEdit): Promise => ( - (await requester.post('/technicians', data)).data -); +const allWhileEvent = async (eventId: Event['id']): Promise => { + const response = await requester.get(`/technicians/while-event/${eventId}`); + return TechnicianWithEventsSchema.array().parse(response.data); +}; -const update = async (id: Technician['id'], data: TechnicianEdit): Promise => ( - (await requester.put(`/technicians/${id}`, data)).data -); +const one = async (id: Technician['id']): Promise => { + const response = await requester.get(`/technicians/${id}`); + return TechnicianSchema.parse(response.data); +}; -const restore = async (id: Technician['id']): Promise => ( - (await requester.put(`/technicians/restore/${id}`)).data -); +const create = async (data: TechnicianEdit): Promise => { + const response = await requester.post('/technicians', data); + return TechnicianSchema.parse(response.data); +}; + +const update = async (id: Technician['id'], data: TechnicianEdit): Promise => { + const response = await requester.put(`/technicians/${id}`, data); + return TechnicianSchema.parse(response.data); +}; + +const restore = async (id: Technician['id']): Promise => { + const response = await requester.put(`/technicians/restore/${id}`); + return TechnicianSchema.parse(response.data); +}; 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 assignments = async (id: Technician['id']): Promise => { + const response = await requester.get(`/technicians/${id}/events`); + return TechnicianEventSchema.array().parse(response.data); +}; + +const documents = async (id: Technician['id']): Promise => { + const response = await requester.get(`/technicians/${id}/documents`); + return DocumentSchema.array().parse(response.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; + const response = await requester.post(`/technicians/${id}/documents`, formData, options); + return DocumentSchema.parse(response.data); }; export default { @@ -108,6 +169,7 @@ export default { update, remove, restore, + assignments, documents, attachDocument, }; diff --git a/client/src/stores/api/users.ts b/client/src/stores/api/users.ts index 656313aac..b5c72623a 100644 --- a/client/src/stores/api/users.ts +++ b/client/src/stores/api/users.ts @@ -1,30 +1,88 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { Group } from './groups'; +import { withPaginationEnvelope } from './@schema'; + +import type { PaginatedData, ListingParams } from './@types'; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +/** + * Modes d'affichage des événements. + * + * NOTE IMPORTANTE: + * En cas de modif., pensez à aussi mettre à jour les constantes du modèle back-end. + * {@see {@link /server/src/App/Models/User.php}} + */ +export enum BookingsViewMode { + /** Vue en calendrier (timeline) */ + CALENDAR = 'calendar', + + /** Vue en liste. */ + LISTING = 'listing', +} -import type { PaginatedData, ListingParams } from '@/stores/api/@types'; -import type { Group } from '@/stores/api/groups'; +export const UserSchema = z.strictObject({ + id: z.number(), + pseudo: z.string(), + first_name: z.string().nullable().transform( + // NOTE: Le `?` ci-dessous n'est pas idéal et doit être évité dans la mesure + // du possible, mais étant donné qu'il est possible que la `person` lié + // ait été supprimée, on préfère utiliser `?` en fallback plutôt que de + // planter le retour. Ceci n'est pas censé arriver mais le cas doit être + // géré, au cas où. + (value: string | null) => value ?? '?', + ), + last_name: z.string().nullable().transform( + // NOTE: Le `?` ci-dessous n'est pas idéal et doit être évité dans la mesure + // du possible, mais étant donné qu'il est possible que la `person` lié + // ait été supprimée, on préfère utiliser `?` en fallback plutôt que de + // planter le retour. Ceci n'est pas censé arriver mais le cas doit être + // géré, au cas où. + (value: string | null) => value ?? '?', + ), + full_name: z.string().nullable().transform( + // NOTE: Le `?` ci-dessous n'est pas idéal et doit être évité dans la mesure + // du possible, mais étant donné qu'il est possible que la `person` lié + // ait été supprimée, on préfère utiliser `?` en fallback plutôt que de + // planter le retour. Ceci n'est pas censé arriver mais le cas doit être + // géré, au cas où. + (value: string | null) => value ?? '?', + ), + phone: z.string().nullable(), + // TODO [zod@>3.22.4]: Remettre `email()`. + email: z.string(), + group: z.nativeEnum(Group), +}); + +export const UserDetailsSchema = UserSchema; + +export const UserSettingsSchema = z.strictObject({ + language: z.string(), + default_bookings_view: z.nativeEnum(BookingsViewMode), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type UserSettings = SchemaInfer; + +export type User = SchemaInfer; + +export type UserDetails = SchemaInfer; // -// - Types +// - Edition // -type UserSettings = { - language: string, - notifications_enabled: boolean, -}; - -export type User = UserSettings & { - id: number, - group: Group, - pseudo: string, - email: string, - first_name: string, - last_name: string, - full_name: string, - phone: string | null, -}; - -export type UserDetails = User; - export type UserEdit = { first_name: string | null, last_name: string | null, @@ -35,49 +93,63 @@ export type UserEdit = { group: Group, }; -type UserSettingsEdit = Partial; +export type UserEditSelf = Omit; -type UserEditSelf = Omit; +export type UserSettingsEdit = Partial; + +// +// - Récupération +// type GetAllParams = ListingParams & { deleted?: boolean, group?: Group, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (params: GetAllParams): Promise> => ( - (await requester.get('/users', { params })).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = await requester.get('/users', { params }); + return withPaginationEnvelope(UserSchema).parse(response.data); +}; -const one = async (id: User['id'] | 'self'): Promise => ( - (await requester.get(`/users/${id}`)).data -); +const one = async (id: User['id'] | 'self'): Promise => { + const response = await requester.get(`/users/${id}`); + return UserDetailsSchema.parse(response.data); +}; -const create = async (data: UserEdit): Promise => ( - (await requester.post('/users', data)).data -); +const create = async (data: UserEdit): Promise => { + const response = await requester.post('/users', data); + return UserDetailsSchema.parse(response.data); +}; 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 { - return (await requester.put(`/users/${id}`, data)).data; + const response = await requester.put(`/users/${id}`, data); + return UserDetailsSchema.parse(response.data); } -/* eslint-enable func-style */ -const getSettings = async (id: User['id']): Promise => ( - (await requester.get(`/users/${id}/settings`)).data -); +const getSettings = async (id: User['id'] | 'self'): Promise => { + const response = await requester.get(`/users/${id}/settings`); + return UserSettingsSchema.parse(response.data); +}; -const saveSettings = async (id: User['id'], data: UserSettingsEdit): Promise => ( - (await requester.put(`/users/${id}/settings`, data)).data -); +const updateSettings = async (id: User['id'] | 'self', data: UserSettingsEdit): Promise => { + const response = await requester.put(`/users/${id}/settings`, data); + return UserSettingsSchema.parse(response.data); +}; -const restore = async (id: User['id']): Promise => ( - (await requester.put(`/users/restore/${id}`)).data -); +const restore = async (id: User['id']): Promise => { + const response = await requester.put(`/users/restore/${id}`); + return UserDetailsSchema.parse(response.data); +}; const remove = async (id: User['id']): Promise => { await requester.delete(`/users/${id}`); @@ -89,7 +161,7 @@ export default { create, update, getSettings, - saveSettings, + updateSettings, restore, remove, }; diff --git a/client/src/stores/auth.ts b/client/src/stores/auth.ts index 21ae5e71f..60a5c708e 100644 --- a/client/src/stores/auth.ts +++ b/client/src/stores/auth.ts @@ -7,6 +7,7 @@ import apiSession from '@/stores/api/session'; import type { Module, ActionContext } from 'vuex'; import type { Session, Credentials } from '@/stores/api/session'; import type { Group } from '@/stores/api/groups'; +import type { UserSettings } from '@/stores/api/users'; export type State = { user: Session | null, @@ -41,6 +42,8 @@ const store: Module = { const normalizedGroups = Array.isArray(groups) ? groups : [groups]; return normalizedGroups.includes(state.user.group); }, + + user: (state: State) => state.user, }, mutations: { setUser(state: State, user: Session) { @@ -54,6 +57,10 @@ const store: Module = { setLocale(state: State, language: string) { state.user!.language = language; }, + + setInterfaceSettings(state: State, settings: UserSettings) { + state.user!.default_bookings_view = settings.default_bookings_view; + }, }, actions: { async fetch({ dispatch, commit }: ActionContext) { @@ -86,13 +93,9 @@ const store: Module = { }, async logout(_: ActionContext, full: boolean = true) { - const hasPotentiallyStatefulSession = !!( - config.auth.isCASEnabled || - config.auth.isSAML2Enabled - ); const theme = ''; - if (hasPotentiallyStatefulSession && full) { + if (full) { window.location.assign(`${config.baseUrl}${theme}/logout`); } else { cookies.remove(config.auth.cookie); diff --git a/client/src/stores/settings.ts b/client/src/stores/settings.ts index e471412d3..aa071a53b 100644 --- a/client/src/stores/settings.ts +++ b/client/src/stores/settings.ts @@ -1,11 +1,17 @@ +import Day from '@/utils/day'; +import Period from '@/utils/period'; +import DateTime from '@/utils/datetime'; import apiSettings, { MaterialDisplayMode, ReturnInventoryMode } from '@/stores/api/settings'; import type { Module, ActionContext } from 'vuex'; -import type { Settings } from '@/stores/api/settings'; +import type { OpeningDay, Settings } from '@/stores/api/settings'; export type State = Settings; const getDefaults = (): Settings => ({ + general: { + openingHours: [], + }, eventSummary: { customText: { title: null, @@ -13,6 +19,10 @@ const getDefaults = (): Settings => ({ }, materialDisplayMode: MaterialDisplayMode.SUB_CATEGORIES, showLegalNumbers: true, + showReplacementPrices: true, + showDescriptions: false, + showTags: false, + showPictures: false, }, calendar: { event: { @@ -31,6 +41,33 @@ const getDefaults = (): Settings => ({ const store: Module = { namespaced: true, state: getDefaults(), + getters: { + isOpen: (state: State) => (date: Day | DateTime) => ( + state.general.openingHours.some((openingDay: OpeningDay) => { + // - Si la date comparée est à `00:00:00`, on regarde si on a pas une heure + // de fermeture pour le jour précédent à `24:00:00`. + const shouldCheckYesterdayMidnight = ( + date instanceof DateTime && + date.isStartOfDay() && + openingDay.weekday === date.subDay().get('day') + ); + if (shouldCheckYesterdayMidnight && /^24:00(?::00(?:\.000)?)?$/.test(openingDay.end_time)) { + return true; + } + + const isOpenDay = openingDay.weekday === date.get('day'); + if (!isOpenDay || date instanceof Day) { + return isOpenDay; + } + + const openingPeriod = new Period( + date.setTime(openingDay.start_time), + date.setTime(openingDay.end_time), + ); + return date.isBetween(openingPeriod, '[]'); + }) + ), + }, mutations: { reset(state: State) { Object.assign(state, getDefaults()); @@ -40,12 +77,22 @@ const store: Module = { }, }, actions: { - reset({ commit }: ActionContext) { - commit('reset'); + async boot({ dispatch }: ActionContext) { + await dispatch('fetch'); + + const refresh = async (): Promise => { + await dispatch('fetch'); + }; + setInterval(refresh, 30_000); // - 30 secondes. }, + async fetch({ commit }: ActionContext) { commit('set', await apiSettings.all()); }, + + reset({ commit }: ActionContext) { + commit('reset'); + }, }, }; diff --git a/client/src/themes/default/components/Alert/_variables.scss b/client/src/themes/default/components/Alert/_variables.scss new file mode 100644 index 000000000..12fb7c9c0 --- /dev/null +++ b/client/src/themes/default/components/Alert/_variables.scss @@ -0,0 +1,15 @@ +// +// - Variantes +// + +$warning-variant: ( + icon: 'exclamation-triangle', + background: #644a2b, + color: #fff, +) !default; + +$info-variant: ( + icon: 'info', + background: #2d3348, + color: #fff, +) !default; diff --git a/client/src/themes/default/components/Alert/index.scss b/client/src/themes/default/components/Alert/index.scss new file mode 100644 index 000000000..6b2b40ac8 --- /dev/null +++ b/client/src/themes/default/components/Alert/index.scss @@ -0,0 +1,39 @@ +@use './variables' as *; +@use '~@/themes/default/style/globals'; +@use 'sass:map'; + +.Alert { + display: flex; + align-items: center; + padding: globals.$spacing-medium globals.$spacing-medium; + border-radius: globals.$border-radius-large; + white-space: pre-line; + gap: globals.$spacing-medium; + + &::before { + opacity: 0.8; + } + + // + // - Variantes + // + + // stylelint-disable-next-line scss/dollar-variable-first-in-block, order/order + $variants: ( + warning: $warning-variant, + info: $info-variant, + ); + + @each $name, $variant in $variants { + &--#{$name} { + background: map.get($variant, background); + color: map.get($variant, color); + + @include globals.icon(map.get($variant, icon)) { + min-width: 20px; + font-size: 1.5rem; + text-align: center; + } + } + } +} diff --git a/client/src/themes/default/components/Alert/index.tsx b/client/src/themes/default/components/Alert/index.tsx new file mode 100644 index 000000000..fd4730a60 --- /dev/null +++ b/client/src/themes/default/components/Alert/index.tsx @@ -0,0 +1,44 @@ +import './index.scss'; +import { defineComponent } from '@vue/composition-api'; + +import type { PropType } from '@vue/composition-api'; + +enum Type { + /** Une alerte d'avertissement. */ + WARNING = 'warning', + + /** Une alerte d'information. */ + INFO = 'info', +} + +type Props = { + /** Le type (= variante) de l'alerte. */ + type: Type, +}; + +/** Une alerte. */ +const Alert = defineComponent({ + name: 'Alert', + props: { + type: { + type: String as PropType, + required: true, + validator: (value: unknown) => ( + typeof value === 'string' && + (Object.values(Type) as string[]).includes(value) + ), + }, + }, + render() { + const { type } = this; + const children = this.$slots.default; + + return ( +
+ {children} +
+ ); + }, +}); + +export default Alert; diff --git a/client/src/themes/default/components/App/index.js b/client/src/themes/default/components/App/index.js index a256ad269..adf37dbf0 100644 --- a/client/src/themes/default/components/App/index.js +++ b/client/src/themes/default/components/App/index.js @@ -59,7 +59,7 @@ const App = { }, render() { const { layout } = this; - invariant(layout in layouts, `Le layout "${layout}" n'existe pas.`); + invariant(layout in layouts, `The \`${layout}\` layout doesn't exist.`); const Layout = layouts[layout]; return ( diff --git a/client/src/themes/default/components/Button/_variables.scss b/client/src/themes/default/components/Button/_variables.scss index 19a9b875d..e4c41394d 100644 --- a/client/src/themes/default/components/Button/_variables.scss +++ b/client/src/themes/default/components/Button/_variables.scss @@ -4,11 +4,11 @@ /// Padding vertical des boutons. /// @type Number -$padding-y: 0.45rem !default; +$padding-y: 7px !default; /// Padding horizontal des boutons. /// @type Number -$padding-x: 0.626rem !default; +$padding-x: 10px !default; /// Marge entre les éléments internes du bouton (texte, icône(s)). /// @type Number @@ -83,13 +83,13 @@ $primary-variant: ( // - Focused focused-color: #fff, - focused-background: color.adjust(globals.$primary-color, $saturation: 23.3%, $lightness: 5.5%), - focused-border-color: color.adjust(globals.$primary-color, $saturation: 23.3%, $lightness: 5.5%), + focused-background: color.adjust(globals.$primary-color, $lightness: 5.5%), + focused-border-color: color.adjust(globals.$primary-color, $lightness: 5.5%), // - Active active-color: #fff, - active-background: color.adjust(globals.$primary-color, $saturation: 23.8%, $lightness: -4.5%), - active-border-color: color.adjust(globals.$primary-color, $saturation: 23.8%, $lightness: -4.5%), + active-background: color.adjust(globals.$primary-color, $lightness: -4.5%), + active-border-color: color.adjust(globals.$primary-color, $lightness: -4.5%), ) !default; // @@ -98,19 +98,19 @@ $primary-variant: ( // stylelint-disable-next-line value-list-max-empty-lines $secondary-variant: ( - color: #fff, - background: color.adjust(globals.$primary-color, $saturation: -25%, $lightness: -22%), - border-color: globals.$primary-color, + color: color.adjust(globals.$primary-color, $saturation: -30%, $lightness: 60%), + background: color.adjust(globals.$primary-color, $alpha: -0.7), + border-color: transparent, // - Focused - focused-color: map.get($primary-variant, focused-color), - focused-background: map.get($primary-variant, focused-background), - focused-border-color: map.get($primary-variant, focused-border-color), + focused-color: color.adjust(globals.$primary-color, $saturation: -30%, $lightness: 65%), + focused-background: color.adjust(globals.$primary-color, $alpha: -0.7, $lightness: 10%), + focused-border-color: transparent, // - Active - active-color: map.get($primary-variant, active-color), - active-background: map.get($primary-variant, active-background), - active-border-color: map.get($primary-variant, active-border-color), + active-color: color.adjust(globals.$primary-color, $saturation: -30%, $lightness: 55%), + active-background: color.adjust(globals.$primary-color, $alpha: -0.7, $lightness: -6%), + active-border-color: transparent, ) !default; // diff --git a/client/src/themes/default/components/Button/index.scss b/client/src/themes/default/components/Button/index.scss index 8a98e94e9..90c74cc94 100644 --- a/client/src/themes/default/components/Button/index.scss +++ b/client/src/themes/default/components/Button/index.scss @@ -12,7 +12,7 @@ padding: $padding-y $padding-x; border: $border-width solid; border-radius: $border-radius; - font-size: 1rem; + font-size: 1.05rem; line-height: 1; text-decoration: none; white-space: nowrap; @@ -21,50 +21,50 @@ transition: all 300ms; gap: $internal-spacing; - & + & { - margin-left: globals.$spacing-small; - } - &:hover, &:focus { outline: 0; } + &::before, + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1em; + height: 1.1em; + font-size: 1.05em; + line-height: 1.15; + text-align: center; + vertical-align: -0.05rem; + } + // - // - Disabled + // - Modifiers // - &--disabled { - cursor: not-allowed; - opacity: 0.5; + &--collapsible { + &#{$block}--with-icon { + #{$block}__content { + display: none; + } + } } // - // - Loading + // - État // + &--disabled { + cursor: not-allowed; + opacity: 0.5; + } + &--loading { cursor: wait; opacity: 0.75; } - // - // - Icône - // - - &::before, - &__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.1em; - height: 1.1em; - font-size: 1.1em; - line-height: 1.15; - text-align: center; - vertical-align: -0.05rem; - } - // // - Tailles // @@ -127,4 +127,18 @@ } } } + + // + // - Responsive + // + + @media (min-width: globals.$screen-tablet) { + &--collapsible { + &#{$block}--with-icon { + #{$block}__content { + display: block; + } + } + } + } } diff --git a/client/src/themes/default/components/Button/index.tsx b/client/src/themes/default/components/Button/index.tsx index db0e830ac..5dd16adb1 100644 --- a/client/src/themes/default/components/Button/index.tsx +++ b/client/src/themes/default/components/Button/index.tsx @@ -1,12 +1,12 @@ import './index.scss'; import { defineComponent } from '@vue/composition-api'; -import Icon, { VARIANTS as ICON_VARIANTS } from '@/themes/default/components/Icon'; +import Icon, { Variant as IconVariant } from '@/themes/default/components/Icon'; import Fragment from '@/components/Fragment'; import type { Location } from 'vue-router'; import type { TooltipOptions } from 'v-tooltip'; import type { PropType } from '@vue/composition-api'; -import type { Props as IconProps, Variant } from '@/themes/default/components/Icon'; +import type { Props as IconProps } from '@/themes/default/components/Icon'; export const TYPES = [ 'default', 'success', 'warning', 'danger', @@ -21,7 +21,7 @@ const PREDEFINED_TYPES = { icon: 'plus', }, edit: { - type: 'success', + type: 'default', icon: 'edit', }, trash: { @@ -45,7 +45,7 @@ const PREDEFINED_TYPES = { export type PredefinedType = keyof typeof PREDEFINED_TYPES; export type Type = (typeof TYPES)[number]; -type IconName = string | `${string}:${Variant}`; +type IconName = string | `${string}:${IconVariant}`; type IconPosition = 'before' | 'after'; type IconOptions = { name: IconName, position?: IconPosition }; export type IconLoose = IconName | IconOptions; @@ -153,6 +153,17 @@ type Props = { */ loading?: boolean, + /** + * Le bouton peut-il être affiché de manière minimaliste + * (= uniquement l'icône) pour les petits écrans ? + * + * Quand cette prop. vaut `true`, pour les écrans plus petit que le format + * tablette, si le bouton comporte une icône, seule celle-ci sera affichée. + * + * @default false + */ + collapsible?: boolean, + /** * Le bouton est-il désactivé ? * @@ -212,6 +223,10 @@ const Button = defineComponent({ type: Boolean as PropType['external']>, default: false, }, + collapsible: { + type: Boolean as PropType['collapsible']>, + default: false, + }, disabled: { type: Boolean as PropType['disabled']>, default: false, @@ -250,8 +265,8 @@ const Button = defineComponent({ } const [iconType, variant] = icon.split(':'); - return ICON_VARIANTS.includes(variant) - ? { name: iconType, variant: variant as Variant } + return Object.values(IconVariant).includes(variant as any) + ? { name: iconType, variant: variant as IconVariant } : { name: iconType }; }, @@ -288,6 +303,7 @@ const Button = defineComponent({ size, loading, disabled, + collapsible, external, htmlType, iconPosition, @@ -302,6 +318,8 @@ const Button = defineComponent({ `Button--${type}`, `Button--${size}`, { + 'Button--collapsible': collapsible, + 'Button--with-icon': icon !== undefined, 'Button--disabled': disabled || loading, 'Button--loading': loading, }, diff --git a/client/src/themes/default/components/ButtonDropdown/index.scss b/client/src/themes/default/components/ButtonDropdown/index.scss index 4d17568db..3ea5f83f6 100644 --- a/client/src/themes/default/components/ButtonDropdown/index.scss +++ b/client/src/themes/default/components/ButtonDropdown/index.scss @@ -29,10 +29,6 @@ border-bottom-left-radius: 0; } - &__action-button { - width: 100%; - } - // // - Menu // @@ -42,9 +38,12 @@ z-index: 1; top: 100%; right: 0; + display: flex; + flex-direction: column; margin: 2px 0 0; padding: 0; - background: globals.$bg-color-dropdown; + background: globals.$dropdown-background-color; + gap: 2px; box-shadow: 0 4px 9px #1b1b1b; transform-origin: 50% 0%; transform: scaleY(0); @@ -53,13 +52,26 @@ &__item { flex: 0 0 auto; + display: flex; margin: 0; white-space: nowrap; list-style: none; + } + } - & + & { - margin-top: 2px; - } + &__action-button { + flex: 1; + + &--primary { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &--secondary { + flex: 0 0 auto; + border-left: 1px solid globals.$divider-color; + border-top-left-radius: 0; + border-bottom-left-radius: 0; } } diff --git a/client/src/themes/default/components/ButtonDropdown/index.tsx b/client/src/themes/default/components/ButtonDropdown/index.tsx index adf04350e..31c94a549 100644 --- a/client/src/themes/default/components/ButtonDropdown/index.tsx +++ b/client/src/themes/default/components/ButtonDropdown/index.tsx @@ -53,6 +53,12 @@ type Action = { * N'est utile que quand l'action secondaire n'est pas un lien. */ onClick?(e: MouseEvent): void, + + /** + * Action supplémentaire éventuelle, qui sera affichée sur la même ligne + * que l'action secondaire, à droite. + */ + secondary?: Action, }; type Props = { @@ -232,10 +238,25 @@ const ButtonDropdown = defineComponent({ external={action.external} onClick={action.onClick ?? (() => {})} disabled={disabled} - class="ButtonDropdown__action-button" + class={[ + 'ButtonDropdown__action-button', + { 'ButtonDropdown__action-button--primary': !!action.secondary }, + ]} > {action.label} + {!!action.secondary && ( + ); } diff --git a/client/src/themes/default/components/CriticalError/index.scss b/client/src/themes/default/components/CriticalError/index.scss index 458a849a4..f79ec64d4 100644 --- a/client/src/themes/default/components/CriticalError/index.scss +++ b/client/src/themes/default/components/CriticalError/index.scss @@ -6,7 +6,7 @@ &__illustration { width: 80%; - max-width: 25em; + max-width: 25rem; fill: currentColor; opacity: 0.2; } @@ -16,7 +16,7 @@ white-space: pre-line; } - &__back-to-calendar, + &__back-to-home, &__refresh { padding: 0.5rem 1rem; font-size: 1.1rem; @@ -31,14 +31,14 @@ } @media (min-width: globals.$screen-desktop) { - font-size: 1.5rem; + font-size: 1.3rem; &__message { margin: globals.$spacing-large 0 globals.$spacing-medium; white-space: pre-line; } - &__back-to-calendar, + &__back-to-home, &__refresh { padding: 0.75rem 1.5rem; font-size: 1.2rem; diff --git a/client/src/themes/default/components/DatePicker/__tests__/utils/normalizer.spec.ts b/client/src/themes/default/components/DatePicker/__tests__/utils/normalizer.spec.ts new file mode 100644 index 000000000..b94e2c554 --- /dev/null +++ b/client/src/themes/default/components/DatePicker/__tests__/utils/normalizer.spec.ts @@ -0,0 +1,341 @@ +import Day from '@/utils/day'; +import Period from '@/utils/period'; +import DateTime from '@/utils/datetime'; +import { Type } from '../../_types'; +import { + normalizeInputValue, + normalizeCoreValue, + convertValueType, +} from '../../utils/normalizer'; + +describe('DatePicker Utils: Normalizer', () => { + describe('normalizeInputValue()', () => { + it('should throw when an invalid value is passed for the Datepicker constraints', () => { + const period = new Period('2024-01-01 14:30:00', '2024-01-01 18:00:00'); + const fullDayPeriod = new Period('2024-01-01', '2025-01-01', true); + const dateTime = new DateTime('2024-01-01 14:30:00'); + const day = new Day('2024-01-01'); + + // + // - Type: `date` / Mode: Valeur seule. + // + + // -- ... avec une `Period`. + expect(() => normalizeInputValue(period, Type.DATE, false, false)).toThrow(); + expect(() => normalizeInputValue(fullDayPeriod, Type.DATE, false, false)).toThrow(); + + // -- ... avec une instance de `DateTime`. + expect(() => normalizeInputValue(dateTime, Type.DATE, false, false)).toThrow(); + + // -- ... avec une instance de `Day` => Pas d'erreur. + expect(() => normalizeInputValue(day, Type.DATE, false, false)).not.toThrow(); + + // + // - Type: `date` / Mode: Période. + // + + // -- ... avec une instance de `Day`. + expect(() => normalizeInputValue(day, Type.DATE, true, false)).toThrow(); + + // -- ... avec une instance de `DateTime`. + expect(() => normalizeInputValue(dateTime, Type.DATE, true, false)).toThrow(); + + // -- ... avec une `Period` à l'heure près. + expect(() => normalizeInputValue(period, Type.DATE, true, false)).toThrow(); + + // -- ... avec une `Period` en jours entiers => Pas d'erreur. + expect(() => normalizeInputValue(fullDayPeriod, Type.DATE, true, false)).not.toThrow(); + + // + // - Type: `datetime` / Mode: Valeur seule. + // + + // -- ... avec une `Period`. + expect(() => normalizeInputValue(period, Type.DATETIME, false, false)).toThrow(); + expect(() => normalizeInputValue(fullDayPeriod, Type.DATETIME, false, false)).toThrow(); + + // -- ... avec une instance de `Day`. + expect(() => normalizeInputValue(day, Type.DATETIME, false, false)).toThrow(); + + // -- ... avec une instance de `DateTime` => Pas d'erreur. + expect(() => normalizeInputValue(dateTime, Type.DATETIME, false, false)).not.toThrow(); + + // + // - Type: `datetime` / Mode: Période. + // + + // -- ... avec une instance de `Day`. + expect(() => normalizeInputValue(day, Type.DATETIME, true, false)).toThrow(); + + // -- ... avec une instance de `DateTime`. + expect(() => normalizeInputValue(dateTime, Type.DATETIME, true, false)).toThrow(); + + // -- ... avec une `Period` en jours entiers. + expect(() => normalizeInputValue(fullDayPeriod, Type.DATETIME, true, false)).toThrow(); + + // -- ... avec une `Period` à l'heure près => Pas d'erreur. + expect(() => normalizeInputValue(period, Type.DATETIME, true, false)).not.toThrow(); + }); + + it('should return the right value', () => { + // - Valeur nulle. + expect(normalizeInputValue(null, Type.DATE, false, false)).toBeNull(); + expect(normalizeInputValue(null, Type.DATE, true, false)).toBeNull(); + expect(normalizeInputValue(null, Type.DATETIME, false, false)).toBeNull(); + expect(normalizeInputValue(null, Type.DATETIME, true, false)).toBeNull(); + + // + // - Type: `date` / Mode: Valeur seule. + // + + const day = new Day('2024-01-01'); + + // -- ... Avec les minutes. + const result1 = normalizeInputValue(day, Type.DATE, false, false); + expect(result1).toBeInstanceOf(Day); + expect(result1!.toString()).toBe('2024-01-01'); + + // -- ... Sans les minutes. + const result2 = normalizeInputValue(day, Type.DATE, false, true); + expect(result2).toBeInstanceOf(Day); + expect(result2!.toString()).toBe('2024-01-01'); + + // + // - Type: `date` / Mode: Période. + // + + const fullDayPeriod = new Period('2024-01-01', '2025-01-01', true); + + // -- ... Avec les minutes. + const result3 = normalizeInputValue(fullDayPeriod, Type.DATE, true, false); + expect(result3).toBeInstanceOf(Period); + expect(result3!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2025-01-01', + isFullDays: true, + }); + + // -- ... Sans les minutes. + const result4 = normalizeInputValue(fullDayPeriod, Type.DATE, true, true); + expect(result4).toBeInstanceOf(Period); + expect(result4!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2025-01-01', + isFullDays: true, + }); + + // + // - Type: `datetime` / Mode: Valeur seule. + // + + const dateTime = new DateTime('2024-01-01 14:38:24'); + + // -- ... Avec les minutes. + const result5 = normalizeInputValue(dateTime, Type.DATETIME, false, false); + expect(result5).toBeInstanceOf(DateTime); + expect(result5!.toString()).toBe('2024-01-01 14:45:00'); + + // -- ... Sans les minutes. + const result6 = normalizeInputValue(dateTime, Type.DATETIME, false, true); + expect(result6).toBeInstanceOf(DateTime); + expect(result6!.toString()).toBe('2024-01-01 15:00:00'); + + // + // - Type: `datetime` / Mode: Période. + // + + const period = new Period('2024-01-01 14:38:24', '2024-01-01 14:38:24'); + + // -- ... Avec les minutes. + const result7 = normalizeInputValue(period, Type.DATETIME, true, false); + expect(result7).toBeInstanceOf(Period); + expect(result7!.toSerialized()).toStrictEqual({ + start: '2024-01-01 14:30:00', + end: '2024-01-01 14:45:00', + isFullDays: false, + }); + + // -- ... Sans les minutes. + const result8 = normalizeInputValue(period, Type.DATETIME, true, true); + expect(result8).toBeInstanceOf(Period); + expect(result8!.toSerialized()).toStrictEqual({ + start: '2024-01-01 14:00:00', + end: '2024-01-01 15:00:00', + isFullDays: false, + }); + }); + }); + + describe('normalizeCoreValue()', () => { + // - Valeur nulle. + [Type.DATE, Type.DATETIME].forEach((dateMode: Type) => { + expect(normalizeCoreValue(null, dateMode, false)).toBeNull(); + expect(normalizeCoreValue(null, dateMode, true)).toBeNull(); + expect(normalizeCoreValue([] as any, dateMode, true)).toBeNull(); + expect(normalizeCoreValue([null, null], dateMode, true)).toBeNull(); + expect(normalizeCoreValue( + ['2024-01-01', '2024-01-01', '2024-01-01'] as any, + dateMode, + true, + )).toBeNull(); + }); + + // + // - Type: `date` / Mode: Valeur seule. + // + + // -- Avec une valeur simple ... + const result1 = normalizeCoreValue('2024-01-01', Type.DATE, false); + expect(result1).toBeInstanceOf(Day); + expect(result1!.toString()).toBe('2024-01-01'); + + // -- Avec un tableau ... + const result2 = normalizeCoreValue(['2024-01-01', '2024-01-02'], Type.DATE, false); + expect(result2).toBeInstanceOf(Day); + expect(result2!.toString()).toBe('2024-01-01'); + + // + // - Type: `date` / Mode: Période. + // + + // -- Avec un tableau ... + const result3 = normalizeCoreValue(['2024-01-01', '2025-01-01'], Type.DATE, true); + expect(result3).toBeInstanceOf(Period); + expect(result3!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2025-01-01', + isFullDays: true, + }); + + // -- Avec une valeur simple ... + const result4 = normalizeCoreValue('2024-01-01', Type.DATE, true); + expect(result4).toBeInstanceOf(Period); + expect(result4!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2024-01-01', + isFullDays: true, + }); + + // + // - Type: `datetime` / Mode: Valeur seule. + // + + // -- Avec une valeur simple ... + const result5 = normalizeCoreValue('2024-01-01 14:30:00', Type.DATETIME, false); + expect(result5).toBeInstanceOf(DateTime); + expect(result5!.toString()).toBe('2024-01-01 14:30:00'); + + // -- Avec un tableau ... + const result6 = normalizeCoreValue(['2024-01-01 14:30:00', '2024-02-12 15:15:00'], Type.DATETIME, false); + expect(result6).toBeInstanceOf(DateTime); + expect(result6!.toString()).toBe('2024-01-01 14:30:00'); + + // + // - Type: `datetime` / Mode: Période. + // + + // -- Avec un tableau ... + const result7 = normalizeCoreValue(['2024-01-01 14:30:00', '2024-02-12 15:15:00'], Type.DATETIME, true); + expect(result7).toBeInstanceOf(Period); + expect(result7!.toSerialized()).toStrictEqual({ + start: '2024-01-01 14:30:00', + end: '2024-02-12 15:15:00', + isFullDays: false, + }); + + // -- Avec une valeur simple ... + const result8 = normalizeCoreValue('2024-01-01 14:30:00', Type.DATETIME, true); + expect(result8).toBeInstanceOf(Period); + expect(result8!.toSerialized()).toStrictEqual({ + start: '2024-01-01 14:30:00', + end: '2024-01-01 14:30:00', + isFullDays: false, + }); + }); + + describe('convertValueType()', () => { + // - Valeur nulle. + expect(convertValueType(null, Type.DATE, false, false)).toBeNull(); + expect(convertValueType(null, Type.DATETIME, false, false)).toBeNull(); + + // + // - Type: `date` / Mode: Valeur seule. + // + + const dateTime = new DateTime('2024-01-01 14:38:24'); + + // -- ... Avec les minutes. + const result1 = convertValueType(dateTime, Type.DATE, false, false); + expect(result1).toBeInstanceOf(Day); + expect(result1!.toString()).toBe('2024-01-01'); + + // -- ... Sans les minutes. + const result2 = convertValueType(dateTime, Type.DATE, false, true); + expect(result2).toBeInstanceOf(Day); + expect(result2!.toString()).toBe('2024-01-01'); + + // + // - Type: `date` / Mode: Période. + // + + const period = new Period('2024-01-01 14:38:24', '2024-01-01 14:38:24'); + + // -- ... Avec les minutes. + const result3 = convertValueType(period, Type.DATE, true, false); + expect(result3).toBeInstanceOf(Period); + expect(result3!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2024-01-01', + isFullDays: true, + }); + + // -- ... Sans les minutes. + const result4 = convertValueType(period, Type.DATE, true, true); + expect(result4).toBeInstanceOf(Period); + expect(result4!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2024-01-01', + isFullDays: true, + }); + + // + // - Type: `datetime` / Mode: Valeur seule. + // + + const day = new Day('2024-01-01'); + + // -- ... Avec les minutes. + const result5 = convertValueType(day, Type.DATETIME, false, false); + expect(result5).toBeInstanceOf(DateTime); + expect(result5!.toString()).toBe('2024-01-01 12:00:00'); + + // -- ... Sans les minutes. + const result6 = convertValueType(day, Type.DATETIME, false, true); + expect(result6).toBeInstanceOf(DateTime); + expect(result6!.toString()).toBe('2024-01-01 12:00:00'); + + // + // - Type: `datetime` / Mode: Période. + // + + const fullDayPeriod = new Period('2024-01-01', '2025-01-01', true); + + // -- ... Avec les minutes. + const result7 = convertValueType(fullDayPeriod, Type.DATETIME, true, false); + expect(result7).toBeInstanceOf(Period); + expect(result7!.toSerialized()).toStrictEqual({ + start: '2024-01-01 12:00:00', + end: '2025-01-01 12:00:00', + isFullDays: false, + }); + + // -- ... Sans les minutes. + const result8 = convertValueType(fullDayPeriod, Type.DATETIME, true, true); + expect(result8).toBeInstanceOf(Period); + expect(result8!.toSerialized()).toStrictEqual({ + start: '2024-01-01 12:00:00', + end: '2025-01-01 12:00:00', + isFullDays: false, + }); + }); +}); diff --git a/client/src/themes/default/components/DatePicker/_types.ts b/client/src/themes/default/components/DatePicker/_types.ts new file mode 100644 index 000000000..99e5a1012 --- /dev/null +++ b/client/src/themes/default/components/DatePicker/_types.ts @@ -0,0 +1,59 @@ +import type Period from '@/utils/period'; +import type DateTime from '@/utils/datetime'; +import type Day from '@/utils/day'; + +export enum Type { + /** Sélection de ou des dates uniquement (jour, mois, année). */ + DATE = 'date', + + /** Sélection de date et heures. */ + DATETIME = 'datetime', +} + +export type DisableDateFunction = (date: DateTime, granularity: 'day' | 'minute') => boolean; + +// +// - Valeur +// + +/** Représente une valeur du datepicker. */ +export type Value = ( + | ( + R extends true + ? Period + : T extends Type.DATETIME + ? DateTime + : Day + ) + | null +); + +/** Représente une valeur dans le format du "core" pour une date seule. */ +export type CoreDateValue = string | null; + +/** Représente une valeur dans le format du "core" du datepicker. */ +export type CoreValue = ( + | [start: CoreDateValue, end: CoreDateValue] + | CoreDateValue +); + +// +// - Snippets +// + +export type RawDateSnippet = { + labelKey: string, + period(today: Day): Day, +}; + +export type RawRangeSnippet = { + labelKey: string, + period(today: Day): Period, +}; + +export type Snippet = { + label: string, + periodLabel: string, + isActive: boolean, + period: Date | [Date, Date], +}; diff --git a/client/src/themes/default/components/Datepicker/index.scss b/client/src/themes/default/components/DatePicker/index.scss similarity index 73% rename from client/src/themes/default/components/Datepicker/index.scss rename to client/src/themes/default/components/DatePicker/index.scss index 3d11e15ac..7321b541b 100644 --- a/client/src/themes/default/components/Datepicker/index.scss +++ b/client/src/themes/default/components/DatePicker/index.scss @@ -1,7 +1,7 @@ @use 'sass:color'; @use '~@/themes/default/style/globals'; -.Datepicker { +.DatePicker { display: inline-block; width: 100%; @@ -61,22 +61,27 @@ @extend %reset-dd; color: globals.$text-soft-color; + font-weight: 400; white-space: nowrap; } &:hover { - background: #a84825; + background: rgba(globals.$primary-color, 0.5); + + #{$sub-block}__value { + color: color.adjust(globals.$primary-color, $lightness: 30%, $saturation: -80%); + } } &--active { &, &:hover { - background: rgba(250, 99, 23, 0.2); - color: #fb6418; + background: rgba(globals.$primary-color, 0.2); + color: color.adjust(globals.$primary-color, $lightness: 45%); font-weight: 500; #{$sub-block}__value { - color: color.adjust(#fb6418, $alpha: -0.4); + color: color.adjust(globals.$primary-color, $lightness: 45%, $alpha: -0.4); } } } @@ -89,6 +94,8 @@ &--invalid { .mx-input:not(:focus) { border-color: globals.$input-error-border-color; + background-color: globals.$input-error-background-color; + color: globals.$input-error-text-color; } } } diff --git a/client/src/themes/default/components/Datepicker/index.tsx b/client/src/themes/default/components/DatePicker/index.tsx similarity index 68% rename from client/src/themes/default/components/Datepicker/index.tsx rename to client/src/themes/default/components/DatePicker/index.tsx index d3be9cc0d..f7d912e84 100644 --- a/client/src/themes/default/components/Datepicker/index.tsx +++ b/client/src/themes/default/components/DatePicker/index.tsx @@ -1,21 +1,24 @@ import './index.scss'; -import moment from 'moment'; +import Day from '@/utils/day'; +import warning from 'warning'; +import Period from '@/utils/period'; +import DateTime from '@/utils/datetime'; import { defineComponent } from '@vue/composition-api'; -import CoreDatepicker from 'vue2-datepicker'; +import CoreDatePicker from 'vue2-datepicker'; import Fragment from '@/components/Fragment'; import Switch from '@/themes/default/components/SwitchToggle'; import Button from '@/themes/default/components/Button'; import frPickerTranslations from 'vue2-datepicker/locale/es/fr'; import enPickerTranslations from 'vue2-datepicker/locale/es/en'; +import { RANGE_SNIPPETS, DATE_SNIPPETS } from './utils/snippets'; import { Type } from './_types'; import { - normalizeValue, - RANGE_SNIPPETS, - DATE_SNIPPETS, + normalizeInputValue, + normalizeCoreValue, + convertValueType, MINUTES_STEP, -} from './_utils'; +} from './utils/normalizer'; -import type { Moment, MomentInput } from 'moment'; import type { PropType } from '@vue/composition-api'; import type { Formatter, @@ -25,20 +28,15 @@ import type { DatePickerSlotParams, } from 'vue2-datepicker'; import type { - LooseValue, - LooseDateValue, Value, + CoreValue, Snippet, RawDateSnippet, RawRangeSnippet, + DisableDateFunction, } from './_types'; -const PICKER_TRANSLATIONS: Record = { - fr: frPickerTranslations, - en: enPickerTranslations, -}; - -type Props = { +type Props = { /** * Le nom du champ (attribut `[name]`). * @@ -53,11 +51,9 @@ type Props = { * - `date`: Sélection de date sans heure. * - `datetime`: Sélection de date et heure. * - * Attention, la prop. `canT` - * * @default Type.DATE */ - type?: Type, + type?: T, /** * Mode "période". @@ -72,18 +68,18 @@ type Props = { /** * Active la permutation des "Jours entiers". * - * Ce mode est uniquement compatible avec les types `date` et `datetime`. + * Ce mode est uniquement utilisable avec les types `date` et `datetime`. * * - Si `true`, l'utilisateur pourra choisir d'activer ou non le mode "Jour(s) entier(s)". * - Si `false`, le mode "Jour(s) entier(s)" ne sera pas proposé. * * Si cette option est activée, il faudra veiller à observer l'événement `onChange` et notamment * son deuxième paramètre (qui ne sera passé que quand cette option est activée) qui contiendra - * un booléen `isFullDays`. S'il est à `true`, il faudra veiller à changer le type en `DATE` et + * un booléen `isFullDays`. S'il est à `true`, il faudra veiller à changer le `type` en `DATE` et * dans le cas contraire en `DATETIME`. * * À noter aussi que si cette option est activée et que la prop. `name` est spécifiée, un champ - * hidden `is_full_days` sera utilisé pour stocker la valeur courante du switch. + * hidden `isFullDays` sera utilisé pour stocker la valeur courante du switch. * * @default false */ @@ -103,10 +99,10 @@ type Props = { withoutMinutes?: boolean, /** Date minimum sélectionnable dans le sélecteur. */ - minDate?: 'now' | Moment | Date | string | number, + minDate?: 'now' | DateTime | Day, /** Date maximum sélectionnable dans le sélecteur. */ - maxDate?: 'now' | Moment | Date | string | number, + maxDate?: 'now' | DateTime | Day, /** * Une éventuelle fonction permettant de désactiver certaines dates. @@ -114,10 +110,10 @@ type Props = { * - Si la fonction renvoie `true`, la date ne sera pas sélectionnable. * - Si elle renvoie `false`, elle le sera. */ - disabledDate?(date: Moment, granularity: 'day' | 'minute'): boolean, + disabledDate?: DisableDateFunction, /** La valeur actuelle du champ. */ - value: LooseValue, + value?: Value, /** * L'éventuel texte affiché en filigrane dans le @@ -128,8 +124,22 @@ type Props = { /** Le champ est-il désactivé ? */ disabled?: boolean, + /** + * Le champ est-il en lecture seule ? + * + * Cette prop. peut recevoir les valeurs suivantes: + * - Un booléen qui aura un effet similaire à la prop. `disabled`. + * - Les chaînes `start` ou `end`, uniquement utilisables quand le sélecteur + * de date est en mode `range`. Ceci aura pour effet de mettre en lecture + * seule seulement la partie indiqué tout en laissant l'autre modifiable. + */ + readonly?: boolean | 'start' | 'end', + /** Le champ doit-il être marqué comme invalide ? */ invalid?: boolean, + + /** Le champ peut-il être vidé ? */ + clearable?: boolean, }; type InstanceProperties = { @@ -138,21 +148,26 @@ type InstanceProperties = { type Data = { showTimePanel: boolean, - now: number, + now: DateTime, }; const FORMATTER: Formatter = { stringify: (date: Date | null | undefined, format: string): string => ( - date ? moment(date).format(format) : '' + date ? new DateTime(date).format(format) : '' ), - parse: (value: LooseDateValue): Date | null => ( - value ? moment(value).toDate() : null + parse: (value: string | null | undefined): Date | null => ( + value ? new DateTime(value).toDate() : null ), }; +const PICKER_TRANSLATIONS: Record = { + fr: frPickerTranslations, + en: enPickerTranslations, +}; + /** Un sélecteur de date(s), heure(s) et période. */ -const Datepicker = defineComponent({ - name: 'Datepicker', +const DatePicker = defineComponent({ + name: 'DatePicker', inject: { 'input.invalid': { default: { value: false } }, 'input.disabled': { default: { value: false } }, @@ -173,22 +188,8 @@ const Datepicker = defineComponent({ }, }, value: { - type: [Array, String] as PropType, + type: [Period, DateTime, Day] as PropType['value']>, default: null, - validator: (value: unknown) => { - const isValidDateString = (date: unknown): boolean => ( - [undefined, null].includes(date as any) || - (typeof date === 'string' && moment(date).isValid()) - ); - - if (Array.isArray(value)) { - return !value.some((date: unknown) => ( - !isValidDateString(date) - )); - } - - return isValidDateString(value); - }, }, range: { type: Boolean as PropType['range']>, @@ -199,11 +200,11 @@ const Datepicker = defineComponent({ default: undefined, }, minDate: { - type: [String, Object, Date, Number] as PropType, + type: [String, DateTime, Day] as PropType, default: undefined, }, maxDate: { - type: [String, Object, Date, Number] as PropType, + type: [String, DateTime, Day] as PropType, default: undefined, }, withFullDaysToggle: { @@ -222,32 +223,38 @@ const Datepicker = defineComponent({ type: Boolean as PropType, default: undefined, }, + readonly: { + type: [Boolean, String] as PropType['readonly']>, + default: false, + }, invalid: { type: Boolean as PropType, default: undefined, }, + clearable: { + type: Boolean as PropType, + default: false, + }, placeholder: { type: String as PropType, default: undefined, }, }, + emits: ['input', 'change'], setup: (): InstanceProperties => ({ nowTimer: undefined, }), - data(): Data { - return { - showTimePanel: false, - now: Date.now(), - }; - }, - emit: ['input', 'change'], + data: (): Data => ({ + showTimePanel: false, + now: DateTime.now(), + }), computed: { isFullDays(): boolean { return this.type === Type.DATE; }, normalizedValue(): Value { - return normalizeValue( + return normalizeInputValue( this.value, this.type, this.range, @@ -255,6 +262,22 @@ const Datepicker = defineComponent({ ); }, + coreValue(): CoreValue { + const value = this.normalizedValue; + if (value === null) { + return this.range ? [null, null] : null; + } + + if (this.range) { + return [ + (value as Period).start.toString(), + (value as Period).end.toString(), + ]; + } + + return (value as DateTime | Day).toString(); + }, + inheritedInvalid(): boolean { if (this.invalid !== undefined) { return this.invalid; @@ -275,12 +298,11 @@ const Datepicker = defineComponent({ return this['input.disabled'].value; }, - displayFormat(): string { - const dateFormat = this.range ? 'll' : 'LL'; - - return this.type === Type.DATETIME - ? `${dateFormat} HH:mm` - : dateFormat; + normalizedReadonly(): boolean | 'start' | 'end' { + if (typeof this.readonly === 'boolean') { + return this.readonly; + } + return this.range ? this.readonly : true; }, timePickerOptions(): TimePickerOptions { @@ -303,14 +325,22 @@ const Datepicker = defineComponent({ }; }, - outputFormat(): string { + displayFormat(): string { + const dateFormat = this.range ? 'll' : 'LL'; + + return this.type === Type.DATETIME + ? `${dateFormat} HH:mm` + : dateFormat; + }, + + valueFormat(): string { return this.type === Type.DATETIME ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'; }, dateFormat(): string { - const currentLocaleData = moment.localeData(); + const currentLocaleData = DateTime.localeData(); return currentLocaleData.longDateFormat('LL'); }, @@ -319,7 +349,7 @@ const Datepicker = defineComponent({ return PICKER_TRANSLATIONS[locale] ?? undefined; }, - disabledDateFactory(): ((granularity: 'day' | 'minute') => (rawDate: MomentInput) => boolean) { + disabledDateFactory(): ((granularity: 'day' | 'minute') => (rawDate: Date) => boolean) { const { now, disabledDate, @@ -330,25 +360,28 @@ const Datepicker = defineComponent({ const withHours = type === Type.DATETIME; const withMinutes = withHours && !this.withoutMinutes; - return (granularity: 'day' | 'minute') => (rawDate: MomentInput): boolean => { - const date = moment(rawDate); + return (granularity: 'day' | 'minute') => (rawDate: Date): boolean => { + const date = new DateTime(rawDate); if (disabledDate !== undefined && disabledDate(date, granularity)) { return true; } if (rawMinDate !== undefined) { - const minDate: Moment = (() => { + const minDate: DateTime = (() => { if (rawMinDate === 'now') { if (withHours) { if (withMinutes) { - return moment(now).startOf('minute'); + return now.startOfMinute(); } - return moment(now).startOf('hour'); + return now.startOfHour(); } - return moment(now).startOf('day'); + return now.startOfDay(); } - return moment(rawMinDate); + + return rawMinDate instanceof Day + ? rawMinDate.toDateTime().startOfDay() + : rawMinDate; })(); if (date.isBefore(minDate, granularity)) { return true; @@ -356,22 +389,26 @@ const Datepicker = defineComponent({ } if (rawMaxDate !== undefined) { - const maxDate: Moment = (() => { + const maxDate: DateTime = (() => { if (rawMaxDate === 'now') { if (withHours) { if (withMinutes) { - return moment(now).endOf('minute'); + return now.endOfMinute(true); } - return moment(now).endOf('hour'); + return now.endOfHour(true); } - return moment(now).endOf('day'); + return now.endOfDay(true); } - return moment(rawMaxDate); + + return rawMaxDate instanceof Day + ? rawMaxDate.toDateTime().endOfDay(true) + : rawMaxDate; })(); if (date.isAfter(maxDate, granularity)) { return true; } } + return false; }; }, @@ -386,25 +423,21 @@ const Datepicker = defineComponent({ return DATE_SNIPPETS.map((snippetGroup: RawDateSnippet[]): Snippet[] => ( snippetGroup.map((snippet: RawDateSnippet): Snippet => { const { labelKey, period: periodFunc } = snippet; - const currentValue = this.normalizedValue as Value; - const period = periodFunc(moment(now)); + const currentDate = this.normalizedValue as Value; + const snippetDay = periodFunc(new Day(now)); return { label: __(labelKey), - periodLabel: period.format('ll'), + periodLabel: snippetDay.format('ll'), isActive: ( - currentValue !== null - ? period.isSame(currentValue, 'day') + currentDate !== null + ? snippetDay.isSame(currentDate, 'day') : false ), period: ( this.type !== Type.DATETIME - ? period.clone().startOf('day').toDate() - : ( - period.clone() - .set({ hour: 12, minute: 0, second: 0 }) - .toDate() - ) + ? snippetDay.toDateTime().toDate() + : snippetDay.toDateTime().set('hour', 12).toDate() ), }; }) @@ -414,19 +447,19 @@ const Datepicker = defineComponent({ return RANGE_SNIPPETS.map((snippetGroup: RawRangeSnippet[]): Snippet[] => ( snippetGroup.map((snippet: RawRangeSnippet): Snippet => { const { labelKey, period: periodFunc } = snippet; - const [currentStart, currentEnd] = this.normalizedValue as Value; - const [periodStart, periodEnd] = periodFunc(moment(now)); + const currentPeriod = this.normalizedValue as Value; + const snippetPeriod = periodFunc(new Day(now)); let periodLabelParts: [string, string]; - if (periodStart.isSame(periodEnd, 'year')) { + if (snippetPeriod.start.isSame(snippetPeriod.end, 'year')) { periodLabelParts = [ - periodStart.format(__('range-format.same-year.start')), - periodEnd.format(__('range-format.same-year.end')), + snippetPeriod.start.format(__('range-format.same-year.start')), + snippetPeriod.end.format(__('range-format.same-year.end')), ]; } else { periodLabelParts = [ - periodStart.format(__('range-format.full.start')), - periodEnd.format(__('range-format.full.end')), + snippetPeriod.start.format(__('range-format.full.start')), + snippetPeriod.end.format(__('range-format.full.end')), ]; } @@ -434,24 +467,20 @@ const Datepicker = defineComponent({ label: __(labelKey), periodLabel: periodLabelParts.join(' - '), isActive: ((): boolean => { - if (currentStart === null || currentEnd === null) { + if (currentPeriod === null) { return false; } return ( - periodStart.isSame(currentStart, 'day') && - periodEnd.isSame(currentEnd, 'day') + snippetPeriod.start.isSame(currentPeriod.start, 'day') && + snippetPeriod.end.isSame(currentPeriod.end, 'day') ); })(), period: ( - [periodStart, periodEnd].map((date: Moment) => ( + [snippetPeriod.start, snippetPeriod.end].map((day: Day) => ( this.type !== Type.DATETIME - ? date.clone().startOf('day').toDate() - : ( - date.clone() - .set({ hour: 12, minute: 0, second: 0 }) - .toDate() - ) + ? day.toDateTime().toDate() + : day.toDateTime().set('hour', 12).toDate() )) as [Date, Date] ), }; @@ -459,9 +488,15 @@ const Datepicker = defineComponent({ )); }, }, + created() { + warning( + typeof this.readonly === 'boolean' || this.range, + 'The prop `readonly` should be passed as boolean when used with a non-range ``.', + ); + }, mounted() { // - Actualise le timestamp courant toutes les 10 secondes. - this.nowTimer = setInterval(() => { this.now = Date.now(); }, 10_000); + this.nowTimer = setInterval(() => { this.now = DateTime.now(); }, 10_000); }, beforeDestroy() { if (this.nowTimer) { @@ -475,12 +510,15 @@ const Datepicker = defineComponent({ // - // ------------------------------------------------------ - handleInput(newValue: LooseValue) { - const normalizedValue = normalizeValue( + handleInput(newValue: CoreValue) { + if (this.inheritedDisabled) { + return; + } + + const normalizedValue = normalizeCoreValue( newValue, this.type, this.range, - this.withoutMinutes, ); if (this.withFullDaysToggle) { @@ -498,7 +536,7 @@ const Datepicker = defineComponent({ } const newIsFullDays = !this.isFullDays; - const newValue = normalizeValue( + const newValue = convertValueType( this.normalizedValue, newIsFullDays ? Type.DATE : Type.DATETIME, this.range, @@ -529,7 +567,7 @@ const Datepicker = defineComponent({ __(key: string, params?: Record, count?: number): string { key = !key.startsWith('global.') - ? `components.Datepicker.${key}` + ? `components.DatePicker.${key}` : key.replace(/^global\./, ''); return this.$t(key, params, count); @@ -544,18 +582,21 @@ const Datepicker = defineComponent({ snippets, isFullDays, translations, + coreValue, normalizedValue: value, inheritedDisabled: disabled, + normalizedReadonly: readonly, inheritedInvalid: invalid, showTimePanel, displayFormat, - outputFormat, + valueFormat, dateFormat, placeholder, disabledDateFactory, withFullDaysToggle, withoutMinutes, withSnippets, + clearable, timePickerOptions, handleToggleFullDays, handleToggleMode, @@ -570,12 +611,12 @@ const Datepicker = defineComponent({ } return ( -
-