diff --git a/packages/server/src/api/controllers/Sales/PaymentReceives.ts b/packages/server/src/api/controllers/Sales/PaymentReceives.ts index 7cfa93a00..0bef1e60d 100644 --- a/packages/server/src/api/controllers/Sales/PaymentReceives.ts +++ b/packages/server/src/api/controllers/Sales/PaymentReceives.ts @@ -1,10 +1,11 @@ import { Inject, Service } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query, ValidationChain } from 'express-validator'; +import { body, check, param, query, ValidationChain } from 'express-validator'; import { AbilitySubject, IPaymentReceiveDTO, PaymentReceiveAction, + PaymentReceiveMailOptsDTO, } from '@/interfaces'; import BaseController from '@/api/controllers/BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; @@ -117,6 +118,25 @@ export default class PaymentReceivesController extends BaseController { asyncMiddleware(this.deletePaymentReceive.bind(this)), this.handleServiceErrors ); + router.post( + '/:id/mail', + [ + ...this.paymentReceiveValidation, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), + ], + this.sendPaymentReceiveByMail.bind(this), + this.handleServiceErrors + ); + router.get( + '/:id/mail', + [...this.paymentReceiveValidation], + asyncMiddleware(this.getPaymentDefaultMail.bind(this)), + this.handleServiceErrors + ); return router; } @@ -416,27 +436,26 @@ export default class PaymentReceivesController extends BaseController { const { id: paymentReceiveId } = req.params; try { - const paymentReceive = - await this.paymentReceiveApplication.getPaymentReceive( - tenantId, - paymentReceiveId - ); - const ACCEPT_TYPE = { APPLICATION_PDF: 'application/pdf', APPLICATION_JSON: 'application/json', }; res.format({ - [ACCEPT_TYPE.APPLICATION_JSON]: () => { + [ACCEPT_TYPE.APPLICATION_JSON]: async () => { + const paymentReceive = + await this.paymentReceiveApplication.getPaymentReceive( + tenantId, + paymentReceiveId + ); return res.status(200).send({ - payment_receive: this.transfromToResponse(paymentReceive), + payment_receive: paymentReceive, }); }, [ACCEPT_TYPE.APPLICATION_PDF]: async () => { const pdfContent = await this.paymentReceiveApplication.getPaymentReceivePdf( tenantId, - paymentReceive + paymentReceiveId ); res.set({ 'Content-Type': 'application/pdf', @@ -507,6 +526,66 @@ export default class PaymentReceivesController extends BaseController { } }; + /** + * Sends mail invoice of the given sale invoice. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns + */ + public sendPaymentReceiveByMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: paymentReceiveId } = req.params; + const paymentMailDTO: PaymentReceiveMailOptsDTO = this.matchedBodyData( + req, + { + includeOptionals: false, + } + ); + try { + await this.paymentReceiveApplication.notifyPaymentByMail( + tenantId, + paymentReceiveId, + paymentMailDTO + ); + return res.status(200).send({ + code: 200, + message: 'The payment notification has been sent successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the default mail options of the given payment transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public getPaymentDefaultMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: paymentReceiveId } = req.params; + + try { + const data = await this.paymentReceiveApplication.getPaymentMailOptions( + tenantId, + paymentReceiveId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + /** * Handles service errors. * @param error @@ -514,7 +593,7 @@ export default class PaymentReceivesController extends BaseController { * @param res * @param next */ - handleServiceErrors( + private handleServiceErrors( error: Error, req: Request, res: Response, diff --git a/packages/server/src/api/controllers/Sales/SalesEstimates.ts b/packages/server/src/api/controllers/Sales/SalesEstimates.ts index 9f3cf3719..b34c1ccf2 100644 --- a/packages/server/src/api/controllers/Sales/SalesEstimates.ts +++ b/packages/server/src/api/controllers/Sales/SalesEstimates.ts @@ -1,10 +1,11 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query } from 'express-validator'; +import { body, check, param, query } from 'express-validator'; import { Inject, Service } from 'typedi'; import { AbilitySubject, ISaleEstimateDTO, SaleEstimateAction, + SaleEstimateMailOptionsDTO, } from '@/interfaces'; import BaseController from '@/api/controllers/BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; @@ -121,6 +122,27 @@ export default class SalesEstimatesController extends BaseController { this.handleServiceErrors, this.dynamicListService.handlerErrorsToResponse ); + router.post( + '/:id/mail', + [ + ...this.validateSpecificEstimateSchema, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleEstimateMail.bind(this)), + this.handleServiceErrors + ); + router.get( + '/:id/mail', + [...this.validateSpecificEstimateSchema], + this.validationResult, + asyncMiddleware(this.getSaleEstimateMail.bind(this)), + this.handleServiceErrors + ); return router; } @@ -362,22 +384,22 @@ export default class SalesEstimatesController extends BaseController { const { tenantId } = req; try { - const estimate = await this.saleEstimatesApplication.getSaleEstimate( - tenantId, - estimateId - ); // Response formatter. res.format({ // JSON content type. - [ACCEPT_TYPE.APPLICATION_JSON]: () => { - return res.status(200).send(this.transfromToResponse({ estimate })); + [ACCEPT_TYPE.APPLICATION_JSON]: async () => { + const estimate = await this.saleEstimatesApplication.getSaleEstimate( + tenantId, + estimateId + ); + return res.status(200).send({ estimate }); }, // PDF content type. [ACCEPT_TYPE.APPLICATION_PDF]: async () => { const pdfContent = await this.saleEstimatesApplication.getSaleEstimatePdf( tenantId, - estimate + estimateId ); res.set({ 'Content-Type': 'application/pdf', @@ -478,6 +500,65 @@ export default class SalesEstimatesController extends BaseController { } }; + /** + * Send the sale estimate mail. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private sendSaleEstimateMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: invoiceId } = req.params; + const saleEstimateDTO: SaleEstimateMailOptionsDTO = this.matchedBodyData( + req, + { + includeOptionals: false, + } + ); + try { + await this.saleEstimatesApplication.sendSaleEstimateMail( + tenantId, + invoiceId, + saleEstimateDTO + ); + return res.status(200).send({ + code: 200, + message: 'The sale estimate mail has been sent successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the default mail options of the given sale estimate. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private getSaleEstimateMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: invoiceId } = req.params; + + try { + const data = await this.saleEstimatesApplication.getSaleEstimateMail( + tenantId, + invoiceId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + /** * Handles service errors. * @param {Error} error diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index d90b94d8d..9e5ac8d25 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -1,5 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query } from 'express-validator'; +import { body, check, param, query } from 'express-validator'; import { Service, Inject } from 'typedi'; import BaseController from '../BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; @@ -10,6 +10,7 @@ import { ISaleInvoiceCreateDTO, SaleInvoiceAction, AbilitySubject, + SendInvoiceMailDTO, } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { SaleInvoiceApplication } from '@/services/Sales/Invoices/SaleInvoicesApplication'; @@ -145,6 +146,48 @@ export default class SaleInvoicesController extends BaseController { this.handleServiceErrors, this.dynamicListService.handlerErrorsToResponse ); + router.get( + '/:id/mail-reminder', + this.specificSaleInvoiceValidation, + this.validationResult, + asyncMiddleware(this.getSaleInvoiceMailReminder.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id/mail-reminder', + [ + ...this.specificSaleInvoiceValidation, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleInvoiceMailReminder.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id/mail', + [ + ...this.specificSaleInvoiceValidation, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleInvoiceMail.bind(this)), + this.handleServiceErrors + ); + router.get( + '/:id/mail', + [...this.specificSaleInvoiceValidation], + this.validationResult, + asyncMiddleware(this.getSaleInvoiceMail.bind(this)), + this.handleServiceErrors + ); return router; } @@ -630,6 +673,119 @@ export default class SaleInvoicesController extends BaseController { } }; + /** + * Sends mail invoice of the given sale invoice. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async sendSaleInvoiceMail( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req, { + includeOptionals: false, + }); + + try { + await this.saleInvoiceApplication.sendSaleInvoiceMail( + tenantId, + invoiceId, + invoiceMailDTO + ); + return res.status(200).send({ + code: 200, + message: 'The sale invoice mail has been sent successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retreivers the sale invoice reminder options. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async getSaleInvoiceMailReminder( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + + try { + const data = await this.saleInvoiceApplication.getSaleInvoiceMailReminder( + tenantId, + invoiceId + ); + return res.status(200).send(data); + } catch (error) { + next(error); + } + } + + /** + * Sends mail invoice of the given sale invoice. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async sendSaleInvoiceMailReminder( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req, { + includeOptionals: false, + }); + try { + await this.saleInvoiceApplication.sendSaleInvoiceMailReminder( + tenantId, + invoiceId, + invoiceMailDTO + ); + return res.status(200).send({ + code: 200, + message: 'The sale invoice mail reminder has been sent successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retrieves the default mail options of the given sale invoice. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async getSaleInvoiceMail( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + + try { + const data = await this.saleInvoiceApplication.getSaleInvoiceMail( + tenantId, + invoiceId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + } + /** * Handles service errors. * @param {Error} error diff --git a/packages/server/src/api/controllers/Sales/SalesReceipts.ts b/packages/server/src/api/controllers/Sales/SalesReceipts.ts index 3eabcf84e..6151561f8 100644 --- a/packages/server/src/api/controllers/Sales/SalesReceipts.ts +++ b/packages/server/src/api/controllers/Sales/SalesReceipts.ts @@ -1,9 +1,9 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query } from 'express-validator'; +import { body, check, param, query } from 'express-validator'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseController from '../BaseController'; -import { ISaleReceiptDTO } from '@/interfaces/SaleReceipt'; +import { ISaleReceiptDTO, SaleReceiptMailOpts, SaleReceiptMailOptsDTO } from '@/interfaces/SaleReceipt'; import { ServiceError } from '@/exceptions'; import DynamicListingService from '@/services/DynamicListing/DynamicListService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; @@ -46,6 +46,29 @@ export default class SalesReceiptsController extends BaseController { this.saleReceiptSmsDetails, this.handleServiceErrors ); + router.post( + '/:id/mail', + [ + ...this.specificReceiptValidationSchema, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_receipt').optional().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleReceiptMail.bind(this)), + this.handleServiceErrors + ); + router.get( + '/:id/mail', + [ + ...this.specificReceiptValidationSchema, + ], + this.validationResult, + asyncMiddleware(this.getSaleReceiptMail.bind(this)), + this.handleServiceErrors + ); router.post( '/:id', CheckPolicies(SaleReceiptAction.Edit, AbilitySubject.SaleReceipt), @@ -314,26 +337,24 @@ export default class SalesReceiptsController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - async getSaleReceipt(req: Request, res: Response, next: NextFunction) { + public async getSaleReceipt(req: Request, res: Response, next: NextFunction) { const { id: saleReceiptId } = req.params; const { tenantId } = req; try { - const saleReceipt = await this.saleReceiptsApplication.getSaleReceipt( - tenantId, - saleReceiptId - ); res.format({ - 'application/json': () => { - return res - .status(200) - .send(this.transfromToResponse({ saleReceipt })); + 'application/json': async () => { + const saleReceipt = await this.saleReceiptsApplication.getSaleReceipt( + tenantId, + saleReceiptId + ); + return res.status(200).send({ saleReceipt }); }, 'application/pdf': async () => { const pdfContent = await this.saleReceiptsApplication.getSaleReceiptPdf( tenantId, - saleReceipt + saleReceiptId ); res.set({ 'Content-Type': 'application/pdf', @@ -405,6 +426,64 @@ export default class SalesReceiptsController extends BaseController { } }; + /** + * Sends mail notification of the given sale receipt. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public sendSaleReceiptMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: receiptId } = req.params; + const receiptMailDTO: SaleReceiptMailOptsDTO = this.matchedBodyData(req, { + includeOptionals: false, + }); + + try { + await this.saleReceiptsApplication.sendSaleReceiptMail( + tenantId, + receiptId, + receiptMailDTO + ); + return res.status(200).send({ + code: 200, + message: + 'The sale receipt notification via sms has been sent successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the default mail options of the given sale receipt. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public getSaleReceiptMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: receiptId } = req.params; + + try { + const data = await this.saleReceiptsApplication.getSaleReceiptMail( + tenantId, + receiptId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + /** * Handles service errors. * @param {Error} error diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index bc6833130..0dc9d9676 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -58,6 +58,7 @@ module.exports = { secure: !!parseInt(process.env.MAIL_SECURE, 10), username: process.env.MAIL_USERNAME, password: process.env.MAIL_PASSWORD, + from: process.env.MAIL_FROM_ADDRESS, }, /** diff --git a/packages/server/src/interfaces/Mailable.ts b/packages/server/src/interfaces/Mailable.ts index 36cc3c81f..5682f2529 100644 --- a/packages/server/src/interfaces/Mailable.ts +++ b/packages/server/src/interfaces/Mailable.ts @@ -1,9 +1,17 @@ +export type IMailAttachment = MailAttachmentPath | MailAttachmentContent; + +export interface MailAttachmentPath { + filename: string; + path: string; + cid: string; +} +export interface MailAttachmentContent { + filename: string; + content: Buffer; +} export interface IMailable { - constructor( - view: string, - data?: { [key: string]: string | number }, - ); + constructor(view: string, data?: { [key: string]: string | number }); send(): Promise; build(): void; setData(data: { [key: string]: string | number }): IMailable; @@ -13,4 +21,27 @@ export interface IMailable { setView(view: string): IMailable; render(data?: { [key: string]: string | number }): string; getViewContent(): string; -} \ No newline at end of file +} + +export interface AddressItem { + label: string; + mail: string; + primary?: boolean; +} + +export interface CommonMailOptions { + toAddresses: AddressItem[]; + fromAddresses: AddressItem[]; + from: string; + to: string | string[]; + subject: string; + body: string; + data?: Record; +} + +export interface CommonMailOptionsDTO { + to?: string | string[]; + from?: string; + subject?: string; + body?: string; +} diff --git a/packages/server/src/interfaces/PaymentReceive.ts b/packages/server/src/interfaces/PaymentReceive.ts index 6f8d8552a..2926d923c 100644 --- a/packages/server/src/interfaces/PaymentReceive.ts +++ b/packages/server/src/interfaces/PaymentReceive.ts @@ -1,5 +1,9 @@ import { Knex } from 'knex'; -import { ISystemUser } from '@/interfaces'; +import { + CommonMailOptions, + CommonMailOptionsDTO, + ISystemUser, +} from '@/interfaces'; import { ILedgerEntry } from './Ledger'; import { ISaleInvoice } from './SaleInvoice'; @@ -19,7 +23,7 @@ export interface IPaymentReceive { createdAt: Date; updatedAt: Date; localAmount?: number; - branchId?: number + branchId?: number; } export interface IPaymentReceiveCreateDTO { customerId: number; @@ -165,3 +169,7 @@ export type IPaymentReceiveGLCommonEntry = Pick< | 'createdAt' | 'branchId' >; + +export interface PaymentReceiveMailOpts extends CommonMailOptions {} + +export interface PaymentReceiveMailOptsDTO extends CommonMailOptionsDTO {} diff --git a/packages/server/src/interfaces/SaleEstimate.ts b/packages/server/src/interfaces/SaleEstimate.ts index f2a820e98..171c8a0d1 100644 --- a/packages/server/src/interfaces/SaleEstimate.ts +++ b/packages/server/src/interfaces/SaleEstimate.ts @@ -1,6 +1,7 @@ import { Knex } from 'knex'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter'; +import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; export interface ISaleEstimate { id?: number; @@ -124,3 +125,11 @@ export interface ISaleEstimateApprovedEvent { saleEstimate: ISaleEstimate; trx: Knex.Transaction; } + +export interface SaleEstimateMailOptions extends CommonMailOptions { + attachEstimate?: boolean; +} + +export interface SaleEstimateMailOptionsDTO extends CommonMailOptionsDTO { + attachEstimate?: boolean; +} \ No newline at end of file diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index 7ef8fdea2..394319e86 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { ISystemUser, IAccount, ITaxTransaction } from '@/interfaces'; +import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; import { IDynamicListFilter } from '@/interfaces/DynamicFilter'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; @@ -186,3 +187,17 @@ export enum SaleInvoiceAction { Writeoff = 'Writeoff', NotifyBySms = 'NotifyBySms', } + +export interface SaleInvoiceMailOptions extends CommonMailOptions { + attachInvoice: boolean; +} + +export interface SendInvoiceMailDTO extends CommonMailOptionsDTO { + attachInvoice?: boolean; +} + +export interface ISaleInvoiceNotifyPayload { + tenantId: number; + saleInvoiceId: number; + messageDTO: SendInvoiceMailDTO; +} diff --git a/packages/server/src/interfaces/SaleReceipt.ts b/packages/server/src/interfaces/SaleReceipt.ts index 4d319ec4f..1e8ffa98e 100644 --- a/packages/server/src/interfaces/SaleReceipt.ts +++ b/packages/server/src/interfaces/SaleReceipt.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { IItemEntry } from './ItemEntry'; +import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; export interface ISaleReceipt { id?: number; @@ -134,3 +135,11 @@ export interface ISaleReceiptDeletingPayload { oldSaleReceipt: ISaleReceipt; trx: Knex.Transaction; } + +export interface SaleReceiptMailOpts extends CommonMailOptions { + attachReceipt: boolean; +} + +export interface SaleReceiptMailOptsDTO extends CommonMailOptionsDTO { + attachReceipt?: boolean; +} diff --git a/packages/server/src/lib/Mail/index.ts b/packages/server/src/lib/Mail/index.ts index dd79c934b..015ca02a8 100644 --- a/packages/server/src/lib/Mail/index.ts +++ b/packages/server/src/lib/Mail/index.ts @@ -2,18 +2,13 @@ import fs from 'fs'; import Mustache from 'mustache'; import { Container } from 'typedi'; import path from 'path'; -import { IMailable } from '@/interfaces'; - -interface IMailAttachment { - filename: string; - path: string; - cid: string; -} +import { IMailAttachment } from '@/interfaces'; export default class Mail { view: string; - subject: string; - to: string; + subject: string = ''; + content: string = ''; + to: string | string[]; from: string = `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`; data: { [key: string]: string | number }; attachments: IMailAttachment[]; @@ -21,16 +16,24 @@ export default class Mail { /** * Mail options. */ - private get mailOptions() { + public get mailOptions() { return { to: this.to, from: this.from, subject: this.subject, - html: this.render(this.data), + html: this.html, attachments: this.attachments, }; } + /** + * Retrieves the html content of the mail. + * @returns {string} + */ + public get html() { + return this.view ? Mail.render(this.view, this.data) : this.content; + } + /** * Sends the given mail to the target address. */ @@ -52,7 +55,7 @@ export default class Mail { * Set send mail to address. * @param {string} to - */ - setTo(to: string) { + setTo(to: string | string[]) { this.to = to; return this; } @@ -62,11 +65,16 @@ export default class Mail { * @param {string} from * @return {} */ - private setFrom(from: string) { + setFrom(from: string) { this.from = from; return this; } + /** + * Set attachments to the mail. + * @param {IMailAttachment[]} attachments + * @returns {Mail} + */ setAttachments(attachments: IMailAttachment[]) { this.attachments = attachments; return this; @@ -95,21 +103,26 @@ export default class Mail { return this; } + setContent(content: string) { + this.content = content; + return this; + } + /** * Renders the view template with the given data. * @param {object} data * @return {string} */ - render(data): string { - const viewContent = this.getViewContent(); + static render(view: string, data: Record): string { + const viewContent = Mail.getViewContent(view); return Mustache.render(viewContent, data); } /** * Retrieve view content from the view directory. */ - private getViewContent(): string { - const filePath = path.join(global.__views_dir, `/${this.view}`); + static getViewContent(view: string): string { + const filePath = path.join(global.__views_dir, `/${view}`); return fs.readFileSync(filePath, 'utf8'); } } diff --git a/packages/server/src/loaders/jobs.ts b/packages/server/src/loaders/jobs.ts index 4fa3aadb1..74c7d6b6e 100644 --- a/packages/server/src/loaders/jobs.ts +++ b/packages/server/src/loaders/jobs.ts @@ -5,6 +5,11 @@ import RewriteInvoicesJournalEntries from 'jobs/WriteInvoicesJEntries'; import UserInviteMailJob from 'jobs/UserInviteMail'; import OrganizationSetupJob from 'jobs/OrganizationSetup'; import OrganizationUpgrade from 'jobs/OrganizationUpgrade'; +import { SendSaleInvoiceMailJob } from '@/services/Sales/Invoices/SendSaleInvoiceMailJob'; +import { SendSaleInvoiceReminderMailJob } from '@/services/Sales/Invoices/SendSaleInvoiceMailReminderJob'; +import { SendSaleEstimateMailJob } from '@/services/Sales/Estimates/SendSaleEstimateMailJob'; +import { SaleReceiptMailNotificationJob } from '@/services/Sales/Receipts/SaleReceiptMailNotificationJob'; +import { PaymentReceiveMailNotificationJob } from '@/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob'; export default ({ agenda }: { agenda: Agenda }) => { new ResetPasswordMailJob(agenda); @@ -13,6 +18,11 @@ export default ({ agenda }: { agenda: Agenda }) => { new RewriteInvoicesJournalEntries(agenda); new OrganizationSetupJob(agenda); new OrganizationUpgrade(agenda); + new SendSaleInvoiceMailJob(agenda); + new SendSaleInvoiceReminderMailJob(agenda); + new SendSaleEstimateMailJob(agenda); + new SaleReceiptMailNotificationJob(agenda); + new PaymentReceiveMailNotificationJob(agenda); agenda.start(); }; diff --git a/packages/server/src/models/Customer.ts b/packages/server/src/models/Customer.ts index 690b77d55..631763b71 100644 --- a/packages/server/src/models/Customer.ts +++ b/packages/server/src/models/Customer.ts @@ -24,6 +24,9 @@ export default class Customer extends mixin(TenantModel, [ CustomViewBaseModel, ModelSearchable, ]) { + email: string; + displayName: string; + /** * Query builder. */ @@ -76,6 +79,19 @@ export default class Customer extends mixin(TenantModel, [ return 'debit'; } + /** + * + */ + get contactAddresses() { + return [ + { + mail: this.email, + label: this.displayName, + primary: true + }, + ].filter((c) => c.mail); + } + /** * Model modifiers. */ diff --git a/packages/server/src/services/MailNotification/ContactMailNotification.ts b/packages/server/src/services/MailNotification/ContactMailNotification.ts new file mode 100644 index 000000000..e1e733a79 --- /dev/null +++ b/packages/server/src/services/MailNotification/ContactMailNotification.ts @@ -0,0 +1,106 @@ +import { Inject, Service } from 'typedi'; +import { CommonMailOptions } from '@/interfaces'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { MailTenancy } from '@/services/MailTenancy/MailTenancy'; +import { formatSmsMessage } from '@/utils'; +import { Tenant } from '@/system/models'; + +@Service() +export class ContactMailNotification { + @Inject() + private mailTenancy: MailTenancy; + + @Inject() + private tenancy: HasTenancyService; + + /** + * Parses the default message options. + * @param {number} tenantId - + * @param {number} invoiceId - + * @param {string} subject - + * @param {string} body - + * @returns {Promise} + */ + public async getDefaultMailOptions( + tenantId: number, + contactId: number, + subject: string = '', + body: string = '' + ): Promise { + const { Customer } = this.tenancy.models(tenantId); + const contact = await Customer.query() + .findById(contactId) + .throwIfNotFound(); + + const toAddresses = contact.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 || ''; + + return { + subject, + body, + to, + from, + fromAddresses, + toAddresses, + }; + } + + /** + * 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} + */ + public async getMailOptions( + tenantId: number, + contactId: number, + defaultSubject?: string, + defaultBody?: string, + formatterData?: Record + ): Promise { + const mailOpts = await this.getDefaultMailOptions( + tenantId, + contactId, + defaultSubject, + defaultBody + ); + const commonFormatArgs = await this.getCommonFormatArgs(tenantId); + const formatArgs = { + ...commonFormatArgs, + ...formatterData, + }; + const subject = formatSmsMessage(mailOpts.subject, formatArgs); + const body = formatSmsMessage(mailOpts.body, formatArgs); + + return { + ...mailOpts, + subject, + body, + }; + } + + /** + * Retrieves the common format args. + * @param {number} tenantId + * @returns {Promise>} + */ + public async getCommonFormatArgs( + tenantId: number + ): Promise> { + const organization = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + return { + CompanyName: organization.metadata.name, + }; + } +} diff --git a/packages/server/src/services/MailNotification/constants.ts b/packages/server/src/services/MailNotification/constants.ts new file mode 100644 index 000000000..95b720d70 --- /dev/null +++ b/packages/server/src/services/MailNotification/constants.ts @@ -0,0 +1,6 @@ +export const ERRORS = { + MAIL_FROM_NOT_FOUND: 'Mail from address not found', + MAIL_TO_NOT_FOUND: 'Mail to address not found', + MAIL_SUBJECT_NOT_FOUND: 'Mail subject not found', + MAIL_BODY_NOT_FOUND: 'Mail body not found', +}; diff --git a/packages/server/src/services/MailNotification/utils.ts b/packages/server/src/services/MailNotification/utils.ts new file mode 100644 index 000000000..b9e37b297 --- /dev/null +++ b/packages/server/src/services/MailNotification/utils.ts @@ -0,0 +1,33 @@ +import { isEmpty } from 'lodash'; +import { ServiceError } from '@/exceptions'; +import { CommonMailOptions, CommonMailOptionsDTO } from '@/interfaces'; +import { ERRORS } from './constants'; + +/** + * Merges the mail options with incoming options. + * @param {Partial} mailOptions + * @param {Partial} overridedOptions + * @throws {ServiceError} + */ +export function parseAndValidateMailOptions( + mailOptions: Partial, + overridedOptions: Partial +) { + const mergedMessageOptions = { + ...mailOptions, + ...overridedOptions, + }; + if (isEmpty(mergedMessageOptions.from)) { + throw new ServiceError(ERRORS.MAIL_FROM_NOT_FOUND); + } + if (isEmpty(mergedMessageOptions.to)) { + throw new ServiceError(ERRORS.MAIL_TO_NOT_FOUND); + } + if (isEmpty(mergedMessageOptions.subject)) { + throw new ServiceError(ERRORS.MAIL_SUBJECT_NOT_FOUND); + } + if (isEmpty(mergedMessageOptions.body)) { + throw new ServiceError(ERRORS.MAIL_BODY_NOT_FOUND); + } + return mergedMessageOptions; +} diff --git a/packages/server/src/services/MailTenancy/MailTenancy.ts b/packages/server/src/services/MailTenancy/MailTenancy.ts new file mode 100644 index 000000000..6f8e82e11 --- /dev/null +++ b/packages/server/src/services/MailTenancy/MailTenancy.ts @@ -0,0 +1,25 @@ +import config from '@/config'; +import { Tenant } from "@/system/models"; +import { Service } from 'typedi'; + + +@Service() +export class MailTenancy { + /** + * Retrieves the senders mails of the given tenant. + * @param {number} tenantId + */ + public async senders(tenantId: number) { + const tenant = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + return [ + { + mail: config.mail.from, + label: tenant.metadata.name, + primary: true, + } + ].filter((item) => item.mail) + } +} \ No newline at end of file diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts index 3f63b27de..f1c7b3cdf 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts @@ -7,6 +7,8 @@ import { ISaleEstimate, ISaleEstimateDTO, ISalesEstimatesFilter, + SaleEstimateMailOptions, + SaleEstimateMailOptionsDTO, } from '@/interfaces'; import { EditSaleEstimate } from './EditSaleEstimate'; import { DeleteSaleEstimate } from './DeleteSaleEstimate'; @@ -17,6 +19,7 @@ import { ApproveSaleEstimate } from './ApproveSaleEstimate'; import { RejectSaleEstimate } from './RejectSaleEstimate'; import { SaleEstimateNotifyBySms } from './SaleEstimateSmsNotify'; import { SaleEstimatesPdf } from './SaleEstimatesPdf'; +import { SendSaleEstimateMail } from './SendSaleEstimateMail'; @Service() export class SaleEstimatesApplication { @@ -50,6 +53,9 @@ export class SaleEstimatesApplication { @Inject() private saleEstimatesPdfService: SaleEstimatesPdf; + @Inject() + private sendEstimateMailService: SendSaleEstimateMail; + /** * Create a sale estimate. * @param {number} tenantId - The tenant id. @@ -198,15 +204,49 @@ export class SaleEstimatesApplication { }; /** - * + * Retrieve the PDF content of the given sale estimate. * @param {number} tenantId - * @param {} saleEstimate + * @param {number} saleEstimateId * @returns */ - public getSaleEstimatePdf(tenantId: number, saleEstimate) { + public getSaleEstimatePdf(tenantId: number, saleEstimateId: number) { return this.saleEstimatesPdfService.getSaleEstimatePdf( tenantId, - saleEstimate + saleEstimateId + ); + } + + /** + * Send the reminder mail of the given sale estimate. + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns {Promise} + */ + public sendSaleEstimateMail( + tenantId: number, + saleEstimateId: number, + saleEstimateMailOpts: SaleEstimateMailOptionsDTO + ): Promise { + return this.sendEstimateMailService.triggerMail( + tenantId, + saleEstimateId, + saleEstimateMailOpts + ); + } + + /** + * Retrieves the default mail options of the given sale estimate. + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns {Promise} + */ + public getSaleEstimateMail( + tenantId: number, + saleEstimateId: number + ): Promise { + return this.sendEstimateMailService.getMailOptions( + tenantId, + saleEstimateId ); } } diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts b/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts index db19743f7..af1d2098c 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts @@ -1,6 +1,7 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; +import { GetSaleEstimate } from './GetSaleEstimate'; @Service() export class SaleEstimatesPdf { @@ -10,11 +11,19 @@ export class SaleEstimatesPdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private getSaleEstimate: GetSaleEstimate; + /** * Retrieve sale invoice pdf content. - * @param {} saleInvoice - + * @param {number} tenantId - + * @param {ISaleInvoice} saleInvoice - */ - async getSaleEstimatePdf(tenantId: number, saleEstimate) { + public async getSaleEstimatePdf(tenantId: number, saleEstimateId: number) { + const saleEstimate = await this.getSaleEstimate.getEstimate( + tenantId, + saleEstimateId + ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/estimate-regular', diff --git a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts new file mode 100644 index 000000000..258496306 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts @@ -0,0 +1,146 @@ +import { Inject, Service } from 'typedi'; +import Mail from '@/lib/Mail'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { + DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT, + DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT, +} from './constants'; +import { SaleEstimatesPdf } from './SaleEstimatesPdf'; +import { GetSaleEstimate } from './GetSaleEstimate'; +import { + SaleEstimateMailOptions, + SaleEstimateMailOptionsDTO, +} from '@/interfaces'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; + +@Service() +export class SendSaleEstimateMail { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private estimatePdf: SaleEstimatesPdf; + + @Inject() + private getSaleEstimateService: GetSaleEstimate; + + @Inject() + private contactMailNotification: ContactMailNotification; + + @Inject('agenda') + private agenda: any; + + /** + * Triggers the reminder mail of the given sale estimate. + * @param {number} tenantId - + * @param {number} saleEstimateId - + * @param {SaleEstimateMailOptionsDTO} messageOptions - + * @returns {Promise} + */ + public async triggerMail( + tenantId: number, + saleEstimateId: number, + messageOptions: SaleEstimateMailOptionsDTO + ): Promise { + const payload = { + tenantId, + saleEstimateId, + messageOptions, + }; + await this.agenda.now('sale-estimate-mail-send', payload); + } + + /** + * Formates the text of the mail. + * @param {number} tenantId - Tenant id. + * @param {number} estimateId - Estimate id. + * @returns {Promise>} + */ + public formatterData = async (tenantId: number, estimateId: number) => { + const estimate = await this.getSaleEstimateService.getEstimate( + tenantId, + estimateId + ); + return { + CustomerName: estimate.customer.displayName, + EstimateNumber: estimate.estimateNumber, + EstimateDate: estimate.formattedEstimateDate, + EstimateAmount: estimate.formattedAmount, + EstimateExpirationDate: estimate.formattedExpirationDate, + }; + }; + + /** + * Retrieves the mail options. + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns {Promise} + */ + public getMailOptions = async ( + tenantId: number, + saleEstimateId: number + ): Promise => { + const { SaleEstimate } = this.tenancy.models(tenantId); + + const saleEstimate = await SaleEstimate.query() + .findById(saleEstimateId) + .throwIfNotFound(); + + const formatterData = await this.formatterData(tenantId, saleEstimateId); + + const mailOptions = await this.contactMailNotification.getMailOptions( + tenantId, + saleEstimate.customerId, + DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT, + DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT, + formatterData + ); + return { + ...mailOptions, + data: formatterData, + attachEstimate: true + }; + }; + + /** + * Sends the mail notification of the given sale estimate. + * @param {number} tenantId + * @param {number} saleEstimateId + * @param {SaleEstimateMailOptions} messageOptions + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + saleEstimateId: number, + messageOptions: SaleEstimateMailOptionsDTO + ): Promise { + const localMessageOpts = await this.getMailOptions( + tenantId, + saleEstimateId + ); + // Overrides and validates the given mail options. + const messageOpts = parseAndValidateMailOptions( + localMessageOpts, + messageOptions + ); + const mail = new Mail() + .setSubject(messageOpts.subject) + .setTo(messageOpts.to) + .setContent(messageOpts.body); + + if (messageOpts.attachEstimate) { + const estimatePdfBuffer = await this.estimatePdf.getSaleEstimatePdf( + tenantId, + saleEstimateId + ); + mail.setAttachments([ + { + filename: messageOpts.data?.EstimateNumber || 'estimate.pdf', + content: estimatePdfBuffer, + }, + ]); + } + await mail.send(); + } +} diff --git a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMailJob.ts b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMailJob.ts new file mode 100644 index 000000000..b5e8eda39 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMailJob.ts @@ -0,0 +1,36 @@ +import Container, { Service } from 'typedi'; +import { SendSaleEstimateMail } from './SendSaleEstimateMail'; + +@Service() +export class SendSaleEstimateMailJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'sale-estimate-mail-send', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers sending invoice mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, saleEstimateId, messageOptions } = job.attrs.data; + const sendSaleEstimateMail = Container.get(SendSaleEstimateMail); + + try { + await sendSaleEstimateMail.sendMail( + tenantId, + saleEstimateId, + messageOptions + ); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/Estimates/constants.ts b/packages/server/src/services/Sales/Estimates/constants.ts index 2b58c74a8..6b689a0e1 100644 --- a/packages/server/src/services/Sales/Estimates/constants.ts +++ b/packages/server/src/services/Sales/Estimates/constants.ts @@ -1,3 +1,18 @@ +export const DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT = + 'Estimate {EstimateNumber} is awaiting your approval'; +export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `

Dear {CustomerName}

+

Thank you for your business, You can view or print your estimate from attachements.

+

+Estimate #{EstimateNumber}
+Expiration Date : {EstimateExpirationDate}
+Amount : {EstimateAmount}
+

+ +

+Regards
+{CompanyName} +

+`; export const ERRORS = { SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND', @@ -8,7 +23,7 @@ export const ERRORS = { CUSTOMER_HAS_SALES_ESTIMATES: 'CUSTOMER_HAS_SALES_ESTIMATES', SALE_ESTIMATE_NO_IS_REQUIRED: 'SALE_ESTIMATE_NO_IS_REQUIRED', SALE_ESTIMATE_ALREADY_DELIVERED: 'SALE_ESTIMATE_ALREADY_DELIVERED', - SALE_ESTIMATE_ALREADY_APPROVED: 'SALE_ESTIMATE_ALREADY_APPROVED' + SALE_ESTIMATE_ALREADY_APPROVED: 'SALE_ESTIMATE_ALREADY_APPROVED', }; export const DEFAULT_VIEW_COLUMNS = []; diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts index f2245afef..b57f86ed9 100644 --- a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts @@ -24,8 +24,7 @@ export class GetSaleInvoice { */ public async getSaleInvoice( tenantId: number, - saleInvoiceId: number, - authorizedUser: ISystemUser + saleInvoiceId: number ): Promise { const { SaleInvoice } = this.tenancy.models(tenantId); diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts new file mode 100644 index 000000000..2a65d316e --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts @@ -0,0 +1,3 @@ +export class GetSaleInvoiceMailReminder { + public getInvoiceMailReminder(tenantId: number, saleInvoiceId: number) {} +} diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts index 3d17c699c..9cccf94ef 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts @@ -1,7 +1,8 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; -import { ISaleInvoice } from '@/interfaces'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { CommandSaleInvoiceValidators } from './CommandSaleInvoiceValidators'; @Service() export class SaleInvoicePdf { @@ -11,16 +12,34 @@ export class SaleInvoicePdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private validators: CommandSaleInvoiceValidators; + + @Inject() + private tenancy: HasTenancyService; + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - Tenant Id. * @param {ISaleInvoice} saleInvoice - * @returns {Promise} */ - async saleInvoicePdf( + public async saleInvoicePdf( tenantId: number, - saleInvoice: ISaleInvoice + invoiceId: number ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + const saleInvoice = await SaleInvoice.query() + .findById(invoiceId) + .withGraphFetched('entries.item') + .withGraphFetched('entries.tax') + .withGraphFetched('customer') + .withGraphFetched('taxes.taxRate'); + + // Validates the given sale invoice existance. + this.validators.validateInvoiceExistance(saleInvoice); + const htmlContent = await this.templateInjectable.render( tenantId, 'modules/invoice-regular', diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts index 8a37386f9..bc3f8c24b 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts @@ -11,6 +11,7 @@ import { ISystemUser, ITenantUser, InvoiceNotificationType, + SendInvoiceMailDTO, } from '@/interfaces'; import { Inject, Service } from 'typedi'; import { CreateSaleInvoice } from './CreateSaleInvoice'; @@ -24,6 +25,9 @@ import { WriteoffSaleInvoice } from './WriteoffSaleInvoice'; import { SaleInvoicePdf } from './SaleInvoicePdf'; import { GetInvoicePaymentsService } from './GetInvoicePaymentsService'; import { SaleInvoiceNotifyBySms } from './SaleInvoiceNotifyBySms'; +import { SendInvoiceMailReminder } from './SendSaleInvoiceMailReminder'; +import { SendSaleInvoiceMail } from './SendSaleInvoiceMail'; +import { GetSaleInvoiceMailReminder } from './GetSaleInvoiceMailReminder'; @Service() export class SaleInvoiceApplication { @@ -60,6 +64,15 @@ export class SaleInvoiceApplication { @Inject() private invoiceSms: SaleInvoiceNotifyBySms; + @Inject() + private sendInvoiceReminderService: SendInvoiceMailReminder; + + @Inject() + private sendSaleInvoiceMailService: SendSaleInvoiceMail; + + @Inject() + private getSaleInvoiceReminderService: GetSaleInvoiceMailReminder; + /** * Creates a new sale invoice with associated GL entries. * @param {number} tenantId @@ -236,13 +249,13 @@ export class SaleInvoiceApplication { }; /** - * - * @param {number} tenantId ] - * @param saleInvoice - * @returns + * Retrieves the pdf buffer of the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} saleInvoice + * @returns {Promise} */ - public saleInvoicePdf(tenantId: number, saleInvoice) { - return this.pdfSaleInvoiceService.saleInvoicePdf(tenantId, saleInvoice); + public saleInvoicePdf(tenantId: number, saleInvoiceId: number) { + return this.pdfSaleInvoiceService.saleInvoicePdf(tenantId, saleInvoiceId); } /** @@ -279,4 +292,67 @@ export class SaleInvoiceApplication { invoiceSmsDetailsDTO ); }; + + /** + * Retrieves the metadata of invoice mail reminder. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {} + */ + public getSaleInvoiceMailReminder(tenantId: number, saleInvoiceId: number) { + return this.sendInvoiceReminderService.getMailOption( + tenantId, + saleInvoiceId + ); + } + + /** + * Sends reminder of the given invoice to the invoice's customer. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {} + */ + public sendSaleInvoiceMailReminder( + tenantId: number, + saleInvoiceId: number, + messageDTO: SendInvoiceMailDTO + ) { + return this.sendInvoiceReminderService.triggerMail( + tenantId, + saleInvoiceId, + messageDTO + ); + } + + /** + * Sends the invoice mail of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageDTO + * @returns {Promise} + */ + public sendSaleInvoiceMail( + tenantId: number, + saleInvoiceId: number, + messageDTO: SendInvoiceMailDTO + ) { + return this.sendSaleInvoiceMailService.triggerMail( + tenantId, + saleInvoiceId, + messageDTO + ); + } + + /** + * Retrieves the default mail options of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceid + * @returns {Promise} + */ + public getSaleInvoiceMail(tenantId: number, saleInvoiceid: number) { + return this.sendSaleInvoiceMailService.getMailOption( + tenantId, + saleInvoiceid + ); + } } diff --git a/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts b/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts new file mode 100644 index 000000000..52ef46a59 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts @@ -0,0 +1,83 @@ +import { Inject, Service } from 'typedi'; +import { SaleInvoiceMailOptions } from '@/interfaces'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetSaleInvoice } from './GetSaleInvoice'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { + DEFAULT_INVOICE_MAIL_CONTENT, + DEFAULT_INVOICE_MAIL_SUBJECT, +} from './constants'; + +@Service() +export class SendSaleInvoiceMailCommon { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private getSaleInvoiceService: GetSaleInvoice; + + @Inject() + private contactMailNotification: ContactMailNotification; + + /** + * Retrieves the mail options. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Invoice id. + * @param {string} defaultSubject - Subject text. + * @param {string} defaultBody - Subject body. + * @returns {Promise} + */ + public async getMailOption( + tenantId: number, + invoiceId: number, + defaultSubject: string = DEFAULT_INVOICE_MAIL_SUBJECT, + defaultBody: string = DEFAULT_INVOICE_MAIL_CONTENT + ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + const saleInvoice = await SaleInvoice.query() + .findById(invoiceId) + .throwIfNotFound(); + + const formatterData = await this.formatText(tenantId, invoiceId); + + const mailOptions = await this.contactMailNotification.getMailOptions( + tenantId, + saleInvoice.customerId, + defaultSubject, + defaultBody, + formatterData + ); + return { + ...mailOptions, + attachInvoice: true, + }; + } + + /** + * Retrieves the formatted text of the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Sale invoice id. + * @param {string} text - The given text. + * @returns {Promise} + */ + public formatText = async ( + tenantId: number, + invoiceId: number + ): Promise> => { + const invoice = await this.getSaleInvoiceService.getSaleInvoice( + tenantId, + invoiceId + ); + + return { + CustomerName: invoice.customer.displayName, + InvoiceNumber: invoice.invoiceNo, + InvoiceDueAmount: invoice.dueAmountFormatted, + InvoiceDueDate: invoice.dueDateFormatted, + InvoiceDate: invoice.invoiceDateFormatted, + InvoiceAmount: invoice.totalFormatted, + OverdueDays: invoice.overdueDays, + }; + }; +} diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts new file mode 100644 index 000000000..05db4f73e --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts @@ -0,0 +1,95 @@ +import { Inject, Service } from 'typedi'; +import Mail from '@/lib/Mail'; +import { 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'; + +@Service() +export class SendSaleInvoiceMail { + @Inject() + private invoicePdf: SaleInvoicePdf; + + @Inject() + private invoiceMail: SendSaleInvoiceMailCommon; + + @Inject('agenda') + private agenda: any; + + /** + * Sends the invoice mail of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageDTO + */ + public async triggerMail( + tenantId: number, + saleInvoiceId: number, + messageDTO: SendInvoiceMailDTO + ) { + const payload = { + tenantId, + saleInvoiceId, + messageDTO, + }; + await this.agenda.now('sale-invoice-mail-send', payload); + } + + /** + * Retrieves the mail options of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {Promise} + */ + 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 + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageDTO + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + saleInvoiceId: number, + messageDTO: SendInvoiceMailDTO + ) { + const defaultMessageOpts = await this.getMailOption( + tenantId, + saleInvoiceId + ); + // Merge message opts with default options and validate the incoming options. + const messageOpts = parseAndValidateMailOptions( + defaultMessageOpts, + messageDTO + ); + const mail = new Mail() + .setSubject(messageOpts.subject) + .setTo(messageOpts.to) + .setContent(messageOpts.body); + + if (messageOpts.attachInvoice) { + // Retrieves document buffer of the invoice pdf document. + const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf( + tenantId, + saleInvoiceId + ); + mail.setAttachments([ + { filename: 'invoice.pdf', content: invoicePdfBuffer }, + ]); + } + await mail.send(); + } +} diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts new file mode 100644 index 000000000..3c1e49a6c --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts @@ -0,0 +1,33 @@ +import Container, { Service } from 'typedi'; +import events from '@/subscribers/events'; +import { SendSaleInvoiceMail } from './SendSaleInvoiceMail'; + +@Service() +export class SendSaleInvoiceMailJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'sale-invoice-mail-send', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers sending invoice mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, saleInvoiceId, messageDTO } = job.attrs.data; + const sendInvoiceMail = Container.get(SendSaleInvoiceMail); + + try { + await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageDTO); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts new file mode 100644 index 000000000..b5389a8a0 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts @@ -0,0 +1,90 @@ +import { Inject, Service } from 'typedi'; +import { SendInvoiceMailDTO } from '@/interfaces'; +import Mail from '@/lib/Mail'; +import { SaleInvoicePdf } from './SaleInvoicePdf'; +import { SendSaleInvoiceMailCommon } from './SendInvoiceInvoiceMailCommon'; +import { + DEFAULT_INVOICE_REMINDER_MAIL_CONTENT, + DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, +} from './constants'; + +@Service() +export class SendInvoiceMailReminder { + @Inject('agenda') + private agenda: any; + + @Inject() + private invoicePdf: SaleInvoicePdf; + + @Inject() + private invoiceCommonMail: SendSaleInvoiceMailCommon; + + /** + * Triggers the reminder mail of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + */ + public async triggerMail( + tenantId: number, + saleInvoiceId: number, + messageOptions: SendInvoiceMailDTO + ) { + const payload = { + tenantId, + saleInvoiceId, + messageOptions, + }; + await this.agenda.now('sale-invoice-reminder-mail-send', payload); + } + + /** + * Retrieves the mail options of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {Promise} + */ + public async getMailOption(tenantId: number, saleInvoiceId: number) { + return this.invoiceCommonMail.getMailOption( + tenantId, + saleInvoiceId, + DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, + DEFAULT_INVOICE_REMINDER_MAIL_CONTENT + ); + } + + /** + * Triggers the mail invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageOptions + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + saleInvoiceId: number, + messageOptions: SendInvoiceMailDTO + ) { + const localMessageOpts = await this.getMailOption(tenantId, saleInvoiceId); + + const messageOpts = { + ...localMessageOpts, + ...messageOptions, + }; + const mail = new Mail() + .setSubject(messageOpts.subject) + .setTo(messageOpts.to) + .setContent(messageOpts.body); + + if (messageOpts.attachInvoice) { + // Retrieves document buffer of the invoice pdf document. + const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf( + tenantId, + saleInvoiceId + ); + mail.setAttachments([ + { filename: 'invoice.pdf', content: invoicePdfBuffer }, + ]); + } + await mail.send(); + } +} diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts new file mode 100644 index 000000000..6570a153f --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts @@ -0,0 +1,32 @@ +import Container, { Service } from 'typedi'; +import { SendInvoiceMailReminder } from './SendSaleInvoiceMailReminder'; + +@Service() +export class SendSaleInvoiceReminderMailJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'sale-invoice-reminder-mail-send', + { priority: 'high', concurrency: 1 }, + this.handler + ); + } + + /** + * Triggers sending invoice mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, saleInvoiceId, messageOptions } = job.attrs.data; + const sendInvoiceMail = Container.get(SendInvoiceMailReminder); + + try { + await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageOptions); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/Invoices/constants.ts b/packages/server/src/services/Sales/Invoices/constants.ts index 018dec027..404b7e613 100644 --- a/packages/server/src/services/Sales/Invoices/constants.ts +++ b/packages/server/src/services/Sales/Invoices/constants.ts @@ -1,3 +1,35 @@ +export const DEFAULT_INVOICE_MAIL_SUBJECT = + 'Invoice {InvoiceNumber} from {CompanyName}'; +export const DEFAULT_INVOICE_MAIL_CONTENT = ` +

Dear {CustomerName}

+

Thank you for your business, You can view or print your invoice from attachements.

+

+Invoice #{InvoiceNumber}
+Due Date : {InvoiceDueDate}
+Amount : {InvoiceAmount}
+

+ +

+Regards
+{CompanyName} +

+`; + +export const DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT = + 'Invoice {InvoiceNumber} reminder from {CompanyName}'; +export const DEFAULT_INVOICE_REMINDER_MAIL_CONTENT = ` +

Dear {CustomerName}

+

You might have missed the payment date and the invoice is now overdue by {OverdueDays} days.

+

Invoice #{InvoiceNumber}
+Due Date : {InvoiceDueDate}
+Amount : {InvoiceAmount}

+ +

+Regards
+{CompanyName} +

+`; + export const ERRORS = { INVOICE_NUMBER_NOT_UNIQUE: 'INVOICE_NUMBER_NOT_UNIQUE', SALE_INVOICE_NOT_FOUND: 'SALE_INVOICE_NOT_FOUND', @@ -16,6 +48,7 @@ export const ERRORS = { PAYMENT_ACCOUNT_CURRENCY_INVALID: 'PAYMENT_ACCOUNT_CURRENCY_INVALID', SALE_INVOICE_ALREADY_WRITTEN_OFF: 'SALE_INVOICE_ALREADY_WRITTEN_OFF', SALE_INVOICE_NOT_WRITTEN_OFF: 'SALE_INVOICE_NOT_WRITTEN_OFF', + NO_INVOICE_CUSTOMER_EMAIL_ADDR: 'NO_INVOICE_CUSTOMER_EMAIL_ADDR', }; export const DEFAULT_VIEW_COLUMNS = []; diff --git a/packages/server/src/services/Sales/PaymentReceives/GetPaymentReeceivePdf.ts b/packages/server/src/services/Sales/PaymentReceives/GetPaymentReeceivePdf.ts index e05937f76..e3d3cfb26 100644 --- a/packages/server/src/services/Sales/PaymentReceives/GetPaymentReeceivePdf.ts +++ b/packages/server/src/services/Sales/PaymentReceives/GetPaymentReeceivePdf.ts @@ -1,7 +1,7 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; -import { IPaymentReceive } from '@/interfaces'; +import { GetPaymentReceive } from './GetPaymentReceive'; @Service() export default class GetPaymentReceivePdf { @@ -11,6 +11,9 @@ export default class GetPaymentReceivePdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private getPaymentService: GetPaymentReceive; + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - @@ -19,8 +22,12 @@ export default class GetPaymentReceivePdf { */ async getPaymentReceivePdf( tenantId: number, - paymentReceive: IPaymentReceive + paymentReceiveId: number ): Promise { + const paymentReceive = await this.getPaymentService.getPaymentReceive( + tenantId, + paymentReceiveId + ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/payment-receive-standard', diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts new file mode 100644 index 000000000..acb1ea7a1 --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts @@ -0,0 +1,128 @@ +import { Inject, Service } from 'typedi'; +import { + PaymentReceiveMailOpts, + PaymentReceiveMailOptsDTO, + SendInvoiceMailDTO, +} from '@/interfaces'; +import Mail from '@/lib/Mail'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { + DEFAULT_PAYMENT_MAIL_CONTENT, + DEFAULT_PAYMENT_MAIL_SUBJECT, +} from './constants'; +import { GetPaymentReceive } from './GetPaymentReceive'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; + +@Service() +export class SendPaymentReceiveMailNotification { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private getPaymentService: GetPaymentReceive; + + @Inject() + private contactMailNotification: ContactMailNotification; + + @Inject('agenda') + private agenda: any; + + /** + * Sends the mail of the given payment receive. + * @param {number} tenantId + * @param {number} paymentReceiveId + * @param {PaymentReceiveMailOptsDTO} messageDTO + * @returns {Promise} + */ + public async triggerMail( + tenantId: number, + paymentReceiveId: number, + messageDTO: PaymentReceiveMailOptsDTO + ): Promise { + const payload = { + tenantId, + paymentReceiveId, + messageDTO, + }; + await this.agenda.now('payment-receive-mail-send', payload); + } + + /** + * Retrieves the default payment mail options. + * @param {number} tenantId - Tenant id. + * @param {number} paymentReceiveId - Payment receive id. + * @returns {Promise} + */ + public getMailOptions = async ( + tenantId: number, + paymentId: number + ): Promise => { + const { PaymentReceive } = this.tenancy.models(tenantId); + + const paymentReceive = await PaymentReceive.query() + .findById(paymentId) + .throwIfNotFound(); + + const formatterData = await this.textFormatter(tenantId, paymentId); + + return this.contactMailNotification.getMailOptions( + tenantId, + paymentReceive.customerId, + DEFAULT_PAYMENT_MAIL_SUBJECT, + DEFAULT_PAYMENT_MAIL_CONTENT, + formatterData + ); + }; + + /** + * Retrieves the formatted text of the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Sale invoice id. + * @param {string} text - The given text. + * @returns {Promise} + */ + public textFormatter = async ( + tenantId: number, + invoiceId: number + ): Promise> => { + const payment = await this.getPaymentService.getPaymentReceive( + tenantId, + invoiceId + ); + return { + CustomerName: payment.customer.displayName, + PaymentNumber: payment.payment_receive_no, + PaymentDate: payment.formattedPaymentDate, + PaymentAmount: payment.formattedAmount, + }; + }; + + /** + * Triggers the mail invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageDTO + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + paymentReceiveId: number, + messageDTO: SendInvoiceMailDTO + ): Promise { + const defaultMessageOpts = await this.getMailOptions( + tenantId, + paymentReceiveId + ); + // Parsed message opts with default options. + const parsedMessageOpts = parseAndValidateMailOptions( + defaultMessageOpts, + messageDTO + ); + await new Mail() + .setSubject(parsedMessageOpts.subject) + .setTo(parsedMessageOpts.to) + .setContent(parsedMessageOpts.body) + .send(); + } +} diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts new file mode 100644 index 000000000..236a33758 --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts @@ -0,0 +1,35 @@ +import Container, { Service } from 'typedi'; +import events from '@/subscribers/events'; +import { SendPaymentReceiveMailNotification } from './PaymentReceiveMailNotification'; + +@Service() +export class PaymentReceiveMailNotificationJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'payment-receive-mail-send', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers sending payment notification via mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, paymentReceiveId, messageDTO } = job.attrs.data; + const paymentMail = Container.get(SendPaymentReceiveMailNotification); + + console.log(tenantId, paymentReceiveId, messageDTO); + + try { + await paymentMail.sendMail(tenantId, paymentReceiveId, messageDTO); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts index afeca6010..0d5669bf8 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts @@ -7,6 +7,7 @@ import { IPaymentReceiveSmsDetails, IPaymentReceivesFilter, ISystemUser, + PaymentReceiveMailOptsDTO, } from '@/interfaces'; import { Inject, Service } from 'typedi'; import { CreatePaymentReceive } from './CreatePaymentReceive'; @@ -17,7 +18,7 @@ import { GetPaymentReceive } from './GetPaymentReceive'; import { GetPaymentReceiveInvoices } from './GetPaymentReceiveInvoices'; import { PaymentReceiveNotifyBySms } from './PaymentReceiveSmsNotify'; import GetPaymentReceivePdf from './GetPaymentReeceivePdf'; -import { PaymentReceive } from '@/models'; +import { SendPaymentReceiveMailNotification } from './PaymentReceiveMailNotification'; @Service() export class PaymentReceivesApplication { @@ -42,6 +43,9 @@ export class PaymentReceivesApplication { @Inject() private paymentSmsNotify: PaymentReceiveNotifyBySms; + @Inject() + private paymentMailNotify: SendPaymentReceiveMailNotification; + @Inject() private getPaymentReceivePdfService: GetPaymentReceivePdf; @@ -176,18 +180,47 @@ export class PaymentReceivesApplication { }; /** - * Retrieve PDF content of the given payment receive. + * Notify customer via mail about payment receive details. + * @param {number} tenantId + * @param {number} paymentReceiveId + * @param {IPaymentReceiveMailOpts} messageOpts + * @returns {Promise} + */ + public notifyPaymentByMail( + tenantId: number, + paymentReceiveId: number, + messageOpts: PaymentReceiveMailOptsDTO + ): Promise { + return this.paymentMailNotify.triggerMail( + tenantId, + paymentReceiveId, + messageOpts + ); + } + + /** + * Retrieves the default mail options of the given payment transaction. + * @param {number} tenantId + * @param {number} paymentReceiveId + * @returns {Promise} + */ + public getPaymentMailOptions(tenantId: number, paymentReceiveId: number) { + return this.paymentMailNotify.getMailOptions(tenantId, paymentReceiveId); + } + + /** + * Retrieve pdf content of the given payment receive. * @param {number} tenantId * @param {PaymentReceive} paymentReceive * @returns */ public getPaymentReceivePdf = ( tenantId: number, - paymentReceive: PaymentReceive + paymentReceiveId: number ) => { return this.getPaymentReceivePdfService.getPaymentReceivePdf( tenantId, - paymentReceive + paymentReceiveId ); }; } diff --git a/packages/server/src/services/Sales/PaymentReceives/constants.ts b/packages/server/src/services/Sales/PaymentReceives/constants.ts index ccd8d75ee..405939617 100644 --- a/packages/server/src/services/Sales/PaymentReceives/constants.ts +++ b/packages/server/src/services/Sales/PaymentReceives/constants.ts @@ -1,3 +1,18 @@ +export const DEFAULT_PAYMENT_MAIL_SUBJECT = 'Payment Received by {CompanyName}'; +export const DEFAULT_PAYMENT_MAIL_CONTENT = ` +

Dear {CustomerName}

+

Thank you for your payment. It was a pleasure doing business with you. We look forward to work together again!

+

+Payment Date : {PaymentDate}
+Amount : {PaymentAmount}
+

+ +

+Regards
+{CompanyName} +

+`; + export const ERRORS = { PAYMENT_RECEIVE_NO_EXISTS: 'PAYMENT_RECEIVE_NO_EXISTS', PAYMENT_RECEIVE_NOT_EXISTS: 'PAYMENT_RECEIVE_NOT_EXISTS', @@ -12,6 +27,7 @@ export const ERRORS = { PAYMENT_CUSTOMER_SHOULD_NOT_UPDATE: 'PAYMENT_CUSTOMER_SHOULD_NOT_UPDATE', CUSTOMER_HAS_PAYMENT_RECEIVES: 'CUSTOMER_HAS_PAYMENT_RECEIVES', PAYMENT_ACCOUNT_CURRENCY_INVALID: 'PAYMENT_ACCOUNT_CURRENCY_INVALID', + NO_INVOICE_CUSTOMER_EMAIL_ADDR: 'NO_INVOICE_CUSTOMER_EMAIL_ADDR', }; export const DEFAULT_VIEWS = []; diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts index 8dfdd4c75..459d9c62e 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts @@ -5,6 +5,8 @@ import { IPaginationMeta, ISaleReceipt, ISalesReceiptsFilter, + SaleReceiptMailOpts, + SaleReceiptMailOptsDTO, } from '@/interfaces'; import { EditSaleReceipt } from './EditSaleReceipt'; import { GetSaleReceipt } from './GetSaleReceipt'; @@ -13,6 +15,7 @@ import { GetSaleReceipts } from './GetSaleReceipts'; import { CloseSaleReceipt } from './CloseSaleReceipt'; import { SaleReceiptsPdf } from './SaleReceiptsPdfService'; import { SaleReceiptNotifyBySms } from './SaleReceiptNotifyBySms'; +import { SaleReceiptMailNotification } from './SaleReceiptMailNotification'; @Service() export class SaleReceiptApplication { @@ -40,6 +43,9 @@ export class SaleReceiptApplication { @Inject() private saleReceiptNotifyBySmsService: SaleReceiptNotifyBySms; + @Inject() + private saleReceiptNotifyByMailService: SaleReceiptMailNotification; + /** * Creates a new sale receipt with associated entries. * @param {number} tenantId @@ -166,4 +172,38 @@ export class SaleReceiptApplication { saleReceiptId ); } + + /** + * Sends the receipt mail of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + * @returns {Promise} + */ + public sendSaleReceiptMail( + tenantId: number, + saleReceiptId: number, + messageOpts: SaleReceiptMailOptsDTO + ): Promise { + return this.saleReceiptNotifyByMailService.triggerMail( + tenantId, + saleReceiptId, + messageOpts + ); + } + + /** + * Retrieves the default mail options of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + * @returns {Promise} + */ + public getSaleReceiptMail( + tenantId: number, + saleReceiptId: number + ): Promise { + return this.saleReceiptNotifyByMailService.getMailOptions( + tenantId, + saleReceiptId + ); + } } diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts new file mode 100644 index 000000000..572bed2f8 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts @@ -0,0 +1,142 @@ +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import Mail from '@/lib/Mail'; +import { GetSaleReceipt } from './GetSaleReceipt'; +import { SaleReceiptsPdf } from './SaleReceiptsPdfService'; +import { + DEFAULT_RECEIPT_MAIL_CONTENT, + DEFAULT_RECEIPT_MAIL_SUBJECT, +} from './constants'; +import { SaleReceiptMailOpts, SaleReceiptMailOptsDTO } from '@/interfaces'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; + +@Service() +export class SaleReceiptMailNotification { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private getSaleReceiptService: GetSaleReceipt; + + @Inject() + private receiptPdfService: SaleReceiptsPdf; + + @Inject() + private contactMailNotification: ContactMailNotification; + + @Inject('agenda') + private agenda: any; + + /** + * Sends the receipt mail of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + * @param {SaleReceiptMailOptsDTO} messageDTO + */ + public async triggerMail( + tenantId: number, + saleReceiptId: number, + messageOpts: SaleReceiptMailOptsDTO + ) { + const payload = { + tenantId, + saleReceiptId, + messageOpts, + }; + await this.agenda.now('sale-receipt-mail-send', payload); + } + + /** + * Retrieves the mail options of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + * @returns {Promise} + */ + public async getMailOptions( + tenantId: number, + saleReceiptId: number + ): Promise { + const { SaleReceipt } = this.tenancy.models(tenantId); + + const saleReceipt = await SaleReceipt.query() + .findById(saleReceiptId) + .throwIfNotFound(); + + const formattedData = await this.textFormatter(tenantId, saleReceiptId); + + const mailOpts = await this.contactMailNotification.getMailOptions( + tenantId, + saleReceipt.customerId, + DEFAULT_RECEIPT_MAIL_SUBJECT, + DEFAULT_RECEIPT_MAIL_CONTENT, + formattedData + ); + return { + ...mailOpts, + attachReceipt: true, + }; + } + + /** + * Retrieves the formatted text of the given sale receipt. + * @param {number} tenantId - Tenant id. + * @param {number} receiptId - Sale receipt id. + * @param {string} text - The given text. + * @returns {Promise} + */ + public textFormatter = async ( + tenantId: number, + receiptId: number + ): Promise> => { + const receipt = await this.getSaleReceiptService.getSaleReceipt( + tenantId, + receiptId + ); + return { + CustomerName: receipt.customer.displayName, + ReceiptNumber: receipt.receiptNumber, + ReceiptDate: receipt.formattedReceiptDate, + ReceiptAmount: receipt.formattedAmount, + }; + }; + + /** + * Triggers the mail notification of the given sale receipt. + * @param {number} tenantId - Tenant id. + * @param {number} saleReceiptId - Sale receipt id. + * @param {SaleReceiptMailOpts} messageDTO - Overrided message options. + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + saleReceiptId: number, + messageOpts: SaleReceiptMailOptsDTO + ) { + const defaultMessageOpts = await this.getMailOptions( + tenantId, + saleReceiptId + ); + // Merges message opts with default options. + const parsedMessageOpts = parseAndValidateMailOptions( + defaultMessageOpts, + messageOpts + ); + const mail = new Mail() + .setSubject(parsedMessageOpts.subject) + .setTo(parsedMessageOpts.to) + .setContent(parsedMessageOpts.body); + + if (parsedMessageOpts.attachReceipt) { + // Retrieves document buffer of the receipt pdf document. + const receiptPdfBuffer = await this.receiptPdfService.saleReceiptPdf( + tenantId, + saleReceiptId + ); + mail.setAttachments([ + { filename: 'receipt.pdf', content: receiptPdfBuffer }, + ]); + } + await mail.send(); + } +} diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotificationJob.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotificationJob.ts new file mode 100644 index 000000000..f32325114 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotificationJob.ts @@ -0,0 +1,36 @@ +import Container, { Service } from 'typedi'; +import { SaleReceiptMailNotification } from './SaleReceiptMailNotification'; + +@Service() +export class SaleReceiptMailNotificationJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'sale-receipt-mail-send', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers sending invoice mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, saleReceiptId, messageOpts } = job.attrs.data; + const receiveMailNotification = Container.get(SaleReceiptMailNotification); + + try { + await receiveMailNotification.sendMail( + tenantId, + saleReceiptId, + messageOpts + ); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts index c06263212..cad2b5f93 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts @@ -1,6 +1,7 @@ import { Inject, Service } from 'typedi'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; +import { GetSaleReceipt } from './GetSaleReceipt'; @Service() export class SaleReceiptsPdf { @@ -10,11 +11,20 @@ export class SaleReceiptsPdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private getSaleReceiptService: GetSaleReceipt; + /** - * Retrieve sale invoice pdf content. - * @param {} saleInvoice - + * Retrieves sale invoice pdf content. + * @param {number} tenantId - + * @param {number} saleInvoiceId - + * @returns {Promise} */ - public async saleReceiptPdf(tenantId: number, saleReceipt) { + public async saleReceiptPdf(tenantId: number, saleReceiptId: number) { + const saleReceipt = await this.getSaleReceiptService.getSaleReceipt( + tenantId, + saleReceiptId + ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/receipt-regular', diff --git a/packages/server/src/services/Sales/Receipts/constants.ts b/packages/server/src/services/Sales/Receipts/constants.ts index bf0cdef18..084af9214 100644 --- a/packages/server/src/services/Sales/Receipts/constants.ts +++ b/packages/server/src/services/Sales/Receipts/constants.ts @@ -1,3 +1,19 @@ +export const DEFAULT_RECEIPT_MAIL_SUBJECT = + 'Receipt {ReceiptNumber} from {CompanyName}'; +export const DEFAULT_RECEIPT_MAIL_CONTENT = ` +

Dear {CustomerName}

+

Thank you for your business, You can view or print your receipt from attachements.

+

+Receipt #{ReceiptNumber}
+Amount : {ReceiptAmount}
+

+ +

+Regards
+{CompanyName} +

+`; + export const ERRORS = { SALE_RECEIPT_NOT_FOUND: 'SALE_RECEIPT_NOT_FOUND', DEPOSIT_ACCOUNT_NOT_FOUND: 'DEPOSIT_ACCOUNT_NOT_FOUND', @@ -6,6 +22,7 @@ export const ERRORS = { SALE_RECEIPT_IS_ALREADY_CLOSED: 'SALE_RECEIPT_IS_ALREADY_CLOSED', SALE_RECEIPT_NO_IS_REQUIRED: 'SALE_RECEIPT_NO_IS_REQUIRED', CUSTOMER_HAS_SALES_INVOICES: 'CUSTOMER_HAS_SALES_INVOICES', + NO_INVOICE_CUSTOMER_EMAIL_ADDR: 'NO_INVOICE_CUSTOMER_EMAIL_ADDR' }; export const DEFAULT_VIEW_COLUMNS = []; diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 24ca0a0a3..e54f48152 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -129,6 +129,9 @@ export default { onNotifySms: 'onSaleInvoiceNotifySms', onNotifiedSms: 'onSaleInvoiceNotifiedSms', + + onNotifyMail: 'onSaleInvoiceNotifyMail', + onNotifyReminderMail: 'onSaleInvoiceNotifyReminderMail' }, /** @@ -160,6 +163,8 @@ export default { onRejecting: 'onSaleEstimateRejecting', onRejected: 'onSaleEstimateRejected', + + onNotifyMail: 'onSaleEstimateNotifyMail' }, /** diff --git a/packages/webapp/package.json b/packages/webapp/package.json index d4e1c65d1..c1c2415aa 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -20,6 +20,13 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", + "@tiptap/extension-color": "latest", + "@tiptap/extension-text-style": "2.1.13", + "@tiptap/core": "2.1.13", + "@tiptap/pm": "2.1.13", + "@tiptap/extension-list-item": "2.1.13", + "@tiptap/react": "2.1.13", + "@tiptap/starter-kit": "2.1.13", "@types/jest": "^26.0.15", "@types/js-money": "^0.6.1", "@types/lodash": "^4.14.172", diff --git a/packages/webapp/src/components/DialogsContainer.tsx b/packages/webapp/src/components/DialogsContainer.tsx index 2ee579980..b301e0492 100644 --- a/packages/webapp/src/components/DialogsContainer.tsx +++ b/packages/webapp/src/components/DialogsContainer.tsx @@ -47,6 +47,10 @@ import ProjectInvoicingFormDialog from '@/containers/Projects/containers/Project import ProjectBillableEntriesFormDialog from '@/containers/Projects/containers/ProjectBillableEntriesFormDialog'; import TaxRateFormDialog from '@/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog'; import { DialogsName } from '@/constants/dialogs'; +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/PaymentReceives/PaymentMailDialog/PaymentMailDialog'; /** * Dialogs container. @@ -137,6 +141,10 @@ export default function DialogsContainer() { dialogName={DialogsName.ProjectBillableEntriesForm} /> + + + + ); } diff --git a/packages/webapp/src/components/Forms/FRichEditor.tsx b/packages/webapp/src/components/Forms/FRichEditor.tsx new file mode 100644 index 000000000..d490f87f5 --- /dev/null +++ b/packages/webapp/src/components/Forms/FRichEditor.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { FieldConfig, FieldProps } from 'formik'; +import { Field } from '@blueprintjs-formik/core'; +import { RichEditor, RichEditorProps } from '../../components/RichEditor'; + +export interface FRichEditorProps + extends Omit, + RichEditorProps { + name: string; + value?: string; +} + +interface FieldToRichEditorProps + extends FieldProps, + Omit {} + +/** + * Transformes the field props to `RichEditor` props. + * @param {FieldToRichEditorProps} + * @returns {HTMLSelectProps} + */ +function fieldToRichEditor({ + field: { onBlur: onFieldBlur, ...field }, + form: { touched, errors, ...form }, + ...props +}: FieldToRichEditorProps): RichEditorProps { + return { + ...field, + ...props, + onChange: (value: string) => { + form.setFieldValue(field.name, value); + }, + }; +} + +/** + * Transformes field props to `RichEditor` props. + * @param {FieldToRichEditorProps} + * @returns {JSX.Element} + */ +function FieldToRichEditor({ ...props }: FieldToRichEditorProps): JSX.Element { + return ; +} + +/** + * Rich editor wrapper to bind with Formik. + * @param {FRichEditorProps} props - + * @returns {JSX.Element} + */ +export function FRichEditor({ ...props }: FRichEditorProps): JSX.Element { + return ; +} diff --git a/packages/webapp/src/components/Forms/index.tsx b/packages/webapp/src/components/Forms/index.tsx index d4fb2aec0..c638ac029 100644 --- a/packages/webapp/src/components/Forms/index.tsx +++ b/packages/webapp/src/components/Forms/index.tsx @@ -4,4 +4,5 @@ export * from './FMoneyInputGroup'; export * from './BlueprintFormik'; export * from './InputPrependText'; export * from './InputPrependButton'; -export * from './MoneyInputGroup'; \ No newline at end of file +export * from './MoneyInputGroup'; +export * from './FRichEditor'; \ No newline at end of file diff --git a/packages/webapp/src/components/RichEditor/RichEditor.style.scss b/packages/webapp/src/components/RichEditor/RichEditor.style.scss new file mode 100644 index 000000000..942fdf81e --- /dev/null +++ b/packages/webapp/src/components/RichEditor/RichEditor.style.scss @@ -0,0 +1,66 @@ +/* Basic editor styles */ +.tiptap { + color: #222; + + &:focus-visible { + outline: none; + } + + >*+* { + margin-top: 0.75em; + } + + ul, + ol { + padding: 0 1rem; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + } + + code { + background: rgba(#ffffff, 0.1); + color: rgba(#ffffff, 0.6); + border: 1px solid rgba(#ffffff, 0.1); + border-radius: 0.5rem; + padding: 0.2rem; + } + + pre { + background: rgba(#ffffff, 0.1); + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + + code { + color: inherit; + padding: 0; + background: none; + font-size: 0.8rem; + border: none; + } + } + + img { + max-width: 100%; + height: auto; + } + + blockquote { + margin-left: 0; + padding-left: 1rem; + border-left: 2px solid rgba(#ffffff, 0.4); + + hr { + border: none; + border-top: 2px solid rgba(#ffffff, 0.1); + margin: 2rem 0; + } + } +} \ No newline at end of file diff --git a/packages/webapp/src/components/RichEditor/RichEditor.tsx b/packages/webapp/src/components/RichEditor/RichEditor.tsx new file mode 100644 index 000000000..da82fb09f --- /dev/null +++ b/packages/webapp/src/components/RichEditor/RichEditor.tsx @@ -0,0 +1,58 @@ +// @ts-nocheck +import { Color } from '@tiptap/extension-color'; +import ListItem from '@tiptap/extension-list-item'; +import TextStyle from '@tiptap/extension-text-style'; +import { EditorProvider } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; +import { Box } from '../Layout/Box'; +import './RichEditor.style.scss'; + +const extensions = [ + Color.configure({ types: [TextStyle.name, ListItem.name] }), + TextStyle.configure({ types: [ListItem.name] }), + StarterKit.configure({ + bulletList: { + keepMarks: true, + keepAttributes: false, + }, + orderedList: { + keepMarks: true, + keepAttributes: false, + }, + }), +]; + +export interface RichEditorProps { + value?: string; + initialValue?: string; + onChange?: (value: string) => void; + className?: string; +} +export const RichEditor = ({ + value, + initialValue, + onChange, + className, +}: RichEditorProps) => { + const [content, handleChange] = useUncontrolled({ + value, + initialValue, + onChange, + finalValue: '', + }); + + const handleBlur = ({ editor }) => { + handleChange(editor.getHTML()); + }; + + return ( + + + + ); +}; diff --git a/packages/webapp/src/components/RichEditor/index.ts b/packages/webapp/src/components/RichEditor/index.ts new file mode 100644 index 000000000..226b701f3 --- /dev/null +++ b/packages/webapp/src/components/RichEditor/index.ts @@ -0,0 +1 @@ +export * from './RichEditor'; \ No newline at end of file diff --git a/packages/webapp/src/constants/dialogs.ts b/packages/webapp/src/constants/dialogs.ts index 115c25af2..5378efb24 100644 --- a/packages/webapp/src/constants/dialogs.ts +++ b/packages/webapp/src/constants/dialogs.ts @@ -48,4 +48,8 @@ export enum DialogsName { ProjectBillableEntriesForm = 'project-billable-entries', InvoiceNumberSettings = 'InvoiceNumberSettings', TaxRateForm = 'tax-rate-form', + InvoiceMail = 'invoice-mail', + EstimateMail = 'estimate-mail', + ReceiptMail = 'receipt-mail', + PaymentMail = 'payment-mail', } diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx index 6f1ca3e91..3ba5b0d02 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx @@ -26,6 +26,7 @@ import { import { compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; +import { DialogsName } from '@/constants/dialogs'; /** * Estimate read-only details actions bar of the drawer. @@ -65,6 +66,10 @@ function EstimateDetailActionsBar({ const handleNotifyViaSMS = () => { openDialog('notify-estimate-via-sms', { estimateId }); }; + // Handles the estimate mail dialog. + const handleMailEstimate = () => { + openDialog(DialogsName.EstimateMail, { estimateId }); + }; return ( @@ -79,12 +84,19 @@ function EstimateDetailActionsBar({ + + + + + + + ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/index.ts b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/index.ts new file mode 100644 index 000000000..bebbc8bef --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/index.ts @@ -0,0 +1 @@ +export * from './EstimateMailDialog'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx index 402771604..dec1d548c 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx @@ -22,6 +22,7 @@ import { useEstimatesListContext } from './EstimatesListProvider'; import { useMemorizedColumnsWidths } from '@/hooks'; import { compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; +import { DialogsName } from '@/constants/dialogs'; /** * Estimates datatable. @@ -100,6 +101,11 @@ function EstimatesDataTable({ openDrawer(DRAWERS.ESTIMATE_DETAILS, { estimateId: cell.row.original.id }); }; + // Handle mail send estimate. + const handleMailSendEstimate = ({ id }) => { + openDialog(DialogsName.EstimateMail, { estimateId: id }); + } + // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = useMemorizedColumnsWidths(TABLES.ESTIMATES); @@ -153,6 +159,7 @@ function EstimatesDataTable({ onConvert: handleConvertToInvoice, onViewDetails: handleViewDetailEstimate, onPrint: handlePrintEstimate, + onSendMail: handleMailSendEstimate, }} /> diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/components.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/components.tsx index a596a419a..5edfd9051 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/components.tsx @@ -64,6 +64,7 @@ export function ActionsMenu({ onConvert, onViewDetails, onPrint, + onSendMail }, }) { return ( @@ -129,6 +130,11 @@ export function ActionsMenu({ + } + text={'Send Mail'} + onClick={safeCallback(onSendMail, original)} + /> } text={intl.get('print')} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx new file mode 100644 index 000000000..63430ce10 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx @@ -0,0 +1,37 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const InvoiceMailDialogContent = React.lazy( + () => import('./InvoiceMailDialogContent'), +); + +/** + * Invoice mail dialog. + */ +function InvoiceMailDialog({ + dialogName, + payload: { invoiceId = null }, + isOpen, +}) { + return ( + + + + + + ); +} +export default compose(withDialogRedux())(InvoiceMailDialog); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx new file mode 100644 index 000000000..ae16a0cf2 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx @@ -0,0 +1,44 @@ +// @ts-nocheck +import React, { createContext } from 'react'; +import { useSaleInvoiceDefaultOptions } from '@/hooks/query'; +import { DialogContent } from '@/components'; + +interface InvoiceMailDialogBootValues { + invoiceId: number; + mailOptions: any; +} + +const InvoiceMailDialagBoot = createContext(); + +interface InvoiceMailDialogBootProps { + invoiceId: number; + children: React.ReactNode; +} + +/** + * Invoice mail dialog boot provider. + */ +function InvoiceMailDialogBoot({ + invoiceId, + ...props +}: InvoiceMailDialogBootProps) { + const { data: mailOptions, isLoading: isMailOptionsLoading } = + useSaleInvoiceDefaultOptions(invoiceId); + + const provider = { + saleInvoiceId: invoiceId, + mailOptions, + isMailOptionsLoading, + }; + + return ( + + + + ); +} + +const useInvoiceMailDialogBoot = () => + React.useContext(InvoiceMailDialagBoot); + +export { InvoiceMailDialogBoot, useInvoiceMailDialogBoot }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx new file mode 100644 index 000000000..37f3f091f --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx @@ -0,0 +1,17 @@ +import { InvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; +import { InvoiceMailDialogForm } from './InvoiceMailDialogForm'; + +interface InvoiceMailDialogContentProps { + dialogName: string; + invoiceId: number; +} +export default function InvoiceMailDialogContent({ + dialogName, + invoiceId, +}: InvoiceMailDialogContentProps) { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts new file mode 100644 index 000000000..1c365ac4a --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts @@ -0,0 +1,9 @@ +// @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'), +}); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx new file mode 100644 index 000000000..794ed890d --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx @@ -0,0 +1,79 @@ +// @ts-nocheck +import { Formik } from 'formik'; +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { useInvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; +import { DialogsName } from '@/constants/dialogs'; +import { AppToaster } from '@/components'; +import { useSendSaleInvoiceMail } from '@/hooks/query'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +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; +} + +function InvoiceMailDialogFormRoot({ + // #withDialogActions + closeDialog, +}) { + 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, + }); + closeDialog(DialogsName.InvoiceMail); + setSubmitting(false); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + setSubmitting(false); + }); + }; + // Handle the close button click. + const handleClose = () => { + closeDialog(DialogsName.InvoiceMail); + }; + + return ( + + + + ); +} + +export const InvoiceMailDialogForm = R.compose(withDialogActions)( + InvoiceMailDialogFormRoot, +); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx new file mode 100644 index 000000000..07e104027 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx @@ -0,0 +1,66 @@ +// @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 ( +
+
+ + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts new file mode 100644 index 000000000..b64dcaaf3 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts @@ -0,0 +1 @@ +export * from './InvoiceMailDialog'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/InvoicesDataTable.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/InvoicesDataTable.tsx index c2f5a78df..75ba9b7f6 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/InvoicesDataTable.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/InvoicesDataTable.tsx @@ -26,6 +26,7 @@ import { useInvoicesListContext } from './InvoicesListProvider'; import { compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; +import { DialogsName } from '@/constants/dialogs'; /** * Invoices datatable. @@ -98,6 +99,11 @@ function InvoicesDataTable({ openDialog('invoice-pdf-preview', { invoiceId: id }); }; + // Handle send mail invoice. + const handleSendMailInvoice = ({ id }) => { + openDialog(DialogsName.InvoiceMail, { invoiceId: id }); + }; + // Handle cell click. const handleCellClick = (cell, event) => { openDrawer(DRAWERS.INVOICE_DETAILS, { invoiceId: cell.row.original.id }); @@ -157,6 +163,7 @@ function InvoicesDataTable({ onViewDetails: handleViewDetailInvoice, onPrint: handlePrintInvoice, onConvert: handleConvertToCreitNote, + onSendMail: handleSendMailInvoice }} /> diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx index c5ebc3aee..2739ad9e5 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx @@ -128,6 +128,7 @@ export function ActionsMenu({ onQuick, onViewDetails, onPrint, + onSendMail }, row: { original }, }) { @@ -150,7 +151,6 @@ export function ActionsMenu({ text={intl.get('invoice.convert_to_credit_note')} onClick={safeCallback(onConvert, original)} /> - } @@ -169,6 +169,11 @@ export function ActionsMenu({
+ } + text={'Send Mail'} + onClick={safeCallback(onSendMail, original)} + /> } text={intl.get('print')} diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx new file mode 100644 index 000000000..6da51d03e --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx @@ -0,0 +1,37 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const PaymentMailDialogContent = React.lazy( + () => import('./PaymentMailDialogContent'), +); + +/** + * Payment mail dialog. + */ +function PaymentMailDialog({ + dialogName, + payload: { paymentReceiveId = null }, + isOpen, +}) { + return ( + + + + + + ); +} +export default compose(withDialogRedux())(PaymentMailDialog); diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx new file mode 100644 index 000000000..aa08bd2e1 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx @@ -0,0 +1,45 @@ +// @ts-nocheck +import React, { createContext } from 'react'; +import { usePaymentReceiveDefaultOptions } from '@/hooks/query'; +import { DialogContent } from '@/components'; + +interface PaymentMailDialogBootValues { + paymentReceiveId: number; + mailOptions: any; +} + +const PaymentMailDialogBootContext = + createContext(); + +interface PaymentMailDialogBootProps { + paymentReceiveId: number; + children: React.ReactNode; +} + +/** + * Payment mail dialog boot provider. + */ +function PaymentMailDialogBoot({ + paymentReceiveId, + ...props +}: PaymentMailDialogBootProps) { + const { data: mailOptions, isLoading: isMailOptionsLoading } = + usePaymentReceiveDefaultOptions(paymentReceiveId); + + const provider = { + mailOptions, + isMailOptionsLoading, + paymentReceiveId + }; + + return ( + + + + ); +} + +const usePaymentMailDialogBoot = () => + React.useContext(PaymentMailDialogBootContext); + +export { PaymentMailDialogBoot, usePaymentMailDialogBoot }; diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx new file mode 100644 index 000000000..12fa57e05 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx @@ -0,0 +1,17 @@ +import { PaymentMailDialogBoot } from './PaymentMailDialogBoot'; +import { PaymentMailDialogForm } from './PaymentMailDialogForm'; + +interface PaymentMailDialogContentProps { + dialogName: string; + paymentReceiveId: number; +} +export default function PaymentMailDialogContent({ + dialogName, + paymentReceiveId, +}: PaymentMailDialogContentProps) { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx new file mode 100644 index 000000000..bf0aa578b --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx @@ -0,0 +1,78 @@ +// @ts-nocheck +import { Formik, FormikBag } from 'formik'; +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { usePaymentMailDialogBoot } from './PaymentMailDialogBoot'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; +import { useSendPaymentReceiveMail } from '@/hooks/query'; +import { PaymentMailDialogFormContent } from './PaymentMailDialogFormContent'; +import { + MailNotificationFormValues, + initialMailNotificationValues, + transformMailFormToRequest, + transformMailFormToInitialValues, +} from '@/containers/SendMailNotification/utils'; +import { AppToaster } from '@/components'; + +const initialFormValues = { + ...initialMailNotificationValues, + attachPayment: true, +}; + +interface PaymentMailFormValue extends MailNotificationFormValues { + attachPayment: boolean; +} + +export function PaymentMailDialogFormRoot({ + // #withDialogActions + closeDialog, +}) { + const { mailOptions, paymentReceiveId } = usePaymentMailDialogBoot(); + const { mutateAsync: sendPaymentMail } = useSendPaymentReceiveMail(); + + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); + // Handles the form submitting. + const handleSubmit = ( + values: PaymentMailFormValue, + { setSubmitting }: FormikBag, + ) => { + const reqValues = transformMailFormToRequest(values); + + setSubmitting(true); + sendPaymentMail([paymentReceiveId, reqValues]) + .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); + setSubmitting(false); + closeDialog(DialogsName.PaymentMail); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + setSubmitting(false); + closeDialog(DialogsName.PaymentMail); + }); + }; + + const handleClose = () => { + closeDialog(DialogsName.PaymentMail); + }; + + return ( + + + + ); +} + +export const PaymentMailDialogForm = R.compose(withDialogActions)( + PaymentMailDialogFormRoot, +); diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx new file mode 100644 index 000000000..5a04f0f28 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx @@ -0,0 +1,66 @@ +// @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 { usePaymentMailDialogBoot } from './PaymentMailDialogBoot'; + +interface PaymentMailDialogFormContentProps { + onClose?: () => void; +} + +export function PaymentMailDialogFormContent({ + onClose, +}: PaymentMailDialogFormContentProps) { + const { mailOptions } = usePaymentMailDialogBoot(); + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/index.ts b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/index.ts new file mode 100644 index 000000000..5a2fbde70 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/index.ts @@ -0,0 +1 @@ +export * from './PaymentMailDialog'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/PaymentReceivesTable.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/PaymentReceivesTable.tsx index 15a8346df..82e7f7cb5 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/PaymentReceivesTable.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/PaymentReceivesTable.tsx @@ -17,12 +17,14 @@ import withPaymentReceives from './withPaymentReceives'; import withPaymentReceivesActions from './withPaymentReceivesActions'; import withAlertsActions from '@/containers/Alert/withAlertActions'; import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; import withSettings from '@/containers/Settings/withSettings'; import { usePaymentReceivesColumns, ActionsMenu } from './components'; import { usePaymentReceivesListContext } from './PaymentReceiptsListProvider'; import { useMemorizedColumnsWidths } from '@/hooks'; import { DRAWERS } from '@/constants/drawers'; +import { DialogsName } from '@/constants/dialogs'; /** * Payment receives datatable. @@ -31,15 +33,15 @@ function PaymentReceivesDataTable({ // #withPaymentReceivesActions setPaymentReceivesTableState, - // #withPaymentReceives - paymentReceivesTableState, - // #withAlertsActions openAlert, // #withDrawerActions openDrawer, + // #withDialogActions + openDialog, + // #withSettings paymentReceivesTableSize, }) { @@ -73,6 +75,11 @@ function PaymentReceivesDataTable({ openDrawer(DRAWERS.PAYMENT_RECEIVE_DETAILS, { paymentReceiveId: id }); }; + // Handle mail send payment receive. + const handleSendMailPayment = ({ id }) => { + openDialog(DialogsName.PaymentMail, { paymentReceiveId: id }); + }; + // Handle cell click. const handleCellClick = (cell, event) => { openDrawer(DRAWERS.PAYMENT_RECEIVE_DETAILS, { @@ -129,6 +136,7 @@ function PaymentReceivesDataTable({ onDelete: handleDeletePaymentReceive, onEdit: handleEditPaymentReceive, onViewDetails: handleViewDetailPaymentReceive, + onSendMail: handleSendMailPayment, }} /> @@ -139,6 +147,7 @@ export default compose( withPaymentReceivesActions, withAlertsActions, withDrawerActions, + withDialogActions, withPaymentReceives(({ paymentReceivesTableState }) => ({ paymentReceivesTableState, })), diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/components.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/components.tsx index 4af4f14ba..fd6aec581 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/components.tsx @@ -15,14 +15,17 @@ import { import { FormatDateCell, Money, Icon, Can } from '@/components'; import { safeCallback } from '@/utils'; import { CLASSES } from '@/constants/classes'; -import { PaymentReceiveAction, AbilitySubject } from '@/constants/abilityOption'; +import { + PaymentReceiveAction, + AbilitySubject, +} from '@/constants/abilityOption'; /** * Table actions menu. */ export function ActionsMenu({ row: { original: paymentReceive }, - payload: { onEdit, onDelete, onViewDetails }, + payload: { onEdit, onDelete, onViewDetails, onSendMail }, }) { return ( @@ -31,6 +34,11 @@ export function ActionsMenu({ text={intl.get('view_details')} onClick={safeCallback(onViewDetails, paymentReceive)} /> + } + text={'Send Mail'} + onClick={safeCallback(onSendMail, paymentReceive)} + /> import('./ReceiptMailDialogContent'), +); + +/** + * Invoice mail dialog. + */ +function ReceiptMailDialog({ + dialogName, + payload: { receiptId = null }, + isOpen, +}) { + return ( + + + + + + ); +} +export default compose(withDialogRedux())(ReceiptMailDialog); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx new file mode 100644 index 000000000..09eeb55f1 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx @@ -0,0 +1,45 @@ +// @ts-nocheck +import React, { createContext } from 'react'; +import { useSaleReceiptDefaultOptions } from '@/hooks/query'; +import { DialogContent } from '@/components'; + +interface ReceiptMailDialogBootValues { + receiptId: number; + mailOptions: any; +} + +const ReceiptMailDialogBootContext = + createContext(); + +interface ReceiptMailDialogBootProps { + receiptId: number; + children: React.ReactNode; +} + +/** + * Receipt mail dialog boot provider. + */ +function ReceiptMailDialogBoot({ + receiptId, + ...props +}: ReceiptMailDialogBootProps) { + const { data: mailOptions, isLoading: isMailOptionsLoading } = + useSaleReceiptDefaultOptions(receiptId); + + const provider = { + saleReceiptId: receiptId, + mailOptions, + isMailOptionsLoading, + }; + + return ( + + + + ); +} + +const useReceiptMailDialogBoot = () => + React.useContext(ReceiptMailDialogBootContext); + +export { ReceiptMailDialogBoot, useReceiptMailDialogBoot }; diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx new file mode 100644 index 000000000..955620f86 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { ReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; +import { ReceiptMailDialogForm } from './ReceiptMailDialogForm'; + +interface ReceiptMailDialogContentProps { + dialogName: string + receiptId: number; +} +export default function ReceiptMailDialogContent({ + dialogName, + receiptId, +}: ReceiptMailDialogContentProps) { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx new file mode 100644 index 000000000..fb9b845af --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +import { Formik, FormikBag } from 'formik'; +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { useReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; +import { useSendSaleReceiptMail } from '@/hooks/query'; +import { ReceiptMailDialogFormContent } from './ReceiptMailDialogFormContent'; +import { + initialMailNotificationValues, + MailNotificationFormValues, + transformMailFormToInitialValues, + transformMailFormToRequest, +} from '@/containers/SendMailNotification/utils'; +import { AppToaster } from '@/components'; + +const initialFormValues = { + ...initialMailNotificationValues, + attachReceipt: true, +}; +interface ReceiptMailFormValues extends MailNotificationFormValues { + attachReceipt: boolean; +} + +function ReceiptMailDialogFormRoot({ closeDialog }) { + const { mailOptions, saleReceiptId } = useReceiptMailDialogBoot(); + const { mutateAsync: sendReceiptMail } = useSendSaleReceiptMail(); + + // Transformes mail options to initial form values. + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); + // Handle the form submitting. + const handleSubmit = ( + values: ReceiptMailFormValues, + { setSubmitting }: FormikBag, + ) => { + const reqValues = transformMailFormToRequest(values); + + setSubmitting(true); + sendReceiptMail([saleReceiptId, reqValues]) + .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); + closeDialog(DialogsName.ReceiptMail); + setSubmitting(false); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + setSubmitting(false); + }); + }; + // Handle the close button click. + const handleClose = () => { + closeDialog(DialogsName.ReceiptMail); + }; + + return ( + + + + ); +} + +export const ReceiptMailDialogForm = R.compose(withDialogActions)( + ReceiptMailDialogFormRoot, +); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx new file mode 100644 index 000000000..d824d35af --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx @@ -0,0 +1,66 @@ +// @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 { useReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; +import { saveInvoke } from '@/utils'; + +interface SendMailNotificationFormProps { + onClose?: () => void; +} + +export function ReceiptMailDialogFormContent({ + onClose, +}: SendMailNotificationFormProps) { + const { mailOptions } = useReceiptMailDialogBoot(); + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/index.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/index.tsx new file mode 100644 index 000000000..575fb462b --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/index.tsx @@ -0,0 +1 @@ +export * from './ReceiptMailDialog'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.tsx index 828ff5a70..7517cf104 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.tsx @@ -140,7 +140,6 @@ function ReceiptActionsBar({ icon={} text={} /> - { + openDialog(DialogsName.ReceiptMail, { receiptId: id }); + }; + // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = useMemorizedColumnsWidths(TABLES.RECEIPTS); @@ -141,6 +147,7 @@ function ReceiptsDataTable({ onClose: handleCloseReceipt, onViewDetails: handleViewDetailReceipt, onPrint: handlePrintInvoice, + onSendMail: handleSendMailReceipt, }} /> diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/components.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/components.tsx index 4d843937e..e76b2d9c6 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/components.tsx @@ -24,7 +24,7 @@ import { SaleReceiptAction, AbilitySubject } from '@/constants/abilityOption'; * @returns {React.JSX} */ export function ActionsMenu({ - payload: { onEdit, onDelete, onClose, onDrawer, onViewDetails, onPrint }, + payload: { onEdit, onDelete, onClose, onSendMail, onViewDetails, onPrint }, row: { original: receipt }, }) { return ( @@ -51,6 +51,11 @@ export function ActionsMenu({
+ } + text={'Send Mail'} + onClick={safeCallback(onSendMail, receipt)} + /> } text={intl.get('print')} diff --git a/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx new file mode 100644 index 000000000..b7e578b91 --- /dev/null +++ b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx @@ -0,0 +1,143 @@ +// @ts-nocheck +import { + Box, + FFormGroup, + FInputGroup, + FMultiSelect, + FRichEditor, + Hint, +} from '@/components'; +import styled from 'styled-components'; +import { Position } from '@blueprintjs/core'; +import { SelectOptionProps } from '@blueprintjs-formik/select'; + +interface MailNotificationFormProps { + fromAddresses: SelectOptionProps[]; + toAddresses: SelectOptionProps[]; +} + +const commonAddressSelect = { + placeholder: '', + labelAccessor: '', + valueAccessor: 'mail', + tagAccessor: (item) => `<${item.label}> (${item.mail})`, + textAccessor: (item) => `<${item.label}> (${item.mail})`, +}; + +export function MailNotificationForm({ + fromAddresses, + toAddresses, +}: MailNotificationFormProps) { + return ( + + + + } + name={'from'} + inline={true} + fastField={true} + > + + + + + + + + + + + + + + + ); +} + +const MailMessageEditor = styled(FRichEditor)` + padding: 15px; + border: 1px solid #dedfe9; + border-top: 0; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +`; + +const HeaderBox = styled('div')` + border-top-right-radius: 5px; + border-top-left-radius: 5px; + border: 1px solid #dddfe9; + border-bottom: 2px solid #eaeaef; + padding: 6px 15px; + + .bp4-form-group { + margin: 0; + padding-top: 8px; + padding-bottom: 8px; + + &:not(:last-of-type) { + border-bottom: 1px solid #dddfe9; + } + &:first-of-type { + padding-top: 0; + } + &:last-of-type { + padding-bottom: 0; + } + } + + .bp4-form-content { + flex: 1 0; + } + + .bp4-label { + min-width: 65px; + color: #738091; + } + + .bp4-input { + border-color: transparent; + padding: 0; + + &:focus, + &.bp4-active { + box-shadow: 0 0 0 0; + } + } + + .bp4-input-ghost { + margin-top: 5px; + } + .bp4-tag-input-values { + margin: 0; + } +`; diff --git a/packages/webapp/src/containers/SendMailNotification/index.ts b/packages/webapp/src/containers/SendMailNotification/index.ts new file mode 100644 index 000000000..5662fe7c9 --- /dev/null +++ b/packages/webapp/src/containers/SendMailNotification/index.ts @@ -0,0 +1 @@ +export * from './MailNotificationForm'; \ No newline at end of file diff --git a/packages/webapp/src/containers/SendMailNotification/utils.ts b/packages/webapp/src/containers/SendMailNotification/utils.ts new file mode 100644 index 000000000..59d0f6420 --- /dev/null +++ b/packages/webapp/src/containers/SendMailNotification/utils.ts @@ -0,0 +1,44 @@ +import { castArray, first } from 'lodash'; +import { transformToForm } from '@/utils'; + +export const initialMailNotificationValues = { + from: [], + to: [], + subject: '', + body: '', +}; + +export interface MailNotificationFormValues { + from: string[]; + to: string[]; + subject: string; + body: string; +} + +export const transformMailFormToRequest = ( + values: MailNotificationFormValues, +) => { + return { + ...values, + from: first(values.from), + to: values.to?.join(', '), + }; +}; + +/** + * Transformes the mail options response values to form initial values. + * @param {any} mailOptions + * @param {MailNotificationFormValues} initialValues + * @returns {MailNotificationFormValues} + */ +export const transformMailFormToInitialValues = ( + mailOptions: any, + initialValues: MailNotificationFormValues, +): MailNotificationFormValues => { + return { + ...initialValues, + ...transformToForm(mailOptions, initialValues), + from: mailOptions.from ? castArray(mailOptions.from) : [], + to: mailOptions.to ? castArray(mailOptions.to) : [], + }; +}; diff --git a/packages/webapp/src/hooks/query/estimates.tsx b/packages/webapp/src/hooks/query/estimates.tsx index 8bb3c2731..9ab867857 100644 --- a/packages/webapp/src/hooks/query/estimates.tsx +++ b/packages/webapp/src/hooks/query/estimates.tsx @@ -239,3 +239,33 @@ export function useEstimateSMSDetail(estimateId, props, requestProps) { }, ); } + +export function useSendSaleEstimateMail(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => apiRequest.post(`sales/estimates/${id}/mail`, values), + { + onSuccess: (res, [id, values]) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +export function useSaleEstimateDefaultOptions(estimateId, props) { + return useRequestQuery( + [t.SALE_ESTIMATE_MAIL_OPTIONS, estimateId], + { + method: 'get', + url: `sales/estimates/${estimateId}/mail`, + }, + { + select: (res) => res.data.data, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/invoices.tsx b/packages/webapp/src/hooks/query/invoices.tsx index 13009042c..2adbf87e0 100644 --- a/packages/webapp/src/hooks/query/invoices.tsx +++ b/packages/webapp/src/hooks/query/invoices.tsx @@ -306,3 +306,34 @@ export function useInvoicePaymentTransactions(invoiceId, props) { }, ); } + +export function useSendSaleInvoiceMail(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => apiRequest.post(`sales/invoices/${id}/mail`, values), + { + onSuccess: (res, [id, values]) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +export function useSaleInvoiceDefaultOptions(invoiceId, props) { + return useRequestQuery( + [t.SALE_INVOICE_DEFAULT_OPTIONS, invoiceId], + { + method: 'get', + url: `sales/invoices/${invoiceId}/mail`, + }, + { + select: (res) => res.data.data, + ...props, + }, + ); +} + diff --git a/packages/webapp/src/hooks/query/paymentReceives.tsx b/packages/webapp/src/hooks/query/paymentReceives.tsx index 49d53ad67..376993e19 100644 --- a/packages/webapp/src/hooks/query/paymentReceives.tsx +++ b/packages/webapp/src/hooks/query/paymentReceives.tsx @@ -234,3 +234,34 @@ export function usePaymentReceiveSMSDetail( export function usePdfPaymentReceive(paymentReceiveId) { return useRequestPdf(`sales/payment_receives/${paymentReceiveId}`); } + +export function useSendPaymentReceiveMail(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => + apiRequest.post(`sales/payment_receives/${id}/mail`, values), + { + onSuccess: (res, [id, values]) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +export function usePaymentReceiveDefaultOptions(paymentReceiveId, props) { + return useRequestQuery( + [t.PAYMENT_RECEIVE_MAIL_OPTIONS, paymentReceiveId], + { + method: 'get', + url: `sales/payment_receives/${paymentReceiveId}/mail`, + }, + { + select: (res) => res.data.data, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/receipts.tsx b/packages/webapp/src/hooks/query/receipts.tsx index 907cfc8af..7a6ae2ce9 100644 --- a/packages/webapp/src/hooks/query/receipts.tsx +++ b/packages/webapp/src/hooks/query/receipts.tsx @@ -207,3 +207,36 @@ export function useReceiptSMSDetail(receiptId, props, requestProps) { }, ); } + +/** + * + */ +export function useSendSaleReceiptMail(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => apiRequest.post(`sales/receipts/${id}/mail`, values), + { + onSuccess: () => { + // Invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +export function useSaleReceiptDefaultOptions(invoiceId, props) { + return useRequestQuery( + [t.SALE_RECEIPT_MAIL_OPTIONS, invoiceId], + { + method: 'get', + url: `sales/receipts/${invoiceId}/mail`, + }, + { + select: (res) => res.data.data, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index c0173cab7..0a10b1e63 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -69,6 +69,7 @@ const SALE_ESTIMATES = { SALE_ESTIMATE: 'SALE_ESTIMATE', SALE_ESTIMATE_SMS_DETAIL: 'SALE_ESTIMATE_SMS_DETAIL', NOTIFY_SALE_ESTIMATE_BY_SMS: 'NOTIFY_SALE_ESTIMATE_BY_SMS', + SALE_ESTIMATE_MAIL_OPTIONS: 'SALE_ESTIMATE_MAIL_OPTIONS', }; const SALE_RECEIPTS = { @@ -76,6 +77,7 @@ const SALE_RECEIPTS = { SALE_RECEIPT: 'SALE_RECEIPT', SALE_RECEIPT_SMS_DETAIL: 'SALE_RECEIPT_SMS_DETAIL', NOTIFY_SALE_RECEIPT_BY_SMS: 'NOTIFY_SALE_RECEIPT_BY_SMS', + SALE_RECEIPT_MAIL_OPTIONS: 'SALE_RECEIPT_MAIL_OPTIONS' }; const INVENTORY_ADJUSTMENTS = { @@ -101,6 +103,7 @@ const PAYMENT_RECEIVES = { PAYMENT_RECEIVE_EDIT_PAGE: 'PAYMENT_RECEIVE_EDIT_PAGE', PAYMENT_RECEIVE_SMS_DETAIL: 'PAYMENT_RECEIVE_SMS_DETAIL', NOTIFY_PAYMENT_RECEIVE_BY_SMS: 'NOTIFY_PAYMENT_RECEIVE_BY_SMS', + PAYMENT_RECEIVE_MAIL_OPTIONS: 'PAYMENT_RECEIVE_MAIL_OPTIONS', }; const SALE_INVOICES = { @@ -112,6 +115,7 @@ const SALE_INVOICES = { BAD_DEBT: 'BAD_DEBT', CANCEL_BAD_DEBT: 'CANCEL_BAD_DEBT', SALE_INVOICE_PAYMENT_TRANSACTIONS: 'SALE_INVOICE_PAYMENT_TRANSACTIONS', + SALE_INVOICE_DEFAULT_OPTIONS: 'SALE_INVOICE_DEFAULT_OPTIONS' }; const USERS = { diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index b13e9ffa5..e60213bb8 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -561,8 +561,14 @@ export default { }, 'content-copy': { path: [ - 'M15 0H5c-.55 0-1 .45-1 1v2h2V2h8v7h-1v2h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1zm-4 4H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zm-1 10H2V6h8v8z' + 'M15 0H5c-.55 0-1 .45-1 1v2h2V2h8v7h-1v2h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1zm-4 4H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zm-1 10H2V6h8v8z', ], - viewBox: '0 0 16 16' - } + viewBox: '0 0 16 16', + }, + envelope: { + path: [ + 'M0 4.01v11.91l6.27-6.27L0 4.01zm18.91-1.03H1.09L10 10.97l8.91-7.99zm-5.18 6.66L20 15.92V4.01l-6.27 5.63zm-3.23 2.9c-.13.12-.31.19-.5.19s-.37-.07-.5-.19l-2.11-1.89-6.33 6.33h17.88l-6.33-6.33-2.11 1.89z', + ], + viewBox: '0 0 20 20', + }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9482a23bf..a3cdf6aa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,6 +471,27 @@ importers: '@testing-library/user-event': specifier: ^7.2.1 version: 7.2.1(@testing-library/dom@8.20.0) + '@tiptap/core': + specifier: 2.1.13 + version: 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-color': + specifier: latest + version: 2.1.13(@tiptap/core@2.1.13)(@tiptap/extension-text-style@2.1.13) + '@tiptap/extension-list-item': + specifier: 2.1.13 + version: 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-text-style': + specifier: 2.1.13 + version: 2.1.13(@tiptap/core@2.1.13) + '@tiptap/pm': + specifier: 2.1.13 + version: 2.1.13 + '@tiptap/react': + specifier: 2.1.13 + version: 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13)(react-dom@18.2.0)(react@18.2.0) + '@tiptap/starter-kit': + specifier: 2.1.13 + version: 2.1.13(@tiptap/pm@2.1.13) '@types/jest': specifier: ^26.0.15 version: 26.0.24 @@ -5690,6 +5711,34 @@ packages: reselect: 4.1.7 dev: false + /@remirror/core-constants@2.0.2: + resolution: {integrity: sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ==} + dev: false + + /@remirror/core-helpers@3.0.0: + resolution: {integrity: sha512-tusEgQJIqg4qKj6HSBUFcyRnWnziw3neh4T9wOmsPGHFC3w9kl5KSrDb9UAgE8uX6y32FnS7vJ955mWOl3n50A==} + dependencies: + '@remirror/core-constants': 2.0.2 + '@remirror/types': 1.0.1 + '@types/object.omit': 3.0.3 + '@types/object.pick': 1.3.4 + '@types/throttle-debounce': 2.1.0 + case-anything: 2.1.13 + dash-get: 1.0.2 + deepmerge: 4.3.1 + fast-deep-equal: 3.1.3 + make-error: 1.3.6 + object.omit: 3.0.0 + object.pick: 1.3.0 + throttle-debounce: 3.0.1 + dev: false + + /@remirror/types@1.0.1: + resolution: {integrity: sha512-VlZQxwGnt1jtQ18D6JqdIF+uFZo525WEqrfp9BOc3COPpK4+AWCgdnAWL+ho6imWcoINlGjR/+3b6y5C1vBVEA==} + dependencies: + type-fest: 2.19.0 + dev: false + /@rollup/plugin-babel@5.3.1(@babel/core@7.20.12)(rollup@2.79.1): resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'} @@ -5968,6 +6017,273 @@ packages: '@testing-library/dom': 8.20.0 dev: false + /@tiptap/core@2.1.13(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-cMC8bgTN63dj1Mv82iDeeLl6sa9kY0Pug8LSalxVEptRmyFVsVxGgu2/6Y3T+9aCYScxfS06EkA8SdzFMAwYTQ==} + peerDependencies: + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-blockquote@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-oe6wSQACmODugoP9XH3Ouffjy4BsOBWfTC+dETHNCG6ZED6ShHN3CB9Vr7EwwRgmm2WLaKAjMO1sVumwH+Z1rg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-bold@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-6cHsQTh/rUiG4jkbJer3vk7g60I5tBwEBSGpdxmEHh83RsvevD8+n92PjA24hYYte5RNlATB011E1wu8PVhSvw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-bubble-menu@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-Hm7e1GX3AI6lfaUmr6WqsS9MMyXIzCkhh+VQi6K8jj4Q4s8kY4KPoAyD/c3v9pZ/dieUtm2TfqrOCkbHzsJQBg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + tippy.js: 6.3.7 + dev: false + + /@tiptap/extension-bullet-list@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-NkWlQ5bLPUlcROj6G/d4oqAxMf3j3wfndGOPp0z8OoXJtVbVoXl/aMSlLbVgE6n8r6CS8MYxKhXNxrb7Ll2foA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-code-block@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-E3tweNExPOV+t1ODKX0MDVsS0aeHGWc1ECt+uyp6XwzsN0bdF2A5+pttQqM7sTcMnQkVACGFbn9wDeLRRcfyQg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-code@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-f5fLYlSgliVVa44vd7lQGvo49+peC+Z2H0Fn84TKNCH7tkNZzouoJsHYn0/enLaQ9Sq+24YPfqulfiwlxyiT8w==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-color@2.1.13(@tiptap/core@2.1.13)(@tiptap/extension-text-style@2.1.13): + resolution: {integrity: sha512-T3tJXCIfFxzIlGOhvbPVIZa3y36YZRPYIo2TKsgkTz8LiMob6hRXXNFjsrFDp2Fnu3DrBzyvrorsW7767s4eYg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/extension-text-style': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-text-style': 2.1.13(@tiptap/core@2.1.13) + dev: false + + /@tiptap/extension-document@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-wLwiTWsVmZTGIE5duTcHRmW4ulVxNW4nmgfpk95+mPn1iKyNGtrVhGWleLhBlTj+DWXDtcfNWZgqZkZNzhkqYQ==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-dropcursor@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-NAyJi4BJxH7vl/2LNS1X0ndwFKjEtX+cRgshXCnMyh7qNpIRW6Plczapc/W1OiMncOEhZJfpZfkRSfwG01FWFg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-floating-menu@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-9Oz7pk1Nts2+EyY+rYfnREGbLzQ5UFazAvRhF6zAJdvyuDmAYm0Jp6s0GoTrpV0/dJEISoFaNpPdMJOb9EBNRw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + tippy.js: 6.3.7 + dev: false + + /@tiptap/extension-gapcursor@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-Cl5apsoTcyPPCgE3ThufxQxZ1wyqqh+9uxUN9VF9AbeTkid6oPZvKXwaILf6AFnkSy+SuKrb9kZD2iaezxpzXw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-hard-break@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-TGkMzMQayuKg+vN4du0x1ahEItBLcCT1jdWeRsjdM8gHfzbPLdo4PQhVsvm1I0xaZmbJZelhnVsUwRZcIu1WNA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-heading@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-PEmc19QLmlVUTiHWoF0hpgNTNPNU0nlaFmMKskzO+cx5Df4xvHmv/UqoIwp7/UFbPMkfVJT1ozQU7oD1IWn9Hg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-history@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-1ouitThGTBUObqw250aDwGLMNESBH5PRXIGybsCFO1bktdmWtEw7m72WY41EuX2BH8iKJpcYPerl3HfY1vmCNw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-horizontal-rule@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-7OgjgNqZXvBejgULNdMSma2M1nzv4bbZG+FT5XMFZmEOxR9IB1x/RzChjPdeicff2ZK2sfhMBc4Y9femF5XkUg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-italic@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-HyDJfuDn5hzwGKZiANcvgz6wcum6bEgb4wmJnfej8XanTMJatNVv63TVxCJ10dSc9KGpPVcIkg6W8/joNXIEbw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-list-item@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-6e8iiCWXOiJTl1XOwVW2tc0YG18h70HUtEHFCx2m5HspOGFKsFEaSS3qYxOheM9HxlmQeDt8mTtqftRjEFRxPQ==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-ordered-list@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-UO4ZAL5Vrr1WwER5VjgmeNIWHpqy9cnIRo1En07gZ0OWTjs1eITPcu+4TCn1ZG6DhoFvAQzE5DTxxdhIotg+qw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-paragraph@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-cEoZBJrsQn69FPpUMePXG/ltGXtqKISgypj70PEHXt5meKDjpmMVSY4/8cXvFYEYsI9GvIwyAK0OrfAHiSoROA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-strike@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-VN6zlaCNCbyJUCDyBFxavw19XmQ4LkCh8n20M8huNqW77lDGXA2A7UcWLHaNBpqAijBRu9mWI8l4Bftyf2fcAw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-text-style@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-K9/pNHxpZKQoc++crxrsppVUSeHv8YevfY2FkJ4YMaekGcX+q4BRrHR0tOfii4izAUPJF2L0/PexLQaWXtAY1w==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-text@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-zzsTTvu5U67a8WjImi6DrmpX2Q/onLSaj+LRWPh36A1Pz2WaxW5asZgaS+xWCnR+UrozlCALWa01r7uv69jq0w==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/pm@2.1.13: + resolution: {integrity: sha512-zNbA7muWsHuVg12GrTgN/j119rLePPq5M8dZgkKxUwdw8VmU3eUyBp1SihPEXJ2U0MGdZhNhFX7Y74g11u66sg==} + dependencies: + prosemirror-changeset: 2.2.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.5.2 + prosemirror-dropcursor: 1.8.1 + prosemirror-gapcursor: 1.3.2 + prosemirror-history: 1.3.2 + prosemirror-inputrules: 1.3.0 + prosemirror-keymap: 1.2.2 + prosemirror-markdown: 1.12.0 + prosemirror-menu: 1.2.4 + prosemirror-model: 1.19.4 + prosemirror-schema-basic: 1.2.2 + prosemirror-schema-list: 1.3.0 + prosemirror-state: 1.4.3 + prosemirror-tables: 1.3.5 + prosemirror-trailing-node: 2.0.7(prosemirror-model@1.19.4)(prosemirror-state@1.4.3)(prosemirror-view@1.32.7) + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /@tiptap/react@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Dq3f8EtJnpImP3iDtJo+7bulnN9SJZRZcVVzxHXccLcC2MxtmDdlPGZjP+wxO800nd8toSIOd5734fPNf/YcfA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-bubble-menu': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-floating-menu': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@tiptap/starter-kit@2.1.13(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-ph/mUR/OwPtPkZ5rNHINxubpABn8fHnvJSdhXFrY/q6SKoaO11NZXgegRaiG4aL7O6Sz4LsZVw6Sm0Ae+GJmrg==} + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-blockquote': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-bold': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-bullet-list': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-code': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-code-block': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-document': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-dropcursor': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-gapcursor': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-hard-break': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-heading': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-history': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-horizontal-rule': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-italic': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-list-item': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-ordered-list': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-paragraph': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-strike': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-text': 2.1.13(@tiptap/core@2.1.13) + transitivePeerDependencies: + - '@tiptap/pm' + dev: false + /@tootallnate/once@1.1.2: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -6276,6 +6592,14 @@ packages: /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} + /@types/object.omit@3.0.3: + resolution: {integrity: sha512-xrq4bQTBGYY2cw+gV4PzoG2Lv3L0pjZ1uXStRRDQoATOYW1lCsFQHhQ+OkPhIcQoqLjAq7gYif7D14Qaa6Zbew==} + dev: false + + /@types/object.pick@1.3.4: + resolution: {integrity: sha512-5PjwB0uP2XDp3nt5u5NJAG2DORHIRClPzWT/TTZhJ2Ekwe8M5bA9tvPdi9NO/n2uvu2/ictat8kgqvLfcIE1SA==} + dev: false + /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} @@ -6473,6 +6797,10 @@ packages: pretty-format: 25.5.0 dev: false + /@types/throttle-debounce@2.1.0: + resolution: {integrity: sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ==} + dev: false + /@types/triple-beam@1.3.2: resolution: {integrity: sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g==} dev: false @@ -8839,6 +9167,11 @@ packages: engines: {node: '>=0.10.0'} dev: false + /case-anything@2.1.13: + resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} + engines: {node: '>=12.13'} + dev: false + /case-sensitive-paths-webpack-plugin@2.4.0: resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} engines: {node: '>=4'} @@ -9780,6 +10113,10 @@ packages: /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + /crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + dev: false + /cron-parser@3.5.0: resolution: {integrity: sha512-wyVZtbRs6qDfFd8ap457w3XVntdvqcwBGxBoTvJQH9KGVKL/fB+h2k3C8AqiVxvUQKN1Ps/Ns46CNViOpVDhfQ==} engines: {node: '>=0.8'} @@ -10174,6 +10511,10 @@ packages: engines: {node: '>=8'} dev: true + /dash-get@1.0.2: + resolution: {integrity: sha512-4FbVrHDwfOASx7uQVxeiCTo7ggSdYZbqs8lH+WU6ViypPlDbe9y6IP5VVUDQBv9DcnyaiPT5XT0UWHgJ64zLeQ==} + dev: false + /dashdash@1.14.1: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} @@ -10945,6 +11286,11 @@ packages: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} dev: false + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -16314,6 +16660,12 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} dev: true + /linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + dependencies: + uc.micro: 2.0.0 + dev: false + /load-json-file@1.1.0: resolution: {integrity: sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==} engines: {node: '>=0.10.0'} @@ -16722,6 +17074,18 @@ packages: object-visit: 1.0.1 dev: false + /markdown-it@14.0.0: + resolution: {integrity: sha512-seFjF0FIcPt4P9U39Bq1JYblX0KZCjDLFFQPHpL5AzHpqPEKtosxmdq/LTVZnjfH7tjt9BxStm+wXcDBNuYmzw==} + hasBin: true + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.0.0 + dev: false + /match-sorter@4.2.1: resolution: {integrity: sha512-s+3h9TiZU9U1pWhIERHf8/f4LmBN6IXaRgo2CI17+XGByGS1GvG5VvXK9pcGyCjGe3WM3mSYRC3ipGrd5UEVgw==} dependencies: @@ -16785,6 +17149,10 @@ packages: resolution: {integrity: sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==} dev: false + /mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + dev: false + /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -18069,6 +18437,13 @@ packages: make-iterator: 1.0.1 dev: false + /object.omit@3.0.0: + resolution: {integrity: sha512-EO+BCv6LJfu+gBIF3ggLicFebFLN5zqzz/WWJlMFfkMyGth+oBkhxzDl0wx2W4GkLzuQs/FsSkXZb2IMWQqmBQ==} + engines: {node: '>=0.10.0'} + dependencies: + is-extendable: 1.0.1 + dev: false + /object.pick@1.3.0: resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==} engines: {node: '>=0.10.0'} @@ -18245,6 +18620,10 @@ packages: readable-stream: 2.3.7 dev: false + /orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + dev: false + /os-browserify@0.3.0: resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} dev: true @@ -19825,6 +20204,149 @@ packages: resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==} dev: false + /prosemirror-changeset@2.2.1: + resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==} + dependencies: + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + dependencies: + prosemirror-state: 1.4.3 + dev: false + + /prosemirror-commands@1.5.2: + resolution: {integrity: sha512-hgLcPaakxH8tu6YvVAaILV2tXYsW3rAdDR8WNkeKGcgeMVQg3/TMhPdVoh7iAmfgVjZGtcOSjKiQaoeKjzd2mQ==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-dropcursor@1.8.1: + resolution: {integrity: sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==} + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-gapcursor@1.3.2: + resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==} + dependencies: + prosemirror-keymap: 1.2.2 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-history@1.3.2: + resolution: {integrity: sha512-/zm0XoU/N/+u7i5zepjmZAEnpvjDtzoPWW6VmKptcAnPadN/SStsBjMImdCEbb3seiNTpveziPTIrXQbHLtU1g==} + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + rope-sequence: 1.3.4 + dev: false + + /prosemirror-inputrules@1.3.0: + resolution: {integrity: sha512-z1GRP2vhh5CihYMQYsJSa1cOwXb3SYxALXOIfAkX8nZserARtl9LiL+CEl+T+OFIsXc3mJIHKhbsmRzC0HDAXA==} + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-keymap@1.2.2: + resolution: {integrity: sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==} + dependencies: + prosemirror-state: 1.4.3 + w3c-keyname: 2.2.8 + dev: false + + /prosemirror-markdown@1.12.0: + resolution: {integrity: sha512-6F5HS8Z0HDYiS2VQDZzfZP6A0s/I0gbkJy8NCzzDMtcsz3qrfqyroMMeoSjAmOhDITyon11NbXSzztfKi+frSQ==} + dependencies: + markdown-it: 14.0.0 + prosemirror-model: 1.19.4 + dev: false + + /prosemirror-menu@1.2.4: + resolution: {integrity: sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==} + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.5.2 + prosemirror-history: 1.3.2 + prosemirror-state: 1.4.3 + dev: false + + /prosemirror-model@1.19.4: + resolution: {integrity: sha512-RPmVXxUfOhyFdayHawjuZCxiROsm9L4FCUA6pWI+l7n2yCBsWy9VpdE1hpDHUS8Vad661YLY9AzqfjLhAKQ4iQ==} + dependencies: + orderedmap: 2.1.1 + dev: false + + /prosemirror-schema-basic@1.2.2: + resolution: {integrity: sha512-/dT4JFEGyO7QnNTe9UaKUhjDXbTNkiWTq/N4VpKaF79bBjSExVV2NXmJpcM7z/gD7mbqNjxbmWW5nf1iNSSGnw==} + dependencies: + prosemirror-model: 1.19.4 + dev: false + + /prosemirror-schema-list@1.3.0: + resolution: {integrity: sha512-Hz/7gM4skaaYfRPNgr421CU4GSwotmEwBVvJh5ltGiffUJwm7C8GfN/Bc6DR1EKEp5pDKhODmdXXyi9uIsZl5A==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-state@1.4.3: + resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-tables@1.3.5: + resolution: {integrity: sha512-JSZ2cCNlApu/ObAhdPyotrjBe2cimniniTpz60YXzbL0kZ+47nEYk2LWbfKU2lKpBkUNquta2PjteoNi4YCluQ==} + dependencies: + prosemirror-keymap: 1.2.2 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-trailing-node@2.0.7(prosemirror-model@1.19.4)(prosemirror-state@1.4.3)(prosemirror-view@1.32.7): + resolution: {integrity: sha512-8zcZORYj/8WEwsGo6yVCRXFMOfBo0Ub3hCUvmoWIZYfMP26WqENU0mpEP27w7mt8buZWuGrydBewr0tOArPb1Q==} + peerDependencies: + prosemirror-model: ^1.19.0 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.31.2 + dependencies: + '@remirror/core-constants': 2.0.2 + '@remirror/core-helpers': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-transform@1.8.0: + resolution: {integrity: sha512-BaSBsIMv52F1BVVMvOmp1yzD3u65uC3HTzCBQV1WDPqJRQ2LuHKcyfn0jwqodo8sR9vVzMzZyI+Dal5W9E6a9A==} + dependencies: + prosemirror-model: 1.19.4 + dev: false + + /prosemirror-view@1.32.7: + resolution: {integrity: sha512-pvxiOoD4shW41X5bYDjRQk3DSG4fMqxh36yPMt7VYgU3dWRmqFzWJM/R6zeo1KtC8nyk717ZbQND3CC9VNeptw==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + /proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} dev: true @@ -19979,6 +20501,11 @@ packages: pump: 2.0.1 dev: false + /punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + dev: false + /punycode@1.3.2: resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} dev: true @@ -21500,6 +22027,10 @@ packages: fsevents: 2.3.2 dev: false + /rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + dev: false + /rsvp@4.8.5: resolution: {integrity: sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==} engines: {node: 6.* || >= 7.*} @@ -23119,6 +23650,11 @@ packages: engines: {node: '>=8'} dev: false + /throttle-debounce@3.0.1: + resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} + engines: {node: '>=10'} + dev: false + /through2-filter@3.0.0: resolution: {integrity: sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==} dependencies: @@ -23185,6 +23721,12 @@ packages: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} dev: false + /tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + dependencies: + '@popperjs/core': 2.11.8 + dev: false + /tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} dependencies: @@ -23569,6 +24111,11 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + /type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + dev: false + /type-is@1.6.15: resolution: {integrity: sha512-0uqZYZDiBICTVXEsNcDLueZLPgZ8FgGe8lmVDQ0FcVFUeaxsPbFWiz60ZChVw8VELIt7iGuCehOrZSYjYteWKQ==} engines: {node: '>= 0.6'} @@ -23661,6 +24208,10 @@ packages: engines: {node: '>=4.2.0'} hasBin: true + /uc.micro@2.0.0: + resolution: {integrity: sha512-DffL94LsNOccVn4hyfRe5rdKa273swqeA5DJpMOeFmEn1wCDc7nAbbB0gXlgBCL7TNzeTv6G7XVWzan7iJtfig==} + dev: false + /uglify-js@3.17.4: resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} engines: {node: '>=0.8.0'} @@ -24125,6 +24676,10 @@ packages: browser-process-hrtime: 1.0.0 dev: false + /w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + dev: false + /w3c-xmlserializer@1.1.2: resolution: {integrity: sha512-p10l/ayESzrBMYWRID6xbuCKh2Fp77+sA0doRuGn4tTIMrrZVeqfpKjXHY+oDh3K4nLdPgNwMTVP6Vp4pvqbNg==} dependencies: