diff --git a/packages/server/src/api/controllers/Organization.ts b/packages/server/src/api/controllers/Organization.ts index 9d041f9d1..9e01c8da2 100644 --- a/packages/server/src/api/controllers/Organization.ts +++ b/packages/server/src/api/controllers/Organization.ts @@ -18,7 +18,7 @@ import BaseController from '@/api/controllers/BaseController'; @Service() export default class OrganizationController extends BaseController { @Inject() - organizationService: OrganizationService; + private organizationService: OrganizationService; /** * Router constructor. @@ -56,10 +56,10 @@ export default class OrganizationController extends BaseController { } /** - * Organization setup schema. - * @return {ValidationChain[]} + * Build organization validation schema. + * @returns {ValidationChain[]} */ - private get commonOrganizationValidationSchema(): ValidationChain[] { + private get buildOrganizationValidationSchema(): ValidationChain[] { return [ check('name').exists().trim(), check('industry').optional({ nullable: true }).isString().trim(), @@ -72,21 +72,34 @@ export default class OrganizationController extends BaseController { ]; } - /** - * Build organization validation schema. - * @returns {ValidationChain[]} - */ - private get buildOrganizationValidationSchema(): ValidationChain[] { - return [...this.commonOrganizationValidationSchema]; - } - /** * Update organization validation schema. * @returns {ValidationChain[]} */ private get updateOrganizationValidationSchema(): ValidationChain[] { return [ - ...this.commonOrganizationValidationSchema, + // # Profile + check('name').optional().trim(), + check('industry').optional({ nullable: true }).isString().trim(), + check('location').optional().isString().isISO31661Alpha2(), + check('base_currency').optional().isISO4217(), + check('timezone').optional().isIn(moment.tz.names()), + check('fiscal_year').optional().isIn(MONTHS), + check('language').optional().isString().isIn(ACCEPTED_LOCALES), + check('date_format').optional().isIn(DATE_FORMATS), + + // # Address + check('address.address_1').optional().isString().trim(), + check('address.address_2').optional().isString().trim(), + check('address.postal_code').optional().isString().trim(), + check('address.city').optional().isString().trim(), + check('address.state_province').optional().isString().trim(), + check('address.phone').optional().isString().trim(), + + // # Branding + check('primary_color').optional({ nullable: true }).isHexColor().trim(), + check('logo_key').optional({ nullable: true }).isString().trim(), + check('tax_number').optional({ nullable: true }).isString().trim(), ]; } @@ -156,7 +169,7 @@ export default class OrganizationController extends BaseController { next: NextFunction ) { const { tenantId } = req; - const tenantDTO = this.matchedBodyData(req); + const tenantDTO = this.matchedBodyData(req, { includeOptionals: false }); try { await this.organizationService.updateOrganization(tenantId, tenantDTO); diff --git a/packages/server/src/interfaces/Setup.ts b/packages/server/src/interfaces/Setup.ts index da776b89e..32c8fe97b 100644 --- a/packages/server/src/interfaces/Setup.ts +++ b/packages/server/src/interfaces/Setup.ts @@ -18,14 +18,26 @@ export interface IOrganizationBuildDTO { dateFormat?: string; } +interface OrganizationAddressDTO { + address1: string; + address2: string; + postalCode: string; + city: string; + stateProvince: string; + phone: string; +} + export interface IOrganizationUpdateDTO { name: string; - location: string; - baseCurrency: string; - timezone: string; - fiscalYear: string; - industry: string; - taxNumber: string; + location?: string; + baseCurrency?: string; + timezone?: string; + fiscalYear?: string; + industry?: string; + taxNumber?: string; + primaryColor?: string; + logoKey?: string; + address?: OrganizationAddressDTO; } export interface IOrganizationBuildEventPayload { @@ -36,4 +48,4 @@ export interface IOrganizationBuildEventPayload { export interface IOrganizationBuiltEventPayload { tenantId: number; -} \ No newline at end of file +} diff --git a/packages/server/src/services/Organization/OrganizationService.ts b/packages/server/src/services/Organization/OrganizationService.ts index 6d7fa1621..67c9656c8 100644 --- a/packages/server/src/services/Organization/OrganizationService.ts +++ b/packages/server/src/services/Organization/OrganizationService.ts @@ -93,7 +93,7 @@ export default class OrganizationService { // Triggers the organization built event. await this.eventPublisher.emitAsync(events.organization.built, { tenantId: tenant.id, - } as IOrganizationBuiltEventPayload) + } as IOrganizationBuiltEventPayload); } /** @@ -190,11 +190,13 @@ export default class OrganizationService { this.throwIfTenantNotExists(tenant); // Validate organization transactions before mutate base currency. - await this.validateMutateBaseCurrency( - tenant, - organizationDTO.baseCurrency, - tenant.metadata?.baseCurrency - ); + if (organizationDTO.baseCurrency) { + await this.validateMutateBaseCurrency( + tenant, + organizationDTO.baseCurrency, + tenant.metadata?.baseCurrency + ); + } await tenant.saveMetadata(organizationDTO); if (organizationDTO.baseCurrency !== tenant.metadata?.baseCurrency) { diff --git a/packages/server/src/services/Organization/OrganizationUpgrade.ts b/packages/server/src/services/Organization/OrganizationUpgrade.ts index f8b6f7b57..9155539aa 100644 --- a/packages/server/src/services/Organization/OrganizationUpgrade.ts +++ b/packages/server/src/services/Organization/OrganizationUpgrade.ts @@ -13,16 +13,13 @@ import TenantsManagerService from '@/services/Tenancy/TenantsManager'; @Service() export default class OrganizationUpgrade { @Inject() - tenancy: HasTenancyService; + private organizationService: OrganizationService; @Inject() - organizationService: OrganizationService; - - @Inject() - tenantsManager: TenantsManagerService; + private tenantsManager: TenantsManagerService; @Inject('agenda') - agenda: any; + private agenda: any; /** * Upgrades the given organization database. @@ -102,4 +99,4 @@ export default class OrganizationUpgrade { throw new ServiceError(ERRORS.TENANT_UPGRADE_IS_RUNNING); } } -} \ No newline at end of file +} diff --git a/packages/server/src/services/PaymentLinks/GetInvoicePaymentLinkMetadata.ts b/packages/server/src/services/PaymentLinks/GetInvoicePaymentLinkMetadata.ts index b63b67901..d12adca4b 100644 --- a/packages/server/src/services/PaymentLinks/GetInvoicePaymentLinkMetadata.ts +++ b/packages/server/src/services/PaymentLinks/GetInvoicePaymentLinkMetadata.ts @@ -45,6 +45,7 @@ export class GetInvoicePaymentLinkMetadata { .withGraphFetched('entries.item') .withGraphFetched('customer') .withGraphFetched('taxes.taxRate') + .withGraphFetched('paymentMethods.paymentIntegration') .throwIfNotFound(); return this.transformer.transform( diff --git a/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts index 15119e436..b85696497 100644 --- a/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts @@ -1,6 +1,8 @@ +import { Transform } from 'form-data'; import { ItemEntryTransformer } from './ItemEntryTransformer'; import { SaleInvoiceTaxEntryTransformer } from './SaleInvoiceTaxEntryTransformer'; import { SaleInvoiceTransformer } from './SaleInvoiceTransformer'; +import { Transformer } from '@/lib/Transformer/Transformer'; export class GetInvoicePaymentLinkMetaTransformer extends SaleInvoiceTransformer { /** @@ -17,7 +19,6 @@ export class GetInvoicePaymentLinkMetaTransformer extends SaleInvoiceTransformer */ public includeAttributes = (): string[] => { return [ - 'companyName', 'customerName', 'dueAmount', 'dueDateFormatted', @@ -39,6 +40,9 @@ export class GetInvoicePaymentLinkMetaTransformer extends SaleInvoiceTransformer 'termsConditions', 'entries', 'taxes', + 'organization', + 'isReceivable', + 'hasStripePaymentMethod', ]; }; @@ -46,8 +50,15 @@ export class GetInvoicePaymentLinkMetaTransformer extends SaleInvoiceTransformer return invoice.customer.displayName; } - public companyName() { - return 'Bigcapital Technology, Inc.'; + /** + * Retrieves the organization metadata for the payment link. + * @returns + */ + public organization(invoice) { + return this.item( + this.context.organization, + new GetPaymentLinkOrganizationMetaTransformer() + ); } /** @@ -80,6 +91,44 @@ export class GetInvoicePaymentLinkMetaTransformer extends SaleInvoiceTransformer } ); }; + + protected isReceivable(invoice) { + return invoice.dueAmount > 0; + } + + protected hasStripePaymentMethod(invoice) { + return invoice.paymentMethods.some( + (paymentMethod) => paymentMethod.paymentIntegration.service === 'Stripe' + ); + } +} + +class GetPaymentLinkOrganizationMetaTransformer extends Transformer { + /** + * Include these attributes to item entry object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'primaryColor', + 'name', + 'address', + 'logoUri', + 'addressTextFormatted', + ]; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieves the formatted text of organization address. + * @returns {string} + */ + public addressTextFormatted() { + return this.context.organization.addressTextFormatted; + } } class GetInvoicePaymentLinkEntryMetaTransformer extends ItemEntryTransformer { diff --git a/packages/server/src/system/migrations/20240928145627_add_logo_key_to_tenant_metadata.js b/packages/server/src/system/migrations/20240928145627_add_logo_key_to_tenant_metadata.js new file mode 100644 index 000000000..c1197335b --- /dev/null +++ b/packages/server/src/system/migrations/20240928145627_add_logo_key_to_tenant_metadata.js @@ -0,0 +1,23 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.table('tenants_metadata', (table) => { + table.string('primary_color'); + table.string('logo_key'); + table.json('address'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.table('tenants_metadata', (table) => { + table.dropColumn('primary_color'); + table.dropColumn('logo_key'); + table.dropColumn('address'); + }); +}; diff --git a/packages/server/src/system/models/Tenant.ts b/packages/server/src/system/models/Tenant.ts index bea33bb2d..c91ca2081 100644 --- a/packages/server/src/system/models/Tenant.ts +++ b/packages/server/src/system/models/Tenant.ts @@ -169,7 +169,7 @@ export default class Tenant extends BaseModel { */ static async saveMetadata(tenantId, metadata) { const foundMetadata = await TenantMetadata.query().findOne({ tenantId }); - const updateOrInsert = foundMetadata ? 'update' : 'insert'; + const updateOrInsert = foundMetadata ? 'patch' : 'insert'; return TenantMetadata.query() [updateOrInsert]({ diff --git a/packages/server/src/system/models/TenantMetadata.ts b/packages/server/src/system/models/TenantMetadata.ts index 98953dd43..5ff89d84a 100644 --- a/packages/server/src/system/models/TenantMetadata.ts +++ b/packages/server/src/system/models/TenantMetadata.ts @@ -1,8 +1,43 @@ +import { addressTextFormat } from '@/utils/address-text-format'; import BaseModel from 'models/Model'; export default class TenantMetadata extends BaseModel { - baseCurrency: string; - name: string; + baseCurrency!: string; + name!: string; + tenantId!: number; + industry!: string; + location!: string; + language!: string; + timezone!: string; + dateFormat!: string; + fiscalYear!: string; + primaryColor!: string; + logoKey!: string; + address!: Record; + + /** + * Json schema. + */ + static get jsonSchema() { + return { + type: 'object', + required: ['tenantId', 'name', 'baseCurrency'], + properties: { + tenantId: { type: 'integer' }, + name: { type: 'string', maxLength: 255 }, + industry: { type: 'string', maxLength: 255 }, + location: { type: 'string', maxLength: 255 }, + baseCurrency: { type: 'string', maxLength: 3 }, + language: { type: 'string', maxLength: 255 }, + timezone: { type: 'string', maxLength: 255 }, + dateFormat: { type: 'string', maxLength: 255 }, + fiscalYear: { type: 'string', maxLength: 255 }, + primaryColor: { type: 'string', maxLength: 7 }, // Assuming hex color code + logoKey: { type: 'string', maxLength: 255 }, + address: { type: 'object' }, + }, + }; + } /** * Table name. @@ -10,4 +45,44 @@ export default class TenantMetadata extends BaseModel { static get tableName() { return 'tenants_metadata'; } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['logoUri']; + } + + /** + * Organization logo url. + * @returns {string | null} + */ + public get logoUri() { + return this.logoKey + ? `https://bigcapital.sfo3.digitaloceanspaces.com/${this.logoKey}` + : null; + } + + /** + * Retrieves the organization address formatted text. + * @returns {string} + */ + public get addressTextFormatted() { + const defaultMessage = `{ORGANIZATION_NAME} + {ADDRESS_1}, + {ADDRESS_2}, + {CITY} {STATE}, + {POSTAL_CODE}, + {COUNTRY} +`; + return addressTextFormat(defaultMessage, { + organizationName: this.name, + address1: this.address?.address1, + address2: this.address?.address2, + state: this.address?.stateProvince, + city: this.address?.city, + postalCode: this.address?.postalCode, + country: 'United State', + }); + } } diff --git a/packages/server/src/utils/address-text-format.ts b/packages/server/src/utils/address-text-format.ts new file mode 100644 index 000000000..73467aa9c --- /dev/null +++ b/packages/server/src/utils/address-text-format.ts @@ -0,0 +1,42 @@ +interface OrganizationAddressFormatArgs { + organizationName?: string; + address1?: string; + address2?: string; + state?: string; + city?: string; + country?: string; + postalCode?: string; +} + +const defaultMessage = ` + {ORGANIZATION_NAME} + {ADDRESS_1}, + {ADDRESS_2}, + {CITY} {STATE}, + {POSTAL_CODE}, + {COUNTRY} +`; + +export const addressTextFormat = ( + message: string, + args: OrganizationAddressFormatArgs +) => { + const replacements: Record = { + ORGANIZATION_NAME: args.organizationName || '', + ADDRESS_1: args.address1 || '', + ADDRESS_2: args.address2 || '', + CITY: args.city || '', + STATE: args.state || '', + POSTAL_CODE: args.postalCode || '', + COUNTRY: args.country || '', + }; + let formattedMessage = Object.entries(replacements).reduce( + (msg, [key, value]) => { + return value ? msg.split(`{${key}}`).join(value) : msg; + }, + message + ); + formattedMessage = formattedMessage.replace(/\n/g, '
'); + + return formattedMessage.trim(); +}; diff --git a/packages/webapp/src/constants/PdfTemplates.ts b/packages/webapp/src/constants/PdfTemplates.ts index 6b1ec8b96..26c52b7f9 100644 --- a/packages/webapp/src/constants/PdfTemplates.ts +++ b/packages/webapp/src/constants/PdfTemplates.ts @@ -1,4 +1,5 @@ -export const DefaultPdfTemplateTerms = 'All services provided are non-refundable. For any disputes, please contact us within 7 days of receiving this invoice.'; +export const DefaultPdfTemplateTerms = + 'All services provided are non-refundable. For any disputes, please contact us within 7 days of receiving this invoice.'; export const DefaultPdfTemplateStatement = 'Thank you for your business. We look forward to working with you again!'; @@ -7,3 +8,19 @@ export const DefaultPdfTemplateItemName = 'Web development'; export const DefaultPdfTemplateItemDescription = 'Website development with content and SEO optimization'; + +export const DefaultPdfTemplateAddressBilledTo = `Bigcapital Technology, Inc.
+131 Continental Dr Suite 305 Newark,
+Delaware 19713,
+United States,
++1 762-339-5634,
+ahmed@bigcapital.app +`; + + +export const DefaultPdfTemplateAddressBilledFrom = `131 Continental Dr Suite 305 Newark,
+Delaware 19713,
+United States,
++1 762-339-5634,
+ahmed@bigcapital.app +`; \ No newline at end of file diff --git a/packages/webapp/src/constants/preferencesMenu.tsx b/packages/webapp/src/constants/preferencesMenu.tsx index b6c474836..468cfd89d 100644 --- a/packages/webapp/src/constants/preferencesMenu.tsx +++ b/packages/webapp/src/constants/preferencesMenu.tsx @@ -8,6 +8,11 @@ export default [ disabled: false, href: '/preferences/general', }, + { + text: 'Branding', + disabled: false, + href: '/preferences/branding', + }, { text: 'Billing', href: '/preferences/billing', diff --git a/packages/webapp/src/containers/ElementCustomize/components/CompanyLogoUpload.tsx b/packages/webapp/src/containers/ElementCustomize/components/CompanyLogoUpload.tsx index 2901cdea6..a5b3ec195 100644 --- a/packages/webapp/src/containers/ElementCustomize/components/CompanyLogoUpload.tsx +++ b/packages/webapp/src/containers/ElementCustomize/components/CompanyLogoUpload.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import { useRef, useState } from 'react'; +import clsx from 'classnames'; import { Button, Intent } from '@blueprintjs/core'; import { Icon, Stack } from '@/components'; import { Dropzone, DropzoneProps } from '@/components/Dropzone'; @@ -69,7 +70,7 @@ export function CompanyLogoUpload({ onReject={(files) => console.log('rejected files', files)} maxSize={5 * 1024 ** 2} accept={[MIME_TYPES.png, MIME_TYPES.jpeg]} - classNames={{ root: styles?.root, content: styles.dropzoneContent }} + classNames={{ root: clsx(styles?.root, classNames?.root), content: styles.dropzoneContent }} activateOnClick={false} openRef={openRef} {...dropzoneProps} diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.module.scss b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.module.scss index 8401ca7c3..a431cfcfe 100644 --- a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.module.scss +++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.module.scss @@ -17,7 +17,6 @@ width :50px; border-radius: 50px; background-color: #dfdfdf; - background-image: url('https://pbs.twimg.com/profile_images/1381635804397703182/x5chIdsO_400x400.png'); background-position: center center; background-size: cover; background-repeat: no-repeat; diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx index e83a7e318..6bb2114ca 100644 --- a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx +++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx @@ -38,13 +38,20 @@ export function PaymentPortal() { - - {sharableLinkMeta?.companyName} + {sharableLinkMeta?.organization?.logoUri && ( + + )} + {sharableLinkMeta?.organization?.name}

- {sharableLinkMeta?.companyName} Sent an Invoice for{' '} + {sharableLinkMeta?.organization?.name} Sent an Invoice for{' '} {sharableLinkMeta?.totalFormatted}

@@ -89,7 +96,6 @@ export function PaymentPortal() { {tax?.taxRateAmountFormatted} ))} - - + {sharableLinkMeta?.isReceivable && + sharableLinkMeta?.hasStripePaymentMethod && ( + + )}
@@ -143,15 +152,11 @@ export function PaymentPortal() {
- - - Bigcapital Technology, Inc. - - 131 Continental Dr Suite 305 Newark, - Delaware 19713 - United States - ahmed@bigcapital.app - + © 2024 Bigcapital Technology, Inc. diff --git a/packages/webapp/src/containers/Preferences/Branding/PreferencesBranding.module.scss b/packages/webapp/src/containers/Preferences/Branding/PreferencesBranding.module.scss new file mode 100644 index 000000000..a228d1d9a --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Branding/PreferencesBranding.module.scss @@ -0,0 +1,6 @@ + + +.fileUploadRoot{ + width: 350px; + height: 140px; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingBoot.tsx b/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingBoot.tsx new file mode 100644 index 000000000..7456efbca --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingBoot.tsx @@ -0,0 +1,46 @@ +import { useCurrentOrganization } from '@/hooks/query'; +import React, { createContext, useContext, ReactNode } from 'react'; + +interface PreferencesBrandingContextType { + isOrganizationLoading: boolean; + organization: any; +} + +const PreferencesBrandingContext = + createContext( + {} as PreferencesBrandingContextType, + ); + +interface PreferencesBrandingProviderProps { + children: ReactNode; +} + +export const PreferencesBrandingBoot: React.FC< + PreferencesBrandingProviderProps +> = ({ children }) => { + // Fetches current organization information. + const { isLoading: isOrganizationLoading, data: organization } = + useCurrentOrganization({}); + + const contextValue: PreferencesBrandingContextType = { + isOrganizationLoading, + organization, + }; + + return ( + + {children} + + ); +}; + +export const usePreferencesBrandingBoot = () => { + const context = useContext(PreferencesBrandingContext); + + if (context === undefined) { + throw new Error( + 'usePreferencesBranding must be used within a PreferencesBrandingProvider', + ); + } + return context; +}; diff --git a/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingForm.tsx b/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingForm.tsx new file mode 100644 index 000000000..5bbdc829c --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingForm.tsx @@ -0,0 +1,114 @@ +import React, { CSSProperties } from 'react'; +import { Formik, Form, FormikHelpers } from 'formik'; +import * as Yup from 'yup'; +import { omit } from 'lodash'; +import { PreferencesBrandingFormValues } from './_types'; +import { useUploadAttachments } from '@/hooks/query/attachments'; +import { AppToaster } from '@/components'; +import { Intent } from '@blueprintjs/core'; +import { + excludePrivateProps, + transformToCamelCase, + transformToForm, + transfromToSnakeCase, +} from '@/utils'; +import { useUpdateOrganization } from '@/hooks/query'; +import { usePreferencesBrandingBoot } from './PreferencesBrandingBoot'; + +const initialValues = { + logoKey: '', + logoUri: '', + primaryColor: '', +}; + +const validationSchema = Yup.object({ + logoKey: Yup.string().optional(), + logoUri: Yup.string().optional(), + primaryColor: Yup.string().required('Primary color is required'), +}); + +interface PreferencesBrandingFormProps { + children: React.ReactNode; +} + +export const PreferencesBrandingForm = ({ + children, +}: PreferencesBrandingFormProps) => { + // Uploads the attachments. + const { mutateAsync: uploadAttachments } = useUploadAttachments({}); + // Mutate organization information. + const { mutateAsync: updateOrganization } = useUpdateOrganization(); + + const { organization } = usePreferencesBrandingBoot(); + + const formInitialValues = { + ...transformToForm( + transformToCamelCase(organization?.metadata), + initialValues, + ), + } as PreferencesBrandingFormValues; + + // Handle the form submitting. + const handleSubmit = async ( + values: PreferencesBrandingFormValues, + { setSubmitting }: FormikHelpers, + ) => { + const _values = { ...values }; + + const handleError = (message: string) => { + AppToaster.show({ intent: Intent.DANGER, message }); + setSubmitting(false); + }; + // Start upload the company logo file if it is presented. + if (values._logoFile) { + const formData = new FormData(); + const key = Date.now().toString(); + + formData.append('file', values._logoFile); + formData.append('internalKey', key); + + try { + // @ts-expect-error + const uploadedAttachmentRes = await uploadAttachments(formData); + setSubmitting(false); + + // Adds the attachment key to the values after finishing upload. + _values['logoKey'] = uploadedAttachmentRes?.key; + } catch { + handleError('An error occurred while uploading company logo.'); + setSubmitting(false); + return; + } + } + // Exclude all the private props that starts with _. + const excludedPrivateValues = excludePrivateProps(_values); + + const __values = transfromToSnakeCase( + omit(excludedPrivateValues, ['logoUri']), + ); + // Update organization branding. + // @ts-expect-error + await updateOrganization({ ...__values }); + + AppToaster.show({ + message: 'Organization branding has been updated.', + intent: Intent.SUCCESS, + }); + }; + + return ( + +
{children}
+
+ ); +}; + +const formStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + flex: 1, +}; diff --git a/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingFormContent.tsx b/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingFormContent.tsx new file mode 100644 index 000000000..3fd8d2c1a --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingFormContent.tsx @@ -0,0 +1,77 @@ +import { Button, Classes, Intent, Text } from '@blueprintjs/core'; +import { useFormikContext } from 'formik'; +import { FFormGroup, Group, Stack } from '@/components'; +import { FColorInput } from '@/components/Forms/FColorInput'; +import { CompanyLogoUpload } from '@/containers/ElementCustomize/components/CompanyLogoUpload'; +import { PreferencesBrandingFormValues } from './_types'; +import styles from './PreferencesBranding.module.scss'; + +export function PreferencesBrandingFormContent() { + return ( + + + + + + + + + + + + + ); +} + +export function PreferencesBrandingFormFooter() { + const { isSubmitting } = useFormikContext(); + + return ( + + + + ); +} + +export function BrandingCompanyLogoUpload() { + const { setFieldValue, values } = + useFormikContext(); + + return ( + { + const imageUrl = file ? URL.createObjectURL(file) : ''; + + setFieldValue('_logoFile', file); + setFieldValue('logoUri', imageUrl); + setFieldValue('logoKey', ''); + }} + classNames={{ + root: styles.fileUploadRoot, + }} + /> + ); +} + +function BrandingCompanyLogoDesc() { + return ( + + + This logo will be displayed in transaction PDFs and email notifications. + + + Preferred Image Dimensions: 240 × 240 pixels @ 72 DPI Maximum File Size: + 1MB + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingPage.tsx b/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingPage.tsx new file mode 100644 index 000000000..295f4a66e --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Branding/PreferencesBrandingPage.tsx @@ -0,0 +1,32 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { useEffect } from 'react'; +import { Stack } from '@/components'; +import { PreferencesBrandingBoot } from './PreferencesBrandingBoot'; +import { PreferencesBrandingForm } from './PreferencesBrandingForm'; +import { + PreferencesBrandingFormContent, + PreferencesBrandingFormFooter, +} from './PreferencesBrandingFormContent'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; + +function PreferencesBrandingPageRoot({ changePreferencesPageTitle }) { + useEffect(() => { + changePreferencesPageTitle('Branding'); + }, [changePreferencesPageTitle]); + + return ( + + + + + + + + + ); +} + +export default R.compose(withDashboardActions)(PreferencesBrandingPageRoot); diff --git a/packages/webapp/src/containers/Preferences/Branding/_types.ts b/packages/webapp/src/containers/Preferences/Branding/_types.ts new file mode 100644 index 000000000..603d8254a --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Branding/_types.ts @@ -0,0 +1,6 @@ +export interface PreferencesBrandingFormValues { + logoKey: string; + logoUri: string; + primaryColor: string; + _logoFile?: any; +} diff --git a/packages/webapp/src/containers/Preferences/General/GeneralForm.tsx b/packages/webapp/src/containers/Preferences/General/GeneralForm.tsx index d67d0ea65..8d983d547 100644 --- a/packages/webapp/src/containers/Preferences/General/GeneralForm.tsx +++ b/packages/webapp/src/containers/Preferences/General/GeneralForm.tsx @@ -14,6 +14,8 @@ import { FFormGroup, FInputGroup, FSelect, + Stack, + Group, } from '@/components'; import { inputIntent } from '@/utils'; import { CLASSES } from '@/constants/classes'; @@ -99,6 +101,47 @@ export default function PreferencesGeneralForm({ isSubmitting }) { /> + {/* ---------- Address ---------- */} + + + + + + + + + + + + + + + {/* ---------- Base currency ---------- */} ; - billedFromAddress?: Array; + billedToAddress?: string; + billedFromAddress?: string; showBilledToAddress?: boolean; showBilledFromAddress?: boolean; billedToLabel?: string; @@ -70,23 +72,11 @@ export function CreditNotePaperTemplate({ 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, + billedToAddress = DefaultPdfTemplateAddressBilledTo, + billedFromAddress = DefaultPdfTemplateAddressBilledFrom, + showBilledFromAddress = true, + showBilledToAddress = true, billedToLabel = 'Billed To', // Total @@ -152,14 +142,16 @@ export function CreditNotePaperTemplate({ {showBilledFromAddress && ( - {companyName}, ...billedFromAddress]} - /> + + {companyName} + + )} {showBilledToAddress && ( - {billedToLabel}, ...billedToAddress]} - /> + + {billedToLabel} + + )} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateCustomize/EstimatePaperTemplate.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateCustomize/EstimatePaperTemplate.tsx index 5985ba3cb..0426b9a30 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateCustomize/EstimatePaperTemplate.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateCustomize/EstimatePaperTemplate.tsx @@ -1,4 +1,4 @@ -import { Stack } from '@/components'; +import { Box, Stack } from '@/components'; import { PaperTemplate, PaperTemplateProps, @@ -8,6 +8,8 @@ import { DefaultPdfTemplateItemDescription, DefaultPdfTemplateStatement, DefaultPdfTemplateItemName, + DefaultPdfTemplateAddressBilledTo, + DefaultPdfTemplateAddressBilledFrom, } from '@/constants/PdfTemplates'; export interface EstimatePaperTemplateProps extends PaperTemplateProps { @@ -31,10 +33,10 @@ export interface EstimatePaperTemplateProps extends PaperTemplateProps { // Address showBilledToAddress?: boolean; - billedToAddress?: Array; + billedToAddress?: string; showBilledFromAddress?: boolean; - billedFromAddress?: Array; + billedFromAddress?: string; billedToLabel?: string; // Totals @@ -74,25 +76,14 @@ export function EstimatePaperTemplate({ 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', - ], + // # Address + billedToAddress = DefaultPdfTemplateAddressBilledTo, + billedFromAddress = DefaultPdfTemplateAddressBilledFrom, showBilledFromAddress = true, showBilledToAddress = true, billedToLabel = 'Billed To', + // #Total total = '$1000.00', totalLabel = 'Total', showTotal = true, @@ -101,10 +92,12 @@ export function EstimatePaperTemplate({ subtotalLabel = 'Subtotal', showSubtotal = true, + // # Customer Note showCustomerNote = true, customerNote = DefaultPdfTemplateStatement, customerNoteLabel = 'Customer Note', + // # Terms & Conditions showTermsConditions = true, termsConditions = DefaultPdfTemplateTerms, termsConditionsLabel = 'Terms & Conditions', @@ -145,13 +138,11 @@ export function EstimatePaperTemplate({ {estimateNumebr} )} - {showEstimateDate && ( {estimateDate} )} - {showExpirationDate && ( {expirationDate} @@ -161,14 +152,16 @@ export function EstimatePaperTemplate({ {showBilledFromAddress && ( - {companyName}, ...billedFromAddress]} - /> + + {companyName} + + )} {showBilledToAddress && ( - {billedToLabel}, ...billedToAddress]} - /> + + {billedToLabel} + + )} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate.tsx index 6620f0e28..e2a469fe9 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate.tsx @@ -1,11 +1,13 @@ import React from 'react'; import { PaperTemplate, PaperTemplateTotalBorder } from './PaperTemplate'; -import { Stack } from '@/components'; +import { Box, Stack } from '@/components'; import { DefaultPdfTemplateTerms, DefaultPdfTemplateItemDescription, DefaultPdfTemplateStatement, DefaultPdfTemplateItemName, + DefaultPdfTemplateAddressBilledTo, + DefaultPdfTemplateAddressBilledFrom, } from '@/constants/PdfTemplates'; interface PapaerLine { item?: string; @@ -89,8 +91,8 @@ export interface InvoicePaperTemplateProps { lines?: Array; taxes?: Array; - billedFromAddres?: Array; - billedToAddress?: Array; + billedFromAddres?: string; + billedToAddress?: string; } export function InvoicePaperTemplate({ @@ -169,21 +171,8 @@ export function InvoicePaperTemplate({ statementLabel = 'Statement', showStatement = true, statement = DefaultPdfTemplateStatement, - 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', - ], + billedToAddress = DefaultPdfTemplateAddressBilledTo, + billedFromAddres = DefaultPdfTemplateAddressBilledFrom, }: InvoicePaperTemplateProps) { return ( {showBilledFromAddress && ( - {companyName}, ...billedFromAddres]} - /> + + {companyName} + + )} {showBillingToAddress && ( - {billedToLabel}, ...billedToAddress]} - /> + + {billedToLabel} + + )} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/PaperTemplate.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/PaperTemplate.tsx index 3b6e20d97..a6b8411dd 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/PaperTemplate.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/PaperTemplate.tsx @@ -1,7 +1,7 @@ import React from 'react'; import clsx from 'classnames'; import { get } from 'lodash'; -import { Group, GroupProps, Stack } from '@/components'; +import { Box, Group, GroupProps, Stack } from '@/components'; import styles from './InvoicePaperTemplate.module.scss'; export interface PaperTemplateProps { @@ -123,16 +123,14 @@ PaperTemplate.AddressesGroup = (props: GroupProps) => { return ; }; PaperTemplate.Address = ({ - items, + children, }: { - items: Array; + children: React.ReactNode; }) => { return ( - - {items.map((item, index) => ( -
{item}
- ))} -
+ + {children} + ); }; diff --git a/packages/webapp/src/containers/Sales/PaymentsReceived/PaymentReceivedCustomize/PaymentReceivedPaperTemplate.tsx b/packages/webapp/src/containers/Sales/PaymentsReceived/PaymentReceivedCustomize/PaymentReceivedPaperTemplate.tsx index 114842bf8..496afddd1 100644 --- a/packages/webapp/src/containers/Sales/PaymentsReceived/PaymentReceivedCustomize/PaymentReceivedPaperTemplate.tsx +++ b/packages/webapp/src/containers/Sales/PaymentsReceived/PaymentReceivedCustomize/PaymentReceivedPaperTemplate.tsx @@ -1,15 +1,19 @@ -import { Stack } from '@/components'; +import { Box, Stack } from '@/components'; import { PaperTemplate, PaperTemplateProps, PaperTemplateTotalBorder, } from '../../Invoices/InvoiceCustomize/PaperTemplate'; +import { + DefaultPdfTemplateAddressBilledFrom, + DefaultPdfTemplateAddressBilledTo, +} from '@/constants/PdfTemplates'; export interface PaymentReceivedPaperTemplateProps extends PaperTemplateProps { - billedToAddress?: Array; - showBillingToAddress?: boolean; + billedToAddress?: string; + showBilledToAddress?: boolean; - billedFromAddress?: Array; + billedFromAddress?: string; showBilledFromAddress?: boolean; billedToLabel?: string; @@ -52,23 +56,10 @@ export function PaymentReceivedPaperTemplate({ // # Company name 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', - ], + billedToAddress = DefaultPdfTemplateAddressBilledTo, + billedFromAddress = DefaultPdfTemplateAddressBilledFrom, showBilledFromAddress, - showBillingToAddress, + showBilledToAddress, billedToLabel = 'Billed To', total = '$1000.00', @@ -119,14 +110,16 @@ export function PaymentReceivedPaperTemplate({ {showBilledFromAddress && ( - {companyName}, ...billedFromAddress]} - /> + + {companyName} + + )} - {showBillingToAddress && ( - {billedToLabel}, ...billedToAddress]} - /> + {showBilledToAddress && ( + + {billedToLabel} + + )} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptCustomize/ReceiptPaperTemplate.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptCustomize/ReceiptPaperTemplate.tsx index ed00a2775..00faba13b 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptCustomize/ReceiptPaperTemplate.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptCustomize/ReceiptPaperTemplate.tsx @@ -1,4 +1,4 @@ -import { Stack } from '@/components'; +import { Box, Stack } from '@/components'; import { PaperTemplate, PaperTemplateProps, @@ -8,12 +8,14 @@ import { DefaultPdfTemplateItemDescription, DefaultPdfTemplateStatement, DefaultPdfTemplateItemName, + DefaultPdfTemplateAddressBilledTo, + DefaultPdfTemplateAddressBilledFrom, } from '@/constants/PdfTemplates'; export interface ReceiptPaperTemplateProps extends PaperTemplateProps { // Addresses - billedToAddress?: Array; - billedFromAddress?: Array; + billedToAddress?: string; + billedFromAddress?: string; showBilledFromAddress?: boolean; showBilledToAddress?: boolean; billedToLabel?: string; @@ -71,21 +73,8 @@ export function ReceiptPaperTemplate({ 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', - ], + billedToAddress = DefaultPdfTemplateAddressBilledTo, + billedFromAddress = DefaultPdfTemplateAddressBilledFrom, showBilledFromAddress = true, showBilledToAddress = true, billedToLabel = 'Billed To', @@ -147,14 +136,16 @@ export function ReceiptPaperTemplate({ {showBilledFromAddress && ( - {companyName}, ...billedFromAddress]} - /> + + {companyName} + + )} {showBilledToAddress && ( - {billedToLabel}, ...billedToAddress]} - /> + + {billedToLabel} + + )} diff --git a/packages/webapp/src/hooks/query/organization.tsx b/packages/webapp/src/hooks/query/organization.tsx index 3a68efa89..72aaad94a 100644 --- a/packages/webapp/src/hooks/query/organization.tsx +++ b/packages/webapp/src/hooks/query/organization.tsx @@ -77,12 +77,12 @@ export function useOrganizationSetup() { /** * Saves the settings. */ -export function useUpdateOrganization(props) { +export function useUpdateOrganization(props = {}) { const queryClient = useQueryClient(); const apiRequest = useApiRequest(); return useMutation( - (information) => apiRequest.put('organization', information), + (information: any) => apiRequest.put('organization', information), { onSuccess: () => { queryClient.invalidateQueries(t.ORGANIZATION_CURRENT); diff --git a/packages/webapp/src/hooks/query/payment-link.ts b/packages/webapp/src/hooks/query/payment-link.ts index 630abf437..1eadbed73 100644 --- a/packages/webapp/src/hooks/query/payment-link.ts +++ b/packages/webapp/src/hooks/query/payment-link.ts @@ -54,6 +54,23 @@ export function useCreatePaymentLink( // Get Invoice Payment Link // ----------------------------------------- +interface GetInvoicePaymentLinkAddressResponse { + address_1: string; + address_2: string; + postal_code: string; + city: string; + state_province: string; + phone: string; +} + +interface GetInvoicePaymentLinkOrganizationRes { + address: Record; + name: string; + primaryColor: string; + logoUri: string; + addressTextFormatted: string; +} + export interface GetInvoicePaymentLinkResponse { dueAmount: number; dueAmountFormatted: string; @@ -70,7 +87,6 @@ export interface GetInvoicePaymentLinkResponse { totalFormatted: string; totalLocalFormatted: string; customerName: string; - companyName: string; invoiceMessage: string; termsConditions: string; entries: Array<{ @@ -89,7 +105,11 @@ export interface GetInvoicePaymentLinkResponse { taxRateAmountFormatted: string; taxRateCode: string; }>; + organization: GetInvoicePaymentLinkOrganizationRes; + hasStripePaymentMethod: boolean; + isReceivable: boolean; } + /** * Fetches the sharable invoice link metadata for a given link ID. * @param {string} linkId - The ID of the link to fetch metadata for. diff --git a/packages/webapp/src/routes/preferences.tsx b/packages/webapp/src/routes/preferences.tsx index 5f3cc1088..f78e8b314 100644 --- a/packages/webapp/src/routes/preferences.tsx +++ b/packages/webapp/src/routes/preferences.tsx @@ -9,6 +9,11 @@ export const getPreferenceRoutes = () => [ component: lazy(() => import('@/containers/Preferences/General/General')), exact: true, }, + { + path: `${BASE_URL}/branding`, + component: lazy(() => import('../containers/Preferences/Branding/PreferencesBrandingPage')), + exact: true, + }, { path: `${BASE_URL}/users`, component: lazy(() => import('../containers/Preferences/Users/Users')),