feat: wip preview invoice payment mail

This commit is contained in:
Ahmed Bouhuolia
2024-10-29 21:14:46 +02:00
parent 12189f018d
commit e10c530b4b
13 changed files with 301 additions and 81 deletions

View File

@@ -235,6 +235,7 @@ export enum SaleInvoiceAction {
export interface SaleInvoiceMailOptions extends CommonMailOptions { export interface SaleInvoiceMailOptions extends CommonMailOptions {
attachInvoice?: boolean; attachInvoice?: boolean;
formatArgs?: Record<string, any>;
} }
export interface SendInvoiceMailDTO extends CommonMailOptionsDTO { export interface SendInvoiceMailDTO extends CommonMailOptionsDTO {

View File

@@ -33,7 +33,7 @@ export class SaleInvoicePdf {
* Retrieve sale invoice pdf content. * Retrieve sale invoice pdf content.
* @param {number} tenantId - Tenant Id. * @param {number} tenantId - Tenant Id.
* @param {ISaleInvoice} saleInvoice - * @param {ISaleInvoice} saleInvoice -
* @returns {Promise<Buffer>} * @returns {Promise<[Buffer, string]>}
*/ */
public async saleInvoicePdf( public async saleInvoicePdf(
tenantId: number, tenantId: number,

View File

@@ -48,11 +48,14 @@ export class SendSaleInvoiceMailCommon {
tenantId, tenantId,
saleInvoice.customerId saleInvoice.customerId
); );
const formatArgs = await this.getInvoiceFormatterArgs(tenantId, invoiceId);
return { return {
...contactMailDefaultOptions, ...contactMailDefaultOptions,
attachInvoice: true,
subject: defaultSubject, subject: defaultSubject,
message: defaultMessage, message: defaultMessage,
attachInvoice: true,
formatArgs,
}; };
} }
@@ -103,7 +106,11 @@ export class SendSaleInvoiceMailCommon {
tenantId, tenantId,
invoiceId invoiceId
); );
const commonArgs =
await this.contactMailNotification.getCommonFormatArgs(tenantId);
return { return {
...commonArgs,
CustomerName: invoice.customer.displayName, CustomerName: invoice.customer.displayName,
InvoiceNumber: invoice.invoiceNo, InvoiceNumber: invoice.invoiceNo,
InvoiceDueAmount: invoice.dueAmountFormatted, InvoiceDueAmount: invoice.dueAmountFormatted,

View File

@@ -15,9 +15,8 @@ import {
parseMailOptions, parseMailOptions,
validateRequiredMailOptions, validateRequiredMailOptions,
} from '@/services/MailNotification/utils'; } from '@/services/MailNotification/utils';
import events from '@/subscribers/events';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { ParsedNumberSearch } from 'libphonenumber-js'; import events from '@/subscribers/events';
@Service() @Service()
export class SendSaleInvoiceMail { export class SendSaleInvoiceMail {

View File

@@ -1,11 +1,15 @@
import { Box } from '@/components'; import { Box, Group, Stack } from '@/components';
import { InvoiceMailReceiptPreview } from '../InvoiceCustomize/InvoiceMailReceiptPreview'; import { InvoiceMailReceiptPreview } from '../InvoiceCustomize/InvoiceMailReceiptPreview';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot'; import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { x } from '@xstyled/emotion';
import { InvoiceSendMailPreviewWithHeader } from './InvoiceSendMailHeaderPreview';
import { useSendInvoiceMailMessage } from './_hooks';
export function InvoiceMailReceiptPreviewConneceted() { export function InvoiceMailReceiptPreviewConneceted() {
const { invoice } = useInvoiceSendMailBoot(); const { invoice } = useInvoiceSendMailBoot();
const mailMessage = useSendInvoiceMailMessage();
const items = useMemo( const items = useMemo(
() => () =>
@@ -18,16 +22,19 @@ export function InvoiceMailReceiptPreviewConneceted() {
); );
return ( return (
<Box px={4} pt={8} pb={16}> <InvoiceSendMailPreviewWithHeader>
<InvoiceMailReceiptPreview <Box px={4} pt={8} pb={16}>
total={invoice.total_formatted} <InvoiceMailReceiptPreview
dueDate={invoice.due_date_formatted} total={invoice.total_formatted}
invoiceNumber={invoice.invoice_no} dueDate={invoice.due_date_formatted}
items={items} invoiceNumber={invoice.invoice_no}
className={css` items={items}
margin: 0 auto; message={mailMessage}
`} className={css`
/> margin: 0 auto;
</Box> `}
/>
</Box>
</InvoiceSendMailPreviewWithHeader>
); );
} }

View File

@@ -1,13 +1,20 @@
// @ts-nocheck // @ts-nocheck
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext } from 'react';
import { Spinner } from '@blueprintjs/core'; import { Spinner } from '@blueprintjs/core';
import { useInvoice } from '@/hooks/query'; import {
GetSaleInvoiceDefaultOptionsResponse,
useInvoice,
useSaleInvoiceDefaultOptions,
} from '@/hooks/query';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
interface InvoiceSendMailBootValues { interface InvoiceSendMailBootValues {
invoice: any; invoice: any;
invoiceId: number; invoiceId: number;
isInvoiceLoading: boolean; isInvoiceLoading: boolean;
invoiceMailOptions: GetSaleInvoiceDefaultOptionsResponse | undefined;
isInvoiceMailOptionsLoading: boolean;
} }
interface InvoiceSendMailBootProps { interface InvoiceSendMailBootProps {
children: React.ReactNode; children: React.ReactNode;
@@ -21,10 +28,15 @@ export const InvoiceSendMailBoot = ({ children }: InvoiceSendMailBootProps) => {
payload: { invoiceId }, payload: { invoiceId },
} = useDrawerContext(); } = useDrawerContext();
// Invoice details.
const { data: invoice, isLoading: isInvoiceLoading } = useInvoice(invoiceId, { const { data: invoice, isLoading: isInvoiceLoading } = useInvoice(invoiceId, {
enabled: !!invoiceId, enabled: !!invoiceId,
}); });
const isLoading = isInvoiceLoading; // Invoice mail options.
const { data: invoiceMailOptions, isLoading: isInvoiceMailOptionsLoading } =
useSaleInvoiceDefaultOptions(invoiceId);
const isLoading = isInvoiceLoading || isInvoiceMailOptionsLoading;
if (isLoading) { if (isLoading) {
return <Spinner size={20} />; return <Spinner size={20} />;
@@ -33,6 +45,8 @@ export const InvoiceSendMailBoot = ({ children }: InvoiceSendMailBootProps) => {
invoice, invoice,
isInvoiceLoading, isInvoiceLoading,
invoiceId, invoiceId,
invoiceMailOptions,
isInvoiceMailOptionsLoading,
}; };
return ( return (

View File

@@ -1,6 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { useState } from 'react'; import { useRef, useState } from 'react';
import { Button, Intent, MenuItem } from '@blueprintjs/core'; import { Button, Intent, MenuItem, Position } from '@blueprintjs/core';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { x } from '@xstyled/emotion'; import { x } from '@xstyled/emotion';
@@ -9,8 +9,10 @@ import {
FFormGroup, FFormGroup,
FInputGroup, FInputGroup,
FMultiSelect, FMultiSelect,
FSelect,
FTextArea, FTextArea,
Group, Group,
Icon,
Stack, Stack,
} from '@/components'; } from '@/components';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
@@ -60,6 +62,7 @@ const fieldsWrapStyle = css`
export function InvoiceSendMailFields() { export function InvoiceSendMailFields() {
const [showCCField, setShowCCField] = useState<boolean>(false); const [showCCField, setShowCCField] = useState<boolean>(false);
const [showBccField, setShowBccField] = useState<boolean>(false); const [showBccField, setShowBccField] = useState<boolean>(false);
const textareaRef = useRef(null);
const { values, setFieldValue } = useFormikContext(); const { values, setFieldValue } = useFormikContext();
const items = useInvoiceMailItems(); const items = useInvoiceMailItems();
@@ -117,6 +120,30 @@ export function InvoiceSendMailFields() {
</Group> </Group>
); );
const handleTextareaChange = () => {
const textarea = textareaRef.current;
if (!textarea) return;
const { selectionStart, selectionEnd } = textarea;
const insertText = '{Variable}';
// Insert the text at the cursor position
const message =
text.substring(0, selectionStart) +
insertText +
text.substring(selectionEnd);
setFieldValue('message', message);
// Move the cursor to the end of the inserted text
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd =
selectionStart + insertText.length;
textarea.focus();
}, 0);
};
return ( return (
<Stack <Stack
bg="white" bg="white"
@@ -144,6 +171,7 @@ export function InvoiceSendMailFields() {
resetOnQuery resetOnQuery
resetOnSelect resetOnSelect
fill fill
fastField
/> />
{showCCField && ( {showCCField && (
<FMultiSelect <FMultiSelect
@@ -161,6 +189,7 @@ export function InvoiceSendMailFields() {
resetOnQuery resetOnQuery
resetOnSelect resetOnSelect
fill fill
fastField
/> />
)} )}
{showBccField && ( {showBccField && (
@@ -179,25 +208,62 @@ export function InvoiceSendMailFields() {
resetOnQuery resetOnQuery
resetOnSelect resetOnSelect
fill fill
fastField
/> />
)} )}
</Stack> </Stack>
</FFormGroup> </FFormGroup>
<FFormGroup label={'Submit'} name={'subject'}> <FFormGroup label={'Submit'} name={'subject'}>
<FInputGroup name={'subject'} large /> <FInputGroup name={'subject'} large fastField />
</FFormGroup> </FFormGroup>
<FFormGroup label={'Message'} name={'message'}> <FFormGroup label={'Message'} name={'message'}>
<FTextArea <Stack spacing={0}>
name={'message'} <Group
large border={'1px solid #ced4da'}
fill borderBottom={0}
className={css` borderRadius={'3px 3px 0 0'}
resize: vertical; >
min-height: 200px; <FSelect
`} selectedItem={'customerName'}
/> name={'item'}
items={[{ value: 'customerName', text: 'Customer Name' }]}
onItemChange={handleTextareaChange}
popoverProps={{
fill: false,
position: Position.BOTTOM_LEFT,
minimal: true,
}}
input={({ activeItem, text, label, value }) => (
<Button
minimal
rightIcon={
<Icon icon={'caret-down-16'} color={'#8F99A8'} />
}
>
{text}
</Button>
)}
fill={false}
fastField
/>
</Group>
<FTextArea
ref={textareaRef}
name={'message'}
large
fill
fastField
className={css`
resize: vertical;
min-height: 200px;
border-top-right-radius: 0px;
border-top-left-radius: 0px;
`}
/>
</Stack>
</FFormGroup> </FFormGroup>
</Stack> </Stack>
@@ -243,4 +309,3 @@ function InvoiceSendMailFooter() {
</Group> </Group>
); );
} }

View File

@@ -1,4 +1,3 @@
import * as Yup from 'yup';
import { Form, Formik, FormikHelpers } from 'formik'; import { Form, Formik, FormikHelpers } from 'formik';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
@@ -9,22 +8,14 @@ import { AppToaster } from '@/components';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot'; import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { useDrawerActions } from '@/hooks/state'; import { useDrawerActions } from '@/hooks/state';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
import { transformToForm } from '@/utils';
const initialValues = { const initialValues = {
subject: 'invoice INV-0002 for AED 0.00', subject: '',
message: `Hi Ahmed, message: '',
to: [],
Heres invoice INV-0002 for AED 0.00 cc: [],
bcc: [],
The amount outstanding of AED $100,00 is due on 2 October 2024
View your bill online From your online you can print a PDF or pay your outstanding bills,
If you have any questions, please let us know,
Thanks,
Mohamed`,
to: ['a.bouhuolia@gmail.com'],
}; };
interface InvoiceSendMailFormProps { interface InvoiceSendMailFormProps {
@@ -33,10 +24,14 @@ interface InvoiceSendMailFormProps {
export function InvoiceSendMailForm({ children }: InvoiceSendMailFormProps) { export function InvoiceSendMailForm({ children }: InvoiceSendMailFormProps) {
const { mutateAsync: sendInvoiceMail } = useSendSaleInvoiceMail(); const { mutateAsync: sendInvoiceMail } = useSendSaleInvoiceMail();
const { invoiceId } = useInvoiceSendMailBoot(); const { invoiceId, invoiceMailOptions } = useInvoiceSendMailBoot();
const { name } = useDrawerContext(); const { name } = useDrawerContext();
const { closeDrawer } = useDrawerActions(); const { closeDrawer } = useDrawerActions();
const _initialValues = {
...initialValues,
...transformToForm(invoiceMailOptions, initialValues),
};
const handleSubmit = ( const handleSubmit = (
values: InvoiceSendMailFormValues, values: InvoiceSendMailFormValues,
{ setSubmitting }: FormikHelpers<InvoiceSendMailFormValues>, { setSubmitting }: FormikHelpers<InvoiceSendMailFormValues>,
@@ -62,7 +57,7 @@ export function InvoiceSendMailForm({ children }: InvoiceSendMailFormProps) {
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={_initialValues}
validationSchema={InvoiceSendMailFormSchema} validationSchema={InvoiceSendMailFormSchema}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >

View File

@@ -0,0 +1,75 @@
import { css } from '@emotion/css';
import { x } from '@xstyled/emotion';
import { Box, Group, Stack } from '@/components';
import React from 'react';
import { useSendInvoiceMailSubject } from './_hooks';
export function InvoiceSendMailHeaderPreview() {
const mailSubject = useSendInvoiceMailSubject();
return (
<Stack
bg={'white'}
borderBottom={'1px solid #dcdcdd'}
padding={'22px 30px'}
spacing={8}
position={'sticky'}
top={0}
zIndex={1}
>
<Box>
<x.h2 fontWeight={600} fontSize={16}>
{mailSubject}
</x.h2>
</Box>
<Group display="flex" gap={2}>
<Group display="flex" alignItems="center" gap={15}>
<x.abbr
role="presentation"
className={css`
background-color: #daa3e4;
color: #3f1946;
fill: #daa3e4;
height: 40px;
width: 40px;
line-height: 40px;
text-align: center;
border-radius: 40px;
font-size: 14px;
`}
>
A
</x.abbr>
<Stack spacing={2}>
<Group spacing={2}>
<Box fontWeight={600}>Ahmed </Box>
<Box color={'#738091'}>
&lt;messaging-service@post.xero.com&gt;
</Box>
</Group>
<Box fontSize={'sm'} color={'#738091'}>
Reply to: Ahmed &lt;a.m.bouhuolia@gmail.com&gt;
</Box>
</Stack>
</Group>
</Group>
</Stack>
);
}
export function InvoiceSendMailPreviewWithHeader({
children,
}: {
children: React.ReactNode;
}) {
return (
<Box>
<InvoiceSendMailHeaderPreview />
<Box>{children}</Box>
</Box>
);
}

View File

@@ -1,27 +1,30 @@
import { Box } from "@/components"; import { Box } from '@/components';
import { InvoicePaperTemplate } from "../InvoiceCustomize/InvoicePaperTemplate"; import { InvoicePaperTemplate } from '../InvoiceCustomize/InvoicePaperTemplate';
import { css } from "@emotion/css"; import { css } from '@emotion/css';
import { useInvoiceSendMailBoot } from "./InvoiceSendMailContentBoot"; import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { InvoiceSendMailPreviewWithHeader } from './InvoiceSendMailHeaderPreview';
export function InvoiceSendPdfPreviewConnected() { export function InvoiceSendPdfPreviewConnected() {
const { invoice } = useInvoiceSendMailBoot(); const { invoice } = useInvoiceSendMailBoot();
return ( return (
<Box px={4} py={6}> <InvoiceSendMailPreviewWithHeader>
<InvoicePaperTemplate <Box px={4} py={6}>
dueDate={invoice.due_date_formatted} <InvoicePaperTemplate
dateIssue={invoice.invoice_date_formatted} dueDate={invoice.due_date_formatted}
invoiceNumber={invoice.invoice_no} dateIssue={invoice.invoice_date_formatted}
total={invoice.total_formatted} invoiceNumber={invoice.invoice_no}
subtotal={invoice.subtotal} total={invoice.total_formatted}
discount={''} subtotal={invoice.subtotal}
paymentMade={''} discount={''}
balanceDue={invoice.due_amount_Formatted} paymentMade={''}
statement={invoice.statement} balanceDue={invoice.due_amount_Formatted}
className={css` statement={invoice.statement}
margin: 0 auto; className={css`
`} margin: 0 auto;
/> `}
</Box> />
</Box>
</InvoiceSendMailPreviewWithHeader>
); );
} }

View File

@@ -1,6 +1,8 @@
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { chain } from 'lodash'; import { camelCase, chain, defaultTo, mapKeys, upperFirst } from 'lodash';
import { InvoiceSendMailFormValues } from './_types'; import { InvoiceSendMailFormValues } from './_types';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { useMemo } from 'react';
export const useInvoiceMailItems = () => { export const useInvoiceMailItems = () => {
const { values } = useFormikContext<InvoiceSendMailFormValues>(); const { values } = useFormikContext<InvoiceSendMailFormValues>();
@@ -16,3 +18,42 @@ export const useInvoiceMailItems = () => {
})) }))
.value(); .value();
}; };
export const useSendInvoiceMailFormatArgs = () => {
const { invoiceMailOptions } = useInvoiceSendMailBoot();
return useMemo(() => {
return mapKeys(invoiceMailOptions?.formatArgs, (_, key) =>
upperFirst(camelCase(key)),
);
}, [invoiceMailOptions]);
};
export const useSendInvoiceMailSubject = () => {
const { values } = useFormikContext<InvoiceSendMailFormValues>();
const formatArgs = useSendInvoiceMailFormatArgs();
return formatSmsMessage(values?.subject, formatArgs);
};
export const useSendInvoiceMailMessage = () => {
const { values } = useFormikContext<InvoiceSendMailFormValues>();
const formatArgs = useSendInvoiceMailFormatArgs();
return formatSmsMessage(values?.message, formatArgs);
};
export const formatSmsMessage = (
message: string,
args: Record<string, any>,
) => {
let formattedMessage = message;
Object.keys(args).forEach((key) => {
const variable = `{${key}}`;
const value = defaultTo(args[key], '');
formattedMessage = formattedMessage.replace(variable, value);
});
return formattedMessage;
};

View File

@@ -2,6 +2,6 @@ export interface InvoiceSendMailFormValues {
subject: string; subject: string;
message: string; message: string;
to: string[]; to: string[];
cc?: string[]; cc: string[];
bcc?: string[]; bcc: string[];
} }

View File

@@ -5,6 +5,8 @@ import {
useQuery, useQuery,
UseMutationOptions, UseMutationOptions,
UseMutationResult, UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query'; } from 'react-query';
import { useRequestQuery } from '../useQueryRequest'; import { useRequestQuery } from '../useQueryRequest';
import { transformPagination, transformToCamelCase } from '@/utils'; import { transformPagination, transformToCamelCase } from '@/utils';
@@ -364,17 +366,28 @@ export function useSendSaleInvoiceMail(
); );
} }
export function useSaleInvoiceDefaultOptions(invoiceId, props) { export interface GetSaleInvoiceDefaultOptionsResponse {
return useRequestQuery( to: Array<string>;
from: Array<String>;
subject: string;
message: string;
attachInvoice: boolean;
formatArgs: Record<string, string>;
}
export function useSaleInvoiceDefaultOptions(
invoiceId: number,
options?: UseQueryOptions<GetSaleInvoiceDefaultOptionsResponse>,
): UseQueryResult<GetSaleInvoiceDefaultOptionsResponse> {
const apiRequest = useApiRequest();
return useQuery<GetSaleInvoiceDefaultOptionsResponse>(
[t.SALE_INVOICE_DEFAULT_OPTIONS, invoiceId], [t.SALE_INVOICE_DEFAULT_OPTIONS, invoiceId],
{ () =>
method: 'get', apiRequest
url: `sales/invoices/${invoiceId}/mail`, .get(`/sales/invoices/${invoiceId}/mail`)
}, .then((res) => transformToCamelCase(res.data?.data)),
{ options,
select: (res) => res.data.data,
...props,
},
); );
} }