diff --git a/packages/server/src/models/ManualJournal.ts b/packages/server/src/models/ManualJournal.ts index e4380acb8..3d25b377f 100644 --- a/packages/server/src/models/ManualJournal.ts +++ b/packages/server/src/models/ManualJournal.ts @@ -96,7 +96,7 @@ export default class ManualJournal extends mixin(TenantModel, [ static get relationMappings() { const AccountTransaction = require('models/AccountTransaction'); const ManualJournalEntry = require('models/ManualJournalEntry'); - const ManualJournal = require('models/ManualJournal'); + const Document = require('models/Document'); return { entries: { @@ -127,7 +127,7 @@ export default class ManualJournal extends mixin(TenantModel, [ */ attachments: { relation: Model.ManyToManyRelation, - modelClass: ManualJournal.default, + modelClass: Document.default, join: { from: 'manual_journals.id', through: { diff --git a/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx b/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx index 2cec9242f..c37c5d78f 100644 --- a/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx +++ b/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx @@ -1,17 +1,22 @@ // @ts-nocheck import { useRef } from 'react'; import { Button, Intent } from '@blueprintjs/core'; +import clsx from 'classnames'; import { Box, Icon, Stack } from '@/components'; import { Dropzone, DropzoneProps } from '@/components/Dropzone'; import { MIME_TYPES } from '@/components/Dropzone/mine-types'; -import styles from './ImportDropzone.module.css'; import { useUncontrolled } from '@/hooks/useUncontrolled'; +import styles from './ImportDropzone.module.css'; interface ImportDropzoneFieldProps { initialValue?: File; value?: File; onChange?: (file: File) => void; dropzoneProps?: DropzoneProps; + uploadIcon?: JSX.Element; + title?: string; + subtitle?: string; + classNames?: Record; } export function ImportDropzoneField({ @@ -19,6 +24,10 @@ export function ImportDropzoneField({ value, onChange, dropzoneProps, + uploadIcon = , + title = 'Drag images here or click to select files', + subtitle = 'Drag and Drop file here or Choose file', + classNames, }: ImportDropzoneFieldProps) { const [localValue, handleChange] = useUncontrolled({ value, @@ -38,15 +47,18 @@ export function ImportDropzoneField({ onReject={(files) => console.log('rejected files', files)} maxSize={5 * 1024 ** 2} accept={[MIME_TYPES.csv, MIME_TYPES.xls, MIME_TYPES.xlsx]} - classNames={{ content: styles.dropzoneContent }} + classNames={{ root: classNames?.root, content: styles.dropzoneContent }} activateOnClick={false} openRef={openRef} {...dropzoneProps} > - - - - + + {uploadIcon && {uploadIcon}} + {localValue ? (

{localValue.name}

@@ -56,15 +68,10 @@ export function ImportDropzoneField({
) : ( -

- Drag images here or click to select files -

- - Drag and Drop file here or Choose file - + {title &&

{title}

} + {subtitle && {subtitle}}
)} - + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/UploadAttachmentPopoverContent.module.scss b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/UploadAttachmentPopoverContent.module.scss new file mode 100644 index 000000000..6753ccaaa --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/UploadAttachmentPopoverContent.module.scss @@ -0,0 +1,49 @@ + +.content { +} + +.hintText{ + display: flex; + font-size: 12px; + margin-top: 6px; + color: #8F99A8; + justify-content: space-between; +} + +.attachments{ + margin-top: 12px; +} + +.attachmentItem{ + border: 1px solid #D3D8DE; + border-radius: 3px; + padding: 6px 10px; + justify-content: space-between; +} + +.attachmentFilenameText{ + color: #404854; +} + +.attachmentSizeText{ + font-size: 12px; + color: #738091; +} + +.attachmentContent{ + padding-left: 8px; +} + +.attachmentIcon{ + color: #8F99A8; +} + +.label{ + font-size: 12px; + margin-bottom: 4px; +} + +.dropzoneRoot{ + min-height: 140px; + padding: 10px; +} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/UploadAttachmentsPopoverContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/UploadAttachmentsPopoverContent.tsx new file mode 100644 index 000000000..3973cd7ba --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/UploadAttachmentsPopoverContent.tsx @@ -0,0 +1,193 @@ +// @ts-nocheck +import { useState } from 'react'; +import { isEmpty } from 'lodash'; +import { Button, Intent, Text, Spinner } from '@blueprintjs/core'; +import { Box, Group, Icon, Stack } from '@/components'; +import { ImportDropzoneField } from '@/containers/Import/ImportDropzoneFile'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; +import { + useDeleteAttachment, + useUploadAttachments, +} from '@/hooks/query/attachments'; +import { formatBytes } from './utils'; +import styles from './UploadAttachmentPopoverContent.module.scss'; + +interface AttachmentFileCommon { + originName: string; + key: string; + size: number; + mimeType: string; +} +interface AttachmentFileLoaded extends AttachmentFileCommon {} +interface AttachmentFileLoading extends AttachmentFileCommon { + _loading: boolean; +} +type AttachmentFile = AttachmentFileLoaded | AttachmentFileLoading; + +interface UploadAttachmentsPopoverContentProps { + initialValue?: AttachmentFile[]; + value?: AttachmentFile[]; + onChange?: (value: AttachmentFile[]) => void; +} + +/** + * Uploads and list the attachments with ability to delete particular attachment. + * @param {UploadAttachmentsPopoverContentProps} + */ +export function UploadAttachmentsPopoverContent({ + initialValue, + value, + onChange, +}: UploadAttachmentsPopoverContentProps) { + // Controlled/uncontrolled value state. + const [localFiles, handleFilesChange] = useUncontrolled({ + finalValue: [], + initialValue, + value, + onChange: onChange, + }); + // Stops loading of the given attachment key and updates it to new key, + // that came from the server-side after uploading is done. + const stopLoadingAttachment = ( + localFiles: AttachmentFile[], + internalKey: string, + newKey: string, + ) => { + return localFiles.map((localFile) => { + if (localFile.key === internalKey) { + return { + ...localFile, + key: newKey, + _loading: false, + }; + } + return localFile; + }); + }; + // Uploads the attachments. + const { mutateAsync: uploadAttachments } = useUploadAttachments({ + onSuccess: (data, variables, context) => { + const newLocalFiles = stopLoadingAttachment( + localFiles, + data.config.data.get('internalKey'), + data.data.data.key, + ); + handleFilesChange(newLocalFiles); + }, + }); + // Deletes the attachment. + const { mutateAsync: deleteAttachment } = useDeleteAttachment(); + + // Deletes the attachment of the given file key. + const DeleteButton = ({ fileKey }: { fileKey: string }) => { + const [loading, setLoading] = useState(false); + + const handleClick = () => { + setLoading(true); + deleteAttachment(fileKey).then(() => { + const updatedFiles = localFiles.filter( + (file, i) => file.key !== fileKey, + ); + handleFilesChange(updatedFiles); + setLoading(false); + }); + }; + return ( + + ); + }; + // Handle change dropzone. + const handleChangeDropzone = (file: File) => { + const formData = new FormData(); + const key = Date.now().toString(); + + formData.append('file', file); + formData.append('internalKey', key); + + handleFilesChange([ + { + originName: file.name, + size: file.size, + key, + _loading: true, + }, + ...localFiles, + ]); + uploadAttachments(formData); + }; + + return ( +
+
+ Attach documents + + + + Formats: CSV, XLSX + Maximum: 25MB + + + + {!isEmpty(localFiles) && ( + + {localFiles.map((localFile: AttachmentFile, index: number) => ( + + + {localFile._loading ? ( + + ) : ( + + )} + + + {localFile.originName} + + {localFile._loading ? ( + Loading... + ) : ( + + {formatBytes(localFile.size)} + + )} + + + + {!localFile._loading && ( + + + + + )} + + ))} + + )} +
+
+ ); +} diff --git a/packages/webapp/src/hooks/query/attachments.ts b/packages/webapp/src/hooks/query/attachments.ts new file mode 100644 index 000000000..a9a40972c --- /dev/null +++ b/packages/webapp/src/hooks/query/attachments.ts @@ -0,0 +1,31 @@ +// @ts-nocheck +import { useMutation } from 'react-query'; +import useApiRequest from '../useRequest'; + +const commonInvalidateQueries = (query) => { + // Invalidate accounts. +}; + +/** + * Uploads the given attachments. + */ +export function useUploadAttachments(props) { + const apiRequest = useApiRequest(); + + return useMutation( + (values) => apiRequest.post('/attachments', values), + props, + ); +} + +/** + * Deletes the given attachment key. + */ +export function useDeleteAttachment(props) { + const apiRequest = useApiRequest(); + + return useMutation( + (key: string) => apiRequest.delete(`/attachments/${key}`), + props, + ); +} diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index 95cdbd46a..0e6d27c62 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -578,26 +578,30 @@ export default { viewBox: '0 0 20 20', }, done: { - path: [ - 'M395-285 226-455l50-50 119 118 289-288 50 51-339 339Z', - ], - viewBox: '0 -960 960 960' + path: ['M395-285 226-455l50-50 119 118 289-288 50 51-339 339Z'], + viewBox: '0 -960 960 960', }, download: { path: [ - 'M480-336 288-528l51-51 105 105v-342h72v342l105-105 51 51-192 192ZM263.717-192Q234-192 213-213.15T192-264v-72h72v72h432v-72h72v72q0 29.7-21.162 50.85Q725.676-192 695.96-192H263.717Z' + 'M480-336 288-528l51-51 105 105v-342h72v342l105-105 51 51-192 192ZM263.717-192Q234-192 213-213.15T192-264v-72h72v72h432v-72h72v72q0 29.7-21.162 50.85Q725.676-192 695.96-192H263.717Z', ], - viewBox: '0 -960 960 960' + viewBox: '0 -960 960 960', }, 'chevron-up': { path: [ - 'M12.71,9.29l-4-4C8.53,5.11,8.28,5,8,5S7.47,5.11,7.29,5.29l-4,4C3.11,9.47,3,9.72,3,10c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29L8,7.41l3.29,3.29C11.47,10.89,11.72,11,12,11c0.55,0,1-0.45,1-1C13,9.72,12.89,9.47,12.71,9.29' + 'M12.71,9.29l-4-4C8.53,5.11,8.28,5,8,5S7.47,5.11,7.29,5.29l-4,4C3.11,9.47,3,9.72,3,10c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29L8,7.41l3.29,3.29C11.47,10.89,11.72,11,12,11c0.55,0,1-0.45,1-1C13,9.72,12.89,9.47,12.71,9.29', ], viewBox: '0 0 16 16', }, 'chevron-down': { path: [ - 'M12,5c-0.28,0-0.53,0.11-0.71,0.29L8,8.59L4.71,5.29C4.53,5.11,4.28,5,4,5C3.45,5,3,5.45,3,6c0,0.28,0.11,0.53,0.29,0.71l4,4C7.47,10.89,7.72,11,8,11s0.53-0.11,0.71-0.29l4-4C12.89,6.53,13,6.28,13,6C13,5.45,12.55,5,12,5z' + 'M12,5c-0.28,0-0.53,0.11-0.71,0.29L8,8.59L4.71,5.29C4.53,5.11,4.28,5,4,5C3.45,5,3,5.45,3,6c0,0.28,0.11,0.53,0.29,0.71l4,4C7.47,10.89,7.72,11,8,11s0.53-0.11,0.71-0.29l4-4C12.89,6.53,13,6.28,13,6C13,5.45,12.55,5,12,5z', + ], + viewBox: '0 0 16 16', + }, + media: { + path: [ + 'M11.99,6.99c0.55,0,1-0.45,1-1s-0.45-1-1-1s-1,0.45-1,1S11.44,6.99,11.99,6.99zM14.99,1.99h-14c-0.55,0-1,0.45-1,1v10c0,0.55,0.45,1,1,1h14c0.55,0,1-0.45,1-1v-10C15.99,2.44,15.54,1.99,14.99,1.99zM13.99,10.99l-5-3l-1,2l-3-4l-3,5v-7h12V10.99z', ], viewBox: '0 0 16 16', },