Compare commits

...

19 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
dbbaa387bd feat: send invoice receipt preview 2024-10-31 12:40:48 +02:00
Ahmed Bouhuolia
470bfd32f7 feat: wip send invoice receipt preview 2024-10-30 14:22:54 +02:00
Ahmed Bouhuolia
5fddd080fd feat: wip send invoice mail receipt 2024-10-30 13:10:56 +02:00
Ahmed Bouhuolia
e10c530b4b feat: wip preview invoice payment mail 2024-10-29 21:14:46 +02:00
Ahmed Bouhuolia
12189f018d feat: wip send invoice mail payment template 2024-10-28 18:33:16 +02:00
Ahmed Bouhuolia
0111b0e6ff feat: hook up the request to the send mail form 2024-10-28 12:00:17 +02:00
Ahmed Bouhuolia
0930b0428d fix: tsconfig email-components package 2024-10-28 00:28:52 +02:00
Ahmed Bouhuolia
289f40014e feat: invoice payment email template 2024-10-27 17:20:48 +02:00
Ahmed Bouhuolia
01cc0568f9 feat: wip invoice payment email template 2024-10-27 15:09:08 +02:00
Ahmed Bouhuolia
42ee8ed9fa feat: initialize email-components vite package 2024-10-27 10:16:04 +02:00
Ahmed Bouhuolia
1dae65cb74 feat: Style the send email right buttons 2024-10-26 19:01:37 +02:00
Ahmed Bouhuolia
ce40d67ea2 feat: wip send invoice preview 2024-10-26 18:39:36 +02:00
Ahmed Bouhuolia
728b4cacd9 feat: wip send invoice mail preview 2024-10-24 20:48:16 +02:00
Ahmed Bouhuolia
c321d90575 feat: send invoice mail receipt drawer 2024-10-23 16:30:39 +02:00
Ahmed Bouhuolia
03e6372f14 feat: style the invoice payment preview 2024-10-22 14:36:53 +02:00
Ahmed Bouhuolia
c0481f67ad feat: wip invoice mail receipt preview 2024-10-22 14:00:36 +02:00
Ahmed Bouhuolia
b7f316d25a feat: wip invoice mail receipt preview 2024-10-22 11:59:15 +02:00
Ahmed Bouhuolia
dffd818396 feat: Invoice mail receipt preview 2024-10-21 15:42:12 +02:00
Ahmed Bouhuolia
ccbb399685 Merge pull request #720 from bigcapitalhq/add-estimate-customer-note
fix Customer note does not appear in pdf document
2024-10-19 16:28:33 +02:00
86 changed files with 10517 additions and 1061 deletions

View File

@@ -23,6 +23,7 @@
"@aws-sdk/client-s3": "^3.576.0",
"@aws-sdk/s3-request-presigner": "^3.583.0",
"@bigcapital/utils": "*",
"@bigcapital/email-components": "*",
"@casl/ability": "^5.4.3",
"@hapi/boom": "^7.4.3",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",

View File

@@ -179,10 +179,21 @@ export default class SaleInvoicesController extends BaseController {
'/:id/mail',
[
...this.specificSaleInvoiceValidation,
body('subject').isString().optional(),
body('subject').isString().optional({ nullable: true }),
body('message').isString().optional({ nullable: true }),
body('from').isString().optional(),
body('to').isString().optional(),
body('body').isString().optional(),
body('to').isArray().exists(),
body('to.*').isString().isEmail().optional(),
body('cc').isArray().optional({ nullable: true }),
body('cc.*').isString().isEmail().optional(),
body('bcc').isArray().optional({ nullable: true }),
body('bcc.*').isString().isEmail().optional(),
body('attach_invoice').optional().isBoolean().toBoolean(),
],
this.validationResult,
@@ -190,7 +201,7 @@ export default class SaleInvoicesController extends BaseController {
this.handleServiceErrors
);
router.get(
'/:id/mail',
'/:id/mail/state',
[...this.specificSaleInvoiceValidation],
this.validationResult,
asyncMiddleware(this.getSaleInvoiceMail.bind(this)),
@@ -778,7 +789,7 @@ export default class SaleInvoicesController extends BaseController {
}
/**
* Retrieves the default mail options of the given sale invoice.
* Retrieves the mail state of the given sale invoice.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
@@ -792,7 +803,7 @@ export default class SaleInvoicesController extends BaseController {
const { id: invoiceId } = req.params;
try {
const data = await this.saleInvoiceApplication.getSaleInvoiceMail(
const data = await this.saleInvoiceApplication.getSaleInvoiceMailState(
tenantId,
invoiceId
);

View File

@@ -4,6 +4,9 @@ export const SALE_INVOICE_DELETED = 'Sale invoice deleted';
export const SALE_INVOICE_MAIL_DELIVERED = 'Sale invoice mail delivered';
export const SALE_INVOICE_VIEWED = 'Sale invoice viewed';
export const SALE_INVOICE_PDF_VIEWED = 'Sale invoice PDF viewed';
export const SALE_INVOICE_MAIL_SENT = 'Sale invoice mail sent';
export const SALE_INVOICE_MAIL_REMINDER_SENT =
'Sale invoice reminder mail sent';
export const SALE_ESTIMATE_CREATED = 'Sale estimate created';
export const SALE_ESTIMATE_EDITED = 'Sale estimate edited';

View File

@@ -30,18 +30,14 @@ export interface AddressItem {
}
export interface CommonMailOptions {
toAddresses: AddressItem[];
fromAddresses: AddressItem[];
from: string;
to: string | string[];
from: Array<string>;
subject: string;
body: string;
message: string;
to: Array<string>;
cc?: Array<string>;
bcc?: Array<string>;
data?: Record<string, any>;
}
export interface CommonMailOptionsDTO {
to?: string | string[];
from?: string;
subject?: string;
body?: string;
export interface CommonMailOptionsDTO extends Partial<CommonMailOptions> {
}

View File

@@ -234,7 +234,32 @@ export enum SaleInvoiceAction {
}
export interface SaleInvoiceMailOptions extends CommonMailOptions {
attachInvoice: boolean;
attachInvoice?: boolean;
formatArgs?: Record<string, any>;
}
export interface SaleInvoiceMailState extends SaleInvoiceMailOptions {
invoiceNo: string;
invoiceDate: string;
invoiceDateFormatted: string;
dueDate: string;
dueDateFormatted: string;
total: number;
totalFormatted: string;
subtotal: number;
subtotalFormatted: number;
companyName: string;
companyLogoUri: string;
customerName: string;
// # Invoice entries
entries?: Array<{ label: string; total: string; quantity: string | number }>;
}
export interface SendInvoiceMailDTO extends CommonMailOptionsDTO {
@@ -251,6 +276,7 @@ export interface ISaleInvoiceMailSend {
tenantId: number;
saleInvoiceId: number;
messageOptions: SendInvoiceMailDTO;
formattedMessageOptions: SaleInvoiceMailOptions;
}
export interface ISaleInvoiceMailSent {

View File

@@ -10,6 +10,7 @@ import {
SALE_INVOICE_CREATED,
SALE_INVOICE_DELETED,
SALE_INVOICE_EDITED,
SALE_INVOICE_MAIL_SENT,
SALE_INVOICE_PDF_VIEWED,
SALE_INVOICE_VIEWED,
} from '@/constants/event-tracker';
@@ -43,6 +44,10 @@ export class SaleInvoiceEventsTracker extends EventSubscriber {
events.saleInvoice.onPdfViewed,
this.handleTrackPdfViewedInvoiceEvent
);
bus.subscribe(
events.saleInvoice.onMailSent,
this.handleTrackMailSentInvoiceEvent
);
}
private handleTrackInvoiceCreatedEvent = ({
@@ -90,4 +95,12 @@ export class SaleInvoiceEventsTracker extends EventSubscriber {
properties: {},
});
};
private handleTrackMailSentInvoiceEvent = ({ tenantId }) => {
this.posthog.trackEvent({
distinctId: `tenant-${tenantId}`,
event: SALE_INVOICE_MAIL_SENT,
properties: {},
});
};
}

View File

@@ -4,6 +4,7 @@ import HasTenancyService from '@/services/Tenancy/TenancyService';
import { MailTenancy } from '@/services/MailTenancy/MailTenancy';
import { formatSmsMessage } from '@/utils';
import { Tenant } from '@/system/models';
import { castArray } from 'lodash';
@Service()
export class ContactMailNotification {
@@ -14,76 +15,54 @@ export class ContactMailNotification {
private tenancy: HasTenancyService;
/**
* Parses the default message options.
* @param {number} tenantId -
* @param {number} invoiceId -
* @param {string} subject -
* @param {string} body -
* @returns {Promise<SaleInvoiceMailOptions>}
* Gets the default mail address of the given contact.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Contact id.
* @returns {Promise<Pick<CommonMailOptions, 'to' | 'from'>>}
*/
public async getDefaultMailOptions(
tenantId: number,
contactId: number,
subject: string = '',
body: string = ''
): Promise<CommonMailOptions> {
customerId: number
): Promise<Pick<CommonMailOptions, 'to' | 'from'>> {
const { Customer } = this.tenancy.models(tenantId);
const contact = await Customer.query()
.findById(contactId)
const customer = await Customer.query()
.findById(customerId)
.throwIfNotFound();
const toAddresses = contact.contactAddresses;
const toAddresses = customer.contactAddresses;
const fromAddresses = await this.mailTenancy.senders(tenantId);
const toAddress = toAddresses.find((a) => a.primary);
const fromAddress = fromAddresses.find((a) => a.primary);
const to = toAddress?.mail || '';
const from = fromAddress?.mail || '';
const to = toAddress?.mail ? castArray(toAddress?.mail) : [];
const from = fromAddress?.mail ? castArray(fromAddress?.mail) : [];
return {
subject,
body,
to,
from,
fromAddresses,
toAddresses,
};
return { to, from };
}
/**
* Retrieves the mail options of the given contact.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Invoice id.
* @param {string} defaultSubject - Default subject text.
* @param {string} defaultBody - Default body text.
* @returns {Promise<CommonMailOptions>}
*/
public async getMailOptions(
public async parseMailOptions(
tenantId: number,
contactId: number,
defaultSubject?: string,
defaultBody?: string,
formatterData?: Record<string, any>
mailOptions: CommonMailOptions,
formatterArgs?: Record<string, any>
): Promise<CommonMailOptions> {
const mailOpts = await this.getDefaultMailOptions(
tenantId,
contactId,
defaultSubject,
defaultBody
);
const commonFormatArgs = await this.getCommonFormatArgs(tenantId);
const formatArgs = {
...commonFormatArgs,
...formatterData,
...formatterArgs,
};
const subject = formatSmsMessage(mailOpts.subject, formatArgs);
const body = formatSmsMessage(mailOpts.body, formatArgs);
const subjectFormatted = formatSmsMessage(mailOptions?.subject, formatArgs);
const messageFormatted = formatSmsMessage(mailOptions?.message, formatArgs);
return {
...mailOpts,
subject,
body,
...mailOptions,
subject: subjectFormatted,
message: messageFormatted,
};
}
@@ -100,7 +79,7 @@ export class ContactMailNotification {
.withGraphFetched('metadata');
return {
CompanyName: organization.metadata.name,
['Company Name']: organization.metadata.name,
};
}
}

View File

@@ -1,33 +1,46 @@
import { isEmpty } from 'lodash';
import { castArray, isEmpty } from 'lodash';
import { ServiceError } from '@/exceptions';
import { CommonMailOptions, CommonMailOptionsDTO } from '@/interfaces';
import { CommonMailOptions } from '@/interfaces';
import { ERRORS } from './constants';
/**
* Merges the mail options with incoming options.
* @param {Partial<SaleInvoiceMailOptions>} mailOptions
* @param {Partial<SendInvoiceMailDTO>} overridedOptions
* @throws {ServiceError}
*/
export function parseAndValidateMailOptions(
mailOptions: Partial<CommonMailOptions>,
overridedOptions: Partial<CommonMailOptionsDTO>
) {
export function parseMailOptions(
mailOptions: CommonMailOptions,
overridedOptions: Partial<CommonMailOptions>
): CommonMailOptions {
const mergedMessageOptions = {
...mailOptions,
...overridedOptions,
};
if (isEmpty(mergedMessageOptions.from)) {
const parsedMessageOptions = {
...mergedMessageOptions,
from: mergedMessageOptions?.from
? castArray(mergedMessageOptions?.from)
: [],
to: mergedMessageOptions?.to ? castArray(mergedMessageOptions?.to) : [],
cc: mergedMessageOptions?.cc ? castArray(mergedMessageOptions?.cc) : [],
bcc: mergedMessageOptions?.bcc ? castArray(mergedMessageOptions?.bcc) : [],
};
return parsedMessageOptions;
}
export function validateRequiredMailOptions(
mailOptions: Partial<CommonMailOptions>
) {
if (isEmpty(mailOptions.from)) {
throw new ServiceError(ERRORS.MAIL_FROM_NOT_FOUND);
}
if (isEmpty(mergedMessageOptions.to)) {
if (isEmpty(mailOptions.to)) {
throw new ServiceError(ERRORS.MAIL_TO_NOT_FOUND);
}
if (isEmpty(mergedMessageOptions.subject)) {
if (isEmpty(mailOptions.subject)) {
throw new ServiceError(ERRORS.MAIL_SUBJECT_NOT_FOUND);
}
if (isEmpty(mergedMessageOptions.body)) {
if (isEmpty(mailOptions.message)) {
throw new ServiceError(ERRORS.MAIL_BODY_NOT_FOUND);
}
return mergedMessageOptions;
}

View File

@@ -0,0 +1,66 @@
import { Inject, Service } from 'typedi';
import { GetPdfTemplate } from '@/services/PdfTemplate/GetPdfTemplate';
import { GetSaleInvoice } from './GetSaleInvoice';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import {
InvoicePaymentEmailProps,
renderInvoicePaymentEmail,
} from '@bigcapital/email-components';
import { GetInvoiceMailTemplateAttributesTransformer } from './GetInvoicePaymentMailAttributesTransformer';
@Service()
export class GetInvoicePaymentMail {
@Inject()
private getSaleInvoiceService: GetSaleInvoice;
@Inject()
private getBrandingTemplate: GetPdfTemplate;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the mail template attributes of the given invoice.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Invoice id.
*/
public async getMailTemplateAttributes(tenantId: number, invoiceId: number) {
const invoice = await this.getSaleInvoiceService.getSaleInvoice(
tenantId,
invoiceId
);
const brandingTemplate = await this.getBrandingTemplate.getPdfTemplate(
tenantId,
invoice.pdfTemplateId
);
const mailTemplateAttributes = await this.transformer.transform(
tenantId,
invoice,
new GetInvoiceMailTemplateAttributesTransformer(),
{
invoice,
brandingTemplate,
}
);
return mailTemplateAttributes;
}
/**
* Retrieves the mail template html content.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Invoice id.
*/
public async getMailTemplate(
tenantId: number,
invoiceId: number,
overrideAttributes?: Partial<InvoicePaymentEmailProps>
): Promise<string> {
const attributes = await this.getMailTemplateAttributes(
tenantId,
invoiceId
);
const mergedAttributes = { ...attributes, ...overrideAttributes };
return renderInvoicePaymentEmail(mergedAttributes);
}
}

View File

@@ -0,0 +1,136 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetInvoiceMailTemplateAttributesTransformer extends Transformer {
/**
* Include these attributes to item entry object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'companyLogoUri',
'companyName',
'invoiceAmount',
'primaryColor',
'invoiceAmount',
'invoiceMessage',
'dueDate',
'dueDateLabel',
'invoiceNumber',
'invoiceNumberLabel',
'total',
'totalLabel',
'dueAmount',
'dueAmountLabel',
'viewInvoiceButtonLabel',
'viewInvoiceButtonUrl',
'items',
];
};
public excludeAttributes = (): string[] => {
return ['*'];
};
public companyLogoUri(): string {
return this.options.brandingTemplate?.attributes?.companyLogoUri;
}
public companyName(): string {
return this.context.organization.name;
}
public invoiceAmount(): string {
return this.options.invoice.totalFormatted;
}
public primaryColor(): string {
return this.options?.brandingTemplate?.attributes?.primaryColor;
}
public invoiceMessage(): string {
return '';
}
public dueDate(): string {
return this.options?.invoice?.dueDateFormatted;
}
public dueDateLabel(): string {
return 'Due {dueDate}';
}
public invoiceNumber(): string {
return this.options?.invoice?.invoiceNo;
}
public invoiceNumberLabel(): string {
return 'Invoice # {invoiceNumber}';
}
public total(): string {
return this.options.invoice?.totalFormatted;
}
public totalLabel(): string {
return 'Total';
}
public dueAmount(): string {
return this.options?.invoice.dueAmountFormatted;
}
public dueAmountLabel(): string {
return 'Due Amount';
}
public viewInvoiceButtonLabel(): string {
return 'View Invoice';
}
public viewInvoiceButtonUrl(): string {
return '';
}
public items(): Array<any> {
return this.item(
this.options.invoice?.entries,
new GetInvoiceMailTemplateItemAttrsTransformer()
);
}
}
class GetInvoiceMailTemplateItemAttrsTransformer extends Transformer {
/**
* Include these attributes to item entry object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['quantity', 'label', 'rate'];
};
public excludeAttributes = (): string[] => {
return ['*'];
};
public quantity(entry): string {
return entry?.quantity;
}
public label(entry): string {
console.log(entry);
return entry?.item?.name;
}
public rate(entry): string {
return entry?.rateFormatted;
}
}

View File

@@ -0,0 +1,54 @@
import { SaleInvoiceMailOptions, SaleInvoiceMailState } from '@/interfaces';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject } from 'typedi';
import { SendSaleInvoiceMailCommon } from './SendInvoiceInvoiceMailCommon';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetSaleInvoiceMailStateTransformer } from './GetSaleInvoiceMailStateTransformer';
export class GetSaleInvoiceMailState {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private invoiceMail: SendSaleInvoiceMailCommon;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the invoice mail state of the given sale invoice.
* Invoice mail state includes the mail options, branding attributes and the invoice details.
*
* @param {number} tenantId
* @param {number} saleInvoiceId
* @returns {Promise<SaleInvoiceMailState>}
*/
async getInvoiceMailState(
tenantId: number,
saleInvoiceId: number
): Promise<SaleInvoiceMailState> {
const { SaleInvoice } = this.tenancy.models(tenantId);
const saleInvoice = await SaleInvoice.query()
.findById(saleInvoiceId)
.withGraphFetched('customer')
.withGraphFetched('entries.item')
.withGraphFetched('pdfTemplate')
.throwIfNotFound();
const mailOptions = await this.invoiceMail.getInvoiceMailOptions(
tenantId,
saleInvoiceId
);
// Transforms the sale invoice mail state.
const transformed = await this.transformer.transform(
tenantId,
saleInvoice,
new GetSaleInvoiceMailStateTransformer(),
{
mailOptions,
}
);
return transformed;
}
}

View File

@@ -0,0 +1,104 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { SaleInvoiceTransformer } from './SaleInvoiceTransformer';
import { ItemEntryTransformer } from './ItemEntryTransformer';
export class GetSaleInvoiceMailStateTransformer extends SaleInvoiceTransformer {
/**
* Exclude these attributes from user object.
* @returns {Array}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
public includeAttributes = (): string[] => {
return [
'invoiceDate',
'invoiceDateFormatted',
'dueDate',
'dueDateFormatted',
'dueAmount',
'dueAmountFormatted',
'total',
'totalFormatted',
'subtotal',
'subtotalFormatted',
'invoiceNo',
'entries',
'companyName',
'companyLogoUri',
'primaryColor',
];
};
protected companyName = () => {
return this.context.organization.name;
};
protected companyLogoUri = (invoice) => {
return invoice.pdfTemplate?.attributes?.companyLogoUri;
};
protected primaryColor = (invoice) => {
return invoice.pdfTemplate?.attributes?.primaryColor;
};
/**
*
* @param invoice
* @returns
*/
protected entries = (invoice) => {
return this.item(
invoice.entries,
new GetSaleInvoiceMailStateEntryTransformer(),
{
currencyCode: invoice.currencyCode,
}
);
};
/**
* Merges the mail options with the invoice object.
*/
public transform = (object: any) => {
return {
...this.options.mailOptions,
...object,
};
};
}
class GetSaleInvoiceMailStateEntryTransformer extends ItemEntryTransformer {
/**
* Exclude these attributes from user object.
* @returns {Array}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
public name = (entry) => {
return entry.item.name;
};
public includeAttributes = (): string[] => {
return [
'name',
'quantity',
'quantityFormatted',
'rate',
'rateFormatted',
'total',
'totalFormatted',
];
};
}

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

@@ -11,6 +11,7 @@ import {
ISystemUser,
ITenantUser,
InvoiceNotificationType,
SaleInvoiceMailState,
SendInvoiceMailDTO,
} from '@/interfaces';
import { Inject, Service } from 'typedi';
@@ -29,6 +30,8 @@ import { SendInvoiceMailReminder } from './SendSaleInvoiceMailReminder';
import { SendSaleInvoiceMail } from './SendSaleInvoiceMail';
import { GetSaleInvoiceMailReminder } from './GetSaleInvoiceMailReminder';
import { GetSaleInvoiceState } from './GetSaleInvoiceState';
import { GetSaleInvoiceBrandTemplate } from './GetSaleInvoiceBrandTemplate';
import { GetSaleInvoiceMailState } from './GetSaleInvoiceMailState';
@Service()
export class SaleInvoiceApplication {
@@ -72,7 +75,7 @@ export class SaleInvoiceApplication {
private sendSaleInvoiceMailService: SendSaleInvoiceMail;
@Inject()
private getSaleInvoiceReminderService: GetSaleInvoiceMailReminder;
private getSaleInvoiceMailStateService: GetSaleInvoiceMailState;
@Inject()
private getSaleInvoiceStateService: GetSaleInvoiceState;
@@ -361,10 +364,10 @@ export class SaleInvoiceApplication {
* Retrieves the default mail options of the given sale invoice.
* @param {number} tenantId
* @param {number} saleInvoiceid
* @returns {Promise<SendInvoiceMailDTO>}
* @returns {Promise<SaleInvoiceMailState>}
*/
public getSaleInvoiceMail(tenantId: number, saleInvoiceid: number) {
return this.sendSaleInvoiceMailService.getMailOption(
public getSaleInvoiceMailState(tenantId: number, saleInvoiceid: number) {
return this.getSaleInvoiceMailStateService.getInvoiceMailState(
tenantId,
saleInvoiceid
);

View File

@@ -7,6 +7,7 @@ import {
DEFAULT_INVOICE_MAIL_CONTENT,
DEFAULT_INVOICE_MAIL_SUBJECT,
} from './constants';
import { GetInvoicePaymentMail } from './GetInvoicePaymentMail';
@Service()
export class SendSaleInvoiceMailCommon {
@@ -19,6 +20,9 @@ export class SendSaleInvoiceMailCommon {
@Inject()
private contactMailNotification: ContactMailNotification;
@Inject()
private getInvoicePaymentMail: GetInvoicePaymentMail;
/**
* Retrieves the mail options.
* @param {number} tenantId - Tenant id.
@@ -27,11 +31,11 @@ export class SendSaleInvoiceMailCommon {
* @param {string} defaultBody - Subject body.
* @returns {Promise<SaleInvoiceMailOptions>}
*/
public async getMailOption(
public async getInvoiceMailOptions(
tenantId: number,
invoiceId: number,
defaultSubject: string = DEFAULT_INVOICE_MAIL_SUBJECT,
defaultBody: string = DEFAULT_INVOICE_MAIL_CONTENT
defaultMessage: string = DEFAULT_INVOICE_MAIL_CONTENT
): Promise<SaleInvoiceMailOptions> {
const { SaleInvoice } = this.tenancy.models(tenantId);
@@ -39,21 +43,54 @@ export class SendSaleInvoiceMailCommon {
.findById(invoiceId)
.throwIfNotFound();
const formatterData = await this.formatText(tenantId, invoiceId);
const contactMailDefaultOptions =
await this.contactMailNotification.getDefaultMailOptions(
tenantId,
saleInvoice.customerId
);
const formatArgs = await this.getInvoiceFormatterArgs(tenantId, invoiceId);
const mailOptions = await this.contactMailNotification.getMailOptions(
tenantId,
saleInvoice.customerId,
defaultSubject,
defaultBody,
formatterData
);
return {
...mailOptions,
...contactMailDefaultOptions,
subject: defaultSubject,
message: defaultMessage,
attachInvoice: true,
formatArgs,
};
}
/**
* Formats the given invoice mail options.
* @param {number} tenantId
* @param {number} invoiceId
* @param {SaleInvoiceMailOptions} mailOptions
* @returns {Promise<SaleInvoiceMailOptions>}
*/
public async formatInvoiceMailOptions(
tenantId: number,
invoiceId: number,
mailOptions: SaleInvoiceMailOptions
): Promise<SaleInvoiceMailOptions> {
const formatterArgs = await this.getInvoiceFormatterArgs(
tenantId,
invoiceId
);
const parsedOptions = await this.contactMailNotification.parseMailOptions(
tenantId,
mailOptions,
formatterArgs
);
const message = await this.getInvoicePaymentMail.getMailTemplate(
tenantId,
invoiceId,
{
// # Invoice message
invoiceMessage: parsedOptions.message,
}
);
return { ...parsedOptions, message };
}
/**
* Retrieves the formatted text of the given sale invoice.
* @param {number} tenantId - Tenant id.
@@ -61,7 +98,7 @@ export class SendSaleInvoiceMailCommon {
* @param {string} text - The given text.
* @returns {Promise<string>}
*/
public formatText = async (
public getInvoiceFormatterArgs = async (
tenantId: number,
invoiceId: number
): Promise<Record<string, string | number>> => {
@@ -69,15 +106,18 @@ export class SendSaleInvoiceMailCommon {
tenantId,
invoiceId
);
const commonArgs = await this.contactMailNotification.getCommonFormatArgs(
tenantId
);
return {
CustomerName: invoice.customer.displayName,
InvoiceNumber: invoice.invoiceNo,
InvoiceDueAmount: invoice.dueAmountFormatted,
InvoiceDueDate: invoice.dueDateFormatted,
InvoiceDate: invoice.invoiceDateFormatted,
InvoiceAmount: invoice.totalFormatted,
OverdueDays: invoice.overdueDays,
...commonArgs,
['Customer Name']: invoice.customer.displayName,
['Invoice Number']: invoice.invoiceNo,
['Invoice DueAmount']: invoice.dueAmountFormatted,
['Invoice DueDate']: invoice.dueDateFormatted,
['Invoice Date']: invoice.invoiceDateFormatted,
['Invoice Amount']: invoice.totalFormatted,
['Overdue Days']: invoice.overdueDays,
};
};
}

View File

@@ -4,12 +4,11 @@ import { ISaleInvoiceMailSend, SendInvoiceMailDTO } from '@/interfaces';
import { SaleInvoicePdf } from './SaleInvoicePdf';
import { SendSaleInvoiceMailCommon } from './SendInvoiceInvoiceMailCommon';
import {
DEFAULT_INVOICE_MAIL_CONTENT,
DEFAULT_INVOICE_MAIL_SUBJECT,
} from './constants';
import { parseAndValidateMailOptions } from '@/services/MailNotification/utils';
import events from '@/subscribers/events';
parseMailOptions,
validateRequiredMailOptions,
} from '@/services/MailNotification/utils';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
@Service()
export class SendSaleInvoiceMail {
@@ -51,21 +50,6 @@ export class SendSaleInvoiceMail {
} as ISaleInvoiceMailSend);
}
/**
* Retrieves the mail options of the given sale invoice.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @returns {Promise<SaleInvoiceMailOptions>}
*/
public async getMailOption(tenantId: number, saleInvoiceId: number) {
return this.invoiceMail.getMailOption(
tenantId,
saleInvoiceId,
DEFAULT_INVOICE_MAIL_SUBJECT,
DEFAULT_INVOICE_MAIL_CONTENT
);
}
/**
* Triggers the mail invoice.
* @param {number} tenantId
@@ -78,44 +62,58 @@ export class SendSaleInvoiceMail {
saleInvoiceId: number,
messageOptions: SendInvoiceMailDTO
) {
const defaultMessageOpts = await this.getMailOption(
const defaultMessageOptions = await this.invoiceMail.getInvoiceMailOptions(
tenantId,
saleInvoiceId
);
// Merge message opts with default options and validate the incoming options.
const messageOpts = parseAndValidateMailOptions(
defaultMessageOpts,
// Merges message options with default options and parses the options values.
const parsedMessageOptions = parseMailOptions(
defaultMessageOptions,
messageOptions
);
const mail = new Mail()
.setSubject(messageOpts.subject)
.setTo(messageOpts.to)
.setContent(messageOpts.body);
// Validates the required mail options.
validateRequiredMailOptions(parsedMessageOptions);
if (messageOpts.attachInvoice) {
// Retrieves document buffer of the invoice pdf document.
const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf(
const formattedMessageOptions =
await this.invoiceMail.formatInvoiceMailOptions(
tenantId,
saleInvoiceId
saleInvoiceId,
parsedMessageOptions
);
const mail = new Mail()
.setSubject(formattedMessageOptions.subject)
.setTo(formattedMessageOptions.to)
.setContent(formattedMessageOptions.message);
// Attach invoice document.
if (formattedMessageOptions.attachInvoice) {
// Retrieves document buffer of the invoice pdf document.
const [invoicePdfBuffer, invoiceFilename] =
await this.invoicePdf.saleInvoicePdf(tenantId, saleInvoiceId);
mail.setAttachments([
{ filename: 'invoice.pdf', content: invoicePdfBuffer },
{ filename: `${invoiceFilename}.pdf`, content: invoicePdfBuffer },
]);
}
// Triggers the event `onSaleInvoiceSend`.
await this.eventPublisher.emitAsync(events.saleInvoice.onMailSend, {
const eventPayload = {
tenantId,
saleInvoiceId,
messageOptions,
} as ISaleInvoiceMailSend);
formattedMessageOptions,
} as ISaleInvoiceMailSend;
// Triggers the event `onSaleInvoiceSend`.
await this.eventPublisher.emitAsync(
events.saleInvoice.onMailSend,
eventPayload
);
await mail.send();
// Triggers the event `onSaleInvoiceSend`.
await this.eventPublisher.emitAsync(events.saleInvoice.onMailSent, {
tenantId,
saleInvoiceId,
messageOptions,
} as ISaleInvoiceMailSend);
await this.eventPublisher.emitAsync(
events.saleInvoice.onMailSent,
eventPayload
);
}
}

View File

@@ -1,19 +1,19 @@
import config from '@/config';
export const DEFAULT_INVOICE_MAIL_SUBJECT =
'Invoice {InvoiceNumber} from {CompanyName}';
'Invoice {Invoice Number} from {Company Name}';
export const DEFAULT_INVOICE_MAIL_CONTENT = `
<p>Dear {CustomerName}</p>
<p>Dear {Customer Name}</p>
<p>Thank you for your business, You can view or print your invoice from attachements.</p>
<p>
Invoice <strong>#{InvoiceNumber}</strong><br />
Due Date : <strong>{InvoiceDueDate}</strong><br />
Amount : <strong>{InvoiceAmount}</strong></br />
Invoice <strong>#{Invoice Number}</strong><br />
Due Date : <strong>{Invoice Due Date}</strong><br />
Amount : <strong>{Invoice Amount}</strong></br />
</p>
<p>
<i>Regards</i><br />
<i>{CompanyName}</i>
<i>{Company Name}</i>
</p>
`;

View File

@@ -406,7 +406,7 @@ export const runningAmount = (amount: number) => {
};
};
export const formatSmsMessage = (message, args) => {
export const formatSmsMessage = (message: string, args) => {
let formattedMessage = message;
Object.keys(args).forEach((key) => {

View File

@@ -37,9 +37,9 @@
"@types/lodash": "^4.14.172",
"@types/node": "^14.14.9",
"@types/ramda": "^0.28.14",
"@types/react": "^16.14.28",
"@types/react": "18.3.4",
"@types/react-dom": "18.3.0",
"@types/react-body-classname": "^1.1.7",
"@types/react-dom": "^16.9.16",
"@types/react-helmet": "^6.1.11",
"@types/react-redux": "^7.1.24",
"@types/react-router-dom": "^5.3.3",
@@ -79,6 +79,7 @@
"path-browserify": "^1.0.1",
"plaid": "^9.3.0",
"plaid-threads": "^11.4.3",
"polished": "^4.3.1",
"prop-types": "15.8.1",
"query-string": "^7.1.1",
"ramda": "^0.27.1",

View File

@@ -45,7 +45,6 @@ import ProjectBillableEntriesFormDialog from '@/containers/Projects/containers/P
import TaxRateFormDialog from '@/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog';
import { DialogsName } from '@/constants/dialogs';
import InvoiceExchangeRateChangeDialog from '@/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog';
import InvoiceMailDialog from '@/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog';
import EstimateMailDialog from '@/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog';
import ReceiptMailDialog from '@/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog';
import PaymentMailDialog from '@/containers/Sales/PaymentsReceived/PaymentMailDialog/PaymentMailDialog';
@@ -144,7 +143,6 @@ export default function DialogsContainer() {
<InvoiceExchangeRateChangeDialog
dialogName={DialogsName.InvoiceExchangeRateChangeNotice}
/>
<InvoiceMailDialog dialogName={DialogsName.InvoiceMail} />
<EstimateMailDialog dialogName={DialogsName.EstimateMail} />
<ReceiptMailDialog dialogName={DialogsName.ReceiptMail} />
<PaymentMailDialog dialogName={DialogsName.PaymentMail} />

View File

@@ -31,6 +31,7 @@ import { PaymentReceivedCustomizeDrawer } from '@/containers/Sales/PaymentsRecei
import { BrandingTemplatesDrawer } from '@/containers/BrandingTemplates/BrandingTemplatesDrawer';
import { DRAWERS } from '@/constants/drawers';
import { InvoiceSendMailDrawer } from '@/containers/Sales/Invoices/InvoiceSendMailDrawer/InvoiceSendMailDrawer';
/**
* Drawers container of the dashboard.
@@ -79,6 +80,7 @@ export default function DrawersContainer() {
name={DRAWERS.PAYMENT_RECEIVED_CUSTOMIZE}
/>
<BrandingTemplatesDrawer name={DRAWERS.BRANDING_TEMPLATES} />
<InvoiceSendMailDrawer name={DRAWERS.INVOICE_SEND_MAIL} />
</div>
);
}

View File

@@ -33,5 +33,6 @@ export enum DRAWERS {
PAYMENT_RECEIVED_CUSTOMIZE = 'PAYMENT_RECEIVED_CUSTOMIZE',
BRANDING_TEMPLATES = 'BRANDING_TEMPLATES',
PAYMENT_INVOICE_PREVIEW = 'PAYMENT_INVOICE_PREVIEW',
STRIPE_PAYMENT_INTEGRATION_EDIT = 'STRIPE_PAYMENT_INTEGRATION_EDIT'
STRIPE_PAYMENT_INTEGRATION_EDIT = 'STRIPE_PAYMENT_INTEGRATION_EDIT',
INVOICE_SEND_MAIL = 'INVOICE_SEND_MAIL'
}

View File

@@ -23,8 +23,6 @@ function BrandingTemplatesDrawerRoot({
isOpen={isOpen}
name={name}
payload={payload}
size={'600px'}
style={{ borderLeftColor: '#cbcbcb' }}
>
<DrawerSuspense>
<BrandingTemplatesContent />

View File

@@ -37,8 +37,10 @@ export function ElementCustomizeFieldsMain() {
<Stack spacing={0} className={styles.mainFields}>
<ElementCustomizeHeader label={'Customize'} />
<Stack spacing={0} style={{ flex: '1 1 auto', overflow: 'auto' }}>
<Box style={{ flex: '1 1' }}>{CustomizeTabPanel}</Box>
<Stack spacing={0} flex="1 1 auto" overflow="auto">
<Box flex={'1 1'} overflow="auto">
{CustomizeTabPanel}
</Box>
<ElementCustomizeFooterActions />
</Stack>
</Stack>

View File

@@ -1,19 +1,12 @@
import { Box } from '@/components';
import { Box, Stack } from '@/components';
import { useElementCustomizeContext } from './ElementCustomizeProvider';
export function ElementCustomizePreviewContent() {
const { PaperTemplate } = useElementCustomizeContext();
return (
<Box
style={{
padding: '28px 24px 40px',
backgroundColor: '#F5F5F5',
overflow: 'auto',
flex: '1',
}}
>
<Stack backgroundColor="#F5F5F5" overflow="auto" flex="1 1 0%" spacing={0}>
{PaperTemplate}
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,24 @@
import { InvoicePaymentPage, PaymentPageProps } from './PaymentPage';
export interface InvoicePaymentPagePreviewProps
extends Partial<PaymentPageProps> { }
export function InvoicePaymentPagePreview(
props: InvoicePaymentPagePreviewProps,
) {
return (
<InvoicePaymentPage
paidAmount={'$1,000.00'}
dueDate={'20 Sep 2024'}
total={'$1,000.00'}
subtotal={'$1,000.00'}
dueAmount={'$1,000.00'}
customerName={'Ahmed Bouhuolia'}
organizationName={'Bigcapital Technology, Inc.'}
invoiceNumber={'INV-000001'}
companyLogoUri={' '}
organizationAddress={' '}
{...props}
/>
);
}

View File

@@ -0,0 +1,260 @@
import { Text, Classes, Button, Intent, ButtonProps } from '@blueprintjs/core';
import clsx from 'classnames';
import { css } from '@emotion/css';
import { lighten } from 'polished';
import { Box, Group, Stack } from '@/components';
import styles from './PaymentPortal.module.scss';
export interface PaymentPageProps {
// # Company name
companyLogoUri: string;
organizationName: string;
organizationAddress: string;
// # Colors
primaryColor?: string;
// # Customer name
customerName: string;
customerAddress?: string;
// # Subtotal
subtotal: string;
subtotalLabel?: string;
// # Total
total: string;
totalLabel?: string;
// # Due date
dueDate: string;
// # Paid amount
paidAmount: string;
paidAmountLabel?: string;
// # Due amount
dueAmount: string;
dueAmountLabel?: string;
// # Download invoice button
downloadInvoiceBtnLabel?: string;
downloadInvoiceButtonProps?: Partial<ButtonProps>;
// # View invoice button
viewInvoiceLabel?: string;
viewInvoiceButtonProps?: Partial<ButtonProps>;
// # Invoice number
invoiceNumber: string;
invoiceNumberLabel?: string;
// # Pay button
showPayButton?: boolean;
payButtonLabel?: string;
payInvoiceButtonProps?: Partial<ButtonProps>;
// # Buy note
buyNote?: string;
// # Copyright
copyrightText?: string;
classNames?: Record<string, string>
}
export function InvoicePaymentPage({
// # Company
companyLogoUri,
organizationName,
organizationAddress,
// # Colors
primaryColor = 'rgb(0, 82, 204)',
// # Customer
customerName,
customerAddress,
// # Subtotal
subtotal,
subtotalLabel = 'Subtotal',
// # Total
total,
totalLabel = 'Total',
// # Due date
dueDate,
// # Paid amount
paidAmount,
paidAmountLabel = 'Paid Amount (-)',
// # Invoice number
invoiceNumber,
invoiceNumberLabel = 'Invoice #',
// # Download invoice button
downloadInvoiceBtnLabel = 'Download Invoice',
downloadInvoiceButtonProps,
// # View invoice button
viewInvoiceLabel = 'View Invoice',
viewInvoiceButtonProps,
// # Due amount
dueAmount,
dueAmountLabel = 'Due Amount',
// # Pay button
showPayButton = true,
payButtonLabel = 'Pay {total}',
payInvoiceButtonProps,
// # Buy note
buyNote = 'By confirming your payment, you allow Bigcapital Technology, Inc. to charge you for this payment and save your payment information in accordance with their terms.',
// # Copyright
copyrightText = `© 2024 Bigcapital Technology, Inc. <br /> All rights reserved.`,
classNames,
}: PaymentPageProps) {
return (
<Box className={clsx(styles.root, classNames?.root)}>
<Stack spacing={0} className={styles.body}>
<Stack>
<Group spacing={10}>
{companyLogoUri && (
<Box
className={styles.companyLogoWrap}
style={{
backgroundImage: `url(${companyLogoUri})`,
}}
></Box>
)}
<Text>{organizationName}</Text>
</Group>
<Stack spacing={6}>
<h1 className={clsx(styles.bigTitle, classNames?.bigTitle)}>
{organizationName} Sent an Invoice for {total}
</h1>
<Group spacing={10}>
<Text className={clsx(Classes.TEXT_MUTED, styles.invoiceDueDate)}>
Invoice due {dueDate}{' '}
</Text>
</Group>
</Stack>
<Stack className={styles.address} spacing={2}>
<Box className={styles.customerName}>{customerName}</Box>
{customerAddress && (
<Box dangerouslySetInnerHTML={{ __html: customerAddress }} />
)}
</Stack>
<h2 className={styles.invoiceNumber}>
{invoiceNumberLabel} {invoiceNumber}
</h2>
<Stack spacing={0} className={styles.totals}>
<Group
position={'apart'}
className={clsx(styles.totalItem, styles.borderBottomGray)}
>
<Text>{subtotalLabel}</Text>
<Text>{subtotal}</Text>
</Group>
<Group position={'apart'} className={styles.totalItem}>
<Text>{totalLabel}</Text>
<Text style={{ fontWeight: 500 }}>{total}</Text>
</Group>
{/*
{sharableLinkMeta?.taxes?.map((tax, key) => (
<Group key={key} position={'apart'} className={styles.totalItem}>
<Text>{tax?.name}</Text>
<Text>{tax?.taxRateAmountFormatted}</Text>
</Group>
))} */}
<Group
position={'apart'}
className={clsx(styles.totalItem, styles.borderBottomGray)}
>
<Text>{paidAmountLabel}</Text>
<Text>{paidAmount}</Text>
</Group>
<Group
position={'apart'}
className={clsx(styles.totalItem, styles.borderBottomDark)}
>
<Text>{dueAmountLabel}</Text>
<Text style={{ fontWeight: 500 }}>{dueAmount}</Text>
</Group>
</Stack>
</Stack>
<Stack spacing={8} className={styles.footerButtons}>
<Button
minimal
className={clsx(styles.footerButton, styles.downloadInvoiceButton)}
{...downloadInvoiceButtonProps}
>
{downloadInvoiceBtnLabel}
</Button>
<Button
className={clsx(styles.footerButton, styles.viewInvoiceButton)}
{...viewInvoiceButtonProps}
>
{viewInvoiceLabel}
</Button>
{showPayButton && (
<Button
intent={Intent.PRIMARY}
className={clsx(
styles.footerButton,
styles.buyButton,
css`
&.bp4-intent-primary {
background-color: ${primaryColor};
&:hover,
&:focus {
background-color: ${lighten(0.1, primaryColor)};
}
}
`,
)}
{...payInvoiceButtonProps}
>
{payButtonLabel.replace('{total}', total)}
</Button>
)}
</Stack>
{buyNote && (
<Text className={clsx(Classes.TEXT_MUTED, styles.buyNote)}>
{buyNote}
</Text>
)}
</Stack>
<Stack spacing={18} className={styles.footer}>
<Box dangerouslySetInnerHTML={{ __html: organizationAddress }}></Box>
{copyrightText && (
<Stack
spacing={0}
className={styles.footerText}
dangerouslySetInnerHTML={{ __html: copyrightText }}
></Stack>
)}
</Stack>
</Box>
);
}

View File

@@ -7,7 +7,7 @@
border-radius: 10px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
width: 600px;
margin: 40px auto;
// margin: 40px auto;
color: #222;
background-color: #fff;
}
@@ -28,6 +28,7 @@
font-weight: 500;
color: #222;
font-size: 26px;
line-height: 1.35;
}
.invoiceDueDate{

View File

@@ -29,7 +29,13 @@ export const useStripeIntegrationEditBoot = () => {
return context;
};
export const StripeIntegrationEditBoot: React.FC = ({ children }) => {
interface StripeIntegrationEditBootProps {
children: React.ReactNode;
}
export const StripeIntegrationEditBoot: React.FC<
StripeIntegrationEditBootProps
> = ({ children }) => {
const {
payload: { stripePaymentMethodId },
} = useDrawerContext();

View File

@@ -19,7 +19,12 @@ function CreditNoteCustomizeDrawerRoot({
payload,
}) {
return (
<Drawer isOpen={isOpen} name={name} payload={payload} size={'100%'}>
<Drawer
isOpen={isOpen}
name={name}
payload={payload}
size={'calc(100% - 10px)'}
>
<DrawerSuspense>
<CreditNoteCustomizeDrawerBody />
</DrawerSuspense>

View File

@@ -20,7 +20,12 @@ function EstimateCustomizeDrawerRoot({
payload,
}) {
return (
<Drawer isOpen={isOpen} name={name} payload={payload} size={'100%'}>
<Drawer
isOpen={isOpen}
name={name}
payload={payload}
size={'calc(100% - 10px)'}
>
<DrawerSuspense>
<EstimateCustomizeDrawerBody />
</DrawerSuspense>

View File

@@ -1,10 +1,5 @@
import React from 'react';
import * as R from 'ramda';
import { useFormikContext } from 'formik';
import {
InvoicePaperTemplate,
InvoicePaperTemplateProps,
} from './InvoicePaperTemplate';
import { lazy, Suspense } from 'react';
import { Spinner, Tab } from '@blueprintjs/core';
import {
ElementCustomize,
ElementCustomizeContent,
@@ -16,9 +11,27 @@ import { InvoiceCustomizeSchema } from './InvoiceCustomizeForm.schema';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
import { useDrawerActions } from '@/hooks/state';
import { BrandingTemplateForm } from '@/containers/BrandingTemplates/BrandingTemplateForm';
import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider';
import { initialValues } from './constants';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { InvoiceCustomizeTabs } from './InvoiceCustomizeTabs';
const InvoiceCustomizePaymentPreview = lazy(() =>
import('./InvoiceCustomizePaymentPreview').then((module) => ({
default: module.InvoiceCustomizePaymentPreview,
})),
);
const InvoiceCustomizeMailReceiptPreview = lazy(() =>
import('./InvoiceCustomizeMailReceiptPreview').then((module) => ({
default: module.InvoiceCustomizeMailReceiptPreview,
})),
);
const InvoiceCustomizePdfPreview = lazy(() =>
import('./InvoiceCustomizePdfPreview').then((module) => ({
default: module.InvoiceCustomizePdfPreview,
})),
);
/**
* Invoice branding template customize.
@@ -56,7 +69,39 @@ function InvoiceCustomizeFormContent() {
return (
<ElementCustomizeContent>
<ElementCustomize.PaperTemplate>
<InvoicePaperTemplateFormConnected />
<InvoiceCustomizeTabs
defaultSelectedTabId={'pdf-document'}
id={'customize-preview-tabs'}
renderActiveTabPanelOnly
>
<Tab
id="pdf-document"
title={'PDF document'}
panel={
<Suspense fallback={<Spinner />}>
<InvoiceCustomizePdfPreview />
</Suspense>
}
/>
<Tab
id={'payment-page'}
title={'Payment page'}
panel={
<Suspense fallback={<Spinner />}>
<InvoiceCustomizePaymentPreview />
</Suspense>
}
/>
<Tab
id={'email-receipt'}
title={'Email receipt'}
panel={
<Suspense fallback={<Spinner />}>
<InvoiceCustomizeMailReceiptPreview mx={'auto'} />
</Suspense>
}
/>
</InvoiceCustomizeTabs>
</ElementCustomize.PaperTemplate>
<ElementCustomize.FieldsTab id={'general'} label={'General'}>
@@ -73,28 +118,3 @@ function InvoiceCustomizeFormContent() {
</ElementCustomizeContent>
);
}
/**
* Injects the `InvoicePaperTemplate` component props from the form and branding states.
* @param Component
* @returns {JSX.Element}
*/
const withInvoicePreviewTemplateProps = <P extends object>(
Component: React.ComponentType<P>,
) => {
return (props: Omit<P, keyof InvoicePaperTemplateProps>) => {
const { values } = useFormikContext<InvoiceCustomizeFormValues>();
const { brandingState } = useElementCustomizeContext();
const mergedProps: InvoicePaperTemplateProps = {
...brandingState,
...values,
};
return <Component {...(props as P)} {...mergedProps} />;
};
};
export const InvoicePaperTemplateFormConnected = R.compose(
withInvoicePreviewTemplateProps,
)(InvoicePaperTemplate);

View File

@@ -17,7 +17,12 @@ function InvoiceCustomizeDrawerRoot({
payload,
}) {
return (
<Drawer isOpen={isOpen} name={name} size={'100%'} payload={payload}>
<Drawer
isOpen={isOpen}
name={name}
payload={payload}
size={'calc(100% - 10px)'}
>
<DrawerSuspense>
<InvoiceCustomize />
</DrawerSuspense>

View File

@@ -1,5 +1,6 @@
// @ts-nocheck
import { Classes, Text } from '@blueprintjs/core';
import { Link } from 'react-router-dom';
import {
FFormGroup,
FieldRequiredHint,
@@ -13,7 +14,6 @@ import { CreditCardIcon } from '@/icons/CreditCardIcon';
import { Overlay } from './Overlay';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { BrandingCompanyLogoUploadField } from '@/containers/ElementCustomize/components/BrandingCompanyLogoUploadField';
import { Link } from 'react-router-dom';
import { MANAGE_LINK_URL } from './constants';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
import { useDrawerActions } from '@/hooks/state';

View File

@@ -0,0 +1,35 @@
import * as R from 'ramda';
import { useFormikContext } from 'formik';
import { InvoicePaymentPagePreviewProps } from '@/containers/PaymentPortal/InvoicePaymentPagePreview';
import { InvoiceCustomizeFormValues } from './types';
import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider';
import { InvoiceMailReceiptPreview } from './InvoiceMailReceiptPreview';
import { Box } from '@/components';
const withInvoiceMailReceiptPreviewConnected = <P extends Object>(
Component: React.ComponentType<P>,
) => {
return (props: Omit<P, keyof InvoicePaymentPagePreviewProps>) => {
const { values } = useFormikContext<InvoiceCustomizeFormValues>();
const { brandingState } = useElementCustomizeContext();
const mergedBrandingState = {
...brandingState,
...values,
};
const mergedProps: InvoicePaymentPagePreviewProps = {
companyLogoUri: mergedBrandingState?.companyLogoUri,
primaryColor: mergedBrandingState?.primaryColor,
// organizationAddress: mergedBrandingState,
};
return (
<Box px={4} pt={8} pb={16}>
<Component {...(props as P)} {...mergedProps} />
</Box>
);
};
};
export const InvoiceCustomizeMailReceiptPreview = R.compose(
withInvoiceMailReceiptPreviewConnected,
)(InvoiceMailReceiptPreview);

View File

@@ -0,0 +1,52 @@
import * as R from 'ramda';
import { useFormikContext } from 'formik';
import { css } from '@emotion/css';
import {
InvoicePaymentPagePreview,
InvoicePaymentPagePreviewProps,
} from '@/containers/PaymentPortal/InvoicePaymentPagePreview';
import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider';
import { InvoiceCustomizeFormValues } from './types';
import { Box } from '@/components';
const withInvoicePaymentPreviewPageProps = <P extends Object>(
Component: React.ComponentType<P>,
) => {
return (props: Omit<P, keyof InvoicePaymentPagePreviewProps>) => {
const { values } = useFormikContext<InvoiceCustomizeFormValues>();
const { brandingState } = useElementCustomizeContext();
const mergedBrandingState = {
...brandingState,
...values,
};
const mergedProps: InvoicePaymentPagePreviewProps = {
companyLogoUri: mergedBrandingState?.companyLogoUri,
primaryColor: mergedBrandingState?.primaryColor,
};
return (
<Box px={4} pt={8} pb={16}>
<Component
{...(props as P)}
{...mergedProps}
classNames={{
root: css`
margin: 0 auto;
border-radius: 5px !important;
transform: scale(0.9);
transform-origin: top;
boxshadow: 0 10px 15px rgba(0, 0, 0, 0.05) !important;
`,
bigTitle: css`
color: #333 !important;
`,
}}
/>
</Box>
);
};
};
export const InvoiceCustomizePaymentPreview = R.compose(
withInvoicePaymentPreviewPageProps,
)(InvoicePaymentPagePreview);

View File

@@ -0,0 +1,37 @@
import * as R from 'ramda';
import { useFormikContext } from 'formik';
import {
InvoicePaperTemplate,
InvoicePaperTemplateProps,
} from './InvoicePaperTemplate';
import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider';
import { InvoiceCustomizeFormValues } from './types';
import { Box } from '@/components';
/**
* Injects the `InvoicePaperTemplate` component props from the form and branding states.
* @param {React.ComponentType<P>} Component
* @returns {JSX.Element}
*/
const withInvoicePreviewTemplateProps = <P extends object>(
Component: React.ComponentType<P>,
) => {
return (props: Omit<P, keyof InvoicePaperTemplateProps>) => {
const { values } = useFormikContext<InvoiceCustomizeFormValues>();
const { brandingState } = useElementCustomizeContext();
const mergedProps: InvoicePaperTemplateProps = {
...brandingState,
...values,
};
return (
<Box px={4} py={6}>
<Component {...(props as P)} {...mergedProps} />
</Box>
);
};
};
export const InvoiceCustomizePdfPreview = R.compose(
withInvoicePreviewTemplateProps,
)(InvoicePaperTemplate);

View File

@@ -0,0 +1,37 @@
import { css } from '@emotion/css';
import { Tabs, TabsProps } from '@blueprintjs/core';
interface InvoiceCustomizeTabsProps extends TabsProps {}
export function InvoiceCustomizeTabs(props: InvoiceCustomizeTabsProps) {
return (
<Tabs
className={css`
overflow: hidden;
flex: 1 1;
display: flex;
flex-direction: column;
.bp4-tab-list {
padding: 0 20px;
background: #fff;
border-bottom: 1px solid #dcdcdd;
}
.bp4-tab {
line-height: 40px;
}
.bp4-tab:not([aria-selected='true']) {
color: #5f6b7c;
}
.bp4-tab-indicator-wrapper .bp4-tab-indicator {
height: 2px;
}
.bp4-tab-panel{
margin: 0;
overflow: auto;
}
`}
{...props}
/>
);
}

View File

@@ -0,0 +1,196 @@
import { Button, Intent } from '@blueprintjs/core';
import { css } from '@emotion/css';
import { x } from '@xstyled/emotion';
import { lighten } from 'polished';
import { Group, Stack, StackProps } from '@/components';
export interface InvoiceMailReceiptProps extends StackProps {
// # Company
companyName: string;
companyLogoUri?: string;
// # Colors
primaryColor?: string;
// # Due date
dueDate: string;
dueDateLabel?: string;
// # Due amount
dueAmountLabel?: string;
dueAmount: string;
// # Total
total: string;
totalLabel?: string;
// # Invoice number
invoiceNumber: string;
invoiceNumberLabel?: string;
// # Mail message
message: string;
// # Invoice items
items?: Array<{ label: string; total: string; quantity: string | number }>;
// # View invoice button
showViewInvoiceButton?: boolean;
viewInvoiceButtonLabel?: string;
viewInvoiceButtonOnClick?: () => void;
}
export function InvoiceMailReceipt({
// Company
companyName,
companyLogoUri,
// # Colors
primaryColor = 'rgb(0, 82, 204)',
// Due date
dueDate,
dueDateLabel = 'Due',
// Due amount
dueAmountLabel = 'Due Amount',
dueAmount,
// Total
total,
totalLabel = 'Total',
// Invoice number
invoiceNumber,
invoiceNumberLabel = 'Invoice #',
// Invoice message
message,
// Invoice items
items,
// View invoice button
showViewInvoiceButton = true,
viewInvoiceButtonLabel = 'View Invoice',
viewInvoiceButtonOnClick,
...restProps
}: InvoiceMailReceiptProps) {
return (
<Stack
bg="white"
w={'100%'}
maxWidth={'500px'}
p={'35px 25px'}
borderRadius={'5px'}
boxShadow={'0 10px 15px rgba(0, 0, 0, 0.05)'}
color={'black'}
{...restProps}
>
<Stack spacing={16} textAlign={'center'}>
{companyLogoUri && (
<x.div
h="90px"
w="90px"
mx="auto"
backgroundRepeat="no-repeat"
backgroundPosition="center center"
backgroundSize="contain"
backgroundImage={`url("${companyLogoUri}")`}
></x.div>
)}
<Stack spacing={8}>
<x.h1 m={0} fontSize={'18px'} fontWeight={500} color="#404854">
{companyName}
</x.h1>
<x.h3 color="#383E47" fontWeight={500}>
{total}
</x.h3>
<x.span fontSize={'13px'} color="#404854">
{invoiceNumberLabel} {invoiceNumber}
</x.span>
<x.span fontSize={'13px'} color="#404854">
{dueDateLabel} {dueDate}
</x.span>
</Stack>
</Stack>
<x.p m={0} whiteSpace={'pre-line'} color="#252A31">
{message}
</x.p>
{showViewInvoiceButton && (
<Button
large
intent={Intent.PRIMARY}
className={css`
&.bp4-intent-primary {
background-color: ${primaryColor};
&:hover,
&:focus {
background-color: ${lighten(0.1, primaryColor)};
}
}
&.bp4-large {
min-height: 38px;
}
`}
onClick={viewInvoiceButtonOnClick}
>
{viewInvoiceButtonLabel}
</Button>
)}
<Stack spacing={0}>
{items?.map((item, key) => (
<Group
key={key}
h={'40px'}
position={'apart'}
borderBottomStyle="solid"
borderBottomWidth={'1px'}
borderBottomColor={'#D9D9D9'}
borderTopStyle="solid"
borderTopColor={'#D9D9D9'}
borderTopWidth={'1px'}
>
<x.span>{item.label}</x.span>
<x.span>
{item.quantity} x {item.total}
</x.span>
</Group>
))}
<Group
h={'40px'}
position={'apart'}
borderBottomStyle="solid"
borderBottomWidth={'1px'}
borderColor={'#000'}
>
<x.span fontWeight={500}>{totalLabel}</x.span>
<x.span fontWeight={600} fontSize={15}>
{total}
</x.span>
</Group>
<Group
h={'40px'}
position={'apart'}
borderBottomStyle="solid"
borderBottomWidth={'1px'}
borderBottomColor={'#000'}
>
<x.span fontWeight={500}>{dueAmountLabel}</x.span>
<x.span fontWeight={600} fontSize={15}>
{dueAmount}
</x.span>
</Group>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,38 @@
import {
InvoiceMailReceipt,
InvoiceMailReceiptProps,
} from './InvoiceMailReceipt';
export interface InvoiceMailReceiptPreviewProps
extends Partial<InvoiceMailReceiptProps> {}
const receiptMessage = `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
`;
export function InvoiceMailReceiptPreview(
props: InvoiceMailReceiptPreviewProps,
) {
const propsWithDefaults = {
message: receiptMessage,
companyName: 'Bigcapital Technology, Inc.',
total: '$1,000.00',
invoiceNumber: 'INV-0001',
dueDate: '2 Oct 2024',
dueAmount: '$1,000.00',
items: [{ label: 'Web development', total: '$1000.00', quantity: 1 }],
companyLogoUri: ' ',
...props,
};
return <InvoiceMailReceipt {...propsWithDefaults} />;
}

View File

@@ -1,5 +1,9 @@
import { Classes, Text } from '@blueprintjs/core';
import { PaperTemplate, PaperTemplateTotalBorder } from './PaperTemplate';
import {
PaperTemplate,
PaperTemplateProps,
PaperTemplateTotalBorder,
} from './PaperTemplate';
import { Box, Group, Stack } from '@/components';
import {
DefaultPdfTemplateTerms,
@@ -23,7 +27,7 @@ interface PaperTax {
amount: string;
}
export interface InvoicePaperTemplateProps {
export interface InvoicePaperTemplateProps extends PaperTemplateProps {
primaryColor?: string;
secondaryColor?: string;
@@ -177,9 +181,14 @@ export function InvoicePaperTemplate({
statementLabel = 'Statement',
showStatement = true,
statement = DefaultPdfTemplateStatement,
...props
}: InvoicePaperTemplateProps) {
return (
<PaperTemplate primaryColor={primaryColor} secondaryColor={secondaryColor}>
<PaperTemplate
primaryColor={primaryColor}
secondaryColor={secondaryColor}
{...props}
>
<Stack spacing={24}>
<Group align="start" spacing={10}>
<Stack flex={1}>

View File

@@ -1,10 +1,10 @@
import React from 'react';
import clsx from 'classnames';
import { get, isFunction } from 'lodash';
import { Box, Group, GroupProps } from '@/components';
import { Box, BoxProps, Group, GroupProps } from '@/components';
import styles from './InvoicePaperTemplate.module.scss';
export interface PaperTemplateProps {
export interface PaperTemplateProps extends BoxProps {
primaryColor?: string;
secondaryColor?: string;
children?: React.ReactNode;
@@ -14,13 +14,13 @@ export function PaperTemplate({
primaryColor,
secondaryColor,
children,
...restProps
}: PaperTemplateProps) {
return (
<div className={styles.root}>
<Box {...restProps} className={clsx(styles.root, restProps?.className)}>
<style>{`:root { --invoice-primary-color: ${primaryColor}; --invoice-secondary-color: ${secondaryColor}; }`}</style>
{children}
</div>
</Box>
);
}
@@ -118,9 +118,9 @@ PaperTemplate.TotalLine = ({
);
};
PaperTemplate.MutedText = () => { };
PaperTemplate.MutedText = () => {};
PaperTemplate.Text = () => { };
PaperTemplate.Text = () => {};
PaperTemplate.AddressesGroup = (props: GroupProps) => {
return (

View File

@@ -1,39 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Dialog, DialogSuspense } from '@/components';
import withDialogRedux from '@/components/DialogReduxConnect';
import { compose } from '@/utils';
const InvoiceFormMailDeliverDialogContent = React.lazy(
() => import('./InvoiceFormMailDeliverDialogContent'),
);
/**
* Invoice mail dialog.
*/
function InvoiceFormMailDeliverDialog({
dialogName,
payload: { invoiceId = null },
isOpen,
}) {
return (
<Dialog
name={dialogName}
title={'Invoice Mail'}
isOpen={isOpen}
canEscapeJeyClose={false}
isCloseButtonShown={false}
autoFocus={true}
style={{ width: 600 }}
>
<DialogSuspense>
<InvoiceFormMailDeliverDialogContent
dialogName={dialogName}
invoiceId={invoiceId}
/>
</DialogSuspense>
</Dialog>
);
}
export default compose(withDialogRedux())(InvoiceFormMailDeliverDialog);

View File

@@ -1,40 +0,0 @@
// @ts-nocheck
import * as R from 'ramda';
import { useHistory } from 'react-router-dom';
import InvoiceMailDialogContent from '../../../InvoiceMailDialog/InvoiceMailDialogContent';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { DialogsName } from '@/constants/dialogs';
interface InvoiceFormDeliverDialogContent {
invoiceId: number;
}
function InvoiceFormDeliverDialogContentRoot({
invoiceId,
// #withDialogActions
closeDialog,
}: InvoiceFormDeliverDialogContent) {
const history = useHistory();
const handleSubmit = () => {
history.push('/invoices');
closeDialog(DialogsName.InvoiceFormMailDeliver);
};
const handleCancel = () => {
history.push('/invoices');
closeDialog(DialogsName.InvoiceFormMailDeliver);
};
return (
<InvoiceMailDialogContent
invoiceId={invoiceId}
onFormSubmit={handleSubmit}
onCancelClick={handleCancel}
/>
);
}
export default R.compose(withDialogActions)(
InvoiceFormDeliverDialogContentRoot,
);

View File

@@ -2,7 +2,6 @@
import { useFormikContext } from 'formik';
import InvoiceNumberDialog from '@/containers/Dialogs/InvoiceNumberDialog';
import { DialogsName } from '@/constants/dialogs';
import InvoiceFormMailDeliverDialog from './Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog';
/**
* Invoice form dialogs.
@@ -28,9 +27,6 @@ export default function InvoiceFormDialogs() {
dialogName={DialogsName.InvoiceNumberSettings}
onConfirm={handleInvoiceNumberFormConfirm}
/>
<InvoiceFormMailDeliverDialog
dialogName={DialogsName.InvoiceFormMailDeliver}
/>
</>
);
}

View File

@@ -1,35 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Dialog, DialogSuspense } from '@/components';
import withDialogRedux from '@/components/DialogReduxConnect';
import { compose } from '@/utils';
const InvoiceMailDialogBody = React.lazy(
() => import('./InvoiceMailDialogBody'),
);
/**
* Invoice mail dialog.
*/
function InvoiceMailDialog({
dialogName,
payload: { invoiceId = null },
isOpen,
}) {
return (
<Dialog
name={dialogName}
title={'Invoice Mail'}
isOpen={isOpen}
canEscapeJeyClose={false}
isCloseButtonShown={false}
autoFocus={true}
style={{ width: 600 }}
>
<DialogSuspense>
<InvoiceMailDialogBody invoiceId={invoiceId} />
</DialogSuspense>
</Dialog>
);
}
export default compose(withDialogRedux())(InvoiceMailDialog);

View File

@@ -1,36 +0,0 @@
// @ts-nocheck
import * as R from 'ramda';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import InvoiceMailDialogContent, {
InvoiceMailDialogContentProps,
} from './InvoiceMailDialogContent';
import { DialogsName } from '@/constants/dialogs';
export interface InvoiceMailDialogBodyProps
extends InvoiceMailDialogContentProps {}
function InvoiceMailDialogBodyRoot({
invoiceId,
onCancelClick,
onFormSubmit,
// #withDialogActions
closeDialog,
}: InvoiceMailDialogBodyProps) {
const handleCancelClick = () => {
closeDialog(DialogsName.InvoiceMail);
};
const handleSubmitClick = () => {
closeDialog(DialogsName.InvoiceMail);
};
return (
<InvoiceMailDialogContent
invoiceId={invoiceId}
onCancelClick={handleCancelClick}
onFormSubmit={handleSubmitClick}
/>
);
}
export default R.compose(withDialogActions)(InvoiceMailDialogBodyRoot);

View File

@@ -1,48 +0,0 @@
// @ts-nocheck
import React, { createContext } from 'react';
import { useSaleInvoiceDefaultOptions } from '@/hooks/query';
import { DialogContent } from '@/components';
interface InvoiceMailDialogBootValues {
invoiceId: number;
mailOptions: any;
redirectToInvoicesList: boolean;
}
const InvoiceMailDialagBoot = createContext<InvoiceMailDialogBootValues>();
interface InvoiceMailDialogBootProps {
invoiceId: number;
redirectToInvoicesList?: boolean;
children: React.ReactNode;
}
/**
* Invoice mail dialog boot provider.
*/
function InvoiceMailDialogBoot({
invoiceId,
redirectToInvoicesList,
...props
}: InvoiceMailDialogBootProps) {
const { data: mailOptions, isLoading: isMailOptionsLoading } =
useSaleInvoiceDefaultOptions(invoiceId);
const provider = {
saleInvoiceId: invoiceId,
mailOptions,
isMailOptionsLoading,
redirectToInvoicesList,
};
return (
<DialogContent isLoading={isMailOptionsLoading}>
<InvoiceMailDialagBoot.Provider value={provider} {...props} />
</DialogContent>
);
}
const useInvoiceMailDialogBoot = () =>
React.useContext<InvoiceMailDialogBootValues>(InvoiceMailDialagBoot);
export { InvoiceMailDialogBoot, useInvoiceMailDialogBoot };

View File

@@ -1,22 +0,0 @@
import { InvoiceMailDialogBoot } from './InvoiceMailDialogBoot';
import { InvoiceMailDialogForm } from './InvoiceMailDialogForm';
export interface InvoiceMailDialogContentProps {
invoiceId: number;
onFormSubmit?: () => void;
onCancelClick?: () => void;
}
export default function InvoiceMailDialogContent({
invoiceId,
onFormSubmit,
onCancelClick,
}: InvoiceMailDialogContentProps) {
return (
<InvoiceMailDialogBoot invoiceId={invoiceId}>
<InvoiceMailDialogForm
onFormSubmit={onFormSubmit}
onCancelClick={onCancelClick}
/>
</InvoiceMailDialogBoot>
);
}

View File

@@ -1,9 +0,0 @@
// @ts-nocheck
import * as Yup from 'yup';
export const InvoiceMailFormSchema = Yup.object().shape({
from: Yup.array().required().min(1).max(5).label('From address'),
to: Yup.array().required().min(1).max(5).label('To address'),
subject: Yup.string().required().label('Mail subject'),
body: Yup.string().required().label('Mail body'),
});

View File

@@ -1,69 +0,0 @@
// @ts-nocheck
import { Formik } from 'formik';
import { Intent } from '@blueprintjs/core';
import { useInvoiceMailDialogBoot } from './InvoiceMailDialogBoot';
import { AppToaster } from '@/components';
import { useSendSaleInvoiceMail } from '@/hooks/query';
import { InvoiceMailDialogFormContent } from './InvoiceMailDialogFormContent';
import { InvoiceMailFormSchema } from './InvoiceMailDialogForm.schema';
import {
MailNotificationFormValues,
initialMailNotificationValues,
transformMailFormToRequest,
transformMailFormToInitialValues,
} from '@/containers/SendMailNotification/utils';
const initialFormValues = {
...initialMailNotificationValues,
attachInvoice: true,
};
interface InvoiceMailFormValues extends MailNotificationFormValues {
attachInvoice: boolean;
}
export function InvoiceMailDialogForm({ onFormSubmit, onCancelClick }) {
const { mailOptions, saleInvoiceId } = useInvoiceMailDialogBoot();
const { mutateAsync: sendInvoiceMail } = useSendSaleInvoiceMail();
const initialValues = transformMailFormToInitialValues(
mailOptions,
initialFormValues,
);
// Handle the form submitting.
const handleSubmit = (values: InvoiceMailFormValues, { setSubmitting }) => {
const reqValues = transformMailFormToRequest(values);
setSubmitting(true);
sendInvoiceMail([saleInvoiceId, reqValues])
.then(() => {
AppToaster.show({
message: 'The mail notification has been sent successfully.',
intent: Intent.SUCCESS,
});
setSubmitting(false);
onFormSubmit && onFormSubmit(values);
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
setSubmitting(false);
});
};
// Handle the close button click.
const handleClose = () => {
onCancelClick && onCancelClick();
};
return (
<Formik
initialValues={initialValues}
validationSchema={InvoiceMailFormSchema}
onSubmit={handleSubmit}
>
<InvoiceMailDialogFormContent onClose={handleClose} />
</Formik>
);
}

View File

@@ -1,66 +0,0 @@
// @ts-nocheck
import { Form, useFormikContext } from 'formik';
import { Button, Classes, Intent } from '@blueprintjs/core';
import styled from 'styled-components';
import { FFormGroup, FSwitch } from '@/components';
import { MailNotificationForm } from '@/containers/SendMailNotification';
import { saveInvoke } from '@/utils';
import { useInvoiceMailDialogBoot } from './InvoiceMailDialogBoot';
interface SendMailNotificationFormProps {
onClose?: () => void;
}
export function InvoiceMailDialogFormContent({
onClose,
}: SendMailNotificationFormProps) {
const { isSubmitting } = useFormikContext();
const { mailOptions } = useInvoiceMailDialogBoot();
const handleClose = () => {
saveInvoke(onClose);
};
return (
<Form>
<div className={Classes.DIALOG_BODY}>
<MailNotificationForm
fromAddresses={mailOptions.from_addresses}
toAddresses={mailOptions.to_addresses}
/>
<AttachFormGroup name={'attachInvoice'} inline>
<FSwitch name={'attachInvoice'} label={'Attach Invoice'} />
</AttachFormGroup>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
disabled={isSubmitting}
onClick={handleClose}
style={{ minWidth: '65px' }}
>
Close
</Button>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
style={{ minWidth: '75px' }}
type="submit"
>
Send
</Button>
</div>
</div>
</Form>
);
}
const AttachFormGroup = styled(FFormGroup)`
background: #f8f9fb;
margin-top: 0.6rem;
padding: 4px 14px;
border-radius: 5px;
border: 1px solid #dcdcdd;
`;

View File

@@ -1,2 +0,0 @@
export * from './InvoiceMailDialog';
export * from './InvoiceMailDialogContent';

View File

@@ -0,0 +1,45 @@
import { useMemo } from 'react';
import { css } from '@emotion/css';
import { Box, } from '@/components';
import { InvoiceMailReceiptPreview } from '../InvoiceCustomize/InvoiceMailReceiptPreview';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { InvoiceSendMailPreviewWithHeader } from './InvoiceSendMailHeaderPreview';
import { useSendInvoiceMailMessage } from './_hooks';
export function InvoiceMailReceiptPreviewConneceted() {
const mailMessage = useSendInvoiceMailMessage();
const { invoiceMailState } = useInvoiceSendMailBoot();
const items = useMemo(
() =>
invoiceMailState?.entries?.map((entry: any) => ({
quantity: entry.quantity,
total: entry.totalFormatted,
label: entry.name,
})),
[invoiceMailState?.entries],
);
return (
<InvoiceSendMailPreviewWithHeader>
<Box px={4} pt={8} pb={16}>
<InvoiceMailReceiptPreview
companyName={invoiceMailState?.companyName}
// companyLogoUri={invoiceMailState?.companyLogoUri}
primaryColor={invoiceMailState?.primaryColor}
total={invoiceMailState?.totalFormatted}
dueDate={invoiceMailState?.dueDateFormatted}
dueAmount={invoiceMailState?.dueAmountFormatted}
invoiceNumber={invoiceMailState?.invoiceNo}
items={items}
message={mailMessage}
className={css`
margin: 0 auto;
`}
/>
</Box>
</InvoiceSendMailPreviewWithHeader>
);
}

View File

@@ -0,0 +1,26 @@
import { Group, Stack } from '@/components';
import { Classes } from '@blueprintjs/core';
import { InvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
import { InvoiceSendMailForm } from './InvoiceSendMailForm';
import { InvoiceSendMailHeader } from './InvoiceSendMailHeader';
import { InvoiceSendMailPreview } from './InvoiceSendMailPreview';
import { InvoiceSendMailFields } from './InvoiceSendMailFields';
export function InvoiceSendMailContent() {
return (
<Stack className={Classes.DRAWER_BODY}>
<InvoiceSendMailBoot>
<InvoiceSendMailForm>
<Stack spacing={0} flex={1} overflow="hidden">
<InvoiceSendMailHeader label={'Send Invoice Mail'} />
<Group flex={1} overflow="auto" spacing={0} alignItems={'stretch'}>
<InvoiceSendMailFields />
<InvoiceSendMailPreview />
</Group>
</Stack>
</InvoiceSendMailForm>
</InvoiceSendMailBoot>
</Stack>
);
}

View File

@@ -0,0 +1,57 @@
// @ts-nocheck
import React, { createContext, useContext } from 'react';
import { Spinner } from '@blueprintjs/core';
import {
GetSaleInvoiceDefaultOptionsResponse,
useSaleInvoiceMailState,
} from '@/hooks/query';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
interface InvoiceSendMailBootValues {
invoiceId: number;
invoiceMailState: GetSaleInvoiceDefaultOptionsResponse | undefined;
isInvoiceMailState: boolean;
}
interface InvoiceSendMailBootProps {
children: React.ReactNode;
}
const InvoiceSendMailContentBootContext =
createContext<InvoiceSendMailBootValues>({} as InvoiceSendMailBootValues);
export const InvoiceSendMailBoot = ({ children }: InvoiceSendMailBootProps) => {
const {
payload: { invoiceId },
} = useDrawerContext();
// Invoice mail options.
const { data: invoiceMailState, isLoading: isInvoiceMailState } =
useSaleInvoiceMailState(invoiceId);
const isLoading = isInvoiceMailState;
if (isLoading) {
return <Spinner size={20} />;
}
const value = {
invoiceId,
// # Invoice mail options
isInvoiceMailState,
invoiceMailState,
};
return (
<InvoiceSendMailContentBootContext.Provider value={value}>
{children}
</InvoiceSendMailContentBootContext.Provider>
);
};
InvoiceSendMailBoot.displayName = 'InvoiceSendMailBoot';
export const useInvoiceSendMailBoot = () => {
return useContext<InvoiceSendMailBootValues>(
InvoiceSendMailContentBootContext,
);
};

View File

@@ -0,0 +1,41 @@
import * as R from 'ramda';
import { Drawer, DrawerSuspense } from '@/components';
import withDrawers from '@/containers/Drawer/withDrawers';
import React from 'react';
const InvoiceSendMailContent = React.lazy(() =>
import('./InvoiceSendMailContent').then((module) => ({
default: module.InvoiceSendMailContent,
})),
);
interface InvoiceSendMailDrawerProps {
name: string;
isOpen?: boolean;
payload?: any;
}
function InvoiceSendMailDrawerRoot({
name,
// #withDrawer
isOpen,
payload,
}: InvoiceSendMailDrawerProps) {
return (
<Drawer
isOpen={isOpen}
name={name}
payload={payload}
size={'calc(100% - 10px)'}
>
<DrawerSuspense>
<InvoiceSendMailContent />
</DrawerSuspense>
</Drawer>
);
}
export const InvoiceSendMailDrawer = R.compose(withDrawers())(
InvoiceSendMailDrawerRoot,
);

View File

@@ -0,0 +1,312 @@
// @ts-nocheck
import { Button, Intent, MenuItem, Position } from '@blueprintjs/core';
import { useRef, useState, useMemo, useCallback } from 'react';
import { useFormikContext } from 'formik';
import { SelectOptionProps } from '@blueprintjs-formik/select';
import { css } from '@emotion/css';
import {
FCheckbox,
FFormGroup,
FInputGroup,
FMultiSelect,
FSelect,
FTextArea,
Group,
Icon,
Stack,
} from '@/components';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
import { useDrawerActions } from '@/hooks/state';
import { useInvoiceMailItems, useSendInvoiceFormatArgsOptions } from './_hooks';
// Create new account renderer.
const createNewItemRenderer = (query, active, handleClick) => {
return (
<MenuItem
icon="add"
text={'Now contact address'}
active={active}
onClick={handleClick}
/>
);
};
// Create new item from the given query string.
const createNewItemFromQuery = (name) => ({ name });
const styleEmailButton = css`
&.bp4-button.bp4-small {
width: auto;
margin: 0;
min-height: 26px;
line-height: 26px;
padding-top: 0;
padding-bottom: 0;
font-size: 12px;
}
`;
const fieldsWrapStyle = css`
> :not(:first-of-type) .bp4-input {
border-top-color: transparent;
border-top-right-radius: 0;
border-top-left-radius: 0;
}
> :not(:last-of-type) .bp4-input {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
`;
export function InvoiceSendMailFields() {
const [showCCField, setShowCCField] = useState<boolean>(false);
const [showBccField, setShowBccField] = useState<boolean>(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { values, setFieldValue } = useFormikContext();
const items = useInvoiceMailItems();
const argsOptions = useSendInvoiceFormatArgsOptions();
const handleClickCcBtn = (event) => {
event.preventDefault();
event.stopPropagation();
setShowCCField(true);
};
const handleClickBccBtn = (event) => {
event.preventDefault();
event.stopPropagation();
setShowBccField(true);
};
const handleCreateToItemSelect = (value: SelectOptionProps) => {
setFieldValue('to', [...values?.to, value?.name]);
};
const handleCreateCcItemSelect = (value: SelectOptionProps) => {
setFieldValue('cc', [...values?.cc, value?.name]);
};
const handleCreateBccItemSelect = (value: SelectOptionProps) => {
setFieldValue('bcc', [...values?.bcc, value?.name]);
};
const rightElementsToField = useMemo(() => (
<Group
spacing={0}
paddingRight={'7px'}
paddingTop={'7px'}
fontWeight={500}
color={'#000'}
>
<Button
onClick={handleClickCcBtn}
minimal
small
className={styleEmailButton}
>
CC
</Button>
<Button
onClick={handleClickBccBtn}
minimal
small
className={styleEmailButton}
>
BCC
</Button>
</Group>
), []);
const handleTextareaChange = useCallback((item: SelectOptionProps) => {
const textarea = textareaRef.current;
if (!textarea) return;
const { selectionStart, selectionEnd, value: text } = textarea;
const insertText = `{${item.value}}`;
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);
}, [setFieldValue]);
return (
<Stack
bg="white"
flex={'1'}
maxHeight="100%"
spacing={0}
borderRight="1px solid #dcdcdd"
>
<Stack spacing={0} overflow="auto" flex="1" p={'30px'}>
<FFormGroup label={'To'} name={'to'}>
<Stack spacing={0} className={fieldsWrapStyle}>
<FMultiSelect
items={items}
name={'to'}
placeholder={'To'}
popoverProps={{ minimal: true, fill: true }}
tagInputProps={{
tagProps: { round: true, minimal: true, large: true },
rightElement: rightElementsToField,
large: true,
}}
createNewItemRenderer={createNewItemRenderer}
createNewItemFromQuery={createNewItemFromQuery}
onCreateItemSelect={handleCreateToItemSelect}
resetOnQuery
resetOnSelect
fill
fastField
/>
{showCCField && (
<FMultiSelect
items={items}
name={'cc'}
placeholder={'Cc'}
popoverProps={{ minimal: true, fill: true }}
tagInputProps={{
tagProps: { round: true, minimal: true, large: true },
large: true,
}}
createNewItemRenderer={createNewItemRenderer}
createNewItemFromQuery={createNewItemFromQuery}
onCreateItemSelect={handleCreateCcItemSelect}
resetOnQuery
resetOnSelect
fill
fastField
/>
)}
{showBccField && (
<FMultiSelect
items={items}
name={'bcc'}
placeholder={'Bcc'}
popoverProps={{ minimal: true, fill: true }}
tagInputProps={{
tagProps: { round: true, minimal: true, large: true },
large: true,
}}
createNewItemRenderer={createNewItemRenderer}
createNewItemFromQuery={createNewItemFromQuery}
onCreateItemSelect={handleCreateBccItemSelect}
resetOnQuery
resetOnSelect
fill
fastField
/>
)}
</Stack>
</FFormGroup>
<FFormGroup label={'Submit'} name={'subject'}>
<FInputGroup name={'subject'} large fastField />
</FFormGroup>
<FFormGroup label={'Message'} name={'message'}>
<Stack spacing={0}>
<Group
border={'1px solid #ced4da'}
borderBottom={0}
borderRadius={'3px 3px 0 0'}
>
<FSelect
selectedItem={'customerName'}
name={'item'}
items={argsOptions}
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'} />
}
>
Insert Variable
</Button>
)}
fill={false}
fastField
/>
</Group>
<FTextArea
inputRef={textareaRef}
name={'message'}
large
fill
fastField
className={css`
resize: vertical;
min-height: 300px;
border-top-right-radius: 0px;
border-top-left-radius: 0px;
`}
/>
</Stack>
</FFormGroup>
<Group>
<FCheckbox name={'attachPdf'} label={'Attach PDF'} />
</Group>
</Stack>
<InvoiceSendMailFooter />
</Stack>
);
}
function InvoiceSendMailFooter() {
const { isSubmitting } = useFormikContext();
const { name } = useDrawerContext();
const { closeDrawer } = useDrawerActions();
const handleClose = () => {
closeDrawer(name);
};
return (
<Group
py={'12px'}
px={'16px'}
borderTop="1px solid #d8d8d9"
position={'apart'}
>
<Group spacing={10} ml={'auto'}>
<Button
disabled={isSubmitting}
onClick={handleClose}
style={{ minWidth: '65px' }}
>
Close
</Button>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
style={{ minWidth: '85px' }}
type="submit"
>
Send Mail
</Button>
</Group>
</Group>
);
}

View File

@@ -0,0 +1,11 @@
import * as Yup from 'yup';
export const InvoiceSendMailFormSchema = Yup.object().shape({
subject: Yup.string().required('Subject is required'),
message: Yup.string().required('Message is required'),
to: Yup.array()
.of(Yup.string().email('Invalid email'))
.required('To address is required'),
cc: Yup.array().of(Yup.string().email('Invalid email')),
bcc: Yup.array().of(Yup.string().email('Invalid email')),
});

View File

@@ -0,0 +1,78 @@
import { Form, Formik, FormikHelpers } from 'formik';
import { css } from '@emotion/css';
import { Intent } from '@blueprintjs/core';
import { InvoiceSendMailFormValues } from './_types';
import { InvoiceSendMailFormSchema } from './InvoiceSendMailForm.schema';
import { useSendSaleInvoiceMail } from '@/hooks/query';
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: InvoiceSendMailFormValues = {
subject: '',
message: '',
to: [],
cc: [],
bcc: [],
attachPdf: true,
};
interface InvoiceSendMailFormProps {
children: React.ReactNode;
}
export function InvoiceSendMailForm({ children }: InvoiceSendMailFormProps) {
const { mutateAsync: sendInvoiceMail } = useSendSaleInvoiceMail();
const { invoiceId, invoiceMailState } = useInvoiceSendMailBoot();
const { name } = useDrawerContext();
const { closeDrawer } = useDrawerActions();
const _initialValues: InvoiceSendMailFormValues = {
...initialValues,
...transformToForm(invoiceMailState, initialValues),
};
const handleSubmit = (
values: InvoiceSendMailFormValues,
{ setSubmitting }: FormikHelpers<InvoiceSendMailFormValues>,
) => {
setSubmitting(true);
sendInvoiceMail({ id: invoiceId, values: { ...values } })
.then(() => {
AppToaster.show({
message: 'The invoice mail has been sent to the customer.',
intent: Intent.SUCCESS,
});
setSubmitting(false);
closeDrawer(name);
})
.catch((error) => {
setSubmitting(false);
AppToaster.show({
message: 'Something went wrong!',
intent: Intent.SUCCESS,
});
});
};
return (
<Formik
initialValues={_initialValues}
validationSchema={InvoiceSendMailFormSchema}
onSubmit={handleSubmit}
>
<Form
className={css`
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
`}
>
{children}
</Form>
</Formik>
);
}

View File

@@ -0,0 +1,50 @@
import { Button, Classes } from '@blueprintjs/core';
import { x } from '@xstyled/emotion';
import { Group, Icon } from '@/components';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
import { useDrawerActions } from '@/hooks/state';
interface ElementCustomizeHeaderProps {
label?: string;
children?: React.ReactNode;
closeButton?: boolean;
}
export function InvoiceSendMailHeader({
label,
closeButton = true,
}: ElementCustomizeHeaderProps) {
const { name } = useDrawerContext();
const { closeDrawer } = useDrawerActions();
const handleClose = () => {
closeDrawer(name);
};
return (
<Group
p={'10px'}
pl={'30px'}
bg="white"
alignItems={'center'}
boxShadow={'0 1px 0 rgba(17, 20, 24, .15)'}
zIndex={1}
style={{ position: 'relative' }}
>
{label && (
<x.h1 margin={0} fontSize={20} fontWeight={500} color={'#666'}>
{label}
</x.h1>
)}
{closeButton && (
<Button
aria-label="Close"
className={Classes.DIALOG_CLOSE_BUTTON}
icon={<Icon icon={'smallCross'} color={'#000'} />}
minimal={true}
onClick={handleClose}
style={{ marginLeft: 'auto' }}
/>
)}
</Group>
);
}

View File

@@ -0,0 +1,82 @@
import React, { useMemo } from 'react';
import { x } from '@xstyled/emotion';
import { Box, Group, Stack } from '@/components';
import { useSendInvoiceMailForm, useSendInvoiceMailSubject } from './_hooks';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
export function InvoiceSendMailHeaderPreview() {
const mailSubject = useSendInvoiceMailSubject();
const { invoiceMailState } = useInvoiceSendMailBoot();
const toAddresses = useMailHeaderToAddresses();
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"
w={'40px'}
h={'40px'}
bg={'#daa3e4'}
fill={'#daa3e4'}
color={'#3f1946'}
lineHeight={'40px'}
textAlign={'center'}
borderRadius={'40px'}
fontSize={'14px'}
>
A
</x.abbr>
<Stack spacing={2}>
<Group spacing={2}>
<Box fontWeight={600}>Ahmed </Box>
<Box color={'#738091'}>
&lt;messaging-service@post.bigcapital.app&gt;
</Box>
</Group>
<Box fontSize={'sm'} color={'#738091'}>
Reply to: {invoiceMailState?.companyName} {toAddresses};
</Box>
</Stack>
</Group>
</Group>
</Stack>
);
}
export function InvoiceSendMailPreviewWithHeader({
children,
}: {
children: React.ReactNode;
}) {
return (
<Box>
<InvoiceSendMailHeaderPreview />
<Box>{children}</Box>
</Box>
);
}
export const useMailHeaderToAddresses = () => {
const {
values: { to },
} = useSendInvoiceMailForm();
return useMemo(() => to?.map((email) => '<' + email + '>').join(' '), [to]);
};

View File

@@ -0,0 +1,70 @@
import { lazy, Suspense } from 'react';
import { css } from '@emotion/css';
import { Tab, Tabs } from '@blueprintjs/core';
import { Stack } from '@/components';
const InvoiceMailReceiptPreviewConneceted = lazy(() =>
import('./InvoiceMailReceiptPreviewConnected.').then((module) => ({
default: module.InvoiceMailReceiptPreviewConneceted,
})),
);
const InvoiceSendPdfPreviewConnected = lazy(() =>
import('./InvoiceSendPdfPreviewConnected').then((module) => ({
default: module.InvoiceSendPdfPreviewConnected,
})),
);
export function InvoiceSendMailPreview() {
return (
<Stack bg="#F5F5F5" flex={'1'} maxHeight={'100%'} minWidth="850px">
<Tabs
id={'preview'}
defaultSelectedTabId={'payment-page'}
className={css`
overflow: hidden;
flex: 1 1;
display: flex;
flex-direction: column;
.bp4-tab-list {
padding: 0 20px;
background: #fff;
border-bottom: 1px solid #dcdcdd;
}
.bp4-tab {
line-height: 40px;
}
.bp4-tab:not([aria-selected='true']) {
color: #5f6b7c;
}
.bp4-tab-indicator-wrapper .bp4-tab-indicator {
height: 2px;
}
.bp4-tab-panel {
margin: 0;
overflow: auto;
}
`}
>
<Tab
id={'payment-page'}
title={'Payment page'}
panel={
<Suspense>
<InvoiceMailReceiptPreviewConneceted />
</Suspense>
}
/>
<Tab
id="pdf-document"
title={'PDF document'}
panel={
<Suspense>
<InvoiceSendPdfPreviewConnected />
</Suspense>
}
/>
</Tabs>
</Stack>
);
}

View File

@@ -0,0 +1,18 @@
import { css } from '@emotion/css';
import { Box } from '@/components';
import { InvoicePaperTemplate } from '../InvoiceCustomize/InvoicePaperTemplate';
import { InvoiceSendMailPreviewWithHeader } from './InvoiceSendMailHeaderPreview';
export function InvoiceSendPdfPreviewConnected() {
return (
<InvoiceSendMailPreviewWithHeader>
<Box px={4} py={6}>
<InvoicePaperTemplate
className={css`
margin: 0 auto;
`}
/>
</Box>
</InvoiceSendMailPreviewWithHeader>
);
}

View File

@@ -0,0 +1,73 @@
import { useFormikContext } from 'formik';
import { useMemo } from 'react';
import { SelectOptionProps } from '@blueprintjs-formik/select';
import { chain, defaultTo, mapKeys, snakeCase, startCase } from 'lodash';
import { InvoiceSendMailFormValues } from './_types';
import { useInvoiceSendMailBoot } from './InvoiceSendMailContentBoot';
export const useSendInvoiceMailForm = () => {
return useFormikContext<InvoiceSendMailFormValues>();
};
export const useInvoiceMailItems = () => {
const { values } = useFormikContext<InvoiceSendMailFormValues>();
const cc = values?.cc || [];
const bcc = values?.bcc || [];
return chain([...values?.to, ...cc, ...bcc])
.filter((email) => !!email?.trim())
.uniq()
.map((email) => ({
value: email,
text: email,
}))
.value();
};
export const useSendInvoiceMailFormatArgs = (): Record<string, string> => {
const { invoiceMailState } = useInvoiceSendMailBoot();
return useMemo(() => {
return mapKeys(invoiceMailState?.formatArgs, (_, key) =>
startCase(snakeCase(key).replace('_', ' ')),
);
}, [invoiceMailState]);
};
export const useSendInvoiceMailSubject = (): string => {
const { values } = useFormikContext<InvoiceSendMailFormValues>();
const formatArgs = useSendInvoiceMailFormatArgs();
return formatSmsMessage(values?.subject, formatArgs);
};
export const useSendInvoiceFormatArgsOptions = (): Array<SelectOptionProps> => {
const formatArgs = useSendInvoiceMailFormatArgs();
return Object.keys(formatArgs).map((key) => ({
value: key,
text: key,
}));
};
export const useSendInvoiceMailMessage = (): string => {
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

@@ -0,0 +1,8 @@
export interface InvoiceSendMailFormValues {
subject: string;
message: string;
to: string[];
cc: string[];
bcc: string[];
attachPdf: boolean;
}

View File

@@ -0,0 +1 @@
export * from './InvoiceSendMailDrawer';

View File

@@ -101,7 +101,7 @@ function InvoicesDataTable({
// Handle send mail invoice.
const handleSendMailInvoice = ({ id }) => {
openDialog(DialogsName.InvoiceMail, { invoiceId: id });
openDrawer(DRAWERS.INVOICE_SEND_MAIL, { invoiceId: id });
};
// Handle cell click.

View File

@@ -19,7 +19,12 @@ function ReceiptCustomizeDrawerRoot({
payload,
}) {
return (
<Drawer isOpen={isOpen} name={name} size={'100%'} payload={payload}>
<Drawer
isOpen={isOpen}
name={name}
payload={payload}
size={'calc(100% - 10px)'}
>
<DrawerSuspense>
<ReceiptCustomizeDrawerBody />
</DrawerSuspense>

View File

@@ -1,5 +1,13 @@
// @ts-nocheck
import { useQueryClient, useMutation, useQuery } from 'react-query';
import {
useQueryClient,
useMutation,
useQuery,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { useRequestQuery } from '../useQueryRequest';
import { transformPagination, transformToCamelCase } from '@/utils';
import useApiRequest from '../useRequest';
@@ -312,36 +320,114 @@ export function useInvoicePaymentTransactions(invoiceId, props) {
);
}
export function useSendSaleInvoiceMail(props) {
// # Send sale invoice mail.
// ------------------------------
export interface SendSaleInvoiceMailValues {
id: number;
values: {
subject: string;
message: string;
to: Array<string>;
cc?: Array<string>;
bcc?: Array<string>;
attachInvoice?: boolean;
};
}
export interface SendSaleInvoiceMailResponse { }
/**
* Sends the sale invoice mail.
* @param {UseMutationOptions<SendSaleInvoiceMailValues, Error, SendSaleInvoiceMailResponse>}
* @returns {UseMutationResult<SendSaleInvoiceMailResponse, Error, SendSaleInvoiceMailValues>}
*/
export function useSendSaleInvoiceMail(
options?: UseMutationOptions<
SendSaleInvoiceMailResponse,
Error,
SendSaleInvoiceMailValues
>,
): UseMutationResult<
SendSaleInvoiceMailResponse,
Error,
SendSaleInvoiceMailValues
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation(
([id, values]) => apiRequest.post(`sales/invoices/${id}/mail`, values),
return useMutation<
SendSaleInvoiceMailResponse,
Error,
SendSaleInvoiceMailValues
>(
(value) => apiRequest.post(`sales/invoices/${value.id}/mail`, value.values),
{
onSuccess: (res, [id, values]) => {
// Common invalidate queries.
onSuccess: (res) => {
commonInvalidateQueries(queryClient);
},
...props,
...options,
},
);
}
export function useSaleInvoiceDefaultOptions(invoiceId, props) {
return useRequestQuery(
// # Get sale invoice default options.
// --------------------------------------
export interface GetSaleInvoiceDefaultOptionsResponse {
companyName: string;
dueDate: string;
dueDateFormatted: string;
dueAmount: number;
dueAmountFormatted: string;
entries: Array<{
quantity: number;
quantityFormatted: string;
rate: number;
rateFormatted: string;
total: number;
totalFormatted: string;
}>;
formatArgs: Record<string, string>;
from: string[];
to: string[];
invoiceDate: string;
invoiceDateFormatted: string;
invoiceNo: string;
message: string;
subject: string;
subtotal: number;
subtotalFormatted: string;
total: number;
totalFormatted: string;
attachInvoice: boolean;
primaryColor: string;
}
export function useSaleInvoiceMailState(
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/state`)
.then((res) => transformToCamelCase(res.data?.data)),
options,
);
}
// # Get sale invoice state.
// -------------------------------------
export interface GetSaleInvoiceStateResponse {
defaultTemplateId: number;
}
@@ -360,3 +446,68 @@ export function useGetSaleInvoiceState(
{ ...options },
);
}
// # Get sale invoice branding template.
// --------------------------------------
export interface GetSaleInvoiceBrandingTemplateResponse {
id: number;
default: number;
predefined: number;
resource: string;
resourceFormatted: string;
templateName: string;
updatedAt: string;
createdAt: string;
createdAtFormatted: string;
attributes: {
billedToLabel?: string;
companyLogoKey?: string | null;
companyLogoUri?: string;
dateIssueLabel?: string;
discountLabel?: string;
dueAmountLabel?: string;
dueDateLabel?: string;
invoiceNumberLabel?: string;
itemDescriptionLabel?: string;
itemNameLabel?: string;
itemRateLabel?: string;
itemTotalLabel?: string;
paymentMadeLabel?: string;
primaryColor?: string;
secondaryColor?: string;
showCompanyAddress?: boolean;
showCompanyLogo?: boolean;
showCustomerAddress?: boolean;
showDateIssue?: boolean;
showDiscount?: boolean;
showDueAmount?: boolean;
showDueDate?: boolean;
showInvoiceNumber?: boolean;
showPaymentMade?: boolean;
showStatement?: boolean;
showSubtotal?: boolean;
showTaxes?: boolean;
showTermsConditions?: boolean;
showTotal?: boolean;
statementLabel?: string;
subtotalLabel?: string;
termsConditionsLabel?: string;
totalLabel?: string;
};
}
export function useGetSaleInvoiceBrandingTemplate(
invoiceId: number,
options?: UseQueryOptions<GetSaleInvoiceBrandingTemplateResponse, Error>,
): UseQueryResult<GetSaleInvoiceBrandingTemplateResponse, Error> {
const apiRequest = useApiRequest();
return useQuery<GetSaleInvoiceBrandingTemplateResponse, Error>(
['SALE_INVOICE_BRANDING_TEMPLATE', invoiceId],
() =>
apiRequest
.get(`/sales/invoices/${invoiceId}/template`)
.then((res) => transformToCamelCase(res.data?.data)),
{ ...options },
);
}

7892
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

24
shared/email-components/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,23 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-styling',
],
framework: {
name: '@storybook/react-vite',
options: {
builder: {
viteConfigPath: '.storybook/vite.config.ts',
},
},
},
docs: {
autodocs: 'tag',
},
};
export default config;

View File

@@ -0,0 +1,15 @@
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,17 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
import tailwindcss from 'tailwindcss';
import { UserConfigExport } from 'vite';
const app = async (): Promise<UserConfigExport> => {
return defineConfig({
plugins: [react()],
css: {
postcss: {
plugins: [tailwindcss],
},
},
});
};
// https://vitejs.dev/config/
export default app;

View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@@ -0,0 +1,54 @@
{
"name": "@bigcapital/email-components",
"private": true,
"version": "0.0.0",
"scripts": {
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"storybook:dev": "storybook dev -p 6006",
"storybook:build": "storybook build"
},
"main": "./dist/email-components.umd.js",
"module": "./dist/email-components.es.js",
"types": "./dist/main.d.ts",
"exports": {
".": {
"types": "./dist/main.d.ts",
"import": "./dist/email-components.es.js",
"require": "./dist/email-components.umd.js"
},
"./dist/style.css": "./dist/style.css"
},
"dependencies": {
"@react-email/components": "0.0.25",
"tailwindcss": "^3.4.14",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.3",
"react-dom": "18.3.1",
"react": "18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@storybook/addon-essentials": "7.2.2",
"@storybook/addon-interactions": "7.2.2",
"@storybook/addon-links": "7.2.2",
"@storybook/addon-styling": "1.3.6",
"@storybook/blocks": "7.2.2",
"@storybook/react": "7.2.2",
"@storybook/react-vite": "7.2.2",
"@storybook/testing-library": "0.2.0",
"@types/react": "18.3.4",
"@types/react-dom": "18.3.0",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.13",
"eslint-plugin-storybook": "0.6.13",
"globals": "^15.11.0",
"storybook": "7.2.2",
"typescript": "~5.6.2",
"typescript-eslint": "^8.10.0",
"vite": "^5.4.9"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,36 @@
import { Meta } from '@storybook/react';
import { StoryFn } from '@storybook/react';
import {
InvoicePaymentEmail,
InvoicePaymentEmailProps,
} from './InvoicePaymentEmail';
const meta: Meta<typeof InvoicePaymentEmail> = {
title: 'Invoice Payment Email',
component: InvoicePaymentEmail,
};
export default meta;
const Template: StoryFn<typeof InvoicePaymentEmail> = (
args: InvoicePaymentEmailProps
) => <InvoicePaymentEmail {...args} />;
export const PreviewInvoicePaymentMail = Template.bind({});
PreviewInvoicePaymentMail.args = {
preview: 'Preview text',
companyName: 'ABC Company',
companyLogoUri: 'https://example.com/logo.png',
invoiceAmount: '100.00',
dueDate: '2022-12-31',
invoiceMessage: 'Thank you for your purchase!',
invoiceNumber: 'INV-001',
dueAmount: '100.00',
total: '100.00',
viewInvoiceButtonUrl: 'https://example.com/invoice',
items: [
{ label: 'Item 1', quantity: '1', rate: '50.00' },
{ label: 'Item 2', quantity: '2', rate: '25.00' },
],
};

View File

@@ -0,0 +1,334 @@
import {
Html,
Button,
Head,
Body,
Container,
Section,
Heading,
Text,
Preview,
Tailwind,
Row,
Column,
render,
} from '@react-email/components';
import { CSSProperties } from 'react';
export interface InvoicePaymentEmailProps {
preview: string;
// # Company
companyName?: string;
companyLogoUri?: string;
// # Colors
primaryColor?: string;
// # Invoice amount
invoiceAmount: string;
// # Invoice message
invoiceMessage: string;
// # Invoice total
total: string;
totalLabel?: string;
// # Invoice due amount
dueAmount: string;
dueAmountLabel?: string;
// # Due date
dueDate: string;
dueDateLabel?: string;
// # Invoice number
invoiceNumberLabel?: string;
invoiceNumber: string;
// # View invoice button
viewInvoiceButtonLabel?: string;
viewInvoiceButtonUrl: string;
// # Items
items: Array<{ label: string; quantity: string; rate: string }>;
}
export const InvoicePaymentEmail: React.FC<
Readonly<InvoicePaymentEmailProps>
> = ({
preview,
// # Company
companyName,
companyLogoUri,
// # Colors
primaryColor = 'rgb(0, 82, 204)',
// # Invoice amount
invoiceAmount,
// # Invoice message
invoiceMessage,
// # Due date
dueDate,
dueDateLabel = 'Due {dueDate}',
// # Invoice number
invoiceNumber,
invoiceNumberLabel = 'Invoice # {invoiceNumber}',
// # invoice total
total,
totalLabel = 'Total',
// # Invoice due amount
dueAmountLabel = 'Due Amount',
dueAmount,
// # View invoice button
viewInvoiceButtonLabel = 'View Invoice',
viewInvoiceButtonUrl,
items,
}) => {
return (
<Html lang="en">
<Head />
<Preview>{preview}</Preview>
<Tailwind>
<Body style={bodyStyle}>
<Container style={containerStyle}>
<Section style={mainSectionStyle}>
{companyLogoUri && (
<Section style={logoSectionStyle}>
<div
style={{
...companyLogoStyle,
backgroundImage: `url("${companyLogoUri}")`,
}}
>
Image
</div>
</Section>
)}
<Section style={headerInfoStyle}>
<Row>
<Heading style={invoiceCompanyNameStyle}>
{companyName}
</Heading>
</Row>
<Row>
<Text style={invoiceAmountStyle}>{invoiceAmount}</Text>
</Row>
<Row>
<Text style={invoiceNumberStyle}>
{invoiceNumberLabel?.replace(
'{invoiceNumber}',
invoiceNumber
)}
</Text>
</Row>
<Row>
<Text style={invoiceDateStyle}>
{dueDateLabel.replace('{dueDate}', dueDate)}
</Text>
</Row>
</Section>
<Text style={invoiceMessageStyle}>{invoiceMessage}</Text>
<Button
href={viewInvoiceButtonUrl}
style={{
...viewInvoiceButtonStyle,
backgroundColor: primaryColor,
}}
>
{viewInvoiceButtonLabel}
</Button>
<Section style={totalsSectionStyle}>
{items.map((item, index) => (
<Row key={index} style={itemLineRowStyle}>
<Column width={'50%'}>
<Text style={listItemLabelStyle}>{item.label}</Text>
</Column>
<Column width={'50%'}>
<Text style={listItemAmountStyle}>
{item.quantity} x {item.rate}
</Text>
</Column>
</Row>
))}
<Row style={dueAmounLineRowStyle}>
<Column width={'50%'}>
<Text style={dueAmountLineItemLabelStyle}>
{dueAmountLabel}
</Text>
</Column>
<Column width={'50%'}>
<Text style={dueAmountLineItemAmountStyle}>
{dueAmount}
</Text>
</Column>
</Row>
<Row style={totalLineRowStyle}>
<Column width={'50%'}>
<Text style={totalLineItemLabelStyle}>{totalLabel}</Text>
</Column>
<Column width={'50%'}>
<Text style={totalLineItemAmountStyle}>{total}</Text>
</Column>
</Row>
</Section>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export const renderInvoicePaymentEmail = (props: InvoicePaymentEmailProps) => {
return render(<InvoicePaymentEmail {...props} />);
};
const bodyStyle: CSSProperties = {
backgroundColor: '#F5F5F5',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
padding: '40px 0',
};
const containerStyle: CSSProperties = {
backgroundColor: '#fff',
width: '100%',
maxWidth: '500px',
padding: '35px 25px',
color: '#000',
borderRadius: '5px',
};
const headerInfoStyle: CSSProperties = {
textAlign: 'center',
marginBottom: 20,
};
const mainSectionStyle: CSSProperties = {};
const invoiceAmountStyle: CSSProperties = {
margin: 0,
color: '#383E47',
fontWeight: 500,
};
const invoiceNumberStyle: CSSProperties = {
margin: 0,
fontSize: '13px',
color: '#404854',
};
const invoiceDateStyle: CSSProperties = {
margin: 0,
fontSize: '13px',
color: '#404854',
};
const invoiceCompanyNameStyle: CSSProperties = {
margin: 0,
fontSize: '18px',
fontWeight: 500,
color: '#404854',
};
const viewInvoiceButtonStyle: CSSProperties = {
display: 'block',
cursor: 'pointer',
textAlign: 'center',
fontSize: 16,
padding: '10px 15px',
lineHeight: '1',
backgroundColor: 'rgb(0, 82, 204)',
color: '#fff',
borderRadius: '5px',
};
const listItemLabelStyle: CSSProperties = {
margin: 0,
};
const listItemAmountStyle: CSSProperties = {
margin: 0,
textAlign: 'right',
};
const invoiceMessageStyle: CSSProperties = {
whiteSpace: 'pre-line',
color: '#252A31',
margin: '0 0 20px 0',
};
const dueAmounLineRowStyle: CSSProperties = {
borderBottom: '1px solid #000',
height: 40,
};
const totalLineRowStyle: CSSProperties = {
borderBottom: '1px solid #000',
height: 40,
};
const totalLineItemLabelStyle: CSSProperties = {
...listItemLabelStyle,
fontWeight: 500,
};
const totalLineItemAmountStyle: CSSProperties = {
...listItemAmountStyle,
fontWeight: 600,
};
const dueAmountLineItemLabelStyle: CSSProperties = {
...listItemLabelStyle,
fontWeight: 500,
};
const dueAmountLineItemAmountStyle: CSSProperties = {
...listItemAmountStyle,
fontWeight: 600,
};
const itemLineRowStyle: CSSProperties = {
borderBottom: '1px solid #D9D9D9',
height: 40,
};
const totalsSectionStyle = {
marginTop: '20px',
borderTop: '1px solid #D9D9D9',
};
const logoSectionStyle = {
marginBottom: '15px',
};
const companyLogoStyle = {
height: 90,
width: 90,
borderRadius: '3px',
marginLeft: 'auto',
marginRight: 'auto',
textIndent: '-999999px',
overflow: 'hidden',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center center',
backgroundSize: 'contain',
};

View File

@@ -0,0 +1 @@
export * from './InvoicePaymentEmail';

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"noEmit": true,
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"jsx": "react-jsx",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"target": "ESNext",
"types": ["vitest/globals"],
"resolveJsonModule": true
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,55 @@
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { defineConfig } from 'vitest/config';
import dts from 'vite-plugin-dts';
import tailwindcss from 'tailwindcss';
import { UserConfigExport } from 'vite';
import { name } from './package.json';
const app = async (): Promise<UserConfigExport> => {
/**
* Removes everything before the last
* @octocat/library-repo -> library-repo
* vite-component-library-template -> vite-component-library-template
*/
const formattedName = name.match(/[^/]+$/)?.[0] ?? name;
return defineConfig({
plugins: [
react(),
dts({
insertTypesEntry: true,
}),
],
css: {
postcss: {
plugins: [tailwindcss],
},
},
build: {
lib: {
entry: path.resolve(__dirname, 'src/lib/main.ts'),
name: formattedName,
formats: ['es', 'umd'],
fileName: (format: string) => `${formattedName}.${format}.js`,
},
rollupOptions: {
external: ['react', 'react/jsx-runtime', 'react-dom', 'tailwindcss'],
output: {
globals: {
react: 'React',
'react/jsx-runtime': 'react/jsx-runtime',
'react-dom': 'ReactDOM',
tailwindcss: 'tailwindcss',
},
},
},
},
test: {
globals: true,
environment: 'jsdom',
},
});
};
// https://vitejs.dev/config/
export default app;