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 {
attachInvoice?: boolean;
formatArgs?: Record<string, any>;
}
export interface SendInvoiceMailDTO extends CommonMailOptionsDTO {

View File

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

View File

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

View File

@@ -15,9 +15,8 @@ import {
parseMailOptions,
validateRequiredMailOptions,
} from '@/services/MailNotification/utils';
import events from '@/subscribers/events';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { ParsedNumberSearch } from 'libphonenumber-js';
import events from '@/subscribers/events';
@Service()
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 { css } from '@emotion/css';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { useMemo } from 'react';
import { x } from '@xstyled/emotion';
import { InvoiceSendMailPreviewWithHeader } from './InvoiceSendMailHeaderPreview';
import { useSendInvoiceMailMessage } from './_hooks';
export function InvoiceMailReceiptPreviewConneceted() {
const { invoice } = useInvoiceSendMailBoot();
const mailMessage = useSendInvoiceMailMessage();
const items = useMemo(
() =>
@@ -18,16 +22,19 @@ export function InvoiceMailReceiptPreviewConneceted() {
);
return (
<Box px={4} pt={8} pb={16}>
<InvoiceMailReceiptPreview
total={invoice.total_formatted}
dueDate={invoice.due_date_formatted}
invoiceNumber={invoice.invoice_no}
items={items}
className={css`
margin: 0 auto;
`}
/>
</Box>
<InvoiceSendMailPreviewWithHeader>
<Box px={4} pt={8} pb={16}>
<InvoiceMailReceiptPreview
total={invoice.total_formatted}
dueDate={invoice.due_date_formatted}
invoiceNumber={invoice.invoice_no}
items={items}
message={mailMessage}
className={css`
margin: 0 auto;
`}
/>
</Box>
</InvoiceSendMailPreviewWithHeader>
);
}

View File

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

View File

@@ -1,6 +1,6 @@
// @ts-nocheck
import { useState } from 'react';
import { Button, Intent, MenuItem } from '@blueprintjs/core';
import { useRef, useState } from 'react';
import { Button, Intent, MenuItem, Position } from '@blueprintjs/core';
import { useFormikContext } from 'formik';
import { css } from '@emotion/css';
import { x } from '@xstyled/emotion';
@@ -9,8 +9,10 @@ import {
FFormGroup,
FInputGroup,
FMultiSelect,
FSelect,
FTextArea,
Group,
Icon,
Stack,
} from '@/components';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
@@ -60,6 +62,7 @@ const fieldsWrapStyle = css`
export function InvoiceSendMailFields() {
const [showCCField, setShowCCField] = useState<boolean>(false);
const [showBccField, setShowBccField] = useState<boolean>(false);
const textareaRef = useRef(null);
const { values, setFieldValue } = useFormikContext();
const items = useInvoiceMailItems();
@@ -117,6 +120,30 @@ export function InvoiceSendMailFields() {
</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 (
<Stack
bg="white"
@@ -144,6 +171,7 @@ export function InvoiceSendMailFields() {
resetOnQuery
resetOnSelect
fill
fastField
/>
{showCCField && (
<FMultiSelect
@@ -161,6 +189,7 @@ export function InvoiceSendMailFields() {
resetOnQuery
resetOnSelect
fill
fastField
/>
)}
{showBccField && (
@@ -179,25 +208,62 @@ export function InvoiceSendMailFields() {
resetOnQuery
resetOnSelect
fill
fastField
/>
)}
</Stack>
</FFormGroup>
<FFormGroup label={'Submit'} name={'subject'}>
<FInputGroup name={'subject'} large />
<FInputGroup name={'subject'} large fastField />
</FFormGroup>
<FFormGroup label={'Message'} name={'message'}>
<FTextArea
name={'message'}
large
fill
className={css`
resize: vertical;
min-height: 200px;
`}
/>
<Stack spacing={0}>
<Group
border={'1px solid #ced4da'}
borderBottom={0}
borderRadius={'3px 3px 0 0'}
>
<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>
</Stack>
@@ -243,4 +309,3 @@ function InvoiceSendMailFooter() {
</Group>
);
}

View File

@@ -1,4 +1,3 @@
import * as Yup from 'yup';
import { Form, Formik, FormikHelpers } from 'formik';
import { css } from '@emotion/css';
import { Intent } from '@blueprintjs/core';
@@ -9,22 +8,14 @@ import { AppToaster } from '@/components';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { useDrawerActions } from '@/hooks/state';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
import { transformToForm } from '@/utils';
const initialValues = {
subject: 'invoice INV-0002 for AED 0.00',
message: `Hi Ahmed,
Heres invoice INV-0002 for AED 0.00
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'],
subject: '',
message: '',
to: [],
cc: [],
bcc: [],
};
interface InvoiceSendMailFormProps {
@@ -33,10 +24,14 @@ interface InvoiceSendMailFormProps {
export function InvoiceSendMailForm({ children }: InvoiceSendMailFormProps) {
const { mutateAsync: sendInvoiceMail } = useSendSaleInvoiceMail();
const { invoiceId } = useInvoiceSendMailBoot();
const { invoiceId, invoiceMailOptions } = useInvoiceSendMailBoot();
const { name } = useDrawerContext();
const { closeDrawer } = useDrawerActions();
const _initialValues = {
...initialValues,
...transformToForm(invoiceMailOptions, initialValues),
};
const handleSubmit = (
values: InvoiceSendMailFormValues,
{ setSubmitting }: FormikHelpers<InvoiceSendMailFormValues>,
@@ -62,7 +57,7 @@ export function InvoiceSendMailForm({ children }: InvoiceSendMailFormProps) {
return (
<Formik
initialValues={initialValues}
initialValues={_initialValues}
validationSchema={InvoiceSendMailFormSchema}
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 { InvoicePaperTemplate } from "../InvoiceCustomize/InvoicePaperTemplate";
import { css } from "@emotion/css";
import { useInvoiceSendMailBoot } from "./InvoiceSendMailContentBoot";
import { Box } from '@/components';
import { InvoicePaperTemplate } from '../InvoiceCustomize/InvoicePaperTemplate';
import { css } from '@emotion/css';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { InvoiceSendMailPreviewWithHeader } from './InvoiceSendMailHeaderPreview';
export function InvoiceSendPdfPreviewConnected() {
const { invoice } = useInvoiceSendMailBoot();
return (
<Box px={4} py={6}>
<InvoicePaperTemplate
dueDate={invoice.due_date_formatted}
dateIssue={invoice.invoice_date_formatted}
invoiceNumber={invoice.invoice_no}
total={invoice.total_formatted}
subtotal={invoice.subtotal}
discount={''}
paymentMade={''}
balanceDue={invoice.due_amount_Formatted}
statement={invoice.statement}
className={css`
margin: 0 auto;
`}
/>
</Box>
<InvoiceSendMailPreviewWithHeader>
<Box px={4} py={6}>
<InvoicePaperTemplate
dueDate={invoice.due_date_formatted}
dateIssue={invoice.invoice_date_formatted}
invoiceNumber={invoice.invoice_no}
total={invoice.total_formatted}
subtotal={invoice.subtotal}
discount={''}
paymentMade={''}
balanceDue={invoice.due_amount_Formatted}
statement={invoice.statement}
className={css`
margin: 0 auto;
`}
/>
</Box>
</InvoiceSendMailPreviewWithHeader>
);
}

View File

@@ -1,6 +1,8 @@
import { useFormikContext } from 'formik';
import { chain } from 'lodash';
import { camelCase, chain, defaultTo, mapKeys, upperFirst } from 'lodash';
import { InvoiceSendMailFormValues } from './_types';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { useMemo } from 'react';
export const useInvoiceMailItems = () => {
const { values } = useFormikContext<InvoiceSendMailFormValues>();
@@ -16,3 +18,42 @@ export const useInvoiceMailItems = () => {
}))
.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;
message: string;
to: string[];
cc?: string[];
bcc?: string[];
cc: string[];
bcc: string[];
}

View File

@@ -5,6 +5,8 @@ import {
useQuery,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { useRequestQuery } from '../useQueryRequest';
import { transformPagination, transformToCamelCase } from '@/utils';
@@ -364,17 +366,28 @@ export function useSendSaleInvoiceMail(
);
}
export function useSaleInvoiceDefaultOptions(invoiceId, props) {
return useRequestQuery(
export interface GetSaleInvoiceDefaultOptionsResponse {
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],
{
method: 'get',
url: `sales/invoices/${invoiceId}/mail`,
},
{
select: (res) => res.data.data,
...props,
},
() =>
apiRequest
.get(`/sales/invoices/${invoiceId}/mail`)
.then((res) => transformToCamelCase(res.data?.data)),
options,
);
}