Compare commits

...

33 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
92d1fc670b Merge pull request #292 from bigcapitalhq/big-83-send-an-invoice-to-the-customer-email
feat: Send mail notifications of the sales transactions
2023-12-30 19:14:31 +02:00
Ahmed Bouhuolia
ab7abfea35 feat: mail notifications of sales transactions 2023-12-30 17:49:02 +02:00
Ahmed Bouhuolia
0d15c16d40 feat(server): contact mail notification service 2023-12-29 17:35:34 +02:00
Ahmed Bouhuolia
2a85fe2f3c feat(webapp): the mail notifications dialogs 2023-12-29 17:31:51 +02:00
Ahmed Bouhuolia
dc762567b5 feat(webapp): send mail notififcation of sale transactions 2023-12-28 17:53:51 +02:00
Ahmed Bouhuolia
a676e09537 feat: rich editor component 2023-12-27 23:56:34 +02:00
Ahmed Bouhuolia
c46948049c feat: add send mail icon 2023-12-26 22:57:41 +02:00
Ahmed Bouhuolia
de1b7f132c feat(webapp): send mail notification dialogs 2023-12-26 15:52:38 +02:00
allcontributors[bot]
90b552d5c9 docs: add cschuijt as a contributor for bug (#295)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-12-26 15:46:04 +02:00
Casper Schuijt
39115b3000 fix: do not validate numericality on postal codes of customer/vendor form (#294) 2023-12-26 15:42:29 +02:00
Ahmed Bouhuolia
3c8c3d8253 feat: add endpoints to retrieving the default mail options 2023-12-25 18:21:02 +02:00
Ahmed Bouhuolia
657400c671 feat: tweak mail notifications content 2023-12-24 23:41:04 +02:00
Ahmed Bouhuolia
6356cb5e63 feat: send mail notifications of sale transactions 2023-12-24 21:53:37 +02:00
Ahmed Bouhuolia
13c6e7a62d feat: send mail notifications of payment 2023-12-24 21:51:23 +02:00
Ahmed Bouhuolia
b6d99b1d4b feat: send mail notifications of sale receipts 2023-12-24 21:49:59 +02:00
Ahmed Bouhuolia
f0e15d43d3 feat: send sale estimate mail notification 2023-12-22 23:56:37 +02:00
Ahmed Bouhuolia
50d5ddba8e feat: send invoice notifications 2023-12-22 02:45:44 +02:00
Ahmed Bouhuolia
d2c63878ed feat: send invoices and reminder notifications to the customers 2023-12-21 22:57:17 +02:00
Ahmed Bouhuolia
ef52eaf91a Merge branch 'develop' into big-83-send-an-invoice-to-the-customer-email 2023-12-19 16:08:21 +02:00
Ahmed Bouhuolia
cd71900bdd feat: send invoice through mail 2023-12-18 21:28:53 +02:00
Ahmed Bouhuolia
34e97fc56b chore: update CHANGELOG.md 2023-12-17 23:07:25 +02:00
Ahmed Bouhuolia
e6e119682b Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2023-12-17 23:05:21 +02:00
Ahmed Bouhuolia
e096db2ab8 chore: update CAHNGELOG.md 2023-12-17 23:04:54 +02:00
Ahmed Bouhuolia
cdff641cdb Merge pull request #293 from bigcapitalhq/big-84-fix-the-transaction-tax-rate-currency
fix: the currency code of transaction tax rate entry
2023-12-17 17:52:14 +02:00
Ahmed Bouhuolia
d0126ffc2c fix: the currency code of transaction tax rate entry 2023-12-17 17:51:17 +02:00
Ahmed Bouhuolia
fe043eb594 Merge pull request #291 from bigcapitalhq/BIG-81
feat: Add default customer message and terms conditions to sales transactions
2023-12-16 22:41:58 +02:00
Ahmed Bouhuolia
eb1ff7c151 chore: doc comments 2023-12-16 22:34:36 +02:00
Ahmed Bouhuolia
a862ee9ccf feat: localize the preferences menu 2023-12-16 19:37:20 +02:00
Ahmed Bouhuolia
6953f7c4a3 feat: assign default default messages on sales transactions 2023-12-16 19:26:41 +02:00
Ahmed Bouhuolia
ad53ddb9dd feat: inject default message value to sales forms 2023-12-15 20:15:42 +02:00
Ahmed Bouhuolia
cfd4540a65 feat: wip send an invoice mail the customer email 2023-12-14 23:49:23 +02:00
Ahmed Bouhuolia
217321380a feat: wip default message to sales transactions. 2023-12-14 20:47:52 +02:00
Ahmed Bouhuolia
33e5d1a979 feat: Add default customer message and terms and conditions to the transactions. 2023-12-14 11:01:25 +02:00
136 changed files with 5275 additions and 186 deletions

View File

@@ -69,6 +69,15 @@
"contributions": [
"code"
]
},
{
"login": "cschuijt",
"name": "Casper Schuijt",
"avatar_url": "https://avatars.githubusercontent.com/u/5460015?v=4",
"profile": "http://cschuijt.nl",
"contributions": [
"bug"
]
}
],
"contributorsPerLine": 7,

View File

@@ -2,11 +2,43 @@
All notable changes to Bigcapital server-side will be in this file.
# [0.10.1] - 25-09-2023
## [0.12.1] - 17-11-2023
* feat: Add default customer message and terms conditions to the transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/291
* fix: The currency code of transaction tax rate entry by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/293
## [0.12.0] - 04-11-2023
* feat: Export reports via CSV and XLSX by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/286
* fix: Axios upgrade by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/288
* fix(server): Allow decimal amount in sale/purchase transactions. by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/289
* feat: Optimize invoice documents printing by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/280
* chore(deps): bump axios from 0.20.0 to 1.6.0 in /packages/server by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/284
* chore(deps): bump axios from 0.20.0 to 1.6.0 by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/283
## [0.11.0] - 28-10-2023
* feat: Migrate to pnpm by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/253
* feat: Integrate tax rates to bills by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/260
* feat: Assign default sell/purchase tax rates to items by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/261
* chore(deps-dev): bump @babel/traverse from 7.23.0 to 7.23.2 in /packages/server by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/272
* feat: Improve financial statements rows color by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/276
* fix: Trial balance sheet adjusted balance by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/273
* feat: Adds tax numbers to organization and customers by @kochie in https://github.com/bigcapitalhq/bigcapital/pull/269
* docs: Add kochie as a contributor for code by @allcontributors in https://github.com/bigcapitalhq/bigcapital/pull/277
* feat: Computed Net Income under Equity in Balance Sheet report. by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/271
* fix: Change Dockerfile files with new pnpm by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/278
## [0.10.2] - 02-10-2023
fix(webapp): Disable tax rates from item entries editor table services do not support tax rates (https://github.com/bigcapitalhq/bigcapital/commit/69afa07e3ba45495a4cab3490c15f2b0c40c4790) by @abouolia
fix(server): Add missing method in ItemEntry model (https://github.com/bigcapitalhq/bigcapital/commit/07628ddc37f46c98959ced0323f28752e0a98944) by @abouolia
## [0.10.1] - 25-09-2023
* Fix: Running tenants migration on Docker migration container by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/242
# [0.10.0] - 24-09-2023
## [0.10.0] - 24-09-2023
* Added: Tax rates service by @abouolia @elforjani13 in https://github.com/bigcapitalhq/bigcapital/pull/204
* Added: Sales Tax Liability Summary report by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/204
@@ -16,7 +48,7 @@ All notable changes to Bigcapital server-side will be in this file.
* chore(deps): bump word-wrap from 1.2.3 to 1.2.4 in /packages/webapp by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/199
* chore(deps): bump mongoose from 5.13.15 to 5.13.20 by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/197
# [0.9.12] - 29-08-2023
## [0.9.12] - 29-08-2023
* Refactor: split the services to multiple service classes. (by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/202)
* Fix: create quick customer/vendor by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/206
@@ -30,18 +62,18 @@ All notable changes to Bigcapital server-side will be in this file.
* Fix: filter by customers, vendors and items in reports do not work by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/224
https://github.com/bigcapitalhq/bigcapital/pull/225
# [0.9.11] - 23-07-2023
## [0.9.11] - 23-07-2023
* added: Restart policy to docker compose files. by @suhaibaffan in https://github.com/bigcapitalhq/bigcapital/pull/198
* fix: Expose and expand the rate limit to the env variables by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/195
# [0.9.10] - 18-07-2023
## [0.9.10] - 18-07-2023
* feat(e2e): E2E onboarding process by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/176
* fix(webapp): Show loading message of cost computing job on financial reports by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/196
* fix(webapp): Change the currency code of sales and purchases transactions with foreign contacts.
# [0.9.9] - 28-06-2023
## [0.9.9] - 28-06-2023
* refactor: Customer and vendor select component by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/171
* chore: Move auto-increment components in separate files by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/170
@@ -53,7 +85,7 @@ https://github.com/bigcapitalhq/bigcapital/pull/225
* fix: No currency in amount field on money in/out dialogs by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/179
* fix: No default branch for customer/vendor opening balance branch by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/182
# [0.9.8] - 19-06-2023
## [0.9.8] - 19-06-2023
`bigcapitalhq/webapp`

View File

@@ -107,6 +107,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/KalliopiPliogka"><img src="https://avatars.githubusercontent.com/u/81677549?v=4?s=100" width="100px;" alt="Kalliopi Pliogka"/><br /><sub><b>Kalliopi Pliogka</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3AKalliopiPliogka" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://me.kochie.io"><img src="https://avatars.githubusercontent.com/u/10809884?v=4?s=100" width="100px;" alt="Robert Koch"/><br /><sub><b>Robert Koch</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=kochie" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://cschuijt.nl"><img src="https://avatars.githubusercontent.com/u/5460015?v=4?s=100" width="100px;" alt="Casper Schuijt"/><br /><sub><b>Casper Schuijt</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Acschuijt" title="Bug reports">🐛</a></td>
</tr>
</tbody>
</table>

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},
/**

View File

@@ -59,6 +59,12 @@ export default {
auto_increment: {
type: 'boolean',
},
customer_notes: {
type: 'string',
},
terms_conditions: {
type: 'string',
},
},
sales_receipts: {
next_number: {
@@ -73,6 +79,12 @@ export default {
preferred_deposit_account: {
type: 'number',
},
receipt_message: {
type: 'string',
},
terms_conditions: {
type: 'string',
},
},
sales_invoices: {
next_number: {
@@ -84,6 +96,12 @@ export default {
auto_increment: {
type: 'boolean',
},
customer_notes: {
type: 'string',
},
terms_conditions: {
type: 'string',
},
},
payment_receives: {
next_number: {
@@ -147,6 +165,12 @@ export default {
auto_increment: {
type: 'boolean',
},
customer_notes: {
type: 'string',
},
terms_conditions: {
type: 'string',
},
},
vendor_credit: {
next_number: {

View File

@@ -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<any>;
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;
}
}
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<string, any>;
}
export interface CommonMailOptionsDTO {
to?: string | string[];
from?: string;
subject?: string;
body?: string;
}

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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, any>): 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');
}
}

View File

@@ -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();
};

View File

@@ -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.
*/

View File

@@ -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<SaleInvoiceMailOptions>}
*/
public async getDefaultMailOptions(
tenantId: number,
contactId: number,
subject: string = '',
body: string = ''
): Promise<CommonMailOptions> {
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<CommonMailOptions>}
*/
public async getMailOptions(
tenantId: number,
contactId: number,
defaultSubject?: string,
defaultBody?: string,
formatterData?: Record<string, any>
): Promise<CommonMailOptions> {
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<Record<string, string>>}
*/
public async getCommonFormatArgs(
tenantId: number
): Promise<Record<string, string>> {
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
return {
CompanyName: organization.metadata.name,
};
}
}

View File

@@ -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',
};

View File

@@ -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<SaleInvoiceMailOptions>} mailOptions
* @param {Partial<SendInvoiceMailDTO>} overridedOptions
* @throws {ServiceError}
*/
export function parseAndValidateMailOptions(
mailOptions: Partial<CommonMailOptions>,
overridedOptions: Partial<CommonMailOptionsDTO>
) {
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;
}

View File

@@ -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)
}
}

View File

@@ -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<void>}
*/
public sendSaleEstimateMail(
tenantId: number,
saleEstimateId: number,
saleEstimateMailOpts: SaleEstimateMailOptionsDTO
): Promise<void> {
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<SaleEstimateMailOptions>}
*/
public getSaleEstimateMail(
tenantId: number,
saleEstimateId: number
): Promise<SaleEstimateMailOptions> {
return this.sendEstimateMailService.getMailOptions(
tenantId,
saleEstimateId
);
}
}

View File

@@ -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',

View File

@@ -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<void>}
*/
public async triggerMail(
tenantId: number,
saleEstimateId: number,
messageOptions: SaleEstimateMailOptionsDTO
): Promise<void> {
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<Record<string, any>>}
*/
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<SaleEstimateMailOptions>}
*/
public getMailOptions = async (
tenantId: number,
saleEstimateId: number
): Promise<SaleEstimateMailOptions> => {
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<void>}
*/
public async sendMail(
tenantId: number,
saleEstimateId: number,
messageOptions: SaleEstimateMailOptionsDTO
): Promise<void> {
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();
}
}

View File

@@ -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);
}
};
}

View File

@@ -1,3 +1,18 @@
export const DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT =
'Estimate {EstimateNumber} is awaiting your approval';
export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `<p>Dear {CustomerName}</p>
<p>Thank you for your business, You can view or print your estimate from attachements.</p>
<p>
Estimate <strong>#{EstimateNumber}</strong><br />
Expiration Date : <strong>{EstimateExpirationDate}</strong><br />
Amount : <strong>{EstimateAmount}</strong></br />
</p>
<p>
<i>Regards</i><br />
<i>{CompanyName}</i>
</p>
`;
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 = [];

View File

@@ -24,8 +24,7 @@ export class GetSaleInvoice {
*/
public async getSaleInvoice(
tenantId: number,
saleInvoiceId: number,
authorizedUser: ISystemUser
saleInvoiceId: number
): Promise<ISaleInvoice> {
const { SaleInvoice } = this.tenancy.models(tenantId);

View File

@@ -0,0 +1,3 @@
export class GetSaleInvoiceMailReminder {
public getInvoiceMailReminder(tenantId: number, saleInvoiceId: number) {}
}

View File

@@ -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<Buffer>}
*/
async saleInvoicePdf(
public async saleInvoicePdf(
tenantId: number,
saleInvoice: ISaleInvoice
invoiceId: number
): Promise<Buffer> {
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',

View File

@@ -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<Buffer>}
*/
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<void>}
*/
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<SendInvoiceMailDTO>}
*/
public getSaleInvoiceMail(tenantId: number, saleInvoiceid: number) {
return this.sendSaleInvoiceMailService.getMailOption(
tenantId,
saleInvoiceid
);
}
}

View File

@@ -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<SaleInvoiceMailOptions>}
*/
public async getMailOption(
tenantId: number,
invoiceId: number,
defaultSubject: string = DEFAULT_INVOICE_MAIL_SUBJECT,
defaultBody: string = DEFAULT_INVOICE_MAIL_CONTENT
): Promise<SaleInvoiceMailOptions> {
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<string>}
*/
public formatText = async (
tenantId: number,
invoiceId: number
): Promise<Record<string, string | number>> => {
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,
};
};
}

View File

@@ -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<SaleInvoiceMailOptions>}
*/
public async getMailOption(tenantId: number, saleInvoiceId: number) {
return this.invoiceMail.getMailOption(
tenantId,
saleInvoiceId,
DEFAULT_INVOICE_MAIL_SUBJECT,
DEFAULT_INVOICE_MAIL_CONTENT
);
}
/**
* Triggers the mail invoice.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @param {SendInvoiceMailDTO} messageDTO
* @returns {Promise<void>}
*/
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();
}
}

View File

@@ -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);
}
};
}

View File

@@ -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<SaleInvoiceMailOptions>}
*/
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<void>}
*/
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();
}
}

View File

@@ -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);
}
};
}

View File

@@ -1,3 +1,35 @@
export const DEFAULT_INVOICE_MAIL_SUBJECT =
'Invoice {InvoiceNumber} from {CompanyName}';
export const DEFAULT_INVOICE_MAIL_CONTENT = `
<p>Dear {CustomerName}</p>
<p>Thank you for your business, You can view or print your invoice from attachements.</p>
<p>
Invoice <strong>#{InvoiceNumber}</strong><br />
Due Date : <strong>{InvoiceDueDate}</strong><br />
Amount : <strong>{InvoiceAmount}</strong></br />
</p>
<p>
<i>Regards</i><br />
<i>{CompanyName}</i>
</p>
`;
export const DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT =
'Invoice {InvoiceNumber} reminder from {CompanyName}';
export const DEFAULT_INVOICE_REMINDER_MAIL_CONTENT = `
<p>Dear {CustomerName}</p>
<p>You might have missed the payment date and the invoice is now overdue by {OverdueDays} days.</p>
<p>Invoice <strong>#{InvoiceNumber}</strong><br />
Due Date : <strong>{InvoiceDueDate}</strong><br />
Amount : <strong>{InvoiceAmount}</strong></p>
<p>
<i>Regards</i><br />
<i>{CompanyName}</i>
</p>
`;
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 = [];

View File

@@ -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<Buffer> {
const paymentReceive = await this.getPaymentService.getPaymentReceive(
tenantId,
paymentReceiveId
);
const htmlContent = await this.templateInjectable.render(
tenantId,
'modules/payment-receive-standard',

View File

@@ -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<void>}
*/
public async triggerMail(
tenantId: number,
paymentReceiveId: number,
messageDTO: PaymentReceiveMailOptsDTO
): Promise<void> {
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<PaymentReceiveMailOpts>}
*/
public getMailOptions = async (
tenantId: number,
paymentId: number
): Promise<PaymentReceiveMailOpts> => {
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<string>}
*/
public textFormatter = async (
tenantId: number,
invoiceId: number
): Promise<Record<string, string>> => {
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<void>}
*/
public async sendMail(
tenantId: number,
paymentReceiveId: number,
messageDTO: SendInvoiceMailDTO
): Promise<void> {
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();
}
}

View File

@@ -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);
}
};
}

View File

@@ -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<void>}
*/
public notifyPaymentByMail(
tenantId: number,
paymentReceiveId: number,
messageOpts: PaymentReceiveMailOptsDTO
): Promise<void> {
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<void>}
*/
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
);
};
}

View File

@@ -1,3 +1,18 @@
export const DEFAULT_PAYMENT_MAIL_SUBJECT = 'Payment Received by {CompanyName}';
export const DEFAULT_PAYMENT_MAIL_CONTENT = `
<p>Dear {CustomerName}</p>
<p>Thank you for your payment. It was a pleasure doing business with you. We look forward to work together again!</p>
<p>
Payment Date : <strong>{PaymentDate}</strong><br />
Amount : <strong>{PaymentAmount}</strong></br />
</p>
<p>
<i>Regards</i><br />
<i>{CompanyName}</i>
</p>
`;
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 = [];

View File

@@ -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<void>}
*/
public sendSaleReceiptMail(
tenantId: number,
saleReceiptId: number,
messageOpts: SaleReceiptMailOptsDTO
): Promise<void> {
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<SaleReceiptMailOpts>}
*/
public getSaleReceiptMail(
tenantId: number,
saleReceiptId: number
): Promise<SaleReceiptMailOpts> {
return this.saleReceiptNotifyByMailService.getMailOptions(
tenantId,
saleReceiptId
);
}
}

View File

@@ -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<SaleReceiptMailOptsDTO>}
*/
public async getMailOptions(
tenantId: number,
saleReceiptId: number
): Promise<SaleReceiptMailOpts> {
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<string>}
*/
public textFormatter = async (
tenantId: number,
receiptId: number
): Promise<Record<string, string>> => {
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<void>}
*/
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();
}
}

View File

@@ -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);
}
};
}

View File

@@ -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<Buffer>}
*/
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',

View File

@@ -1,3 +1,19 @@
export const DEFAULT_RECEIPT_MAIL_SUBJECT =
'Receipt {ReceiptNumber} from {CompanyName}';
export const DEFAULT_RECEIPT_MAIL_CONTENT = `
<p>Dear {CustomerName}</p>
<p>Thank you for your business, You can view or print your receipt from attachements.</p>
<p>
Receipt <strong>#{ReceiptNumber}</strong><br />
Amount : <strong>{ReceiptAmount}</strong></br />
</p>
<p>
<i>Regards</i><br />
<i>{CompanyName}</i>
</p>
`;
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 = [];

View File

@@ -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'
},
/**

View File

@@ -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",

View File

@@ -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}
/>
<TaxRateFormDialog dialogName={DialogsName.TaxRateForm} />
<InvoiceMailDialog dialogName={DialogsName.InvoiceMail} />
<EstimateMailDialog dialogName={DialogsName.EstimateMail} />
<ReceiptMailDialog dialogName={DialogsName.ReceiptMail} />
<PaymentMailDialog dialogName={DialogsName.PaymentMail} />
</div>
);
}

View File

@@ -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<FieldConfig, 'children' | 'component' | 'as'>,
RichEditorProps {
name: string;
value?: string;
}
interface FieldToRichEditorProps
extends FieldProps,
Omit<RichEditorProps, 'form'> {}
/**
* 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 <RichEditor {...fieldToRichEditor(props)} />;
}
/**
* Rich editor wrapper to bind with Formik.
* @param {FRichEditorProps} props -
* @returns {JSX.Element}
*/
export function FRichEditor({ ...props }: FRichEditorProps): JSX.Element {
return <Field {...props} component={FieldToRichEditor} />;
}

View File

@@ -4,4 +4,5 @@ export * from './FMoneyInputGroup';
export * from './BlueprintFormik';
export * from './InputPrependText';
export * from './InputPrependButton';
export * from './MoneyInputGroup';
export * from './MoneyInputGroup';
export * from './FRichEditor';

View File

@@ -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;
}
}
}

View File

@@ -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 (
<Box className={className}>
<EditorProvider
extensions={extensions}
content={content}
onBlur={handleBlur}
/>
</Box>
);
};

View File

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

View File

@@ -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',
}

View File

@@ -12,6 +12,22 @@ export default [
text: <T id={'users'} />,
href: '/preferences/users',
},
{
text: <T id={'preferences.estimates'} />,
href: '/preferences/estimates',
},
{
text: <T id={'preferences.invoices'} />,
href: '/preferences/invoices',
},
{
text: <T id={'preferences.receipts'} />,
href: '/preferences/receipts',
},
{
text: <T id={'preferences.creditNotes'} />,
href: '/preferences/credit-notes',
},
{
text: <T id={'currencies'} />,
href: '/preferences/currencies',

View File

@@ -29,7 +29,7 @@ const Schema = Yup.object().shape({
billing_address_2: Yup.string().trim(),
billing_address_city: Yup.string().trim(),
billing_address_state: Yup.string().trim(),
billing_address_postcode: Yup.number().nullable(),
billing_address_postcode: Yup.string().nullable(),
billing_address_phone: Yup.number(),
shipping_address_country: Yup.string().trim(),
@@ -37,7 +37,7 @@ const Schema = Yup.object().shape({
shipping_address_2: Yup.string().trim(),
shipping_address_city: Yup.string().trim(),
shipping_address_state: Yup.string().trim(),
shipping_address_postcode: Yup.number().nullable(),
shipping_address_postcode: Yup.string().nullable(),
shipping_address_phone: Yup.number(),
opening_balance: Yup.number().nullable(),

View File

@@ -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 (
<DrawerActionsBar>
@@ -79,12 +84,19 @@ function EstimateDetailActionsBar({
<NavbarDivider />
</Can>
<Can I={SaleEstimateAction.View} a={AbilitySubject.Estimate}>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="envelope" />}
text={'Send Mail'}
onClick={handleMailEstimate}
/>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="print-16" />}
text={<T id={'print'} />}
onClick={handlePrintEstimate}
/>
<NavbarDivider />
</Can>
<Can I={SaleEstimateAction.Delete} a={AbilitySubject.Estimate}>
<Button

View File

@@ -31,6 +31,7 @@ import {
import { compose } from '@/utils';
import { BadDebtMenuItem } from './utils';
import { DRAWERS } from '@/constants/drawers';
import { DialogsName } from '@/constants/dialogs';
/**
* Invoice details action bar.
@@ -93,6 +94,10 @@ function InvoiceDetailActionsBar({
openAlert('cancel-bad-debt', { invoiceId });
};
const handleMailInvoice = () => {
openDialog(DialogsName.InvoiceMail, { invoiceId });
};
return (
<DrawerActionsBar>
<NavbarGroup>
@@ -117,12 +122,19 @@ function InvoiceDetailActionsBar({
<NavbarDivider />
</Can>
<Can I={SaleInvoiceAction.View} a={AbilitySubject.Invoice}>
<Button
text={'Send Mail'}
icon={<Icon icon="envelope" />}
onClick={handleMailInvoice}
className={Classes.MINIMAL}
/>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="print-16" />}
text={<T id={'print'} />}
onClick={handlePrintInvoice}
/>
<NavbarDivider />
</Can>
<Can I={SaleInvoiceAction.Delete} a={AbilitySubject.Invoice}>
<Button

View File

@@ -28,6 +28,7 @@ import {
import { compose } from '@/utils';
import { DRAWERS } from '@/constants/drawers';
import { DialogsName } from '@/constants/dialogs';
/**
* Payment receive actions bar.
@@ -68,6 +69,10 @@ function PaymentReceiveActionsBar({
openDialog('payment-pdf-preview', { paymentReceiveId });
};
const handleMailPaymentReceive = () => {
openDialog(DialogsName.PaymentMail, { paymentReceiveId });
};
return (
<DrawerActionsBar>
<NavbarGroup>
@@ -81,12 +86,19 @@ function PaymentReceiveActionsBar({
<NavbarDivider />
</Can>
<Can I={PaymentReceiveAction.View} a={AbilitySubject.PaymentReceive}>
<Button
className={Classes.MINIMAL}
text={'Send Mail'}
icon={<Icon icon="envelope" />}
onClick={handleMailPaymentReceive}
/>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="print-16" />}
text={<T id={'print'} />}
onClick={handlePrintPaymentReceive}
/>
<NavbarDivider />
</Can>
<Can I={PaymentReceiveAction.Delete} a={AbilitySubject.PaymentReceive}>
<Button

View File

@@ -24,6 +24,7 @@ import { useReceiptDetailDrawerContext } from './ReceiptDetailDrawerProvider';
import { SaleReceiptAction, AbilitySubject } from '@/constants/abilityOption';
import { safeCallback, compose } from '@/utils';
import { DRAWERS } from '@/constants/drawers';
import { DialogsName } from '@/constants/dialogs';
/**
* Receipt details actions bar.
@@ -60,6 +61,9 @@ function ReceiptDetailActionBar({
const handleNotifyViaSMS = () => {
openDialog('notify-receipt-via-sms', { receiptId });
};
const handleReceiptMail = () => {
openDialog(DialogsName.ReceiptMail, { receiptId });
};
return (
<DrawerActionsBar>
@@ -74,6 +78,12 @@ function ReceiptDetailActionBar({
<NavbarDivider />
</Can>
<Can I={SaleReceiptAction.View} a={AbilitySubject.Receipt}>
<Button
className={Classes.MINIMAL}
text={'Send Mail'}
icon={<Icon icon="envelope" />}
onClick={handleReceiptMail}
/>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="print-16" />}

View File

@@ -284,26 +284,31 @@ export const useComposeRowsOnRemoveTableRow = () => {
/**
* Retrieves the aggregate tax rates from the given item entries.
* @param {string} currencyCode -
* @param {any} taxRates -
* @param {any} entries -
*/
export const aggregateItemEntriesTaxRates = R.curry((taxRates, entries) => {
const taxRatesById = keyBy(taxRates, 'id');
export const aggregateItemEntriesTaxRates = R.curry(
(currencyCode, taxRates, entries) => {
const taxRatesById = keyBy(taxRates, 'id');
// Calculate the total tax amount of invoice entries.
const filteredEntries = entries.filter((e) => e.tax_rate_id);
const groupedTaxRates = groupBy(filteredEntries, 'tax_rate_id');
// Calculate the total tax amount of invoice entries.
const filteredEntries = entries.filter((e) => e.tax_rate_id);
const groupedTaxRates = groupBy(filteredEntries, 'tax_rate_id');
return Object.keys(groupedTaxRates).map((taxRateId) => {
const taxRate = taxRatesById[taxRateId];
const taxRates = groupedTaxRates[taxRateId];
const totalTaxAmount = sumBy(taxRates, 'tax_amount');
const taxAmountFormatted = formattedAmount(totalTaxAmount, 'USD');
return Object.keys(groupedTaxRates).map((taxRateId) => {
const taxRate = taxRatesById[taxRateId];
const taxRates = groupedTaxRates[taxRateId];
const totalTaxAmount = sumBy(taxRates, 'tax_amount');
const taxAmountFormatted = formattedAmount(totalTaxAmount, currencyCode);
return {
taxRateId,
taxRate: taxRate.rate,
label: `${taxRate.name} [${taxRate.rate}%]`,
taxAmount: totalTaxAmount,
taxAmountFormatted,
};
});
});
return {
taxRateId,
taxRate: taxRate.rate,
label: `${taxRate.name} [${taxRate.rate}%]`,
taxAmount: totalTaxAmount,
taxAmountFormatted,
};
});
},
);

View File

@@ -0,0 +1,14 @@
// @ts-nocheck
import { PreferencesCreditNotesBoot } from './PreferencesCreditNotesFormBoot';
import { PreferencesCreditNotesFormPage } from './PreferencesCreditNotesFormPage';
/**
* Credit notes preferences.
*/
export function PreferencesCreditNotes() {
return (
<PreferencesCreditNotesBoot>
<PreferencesCreditNotesFormPage />
</PreferencesCreditNotesBoot>
);
}

View File

@@ -0,0 +1,9 @@
// @ts-nocheck
import * as Yup from 'yup';
const Schema = Yup.object().shape({
termsConditions: Yup.string().optional(),
customerNotes: Yup.string().optional(),
});
export const PreferencesCreditNotesFormSchema = Schema;

View File

@@ -0,0 +1,74 @@
// @ts-nocheck
import styled from 'styled-components';
import { Form } from 'formik';
import { Button, Intent } from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components';
/**
* Preferences credit notes form.
*/
export function PreferencesCreditNotesForm({ isSubmitting }) {
const history = useHistory();
// Handle close click.
const handleCloseClick = () => {
history.go(-1);
};
return (
<Form>
{/* ---------- Customer Notes ---------- */}
<FFormGroup
name={'customerNotes'}
label={<T id={'pref.creditNotes.customerNotes.field'} />}
fastField={true}
>
<FTextArea
medium={'true'}
name={'customerNotes'}
fastField={true}
fill={true}
/>
</FFormGroup>
{/* ---------- Terms & Conditions ---------- */}
<FFormGroup
name={'termsConditions'}
label={<T id={'pref.creditNotes.termsConditions.field'} />}
fastField={true}
>
<FTextArea
medium={'true'}
name={'termsConditions'}
fastField={true}
fill={true}
/>
</FFormGroup>
<CardFooterActions>
<Button loading={isSubmitting} intent={Intent.PRIMARY} type="submit">
<T id={'save'} />
</Button>
<Button onClick={handleCloseClick}>
<T id={'close'} />
</Button>
</CardFooterActions>
</Form>
);
}
const CardFooterActions = styled.div`
padding-top: 16px;
border-top: 1px solid #e0e7ea;
margin-top: 30px;
.bp4-button {
min-width: 70px;
+ .bp4-button {
margin-left: 10px;
}
}
`;

View File

@@ -0,0 +1,55 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import classNames from 'classnames';
import { CLASSES } from '@/constants/classes';
import { useSettings } from '@/hooks/query';
import PreferencesPageLoader from '../PreferencesPageLoader';
import { Card } from '@/components';
const PreferencesCreditNotesFormContext = React.createContext();
function PreferencesCreditNotesBoot({ ...props }) {
// Fetches organization settings.
const { isLoading: isSettingsLoading } = useSettings();
// Provider state.
const provider = {
isSettingsLoading,
};
// Detarmines whether if any query is loading.
const isLoading = isSettingsLoading;
return (
<div
className={classNames(
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT,
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT,
)}
>
<PreferencesCreditNotesCard>
{isLoading ? (
<PreferencesPageLoader />
) : (
<PreferencesCreditNotesFormContext.Provider
value={provider}
{...props}
/>
)}
</PreferencesCreditNotesCard>
</div>
);
}
const PreferencesCreditNotesCard = styled(Card)`
padding: 25px;
.bp4-form-group {
max-width: 600px;
}
`;
const usePreferencesCreditNotesFormContext = () =>
React.useContext(PreferencesCreditNotesFormContext);
export { PreferencesCreditNotesBoot, usePreferencesCreditNotesFormContext };

View File

@@ -0,0 +1,82 @@
// @ts-nocheck
import { useEffect } from 'react';
import intl from 'react-intl-universal';
import { Formik } from 'formik';
import * as R from 'ramda';
import { Intent } from '@blueprintjs/core';
import { AppToaster } from '@/components';
import { PreferencesCreditNotesFormSchema } from './PreferencesCreditNotesForm.schema';
import { PreferencesCreditNotesForm } from './PreferencesCreditNotesForm';
import withDashboardActions from '@/containers/Dashboard/withDashboardActions';
import { compose, transformToForm, transfromToSnakeCase } from '@/utils';
import withSettings from '@/containers/Settings/withSettings';
import { transferObjectOptionsToArray } from '../Accountant/utils';
import { useSaveSettings } from '@/hooks/query';
const defaultValues = {
termsConditions: '',
customerNotes: '',
};
/**
* Preferences - Credit Notes.
*/
function PreferencesCreditNotesFormPageRoot({
// #withDashboardActions
changePreferencesPageTitle,
// #withSettings
creditNoteSettings,
}) {
// Save settings.
const { mutateAsync: saveSettingMutate } = useSaveSettings();
useEffect(() => {
changePreferencesPageTitle(intl.get('preferences.creditNotes'));
}, [changePreferencesPageTitle]);
// Initial values.
const initialValues = {
...defaultValues,
...transformToForm(creditNoteSettings, defaultValues),
};
// Handle the form submit.
const handleFormSubmit = (values, { setSubmitting }) => {
const options = R.compose(
transferObjectOptionsToArray,
transfromToSnakeCase,
)({ creditNote: { ...values } });
// Handle request success.
const onSuccess = () => {
AppToaster.show({
message: intl.get('preferences.credit_notes.success_message'),
intent: Intent.SUCCESS,
});
setSubmitting(false);
};
// Handle request error.
const onError = () => {
setSubmitting(false);
};
saveSettingMutate({ options }).then(onSuccess).catch(onError);
};
return (
<Formik
initialValues={initialValues}
validationSchema={PreferencesCreditNotesFormSchema}
onSubmit={handleFormSubmit}
component={PreferencesCreditNotesForm}
/>
);
}
export const PreferencesCreditNotesFormPage = compose(
withDashboardActions,
withSettings(({ creditNoteSettings }) => ({
creditNoteSettings: creditNoteSettings,
})),
)(PreferencesCreditNotesFormPageRoot);

View File

@@ -0,0 +1,14 @@
// @ts-nocheck
import { PreferencesEstimatesBoot } from './PreferencesEstimatesFormBoot';
import { PreferencesEstimatesFormPage } from './PreferencesEstimatesFormPage';
/**
* Estimates preferences.
*/
export function PreferencesEstimates() {
return (
<PreferencesEstimatesBoot>
<PreferencesEstimatesFormPage />
</PreferencesEstimatesBoot>
);
}

View File

@@ -0,0 +1,9 @@
// @ts-nocheck
import * as Yup from 'yup';
const Schema = Yup.object().shape({
termsConditions: Yup.string().optional(),
customerNotes: Yup.string().optional(),
});
export const PreferencesEstimatesFormSchema = Schema;

View File

@@ -0,0 +1,74 @@
// @ts-nocheck
import styled from 'styled-components';
import { Form } from 'formik';
import { Button, Intent } from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components';
/**
* Preferences estimates form.
*/
export function PreferencesEstimatesForm({ isSubmitting }) {
const history = useHistory();
// Handle close click.
const handleCloseClick = () => {
history.go(-1);
};
return (
<Form>
{/* ---------- Customer Notes ---------- */}
<FFormGroup
name={'customerNotes'}
label={<T id={'pref.estimates.customerNotes.field'} />}
fastField={true}
>
<FTextArea
medium={'true'}
name={'customerNotes'}
fastField={true}
fill={true}
/>
</FFormGroup>
{/* ---------- Terms & Conditions ---------- */}
<FFormGroup
name={'termsConditions'}
label={<T id={'pref.estimates.termsConditions.field'} />}
fastField={true}
>
<FTextArea
medium={'true'}
name={'termsConditions'}
fastField={true}
fill={true}
/>
</FFormGroup>
<CardFooterActions>
<Button loading={isSubmitting} intent={Intent.PRIMARY} type="submit">
<T id={'save'} />
</Button>
<Button onClick={handleCloseClick}>
<T id={'close'} />
</Button>
</CardFooterActions>
</Form>
);
}
const CardFooterActions = styled.div`
padding-top: 16px;
border-top: 1px solid #e0e7ea;
margin-top: 30px;
.bp4-button {
min-width: 70px;
+ .bp4-button {
margin-left: 10px;
}
}
`;

View File

@@ -0,0 +1,55 @@
// @ts-nocheck
import React from 'react';
import classNames from 'classnames';
import { CLASSES } from '@/constants/classes';
import { useSettings } from '@/hooks/query';
import PreferencesPageLoader from '../PreferencesPageLoader';
import styled from 'styled-components';
import { Card } from '@/components';
const PreferencesEstimatesFormContext = React.createContext();
function PreferencesEstimatesBoot({ ...props }) {
// Fetches organization settings.
const { isLoading: isSettingsLoading } = useSettings();
// Provider state.
const provider = {
isSettingsLoading,
};
// Detarmines whether if any query is loading.
const isLoading = isSettingsLoading;
return (
<div
className={classNames(
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT,
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT,
)}
>
<PreferencesEstimatesCard>
{isLoading ? (
<PreferencesPageLoader />
) : (
<PreferencesEstimatesFormContext.Provider
value={provider}
{...props}
/>
)}
</PreferencesEstimatesCard>
</div>
);
}
const usePreferencesEstimatesFormContext = () =>
React.useContext(PreferencesEstimatesFormContext);
const PreferencesEstimatesCard = styled(Card)`
padding: 25px;
.bp4-form-group {
max-width: 600px;
}
`;
export { PreferencesEstimatesBoot, usePreferencesEstimatesFormContext };

View File

@@ -0,0 +1,82 @@
// @ts-nocheck
import React, { useEffect } from 'react';
import intl from 'react-intl-universal';
import { Formik } from 'formik';
import { Intent } from '@blueprintjs/core';
import * as R from 'ramda';
import { AppToaster } from '@/components';
import { PreferencesEstimatesFormSchema } from './PreferencesEstimatesForm.schema';
import { PreferencesEstimatesForm } from './PreferencesEstimatesForm';
import withDashboardActions from '@/containers/Dashboard/withDashboardActions';
import withSettings from '@/containers/Settings/withSettings';
import { transferObjectOptionsToArray } from '../Accountant/utils';
import { compose, transformToForm, transfromToSnakeCase } from '@/utils';
import { useSaveSettings } from '@/hooks/query';
const defaultValues = {
termsConditions: '',
customerNotes: '',
};
/**
* Preferences estimates form.
*/
function PreferencesEstimatesFormPageRoot({
// #withDashboardActions
changePreferencesPageTitle,
// #withSettings
estimatesSettings,
}) {
// Save Organization Settings.
const { mutateAsync: saveSettingMutate } = useSaveSettings();
useEffect(() => {
changePreferencesPageTitle(intl.get('preferences.estimates'));
}, [changePreferencesPageTitle]);
// Initial values.
const initialValues = {
...defaultValues,
...transformToForm(estimatesSettings, defaultValues),
};
// Handle the form submit.
const handleFormSubmit = (values, { setSubmitting }) => {
const options = R.compose(
transferObjectOptionsToArray,
transfromToSnakeCase,
)({ salesEstimates: { ...values } });
// Handle request success.
const onSuccess = (response) => {
AppToaster.show({
message: intl.get('preferences.estimates.success_message'),
intent: Intent.SUCCESS,
});
setSubmitting(false);
};
// Handle request error.
const onError = () => {
setSubmitting(false);
};
saveSettingMutate({ options }).then(onSuccess).catch(onError);
};
return (
<Formik
initialValues={initialValues}
validationSchema={PreferencesEstimatesFormSchema}
onSubmit={handleFormSubmit}
component={PreferencesEstimatesForm}
/>
);
}
export const PreferencesEstimatesFormPage = compose(
withDashboardActions,
withSettings(({ estimatesSettings }) => ({
estimatesSettings: estimatesSettings,
})),
)(PreferencesEstimatesFormPageRoot);

View File

@@ -0,0 +1,9 @@
// @ts-nocheck
import * as Yup from 'yup';
const Schema = Yup.object().shape({
termsConditions: Yup.string().optional(),
customerNotes: Yup.string().optional(),
});
export const PreferencesInvoiceFormSchema = Schema;

View File

@@ -0,0 +1,53 @@
// @ts-nocheck
import React from 'react';
import classNames from 'classnames';
import styled from 'styled-components';
import { CLASSES } from '@/constants/classes';
import { useSettings } from '@/hooks/query';
import PreferencesPageLoader from '../PreferencesPageLoader';
import { Card } from '@/components';
const PreferencesInvoiceFormContext = React.createContext();
function PreferencesInvoicesBoot({ ...props }) {
// Fetches organization settings.
const { isLoading: isSettingsLoading } = useSettings();
// Provider state.
const provider = {
isSettingsLoading
};
// Detarmines whether if any query is loading.
const isLoading = isSettingsLoading;
return (
<div
className={classNames(
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT,
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT,
)}
>
<PreferencesInvoicesCard>
{isLoading ? (
<PreferencesPageLoader />
) : (
<PreferencesInvoiceFormContext.Provider value={provider} {...props} />
)}
</PreferencesInvoicesCard>
</div>
);
}
const PreferencesInvoicesCard = styled(Card)`
padding: 25px;
.bp4-form-group{
max-width: 600px;
}
`;
const usePreferencesInvoiceFormContext = () =>
React.useContext(PreferencesInvoiceFormContext);
export { PreferencesInvoicesBoot, usePreferencesInvoiceFormContext };

View File

@@ -0,0 +1,82 @@
// @ts-nocheck
import React, { useEffect } from 'react';
import intl from 'react-intl-universal';
import { Formik } from 'formik';
import { Intent } from '@blueprintjs/core';
import * as R from 'ramda';
import { AppToaster } from '@/components';
import { PreferencesInvoiceFormSchema } from './PreferencesInvoiceForm.schema';
import { PreferencesInvoicesForm } from './PreferencesInvoicesForm';
import withDashboardActions from '@/containers/Dashboard/withDashboardActions';
import { compose, transformToForm, transfromToSnakeCase } from '@/utils';
import withSettings from '@/containers/Settings/withSettings';
import { transferObjectOptionsToArray } from '../Accountant/utils';
import { useSaveSettings } from '@/hooks/query';
const defaultValues = {
termsConditions: '',
customerNotes: '',
};
/**
* Preferences - Invoices.
*/
function PreferencesInvoiceFormPage({
// #withDashboardActions
changePreferencesPageTitle,
// #withSettings
invoiceSettings,
}) {
// Save settings.
const { mutateAsync: saveSettingMutate } = useSaveSettings();
useEffect(() => {
changePreferencesPageTitle(intl.get('preferences.invoices'));
}, [changePreferencesPageTitle]);
// Initial values.
const initialValues = {
...defaultValues,
...transformToForm(invoiceSettings, defaultValues),
};
// Handle the form submit.
const handleFormSubmit = (values, { setSubmitting }) => {
const options = R.compose(
transferObjectOptionsToArray,
transfromToSnakeCase,
)({ salesInvoices: { ...values } });
// Handle request success.
const onSuccess = () => {
AppToaster.show({
message: intl.get('preferences.invoices.success_message'),
intent: Intent.SUCCESS,
});
setSubmitting(false);
};
// Handle request error.
const onError = () => {
setSubmitting(false);
};
saveSettingMutate({ options }).then(onSuccess).catch(onError);
};
return (
<Formik
initialValues={initialValues}
validationSchema={PreferencesInvoiceFormSchema}
onSubmit={handleFormSubmit}
component={PreferencesInvoicesForm}
/>
);
}
export default compose(
withDashboardActions,
withSettings(({ invoiceSettings }) => ({
invoiceSettings: invoiceSettings,
})),
)(PreferencesInvoiceFormPage);

View File

@@ -0,0 +1,14 @@
// @ts-nocheck
import { PreferencesInvoicesBoot } from './PreferencesInvoiceFormBoot';
import PreferencesInvoiceFormPage from './PreferencesInvoiceFormPage';
/**
* items preferences.
*/
export default function PreferencesInvoices() {
return (
<PreferencesInvoicesBoot>
<PreferencesInvoiceFormPage />
</PreferencesInvoicesBoot>
);
}

View File

@@ -0,0 +1,74 @@
// @ts-nocheck
import styled from 'styled-components';
import { Form } from 'formik';
import { Button, Intent } from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components';
/**
* Invoices preferences form.
*/
export function PreferencesInvoicesForm({ isSubmitting }) {
const history = useHistory();
// Handle close click.
const handleCloseClick = () => {
history.go(-1);
};
return (
<Form>
{/* ---------- Customer Notes ---------- */}
<FFormGroup
name={'customerNotes'}
label={<T id={'pref.invoices.customerNotes.field'} />}
fastField={true}
>
<FTextArea
medium={'true'}
name={'customerNotes'}
fastField={true}
fill={true}
/>
</FFormGroup>
{/* ---------- Terms & Conditions ---------- */}
<FFormGroup
name={'termsConditions'}
label={<T id={'pref.invoices.termsConditions.field'} />}
fastField={true}
>
<FTextArea
medium={'true'}
name={'termsConditions'}
fastField={true}
fill={true}
/>
</FFormGroup>
<CardFooterActions>
<Button loading={isSubmitting} intent={Intent.PRIMARY} type="submit">
<T id={'save'} />
</Button>
<Button onClick={handleCloseClick}>
<T id={'close'} />
</Button>
</CardFooterActions>
</Form>
);
}
const CardFooterActions = styled.div`
padding-top: 16px;
border-top: 1px solid #e0e7ea;
margin-top: 30px;
.bp4-button {
min-width: 70px;
+ .bp4-button {
margin-left: 10px;
}
}
`;

View File

@@ -0,0 +1,14 @@
// @ts-nocheck
import { PreferencesReceiptsBoot } from './PreferencesReceiptsFormBoot';
import { PreferencesReceiptsFormPage } from './PreferencesReceiptsFormPage';
/**
* Preferences - Receipts.
*/
export function PreferencesReceipts() {
return (
<PreferencesReceiptsBoot>
<PreferencesReceiptsFormPage />
</PreferencesReceiptsBoot>
);
}

View File

@@ -0,0 +1,9 @@
// @ts-nocheck
import * as Yup from 'yup';
const Schema = Yup.object().shape({
termsConditions: Yup.string().optional(),
customerNotes: Yup.string().optional(),
});
export const PreferencesReceiptsFormSchema = Schema;

View File

@@ -0,0 +1,74 @@
// @ts-nocheck
import styled from 'styled-components';
import { Form } from 'formik';
import { Button, Intent } from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components';
/**
* Preferences general form.
*/
export function PreferencesReceiptsForm({ isSubmitting }) {
const history = useHistory();
// Handle close click.
const handleCloseClick = () => {
history.go(-1);
};
return (
<Form>
{/* ---------- Customer Notes ---------- */}
<FFormGroup
name={'receiptMessage'}
label={<T id={'pref.receipts.receiptMessage.field'} />}
fastField={true}
>
<FTextArea
medium={'true'}
name={'receiptMessage'}
fastField={true}
fill={true}
/>
</FFormGroup>
{/* ---------- Terms & Conditions ---------- */}
<FFormGroup
name={'termsConditions'}
label={<T id={'pref.receipts.termsConditions.field'} />}
fastField={true}
>
<FTextArea
medium={'true'}
name={'termsConditions'}
fastField={true}
fill={true}
/>
</FFormGroup>
<CardFooterActions>
<Button loading={isSubmitting} intent={Intent.PRIMARY} type="submit">
<T id={'save'} />
</Button>
<Button onClick={handleCloseClick}>
<T id={'close'} />
</Button>
</CardFooterActions>
</Form>
);
}
const CardFooterActions = styled.div`
padding-top: 16px;
border-top: 1px solid #e0e7ea;
margin-top: 30px;
.bp4-button {
min-width: 70px;
+ .bp4-button {
margin-left: 10px;
}
}
`;

View File

@@ -0,0 +1,56 @@
// @ts-nocheck
import React from 'react';
import classNames from 'classnames';
import styled from 'styled-components';
import { CLASSES } from '@/constants/classes';
import { useSettings } from '@/hooks/query';
import PreferencesPageLoader from '../PreferencesPageLoader';
import { Card } from '@/components';
const PreferencesReceiptsFormContext = React.createContext();
function PreferencesReceiptsBoot({ ...props }) {
// Fetches organization settings.
const { isLoading: isSettingsLoading } = useSettings();
// Provider state.
const provider = {
isSettingsLoading,
};
// Detarmines whether if any query is loading.
const isLoading = isSettingsLoading;
return (
<div
className={classNames(
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT,
CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT,
)}
>
<PreferencesReceiptsCard>
{isLoading ? (
<PreferencesPageLoader />
) : (
<PreferencesReceiptsFormContext.Provider
value={provider}
{...props}
/>
)}
</PreferencesReceiptsCard>
</div>
);
}
const PreferencesReceiptsCard = styled(Card)`
padding: 25px;
.bp4-form-group {
max-width: 600px;
}
`;
const usePreferencesReceiptsFormContext = () =>
React.useContext(PreferencesReceiptsFormContext);
export { PreferencesReceiptsBoot, usePreferencesReceiptsFormContext };

View File

@@ -0,0 +1,82 @@
// @ts-nocheck
import React, { useEffect } from 'react';
import intl from 'react-intl-universal';
import { Formik } from 'formik';
import { Intent } from '@blueprintjs/core';
import * as R from 'ramda';
import { AppToaster } from '@/components';
import { PreferencesReceiptsFormSchema } from './PreferencesReceiptsForm.schema';
import { PreferencesReceiptsForm } from './PreferencesReceiptsForm';
import withDashboardActions from '@/containers/Dashboard/withDashboardActions';
import { compose, transformToForm, transfromToSnakeCase } from '@/utils';
import withSettings from '@/containers/Settings/withSettings';
import { useSaveSettings } from '@/hooks/query';
import { transferObjectOptionsToArray } from '../Accountant/utils';
const defaultValues = {
termsConditions: '',
receiptMessage: '',
};
/**
* Preferences - Receipts.
*/
function PreferencesReceiptsFormPageRoot({
// #withDashboardActions
changePreferencesPageTitle,
// #withSettings
receiptSettings,
}) {
// Save settings.
const { mutateAsync: saveSettingMutate } = useSaveSettings();
useEffect(() => {
changePreferencesPageTitle(intl.get('preferences.receipts'));
}, [changePreferencesPageTitle]);
// Initial values.
const initialValues = {
...defaultValues,
...transformToForm(receiptSettings, defaultValues),
};
// Handle the form submit.
const handleFormSubmit = (values, { setSubmitting }) => {
const options = R.compose(
transferObjectOptionsToArray,
transfromToSnakeCase,
)({ salesReceipts: { ...values } });
// Handle request success.
const onSuccess = () => {
AppToaster.show({
message: intl.get('preferences.receipts.success_message'),
intent: Intent.SUCCESS,
});
setSubmitting(false);
};
// Handle request error.
const onError = () => {
setSubmitting(false);
};
saveSettingMutate({ options }).then(onSuccess).catch(onError);
};
return (
<Formik
initialValues={initialValues}
validationSchema={PreferencesReceiptsFormSchema}
onSubmit={handleFormSubmit}
component={PreferencesReceiptsForm}
/>
);
}
export const PreferencesReceiptsFormPage = compose(
withDashboardActions,
withSettings(({ receiptSettings }) => ({
receiptSettings: receiptSettings,
})),
)(PreferencesReceiptsFormPageRoot);

View File

@@ -322,8 +322,8 @@ export const useBillAggregatedTaxRates = () => {
const { taxRates } = useBillFormContext();
const aggregateTaxRates = React.useMemo(
() => aggregateItemEntriesTaxRates(taxRates),
[taxRates],
() => aggregateItemEntriesTaxRates(values.currency_code, taxRates),
[values.currency_code, taxRates],
);
// Calculate the total tax amount of bill entries.
return React.useMemo(() => {

View File

@@ -5,7 +5,7 @@ import classNames from 'classnames';
import { useHistory } from 'react-router-dom';
import { Formik, Form } from 'formik';
import { Intent } from '@blueprintjs/core';
import { isEmpty } from 'lodash';
import { defaultTo, isEmpty } from 'lodash';
import { CLASSES } from '@/constants/classes';
import {
CreateCreditNoteFormSchema,
@@ -48,6 +48,8 @@ function CreditNoteForm({
creditAutoIncrement,
creditNumberPrefix,
creditNextNumber,
creditCustomerNotes,
creditTermsConditions,
// #withCurrentOrganization
organization: { base_currency },
@@ -68,22 +70,21 @@ function CreditNoteForm({
const creditNumber = transactionNumber(creditNumberPrefix, creditNextNumber);
// Initial values.
const initialValues = React.useMemo(
() => ({
...(!isEmpty(creditNote)
? { ...transformToEditForm(creditNote) }
: {
...defaultCreditNote,
...(creditAutoIncrement && {
credit_note_number: creditNumber,
}),
entries: orderingLinesIndexes(defaultCreditNote.entries),
currency_code: base_currency,
...newCreditNote,
const initialValues = {
...(!isEmpty(creditNote)
? { ...transformToEditForm(creditNote) }
: {
...defaultCreditNote,
...(creditAutoIncrement && {
credit_note_number: creditNumber,
}),
}),
[],
);
entries: orderingLinesIndexes(defaultCreditNote.entries),
currency_code: base_currency,
terms_conditions: defaultTo(creditTermsConditions, ''),
note: defaultTo(creditCustomerNotes, ''),
...newCreditNote,
}),
};
// Handles form submit.
const handleFormSubmit = (
@@ -178,6 +179,8 @@ export default compose(
creditAutoIncrement: creditNoteSettings?.autoIncrement,
creditNextNumber: creditNoteSettings?.nextNumber,
creditNumberPrefix: creditNoteSettings?.numberPrefix,
creditCustomerNotes: creditNoteSettings?.customerNotes,
creditTermsConditions: creditNoteSettings?.termsConditions,
})),
withCurrentOrganization(),
)(CreditNoteForm);

View File

@@ -4,7 +4,7 @@ import intl from 'react-intl-universal';
import classNames from 'classnames';
import { Formik, Form } from 'formik';
import { Intent } from '@blueprintjs/core';
import { sumBy, isEmpty } from 'lodash';
import { sumBy, isEmpty, defaultTo } from 'lodash';
import { useHistory } from 'react-router-dom';
import { CLASSES } from '@/constants/classes';
@@ -43,6 +43,8 @@ function EstimateForm({
estimateNextNumber,
estimateNumberPrefix,
estimateAutoIncrementMode,
estimateCustomerNotes,
estimateTermsConditions,
// #withCurrentOrganization
organization: { base_currency },
@@ -60,25 +62,23 @@ function EstimateForm({
estimateNumberPrefix,
estimateNextNumber,
);
// Initial values in create and edit mode.
const initialValues = useMemo(
() => ({
...(!isEmpty(estimate)
? { ...transformToEditForm(estimate) }
: {
...defaultEstimate,
// If the auto-increment mode is enabled, take the next estimate
// number from the settings.
...(estimateAutoIncrementMode && {
estimate_number: estimateNumber,
}),
entries: orderingLinesIndexes(defaultEstimate.entries),
currency_code: base_currency,
const initialValues = {
...(!isEmpty(estimate)
? { ...transformToEditForm(estimate) }
: {
...defaultEstimate,
// If the auto-increment mode is enabled, take the next estimate
// number from the settings.
...(estimateAutoIncrementMode && {
estimate_number: estimateNumber,
}),
}),
[estimate, estimateNumber, estimateAutoIncrementMode, base_currency],
);
entries: orderingLinesIndexes(defaultEstimate.entries),
currency_code: base_currency,
terms_conditions: defaultTo(estimateTermsConditions, ''),
note: defaultTo(estimateCustomerNotes, ''),
}),
};
// Handles form submit.
const handleFormSubmit = (
@@ -181,6 +181,8 @@ export default compose(
estimateNextNumber: estimatesSettings?.nextNumber,
estimateNumberPrefix: estimatesSettings?.numberPrefix,
estimateAutoIncrementMode: estimatesSettings?.autoIncrement,
estimateCustomerNotes: estimatesSettings?.customerNotes,
estimateTermsConditions: estimatesSettings?.termsConditions,
})),
withCurrentOrganization(),
)(EstimateForm);

View File

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

View File

@@ -0,0 +1,44 @@
// @ts-nocheck
import React, { createContext } from 'react';
import { useSaleEstimateDefaultOptions } from '@/hooks/query';
import { DialogContent } from '@/components';
interface EstimateMailDialogBootValues {
estimateId: number;
mailOptions: any;
}
const EstimateMailDialagBoot = createContext<EstimateMailDialogBootValues>();
interface EstimateMailDialogBootProps {
estimateId: number;
children: React.ReactNode;
}
/**
* Estimate mail dialog boot provider.
*/
function EstimateMailDialogBoot({
estimateId,
...props
}: EstimateMailDialogBootProps) {
const { data: mailOptions, isLoading: isMailOptionsLoading } =
useSaleEstimateDefaultOptions(estimateId);
const provider = {
saleEstimateId: estimateId,
mailOptions,
isMailOptionsLoading,
};
return (
<DialogContent isLoading={isMailOptionsLoading}>
<EstimateMailDialagBoot.Provider value={provider} {...props} />
</DialogContent>
);
}
const useEstimateMailDialogBoot = () =>
React.useContext<EstimateMailDialogBootValues>(EstimateMailDialagBoot);
export { EstimateMailDialogBoot, useEstimateMailDialogBoot };

View File

@@ -0,0 +1,17 @@
import { EstimateMailDialogBoot } from './EstimateMailDialogBoot';
import { EstimateMailDialogForm } from './EstimateMailDialogForm';
interface EstimateMailDialogContentProps {
dialogName: string;
estimateId: number;
}
export default function EstimateMailDialogContent({
dialogName,
estimateId,
}: EstimateMailDialogContentProps) {
return (
<EstimateMailDialogBoot estimateId={estimateId}>
<EstimateMailDialogForm />
</EstimateMailDialogBoot>
)
}

View File

@@ -0,0 +1,75 @@
// @ts-nocheck
import { Formik } from 'formik';
import * as R from 'ramda';
import { useEstimateMailDialogBoot } from './EstimateMailDialogBoot';
import { DialogsName } from '@/constants/dialogs';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { useSendSaleEstimateMail } from '@/hooks/query';
import { EstimateMailDialogFormContent } from './EstimateMailDialogFormContent';
import {
initialMailNotificationValues,
MailNotificationFormValues,
transformMailFormToInitialValues,
transformMailFormToRequest,
} from '@/containers/SendMailNotification/utils';
import { Intent } from '@blueprintjs/core';
import { AppToaster } from '@/components';
const initialFormValues = {
...initialMailNotificationValues,
attachEstimate: true,
};
interface EstimateMailFormValues extends MailNotificationFormValues {
attachEstimate: boolean;
}
function EstimateMailDialogFormRoot({
// #withDialogClose
closeDialog,
}) {
const { mutateAsync: sendEstimateMail } = useSendSaleEstimateMail();
const { mailOptions, saleEstimateId } = useEstimateMailDialogBoot();
const initialValues = transformMailFormToInitialValues(
mailOptions,
initialFormValues,
);
// Handle the form submitting.
const handleSubmit = (values: EstimateMailFormValues, { setSubmitting }) => {
const reqValues = transformMailFormToRequest(values);
setSubmitting(true);
sendEstimateMail([saleEstimateId, reqValues])
.then(() => {
AppToaster.show({
message: 'The mail notification has been sent successfully.',
intent: Intent.SUCCESS,
});
closeDialog(DialogsName.EstimateMail);
setSubmitting(false);
})
.catch((error) => {
setSubmitting(false);
closeDialog(DialogsName.EstimateMail);
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
const handleClose = () => {
closeDialog(DialogsName.EstimateMail);
};
return (
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<EstimateMailDialogFormContent onClose={handleClose} />
</Formik>
);
}
export const EstimateMailDialogForm = R.compose(withDialogActions)(
EstimateMailDialogFormRoot,
);

View File

@@ -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 { useEstimateMailDialogBoot } from './EstimateMailDialogBoot';
interface EstimateMailDialogFormContentProps {
onClose?: () => void;
}
export function EstimateMailDialogFormContent({
onClose,
}: EstimateMailDialogFormContentProps) {
const { isSubmitting } = useFormikContext();
const { mailOptions } = useEstimateMailDialogBoot();
const handleClose = () => {
saveInvoke(onClose);
};
return (
<Form>
<div className={Classes.DIALOG_BODY}>
<MailNotificationForm
fromAddresses={mailOptions.from_addresses}
toAddresses={mailOptions.to_addresses}
/>
<AttachFormGroup name={'attachEstimate'} inline>
<FSwitch name={'attachEstimate'} label={'Attach Estimate'} />
</AttachFormGroup>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
disabled={isSubmitting}
onClick={handleClose}
style={{ minWidth: '65px' }}
>
Close
</Button>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
style={{ minWidth: '75px' }}
type="submit"
>
Send
</Button>
</div>
</div>
</Form>
);
}
const AttachFormGroup = styled(FFormGroup)`
background: #f8f9fb;
margin-top: 0.6rem;
padding: 4px 14px;
border-radius: 5px;
border: 1px solid #dcdcdd;
`;

View File

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

View File

@@ -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,
}}
/>
</DashboardContentTable>

View File

@@ -64,6 +64,7 @@ export function ActionsMenu({
onConvert,
onViewDetails,
onPrint,
onSendMail
},
}) {
return (
@@ -129,6 +130,11 @@ export function ActionsMenu({
</Choose>
</Can>
<Can I={SaleEstimateAction.View} a={AbilitySubject.Estimate}>
<MenuItem
icon={<Icon icon={'envelope'} iconSize={16} />}
text={'Send Mail'}
onClick={safeCallback(onSendMail, original)}
/>
<MenuItem
icon={<Icon icon={'print-16'} iconSize={16} />}
text={intl.get('print')}

View File

@@ -4,7 +4,7 @@ import intl from 'react-intl-universal';
import classNames from 'classnames';
import { Formik, Form } from 'formik';
import { Intent } from '@blueprintjs/core';
import { sumBy, isEmpty } from 'lodash';
import { sumBy, isEmpty, defaultTo } from 'lodash';
import { useHistory } from 'react-router-dom';
import { CLASSES } from '@/constants/classes';
import {
@@ -44,6 +44,8 @@ function InvoiceForm({
invoiceNextNumber,
invoiceNumberPrefix,
invoiceAutoIncrementMode,
invoiceCustomerNotes,
invoiceTermsConditions,
// #withCurrentOrganization
organization: { base_currency },
@@ -79,6 +81,8 @@ function InvoiceForm({
}),
entries: orderingLinesIndexes(defaultInvoice.entries),
currency_code: base_currency,
invoice_message: defaultTo(invoiceCustomerNotes, ''),
terms_conditions: defaultTo(invoiceTermsConditions, ''),
...newInvoice,
}),
};
@@ -192,6 +196,8 @@ export default compose(
invoiceNextNumber: invoiceSettings?.nextNumber,
invoiceNumberPrefix: invoiceSettings?.numberPrefix,
invoiceAutoIncrementMode: invoiceSettings?.autoIncrement,
invoiceCustomerNotes: invoiceSettings?.customerNotes,
invoiceTermsConditions: invoiceSettings?.termsConditions,
})),
withCurrentOrganization(),
)(InvoiceForm);

View File

@@ -333,8 +333,8 @@ export const useInvoiceAggregatedTaxRates = () => {
const { taxRates } = useInvoiceFormContext();
const aggregateTaxRates = React.useMemo(
() => aggregateItemEntriesTaxRates(taxRates),
[taxRates],
() => aggregateItemEntriesTaxRates(values.currency_code, taxRates),
[values.currency_code, taxRates],
);
// Calculate the total tax amount of invoice entries.
return React.useMemo(() => {

View File

@@ -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 (
<Dialog
name={dialogName}
title={'Invoice Mail'}
isOpen={isOpen}
canEscapeJeyClose={true}
autoFocus={true}
style={{ width: 600 }}
>
<DialogSuspense>
<InvoiceMailDialogContent
dialogName={dialogName}
invoiceId={invoiceId}
/>
</DialogSuspense>
</Dialog>
);
}
export default compose(withDialogRedux())(InvoiceMailDialog);

View File

@@ -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<InvoiceMailDialogBootValues>();
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 (
<DialogContent isLoading={isMailOptionsLoading}>
<InvoiceMailDialagBoot.Provider value={provider} {...props} />
</DialogContent>
);
}
const useInvoiceMailDialogBoot = () =>
React.useContext<InvoiceMailDialogBootValues>(InvoiceMailDialagBoot);
export { InvoiceMailDialogBoot, useInvoiceMailDialogBoot };

View File

@@ -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 (
<InvoiceMailDialogBoot invoiceId={invoiceId}>
<InvoiceMailDialogForm />
</InvoiceMailDialogBoot>
);
}

View File

@@ -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'),
});

View File

@@ -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 (
<Formik
initialValues={initialValues}
validationSchema={InvoiceMailFormSchema}
onSubmit={handleSubmit}
>
<InvoiceMailDialogFormContent onClose={handleClose} />
</Formik>
);
}
export const InvoiceMailDialogForm = R.compose(withDialogActions)(
InvoiceMailDialogFormRoot,
);

Some files were not shown because too many files have changed in this diff Show More