diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4e208be..0a4bfe4f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Ce projet adhère au principe du [Semantic Versioning](https://semver.org/spec/v - Améliore le calcul du matériel restant dans les événements. - Ajoute la possibilité de limiter les caractéristiques spéciales du matériel par catégorie (#91). - Ajoute le type "date" aux caractéristiques spéciales du matériel (#90). +- Permet l'envoi de documents (fichiers PDF, images JPEG ou PNG) associés à du matériel (#92). ## 0.11.0 (2021-01-14) diff --git a/client/src/components/Alert/index.js b/client/src/components/Alert/index.js index 56fdb24a0..b4f90e854 100644 --- a/client/src/components/Alert/index.js +++ b/client/src/components/Alert/index.js @@ -5,9 +5,11 @@ const ConfirmDelete = ($t, entityName, isSoft = true) => Swal.fire({ text: isSoft ? $t(`page-${entityName}.confirm-delete`) : $t(`page-${entityName}.confirm-permanently-delete`), - type: 'warning', + icon: 'warning', showCancelButton: true, - confirmButtonClass: isSoft ? 'swal2-confirm--trash' : 'swal2-confirm--delete', + customClass: { + confirmButton: isSoft ? 'swal2-confirm--trash' : 'swal2-confirm--delete', + }, confirmButtonText: isSoft ? $t('yes-delete') : $t('yes-permanently-delete'), cancelButtonText: $t('cancel'), }); @@ -15,9 +17,11 @@ const ConfirmDelete = ($t, entityName, isSoft = true) => Swal.fire({ const ConfirmRestore = ($t, entityName) => Swal.fire({ title: $t('please-confirm'), text: $t(`page-${entityName}.confirm-restore`), - type: 'warning', + icon: 'warning', showCancelButton: true, - confirmButtonClass: 'swal2-confirm--restore', + customClass: { + confirmButton: 'swal2-confirm--restore', + }, confirmButtonText: $t('yes-restore'), cancelButtonText: $t('cancel'), }); @@ -28,7 +32,9 @@ const Prompt = ($t, title, placeholder, confirmText, inputValue = '') => Swal.fi inputPlaceholder: $t(placeholder), inputValue, showCancelButton: true, - confirmButtonClass: 'swal2-confirm--success', + customClass: { + confirmButton: 'swal2-confirm--success', + }, confirmButtonText: $t(confirmText), cancelButtonText: $t('cancel'), }); diff --git a/client/src/components/Progressbar/Progressbar.scss b/client/src/components/Progressbar/Progressbar.scss new file mode 100644 index 000000000..3c364bc63 --- /dev/null +++ b/client/src/components/Progressbar/Progressbar.scss @@ -0,0 +1,18 @@ +.Progressbar { + position: relative; + height: 20px; + width: 100%; + background: $bg-color-tooltip; + border-radius: $input-border-radius; + + &__progress { + position: absolute; + left: 0; + top: 0; + bottom: 0; + background: $bg-color-button-info; + border-radius: $input-border-radius; + text-align: center; + overflow: hidden; + } +} diff --git a/client/src/components/Progressbar/Progressbar.vue b/client/src/components/Progressbar/Progressbar.vue new file mode 100644 index 000000000..da337665a --- /dev/null +++ b/client/src/components/Progressbar/Progressbar.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/client/src/config/constants.js b/client/src/config/constants.js index b1f53720f..a5f8ecc4e 100644 --- a/client/src/config/constants.js +++ b/client/src/config/constants.js @@ -2,4 +2,28 @@ const DATE_DB_FORMAT = 'YYYY-MM-DD HH:mm:ss'; const DATE_QUERY_FORMAT = 'YYYY-MM-DD'; const DEBOUNCE_WAIT = 500; // - in miliseconds -export { DATE_DB_FORMAT, DATE_QUERY_FORMAT, DEBOUNCE_WAIT }; +const AUTHORIZED_FILE_TYPES = [ + 'application/pdf', + 'application/zip', + 'application/x-rar-compressed', + 'image/jpeg', + 'image/png', + 'image/webp', + 'text/plain', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.text', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + +export { + DATE_DB_FORMAT, + DATE_QUERY_FORMAT, + DEBOUNCE_WAIT, + AUTHORIZED_FILE_TYPES, + MAX_FILE_SIZE, +}; diff --git a/client/src/locale/en/common.js b/client/src/locale/en/common.js index a24aea8a8..22ec2fb44 100644 --- a/client/src/locale/en/common.js +++ b/client/src/locale/en/common.js @@ -28,6 +28,7 @@ export default { 'close': "Close", 'copy-to-clipboard': "Copy to clipboard", 'copied-in-clipboard': "Copied in clipboard!", + 'almost-done': "Almost done...", 'please-choose': "Please choose...", 'start-typing-to-search': "Start typing to search...", @@ -53,6 +54,7 @@ export default { 'personnal-infos': "Personnal informations", 'minimal-infos': "Minimal informations", 'billing-infos': "Billing informations", + 'documents': "Documents", 'billing': "Billing", 'extra-infos': "Extra informations", 'special-attributes': "Special attributes", diff --git a/client/src/locale/en/errors.js b/client/src/locale/en/errors.js index 306068312..68a8d20cb 100644 --- a/client/src/locale/en/errors.js +++ b/client/src/locale/en/errors.js @@ -17,6 +17,10 @@ export default { 'details-message': "Error message", 'details-file': "File:", 'details-stacktrace': "Stack trace:", + + 'file-type-not-allowed': "Type '{type}' not supported.", + 'file-size-exceeded': "File too large. Maximum {max}.", + 'file-already-exists': "This file already exists in the list.", }, }; /* eslint-enable quotes */ diff --git a/client/src/locale/en/pages.js b/client/src/locale/en/pages.js index 071e15e5f..44ee842f8 100644 --- a/client/src/locale/en/pages.js +++ b/client/src/locale/en/pages.js @@ -146,6 +146,22 @@ export default { 'clear-filters': "Clear filters", }, + 'page-materials-view': { + 'documents': { + 'no-document': "No document yet.", + 'drag-and-drop-files-here': "Drag and drop files here ↓ to add them.", + 'choose-files': "Or click here to choose files to add", + 'send-files': [ + "Send file", + "Send {count} files", + ], + 'click-to-open': "Click to open / download file", + 'confirm-permanently-delete': "Do you really want to permanently delete this document?", + 'saved': "Documents saved.", + 'deleted': "Document deleted.", + }, + }, + 'page-attributes': { 'title': "Material special attributes", 'help': "Here you can check, and add fields that allows you to describe your material according to your own criteria.", diff --git a/client/src/locale/fr/common.js b/client/src/locale/fr/common.js index cbc99c66b..cb351c0f2 100644 --- a/client/src/locale/fr/common.js +++ b/client/src/locale/fr/common.js @@ -28,6 +28,7 @@ export default { 'close': "Fermer", 'copy-to-clipboard': "Copier dans le presse-papier", 'copied-in-clipboard': "Copié dans le presse-papier\u00a0!", + 'almost-done': "Presque terminé...", 'please-choose': "Veuillez choisir...", 'start-typing-to-search': "Commencez à écrire pour rechercher...", @@ -54,6 +55,7 @@ export default { 'minimal-infos': "Informations minimales", 'extra-infos': "Informations supplémentaires", 'billing-infos': "Informations de facturation", + 'documents': "Documents", 'billing': "Facturation", 'special-attributes': "Caractéristiques spéciales", 'pseudo': "Pseudo", diff --git a/client/src/locale/fr/errors.js b/client/src/locale/fr/errors.js index 10b1502ba..42240cba8 100644 --- a/client/src/locale/fr/errors.js +++ b/client/src/locale/fr/errors.js @@ -17,6 +17,10 @@ export default { 'details-message': "Message de l'erreur", 'details-file': "Fichier\u00a0:", 'details-stacktrace': "Trace de la pile\u00a0:", + + 'file-type-not-allowed': "Le type '{type}' n'est pas pris en charge.", + 'file-size-exceeded': "Fichier trop gros. Maximum {max}.", + 'file-already-exists': "Ce fichier est déjà présent dans la liste.", }, }; /* eslint-enable quotes */ diff --git a/client/src/locale/fr/pages.js b/client/src/locale/fr/pages.js index 653e62fee..4d537c6b7 100644 --- a/client/src/locale/fr/pages.js +++ b/client/src/locale/fr/pages.js @@ -146,6 +146,22 @@ export default { 'clear-filters': "Réinitialiser les filtres", }, + 'page-materials-view': { + 'documents': { + 'no-document': "Aucun document pour le moment.", + 'drag-and-drop-files-here': "Glissez-déposez des fichiers ici ↓ pour les ajouter.", + 'choose-files': "Ou cliquez ici pour choisir des fichiers à ajouter", + 'send-files': [ + "Envoyer le fichier", + "Envoyer {count} fichiers", + ], + 'click-to-open': "Cliquez pour ouvrir / télécharger le fichier", + 'confirm-permanently-delete': "Voulez-vous vraiment supprimer définitivement ce document\u00a0?", + 'saved': "Documents sauvegardés.", + 'deleted': "Document supprimé.", + }, + }, + 'page-attributes': { 'title': "Caractéristiques spéciales du matériel", 'help': "Ici vous pouvez consulter, et ajouter les champs qui permettent de décrire votre matériel selon vos propres critères.", diff --git a/client/src/pages/MaterialView/Documents/Documents.scss b/client/src/pages/MaterialView/Documents/Documents.scss new file mode 100644 index 000000000..880bb9ee8 --- /dev/null +++ b/client/src/pages/MaterialView/Documents/Documents.scss @@ -0,0 +1,32 @@ +.MaterialViewDocuments { + height: calc(100% - 10px); + display: flex; + flex-wrap: wrap; + + &__main { + flex: 1; + min-width: 300px; + + .Help { + margin-top: $content-padding-large-vertical; + text-align: center; + } + } + + &__no-document { + margin: 30px 0 0; + text-align: center; + font-size: 1.2rem; + color: $text-light-color; + font-style: italic; + } + + &__list { + margin: 0; + padding: 0 $content-padding-small-vertical 0 0; + } + + .MaterialViewDocumentsUpload { + flex: 1; + } +} diff --git a/client/src/pages/MaterialView/Documents/Documents.vue b/client/src/pages/MaterialView/Documents/Documents.vue new file mode 100644 index 000000000..50f2106ab --- /dev/null +++ b/client/src/pages/MaterialView/Documents/Documents.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/client/src/pages/MaterialView/Documents/Item/Item.scss b/client/src/pages/MaterialView/Documents/Item/Item.scss new file mode 100644 index 000000000..add0e8023 --- /dev/null +++ b/client/src/pages/MaterialView/Documents/Item/Item.scss @@ -0,0 +1,61 @@ +.MaterialViewDocumentsItem { + display: flex; + align-items: center; + list-style: none; + margin: 0 0 10px; + padding: 0; + background: $bg-color-emphasis; + color: $text-base-color; + border-radius: $input-border-radius; + + &__link, + &__no-link { + display: block; + padding: $content-padding-small-vertical; + } + + &__icon { + flex: 0 0 auto; + font-size: 1.8rem; + + .MaterialViewDocumentsItem__link, + .MaterialViewDocumentsItem__no-link { + margin-left: $content-padding-small-vertical; + } + } + + &__name { + flex: 1; + } + + &__size { + flex: 0 0 auto; + color: $text-light-color; + margin-right: $content-padding-small-vertical; + } + + &__actions { + flex: 0 0 auto; + padding-right: $content-padding-small-vertical; + } + + &--with-link { + .MaterialViewDocumentsItem { + &__link, + &__no-link { + color: $text-base-color; + } + } + + &:hover { + background-color: lighten($bg-color-emphasis, 5%); + + .MaterialViewDocumentsItem { + &__link, + &__no-link { + color: $link-hover-color; + } + } + } + } +} diff --git a/client/src/pages/MaterialView/Documents/Item/Item.vue b/client/src/pages/MaterialView/Documents/Item/Item.vue new file mode 100644 index 000000000..87c225d8b --- /dev/null +++ b/client/src/pages/MaterialView/Documents/Item/Item.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/client/src/pages/MaterialView/Documents/Item/index.js b/client/src/pages/MaterialView/Documents/Item/index.js new file mode 100644 index 000000000..23b59ac96 --- /dev/null +++ b/client/src/pages/MaterialView/Documents/Item/index.js @@ -0,0 +1,57 @@ +import Config from '@/config/globalConfig'; +import formatBytes from '@/utils/formatBytes'; +import hasIncludes from '@/utils/hasIncludes'; + +export default { + name: 'MaterialViewDocumentsItem', + props: { + file: { type: [File, Object], required: true }, + }, + computed: { + fileSize() { + return formatBytes(this.file.size); + }, + + fileUrl() { + const { baseUrl } = Config; + return `${baseUrl}/documents/${this.file.id}/download`; + }, + + iconName() { + const { type } = this.file; + if (type === 'application/pdf') { + return 'fa-file-pdf'; + } + if (type.startsWith('image/')) { + return 'fa-file-image'; + } + if (type.startsWith('video/')) { + return 'fa-file-video'; + } + if (type.startsWith('audio/')) { + return 'fa-file-audio'; + } + if (type.startsWith('text/')) { + return 'fa-file-alt'; + } + if (hasIncludes(type, ['zip', 'octet-stream', 'x-rar', 'x-tar', 'x-7z'])) { + return 'fa-file-archive'; + } + if (hasIncludes(type, ['sheet', 'excel'])) { + return 'fa-file-excel'; + } + if (hasIncludes(type, ['wordprocessingml.document', 'msword'])) { + return 'fa-file-word'; + } + if (hasIncludes(type, ['presentation', 'powerpoint'])) { + return 'fa-file-powerpoint'; + } + return 'fa-file'; + }, + }, + methods: { + handleClickRemove() { + this.$emit('remove', this.file); + }, + }, +}; diff --git a/client/src/pages/MaterialView/Documents/Upload/Upload.scss b/client/src/pages/MaterialView/Documents/Upload/Upload.scss new file mode 100644 index 000000000..16b6bddf8 --- /dev/null +++ b/client/src/pages/MaterialView/Documents/Upload/Upload.scss @@ -0,0 +1,60 @@ +.MaterialViewDocumentsUpload { + min-width: 300px; + display: flex; + flex-direction: column; + align-items: center; + border-left: 1px solid $tabs-bottom-border-color; + background-color: transparent; + transition: background-color 300ms ease-out; + + &__title { + flex: 0 0 auto; + margin: 30px 0 15px; + font-size: 1.2rem; + } + + &__choose-files { + margin: 15px 0; + } + + &__send-list { + flex: 1; + width: 100%; + margin: 0; + padding: $content-padding-small-horizontal $content-padding-small-vertical; + } + + &__file-errors { + flex: 1; + margin: $content-padding-large-horizontal 0; + padding: 0 $content-padding-small-vertical; + + &__item { + list-style: none; + color: $text-danger-color; + padding: $content-padding-small-horizontal 0; + } + } + + &__actions { + flex: 1; + margin: 20px 0; + text-align: center; + + &__file-input { + display: none; + } + + .Help { + margin: $content-padding-large-horizontal 0; + } + } + + .Progressbar { + margin-left: $content-padding-large-vertical; + } + + &--drag-over { + background-color: rgba($color-active-button, .15); + } +} diff --git a/client/src/pages/MaterialView/Documents/Upload/Upload.vue b/client/src/pages/MaterialView/Documents/Upload/Upload.vue new file mode 100644 index 000000000..4e9c0ab48 --- /dev/null +++ b/client/src/pages/MaterialView/Documents/Upload/Upload.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/client/src/pages/MaterialView/Documents/Upload/index.js b/client/src/pages/MaterialView/Documents/Upload/index.js new file mode 100644 index 000000000..fe66ed6cd --- /dev/null +++ b/client/src/pages/MaterialView/Documents/Upload/index.js @@ -0,0 +1,131 @@ +import { AUTHORIZED_FILE_TYPES, MAX_FILE_SIZE } from '@/config/constants'; +import formatBytes from '@/utils/formatBytes'; +import Help from '@/components/Help/Help.vue'; +import Progressbar from '@/components/Progressbar/Progressbar.vue'; +import DocumentItem from '../Item/Item.vue'; + +export default { + name: 'MaterialViewDocumentsUpload', + components: { Help, DocumentItem, Progressbar }, + props: { + materialId: { type: String, required: true }, + }, + data() { + return { + error: null, + fileErrors: [], + isDragging: false, + isLoading: false, + files: [], + uploadProgress: 0, + }; + }, + methods: { + openFilesBrowser() { + const fileInput = this.$refs.chooseFilesButton; + fileInput.click(); + }, + + handleDragover() { + this.isDragging = true; + }, + + handleDragleave() { + this.isDragging = false; + }, + + checkFile(file) { + const { type, size, name } = file; + + if (!AUTHORIZED_FILE_TYPES.includes(type)) { + this.fileErrors.push({ + fileName: name, + message: this.$t('errors.file-type-not-allowed', { type }), + }); + return false; + } + + if (size > MAX_FILE_SIZE) { + this.fileErrors.push({ + fileName: name, + message: this.$t('errors.file-size-exceeded', { max: formatBytes(MAX_FILE_SIZE) }), + }); + return false; + } + + const fileExists = this.files.some( + ({ name: existingName }) => existingName === name, + ); + if (fileExists) { + this.fileErrors.push({ + fileName: name, + message: this.$t('errors.file-already-exists'), + }); + return false; + } + + return true; + }, + + addFiles(event) { + event.preventDefault(); + this.isDragging = false; + this.fileErrors = []; + this.error = null; + + const files = event.dataTransfer ? event.dataTransfer.files : event.target.files; + if (!files || files.length === 0) { + return; + } + + const newFiles = [...files].filter(this.checkFile); + + this.files = [...this.files, ...newFiles].sort((a, b) => { + const nameA = a.name.toLowerCase(); + const nameB = b.name.toLowerCase(); + if (nameA < nameB) return -1; + return nameA > nameB ? 1 : 0; + }); + }, + + removeFile(file) { + this.fileErrors = []; + this.files = this.files.filter(({ name }) => name !== file.name); + }, + + uploadFiles() { + this.fileErrors = []; + this.error = null; + this.isLoading = true; + this.uploadProgress = 0; + + const formData = new FormData(); + this.files.forEach((file, index) => { + formData.append(`file-${index}`, file); + }); + + const onUploadProgress = (event) => { + if (!event.lengthComputable) { + return; + } + + const { loaded, total } = event; + this.uploadProgress = (loaded / total) * 100; + }; + + this.$http.post(`materials/${this.materialId}/documents`, formData, { onUploadProgress }) + .then(() => { + this.isLoading = false; + this.files = []; + this.$emit('uploadSuccess'); + this.uploadProgress = 0; + }) + .catch(this.displayError); + }, + + displayError(error) { + this.error = error; + this.isLoading = false; + }, + }, +}; diff --git a/client/src/pages/MaterialView/Documents/index.js b/client/src/pages/MaterialView/Documents/index.js new file mode 100644 index 000000000..6c4fd99e5 --- /dev/null +++ b/client/src/pages/MaterialView/Documents/index.js @@ -0,0 +1,66 @@ +import Alert from '@/components/Alert'; +import Help from '@/components/Help/Help.vue'; +import DocumentItem from './Item/Item.vue'; +import DocumentUpload from './Upload/Upload.vue'; + +export default { + name: 'MaterialViewDocuments', + components: { Help, DocumentItem, DocumentUpload }, + data() { + return { + help: '', + error: null, + isLoading: false, + materialId: this.$route.params.id, + documents: [], + }; + }, + mounted() { + this.fetchDocuments(); + }, + methods: { + fetchDocuments() { + this.isLoading = true; + this.error = null; + + this.$http.get(`materials/${this.materialId}/documents`) + .then(({ data }) => { + this.documents = data; + this.isLoading = false; + }) + .catch(this.displayError); + }, + + handleUploadSuccess() { + this.help = { type: 'success', text: 'page-materials-view.documents.saved' }; + this.fetchDocuments(); + }, + + removeDocument(file) { + this.help = ''; + this.error = null; + + Alert.ConfirmDelete(this.$t, 'materials-view.documents', false) + .then(({ value }) => { + if (!value) { + return; + } + + this.isLoading = true; + + this.$http.delete(`documents/${file.id}`) + .then(() => { + this.isLoading = false; + this.help = { type: 'success', text: 'page-materials-view.documents.deleted' }; + this.fetchDocuments(); + }) + .catch(this.displayError); + }); + }, + + displayError(error) { + this.error = error; + this.isLoading = false; + }, + }, +}; diff --git a/client/src/pages/MaterialView/Infos/Infos.scss b/client/src/pages/MaterialView/Infos/Infos.scss index 9a04df2b4..312f1f4e1 100644 --- a/client/src/pages/MaterialView/Infos/Infos.scss +++ b/client/src/pages/MaterialView/Infos/Infos.scss @@ -1,6 +1,7 @@ .MaterialViewInfos { display: flex; flex-wrap: wrap; + padding-bottom: 6rem; &__main { flex: 2; diff --git a/client/src/pages/MaterialView/MaterialView.scss b/client/src/pages/MaterialView/MaterialView.scss index 5008bdf89..f4f7bcf05 100644 --- a/client/src/pages/MaterialView/MaterialView.scss +++ b/client/src/pages/MaterialView/MaterialView.scss @@ -1,8 +1,22 @@ .MaterialView { - color: $text-base-color; - padding-bottom: 6rem; + height: 100%; - .vue-tablist { + > .Help { margin-bottom: 1rem; } + + &__body { + height: 100%; + display: flex; + flex-direction: column; + color: $text-base-color; + + .vue-tablist { + margin-bottom: 1rem; + } + + .vue-tabpanel { + flex: 1; + } + } } diff --git a/client/src/pages/MaterialView/MaterialView.vue b/client/src/pages/MaterialView/MaterialView.vue index c9d1064b4..2ac566cc7 100644 --- a/client/src/pages/MaterialView/MaterialView.vue +++ b/client/src/pages/MaterialView/MaterialView.vue @@ -1,19 +1,30 @@