feat: Uploading company logo

This commit is contained in:
Ahmed Bouhuolia
2024-09-24 20:28:19 +02:00
parent d16c57b63b
commit 37fd4a1fdb
20 changed files with 404 additions and 171 deletions

View File

@@ -1,12 +1,17 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { GetPdfTemplateTransformer } from './GetPdfTemplateTransformer';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@Service()
export class GetPdfTemplate {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable
/**
* Retrieves a pdf template by its ID.
* @param {number} tenantId - The ID of the tenant.
@@ -24,6 +29,10 @@ export class GetPdfTemplate {
.findById(templateId)
.throwIfNotFound();
return template;
return this.transformer.transform(
tenantId,
template,
new GetPdfTemplateTransformer()
);
}
}

View File

@@ -0,0 +1,62 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { getTransactionTypeLabel } from '@/utils/transactions-types';
export class GetPdfTemplateTransformer extends Transformer {
/**
* Includeded attributes.
* @returns {string[]}
*/
public includeAttributes = (): string[] => {
return ['createdAtFormatted', 'resourceFormatted', 'attributes'];
};
/**
* Formats the creation date of the PDF template.
* @param {Object} template
* @returns {string} A formatted string representing the creation date of the template.
*/
protected createdAtFormatted = (template) => {
return this.formatDate(template.createdAt);
};
/**
* Formats the creation date of the PDF template.
* @param {Object} template -
* @returns {string} A formatted string representing the creation date of the template.
*/
protected resourceFormatted = (template) => {
return getTransactionTypeLabel(template.resource);
};
/**
* Retrieves transformed brand attributes.
* @param {} template
* @returns
*/
protected attributes = (template) => {
return this.item(
template.attributes,
new GetPdfTemplateAttributesTransformer()
);
};
}
class GetPdfTemplateAttributesTransformer extends Transformer {
/**
* Includeded attributes.
* @returns {string[]}
*/
public includeAttributes = (): string[] => {
return ['companyLogoUri'];
};
/**
* Retrieves the company logo uri.
* @returns {string}
*/
protected companyLogoUri(template) {
return template.companyLogoKey
? `https://bigcapital.sfo3.digitaloceanspaces.com/${template.companyLogoKey}`
: '';
}
}

View File

@@ -19,6 +19,7 @@ import {
import { FormikHelpers } from 'formik';
import { BrandingTemplateValues } from './types';
import { useUploadAttachments } from '@/hooks/query/attachments';
import { excludePrivateProps } from '@/utils';
interface BrandingTemplateFormProps<T> extends ElementCustomizeProps<T> {
resource: string;
@@ -41,7 +42,7 @@ export function BrandingTemplateForm<T extends BrandingTemplateValues>({
const initialValues = useBrandingTemplateFormInitialValues<T>(defaultValues);
const [isUploading, setIsLoading] = useState<boolean>(false);
// Uploads the attachments.
const { mutateAsync: uploadAttachments } = useUploadAttachments({
onSuccess: () => {
@@ -53,38 +54,53 @@ export function BrandingTemplateForm<T extends BrandingTemplateValues>({
// - Push the updated data.
const handleFormSubmit = async (
values: T,
{ setSubmitting }: FormikHelpers<T>,
{ setSubmitting, setFieldValue }: FormikHelpers<T>,
) => {
const _values = { ...values };
// Handle create/edit request success.
const handleSuccess = (message: string) => {
AppToaster.show({ intent: Intent.SUCCESS, message });
setSubmitting(false);
onSuccess && onSuccess();
};
// Handle create/edit request error.
const handleError = (message: string) => {
AppToaster.show({ intent: Intent.DANGER, message });
setSubmitting(false);
onError && onError();
};
if (values.companyLogoFile) {
isUploading(true);
// Start upload the company logo file if it is presented.
if (values._companyLogoFile) {
setIsLoading(true);
const formData = new FormData();
const key = Date.now().toString();
formData.append('file', values.companyLogoFile);
formData.append('file', values._companyLogoFile);
formData.append('internalKey', key);
try {
await uploadAttachments(formData);
const uploadedAttachmentRes = await uploadAttachments(formData);
setIsLoading(false);
// Adds the attachment key to the values after finishing upload.
_values['companyLogoKey'] = uploadedAttachmentRes?.key;
} catch {
handleError('An error occurred while uploading company logo.');
setIsLoading(false);
return;
}
}
// Exclude all the private props that starts with _.
const excludedPrivateValues = excludePrivateProps(_values);
// Transform the the form values to request based on the mode (new or edit mode).
const reqValues = templateId
? transformToEditRequest(excludedPrivateValues, initialValues)
: transformToNewRequest(excludedPrivateValues, initialValues, resource);
// Template id is presented means edit mode.
if (templateId) {
const reqValues = transformToEditRequest(values);
setSubmitting(true);
try {
@@ -94,7 +110,6 @@ export function BrandingTemplateForm<T extends BrandingTemplateValues>({
handleError('An error occurred while updating the PDF template.');
}
} else {
const reqValues = transformToNewRequest(values, resource);
setSubmitting(true);
try {
@@ -119,3 +134,8 @@ export function BrandingTemplateForm<T extends BrandingTemplateValues>({
export const validationSchema = Yup.object().shape({
templateName: Yup.string().required('Template Name is required'),
});
// Initial values - companyLogoKey, companyLogoUri
// Form - _companyLogoFile, companyLogoKey, companyLogoUri
// Request - companyLogoKey

View File

@@ -7,26 +7,35 @@ import {
import { useBrandingTemplateBoot } from './BrandingTemplateBoot';
import { transformToForm } from '@/utils';
import { BrandingTemplateValues } from './types';
import { useFormikContext } from 'formik';
import { DRAWERS } from '@/constants/drawers';
const commonExcludedAttrs = ['templateName', 'companyLogoUri'];
export const transformToEditRequest = <T extends BrandingTemplateValues>(
values: T,
defaultValues: T,
): EditPdfTemplateValues => {
return {
templateName: values.templateName,
attributes: omit(values, ['templateName']),
attributes: transformToForm(
omit(values, commonExcludedAttrs),
defaultValues,
),
};
};
export const transformToNewRequest = <T extends BrandingTemplateValues>(
values: T,
defaultValues: T,
resource: string,
): CreatePdfTemplateValues => {
return {
resource,
templateName: values.templateName,
attributes: omit(values, ['templateName']),
attributes: transformToForm(
omit(values, commonExcludedAttrs),
defaultValues,
),
};
};
@@ -66,5 +75,5 @@ export const getButtonLabelFromResource = (resource: string) => {
CreditNote: 'Create Credit Note Branding',
PaymentReceive: 'Create Payment Branding',
};
return R.prop(resource, pairs) || 'Create Branding Template';
}
return R.prop(resource, pairs) || 'Create Branding Template';
};

View File

@@ -2,4 +2,7 @@
export interface BrandingTemplateValues {
templateName: string;
companyLogoKey?: string;
companyLogoUri?: string;
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Group } from '@/components';
import { Group } from '@/components';
import { ElementCustomizeProvider } from './ElementCustomizeProvider';
import {
ElementCustomizeForm,

View File

@@ -0,0 +1,22 @@
// @ts-nocheck
import { useFormikContext } from 'formik';
import { FFormGroup } from '@/components';
import { CompanyLogoUpload } from './CompanyLogoUpload';
export function BrandingCompanyLogoUploadField() {
const { setFieldValue, values } = useFormikContext();
return (
<FFormGroup name={'companyLogo'} label={''} fastField>
<CompanyLogoUpload
initialPreview={values.companyLogoUri}
onChange={(file) => {
const imageUrl = file ? URL.createObjectURL(file) : '';
setFieldValue('_companyLogoFile', file);
setFieldValue('companyLogoUri', imageUrl);
}}
/>
</FFormGroup>
);
}

View File

@@ -0,0 +1,43 @@
.root {
min-height: 120px;
height: 120px;
padding: 10px;
border: 1px solid;
display: flex;
border: 1px solid #E1E1E1;
position: relative;
display: flex;
justify-content: center;
&:hover .removeButton{
visibility: visible;
opacity: 1;
}
}
.removeButton{
position: absolute;
right: 5px;
top: 5px;
border-radius: 24px;
visibility: hidden;
opacity: 0;
}
.contentPrePreview {
color: #738091;
font-size: 13px;
height: 100%;
justify-content: center;
}
.dropzoneContent{
height: 100%;
width: 100%;
text-align: center;
}
.previewImage{
max-width: 100%;
max-height: 100%;
}

View File

@@ -0,0 +1,105 @@
// @ts-nocheck
import { useRef, useState } from 'react';
import { Button, Intent } from '@blueprintjs/core';
import { Icon, Stack } from '@/components';
import { Dropzone, DropzoneProps } from '@/components/Dropzone';
import { MIME_TYPES } from '@/components/Dropzone/mine-types';
import { useUncontrolled } from '@/hooks/useUncontrolled';
import styles from './CompanyLogoUpload.module.scss';
export interface CompanyLogoUploadProps {
/** Initial preview uri. */
initialPreview?: string;
/** The initial file object for uploading */
initialValue?: File;
/** The current file object for uploading */
value?: File;
/** Function called when the file is changed */
onChange?: (file: File) => void;
/** Props for the Dropzone component */
dropzoneProps?: DropzoneProps;
/** Icon element for the upload button */
uploadIcon?: JSX.Element;
/** Title displayed in the component */
title?: string;
/** Custom CSS class names for styling */
classNames?: Record<string, string>;
}
export function CompanyLogoUpload({
initialPreview,
initialValue,
value,
onChange,
dropzoneProps,
uploadIcon = <Icon icon="download" iconSize={26} />,
title = 'Drag images here or click to select files',
classNames,
}: CompanyLogoUploadProps) {
const [localValue, handleChange] = useUncontrolled<File | null>({
value,
initialValue,
finalValue: null,
onChange,
});
const [initialLocalPreview, setInitialLocalPreview] = useState<string | null>(
initialPreview || null,
);
const openRef = useRef<() => void>(null);
const handleRemove = () => {
handleChange(null);
setInitialLocalPreview(null);
};
const imagePreviewUrl = localValue
? URL.createObjectURL(localValue)
: initialLocalPreview || '';
return (
<Dropzone
onDrop={(files) => handleChange(files[0])}
onReject={(files) => console.log('rejected files', files)}
maxSize={5 * 1024 ** 2}
accept={[MIME_TYPES.png, MIME_TYPES.jpeg]}
classNames={{ root: styles?.root, content: styles.dropzoneContent }}
activateOnClick={false}
openRef={openRef}
{...dropzoneProps}
>
{imagePreviewUrl ? (
<span>
<img src={imagePreviewUrl} alt="" className={styles.previewImage} />
<Button
minimal
intent={Intent.DANGER}
onClick={handleRemove}
icon={<Icon icon={'smallCross'} iconSize={16} />}
className={styles?.removeButton}
/>
</span>
) : (
<Stack spacing={10} align="center" className={styles.contentPrePreview}>
{title && <span className={styles.title}>{title}</span>}
<Button
intent="none"
onClick={() => openRef.current?.()}
style={{ pointerEvents: 'all' }}
minimal
outlined
small
>
{'Upload File'}
</Button>
</Stack>
)}
</Dropzone>
);
}

View File

@@ -10,6 +10,7 @@ import {
import { FColorInput } from '@/components/Forms/FColorInput';
import { Overlay } from '../../Invoices/InvoiceCustomize/Overlay';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { BrandingCompanyLogoUploadField } from '@/containers/ElementCustomize/components/BrandingCompanyLogoUploadField';
export function CreditNoteCustomizeGeneralField() {
const isTemplateNameFilled = useIsTemplateNamedFilled();
@@ -64,15 +65,23 @@ export function CreditNoteCustomizeGeneralField() {
/>
</FFormGroup>
<FFormGroup name={'showCompanyLogo'} label={'Logo'} fastField>
<FSwitch
<Stack spacing={10}>
<FFormGroup
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
large
label={'Logo'}
fastField
/>
</FFormGroup>
style={{ marginBottom: 0 }}
>
<FSwitch
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
fastField
/>
</FFormGroup>
<BrandingCompanyLogoUploadField />
</Stack>
</Stack>
</Overlay>
</Stack>

View File

@@ -1,16 +1,16 @@
// @ts-nocheck
import { Classes, Text } from '@blueprintjs/core';
import { Classes } from '@blueprintjs/core';
import {
FFormGroup,
FInputGroup,
FSwitch,
FieldRequiredHint,
Group,
Stack,
} from '@/components';
import { FColorInput } from '@/components/Forms/FColorInput';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { Overlay } from '../../Invoices/InvoiceCustomize/Overlay';
import { BrandingCompanyLogoUploadField } from '@/containers/ElementCustomize/components/BrandingCompanyLogoUploadField';
export function EstimateCustomizeGeneralField() {
const isTemplateNameFilled = useIsTemplateNamedFilled();
@@ -65,15 +65,24 @@ export function EstimateCustomizeGeneralField() {
/>
</FFormGroup>
<FFormGroup name={'showCompanyLogo'} label={'Logo'} fastField>
<FSwitch
<Stack spacing={10}>
<FFormGroup
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
large
label={'Logo'}
fastField
/>
</FFormGroup>
style={{ marginBottom: 0 }}
>
<FSwitch
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
large
fastField
/>
</FFormGroup>
<BrandingCompanyLogoUploadField />
</Stack>
</Stack>
</Overlay>
</Stack>

View File

@@ -1,88 +0,0 @@
// @ts-nocheck
import { useRef, useState } 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 { useUncontrolled } from '@/hooks/useUncontrolled';
import styles from './CompanyLogoUpload.module.scss';
export interface CompanyLogoUploadProps {
initialValue?: File;
value?: File;
onChange?: (file: File) => void;
dropzoneProps?: DropzoneProps;
uploadIcon?: JSX.Element;
title?: string;
classNames?: Record<string, string>;
}
export function CompanyLogoUpload({
initialValue,
value,
onChange,
dropzoneProps,
uploadIcon = <Icon icon="download" iconSize={26} />,
title = 'Drag images here or click to select files',
classNames,
}: CompanyLogoUploadProps) {
const [localValue, handleChange] = useUncontrolled({
value,
initialValue,
finalValue: null,
onChange,
});
const openRef = useRef<() => void>(null);
const handleRemove = () => {
handleChange(null);
};
const imagePreviewUrl = localValue ? URL.createObjectURL(localValue) : '';
return (
<Dropzone
onDrop={(files) => handleChange(files[0])}
onReject={(files) => console.log('rejected files', files)}
maxSize={5 * 1024 ** 2}
accept={[MIME_TYPES.png, MIME_TYPES.jpeg]}
classNames={{ root: styles?.root, content: styles.dropzoneContent }}
activateOnClick={false}
openRef={openRef}
{...dropzoneProps}
>
<Stack
spacing={12}
align="center"
className={clsx(styles.content, classNames?.content)}
>
{localValue ? (
<Stack spacing={10} justify="center" align="center">
<img src={imagePreviewUrl} alt="" className={styles.previewImage} />
<Button
minimal
intent={Intent.DANGER}
onClick={handleRemove}
icon={<Icon icon={'smallCross'} iconSize={16} />}
className={styles?.removeButton}
/>
</Stack>
) : (
<Stack spacing={10} align="center">
{title && <span className={styles.title}>{title}</span>}
<Button
intent="none"
onClick={() => openRef.current?.()}
style={{ pointerEvents: 'all' }}
minimal
outlined
small
>
{'Upload File'}
</Button>
</Stack>
)}
</Stack>
</Dropzone>
);
}

View File

@@ -12,8 +12,7 @@ import { FColorInput } from '@/components/Forms/FColorInput';
import { CreditCardIcon } from '@/icons/CreditCardIcon';
import { Overlay } from './Overlay';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { CompanyLogoUpload } from './CompanyLogoUpload';
import { useFormikContext } from 'formik';
import { BrandingCompanyLogoUploadField } from '@/containers/ElementCustomize/components/BrandingCompanyLogoUploadField';
export function InvoiceCustomizeGeneralField() {
const isTemplateNameFilled = useIsTemplateNamedFilled();
@@ -68,17 +67,23 @@ export function InvoiceCustomizeGeneralField() {
/>
</FFormGroup>
<FFormGroup name={'showCompanyLogo'} label={'Logo'} fastField>
<FSwitch
<Stack spacing={10}>
<FFormGroup
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
large
label={'Logo'}
fastField
/>
</FFormGroup>
style={{ marginBottom: 0 }}
>
<FSwitch
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
fastField
/>
</FFormGroup>
<CompanyLogoUploadField />
<BrandingCompanyLogoUploadField />
</Stack>
</Stack>
<InvoiceCustomizePaymentManage />
@@ -103,24 +108,9 @@ function InvoiceCustomizePaymentManage() {
<Text>Accept payment methods</Text>
</Group>
<a style={{ fontSize: 13 }} href={'#'}>Manage</a>
<a style={{ fontSize: 13 }} href={'#'}>
Manage
</a>
</Group>
);
}
function CompanyLogoUploadField() {
const { setFieldValue } = useFormikContext();
return (
<FFormGroup name={'companyLogo'} label={''} fastField>
<CompanyLogoUpload
onChange={(file) => {
const imageUrl = file ? URL.createObjectURL(file) : '';
setFieldValue('companyLogoFile', file);
setFieldValue('companyLogo', imageUrl);
}}
/>
</FFormGroup>
);
}

View File

@@ -20,7 +20,7 @@ export interface InvoicePaperTemplateProps {
secondaryColor?: string;
showCompanyLogo?: boolean;
companyLogo?: string;
companyLogoUri?: string;
showInvoiceNumber?: boolean;
invoiceNumber?: string;
@@ -95,7 +95,7 @@ export function InvoicePaperTemplate({
companyName = 'Bigcapital Technology, Inc.',
showCompanyLogo = true,
companyLogo,
companyLogoUri,
dueDate = 'September 3, 2024',
dueDateLabel = 'Date due',
@@ -185,7 +185,7 @@ export function InvoicePaperTemplate({
primaryColor={primaryColor}
secondaryColor={secondaryColor}
showCompanyLogo={showCompanyLogo}
companyLogo={companyLogo}
companyLogo={companyLogoUri}
bigtitle={'Invoice'}
>
<Stack spacing={24}>

View File

@@ -7,8 +7,8 @@ export const initialValues = {
// Company logo.
showCompanyLogo: true,
companyLogo:
'https://cdn-development.mercury.com/demo-assets/avatars/mercury-demo-dark.png',
companyLogoKey: '',
companyLogoUri: '',
// Top details.
showInvoiceNumber: true,

View File

@@ -7,7 +7,8 @@ export interface InvoiceCustomizeValues extends BrandingTemplateValues {
// Company Logo
showCompanyLogo?: boolean;
companyLogo?: string;
companyLogoKey?: string;
companyLogoUri?: string;
// Top details.
showInvoiceNumber?: boolean;

View File

@@ -10,6 +10,7 @@ import {
import { FColorInput } from '@/components/Forms/FColorInput';
import { Overlay } from '../../Invoices/InvoiceCustomize/Overlay';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { BrandingCompanyLogoUploadField } from '@/containers/ElementCustomize/components/BrandingCompanyLogoUploadField';
export function PaymentReceivedCustomizeGeneralField() {
const isTemplateNameFilled = useIsTemplateNamedFilled();
@@ -64,15 +65,24 @@ export function PaymentReceivedCustomizeGeneralField() {
/>
</FFormGroup>
<FFormGroup name={'showCompanyLogo'} label={'Logo'} fastField>
<FSwitch
<Stack spacing={10}>
<FFormGroup
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
large
label={'Logo'}
fastField
/>
</FFormGroup>
style={{ marginBottom: 0 }}
>
<FSwitch
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
large
fastField
/>
</FFormGroup>
<BrandingCompanyLogoUploadField />
</Stack>
</Stack>
</Overlay>
</Stack>

View File

@@ -10,6 +10,7 @@ import {
import { FColorInput } from '@/components/Forms/FColorInput';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { Overlay } from '../../Invoices/InvoiceCustomize/Overlay';
import { BrandingCompanyLogoUploadField } from '@/containers/ElementCustomize/components/BrandingCompanyLogoUploadField';
export function ReceiptCustomizeGeneralField() {
const isTemplateNameFilled = useIsTemplateNamedFilled();
@@ -64,15 +65,23 @@ export function ReceiptCustomizeGeneralField() {
/>
</FFormGroup>
<FFormGroup name={'showCompanyLogo'} label={'Logo'} fastField>
<FSwitch
<Stack spacing={10}>
<FFormGroup
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
large
label={'Logo'}
fastField
/>
</FFormGroup>
style={{ marginBottom: 0 }}
>
<FSwitch
name={'showCompanyLogo'}
label={'Display company logo in the paper'}
style={{ fontSize: 14 }}
fastField
/>
</FFormGroup>
<BrandingCompanyLogoUploadField />
</Stack>
</Stack>
</Overlay>
</Stack>

View File

@@ -1,6 +1,16 @@
// @ts-nocheck
import { useMutation } from 'react-query';
import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
interface UploadAttachmentResponse {
createdAt: string;
id: number;
key: string;
mimeType: string;
originName: string;
size: number;
}
/**
* Uploads the given attachments.
@@ -8,8 +18,11 @@ import useApiRequest from '../useRequest';
export function useUploadAttachments(props) {
const apiRequest = useApiRequest();
return useMutation(
(values) => apiRequest.post('attachments', values),
return useMutation<UploadAttachmentResponse>(
(values) =>
apiRequest
.post('attachments', values)
.then((res) => transformToCamelCase(res.data?.data)),
props,
);
}

View File

@@ -13,7 +13,6 @@ import jsCookie from 'js-cookie';
import { deepMapKeys } from './map-key-deep';
export * from './deep';
export const getCookie = (name, defaultValue) =>
_.defaultTo(jsCookie.get(name), defaultValue);
@@ -352,6 +351,14 @@ export const transformToForm = (obj, emptyInitialValues) => {
);
};
export function excludePrivateProps(
obj: Record<string, any>,
): Record<string, any> {
return Object.fromEntries(
Object.entries(obj).filter(([key, value]) => !key.startsWith('_')),
);
}
export function inputIntent({ error, touched }) {
return error && touched ? Intent.DANGER : '';
}