diff --git a/CHANGELOG.md b/CHANGELOG.md index 9949feb54..785bea9ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,77 @@ All notable changes to Bigcapital server-side will be in this file. -## [0.19.4] - 18-08-2024 +# [0.19.17] + +* fix: Un-categorize bank transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/663 + +# [0.19.16] + +* feat: Tracking more Posthog events by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/653 +* fix: Expense cannot accept credit card as payment account by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/654 +* fix: Suspense the lazy loaded components in banking pages by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/657 +* feat: Add help dropdown menu by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/656 +* feat: Bank pages layout breaking by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/658 +* feat: Datatable UI improvements by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/655 +* fix: Array cast of recognize function rule ids by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/660 +* fix: Payment made filling the form full amount field by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/661 +* feat: Tabular number of all money columns by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/659 +* refactor: The expense G/L writer by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/662 + +## [0.19.15] - + +* fix: Bank transactions infinity scrolling by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/648 +* feat: Integrate multiple branches and warehouses to resource importing by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/645 +* fix: Integrate multiple branches with expense resource by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/649 +* feat: Cover more tracking events. by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/650 +* feat: Track banking service events by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/651 + +## [0.19.14] + +* fix: Import bugs by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/643 +* fix: Set default index to transaction entries by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/644 +* feat(server): Events tracking using Posthog by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/646 + +## [0.19.13] + +* fix: Subscription middleware by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/624 +* fix: Getting the sheet columns in import sheet by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/641 + +## [0.19.12] + +* fix: Typo one-click demo page by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/640 + +## [0.19.11] + +* fix: Avoid running the cost job in import preview by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/635 +* fix: Debounce scheduling calculating items cost by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/634 +* fix: Expand the resources export page size limitation by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/636 +* feat: Optimize loading perf. by splitting big chunks and lazy loading them by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/632 +* fix: Use standard ISO 8601 format for exported data by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/638 +* fix: Add customer type to customers resource by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/639 + +## [0.19.10] + +* fix: Add subscription plans offer text by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/629 + +## [0.19.9] + +* fix: Make webapp package env variables dynamic by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/628 + +## [v0.19.8] + +* fix: Cannot import items income and cost accounts by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/617 +* fix: Some bank account details hidden by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/618 +* feat(ee): One-click demo account by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/616 +* feat: change banking service language by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/619 +* feat(banking): Filter uncategorized bank transactions by date by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/590 +* Fix: Syntax error caused error by @wolone in https://github.com/bigcapitalhq/bigcapital/pull/622 +* fix: Listen to payment webhooks by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/623 +* fix: Add prefix J-00001 to manual journals increments by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/625 +* fix: Disable sms service until Twilo integration by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/626 +* fix: Style tweaks in onboarding page by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/627 + +## [0.19.5] - 18-08-2024 * fix: Allow multi-lines to statements transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/594 * feat: Add amount comparators to amount bank rule field by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/595 @@ -23,6 +93,15 @@ All notable changes to Bigcapital server-side will be in this file. * fix: Delete bank account with uncategorized transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/614 * feat: activate/inactivate account from drawer details by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/615 +## [v0.19.4] + +* feat: Import and export tax rates by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/591 +* feat: Un-categorize bank transactions in bulk by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/587 +* feat: Pending bank transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/589 +* fix: Update `dev` Script in `package.json` to Use `cross-env` by @Champetaman in https://github.com/bigcapitalhq/bigcapital/pull/588 +* fix: Should not load branches on reconcile matching form if the branches not enabled by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/592 +* fix: Rounding the total amount the pending and matched transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/593 + ## [v0.18.0] - 10-08-2024 * feat: Bank rules for automated categorization by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/511 diff --git a/packages/server/resources/scss/base.css b/packages/server/resources/scss/base.css new file mode 100644 index 000000000..9c11398a2 --- /dev/null +++ b/packages/server/resources/scss/base.css @@ -0,0 +1,40 @@ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap'); + +*, +*::before, +*::after { + box-sizing: border-box; +} + +th { + text-align: inherit; + text-align: -webkit-match-parent; +} + +thead, +tbody, +tfoot, +tr, +td, +th { + border-color: inherit; + border-style: solid; + border-width: 0; +} + +body{ + margin: 0; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #000; + background-color: #fff; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; +} + +body, h1, h2, h3, h4, h5, h6{ + font-family: "Noto Sans", sans-serif; + font-optical-sizing: auto; + font-style: normal; +} \ No newline at end of file diff --git a/packages/server/resources/scss/base.scss b/packages/server/resources/scss/base.scss index bf7b32cc6..f7ed229d8 100644 --- a/packages/server/resources/scss/base.scss +++ b/packages/server/resources/scss/base.scss @@ -1,35 +1 @@ @import "./normalize.scss"; - -*, -*::before, -*::after { - box-sizing: border-box; -} - -th { - text-align: inherit; // 2 - text-align: -webkit-match-parent; // 3 -} - -thead, -tbody, -tfoot, -tr, -td, -th { - border-color: inherit; - border-style: solid; - border-width: 0; -} - -body{ - margin: 0; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: #212529; - background-color: #fff; - direction: ltr; - -webkit-text-size-adjust: 100%; - -webkit-tap-highlight-color: transparent; -} diff --git a/packages/server/resources/scss/normalize.css b/packages/server/resources/scss/normalize.css new file mode 100644 index 000000000..631be2581 --- /dev/null +++ b/packages/server/resources/scss/normalize.css @@ -0,0 +1,379 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + + html { + line-height: 1.15; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; + /* 1 */ + height: 0; + /* 1 */ + overflow: visible; + /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; + /* 1 */ + text-decoration: underline; + /* 2 */ + text-decoration: underline dotted; + /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + line-height: 1.15; + /* 1 */ + margin: 0; + /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { + /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { + /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; + /* 1 */ + color: inherit; + /* 2 */ + display: table; + /* 1 */ + max-width: 100%; + /* 1 */ + padding: 0; + /* 3 */ + white-space: normal; + /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; + /* 1 */ + padding: 0; + /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/packages/server/resources/views/PaperTemplateLayout.pug b/packages/server/resources/views/PaperTemplateLayout.pug index 4678ae166..b93bc7388 100644 --- a/packages/server/resources/views/PaperTemplateLayout.pug +++ b/packages/server/resources/views/PaperTemplateLayout.pug @@ -1,6 +1,9 @@ html(lang=locale) head title My Site - #{title} + style + include ../scss/normalize.css + include ../scss/base.css block head body div.paper-template diff --git a/packages/server/resources/views/modules/credit-note-standard.pug b/packages/server/resources/views/modules/credit-note-standard.pug index 8f9367a6b..f580b8fc9 100644 --- a/packages/server/resources/views/modules/credit-note-standard.pug +++ b/packages/server/resources/views/modules/credit-note-standard.pug @@ -1,81 +1,198 @@ extends ../PaperTemplateLayout.pug block head - style - if (isRtl) - include ../../css/modules/credit-rtl.css - else - include ../../css/modules/credit.css + - var prefix = 'bc' + style. + .#{prefix}-root { + color: #111; + padding: 24px 30px; + font-size: 12px; + position: relative; + box-shadow: inset 0 4px 0px 0 var(--invoice-primary-color); + } + .#{prefix}-big-title { + font-size: 60px; + margin: 0; + line-height: 1; + margin-bottom: 25px; + font-weight: 500; + color: #333; + } + .#{prefix}-logo-wrap { + height: 120px; + width: 120px; + position: absolute; + right: 26px; + top: 26px; + overflow: hidden; + } + .#{prefix}-terms-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 24px; + } + .#{prefix}-terms-item { + display: flex; + flex-direction: row; + gap: 12px; + } + .#{prefix}-terms-item__label { + min-width: 120px; + color: #333; + } + .#{prefix}-terms-item__value { + /* Styles for the term value */ + } + .#{prefix}-address-section{ + box-sizing: border-box; + display: flex; + flex-flow: wrap; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: start; + justify-content: flex-start; + gap: 10px; + margin-bottom: 24px; + } + .#{prefix}-address-section > * { + flex: 1 1; + } + .#{prefix}-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: inherit; + } + .#{prefix}-table__header { + font-weight: 400; + border-bottom: 1px solid #000; + padding: 2px 10px; + color: #333; + } + .#{prefix}-table__header:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__header:last-of-type{ + padding-right: 0; + } + .#{prefix}-table__header--right { + text-align: right; + } + .#{prefix}-table__cell { + border-bottom: 1px solid #F6F6F6; + padding: 12px 10px; + } + .#{prefix}-table__cell:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__cell:last-of-type { + padding-right: 0; + } + .#{prefix}-table__cell--right { + text-align: right; + } + + .#{prefix}-totals { + display: flex; + flex-direction: column; + margin-left: auto; + width: 300px; + margin-bottom: 24px; + } + .#{prefix}-totals__item { + display: flex; + padding: 4px 0; + } + .#{prefix}-totals__item--border-gray { + border-bottom: 1px solid #DADADA; + } + .#{prefix}-totals__item--border-dark { + border-bottom: 1px solid #000; + } + .#{prefix}-totals__item--font-weight-bold { + font-weight: bold; + } + .#{prefix}-totals__item-label { + min-width: 160px; + } + .#{prefix}-totals__item-amount { + flex: 1 1 auto; + text-align: right; + } + .#{prefix}-statement { + margin-bottom: 20px; + } + .#{prefix}-statement__label { + color: #666; + } + .#{prefix}-statement__value { + /* Styles for statement value */ + } block content - div.credit - div.credit__header - div.paper - h1.title #{__('credit.paper.credit_note')} - if creditNote.creditNoteNumber - span.creditNoteNumber #{creditNote.creditNoteNumber} + div(class=`${prefix}-root`) + div(class=`${prefix}-big-title`) Credit Note - div.organization - h3.title #{organizationName} - if organizationEmail - span.email #{organizationEmail} + if showCompanyLogo + div(class=`${prefix}-logo-wrap`) + img(src=companyLogo alt=`Company Logo`) + + div(class=`${prefix}-terms-list`) + if showCreditNoteNumber + div(class=`${prefix}-terms-item`) + div(class=`${prefix}-terms-item__label`) #{creditNoteNumberLabel}: + div(class=`${prefix}-terms-item__value`) #{creditNoteNumebr} - div.credit__full-amount - div.label #{__('credit.paper.amount')} - div.amount #{creditNote.formattedAmount} + if showCreditNoteDate + div(class=`${prefix}-terms-item`) + div(class=`${prefix}-terms-item__label`) #{creditNoteDateLabel}: + div(class=`${prefix}-terms-item__value`) #{creditNoteDate} - div.credit__meta - div.credit__meta-item.credit__meta-item--amount - span.label #{__('credit.paper.remaining')} - span.value #{creditNote.formattedCreditsRemaining} + div(class=`${prefix}-address-section`) + if showBilledFromAddress + div(class=`${prefix}-address`) + strong #{companyName} + each address in billedFromAddress + div #{address} + if showBilledToAddress + div(class=`${prefix}-address`) + strong #{billedToLabel} + each address in billedToAddress + div #{address} - div.credit__meta-item.credit__meta-item--billed-to - span.label #{__("credit.paper.billed_to")} - span.value #{creditNote.customer.displayName} - - div.credit__meta-item.credit__meta-item--credit-date - span.label #{__("credit.paper.credit_date")} - span.value #{creditNote.formattedCreditNoteDate} - - div.credit__table - table - thead - tr - th.item #{__("item_entry.paper.item_name")} - th.rate #{__("item_entry.paper.rate")} - th.quantity #{__("item_entry.paper.quantity")} - th.total #{__("item_entry.paper.total")} + table(class=`${prefix}-table`) + thead + tr + th(class=`${prefix}-table__header`) #{'Item'} + th(class=`${prefix}-table__header`) #{'Description'} + th(class=`${prefix}-table__header`) #{'Rate'} + th(class=`${prefix}-table__header`) #{'Total'} tbody - each entry in creditNote.entries - tr - td.item - div.title=entry.item.name - span.description=entry.description - td.rate=entry.rate - td.quantity=entry.quantity - td.total=entry.amount + each line in lines + tr(class=`${prefix}-table__row`) + td(class=`${prefix}-table__cell`) #{line.item} + td(class=`${prefix}-table__cell`) #{line.description} + td(class=`${prefix}-table__cell--right`) #{line.rate} + td(class=`${prefix}-table__cell--right`) #{line.total} - div.credit__table-after - div.credit__table-total - table - tbody - tr.total - td #{__('credit.paper.total')} - td #{creditNote.formattedAmount} - tr.payment-amount - td #{__('credit.paper.credits_used')} - td #{creditNote.formattedCreditsUsed} - tr.blanace-due - td #{__('credit.paper.credits_remaining')} - td #{creditNote.formattedCreditsRemaining} + div(class=`${prefix}-totals`) + if showSubtotal + div(class=`${prefix}-totals__item ${prefix}-totals__item--border-gray`) + div(class=`${prefix}-totals__item-label`) #{subtotallabel} + div(class=`${prefix}-totals__item-amount`) #{subtotal} - div.credit__footer - if creditNote.termsConditions - div.credit__conditions - h3 #{__("credit.paper.terms_conditions")} - p #{creditNote.termsConditions} + if showTotal + div(class=`${prefix}-totals__item ${prefix}-totals__item--border-dark`) + div(class=`${prefix}-totals__item-amount`) #{totalLabel}: + div(class=`${prefix}-totals__item-label`) #{total} - if creditNote.note - div.credit__notes - h3 #{__("credit.paper.notes")} - p #{creditNote.note} \ No newline at end of file + if showCustomerNote + div(class=`${prefix}-statement`) + div(class=`${prefix}-statement__label`) #{customerNoteLabel}: + div(class=`${prefix}-statement__value`) #{customerNote} + + if showTermsConditions + div(class=`${prefix}-statement`) + div(class=`${prefix}-statement__label`) #{termsConditionsLabel}: + div(class=`${prefix}-statement__value`) #{termsConditions} diff --git a/packages/server/resources/views/modules/estimate-regular.pug b/packages/server/resources/views/modules/estimate-regular.pug index 37cf85bfa..a908cb6f1 100644 --- a/packages/server/resources/views/modules/estimate-regular.pug +++ b/packages/server/resources/views/modules/estimate-regular.pug @@ -1,82 +1,207 @@ extends ../PaperTemplateLayout.pug -block head - style - if (isRtl) - include ../../css/modules/estimate-rtl.css - else - include ../../css/modules/estimate.css +block head + - var prefix = 'bc' + style. + .#{prefix}-root { + color: #111; + padding: 24px 30px; + font-size: 12px; + position: relative; + box-shadow: inset 0 4px 0px 0 var(--invoice-primary-color); + } + .#{prefix}-big-title { + font-size: 60px; + margin: 0; + line-height: 1; + margin-bottom: 25px; + font-weight: 500; + color: #333; + } + .#{prefix}-logo-wrap { + height: 120px; + width: 120px; + position: absolute; + right: 26px; + top: 26px; + overflow: hidden; + } + .#{prefix}-terms { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 24px; + } + .#{prefix}-terms-item { + display: flex; + flex-direction: row; + gap: 12px; + } + .#{prefix}-terms-item__label { + min-width: 120px; + color: #333; + } + .#{prefix}-terms-item__value { + } + .#{prefix}-addresses{ + box-sizing: border-box; + display: flex; + flex-flow: wrap; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: start; + justify-content: flex-start; + gap: 10px; + margin-bottom: 24px; + } + .#{prefix}-addresses > * { + flex: 1 1; + } + .#{prefix}-address { + } + .#{prefix}-address__item { + } + .#{prefix}-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: inherit; + } + .#{prefix}-table__header { + font-weight: 400; + border-bottom: 1px solid #000; + padding: 2px 10px; + color: #333; + } + .#{prefix}-table__header:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__header:last-of-type{ + padding-right: 0; + } + .#{prefix}-table__header--right { + text-align: right; + } + .#{prefix}-table__cell { + border-bottom: 1px solid #F6F6F6; + padding: 12px 10px; + } + .#{prefix}-table__cell--right{ + text-align: right; + } + .#{prefix}-table__cell:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__cell:last-of-type { + padding-right: 0; + } + .#{prefix}-totals { + display: flex; + flex-direction: column; + margin-left: auto; + width: 300px; + margin-bottom: 24px; + } + .#{prefix}-totals__item { + display: flex; + padding: 4px 0; + } + .#{prefix}-totals__item--border-gray { + border-bottom: 1px solid #DADADA; + } + .#{prefix}-totals__item--border-dark { + border-bottom: 1px solid #000; + } + .#{prefix}-totals__item--font-weight-bold { + font-weight: bold; + } + .#{prefix}-totals__item-label { + min-width: 160px; + } + .#{prefix}-totals__item-amount { + flex: 1 1 auto; + text-align: right; + } + .#{prefix}-statement { + margin-bottom: 20px; + } + .#{prefix}-statement__label { + color: #666; + } + .#{prefix}-statement__value { + } block content - div.estimate - div.estimate__header - div.paper - h1.title #{__("estimate.paper.estimate")} - span.email #{saleEstimate.estimateNumber} + div(class=`${prefix}-root`, style=`--invoice-primary-color: ${primaryColor}; --invoice-secondary-color: ${secondaryColor};`) + h1(class=`${prefix}-big-title`) Estimate - div.organization - h3.title #{organizationName} - if organizationEmail - span.email #{organizationEmail} + if showCompanyLogo + div(class=`${prefix}-logo-wrap`) + img(alt="", src=companyLogo) - div.estimate__estimate-amount - div.label #{__('estimate.paper.estimate_amount')} - div.amount #{saleEstimate.formattedAmount} + //- Terms List + div(class=`${prefix}-terms`) + if showEstimateNumber + div(class=`${prefix}-terms-item`) + div(class=`${prefix}-terms-item__label`) #{estimateNumberLabel} + div(class=`${prefix}-terms-item__value`) #{estimateNumebr} + if showEstimateDate + div(class=`${prefix}-terms-item`) + div(class=`${prefix}-terms-item__label`) #{estimateDateLabel} + div(class=`${prefix}-terms-item__value`) #{estimateDate} + if showExpirationDate + div(class=`${prefix}-terms-item`) + div(class=`${prefix}-terms-item__label`) #{expirationDateLabel} + div(class=`${prefix}-terms-item__value`) #{expirationDate} - div.estimate__meta - if saleEstimate.estimateNumber - div.estimate__meta-item.estimate__meta-item--estimate-number - span.label #{__("estimate.paper.estimate_number")} - span.value #{saleEstimate.estimateNumber} + //- Addresses (Group section) + div(class=`${prefix}-addresses`) + if showBilledFromAddress + div(class=`${prefix}-address`) + strong #{companyName} + each item in billedFromAddress + div(class=`${prefix}-address__item`) #{item} - div.estimate__meta-item.estimate__meta-item--billed-to - span.label #{__("estimate.paper.billed_to")} - span.value #{saleEstimate.customer.displayName} + if showBilledToAddress + div(class=`${prefix}-address`) + strong #{billedToLabel} + each item in billedToAddress + div(class=`${prefix}-address__item`) #{item} - div.estimate__meta-item.estimate__meta-item--estimate-date - span.label #{__("estimate.paper.estimate_date")} - span.value #{saleEstimate.formattedEstimateDate} - - div.estimate__meta-item.estimate__meta-item--due-date - span.label #{__("estimate.paper.expiration_date")} - span.value #{saleEstimate.formattedExpirationDate} - - div.estimate__table - table - thead - tr - th.item #{__("item_entry.paper.item_name")} - th.rate #{__("item_entry.paper.rate")} - th.quantity #{__("item_entry.paper.quantity")} - th.total #{__("item_entry.paper.total")} + //- Table section (Line items) + table(class=`${prefix}-table`) + thead + tr + th(class=`${prefix}-table__header`) Item + th(class=`${prefix}-table__header`) Description + th(class=`${prefix}-table__header ${prefix}-table__header--right`) Rate + th(class=`${prefix}-table__header ${prefix}-table__header--right`) Total tbody - each entry in saleEstimate.entries - tr - td.item - div.title=entry.item.name - span.description=entry.description - td.rate=entry.rate - td.quantity=entry.quantity - td.total=entry.amount + each line in lines + tr + td(class=`${prefix}-table__cell`) #{line.item} + td(class=`${prefix}-table__cell`) #{line.description} + td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.rate} + td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.total} - div.estimate__table-after - div.estimate__table-total - table - tbody - tr.subtotal - td #{__('estimate.paper.subtotal')} - td #{saleEstimate.formattedAmount} - tr.total - td #{__('estimate.paper.total')} - td #{saleEstimate.formattedAmount} + //- Totals section + div(class=`${prefix}-totals`) + if showSubtotal + div(class=`${prefix}-totals__item ${prefix}-totals__item--border-gray`) + div(class=`${prefix}-totals__item-label`) #{subtotalLabel} + div(class=`${prefix}-totals__item-amount`) #{subtotal} + if showTotal + div(class=`${prefix}-totals__item ${prefix}-totals__item--border-dark ${prefix}-totals__item--font-weight-bold`) + div(class=`${prefix}-totals__item-label`) #{totalLabel} + div(class=`${prefix}-totals__item-amount`) #{total} - div.estimate__footer - if saleEstimate.termsConditions - div.estimate__conditions - h3 #{__("estimate.paper.conditions_title")} - p #{saleEstimate.termsConditions} + //- Statements section + if showCustomerNote && customerNote + div(class=`${prefix}-statement`) + div(class=`${prefix}-statement__label`) #{customerNoteLabel} + div(class=`${prefix}-statement__value`) #{customerNote} - if saleEstimate.note - div.estimate__notes - h3 #{__("estimate.paper.notes_title")} - p #{saleEstimate.note} \ No newline at end of file + if showTermsConditions && termsConditions + div(class=`${prefix}-statement`) + div(class=`${prefix}-statement__label`) #{termsConditionsLabel} + div(class=`${prefix}-statement__value`) #{termsConditions} \ No newline at end of file diff --git a/packages/server/resources/views/modules/invoice-regular.pug b/packages/server/resources/views/modules/invoice-regular.pug deleted file mode 100644 index edddcaf9c..000000000 --- a/packages/server/resources/views/modules/invoice-regular.pug +++ /dev/null @@ -1,92 +0,0 @@ -extends ../PaperTemplateLayout.pug - -block head - style - if (isRtl) - include ../../css/modules/invoice-rtl.css - else - include ../../css/modules/invoice.css - -block content - div.invoice - div.invoice__header - div.paper - h1.title #{__("invoice.paper.invoice")} - if saleInvoice.invoiceNo - span.invoiceNo #{saleInvoice.invoiceNo} - - div.organization - h3.title #{organizationName} - if organizationEmail - span.email #{organizationEmail} - - div.invoice__due-amount - div.label #{__('invoice.paper.invoice_amount')} - div.amount #{saleInvoice.totalFormatted} - - div.invoice__meta - div.invoice__meta-item.invoice__meta-item--amount - span.label #{__('invoice.paper.due_amount')} - span.value #{saleInvoice.dueAmountFormatted} - - div.invoice__meta-item.invoice__meta-item--billed-to - span.label #{__("invoice.paper.billed_to")} - span.value #{saleInvoice.customer.displayName} - - div.invoice__meta-item.invoice__meta-item--invoice-date - span.label #{__("invoice.paper.invoice_date")} - span.value #{saleInvoice.invoiceDateFormatted} - - div.invoice__meta-item.invoice__meta-item--due-date - span.label #{__("invoice.paper.due_date")} - span.value #{saleInvoice.dueDateFormatted} - - div.invoice__table - table - thead - tr - th.item #{__("item_entry.paper.item_name")} - th.rate #{__("item_entry.paper.rate")} - th.quantity #{__("item_entry.paper.quantity")} - th.total #{__("item_entry.paper.total")} - tbody - each entry in saleInvoice.entries - tr - td.item - div.title=entry.item.name - span.description=entry.description - td.rate=entry.rate - td.quantity=entry.quantity - td.total=entry.amount - - div.invoice__table-after - div.invoice__table-total - table - tbody - tr.subtotal - td #{__('invoice.paper.subtotal')} - td #{saleInvoice.subtotalFormatted} - each tax in saleInvoice.taxes - tr.tax_line - td #{tax.name} [#{tax.taxRate}%] - td #{tax.taxRateAmountFormatted} - tr.total - td #{__('invoice.paper.total')} - td #{saleInvoice.totalFormatted} - tr.payment-amount - td #{__('invoice.paper.payment_amount')} - td #{saleInvoice.paymentAmountFormatted} - tr.blanace-due - td #{__('invoice.paper.balance_due')} - td #{saleInvoice.dueAmountFormatted} - - div.invoice__footer - if saleInvoice.termsConditions - div.invoice__conditions - h3 #{__("invoice.paper.conditions_title")} - p #{saleInvoice.termsConditions} - - if saleInvoice.invoiceMessage - div.invoice__notes - h3 #{__("invoice.paper.notes_title")} - p #{saleInvoice.invoiceMessage} \ No newline at end of file diff --git a/packages/server/resources/views/modules/invoice-standard.pug b/packages/server/resources/views/modules/invoice-standard.pug new file mode 100644 index 000000000..ba9339637 --- /dev/null +++ b/packages/server/resources/views/modules/invoice-standard.pug @@ -0,0 +1,242 @@ +extends ../PaperTemplateLayout.pug + +block head + - var prefix = 'bc' + style. + .#{prefix}-root { + color: #111; + padding: 24px 30px; + font-size: 12px; + position: relative; + box-shadow: inset 0 4px 0px 0 var(--invoice-primary-color); + } + .#{prefix}-big-title { + font-size: 60px; + margin: 0; + line-height: 1; + margin-bottom: 25px; + font-weight: 500; + color: #333; + } + .#{prefix}-logo-wrap { + height: 120px; + width: 120px; + position: absolute; + right: 26px; + top: 26px; + overflow: hidden; + } + .#{prefix}-details { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 24px; + } + .#{prefix}-detail { + display: flex; + flex-direction: row; + gap: 12px; + } + .#{prefix}-detail__label { + min-width: 120px; + color: #333; + } + .#{prefix}-detail__value { + /* Styles for detail values */ + } + .#{prefix}-address-root { + box-sizing: border-box; + display: flex; + flex-flow: wrap; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: start; + justify-content: flex-start; + gap: 10px; + margin-bottom: 24px; + } + .#{prefix}-address-from { + flex: 1; + } + .#{prefix}-address-from__item { + /* Styles for items in the billed-from address */ + } + .#{prefix}-address-to { + flex: 1; + } + .#{prefix}-address-to__item { + /* Styles for items in the billed-to address */ + } + .#{prefix}-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: inherit; + } + .#{prefix}-table__header { + font-weight: 400; + border-bottom: 1px solid #000; + padding: 2px 10px; + color: #333; + } + .#{prefix}-table__header:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__header:last-of-type{ + padding-right: 0; + } + .#{prefix}-table__header--right { + text-align: right; + } + .#{prefix}-table__cell { + border-bottom: 1px solid #F6F6F6; + padding: 12px 10px; + } + .#{prefix}-table__cell:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__cell:last-of-type { + padding-right: 0; + } + .#{prefix}-table__cell--right { + text-align: right; + } + .#{prefix}-totals { + display: flex; + flex-direction: column; + margin-left: auto; + width: 300px; + margin-bottom: 24px; + } + .#{prefix}-totals__item { + display: flex; + padding: 4px 0; + } + .#{prefix}-totals__item--border-gray { + border-bottom: 1px solid #DADADA; + } + .#{prefix}-totals__item--border-dark { + border-bottom: 1px solid #000; + } + .#{prefix}-totals__item--font-weight-bold { + font-weight: bold; + } + .#{prefix}-totals__item-label { + min-width: 160px; + } + .#{prefix}-totals__item-amount { + flex: 1 1 auto; + text-align: right; + } + .#{prefix}-paragraph { + margin-bottom: 20px; + } + .#{prefix}-paragraph__label { + color: #666; + } + .#{prefix}-paragraph__value { + /* Styles for values within the paragraph section */ + } +block content + //- block head + div(class=`${prefix}-root`, style=`--invoice-primary-color: ${primaryColor}; --invoice-secondary-color: ${secondaryColor};`) + + //- Title and company logo + h1(class=`${prefix}-big-title`) Invoice + + if showCompanyLogo + div(class=`${prefix}-logo-wrap`) + img(alt="", src=companyLogo) + + //- Invoice details + div(class=`${prefix}-details`) + if showInvoiceNumber + div(class=`${prefix}-detail`) + div(class=`${prefix}-detail__label`) #{invoiceNumberLabel} + div(class=`${prefix}-detail__value`) #{invoiceNumber} + + if showDateIssue + div(class=`${prefix}-detail`) + div(class=`${prefix}-detail__label`) #{dateIssueLabel} + div(class=`${prefix}-detail__value`) #{dateIssue} + + if showDueDate + div(class=`${prefix}-detail`) + div(class=`${prefix}-detail__label`) #{dueDateLabel} + div(class=`${prefix}-detail__value`) #{dueDate} + + //- Address section + div(class=`${prefix}-address-root`) + if showBilledFromAddress + div(class=`${prefix}-address-from`) + strong #{companyName} + each item in billedFromAddres + div(class=`${prefix}-address-from__item`) #{item} + + if showBillingToAddress + div(class=`${prefix}-address-to`) + strong #{billedToLabel} + each item in billedToAddress + div(class=`${prefix}-address-to__item`) #{item} + + //- Invoice table + table(class=`${prefix}-table`) + thead + tr + th(class=`${prefix}-table__header`) #{lineItemLabel} + th(class=`${prefix}-table__header`) #{lineDescriptionLabel} + th(class=`${prefix}-table__header ${prefix}-table__header--right`) #{lineRateLabel} + th(class=`${prefix}-table__header ${prefix}-table__header--right`) #{lineTotalLabel} + tbody + each line in lines + tr + td(class=`${prefix}-table__cell`) #{line.item} + td(class=`${prefix}-table__cell`) #{line.description} + td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.rate} + td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.total} + + //- Totals section + div(class=`${prefix}-totals`) + if showSubtotal + div(class=`${prefix}-totals__item ${prefix}-totals__item--border-gray`) + div(class=`${prefix}-totals__item-label`) #{subtotalLabel} + div(class=`${prefix}-totals__item-amount`) #{subtotal} + + if showDiscount + div(class=`${prefix}-totals__item`) + div(class=`${prefix}-totals__item-label`) #{discountLabel} + div(class=`${prefix}-totals__item-amount`) #{discount} + + if showTaxes + each tax in taxes + div(class=`${prefix}-totals__item`) + div(class=`${prefix}-totals__item-label`) #{tax.label} + div(class=`${prefix}-totals__item-amount`) #{tax.amount} + + if showTotal + div(class=`${prefix}-totals__item ${prefix}-totals__item--border-dark ${prefix}-totals__item--font-weight-bold`) + div(class=`${prefix}-totals__item-label`) #{totalLabel} + div(class=`${prefix}-totals__item-amount`) #{total} + + if showPaymentMade + div(class=`${prefix}-totals__item`) + div(class=`${prefix}-totals__item-label`) #{paymentMadeLabel} + div(class=`${prefix}-totals__item-amount`) #{paymentMade} + + if showBalanceDue + div(class=`${prefix}-totals__item ${prefix}-totals__item--border-dark ${prefix}-totals__item--font-weight-bold`) + div(class=`${prefix}-totals__item-label`) #{balanceDueLabel} + div(class=`${prefix}-totals__item-amount`) #{balanceDue} + + //- Footer section + if showTermsConditions && termsConditions + div(class=`${prefix}-paragraph`) + if termsConditionsLabel + div(class=`${prefix}-paragraph__label`) #{termsConditionsLabel} + div(class=`${prefix}-paragraph__value`) #{termsConditions} + + if showStatement && statement + div(class=`${prefix}-paragraph`) + if statementLabel + div(class=`${prefix}-paragraph__label`) #{statementLabel} + div(class=`${prefix}-paragraph__value`) #{statement} diff --git a/packages/server/resources/views/modules/payment-receive-standard.pug b/packages/server/resources/views/modules/payment-receive-standard.pug index ee415b768..36ba86ed2 100644 --- a/packages/server/resources/views/modules/payment-receive-standard.pug +++ b/packages/server/resources/views/modules/payment-receive-standard.pug @@ -1,67 +1,178 @@ extends ../PaperTemplateLayout.pug block head - style - if (isRtl) - include ../../css/modules/payment-rtl.css - else - include ../../css/modules/payment.css + - var prefix = 'bp3'; + + style. + .#{prefix}-root{ + color: #111; + padding: 24px 30px; + font-size: 12px; + position: relative; + box-shadow: inset 0 4px 0px 0 var(--invoice-primary-color); + } + .#{prefix}-big-title{ + font-size: 60px; + margin: 0; + line-height: 1; + margin-bottom: 25px; + font-weight: 500; + color: #333; + } + .#{prefix}-logo-wrap{ + height: 120px; + width: 120px; + position: absolute; + right: 26px; + top: 26px; + overflow: hidden; + } + .#{prefix}-terms-list{ + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 24px; + } + .#{prefix}-terms-item{ + display: flex; + flex-direction: row; + gap: 12px; + } + .#{prefix}-terms-item__label{ + min-width: 120px; + color: #333; + } + .#{prefix}-addresses{ + box-sizing: border-box; + display: flex; + flex-flow: wrap; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: start; + justify-content: flex-start; + gap: 10px; + margin-bottom: 24px; + } + .#{prefix}-addresses > * { + flex: 1 1; + } + .#{prefix}-address__label{ + } + .#{prefix}-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: inherit; + } + .#{prefix}-table__header { + font-weight: 400; + border-bottom: 1px solid #000; + padding: 2px 10px; + color: #333; + } + .#{prefix}-table__header:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__header:last-of-type{ + padding-right: 0; + } + .#{prefix}-table__header--right { + text-align: right; + } + .#{prefix}-table__cell { + border-bottom: 1px solid #F6F6F6; + padding: 12px 10px; + } + .#{prefix}-table__cell:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__cell:last-of-type { + padding-right: 0; + } + .#{prefix}-table__cell--right { + text-align: right; + } + .#{prefix}-totals { + display: flex; + flex-direction: column; + margin-left: auto; + width: 300px; + margin-bottom: 24px; + } + .#{prefix}-totals__item { + display: flex; + padding: 4px 0; + } + .#{prefix}-totals__item--gray-border { + border-bottom: 1px solid #DADADA; + } + .#{prefix}-totals__item--dark-border { + border-bottom: 1px solid #000; + } + .#{prefix}-totals__item--bold { + font-weight: bold; + } + .#{prefix}-totals__item-label { + min-width: 160px; + } + .#{prefix}-totals__item-amount { + flex: 1 1 auto; + text-align: right; + } block content - div.payment - div.payment__header - div.paper - h1.title #{__("payment.paper.payment_receipt")} - if paymentReceive.paymentReceiveNo - span.paymentNumber #{paymentReceive.paymentReceiveNo} + div(class=`${prefix}-root`) + div(class=`${prefix}-big-title`) Payment - div.organization - h3.title #{organizationName} - if organizationEmail - span.email #{organizationEmail} + if showCompanyLogo + div(class=`${prefix}-logo-wrap`) + img(src=companyLogo alt="Company Logo") + + div(class=`${prefix}-terms-list`) + if showPaymentReceivedNumber + div(class=`${prefix}-terms-item`) + div(class=`${prefix}-terms-item__label`) #{paymentReceivedNumberLabel} + div(class=`${prefix}-terms-item__value`) #{paymentReceivedNumebr} - div.payment__received-amount - div.label #{__('payment.paper.amount_received')} - div.amount #{paymentReceive.formattedAmount} + if showPaymentReceivedDate + div(class=`${prefix}-terms-item`) + div(class=`${prefix}-terms-item__label`) #{paymentReceivedDateLabel} + div(class=`${prefix}-terms-item__value`) #{paymentReceivedDate} + + div(class=`${prefix}-addresses`) + if showBilledFromAddress + div(class=`${prefix}-address`) + strong(class=`${prefix}-address__item`) #{companyName} + each addressLine in billedFromAddress + div(class=`${prefix}-address__item`) #{addressLine} - div.payment__meta - div.payment__meta-item.payment__meta-item--billed-to - span.label #{__("payment.paper.billed_to")} - span.value #{paymentReceive.customer.displayName} + if showBillingToAddress + div(class=`${prefix}-address`) + strong(class=`${prefix}-address__item`) #{billedToLabel} + each addressLine in billedToAddress + div(class=`${prefix}-address__item`) #{addressLine} - div.payment__meta-item.payment__meta-item--payment-date - span.label #{__("payment.paper.payment_date")} - span.value #{paymentReceive.formattedPaymentDate} + table(class=`${prefix}-table`) + thead + tr + th(class=`${prefix}-table__header`) Invoice # + th(class=`${prefix}-table__header ${prefix}-table__header--right`) Invoice Amount + th(class=`${prefix}-table__header ${prefix}-table__header--right`) Paid Amount - div.payment__table - table - thead - tr - th.item #{__("payment.paper.invoice_number")} - th.date #{__("payment.paper.invoice_date")} - th.invoiceAmount #{__("payment.paper.invoice_amount")} - th.paymentAmount #{__("payment.paper.payment_amount")} - tbody - each entry in paymentReceive.entries - tr - td.item=entry.invoice.invoiceNo - td.date=entry.invoice.invoiceDateFormatted - td.invoiceAmount=entry.invoice.totalFormatted - td.paymentAmount=entry.invoice.paymentAmountFormatted + tbody + each line in lines + tr + td(class=`${prefix}-table__cell`) #{line.invoiceNumber} + td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.invoiceAmount} + td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.paidAmount} - div.payment__table-after - div.payment__table-total - table - tbody - tr.payment-amount - td #{__('payment.paper.payment_amount')} - td #{paymentReceive.formattedAmount} - tr.blanace-due - td #{__('payment.paper.balance_due')} - td #{paymentReceive.customer.closingBalance} + div(class=`${prefix}-totals`) + if showSubtotal + div(class=`${prefix}-totals__item ${prefix}-totals__item--gray-border`) + div(class=`${prefix}-totals__item-label`) #{subtotalLabel} + div(class=`${prefix}-totals__item-amount`) #{subtotal} - div.payment__footer - if paymentReceive.statement - div.payment__notes - h3 #{__("payment.paper.statement")} - p #{paymentReceive.statement} \ No newline at end of file + if showTotal + div(class=`${prefix}-totals__item ${prefix}-totals__item--dark-border`) + div(class=`${prefix}-totals__item-label`) #{totalLabel} + div(class=`${prefix}-totals__item-amount`) #{total} diff --git a/packages/server/resources/views/modules/receipt-regular.pug b/packages/server/resources/views/modules/receipt-regular.pug index a0cbb1f61..2aa504031 100644 --- a/packages/server/resources/views/modules/receipt-regular.pug +++ b/packages/server/resources/views/modules/receipt-regular.pug @@ -1,77 +1,198 @@ extends ../PaperTemplateLayout.pug block head - style - if (isRtl) - include ../../css/modules/receipt-rtl.css - else - include ../../css/modules/receipt.css + - var prefix = 'bc' + style. + .#{prefix}-root { + color: #000; + padding: 24px 30px; + font-size: 12px; + position: relative; + box-shadow: inset 0 4px 0px 0 var(--invoice-primary-color); + } + .#{prefix}-logo-wrap { + height: 120px; + width: 120px; + position: absolute; + right: 26px; + top: 26px; + overflow: hidden; + } + .#{prefix}-big-title { + font-size: 60px; + margin: 0; + line-height: 1; + margin-bottom: 25px; + font-weight: 500; + color: #333; + } + .#{prefix}-terms-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 24px; + } + .#{prefix}-terms-item { + display: flex; + flex-direction: row; + gap: 12px; + } + .#{prefix}-terms-item__label { + min-width: 120px; + color: #333; + } + .#{prefix}-terms-item__value {} + .#{prefix}-address-section { + box-sizing: border-box; + display: flex; + flex-flow: wrap; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: start; + justify-content: flex-start; + gap: 10px; + margin-bottom: 24px; + } + .#{prefix}-address-section > * { + flex: 1 1 auto; + } + .#{prefix}-address {} + .#{prefix}-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: inherit; + } + .#{prefix}-table__header { + font-weight: 400; + border-bottom: 1px solid #000; + padding: 2px 10px; + color: #333; + } + .#{prefix}-table__header:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__header:last-of-type{ + padding-right: 0; + } + .#{prefix}-table__header--right { + text-align: right; + } + .#{prefix}-table__cell { + border-bottom: 1px solid #F6F6F6; + padding: 12px 10px; + } + .#{prefix}-table__cell:first-of-type{ + padding-left: 0; + } + .#{prefix}-table__cell:last-of-type { + padding-right: 0; + } + .#{prefix}-table__cell--right { + text-align: right; + } + .#{prefix}-totals { + display: flex; + flex-direction: column; + margin-left: auto; + width: 300px; + margin-bottom: 24px; + } + .#{prefix}-totals__line { + display: flex; + padding: 4px 0; + } + .#{prefix}-totals__line--gray-border { + border-bottom: 1px solid #DADADA; + } + .#{prefix}-totals__line--dark-border { + border-bottom: 1px solid #000; + } + .#{prefix}-totals__line__label { + min-width: 160px; + } + .#{prefix}-totals__line__amount { + flex: 1 1 auto; + text-align: right; + } + .#{prefix}-statement { + margin-bottom: 20px; + } + .#{prefix}-statement__label {} + .#{prefix}-statement__value {} block content - div.receipt - div.receipt__header - div.paper - h1.title #{__("receipt.paper.receipt")} - span.receiptNumber #{saleReceipt.receiptNumber} + //- block head + div(class=`${prefix}-root`, style=`--invoice-primary-color: ${primaryColor}; --invoice-secondary-color: ${secondaryColor};`) + + //- Title and company logo + h1(class=`${prefix}-big-title`) Receipt - div.organization - h3.title #{organizationName} + if showCompanyLogo + div(class=`${prefix}-logo-wrap`) + img(src=companyLogo alt=`Company Logo`) - div.receipt__receipt-amount - div.label #{__('receipt.paper.receipt_amount')} - div.amount #{saleReceipt.formattedAmount} + //- Terms List + div(class=`${prefix}-terms-list`) + if showReceiptNumber + div(class=`${prefix}-terms-item`) + span(class=`${prefix}-terms-item__label`)= receiptNumberLabel + span(class=`${prefix}-terms-item__value`)= receiptNumber + if showReceiptDate + div(class=`${prefix}-terms-item`) + span(class=`${prefix}-terms-item__label`)= receiptDateLabel + span(class=`${prefix}-terms-item__value`)= receiptDate - div.receipt__meta - div.receipt__meta-item.receipt__meta-item--billed-to - span.label #{__("receipt.paper.billed_to")} - span.value #{saleReceipt.customer.displayName} + //- Address Section + div(class=`${prefix}-address-section`) + if showBilledFromAddress + div(class=`${prefix}-address`) + strong= companyName + each addressLine in billedFromAddress + div= addressLine - div.receipt__meta-item.receipt__meta-item--invoice-date - span.label #{__("receipt.paper.receipt_date")} - span.value #{saleReceipt.formattedReceiptDate} + if showBilledToAddress + div(class=`${prefix}-address`) + strong= billedToLabel + each addressLine in billedToAddress + div= addressLine - if saleReceipt.receiptNumber - div.receipt__meta-item.receipt__meta-item--invoice-number - span.label #{__("receipt.paper.receipt_number")} - span.value #{saleReceipt.receiptNumber} + //- Table Section + table(class=`${prefix}-table`) + thead(class=`${prefix}-table__header`) + tr + th(class=`${prefix}-table__header`) Item + th(class=`${prefix}-table__header`) Description + th(class=`${prefix}-table__header ${prefix}-table__header--right`) Rate + th(class=`${prefix}-table__header ${prefix}-table__header--right`) Total + tbody + each line in lines + tr(class=`${prefix}-table__row`) + td(class=`${prefix}-table__cell`)= line.item + td(class=`${prefix}-table__cell`)= line.description + td(class=`${prefix}-table__cell${prefix}-table__cell--right`)= line.rate + td(class=`${prefix}-table__cell${prefix}-table__cell--right`)= line.total - div.receipt__table - table - thead - tr - th.item #{__("item_entry.paper.item_name")} - th.rate #{__("item_entry.paper.rate")} - th.quantity #{__("item_entry.paper.quantity")} - th.total #{__("item_entry.paper.total")} - tbody - each entry in saleReceipt.entries - tr - td.item=entry.item.name - td.rate=entry.rate - td.quantity=entry.quantity - td.total=entry.amount - - div.receipt__table-after - div.receipt__table-total - table - tbody - tr.total - td #{__('receipt.paper.total')} - td #{saleReceipt.formattedAmount} - tr.payment-amount - td #{__('receipt.paper.payment_amount')} - td #{saleReceipt.formattedAmount} - tr.blanace-due - td #{__('receipt.paper.balance_due')} - td #{'$0'} + //- Totals Section + div(class=`${prefix}-totals`) + if showSubtotal + div(class=`${prefix}-totals__line ${prefix}-totals__line--gray-border`) + span(class=`${prefix}-totals__line__label`)= subtotalLabel + span(class=`${prefix}-totals__line__amount`)= subtotal - div.receipt__footer - if saleReceipt.statement - div.receipt__conditions - h3 #{__("receipt.paper.statement")} - p #{saleReceipt.statement} + if showTotal + div(class=`${prefix}-totals__line ${prefix}-totals__line--dark-border`) + span(class=`${prefix}-totals__line__label`)= totalLabel + span(class=`${prefix}-totals__line__amount`)= total - if saleReceipt.receiptMessage - div.receipt__notes - h3 #{__("receipt.paper.notes")} - p #{saleReceipt.receiptMessage} \ No newline at end of file + //- Customer Note Section + if showCustomerNote + div(class=`${prefix}-statement`) + div(class=`${prefix}-statement__label`)= customerNoteLabel + div(class=`${prefix}-statement__value`)= customerNote + + //- Terms & Conditions Section + if showTermsConditions + div(class=`${prefix}-statement`) + div(class=`${prefix}-statement__label`)= termsConditionsLabel + div(class=`${prefix}-statement__value`)= termsConditions diff --git a/packages/server/src/api/controllers/PdfTemplates/PdfTemplatesController.ts b/packages/server/src/api/controllers/PdfTemplates/PdfTemplatesController.ts new file mode 100644 index 000000000..c3758250d --- /dev/null +++ b/packages/server/src/api/controllers/PdfTemplates/PdfTemplatesController.ts @@ -0,0 +1,178 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { check, param, query } from 'express-validator'; +import { Service, Inject } from 'typedi'; +import BaseController from '@/api/controllers/BaseController'; +import { PdfTemplateApplication } from '@/services/PdfTemplate/PdfTemplateApplication'; + +@Service() +export class PdfTemplatesController extends BaseController { + @Inject() + public pdfTemplateApplication: PdfTemplateApplication; + + /** + * Router constructor method. + */ + public router() { + const router = Router(); + + router.delete( + '/:template_id', + [param('template_id').exists().isInt().toInt()], + this.validationResult, + this.deletePdfTemplate.bind(this) + ); + router.post( + '/:template_id', + [ + param('template_id').exists().isInt().toInt(), + check('template_name').exists(), + check('attributes').exists(), + ], + this.validationResult, + this.editPdfTemplate.bind(this) + ); + router.get( + '/', + [query('resource').optional()], + this.validationResult, + this.getPdfTemplates.bind(this) + ); + router.get( + '/:template_id', + [param('template_id').exists().isInt().toInt()], + this.validationResult, + this.getPdfTemplate.bind(this) + ); + router.post( + '/', + [ + check('template_name').exists(), + check('resource').exists(), + check('attributes').exists(), + ], + this.validationResult, + this.createPdfInvoiceTemplate.bind(this) + ); + router.post( + '/:template_id/assign_default', + [param('template_id').exists().isInt().toInt()], + this.validationResult, + this.assginPdfTemplateAsDefault.bind(this) + ); + return router; + } + + async createPdfInvoiceTemplate( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { templateName, resource, attributes } = this.matchedBodyData(req); + + try { + const result = await this.pdfTemplateApplication.createPdfTemplate( + tenantId, + templateName, + resource, + attributes + ); + return res.status(201).send({ + id: result.id, + message: 'The PDF template has been created successfully.', + }); + } catch (error) { + next(error); + } + } + + async editPdfTemplate(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { template_id: templateId } = req.params; + const editTemplateDTO = this.matchedBodyData(req); + + try { + const result = await this.pdfTemplateApplication.editPdfTemplate( + tenantId, + Number(templateId), + editTemplateDTO + ); + return res.status(200).send({ + id: result.id, + message: 'The PDF template has been updated successfully.', + }); + } catch (error) { + next(error); + } + } + + async deletePdfTemplate(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { template_id: templateId } = req.params; + + try { + await this.pdfTemplateApplication.deletePdfTemplate( + tenantId, + Number(templateId) + ); + return res.status(204).send({ + id: templateId, + message: 'The PDF template has been deleted successfully.', + }); + } catch (error) { + next(error); + } + } + + async getPdfTemplate(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { template_id: templateId } = req.params; + + try { + const template = await this.pdfTemplateApplication.getPdfTemplate( + tenantId, + Number(templateId) + ); + return res.status(200).send(template); + } catch (error) { + next(error); + } + } + + async getPdfTemplates(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const query = this.matchedQueryData(req); + + try { + const templates = await this.pdfTemplateApplication.getPdfTemplates( + tenantId, + query + ); + return res.status(200).send(templates); + } catch (error) { + next(error); + } + } + + async assginPdfTemplateAsDefault( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { template_id: templateId } = req.params; + + try { + await this.pdfTemplateApplication.assignPdfTemplateAsDefault( + tenantId, + Number(templateId) + ); + return res.status(204).send({ + id: templateId, + message: 'The given pdf template has been assigned as default template', + }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Sales/CreditNotes.ts b/packages/server/src/api/controllers/Sales/CreditNotes.ts index 95d18b553..e26c26a0d 100644 --- a/packages/server/src/api/controllers/Sales/CreditNotes.ts +++ b/packages/server/src/api/controllers/Sales/CreditNotes.ts @@ -236,6 +236,9 @@ export default class PaymentReceivesController extends BaseController { check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), + + // Pdf template id. + check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(), ]; } diff --git a/packages/server/src/api/controllers/Sales/PaymentReceives.ts b/packages/server/src/api/controllers/Sales/PaymentReceives.ts index 10cbb0c28..2adeb1a84 100644 --- a/packages/server/src/api/controllers/Sales/PaymentReceives.ts +++ b/packages/server/src/api/controllers/Sales/PaymentReceives.ts @@ -167,6 +167,9 @@ export default class PaymentReceivesController extends BaseController { check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), + + // Pdf template id. + check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(), ]; } diff --git a/packages/server/src/api/controllers/Sales/SalesEstimates.ts b/packages/server/src/api/controllers/Sales/SalesEstimates.ts index b1808006f..c19632ce1 100644 --- a/packages/server/src/api/controllers/Sales/SalesEstimates.ts +++ b/packages/server/src/api/controllers/Sales/SalesEstimates.ts @@ -168,9 +168,7 @@ export default class SalesEstimatesController extends BaseController { check('entries.*.item_id').exists().isNumeric().toInt(), check('entries.*.quantity').exists().isNumeric().toInt(), check('entries.*.rate').exists().isNumeric().toFloat(), - check('entries.*.description') - .optional({ nullable: true }) - .trim(), + check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.discount') .optional({ nullable: true }) .isNumeric() @@ -186,6 +184,9 @@ export default class SalesEstimatesController extends BaseController { check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), + + // Pdf template id. + check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(), ]; } diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index 0b2bc948f..012b7f041 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -224,9 +224,7 @@ export default class SaleInvoicesController extends BaseController { .optional({ nullable: true }) .isNumeric() .toFloat(), - check('entries.*.description') - .optional({ nullable: true }) - .trim(), + check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.tax_code') .optional({ nullable: true }) .trim() @@ -257,6 +255,9 @@ export default class SaleInvoicesController extends BaseController { .optional({ nullable: true }) .isNumeric() .toFloat(), + + // Pdf template id. + check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(), ]; } diff --git a/packages/server/src/api/controllers/Sales/SalesReceipts.ts b/packages/server/src/api/controllers/Sales/SalesReceipts.ts index ba2376568..5330c5d26 100644 --- a/packages/server/src/api/controllers/Sales/SalesReceipts.ts +++ b/packages/server/src/api/controllers/Sales/SalesReceipts.ts @@ -148,17 +148,20 @@ export default class SalesReceiptsController extends BaseController { .optional({ nullable: true }) .isNumeric() .toInt(), - check('entries.*.description') - .optional({ nullable: true }) - .trim(), + check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.warehouse_id') .optional({ nullable: true }) .isNumeric() .toInt(), + check('receipt_message').optional().trim(), + check('statement').optional().trim(), check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), + + // Pdf template id. + check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(), ]; } diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 2eed58224..b55694d68 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -67,6 +67,7 @@ import { OneClickDemoController } from './controllers/OneClickDemo/OneClickDemoC import { StripeIntegrationController } from './controllers/StripeIntegration/StripeIntegrationController'; import { ShareLinkController } from './controllers/ShareLink/ShareLinkController'; import { PublicSharableLinkController } from './controllers/ShareLink/PublicSharableLinkController'; +import { PdfTemplatesController } from './controllers/PdfTemplates/PdfTemplatesController'; export default () => { const app = Router(); @@ -155,6 +156,11 @@ export default () => { '/stripe_integration', Container.get(StripeIntegrationController).router() ); + dashboard.use( + '/pdf-templates', + Container.get(PdfTemplatesController).router() + ); + dashboard.use('/', Container.get(ProjectTasksController).router()); dashboard.use('/', Container.get(ProjectTimesController).router()); dashboard.use('/', Container.get(WarehousesItemController).router()); diff --git a/packages/server/src/constants/event-tracker.ts b/packages/server/src/constants/event-tracker.ts index 3fafc76a6..d5d21a508 100644 --- a/packages/server/src/constants/event-tracker.ts +++ b/packages/server/src/constants/event-tracker.ts @@ -1,5 +1,5 @@ export const SALE_INVOICE_CREATED = 'Sale invoice created'; -export const SALE_INVOICE_EDITED = 'Sale invoice d'; +export const SALE_INVOICE_EDITED = 'Sale invoice edited'; export const SALE_INVOICE_DELETED = 'Sale invoice deleted'; export const SALE_INVOICE_MAIL_DELIVERED = 'Sale invoice mail delivered'; diff --git a/packages/server/src/database/migrations/20240911112147_create_pdf_templates_table.js b/packages/server/src/database/migrations/20240911112147_create_pdf_templates_table.js new file mode 100644 index 000000000..be880c262 --- /dev/null +++ b/packages/server/src/database/migrations/20240911112147_create_pdf_templates_table.js @@ -0,0 +1,75 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .createTable('pdf_templates', (table) => { + table.increments('id').primary(); + table.text('resource'); + table.text('template_name'); + table.json('attributes'); + table.boolean('predefined').defaultTo(false); + table.boolean('default').defaultTo(false); + table.timestamps(); + }) + .table('sales_invoices', (table) => { + table + .integer('pdf_template_id') + .unsigned() + .references('id') + .inTable('pdf_templates'); + }) + .table('sales_estimates', (table) => { + table + .integer('pdf_template_id') + .unsigned() + .references('id') + .inTable('pdf_templates'); + }) + .table('sales_receipts', (table) => { + table + .integer('pdf_template_id') + .unsigned() + .references('id') + .inTable('pdf_templates'); + }) + .table('credit_notes', (table) => { + table + .integer('pdf_template_id') + .unsigned() + .references('id') + .inTable('pdf_templates'); + }) + .table('payment_receives', (table) => { + table + .integer('pdf_template_id') + .unsigned() + .references('id') + .inTable('pdf_templates'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema + .table('payment_receives', (table) => { + table.dropColumn('pdf_template_id'); + }) + .table('credit_notes', (table) => { + table.dropColumn('pdf_template_id'); + }) + .table('sales_receipts', (table) => { + table.dropColumn('pdf_template_id'); + }) + .table('sales_estimates', (table) => { + table.dropColumn('pdf_template_id'); + }) + .table('sales_invoices', (table) => { + table.dropColumn('pdf_template_id'); + }) + .dropTableIfExists('pdf_templates'); +}; diff --git a/packages/server/src/database/migrations/20240915195024_seed_standard_pdf_templates.js b/packages/server/src/database/migrations/20240915195024_seed_standard_pdf_templates.js new file mode 100644 index 000000000..74c87bb14 --- /dev/null +++ b/packages/server/src/database/migrations/20240915195024_seed_standard_pdf_templates.js @@ -0,0 +1,44 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex('pdf_templates').insert([ + { + resource: 'SaleInvoice', + templateName: 'Standard Template', + predefined: true, + default: true, + }, + { + resource: 'SaleEstimate', + templateName: 'Standard Template', + predefined: true, + default: true, + }, + { + resource: 'SaleReceipt', + templateName: 'Standard Template', + predefined: true, + default: true, + }, + { + resource: 'CreditNote', + templateName: 'Standard Template', + predefined: true, + default: true, + }, + { + resource: 'PaymentReceive', + templateName: 'Standard Template', + predefined: true, + default: true, + }, + ]); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) {}; diff --git a/packages/server/src/interfaces/CreditNote.ts b/packages/server/src/interfaces/CreditNote.ts index 22ff521d7..ffb4c26ba 100644 --- a/packages/server/src/interfaces/CreditNote.ts +++ b/packages/server/src/interfaces/CreditNote.ts @@ -62,6 +62,8 @@ export interface ICreditNote { branchId?: number; warehouseId: number; createdAt?: Date; + termsConditions: string; + note: string; } export enum CreditNoteAction { @@ -258,3 +260,49 @@ export type ICreditNoteGLCommonEntry = Pick< | 'debit' | 'branchId' >; + +export interface CreditNotePdfTemplateAttributes { + primaryColor: string; + secondaryColor: string; + showCompanyLogo: boolean; + companyLogo: string; + companyName: string; + + billedToAddress: string[]; + billedFromAddress: string[]; + showBilledToAddress: boolean; + showBilledFromAddress: boolean; + billedToLabel: string; + + total: string; + totalLabel: string; + showTotal: boolean; + + subtotal: string; + subtotalLabel: string; + showSubtotal: boolean; + + showCustomerNote: boolean; + customerNote: string; + customerNoteLabel: string; + + showTermsConditions: boolean; + termsConditions: string; + termsConditionsLabel: string; + + lines: Array<{ + item: string; + description: string; + rate: string; + quantity: string; + total: string; + }>; + + showCreditNoteNumber: boolean; + creditNoteNumberLabel: string; + creditNoteNumebr: string; + + creditNoteDate: string; + showCreditNoteDate: boolean; + creditNoteDateLabel: string; +} diff --git a/packages/server/src/interfaces/PaymentReceive.ts b/packages/server/src/interfaces/PaymentReceive.ts index ea90e58af..8dd880872 100644 --- a/packages/server/src/interfaces/PaymentReceive.ts +++ b/packages/server/src/interfaces/PaymentReceive.ts @@ -25,6 +25,7 @@ export interface IPaymentReceived { updatedAt: Date; localAmount?: number; branchId?: number; + pdfTemplateId?: number; } export interface IPaymentReceivedCreateDTO { customerId: number; @@ -185,3 +186,52 @@ export interface PaymentReceiveMailPresendEvent { paymentReceiveId: number; messageOptions: PaymentReceiveMailOptsDTO; } + +export interface PaymentReceivedPdfLineItem { + item: string; + description: string; + rate: string; + quantity: string; + total: string; +} + +export interface PaymentReceivedPdfTax { + label: string; + amount: string; +} + +export interface PaymentReceivedPdfTemplateAttributes { + primaryColor: string; + secondaryColor: string; + showCompanyLogo: boolean; + companyLogo: string; + companyName: string; + + billedToAddress: string[]; + billedFromAddress: string[]; + showBilledFromAddress: boolean; + showBillingToAddress: boolean; + billedToLabel: string; + + total: string; + totalLabel: string; + showTotal: boolean; + + subtotal: string; + subtotalLabel: string; + showSubtotal: boolean; + + lines: Array<{ + invoiceNumber: string; + invoiceAmount: string; + paidAmount: string; + }>; + + showPaymentReceivedNumber: boolean; + paymentReceivedNumberLabel: string; + paymentReceivedNumebr: string; + + paymentReceivedDate: string; + showPaymentReceivedDate: boolean; + paymentReceivedDateLabel: string; +} diff --git a/packages/server/src/interfaces/SaleEstimate.ts b/packages/server/src/interfaces/SaleEstimate.ts index bc6231fc4..f53693b52 100644 --- a/packages/server/src/interfaces/SaleEstimate.ts +++ b/packages/server/src/interfaces/SaleEstimate.ts @@ -143,3 +143,4 @@ export interface ISaleEstimateMailPresendEvent { saleEstimateId: number; messageOptions: SaleEstimateMailOptionsDTO; } + diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index 03827d229..4493782b8 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -45,6 +45,11 @@ export interface ISaleInvoice { subtotal: number; subtotalLocal: number; subtotalExludingTax: number; + + termsConditions: string; + invoiceMessage: string; + + pdfTemplateId?: number; } export interface ISaleInvoiceDTO { @@ -217,3 +222,83 @@ export interface ISaleInvoiceMailSent { saleInvoiceId: number; messageOptions: SendInvoiceMailDTO; } + + +// Invoice Pdf Document +export interface InvoicePdfLine { + item: string; + description: string; + rate: string; + quantity: string; + total: string; +} + +export interface InvoicePdfTax { + label: string; + amount: string; +} + +export interface InvoicePdfTemplateAttributes { + primaryColor: string; + secondaryColor: string; + + companyName: string; + + showCompanyLogo: boolean; + companyLogo: string; + + dueDate: string; + dueDateLabel: string; + showDueDate: boolean; + + dateIssue: string; + dateIssueLabel: string; + showDateIssue: boolean; + + invoiceNumberLabel: string; + invoiceNumber: string; + showInvoiceNumber: boolean; + + showBillingToAddress: boolean; + showBilledFromAddress: boolean; + billedToLabel: string; + + lineItemLabel: string; + lineDescriptionLabel: string; + lineRateLabel: string; + lineTotalLabel: string; + + totalLabel: string; + subtotalLabel: string; + discountLabel: string; + paymentMadeLabel: string; + balanceDueLabel: string; + + showTotal: boolean; + showSubtotal: boolean; + showDiscount: boolean; + showTaxes: boolean; + showPaymentMade: boolean; + showDueAmount: boolean; + showBalanceDue: boolean; + + total: string; + subtotal: string; + discount: string; + paymentMade: string; + balanceDue: string; + + termsConditionsLabel: string; + showTermsConditions: boolean; + termsConditions: string; + + lines: InvoicePdfLine[]; + taxes: InvoicePdfTax[]; + + statementLabel: string; + showStatement: boolean; + statement: string; + + billedToAddress: string[]; + billedFromAddres: string[]; +} \ No newline at end of file diff --git a/packages/server/src/interfaces/SaleReceipt.ts b/packages/server/src/interfaces/SaleReceipt.ts index 14f451dda..d3e458313 100644 --- a/packages/server/src/interfaces/SaleReceipt.ts +++ b/packages/server/src/interfaces/SaleReceipt.ts @@ -155,3 +155,57 @@ export interface ISaleReceiptMailPresend { saleReceiptId: number; messageOptions: SaleReceiptMailOptsDTO; } + +export interface ISaleReceiptBrandingTemplateAttributes { + primaryColor: string; + secondaryColor: string; + showCompanyLogo: boolean; + companyLogo: string; + companyName: string; + + // Address + billedToAddress: string[]; + billedFromAddress: string[]; + showBilledFromAddress: boolean; + showBilledToAddress: boolean; + billedToLabel: string; + + // Total + total: string; + totalLabel: string; + showTotal: boolean; + + // Subtotal + subtotal: string; + subtotalLabel: string; + showSubtotal: boolean; + + // Customer Note + showCustomerNote: boolean; + customerNote: string; + customerNoteLabel: string; + + // Terms & Conditions + showTermsConditions: boolean; + termsConditions: string; + termsConditionsLabel: string; + + // Lines + lines: Array<{ + item: string; + description: string; + rate: string; + quantity: string; + total: string; + }>; + + // Receipt Number + showReceiptNumber: boolean; + receiptNumberLabel: string; + receiptNumebr: string; + + // Receipt Date + receiptDate: string; + showReceiptDate: boolean; + receiptDateLabel: string; +} diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 02877491a..3d349f81e 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -68,6 +68,7 @@ import { BankRule } from '@/models/BankRule'; import { BankRuleCondition } from '@/models/BankRuleCondition'; import { RecognizedBankTransaction } from '@/models/RecognizedBankTransaction'; import { MatchedBankTransaction } from '@/models/MatchedBankTransaction'; +import { PdfTemplate } from '@/models/PdfTemplate'; export default (knex) => { const models = { @@ -139,6 +140,7 @@ export default (knex) => { BankRuleCondition, RecognizedBankTransaction, MatchedBankTransaction, + PdfTemplate }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/PdfTemplate.ts b/packages/server/src/models/PdfTemplate.ts new file mode 100644 index 000000000..91920a4dd --- /dev/null +++ b/packages/server/src/models/PdfTemplate.ts @@ -0,0 +1,45 @@ +import TenantModel from 'models/TenantModel'; + +export class PdfTemplate extends TenantModel { + /** + * Table name. + */ + static get tableName() { + return 'pdf_templates'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Json schema. + */ + static get jsonSchema() { + return { + type: 'object', + properties: { + id: { type: 'integer' }, + templateName: { type: 'string' }, + attributes: { type: 'object' }, // JSON field definition + }, + }; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + return {}; + } +} diff --git a/packages/server/src/services/ChromiumlyTenancy/ChromiumlyTenancy.ts b/packages/server/src/services/ChromiumlyTenancy/ChromiumlyTenancy.ts index 21df50a53..3e8a37daf 100644 --- a/packages/server/src/services/ChromiumlyTenancy/ChromiumlyTenancy.ts +++ b/packages/server/src/services/ChromiumlyTenancy/ChromiumlyTenancy.ts @@ -20,6 +20,10 @@ export class ChromiumlyTenancy { properties?: PageProperties, pdfFormat?: PdfFormat ) { - return this.htmlConvert.convert(tenantId, content, properties, pdfFormat); + const parsedProperties = { + margins: { top: 0, bottom: 0, left: 0, right: 0 }, + ...properties, + } + return this.htmlConvert.convert(tenantId, content, parsedProperties, pdfFormat); } } diff --git a/packages/server/src/services/CreditNotes/CreateCreditNote.ts b/packages/server/src/services/CreditNotes/CreateCreditNote.ts index c47587fbe..d48a1371f 100644 --- a/packages/server/src/services/CreditNotes/CreateCreditNote.ts +++ b/packages/server/src/services/CreditNotes/CreateCreditNote.ts @@ -59,7 +59,7 @@ export default class CreateCreditNote extends BaseCreditNotes { creditNoteDTO.entries ); // Transformes the given DTO to storage layer data. - const creditNoteModel = this.transformCreateEditDTOToModel( + const creditNoteModel = await this.transformCreateEditDTOToModel( tenantId, creditNoteDTO, customer.currencyCode diff --git a/packages/server/src/services/CreditNotes/CreditNoteBrandingTemplate.ts b/packages/server/src/services/CreditNotes/CreditNoteBrandingTemplate.ts new file mode 100644 index 000000000..1b35dc29b --- /dev/null +++ b/packages/server/src/services/CreditNotes/CreditNoteBrandingTemplate.ts @@ -0,0 +1,30 @@ +import { Inject } from "typedi"; +import { GetPdfTemplate } from "../PdfTemplate/GetPdfTemplate"; +import { defaultCreditNoteBrandingAttributes } from "./constants"; +import { mergePdfTemplateWithDefaultAttributes } from "../Sales/Invoices/utils"; + +export class CreditNoteBrandingTemplate { + @Inject() + private getPdfTemplateService: GetPdfTemplate; + + /** + * Retrieves the credit note branding template. + * @param {number} tenantId + * @param {number} templateId + * @returns {} + */ + public async getCreditNoteBrandingTemplate(tenantId: number, templateId: number) { + const template = await this.getPdfTemplateService.getPdfTemplate( + tenantId, + templateId + ); + const attributes = mergePdfTemplateWithDefaultAttributes( + template.attributes, + defaultCreditNoteBrandingAttributes + ); + return { + ...template, + attributes, + }; + } +} diff --git a/packages/server/src/services/CreditNotes/CreditNotes.ts b/packages/server/src/services/CreditNotes/CreditNotes.ts index 414be0e2a..5f3a57a0d 100644 --- a/packages/server/src/services/CreditNotes/CreditNotes.ts +++ b/packages/server/src/services/CreditNotes/CreditNotes.ts @@ -2,6 +2,7 @@ import { Service, Inject } from 'typedi'; import moment from 'moment'; import { omit } from 'lodash'; import * as R from 'ramda'; +import composeAsync from 'async/compose'; import { ServiceError } from '@/exceptions'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { ERRORS } from './constants'; @@ -16,6 +17,7 @@ import AutoIncrementOrdersService from '@/services/Sales/AutoIncrementOrdersServ import { WarehouseTransactionDTOTransform } from '@/services/Warehouses/Integrations/WarehouseTransactionDTOTransform'; import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform'; import { assocItemEntriesDefaultIndex } from '../Items/utils'; +import { BrandingTemplateDTOTransformer } from '../PdfTemplate/BrandingTemplateDTOTransformer'; @Service() export default class BaseCreditNotes { @@ -34,17 +36,20 @@ export default class BaseCreditNotes { @Inject() private warehouseDTOTransform: WarehouseTransactionDTOTransform; + @Inject() + private brandingTemplatesTransformer: BrandingTemplateDTOTransformer; + /** * Transformes the credit/edit DTO to model. * @param {ICreditNoteNewDTO | ICreditNoteEditDTO} creditNoteDTO * @param {string} customerCurrencyCode - */ - protected transformCreateEditDTOToModel = ( + protected transformCreateEditDTOToModel = async ( tenantId: number, creditNoteDTO: ICreditNoteNewDTO | ICreditNoteEditDTO, customerCurrencyCode: string, oldCreditNote?: ICreditNote - ): ICreditNote => { + ): Promise => { // Retrieve the total amount of the given items entries. const amount = this.itemsEntriesService.getTotalItemsEntries( creditNoteDTO.entries @@ -83,10 +88,18 @@ export default class BaseCreditNotes { refundedAmount: 0, invoicesAmount: 0, }; + const initialAsyncDTO = await composeAsync( + // Assigns the default branding template id to the invoice DTO. + this.brandingTemplatesTransformer.assocDefaultBrandingTemplate( + tenantId, + 'CreditNote' + ) + )(initialDTO); + return R.compose( this.branchDTOTransform.transformDTO(tenantId), this.warehouseDTOTransform.transformDTO(tenantId) - )(initialDTO); + )(initialAsyncDTO); }; /** diff --git a/packages/server/src/services/CreditNotes/EditCreditNote.ts b/packages/server/src/services/CreditNotes/EditCreditNote.ts index 0e045227d..cbeed9ab2 100644 --- a/packages/server/src/services/CreditNotes/EditCreditNote.ts +++ b/packages/server/src/services/CreditNotes/EditCreditNote.ts @@ -63,7 +63,7 @@ export default class EditCreditNote extends BaseCreditNotes { creditNoteEditDTO.entries ); // Transformes the given DTO to storage layer data. - const creditNoteModel = this.transformCreateEditDTOToModel( + const creditNoteModel = await this.transformCreateEditDTOToModel( tenantId, creditNoteEditDTO, customer.currencyCode, diff --git a/packages/server/src/services/CreditNotes/GetCreditNotePdf.ts b/packages/server/src/services/CreditNotes/GetCreditNotePdf.ts index c54c8096e..5a1ea0faf 100644 --- a/packages/server/src/services/CreditNotes/GetCreditNotePdf.ts +++ b/packages/server/src/services/CreditNotes/GetCreditNotePdf.ts @@ -2,9 +2,16 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '../ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '../TemplateInjectable/TemplateInjectable'; import GetCreditNote from './GetCreditNote'; +import { CreditNoteBrandingTemplate } from './CreditNoteBrandingTemplate'; +import { CreditNotePdfTemplateAttributes } from '@/interfaces'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { transformCreditNoteToPdfTemplate } from './utils'; @Service() export default class GetCreditNotePdf { + @Inject() + private tenancy: HasTenancyService; + @Inject() private chromiumlyTenancy: ChromiumlyTenancy; @@ -14,25 +21,62 @@ export default class GetCreditNotePdf { @Inject() private getCreditNoteService: GetCreditNote; + @Inject() + private creditNoteBrandingTemplate: CreditNoteBrandingTemplate; + /** - * Retrieve sale invoice pdf content. + * Retrieves sale invoice pdf content. * @param {number} tenantId - Tenant id. * @param {number} creditNoteId - Credit note id. */ public async getCreditNotePdf(tenantId: number, creditNoteId: number) { + const brandingAttributes = await this.getCreditNoteBrandingAttributes( + tenantId, + creditNoteId + ); + console.log(brandingAttributes, 'brandingAttributes'); + + const htmlContent = await this.templateInjectable.render( + tenantId, + 'modules/credit-note-standard', + brandingAttributes + ); + return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent); + } + + /** + * Retrieves credit note branding attributes. + * @param {number} tenantId - The ID of the tenant. + * @param {number} creditNoteId - The ID of the credit note. + * @returns {Promise} The credit note branding attributes. + */ + public async getCreditNoteBrandingAttributes( + tenantId: number, + creditNoteId: number + ): Promise { + const { PdfTemplate } = this.tenancy.models(tenantId); const creditNote = await this.getCreditNoteService.getCreditNote( tenantId, creditNoteId ); - const htmlContent = await this.templateInjectable.render( - tenantId, - 'modules/credit-note-standard', - { - creditNote, - } - ); - return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, { - margins: { top: 0, bottom: 0, left: 0, right: 0 }, - }); + // Retrieve the invoice template id of not found get the default template id. + const templateId = + creditNote.pdfTemplateId ?? + ( + await PdfTemplate.query().findOne({ + resource: 'CreditNote', + default: true, + }) + )?.id; + // Retrieves the credit note branding template. + const brandingTemplate = + await this.creditNoteBrandingTemplate.getCreditNoteBrandingTemplate( + tenantId, + templateId + ); + return { + ...brandingTemplate.attributes, + ...transformCreditNoteToPdfTemplate(creditNote), + }; } } diff --git a/packages/server/src/services/CreditNotes/constants.ts b/packages/server/src/services/CreditNotes/constants.ts index 9d0060075..9691a4b77 100644 --- a/packages/server/src/services/CreditNotes/constants.ts +++ b/packages/server/src/services/CreditNotes/constants.ts @@ -9,7 +9,7 @@ export const ERRORS = { 'CREDIT_NOTE_APPLY_TO_INVOICES_NOT_FOUND', CREDIT_NOTE_HAS_REFUNDS_TRANSACTIONS: 'CREDIT_NOTE_HAS_REFUNDS_TRANSACTIONS', CREDIT_NOTE_HAS_APPLIED_INVOICES: 'CREDIT_NOTE_HAS_APPLIED_INVOICES', - CUSTOMER_HAS_LINKED_CREDIT_NOTES: 'CUSTOMER_HAS_LINKED_CREDIT_NOTES' + CUSTOMER_HAS_LINKED_CREDIT_NOTES: 'CUSTOMER_HAS_LINKED_CREDIT_NOTES', }; export const DEFAULT_VIEW_COLUMNS = []; @@ -66,3 +66,72 @@ export const DEFAULT_VIEWS = [ columns: DEFAULT_VIEW_COLUMNS, }, ]; + +export const defaultCreditNoteBrandingAttributes = { + primaryColor: '', + secondaryColor: '', + showCompanyLogo: true, + companyLogo: '', + companyName: 'Bigcapital Technology, Inc.', + + // Address + billedToAddress: [ + 'Bigcapital Technology, Inc.', + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], + billedFromAddress: [ + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], + showBilledToAddress: true, + showBilledFromAddress: true, + billedToLabel: 'Billed To', + + // Total + total: '$1000.00', + totalLabel: 'Total', + showTotal: true, + + // Subtotal + subtotal: '1000/00', + subtotalLabel: 'Subtotal', + showSubtotal: true, + + // Customer note + showCustomerNote: true, + customerNote: + 'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.', + customerNoteLabel: 'Customer Note', + + // Terms & conditions + showTermsConditions: true, + termsConditions: + 'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.', + termsConditionsLabel: 'Terms & Conditions', + + lines: [ + { + item: 'Simply dummy text', + description: 'Simply dummy text of the printing and typesetting', + rate: '1', + quantity: '1000', + total: '$1000.00', + }, + ], + // Credit note number. + showCreditNoteNumber: true, + creditNoteNumberLabel: 'Credit Note Number', + creditNoteNumebr: '346D3D40-0001', + + // Credit note date. + creditNoteDate: 'September 3, 2024', + showCreditNoteDate: true, + creditNoteDateLabel: 'Credit Note Date', +}; diff --git a/packages/server/src/services/CreditNotes/utils.ts b/packages/server/src/services/CreditNotes/utils.ts new file mode 100644 index 000000000..6b94d955e --- /dev/null +++ b/packages/server/src/services/CreditNotes/utils.ts @@ -0,0 +1,23 @@ +import { CreditNotePdfTemplateAttributes, ICreditNote } from '@/interfaces'; + +export const transformCreditNoteToPdfTemplate = ( + creditNote: ICreditNote +): Partial => { + return { + creditNoteDate: creditNote.formattedCreditNoteDate, + creditNoteNumebr: creditNote.creditNoteNumber, + + total: creditNote.formattedAmount, + subtotal: creditNote.formattedSubtotal, + + lines: creditNote.entries?.map((entry) => ({ + item: entry.item.name, + description: entry.description, + rate: entry.rateFormatted, + quantity: entry.quantityFormatted, + total: entry.totalFormatted, + })), + customerNote: creditNote.note, + termsConditions: creditNote.termsConditions, + }; +}; diff --git a/packages/server/src/services/Items/ItemsEntriesService.ts b/packages/server/src/services/Items/ItemsEntriesService.ts index f307087b2..bab37c367 100644 --- a/packages/server/src/services/Items/ItemsEntriesService.ts +++ b/packages/server/src/services/Items/ItemsEntriesService.ts @@ -238,7 +238,7 @@ export default class ItemsEntriesService { * Sets the cost/sell accounts to the invoice entries. */ public setItemsEntriesDefaultAccounts(tenantId: number) { - return async (entries: IItemEntry[]) => { + return async (entries: IItemEntry[]) => { const { Item } = this.tenancy.models(tenantId); const entriesItemsIds = entries.map((e) => e.itemId); diff --git a/packages/server/src/services/PdfTemplate/AssignPdfTemplateDefault.ts b/packages/server/src/services/PdfTemplate/AssignPdfTemplateDefault.ts new file mode 100644 index 000000000..04a7c5e4f --- /dev/null +++ b/packages/server/src/services/PdfTemplate/AssignPdfTemplateDefault.ts @@ -0,0 +1,63 @@ +import { Service, Inject } from 'typedi'; +import { Knex } from 'knex'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class AssignPdfTemplateDefault { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Assigns a default PDF template for a specific tenant. + * @param {number} tenantId - The ID of the tenant for whom the default template is being assigned. + * @param {number} templateId - The ID of the template to be set as the default. + * @returns {Promise} A promise that resolves when the operation is complete. + * @throws {Error} Throws ddan error if the specified template is not found. + */ + public async assignDefaultTemplate(tenantId: number, templateId: number) { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const oldPdfTempalte = await PdfTemplate.query() + .findById(templateId) + .throwIfNotFound(); + + return this.uow.withTransaction( + tenantId, + async (trx?: Knex.Transaction) => { + // Triggers `onPdfTemplateAssigningDefault` event. + await this.eventPublisher.emitAsync( + events.pdfTemplate.onAssigningDefault, + { + tenantId, + templateId, + } + ); + await PdfTemplate.query(trx) + .where('resource', oldPdfTempalte.resource) + .patch({ default: false }); + + await PdfTemplate.query(trx) + .findById(templateId) + .patch({ default: true }); + + // Triggers `onPdfTemplateAssignedDefault` event. + await this.eventPublisher.emitAsync( + events.pdfTemplate.onAssignedDefault, + { + tenantId, + templateId, + } + ); + } + ); + } +} diff --git a/packages/server/src/services/PdfTemplate/BrandingTemplateDTOTransformer.ts b/packages/server/src/services/PdfTemplate/BrandingTemplateDTOTransformer.ts new file mode 100644 index 000000000..88aea05fb --- /dev/null +++ b/packages/server/src/services/PdfTemplate/BrandingTemplateDTOTransformer.ts @@ -0,0 +1,37 @@ +import * as R from 'ramda'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; + +@Service() +export class BrandingTemplateDTOTransformer { + @Inject() + private tenancy: HasTenancyService; + + /** + * Associates the default branding template id. + * @param {number} tenantId + * @param {string} resource + * @param {Record} object + * @param {string} attributeName + * @returns + */ + public assocDefaultBrandingTemplate = + (tenantId: number, resource: string) => + async (object: Record) => { + const { PdfTemplate } = this.tenancy.models(tenantId); + const attributeName = 'pdfTemplateId'; + + const defaultTemplate = await PdfTemplate.query().findOne({ + resource, + default: true, + }); + if (!defaultTemplate || !isEmpty(object[attributeName])) { + return object; + } + return { + ...object, + [attributeName]: defaultTemplate.id, + }; + }; +} diff --git a/packages/server/src/services/PdfTemplate/CreatePdfTemplate.ts b/packages/server/src/services/PdfTemplate/CreatePdfTemplate.ts new file mode 100644 index 000000000..a437b163c --- /dev/null +++ b/packages/server/src/services/PdfTemplate/CreatePdfTemplate.ts @@ -0,0 +1,51 @@ +import { Inject, Service } from 'typedi'; +import { ICreateInvoicePdfTemplateDTO } from './types'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class CreatePdfTemplate { + @Inject() + private tennacy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Creates a new pdf template. + * @param {number} tenantId + * @param {ICreateInvoicePdfTemplateDTO} invoiceTemplateDTO + */ + public createPdfTemplate( + tenantId: number, + templateName: string, + resource: string, + invoiceTemplateDTO: ICreateInvoicePdfTemplateDTO + ) { + const { PdfTemplate } = this.tennacy.models(tenantId); + const attributes = invoiceTemplateDTO; + + return this.uow.withTransaction(tenantId, async (trx) => { + // Triggers `onPdfTemplateCreating` event. + await this.eventPublisher.emitAsync(events.pdfTemplate.onCreating, { + tenantId, + }); + + const pdfTemplate = await PdfTemplate.query(trx).insert({ + templateName, + resource, + attributes, + }); + // Triggers `onPdfTemplateCreated` event. + await this.eventPublisher.emitAsync(events.pdfTemplate.onCreated, { + tenantId, + }); + return pdfTemplate; + }); + } +} diff --git a/packages/server/src/services/PdfTemplate/DeletePdfTemplate.ts b/packages/server/src/services/PdfTemplate/DeletePdfTemplate.ts new file mode 100644 index 000000000..246d12636 --- /dev/null +++ b/packages/server/src/services/PdfTemplate/DeletePdfTemplate.ts @@ -0,0 +1,55 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from './types'; + +@Service() +export class DeletePdfTemplate { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Deletes a pdf template. + * @param {number} tenantId + * @param {number} templateId - Pdf template id. + */ + public async deletePdfTemplate(tenantId: number, templateId: number) { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const oldPdfTemplate = await PdfTemplate.query() + .findById(templateId) + .throwIfNotFound(); + + // Cannot delete the predefined pdf templates. + if (oldPdfTemplate.predefined) { + throw new ServiceError(ERRORS.CANNOT_DELETE_PREDEFINED_PDF_TEMPLATE); + } + return this.uow.withTransaction(tenantId, async (trx) => { + // Triggers `onPdfTemplateDeleting` event. + await this.eventPublisher.emitAsync(events.pdfTemplate.onDeleting, { + tenantId, + templateId, + oldPdfTemplate, + trx, + }); + await PdfTemplate.query(trx).deleteById(templateId); + + // Triggers `onPdfTemplateDeleted` event. + await this.eventPublisher.emitAsync(events.pdfTemplate.onDeleted, { + tenantId, + templateId, + oldPdfTemplate, + trx, + }); + }); + } +} diff --git a/packages/server/src/services/PdfTemplate/EditPdfTemplate.ts b/packages/server/src/services/PdfTemplate/EditPdfTemplate.ts new file mode 100644 index 000000000..1436e4af5 --- /dev/null +++ b/packages/server/src/services/PdfTemplate/EditPdfTemplate.ts @@ -0,0 +1,58 @@ +import { Inject, Service } from 'typedi'; +import { Knex } from 'knex'; +import { IEditPdfTemplateDTO } from './types'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class EditPdfTemplate { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Edits an existing pdf template. + * @param {number} tenantId + * @param {number} templateId - Template id. + * @param {IEditPdfTemplateDTO} editTemplateDTO + */ + public async editPdfTemplate( + tenantId: number, + templateId: number, + editTemplateDTO: IEditPdfTemplateDTO + ) { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const oldPdfTemplate = await PdfTemplate.query() + .findById(templateId) + .throwIfNotFound(); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onPdfTemplateEditing` event. + await this.eventPublisher.emitAsync(events.pdfTemplate.onEditing, { + tenantId, + templateId, + }); + const pdfTemplate = await PdfTemplate.query(trx) + .where('id', templateId) + .update({ + templateName: editTemplateDTO.templateName, + attributes: editTemplateDTO.attributes, + }); + + // Triggers `onPdfTemplatedEdited` event. + await this.eventPublisher.emitAsync(events.pdfTemplate.onEdited, { + tenantId, + templateId, + }); + return pdfTemplate; + }); + } +} diff --git a/packages/server/src/services/PdfTemplate/GetPdfTemplate.ts b/packages/server/src/services/PdfTemplate/GetPdfTemplate.ts new file mode 100644 index 000000000..c41fe651d --- /dev/null +++ b/packages/server/src/services/PdfTemplate/GetPdfTemplate.ts @@ -0,0 +1,29 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; + +@Service() +export class GetPdfTemplate { + @Inject() + private tenancy: HasTenancyService; + + /** + * Retrieves a pdf template by its ID. + * @param {number} tenantId - The ID of the tenant. + * @param {number} templateId - The ID of the pdf template to retrieve. + * @return {Promise} - The retrieved pdf template. + */ + async getPdfTemplate( + tenantId: number, + templateId: number, + trx?: Knex.Transaction + ): Promise { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const template = await PdfTemplate.query(trx) + .findById(templateId) + .throwIfNotFound(); + + return template; + } +} diff --git a/packages/server/src/services/PdfTemplate/GetPdfTemplates.ts b/packages/server/src/services/PdfTemplate/GetPdfTemplates.ts new file mode 100644 index 000000000..ba6d7cdaa --- /dev/null +++ b/packages/server/src/services/PdfTemplate/GetPdfTemplates.ts @@ -0,0 +1,37 @@ +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { GetPdfTemplatesTransformer } from './GetPdfTemplatesTransformer'; +import { Inject, Service } from 'typedi'; + +@Service() +export class GetPdfTemplates { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformInjectable: TransformerInjectable; + + /** + * Retrieves a list of PDF templates for a specified tenant. + * @param {number} tenantId - The ID of the tenant for which to retrieve templates. + * @param {Object} [query] - Optional query parameters to filter the templates. + * @param {string} [query.resource] - The resource type to filter the templates by. + * @returns {Promise} - A promise that resolves to the transformed list of PDF templates. + */ + async getPdfTemplates(tenantId: number, query?: { resource?: string }) { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const templates = await PdfTemplate.query().onBuild((q) => { + if (query?.resource) { + q.where('resource', query?.resource); + } + q.orderBy('createdAt', 'ASC'); + }); + + return this.transformInjectable.transform( + tenantId, + templates, + new GetPdfTemplatesTransformer() + ); + } +} diff --git a/packages/server/src/services/PdfTemplate/GetPdfTemplatesTransformer.ts b/packages/server/src/services/PdfTemplate/GetPdfTemplatesTransformer.ts new file mode 100644 index 000000000..1b6904752 --- /dev/null +++ b/packages/server/src/services/PdfTemplate/GetPdfTemplatesTransformer.ts @@ -0,0 +1,38 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { getTransactionTypeLabel } from '@/utils/transactions-types'; + +export class GetPdfTemplatesTransformer extends Transformer { + /** + * Exclude attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['attributes']; + }; + + /** + * Includeded attributes. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return ['createdAtFormatted', 'resourceFormatted']; + }; + + /** + * Formats the creation date of the PDF template. + * @param {Object} template + * @returns {string} A formatted string representing the creation date of the template. + */ + protected createdAtFormatted = (template) => { + return this.formatDate(template.createdAt); + }; + + /** + * Formats the creation date of the PDF template. + * @param {Object} template - + * @returns {string} A formatted string representing the creation date of the template. + */ + protected resourceFormatted = (template) => { + return getTransactionTypeLabel(template.resource); + }; +} diff --git a/packages/server/src/services/PdfTemplate/PdfTemplateApplication.ts b/packages/server/src/services/PdfTemplate/PdfTemplateApplication.ts new file mode 100644 index 000000000..5f77475ff --- /dev/null +++ b/packages/server/src/services/PdfTemplate/PdfTemplateApplication.ts @@ -0,0 +1,123 @@ +import { Inject, Service } from 'typedi'; +import { ICreateInvoicePdfTemplateDTO, IEditPdfTemplateDTO } from './types'; +import { CreatePdfTemplate } from './CreatePdfTemplate'; +import { DeletePdfTemplate } from './DeletePdfTemplate'; +import { GetPdfTemplate } from './GetPdfTemplate'; +import { GetPdfTemplates } from './GetPdfTemplates'; +import { EditPdfTemplate } from './EditPdfTemplate'; +import { AssignPdfTemplateDefault } from './AssignPdfTemplateDefault'; + +@Service() +export class PdfTemplateApplication { + @Inject() + private createPdfTemplateService: CreatePdfTemplate; + + @Inject() + private deletePdfTemplateService: DeletePdfTemplate; + + @Inject() + private getPdfTemplateService: GetPdfTemplate; + + @Inject() + private getPdfTemplatesService: GetPdfTemplates; + + @Inject() + private editPdfTemplateService: EditPdfTemplate; + + @Inject() + private assignPdfTemplateDefaultService: AssignPdfTemplateDefault; + + /** + * Creates a new PDF template. + * @param {number} tenantId - + * @param {string} templateName - The name of the PDF template to create. + * @param {string} resource - The resource type associated with the PDF template. + * @param {ICreateInvoicePdfTemplateDTO} invoiceTemplateDTO - The data transfer object containing the details for the new PDF template. + * @returns {Promise} + */ + public async createPdfTemplate( + tenantId: number, + templateName: string, + resource: string, + invoiceTemplateDTO: ICreateInvoicePdfTemplateDTO + ) { + return this.createPdfTemplateService.createPdfTemplate( + tenantId, + templateName, + resource, + invoiceTemplateDTO + ); + } + + /** + * Edits an existing PDF template. + * @param {number} tenantId - The ID of the tenant. + * @param {number} templateId - The ID of the PDF template to edit. + * @param {IEditPdfTemplateDTO} editTemplateDTO - The data transfer object containing the updated details for the PDF template. + * @returns {Promise} + */ + public async editPdfTemplate( + tenantId: number, + templateId: number, + editTemplateDTO: IEditPdfTemplateDTO + ) { + return this.editPdfTemplateService.editPdfTemplate( + tenantId, + templateId, + editTemplateDTO + ); + } + + /** + * Deletes a PDF template. + * @param {number} tenantId - The ID of the tenant. + * @param {number} templateId - The ID of the PDF template to delete. + * @returns {Promise} + */ + + public async deletePdfTemplate(tenantId: number, templateId: number) { + return this.deletePdfTemplateService.deletePdfTemplate( + tenantId, + templateId + ); + } + + /** + * Retrieves a PDF template by its ID for a specified tenant. + * @param {number} tenantId - + * @param {number} templateId - The ID of the PDF template to retrieve. + * @returns {Promise} + */ + public async getPdfTemplate(tenantId: number, templateId: number) { + return this.getPdfTemplateService.getPdfTemplate(tenantId, templateId); + } + + /** + * Retrieves a list of PDF templates. + * @param {number} tenantId - The ID of the tenant for which to retrieve templates. + * @param {Object} query + * @returns {Promise} + */ + public async getPdfTemplates( + tenantId: number, + query?: { resource?: string } + ) { + return this.getPdfTemplatesService.getPdfTemplates(tenantId, query); + } + + /** + * Assigns a PDF template as the default template. + * @param {number} tenantId + * @param {number} templateId - The ID of the PDF template to assign as default. + * @returns {Promise} + */ + public async assignPdfTemplateAsDefault( + tenantId: number, + templateId: number + ) { + return this.assignPdfTemplateDefaultService.assignDefaultTemplate( + tenantId, + templateId + ); + } +} diff --git a/packages/server/src/services/PdfTemplate/types.ts b/packages/server/src/services/PdfTemplate/types.ts new file mode 100644 index 000000000..abe9345a0 --- /dev/null +++ b/packages/server/src/services/PdfTemplate/types.ts @@ -0,0 +1,67 @@ +export enum ERRORS { + CANNOT_DELETE_PREDEFINED_PDF_TEMPLATE = 'CANNOT_DELETE_PREDEFINED_PDF_TEMPLATE', +} + +export interface IEditPdfTemplateDTO { + templateName: string; + attributes: Record; +} + +export interface ICreateInvoicePdfTemplateDTO { + // Colors + primaryColor?: string; + secondaryColor?: string; + + // Company Logo + showCompanyLogo?: boolean; + companyLogo?: string; + + // Top details. + showInvoiceNumber?: boolean; + invoiceNumberLabel?: string; + + showDateIssue?: boolean; + dateIssueLabel?: string; + + showDueDate?: boolean; + dueDateLabel?: string; + + // Company name + companyName?: string; + + // Addresses + showBilledFromAddress?: boolean; + showBillingToAddress?: boolean; + billedToLabel?: string; + + // Entries + itemNameLabel?: string; + itemDescriptionLabel?: string; + itemRateLabel?: string; + itemTotalLabel?: string; + + // Totals + showSubtotal?: boolean; + subtotalLabel?: string; + + showDiscount?: boolean; + discountLabel?: string; + + showTaxes?: boolean; + + showTotal?: boolean; + totalLabel?: string; + + paymentMadeLabel?: string; + showPaymentMade?: boolean; + + dueAmountLabel?: string; + showDueAmount?: boolean; + + // Footer paragraphs. + termsConditionsLabel?: string; + showTermsConditions?: boolean; + + statementLabel?: string; + showStatement?: boolean; +} diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimateDTOTransformer.ts b/packages/server/src/services/Sales/Estimates/SaleEstimateDTOTransformer.ts index 80879af7f..32b1245bd 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimateDTOTransformer.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimateDTOTransformer.ts @@ -1,6 +1,7 @@ import * as R from 'ramda'; import { Inject, Service } from 'typedi'; import { omit, sumBy } from 'lodash'; +import composeAsync from 'async/compose'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { ICustomer, ISaleEstimate, ISaleEstimateDTO } from '@/interfaces'; import { SaleEstimateValidators } from './SaleEstimateValidators'; @@ -10,6 +11,7 @@ import { formatDateFields } from '@/utils'; import moment from 'moment'; import { SaleEstimateIncrement } from './SaleEstimateIncrement'; import { assocItemEntriesDefaultIndex } from '@/services/Items/utils'; +import { BrandingTemplateDTOTransformer } from '@/services/PdfTemplate/BrandingTemplateDTOTransformer'; @Service() export class SaleEstimateDTOTransformer { @@ -28,6 +30,9 @@ export class SaleEstimateDTOTransformer { @Inject() private estimateIncrement: SaleEstimateIncrement; + @Inject() + private brandingTemplatesTransformer: BrandingTemplateDTOTransformer; + /** * Transform create DTO object ot model object. * @param {number} tenantId @@ -81,10 +86,18 @@ export class SaleEstimateDTOTransformer { deliveredAt: moment().toMySqlDateTime(), }), }; + const initialAsyncDTO = await composeAsync( + // Assigns the default branding template id to the invoice DTO. + this.brandingTemplatesTransformer.assocDefaultBrandingTemplate( + tenantId, + 'SaleEstimate' + ) + )(initialDTO); + return R.compose( this.branchDTOTransform.transformDTO(tenantId), this.warehouseDTOTransform.transformDTO(tenantId) - )(initialDTO); + )(initialAsyncDTO); } /** diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts b/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts index af1d2098c..36e4bf8f0 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts @@ -2,9 +2,16 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { GetSaleEstimate } from './GetSaleEstimate'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { SaleEstimatePdfTemplate } from '../Invoices/SaleEstimatePdfTemplate'; +import { transformEstimateToPdfTemplate } from './utils'; +import { EstimatePdfBrandingAttributes } from './constants'; @Service() export class SaleEstimatesPdf { + @Inject() + private tenancy: HasTenancyService; + @Inject() private chromiumlyTenancy: ChromiumlyTenancy; @@ -14,25 +21,59 @@ export class SaleEstimatesPdf { @Inject() private getSaleEstimate: GetSaleEstimate; + @Inject() + private estimatePdfTemplate: SaleEstimatePdfTemplate; + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - * @param {ISaleInvoice} saleInvoice - */ public async getSaleEstimatePdf(tenantId: number, saleEstimateId: number) { - const saleEstimate = await this.getSaleEstimate.getEstimate( + const brandingAttributes = await this.getEstimateBrandingAttributes( tenantId, saleEstimateId ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/estimate-regular', - { - saleEstimate, - } + brandingAttributes ); - return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, { - margins: { top: 0, bottom: 0, left: 0, right: 0 }, - }); + return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent); + } + + /** + * Retrieves the given estimate branding attributes. + * @param {number} tenantId - Tenant id. + * @param {number} estimateId - Estimate id. + * @returns {Promise} + */ + async getEstimateBrandingAttributes( + tenantId: number, + estimateId: number + ): Promise { + const { PdfTemplate } = this.tenancy.models(tenantId); + const saleEstimate = await this.getSaleEstimate.getEstimate( + tenantId, + estimateId + ); + // Retrieve the invoice template id of not found get the default template id. + const templateId = + saleEstimate.pdfTemplateId ?? + ( + await PdfTemplate.query().findOne({ + resource: 'SaleEstimate', + default: true, + }) + )?.id; + const brandingTemplate = + await this.estimatePdfTemplate.getEstimatePdfTemplate( + tenantId, + templateId + ); + return { + ...brandingTemplate.attributes, + ...transformEstimateToPdfTemplate(saleEstimate), + }; } } diff --git a/packages/server/src/services/Sales/Estimates/constants.ts b/packages/server/src/services/Sales/Estimates/constants.ts index 1870c01d2..a5f9a9520 100644 --- a/packages/server/src/services/Sales/Estimates/constants.ts +++ b/packages/server/src/services/Sales/Estimates/constants.ts @@ -173,3 +173,122 @@ export const SaleEstimatesSampleData = [ 'Line Description': 'Qui suscipit ducimus qui qui.', }, ]; + +export const defaultEstimatePdfBrandingAttributes = { + primaryColor: '#000', + secondaryColor: '#000', + showCompanyLogo: true, + companyLogo: '', + companyName: '', + + billedToAddress: [ + 'Bigcapital Technology, Inc.', + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], + billedFromAddress: [ + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], + showBilledFromAddress: true, + showBilledToAddress: true, + billedToLabel: 'Billed To', + + total: '$1000.00', + totalLabel: 'Total', + showTotal: true, + + subtotal: '1000/00', + subtotalLabel: 'Subtotal', + showSubtotal: true, + + showCustomerNote: true, + customerNote: + 'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.', + customerNoteLabel: 'Customer Note', + + showTermsConditions: true, + termsConditions: + 'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.', + termsConditionsLabel: 'Terms & Conditions', + + lines: [ + { + item: 'Simply dummy text', + description: 'Simply dummy text of the printing and typesetting', + rate: '1', + quantity: '1000', + total: '$1000.00', + }, + ], + showEstimateNumber: true, + estimateNumberLabel: 'Estimate Number', + estimateNumebr: '346D3D40-0001', + + estimateDate: 'September 3, 2024', + showEstimateDate: true, + estimateDateLabel: 'Estimate Date', + + expirationDateLabel: 'Expiration Date', + showExpirationDate: true, + expirationDate: 'September 3, 2024', +}; + + +interface EstimatePdfBrandingLineItem { + item: string; + description: string; + rate: string; + quantity: string; + total: string; +} + +export interface EstimatePdfBrandingAttributes { + primaryColor: string; + secondaryColor: string; + showCompanyLogo: boolean; + companyLogo: string; + companyName: string; + + billedToAddress: string[]; + billedFromAddress: string[]; + showBilledFromAddress: boolean; + showBilledToAddress: boolean; + billedToLabel: string; + + total: string; + totalLabel: string; + showTotal: boolean; + + subtotal: string; + subtotalLabel: string; + showSubtotal: boolean; + + showCustomerNote: boolean; + customerNote: string; + customerNoteLabel: string; + + showTermsConditions: boolean; + termsConditions: string; + termsConditionsLabel: string; + + lines: EstimatePdfBrandingLineItem[]; + + showEstimateNumber: boolean; + estimateNumberLabel: string; + estimateNumebr: string; + + estimateDate: string; + showEstimateDate: boolean; + estimateDateLabel: string; + + expirationDateLabel: string; + showExpirationDate: boolean; + expirationDate: string; +} \ No newline at end of file diff --git a/packages/server/src/services/Sales/Estimates/utils.ts b/packages/server/src/services/Sales/Estimates/utils.ts new file mode 100644 index 000000000..879667893 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/utils.ts @@ -0,0 +1,22 @@ +import { EstimatePdfBrandingAttributes } from './constants'; + +export const transformEstimateToPdfTemplate = ( + estimate +): Partial => { + return { + expirationDate: estimate.formattedExpirationDate, + estimateNumebr: estimate.estimateNumber, + estimateDate: estimate.formattedEstimateDate, + lines: estimate.entries.map((entry) => ({ + item: entry.item.name, + description: entry.description, + rate: entry.rateFormatted, + quantity: entry.quantityFormatted, + total: entry.totalFormatted, + })), + total: estimate.formattedSubtotal, + subtotal: estimate.formattedSubtotal, + customerNote: estimate.customerNote, + termsConditions: estimate.termsConditions, + }; +}; diff --git a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts index 8013fe7af..e6a080c04 100644 --- a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts @@ -19,6 +19,7 @@ import { formatDateFields } from 'utils'; import { ItemEntriesTaxTransactions } from '@/services/TaxRates/ItemEntriesTaxTransactions'; import { assocItemEntriesDefaultIndex } from '@/services/Items/utils'; import { ItemEntry } from '@/models'; +import { BrandingTemplateDTOTransformer } from '@/services/PdfTemplate/BrandingTemplateDTOTransformer'; @Service() export class CommandSaleInvoiceDTOTransformer { @@ -40,6 +41,9 @@ export class CommandSaleInvoiceDTOTransformer { @Inject() private taxDTOTransformer: ItemEntriesTaxTransactions; + @Inject() + private brandingTemplatesTransformer: BrandingTemplateDTOTransformer; + /** * Transformes the create DTO to invoice object model. * @param {ISaleInvoiceCreateDTO} saleInvoiceDTO - Sale invoice DTO. @@ -113,11 +117,19 @@ export class CommandSaleInvoiceDTOTransformer { userId: authorizedUser.id, } as ISaleInvoice; + const initialAsyncDTO = await composeAsync( + // Assigns the default branding template id to the invoice DTO. + this.brandingTemplatesTransformer.assocDefaultBrandingTemplate( + tenantId, + 'SaleInvoice' + ) + )(initialDTO); + return R.compose( this.taxDTOTransformer.assocTaxAmountWithheldFromEntries, this.branchDTOTransform.transformDTO(tenantId), this.warehouseDTOTransform.transformDTO(tenantId) - )(initialDTO); + )(initialAsyncDTO); } /** diff --git a/packages/server/src/services/Sales/Invoices/SaleEstimatePdfTemplate.ts b/packages/server/src/services/Sales/Invoices/SaleEstimatePdfTemplate.ts new file mode 100644 index 000000000..de324ea90 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SaleEstimatePdfTemplate.ts @@ -0,0 +1,31 @@ +import { Inject, Service } from 'typedi'; +import { mergePdfTemplateWithDefaultAttributes } from './utils'; +import { GetPdfTemplate } from '@/services/PdfTemplate/GetPdfTemplate'; +import { defaultEstimatePdfBrandingAttributes } from '../Estimates/constants'; + +@Service() +export class SaleEstimatePdfTemplate { + @Inject() + private getPdfTemplateService: GetPdfTemplate; + + /** + * Retrieves the estimate pdf template. + * @param {number} tenantId + * @param {number} invoiceTemplateId + * @returns + */ + async getEstimatePdfTemplate(tenantId: number, estimateTemplateId: number) { + const template = await this.getPdfTemplateService.getPdfTemplate( + tenantId, + estimateTemplateId + ); + const attributes = mergePdfTemplateWithDefaultAttributes( + template.attributes, + defaultEstimatePdfBrandingAttributes + ); + return { + ...template, + attributes, + }; + } +} diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts index 2bbe7e003..0146478b3 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts @@ -2,9 +2,16 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { GetSaleInvoice } from './GetSaleInvoice'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { transformInvoiceToPdfTemplate } from './utils'; +import { InvoicePdfTemplateAttributes } from '@/interfaces'; +import { SaleInvoicePdfTemplate } from './SaleInvoicePdfTemplate'; @Service() export class SaleInvoicePdf { + @Inject() + private tenancy: HasTenancyService; + @Inject() private chromiumlyTenancy: ChromiumlyTenancy; @@ -14,6 +21,9 @@ export class SaleInvoicePdf { @Inject() private getInvoiceService: GetSaleInvoice; + @Inject() + private invoiceBrandingTemplateService: SaleInvoicePdfTemplate; + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - Tenant Id. @@ -24,19 +34,54 @@ export class SaleInvoicePdf { tenantId: number, invoiceId: number ): Promise { - const saleInvoice = await this.getInvoiceService.getSaleInvoice( + const brandingAttributes = await this.getInvoiceBrandingAttributes( tenantId, invoiceId ); const htmlContent = await this.templateInjectable.render( tenantId, - 'modules/invoice-regular', - { - saleInvoice, - } + 'modules/invoice-standard', + brandingAttributes ); - return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, { - margins: { top: 0, bottom: 0, left: 0, right: 0 }, - }); + // Converts the given html content to pdf document. + return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent); + } + + /** + * Retrieves the branding attributes of the given sale invoice. + * @param {number} tenantId + * @param {number} invoiceId + * @returns {Promise} + */ + async getInvoiceBrandingAttributes( + tenantId: number, + invoiceId: number + ): Promise { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const invoice = await this.getInvoiceService.getSaleInvoice( + tenantId, + invoiceId + ); + // Retrieve the invoice template id of not found get the default template id. + const templateId = + invoice.pdfTemplateId ?? + ( + await PdfTemplate.query().findOne({ + resource: 'SaleInvoice', + default: true, + }) + )?.id; + // Getting the branding template attributes. + const brandingTemplate = + await this.invoiceBrandingTemplateService.getInvoicePdfTemplate( + tenantId, + templateId + ); + // Merge the branding template attributes with the invoice. + return { + ...brandingTemplate.attributes, + ...transformInvoiceToPdfTemplate(invoice), + }; } } diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicePdfTemplate.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicePdfTemplate.ts new file mode 100644 index 000000000..563fd3a20 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicePdfTemplate.ts @@ -0,0 +1,31 @@ +import { Inject, Service } from 'typedi'; +import { mergePdfTemplateWithDefaultAttributes } from './utils'; +import { GetPdfTemplate } from '@/services/PdfTemplate/GetPdfTemplate'; +import { defaultInvoicePdfTemplateAttributes } from './constants'; + +@Service() +export class SaleInvoicePdfTemplate { + @Inject() + private getPdfTemplateService: GetPdfTemplate; + + /** + * Retrieves the invoice pdf template. + * @param {number} tenantId + * @param {number} invoiceTemplateId + * @returns + */ + async getInvoicePdfTemplate(tenantId: number, invoiceTemplateId: number){ + const template = await this.getPdfTemplateService.getPdfTemplate( + tenantId, + invoiceTemplateId + ); + const attributes = mergePdfTemplateWithDefaultAttributes( + template.attributes, + defaultInvoicePdfTemplateAttributes + ); + return { + ...template, + attributes, + }; + } +} diff --git a/packages/server/src/services/Sales/Invoices/constants.ts b/packages/server/src/services/Sales/Invoices/constants.ts index 4ed0e6bbd..ffb1db409 100644 --- a/packages/server/src/services/Sales/Invoices/constants.ts +++ b/packages/server/src/services/Sales/Invoices/constants.ts @@ -158,3 +158,88 @@ export const SaleInvoicesSampleData = [ Description: 'Description', }, ]; + +export const defaultInvoicePdfTemplateAttributes = { + primaryColor: 'red', + secondaryColor: 'red', + + companyName: 'Bigcapital Technology, Inc.', + + showCompanyLogo: true, + companyLogo: '', + + dueDateLabel: 'Date due', + showDueDate: true, + + dateIssueLabel: 'Date of issue', + showDateIssue: true, + + // dateIssue, + invoiceNumberLabel: 'Invoice number', + showInvoiceNumber: true, + + // Address + showBillingToAddress: true, + showBilledFromAddress: true, + billedToLabel: 'Billed To', + + // Entries + lineItemLabel: 'Item', + lineDescriptionLabel: 'Description', + lineRateLabel: 'Rate', + lineTotalLabel: 'Total', + + totalLabel: 'Total', + subtotalLabel: 'Subtotal', + discountLabel: 'Discount', + paymentMadeLabel: 'Payment Made', + balanceDueLabel: 'Balance Due', + + // Totals + showTotal: true, + showSubtotal: true, + showDiscount: true, + showTaxes: true, + showPaymentMade: true, + showDueAmount: true, + showBalanceDue: true, + + discount: '0.00', + + // Footer paragraphs. + termsConditionsLabel: 'Terms & Conditions', + showTermsConditions: true, + + lines: [ + { + item: 'Simply dummy text', + description: 'Simply dummy text of the printing and typesetting', + rate: '1', + quantity: '1000', + total: '$1000.00', + }, + ], + taxes: [ + { label: 'Sample Tax1 (4.70%)', amount: '11.75' }, + { label: 'Sample Tax2 (7.00%)', amount: '21.74' }, + ], + + statementLabel: 'Statement', + showStatement: true, + billedToAddress: [ + 'Bigcapital Technology, Inc.', + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], + billedFromAddres: [ + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], +} + diff --git a/packages/server/src/services/Sales/Invoices/utils.ts b/packages/server/src/services/Sales/Invoices/utils.ts new file mode 100644 index 000000000..8131ea2bd --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/utils.ts @@ -0,0 +1,46 @@ +import { pickBy } from 'lodash'; +import { InvoicePdfTemplateAttributes, ISaleInvoice } from '@/interfaces'; + +export const mergePdfTemplateWithDefaultAttributes = ( + brandingTemplate?: Record, + defaultAttributes: Record = {} +) => { + const brandingAttributes = pickBy( + brandingTemplate, + (val, key) => val !== null && Object.keys(defaultAttributes).includes(key) + ); + return { + ...defaultAttributes, + ...brandingAttributes, + }; +}; + +export const transformInvoiceToPdfTemplate = ( + invoice: ISaleInvoice +): Partial => { + return { + dueDate: invoice.dueDateFormatted, + dateIssue: invoice.invoiceDateFormatted, + invoiceNumber: invoice.invoiceNo, + + total: invoice.totalFormatted, + subtotal: invoice.subtotalFormatted, + paymentMade: invoice.paymentAmountFormatted, + balanceDue: invoice.balanceAmountFormatted, + + termsConditions: invoice.termsConditions, + statement: invoice.invoiceMessage, + + lines: invoice.entries.map((entry) => ({ + item: entry.item.name, + description: entry.description, + rate: entry.rateFormatted, + quantity: entry.quantityFormatted, + total: entry.totalFormatted, + })), + taxes: invoice.taxes.map((tax) => ({ + label: tax.name, + amount: tax.taxRateAmountFormatted, + })), + }; +}; diff --git a/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedPdf.ts b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedPdf.ts index 0a357e5ea..afae67359 100644 --- a/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedPdf.ts +++ b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedPdf.ts @@ -2,9 +2,16 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { GetPaymentReceived } from './GetPaymentReceived'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { PaymentReceivedBrandingTemplate } from './PaymentReceivedBrandingTemplate'; +import { transformPaymentReceivedToPdfTemplate } from './utils'; +import { PaymentReceivedPdfTemplateAttributes } from '@/interfaces'; @Service() export default class GetPaymentReceivedPdf { + @Inject() + private tenancy: HasTenancyService; + @Inject() private chromiumlyTenancy: ChromiumlyTenancy; @@ -14,6 +21,9 @@ export default class GetPaymentReceivedPdf { @Inject() private getPaymentService: GetPaymentReceived; + @Inject() + private paymentBrandingTemplateService: PaymentReceivedBrandingTemplate; + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - @@ -24,19 +34,52 @@ export default class GetPaymentReceivedPdf { tenantId: number, paymentReceiveId: number ): Promise { - const paymentReceive = await this.getPaymentService.getPaymentReceive( + const brandingAttributes = await this.getPaymentBrandingAttributes( tenantId, paymentReceiveId ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/payment-receive-standard', - { - paymentReceive, - } + brandingAttributes ); - return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, { - margins: { top: 0, bottom: 0, left: 0, right: 0 }, - }); + // Converts the given html content to pdf document. + return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent); + } + + /** + * Retrieves the given payment received branding attributes. + * @param {number} tenantId + * @param {number} paymentReceivedId + * @returns {Promise} + */ + async getPaymentBrandingAttributes( + tenantId: number, + paymentReceivedId: number + ): Promise { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const paymentReceived = await this.getPaymentService.getPaymentReceive( + tenantId, + paymentReceivedId + ); + const templateId = + paymentReceived?.pdfTemplateId ?? + ( + await PdfTemplate.query().findOne({ + resource: 'PaymentReceive', + default: true, + }) + )?.id; + + const brandingTemplate = + await this.paymentBrandingTemplateService.getPaymentReceivedPdfTemplate( + tenantId, + templateId + ); + return { + ...brandingTemplate.attributes, + ...transformPaymentReceivedToPdfTemplate(paymentReceived), + }; } } diff --git a/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedBrandingTemplate.ts b/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedBrandingTemplate.ts new file mode 100644 index 000000000..625b24d95 --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedBrandingTemplate.ts @@ -0,0 +1,35 @@ +import { GetPdfTemplate } from '@/services/PdfTemplate/GetPdfTemplate'; +import { Inject, Service } from 'typedi'; +import { mergePdfTemplateWithDefaultAttributes } from '../Invoices/utils'; +import { defaultPaymentReceivedPdfTemplateAttributes } from './constants'; +import { PdfTemplate } from '@/models/PdfTemplate'; + +@Service() +export class PaymentReceivedBrandingTemplate { + @Inject() + private getPdfTemplateService: GetPdfTemplate; + + /** + * Retrieves the payment received pdf template. + * @param {number} tenantId + * @param {number} paymentTemplateId + * @returns + */ + public async getPaymentReceivedPdfTemplate( + tenantId: number, + paymentTemplateId: number + ) { + const template = await this.getPdfTemplateService.getPdfTemplate( + tenantId, + paymentTemplateId + ); + const attributes = mergePdfTemplateWithDefaultAttributes( + template.attributes, + defaultPaymentReceivedPdfTemplateAttributes + ); + return { + ...template, + attributes, + }; + } +} diff --git a/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedDTOTransformer.ts b/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedDTOTransformer.ts index 24f0b0737..047607df4 100644 --- a/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedDTOTransformer.ts +++ b/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedDTOTransformer.ts @@ -1,6 +1,7 @@ import * as R from 'ramda'; import { Inject, Service } from 'typedi'; import { omit, sumBy } from 'lodash'; +import composeAsync from 'async/compose'; import { ICustomer, IPaymentReceived, @@ -12,6 +13,7 @@ import { PaymentReceivedIncrement } from './PaymentReceivedIncrement'; import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform'; import { formatDateFields } from '@/utils'; import { assocItemEntriesDefaultIndex } from '@/services/Items/utils'; +import { BrandingTemplateDTOTransformer } from '@/services/PdfTemplate/BrandingTemplateDTOTransformer'; @Service() export class PaymentReceiveDTOTransformer { @@ -24,6 +26,9 @@ export class PaymentReceiveDTOTransformer { @Inject() private branchDTOTransform: BranchTransactionDTOTransform; + @Inject() + private brandingTemplatesTransformer: BrandingTemplateDTOTransformer; + /** * Transformes the create payment receive DTO to model object. * @param {number} tenantId @@ -68,8 +73,16 @@ export class PaymentReceiveDTOTransformer { exchangeRate: paymentReceiveDTO.exchangeRate || 1, entries, }; + const initialAsyncDTO = await composeAsync( + // Assigns the default branding template id to the invoice DTO. + this.brandingTemplatesTransformer.assocDefaultBrandingTemplate( + tenantId, + 'SaleInvoice' + ) + )(initialDTO); + return R.compose( this.branchDTOTransform.transformDTO(tenantId) - )(initialDTO); + )(initialAsyncDTO); } } diff --git a/packages/server/src/services/Sales/PaymentReceived/constants.ts b/packages/server/src/services/Sales/PaymentReceived/constants.ts index 0f48e39fe..3d9b6af88 100644 --- a/packages/server/src/services/Sales/PaymentReceived/constants.ts +++ b/packages/server/src/services/Sales/PaymentReceived/constants.ts @@ -45,3 +45,53 @@ export const PaymentsReceiveSampleData = [ 'Payment Amount': 850, }, ]; + +export const defaultPaymentReceivedPdfTemplateAttributes = { + primaryColor: '#000', + secondaryColor: '#000', + showCompanyLogo: true, + companyLogo: '', + companyName: 'Bigcapital Technology, Inc.', + + billedToAddress: [ + 'Bigcapital Technology, Inc.', + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], + billedFromAddress: [ + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], + showBilledFromAddress: true, + showBillingToAddress: true, + billedToLabel: 'Billed To', + + total: '$1000.00', + totalLabel: 'Total', + showTotal: true, + + subtotal: '1000/00', + subtotalLabel: 'Subtotal', + showSubtotal: true, + + lines: [ + { + invoiceNumber: 'INV-00001', + invoiceAmount: '$1000.00', + paidAmount: '$1000.00', + }, + ], + showPaymentReceivedNumber: true, + paymentReceivedNumberLabel: 'Payment Number', + paymentReceivedNumebr: '346D3D40-0001', + + paymentReceivedDate: 'September 3, 2024', + showPaymentReceivedDate: true, + paymentReceivedDateLabel: 'Payment Date', +}; diff --git a/packages/server/src/services/Sales/PaymentReceived/utils.ts b/packages/server/src/services/Sales/PaymentReceived/utils.ts new file mode 100644 index 000000000..540aab9ed --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceived/utils.ts @@ -0,0 +1,21 @@ +import { + IPaymentReceived, + PaymentReceivedPdfTemplateAttributes, +} from '@/interfaces'; + +export const transformPaymentReceivedToPdfTemplate = ( + payment: IPaymentReceived +): Partial => { + return { + total: payment.formattedAmount, + subtotal: payment.subtotalFormatted, + paymentReceivedNumebr: payment.paymentReceiveNo, + paymentReceivedDate: payment.formattedPaymentDate, + customerName: payment.customer.displayName, + lines: payment.entries.map((entry) => ({ + invoiceNumber: entry.invoice.invoiceNo, + invoiceAmount: entry.invoice.totalFormatted, + paidAmount: entry.paymentAmountFormatted, + })), + }; +}; diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptBrandingTemplate.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptBrandingTemplate.ts new file mode 100644 index 000000000..5d7794421 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptBrandingTemplate.ts @@ -0,0 +1,35 @@ +import { GetPdfTemplate } from '@/services/PdfTemplate/GetPdfTemplate'; +import { Inject, Service } from 'typedi'; +import { defaultSaleReceiptBrandingAttributes } from './constants'; +import { mergePdfTemplateWithDefaultAttributes } from '../Invoices/utils'; + +@Service() +export class SaleReceiptBrandingTemplate { + @Inject() + private getPdfTemplateService: GetPdfTemplate; + + + /** + * Retrieves the sale receipt branding template. + * @param {number} tenantId - The ID of the tenant. + * @param {number} templateId - The ID of the PDF template. + * @returns {Promise} The sale receipt branding template with merged attributes. + */ + public async getSaleReceiptBrandingTemplate( + tenantId: number, + templateId: number + ) { + const template = await this.getPdfTemplateService.getPdfTemplate( + tenantId, + templateId + ); + const attributes = mergePdfTemplateWithDefaultAttributes( + template.attributes, + defaultSaleReceiptBrandingAttributes + ); + return { + ...template, + attributes, + }; + } +} diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptDTOTransformer.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptDTOTransformer.ts index ae492099a..78d14b3b8 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptDTOTransformer.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptDTOTransformer.ts @@ -12,6 +12,7 @@ import { formatDateFields } from '@/utils'; import { SaleReceiptIncrement } from './SaleReceiptIncrement'; import { ItemEntry } from '@/models'; import { assocItemEntriesDefaultIndex } from '@/services/Items/utils'; +import { BrandingTemplateDTOTransformer } from '@/services/PdfTemplate/BrandingTemplateDTOTransformer'; @Service() export class SaleReceiptDTOTransformer { @@ -30,6 +31,9 @@ export class SaleReceiptDTOTransformer { @Inject() private receiptIncrement: SaleReceiptIncrement; + @Inject() + private brandingTemplatesTransformer: BrandingTemplateDTOTransformer; + /** * Transform create DTO object to model object. * @param {ISaleReceiptDTO} saleReceiptDTO - @@ -88,9 +92,17 @@ export class SaleReceiptDTOTransformer { }), entries, }; + const initialAsyncDTO = await composeAsync( + // Assigns the default branding template id to the invoice DTO. + this.brandingTemplatesTransformer.assocDefaultBrandingTemplate( + tenantId, + 'SaleReceipt' + ) + )(initialDTO); + return R.compose( this.branchDTOTransform.transformDTO(tenantId), this.warehouseDTOTransform.transformDTO(tenantId) - )(initialDTO); + )(initialAsyncDTO); } } diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts index cad2b5f93..7b056c1c5 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts @@ -2,9 +2,16 @@ import { Inject, Service } from 'typedi'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { GetSaleReceipt } from './GetSaleReceipt'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { SaleReceiptBrandingTemplate } from './SaleReceiptBrandingTemplate'; +import { transformReceiptToBrandingTemplateAttributes } from './utils'; +import { ISaleReceiptBrandingTemplateAttributes } from '@/interfaces'; @Service() export class SaleReceiptsPdf { + @Inject() + private tenancy: HasTenancyService; + @Inject() private chromiumlyTenancy: ChromiumlyTenancy; @@ -14,26 +21,64 @@ export class SaleReceiptsPdf { @Inject() private getSaleReceiptService: GetSaleReceipt; + @Inject() + private saleReceiptBrandingTemplate: SaleReceiptBrandingTemplate; + /** * Retrieves sale invoice pdf content. - * @param {number} tenantId - + * @param {number} tenantId - * @param {number} saleInvoiceId - * @returns {Promise} */ public async saleReceiptPdf(tenantId: number, saleReceiptId: number) { - const saleReceipt = await this.getSaleReceiptService.getSaleReceipt( + const brandingAttributes = await this.getReceiptBrandingAttributes( tenantId, saleReceiptId ); + // Converts the receipt template to html content. const htmlContent = await this.templateInjectable.render( tenantId, 'modules/receipt-regular', - { - saleReceipt, - } + brandingAttributes ); - return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, { - margins: { top: 0, bottom: 0, left: 0, right: 0 }, - }); + // Renders the html content to pdf document. + return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent); + } + + /** + * Retrieves receipt branding attributes. + * @param {number} tenantId + * @param {number} receiptId + * @returns {Promise} + */ + public async getReceiptBrandingAttributes( + tenantId: number, + receiptId: number + ): Promise { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const saleReceipt = await this.getSaleReceiptService.getSaleReceipt( + tenantId, + receiptId + ); + // Retrieve the invoice template id of not found get the default template id. + const templateId = + saleReceipt.pdfTemplateId ?? + ( + await PdfTemplate.query().findOne({ + resource: 'SaleReceipt', + default: true, + }) + )?.id; + // Retrieves the receipt branding template. + const brandingTemplate = + await this.saleReceiptBrandingTemplate.getSaleReceiptBrandingTemplate( + tenantId, + templateId + ); + return { + ...brandingTemplate.attributes, + ...transformReceiptToBrandingTemplateAttributes(saleReceipt), + }; } } diff --git a/packages/server/src/services/Sales/Receipts/constants.ts b/packages/server/src/services/Sales/Receipts/constants.ts index 06df9a615..491369c74 100644 --- a/packages/server/src/services/Sales/Receipts/constants.ts +++ b/packages/server/src/services/Sales/Receipts/constants.ts @@ -22,7 +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' + NO_INVOICE_CUSTOMER_EMAIL_ADDR: 'NO_INVOICE_CUSTOMER_EMAIL_ADDR', }; export const DEFAULT_VIEW_COLUMNS = []; @@ -47,22 +47,84 @@ export const DEFAULT_VIEWS = [ }, ]; - export const SaleReceiptsSampleData = [ { - "Receipt Date": "2023-01-01", - "Customer": "Randall Kohler", - "Deposit Account": "Petty Cash", - "Exchange Rate": "", - "Receipt Number": "REC-00001", - "Reference No.": "REF-0001", - "Statement": "Delectus unde aut soluta et accusamus placeat.", - "Receipt Message": "Vitae asperiores dicta.", - "Closed": "T", - "Item": "Schmitt Group", - "Quantity": 100, - "Rate": 200, - "Line Description": "Distinctio distinctio sit veritatis consequatur iste quod veritatis." - } - -] \ No newline at end of file + 'Receipt Date': '2023-01-01', + Customer: 'Randall Kohler', + 'Deposit Account': 'Petty Cash', + 'Exchange Rate': '', + 'Receipt Number': 'REC-00001', + 'Reference No.': 'REF-0001', + Statement: 'Delectus unde aut soluta et accusamus placeat.', + 'Receipt Message': 'Vitae asperiores dicta.', + Closed: 'T', + Item: 'Schmitt Group', + Quantity: 100, + Rate: 200, + 'Line Description': + 'Distinctio distinctio sit veritatis consequatur iste quod veritatis.', + }, +]; + +export const defaultSaleReceiptBrandingAttributes = { + primaryColor: '', + secondaryColor: '', + showCompanyLogo: true, + companyLogo: '', + companyName: 'Bigcapital Technology, Inc.', + + // # Address + billedToAddress: [ + 'Bigcapital Technology, Inc.', + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], + billedFromAddress: [ + '131 Continental Dr Suite 305 Newark,', + 'Delaware 19713', + 'United States', + '+1 762-339-5634', + 'ahmed@bigcapital.app', + ], + showBilledFromAddress: true, + showBilledToAddress: true, + billedToLabel: 'Billed To', + + total: '$1000.00', + totalLabel: 'Total', + showTotal: true, + + subtotal: '1000/00', + subtotalLabel: 'Subtotal', + showSubtotal: true, + + showCustomerNote: true, + customerNote: + 'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.', + customerNoteLabel: 'Customer Note', + + showTermsConditions: true, + termsConditions: + 'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.', + termsConditionsLabel: 'Terms & Conditions', + + lines: [ + { + item: 'Simply dummy text', + description: 'Simply dummy text of the printing and typesetting', + rate: '1', + quantity: '1000', + total: '$1000.00', + }, + ], + showReceiptNumber: true, + receiptNumberLabel: 'Receipt Number', + receiptNumebr: '346D3D40-0001', + + receiptDate: 'September 3, 2024', + showReceiptDate: true, + receiptDateLabel: 'Receipt Date', +}; diff --git a/packages/server/src/services/Sales/Receipts/utils.ts b/packages/server/src/services/Sales/Receipts/utils.ts new file mode 100644 index 000000000..e1d9ac9d9 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/utils.ts @@ -0,0 +1,20 @@ +import { ISaleReceipt, ISaleReceiptBrandingTemplateAttributes } from "@/interfaces"; + + + +export const transformReceiptToBrandingTemplateAttributes = (saleReceipt: ISaleReceipt): Partial => { + return { + total: saleReceipt.formattedAmount, + subtotal: saleReceipt.formattedSubtotal, + lines: saleReceipt.entries?.map((entry) => ({ + item: entry.item.name, + description: entry.description, + rate: entry.rateFormatted, + quantity: entry.quantityFormatted, + total: entry.totalFormatted, + })), + + receiptNumber: saleReceipt.receiptNumber, + receiptDate: saleReceipt.formattedReceiptDate, + }; +} \ No newline at end of file diff --git a/packages/server/src/services/TemplateInjectable/TemplateInjectable.ts b/packages/server/src/services/TemplateInjectable/TemplateInjectable.ts index fd2c965a8..31e059ae3 100644 --- a/packages/server/src/services/TemplateInjectable/TemplateInjectable.ts +++ b/packages/server/src/services/TemplateInjectable/TemplateInjectable.ts @@ -17,7 +17,7 @@ export class TemplateInjectable { public async render( tenantId: number, filename: string, - options: Record + options: Record ) { const i18n = this.tenancy.i18n(tenantId); diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index ce6a5a46b..370be2fcc 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -58,7 +58,7 @@ export default { onSubscriptionSubscribed: 'onSubscriptionSubscribed', onSubscriptionPaymentSucceed: 'onSubscriptionPaymentSucceed', - onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed' + onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed', }, /** @@ -687,4 +687,19 @@ export default { import: { onImportCommitted: 'onImportFileCommitted', }, + + // Branding templates + pdfTemplate: { + onCreating: 'onPdfTemplateCreating', + onCreated: 'onPdfTemplateCreated', + + onEditing: 'onPdfTemplateEditing', + onEdited: 'onPdfTemplatedEdited', + + onDeleting: 'onPdfTemplateDeleting', + onDeleted: 'onPdfTemplateDeleted', + + onAssignedDefault: 'onPdfTemplateAssignedDefault', + onAssigningDefault: 'onPdfTemplateAssigningDefault', + }, }; diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 47990d1e2..c7faacdc3 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -79,6 +79,7 @@ "react": "^18.2.0", "react-app-polyfill": "^1.0.6", "react-body-classname": "^1.3.1", + "react-colorful": "^5.6.1", "react-content-loader": "^6.0.1", "react-dev-utils": "^11.0.4", "react-dom": "^18.2.0", diff --git a/packages/webapp/src/components/Card/index.tsx b/packages/webapp/src/components/Card/index.tsx index 8bd314db6..da2c64b43 100644 --- a/packages/webapp/src/components/Card/index.tsx +++ b/packages/webapp/src/components/Card/index.tsx @@ -2,8 +2,12 @@ import React from 'react'; import styled from 'styled-components'; -export function Card({ className, children }) { - return {children}; +export function Card({ className, style, children }) { + return ( + + {children} + + ); } const CardRoot = styled.div` diff --git a/packages/webapp/src/components/Drawer/DrawerProvider.tsx b/packages/webapp/src/components/Drawer/DrawerProvider.tsx index f89256ba0..e5c8ebad2 100644 --- a/packages/webapp/src/components/Drawer/DrawerProvider.tsx +++ b/packages/webapp/src/components/Drawer/DrawerProvider.tsx @@ -1,7 +1,14 @@ // @ts-nocheck import React, { createContext, useContext } from 'react'; -const DrawerContext = createContext(); +interface DrawerContextValue { + name: string; + payload: Record; +} + +const DrawerContext = createContext( + {} as DrawerContextValue, +); /** * Account form provider. diff --git a/packages/webapp/src/components/DrawersContainer.tsx b/packages/webapp/src/components/DrawersContainer.tsx index 6165fda85..89c1a13e5 100644 --- a/packages/webapp/src/components/DrawersContainer.tsx +++ b/packages/webapp/src/components/DrawersContainer.tsx @@ -23,6 +23,12 @@ import WarehouseTransferDetailDrawer from '@/containers/Drawers/WarehouseTransfe import TaxRateDetailsDrawer from '@/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer'; import CategorizeTransactionDrawer from '@/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer'; import ChangeSubscriptionPlanDrawer from '@/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer'; +import { InvoiceCustomizeDrawer } from '@/containers/Sales/Invoices/InvoiceCustomize/InvoiceCustomizeDrawer'; +import { EstimateCustomizeDrawer } from '@/containers/Sales/Estimates/EstimateCustomize/EstimateCustomizeDrawer'; +import { ReceiptCustomizeDrawer } from '@/containers/Sales/Receipts/ReceiptCustomize/ReceiptCustomizeDrawer'; +import { CreditNoteCustomizeDrawer } from '@/containers/Sales/CreditNotes/CreditNoteCustomize/CreditNoteCustomizeDrawer'; +import { PaymentReceivedCustomizeDrawer } from '@/containers/Sales/PaymentsReceived/PaymentReceivedCustomize/PaymentReceivedCustomizeDrawer'; +import { BrandingTemplatesDrawer } from '@/containers/BrandingTemplates/BrandingTemplatesDrawer'; import { DRAWERS } from '@/constants/drawers'; @@ -65,6 +71,14 @@ export default function DrawersContainer() { + + + + + + ); } diff --git a/packages/webapp/src/components/Forms/ColorInput.module.scss b/packages/webapp/src/components/Forms/ColorInput.module.scss new file mode 100644 index 000000000..f0f737129 --- /dev/null +++ b/packages/webapp/src/components/Forms/ColorInput.module.scss @@ -0,0 +1,15 @@ + +.field{ + height: 28px; + line-height: 28px; + border-radius: 5px; +} + +.colorPicker{ + background-color: rgb(103, 114, 229); + border-radius: 3px; + height: 16px; + width: 16px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + cursor: pointer; +} \ No newline at end of file diff --git a/packages/webapp/src/components/Forms/ColorInput.tsx b/packages/webapp/src/components/Forms/ColorInput.tsx new file mode 100644 index 000000000..d06f2fdc8 --- /dev/null +++ b/packages/webapp/src/components/Forms/ColorInput.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import clsx from 'classnames'; +import { + IInputGroupProps, + InputGroup, + IPopoverProps, + Popover, + PopoverInteractionKind, + Position, +} from '@blueprintjs/core'; +import { HexColorPicker } from 'react-colorful'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; +import { Box, BoxProps } from '@/components'; +import { sanitizeToHexColor } from '@/utils/sanitize-hex-color'; +import styles from './ColorInput.module.scss'; + +export interface ColorInputProps { + value?: string; + initialValue?: string; + onChange?: (value: string) => void; + popoverProps?: Partial; + inputProps?: Partial; + pickerProps?: Partial; + pickerWrapProps?: Partial; +} + +export function ColorInput({ + value, + initialValue, + onChange, + popoverProps, + inputProps, + pickerWrapProps, + pickerProps, +}: ColorInputProps) { + const [_value, handleChange] = useUncontrolled({ + value, + initialValue, + onChange, + finalValue: '', + }); + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + }; + + return ( + } + position={Position.BOTTOM} + interactionKind={PopoverInteractionKind.CLICK} + modifiers={{ + offset: { offset: '0, 4' }, + }} + onClose={handleClose} + isOpen={isOpen} + minimal + {...popoverProps} + > + + setIsOpen((oldValue) => !oldValue)} + style={{ backgroundColor: _value }} + className={clsx(styles.colorPicker, pickerProps?.className)} + {...pickerProps} + /> + + } + onChange={(e) => { + const value = sanitizeToHexColor(e.currentTarget.value); + handleChange(value); + }} + {...inputProps} + className={clsx(styles.field, inputProps?.className)} + /> + + ); +} diff --git a/packages/webapp/src/components/Forms/FColorInput.tsx b/packages/webapp/src/components/Forms/FColorInput.tsx new file mode 100644 index 000000000..0622f129b --- /dev/null +++ b/packages/webapp/src/components/Forms/FColorInput.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { getIn, FieldConfig, FieldProps } from 'formik'; +import { Intent } from '@blueprintjs/core'; +import { Field } from '@blueprintjs-formik/core'; +import { ColorInput, ColorInputProps } from './ColorInput'; + +interface ColorInputInputGroupProps + extends Omit, + ColorInputProps {} + +export interface ColorInputToInputProps + extends Omit, + ColorInputProps {} + +/** + * Transforms field props to input group props for ColorInput. + * @param {ColorInputToInputProps} + * @returns {ColorInputProps} + */ +function fieldToColorInputInputGroup({ + field: { onBlur: onFieldBlur, onChange: onFieldChange, value, ...field }, + form: { touched, errors, setFieldValue }, + onChange, + ...props +}: ColorInputToInputProps): ColorInputProps { + const fieldError = getIn(errors, field.name); + const showError = getIn(touched, field.name) && !!fieldError; + + return { + inputProps: { + intent: showError ? Intent.DANGER : Intent.NONE, + }, + value, + onChange: + onChange ?? + function (value: string) { + setFieldValue(field.name, value); + }, + ...field, + ...props, + }; +} + +/** + * Transforms field props to input group props for ColorInput. + * @param {ColorInputToInputProps} props - + * @returns {JSX.Element} + */ +function ColorInputToInputGroup({ + ...props +}: ColorInputToInputProps): JSX.Element { + return ; +} + +/** + * Input group Blueprint component binded with Formik for ColorInput. + * @param {ColorInputInputGroupProps} + * @returns {JSX.Element} + */ +export function FColorInput({ + ...props +}: ColorInputInputGroupProps): JSX.Element { + return ; +} diff --git a/packages/webapp/src/components/Forms/Select.tsx b/packages/webapp/src/components/Forms/Select.tsx index c77c51ce2..93d6fc8ed 100644 --- a/packages/webapp/src/components/Forms/Select.tsx +++ b/packages/webapp/src/components/Forms/Select.tsx @@ -6,16 +6,14 @@ import styled from 'styled-components'; import clsx from 'classnames'; export function FSelect({ ...props }) { - const input = ({ activeItem, text, label, value }) => { - return ( - - ); - }; + const input = ({ activeItem, text, label, value }) => ( + + ); return