diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/PaymentSection.tsx b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/PaymentSection.tsx index 7393808d4..0d920f1c6 100644 --- a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/PaymentSection.tsx +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/PaymentSection.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { components } from '@/api'; @@ -5,14 +6,13 @@ import { CreateReceivablesFormProps } from '@/components/receivables/InvoiceDeta import { RHFTextField } from '@/components/RHF/RHFTextField'; import { t } from '@lingui/macro'; import { useLingui } from '@lingui/react'; -import { - Box, - FormHelperText, - MenuItem, - Skeleton, - useTheme, -} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import { Box, MenuItem, Skeleton, useTheme, Button } from '@mui/material'; +import { + PaymentTermsSummary, + PaymentTermsDialog, +} from './components/PaymentTerms'; import type { SectionGeneralProps } from './Section.types'; type Props = SectionGeneralProps & { @@ -22,46 +22,95 @@ type Props = SectionGeneralProps & { } | undefined; isLoading: boolean; + selectedPaymentTerm?: components['schemas']['PaymentTermsResponse']; + onPaymentTermsChange: ( + id?: components['schemas']['PaymentTermsResponse']['id'] + ) => void; }; +enum TermsDialogState { + Closed, + Create, + Update, +} + export const PaymentSection = ({ disabled, paymentTerms, isLoading, + selectedPaymentTerm, + onPaymentTermsChange, }: Props) => { const { i18n } = useLingui(); const { control } = useFormContext(); + const [termsDialogState, setTermsDialogState] = useState( + TermsDialogState.Closed + ); + + const openEditDialog = () => { + setTermsDialogState(TermsDialogState.Update); + }; + + const onTermsDialogClosed = (id?: string, deleted?: boolean) => { + setTermsDialogState(TermsDialogState.Closed); + + if (id || deleted) { + onPaymentTermsChange(id); + } + }; return ( {isLoading ? ( ) : ( - - {!paymentTerms?.data?.length && } - {paymentTerms?.data?.map(({ id, name, description }) => ( - - {name} - {description && ` (${description})`} - - ))} - - )} - - {!isLoading && !paymentTerms?.data?.length && ( - - {t( - i18n - )`There is no payment terms available. Please create one in the settings.`} - + <> + + + {paymentTerms?.data?.map(({ id, name, description }) => ( + + {name} + {description && ` (${description})`} + + ))} + + {selectedPaymentTerm && ( + + + + )} + + )} ); diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx index ea12f7ff1..87dfffb83 100644 --- a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx @@ -21,8 +21,11 @@ export const FullfillmentSummary = ({ disabled }: SectionGeneralProps) => { const { api } = useMoniteContext(); - const { data: paymentTerms, isLoading: isPaymentTermsLoading } = - api.paymentTerms.getPaymentTerms.useQuery(); + const { + data: paymentTerms, + isLoading: isPaymentTermsLoading, + refetch, + } = api.paymentTerms.getPaymentTerms.useQuery(); const { root } = useRootElements(); const dateTimeFormat = useDateTimeFormat(); @@ -38,6 +41,11 @@ export const FullfillmentSummary = ({ disabled }: SectionGeneralProps) => { (term) => term.id === paymentTermsId ); + const handlePaymentTermsChange = async (newId: string = '') => { + await refetch(); + setValue('payment_terms_id', newId); + }; + return ( <> @@ -146,6 +154,8 @@ export const FullfillmentSummary = ({ disabled }: SectionGeneralProps) => { disabled={disabled} paymentTerms={paymentTerms} isLoading={isPaymentTermsLoading} + selectedPaymentTerm={selectedPaymentTerm} + onPaymentTermsChange={handlePaymentTermsChange} /> diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DeletePaymentTerms.tsx b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DeletePaymentTerms.tsx new file mode 100644 index 000000000..3f7c9b2d8 --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DeletePaymentTerms.tsx @@ -0,0 +1,51 @@ +import { components } from '@/api'; +import { Dialog } from '@/components'; +import { i18n } from '@lingui/core'; +import { t } from '@lingui/macro'; +import { + DialogTitle, + DialogContent, + DialogActions, + Button, +} from '@mui/material'; + +import { usePaymentTermsApi } from './usePaymentTermsApi'; + +export interface DeletePaymentTermsProps { + show: boolean; + closeDialog: (payload: boolean) => void; + paymentTermsId: components['schemas']['PaymentTermsResponse']['id']; +} + +export const DeletePaymentTerms = ({ + show, + closeDialog, + paymentTermsId, +}: DeletePaymentTermsProps) => { + const { deletePaymentTerm } = usePaymentTermsApi({ + onSuccessfullChange: () => closeDialog(true), + }); + + return ( + + {t(i18n)`Delete payment term?`} + + {t( + i18n + )`You won’t be able to use it to create new invoices, but it won’t affect the existing invoices with this term and their future copies.`} + + + + + + + ); +}; diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DiscountForm.tsx b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DiscountForm.tsx new file mode 100644 index 000000000..a2ced3a91 --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DiscountForm.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { UseFormRegister, FieldErrors, FieldError } from 'react-hook-form'; + +import { i18n } from '@lingui/core'; +import { t } from '@lingui/macro'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { + Alert, + Typography, + Button, + Stack, + Card, + CardContent, + CardHeader, + TextField, +} from '@mui/material'; + +import { TermField, PaymentTermsFields } from './types'; + +export interface DiscountFormProps { + field: TermField; + index: number; + isLast: boolean; + remove: () => void; + register: UseFormRegister; + errors: FieldErrors; +} + +export const DiscountForm = ({ + field, + index, + isLast, + remove, + register, + errors, +}: DiscountFormProps) => { + const [error, setError] = useState(''); + + useEffect(() => { + if (Object.keys(errors).length > 0) { + const flatErrors = Object.values(errors[field] || {}).map( + (error) => (error as FieldError)?.message + ); + + setError(flatErrors[0]); + } else { + setError(''); + } + }, [errors, field]); + + return ( + <> + {!!error && ( + + {error} + + )} + + } + onClick={remove} + > + {t(i18n)`Delete`} + + } + /> + + + {t( + i18n + )`Pay in`} + + {t( + i18n + )`to get discount`} + + + + + + ); +}; diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTerms.test.tsx b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTerms.test.tsx new file mode 100644 index 000000000..5d9b2a66d --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTerms.test.tsx @@ -0,0 +1,314 @@ +import { renderWithClient } from '@/utils/test-utils'; +import { requestFn } from '@openapi-qraft/react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; + +import { PaymentTermsDialog } from './PaymentTermsDialog'; + +const closeDialogMock = jest.fn(); +const requestFnMock = requestFn as jest.MockedFunction; + +describe('PaymentTerms', () => { + test('should show a dialog for payment term creation', async () => { + renderWithClient(); + + expect(screen.getByText('Create payment term')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Payment due')).toBeInTheDocument(); + expect(screen.getAllByText('Description')[0]).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /add discount/i }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument(); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + expect(cancelButton).toBeInTheDocument(); + + await fireEvent.click(cancelButton); + + expect(closeDialogMock).toHaveBeenCalledWith(); + }); + + describe('when user clicks `create` without filling the form', () => { + test('should show error message', async () => { + renderWithClient( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => + expect( + screen.getByText( + 'To create a preset you need to fill out all the required fields' + ) + ).toBeInTheDocument() + ); + }); + }); + + describe('when user fills the form', () => { + test('should send a correct request', async () => { + renderWithClient( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + const paymentDueInput = screen.getByRole('spinbutton', { + name: 'Payment due', + }); + const descriptionInput = screen.getByRole('textbox', { + name: 'Description', + }); + + fireEvent.change(nameInput, { target: { value: 'Standard terms' } }); + fireEvent.change(paymentDueInput, { target: { value: 14 } }); + fireEvent.change(descriptionInput, { + target: { value: 'Pay in 14 days' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => + expect(requestFnMock.mock.lastCall?.[1].body).toEqual({ + term_final: { + number_of_days: 14, + }, + name: 'Standard terms', + description: 'Pay in 14 days', + }) + ); + }); + + describe('when user adds discounts', () => { + test('should show discount cards', async () => { + renderWithClient( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Add discount' })); + + expect(screen.getByText('Discount 1')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Delete' }) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Add discount' })); + + const deleteButtons = screen.getAllByRole('button', { name: 'Delete' }); + + expect(screen.getByText('Discount 2')).toBeInTheDocument(); + expect(deleteButtons.length).toBe(2); + expect(deleteButtons[0]).toBeDisabled(); + + fireEvent.click(deleteButtons[1]); + + expect(screen.queryByText('Discount 2')).not.toBeInTheDocument(); + expect(deleteButtons[0]).not.toBeDisabled(); + }); + + test('should show correct validation messages', async () => { + renderWithClient( + + ); + + const createButton = screen.getByRole('button', { name: 'Create' }); + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + const paymentDueInput = screen.getByRole('spinbutton', { + name: 'Payment due', + }); + const descriptionInput = screen.getByRole('textbox', { + name: 'Description', + }); + + fireEvent.change(nameInput, { target: { value: 'Standard terms' } }); + fireEvent.change(paymentDueInput, { target: { value: 14 } }); + fireEvent.change(descriptionInput, { + target: { value: 'Pay in 14 days' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Add discount' })); + fireEvent.click(createButton); + + await waitFor(() => + expect( + screen.getByText( + 'To add a discount you need to fill out all the fields' + ) + ).toBeInTheDocument() + ); + + const discount1Days = screen.getAllByRole('spinbutton')[1]; + const discount1Amount = screen.getAllByRole('spinbutton')[2]; + + fireEvent.change(discount1Days, { target: { value: 15 } }); + fireEvent.change(discount1Amount, { target: { value: 20 } }); + + fireEvent.click(createButton); + + await waitFor(() => + expect( + screen.getByText( + 'The number of days in Discount must be less than of Due days' + ) + ).toBeInTheDocument() + ); + + fireEvent.change(discount1Days, { target: { value: 5 } }); + fireEvent.click(screen.getByRole('button', { name: 'Add discount' })); + fireEvent.click(createButton); + + await waitFor(() => + expect( + screen.getByText( + 'To add a discount you need to fill out all the fields' + ) + ).toBeInTheDocument() + ); + + const discount2Days = screen.getAllByRole('spinbutton')[3]; + const discount2Amount = screen.getAllByRole('spinbutton')[4]; + + fireEvent.change(discount2Days, { target: { value: 3 } }); + fireEvent.change(discount2Amount, { target: { value: 10 } }); + fireEvent.click(createButton); + + await waitFor(() => + expect( + screen.getByText( + 'The number of days in Discount 2 must be more than the number of Discount 1 days' + ) + ).toBeInTheDocument() + ); + }); + + test('should send correct request', async () => { + renderWithClient( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + const paymentDueInput = screen.getByRole('spinbutton', { + name: 'Payment due', + }); + const descriptionInput = screen.getByRole('textbox', { + name: 'Description', + }); + + fireEvent.change(nameInput, { target: { value: 'Standard terms' } }); + fireEvent.change(paymentDueInput, { target: { value: 14 } }); + fireEvent.change(descriptionInput, { + target: { value: 'Pay in 14 days' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Add discount' })); + fireEvent.click(screen.getByRole('button', { name: 'Add discount' })); + + const numberInputs = screen.getAllByRole('spinbutton'); + + // Discount 1 + fireEvent.change(numberInputs[1], { target: { value: 5 } }); + fireEvent.change(numberInputs[2], { target: { value: 10 } }); + // Discount 2 + fireEvent.change(numberInputs[3], { target: { value: 10 } }); + fireEvent.change(numberInputs[4], { target: { value: 5 } }); + + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => + expect(requestFnMock.mock.lastCall?.[1].body).toEqual({ + term_final: { + number_of_days: 14, + }, + name: 'Standard terms', + description: 'Pay in 14 days', + term_1: { + number_of_days: 5, + discount: 10, + }, + term_2: { + number_of_days: 10, + discount: 5, + }, + }) + ); + }); + }); + }); + + describe('when user edits payment terms', () => { + const selectedTerm = { + id: '123', + name: '30 term', + description: 'Pay in 30 days', + term_final: { + number_of_days: 30, + }, + term_1: { + number_of_days: 15, + discount: 10, + }, + term_2: { + number_of_days: 20, + discount: 3, + }, + }; + + test('should show prefilled form', async () => { + renderWithClient( + + ); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + + expect(screen.getByDisplayValue(selectedTerm.name)).toBeInTheDocument(); + expect( + screen.getByDisplayValue(selectedTerm.description) + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue(selectedTerm.term_final.number_of_days) + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue(selectedTerm.term_1.number_of_days) + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue(selectedTerm.term_1.discount) + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue(selectedTerm.term_2.number_of_days) + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue(selectedTerm.term_2.discount) + ).toBeInTheDocument(); + + fireEvent.change(screen.getByDisplayValue(selectedTerm.name), { + target: { value: 'New name' }, + }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(requestFnMock.mock.lastCall?.[1].body).toEqual({ + name: 'New name', + description: 'Pay in 30 days', + term_final: { + number_of_days: 30, + }, + term_1: { + number_of_days: 15, + discount: 10, + }, + term_2: { + number_of_days: 20, + discount: 3, + }, + }); + expect(requestFnMock.mock.lastCall?.[1].parameters?.path).toEqual({ + payment_terms_id: '123', + }); + }); + }); + }); +}); diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsDialog.tsx b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsDialog.tsx new file mode 100644 index 000000000..00f005a90 --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsDialog.tsx @@ -0,0 +1,99 @@ +import { useId, useState } from 'react'; + +import { components } from '@/api'; +import { Dialog } from '@/components'; +import { i18n } from '@lingui/core'; +import { t } from '@lingui/macro'; +import { + Typography, + DialogTitle, + DialogContent, + Divider, + DialogActions, + Button, + Stack, +} from '@mui/material'; + +import { DeletePaymentTerms } from './DeletePaymentTerms'; +import { PaymentTermsForm } from './PaymentTermsForm'; + +export interface PaymentTermsDialogProps { + show: boolean; + closeDialog: ( + id?: components['schemas']['PaymentTermsResponse']['id'], + isDeleted?: boolean + ) => void; + selectedTerm?: components['schemas']['PaymentTermsResponse']; +} + +export const PaymentTermsDialog = ({ + show, + closeDialog, + selectedTerm, +}: PaymentTermsDialogProps) => { + const formName = `Monite-Form-paymentTerms-${useId()}`; + + const submitButtonText = selectedTerm ? t(i18n)`Save` : t(i18n)`Create`; + const titleText = selectedTerm + ? t(i18n)`Edit payment term` + : t(i18n)`Create payment term`; + + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const closeDeleteDialog = (isDeleted: boolean) => { + setShowDeleteDialog(false); + + closeDialog(undefined, isDeleted); + }; + + return ( + <> + closeDialog()}> + + {titleText} + + + + + + + + + {selectedTerm && ( + + )} + + + + + + + + {selectedTerm && ( + + )} + + ); +}; diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx new file mode 100644 index 000000000..892d242d4 --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx @@ -0,0 +1,208 @@ +import { useState, FormEvent, ReactNode, useEffect } from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; + +import { components } from '@/api'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { t } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import AddIcon from '@mui/icons-material/Add'; +import { + Typography, + Button, + Stack, + Card, + CardContent, + Box, + Alert, + TextField, +} from '@mui/material'; + +import { DiscountForm } from './DiscountForm'; +import { getValidation } from './paymentTermsValidation'; +import { PaymentTermsFields, TermField } from './types'; +import { usePaymentTermsApi } from './usePaymentTermsApi'; + +const MAX_DISCOUNTS = 2; + +interface PaymentTermsFormProps { + formName: string; + selectedTerm?: components['schemas']['PaymentTermsResponse'] | null; + onTermsChange?: ( + id?: components['schemas']['PaymentTermsResponse']['id'] + ) => void; +} + +export const PaymentTermsForm = ({ + formName, + selectedTerm, + onTermsChange, +}: PaymentTermsFormProps) => { + const { id: selectedTermId, ...selectedTermsFields } = selectedTerm || {}; + const [error, setError] = useState(); + const { i18n } = useLingui(); + const methods = useForm({ + defaultValues: { ...selectedTermsFields }, + resolver: yupResolver(getValidation(i18n, () => methods.watch())), + }); + + const { + handleSubmit, + formState: { errors }, + register, + } = methods; + + const { createPaymentTerm, updatePaymentTerm } = usePaymentTermsApi({ + onCreationError: () => + setError( + <> + {t(i18n)`Failed to create payment term, please try again.`} +
+ {t(i18n)`If this error recurs, contact your admin.`} + + ), + onSuccessfullChange: onTermsChange, + }); + + const [discountForms, setDiscountForms] = useState(() => { + if (selectedTerm) { + return [TermField.Term1, TermField.Term2].filter((term) => + Boolean(selectedTerm[term]) + ); + } + + return []; + }); + + useEffect(() => { + const sectionErrors = Object.keys(errors).filter( + (errorKey) => + ![...(Object.values(TermField) as string[])].includes(errorKey) + ); + if (sectionErrors.length > 0) { + setError( + t(i18n)`To create a preset you need to fill out all the required fields` + ); + } else { + setError(null); + } + }, [errors, i18n]); + + const addDiscountForm = () => { + if (discountForms.length === MAX_DISCOUNTS) return; + const nextDiscount = discountForms.length + ? TermField.Term2 + : TermField.Term1; + + setDiscountForms((prev) => [...prev, nextDiscount]); + methods.setValue(nextDiscount, { number_of_days: null, discount: null }); + }; + + const removeDiscountForm = () => { + if (!discountForms.length) return; + + const discountToRemove = discountForms[discountForms.length - 1]; + setDiscountForms((prev) => prev.slice(0, -1)); + methods.setValue(discountToRemove, null); + }; + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + + handleSubmit((values) => { + const { term_1, term_2, ...rest } = values; + const payload = { ...rest }; + + if (term_1) { + Object.assign(payload, { term_1 }); + } + + if (term_2) { + Object.assign(payload, { term_2 }); + } + + if (selectedTermId) { + updatePaymentTerm(selectedTermId, payload); + } else { + createPaymentTerm(payload); + } + })(event); + }; + + return ( + + {!!error && ( + + {error} + + )} +
+ {t(i18n)`Settings`} + + + + + + + + + + + {t( + i18n + )`Early payment discounts`} + + {t(i18n)`Offer a discount to your customer if they pay early.`} +
+ {t(i18n)`You can set up to 2 early payment discounts per invoice.`} +
+
+ + {discountForms.map((field, index) => ( + + ))} + + {discountForms.length < MAX_DISCOUNTS && ( + + )} +
+
+ ); +}; diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx new file mode 100644 index 000000000..37ba5e4a2 --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx @@ -0,0 +1,54 @@ +import { components } from '@/api'; +import { t } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { + CalendarToday as CalendarTodayIcon, + Sell as SellIcon, +} from '@mui/icons-material'; +import { Card, CardContent, Button } from '@mui/material'; + +import { PaymentTermSummaryItem } from './PaymentTermsSummaryItem'; + +export interface PaymentTermsSummaryProps { + paymentTerm: components['schemas']['PaymentTermsResponse']; + openEditDialog: () => void; +} + +export const PaymentTermsSummary = ({ + paymentTerm, + openEditDialog, +}: PaymentTermsSummaryProps) => { + const { i18n } = useLingui(); + const { term_final, term_1, term_2 } = paymentTerm; + + return ( + + + {term_1 && ( + } + leftLine={t(i18n)`Pay in the first ${term_1.number_of_days} days`} + rightLine={t(i18n)`${term_1.discount}% discount`} + sx={{ mb: 2 }} + /> + )} + {term_2 && ( + } + leftLine={t(i18n)`Pay in the first ${term_2.number_of_days} days`} + rightLine={t(i18n)`${term_2.discount}% discount`} + sx={{ mb: 2 }} + /> + )} + } + leftLine={t(i18n)`Payment due`} + rightLine={t(i18n)`${term_final?.number_of_days} days`} + /> + + + + ); +}; diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummaryItem.tsx b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummaryItem.tsx new file mode 100644 index 000000000..66e16a0f1 --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummaryItem.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; + +import { SvgIconProps, SxProps, Stack, Typography } from '@mui/material'; + +export interface PaymentTermSummaryItemProps { + renderIcon: (props: SvgIconProps) => ReactNode; + leftLine: string; + rightLine: string; + sx?: SxProps; +} + +export const PaymentTermSummaryItem = ({ + renderIcon, + leftLine, + rightLine, + sx, +}: PaymentTermSummaryItemProps) => { + return ( + + + {renderIcon({ sx: { mr: 1, fontSize: 16 } })} + {leftLine} + + {rightLine} + + ); +}; diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/index.ts b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/index.ts new file mode 100644 index 000000000..9abe933d6 --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/index.ts @@ -0,0 +1,2 @@ +export * from './PaymentTermsSummary'; +export * from './PaymentTermsDialog'; diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts new file mode 100644 index 000000000..6724e35a0 --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts @@ -0,0 +1,92 @@ +import { type I18n } from '@lingui/core'; +import { t } from '@lingui/macro'; + +import * as yup from 'yup'; + +const lessThanDue = (value: number | undefined, getFormState: () => any) => { + const form = getFormState(); + const termFinalDays = form?.term_final?.number_of_days; + + if (!value || !termFinalDays) { + return true; + } + + return value < termFinalDays; +}; + +const largerThanPreviousDiscount = ( + value: number | undefined, + getFormState: () => any +) => { + const form = getFormState(); + const previousTermDays = form?.term_1?.number_of_days; + + if (!value || !previousTermDays) { + return true; + } + + return value > previousTermDays; +}; + +export const getValidation = (i18n: I18n, getFormState: () => any) => + yup.object({ + name: yup.string().max(100).required(), + term_final: yup.object().shape({ + number_of_days: yup.number().required(), + }), + description: yup.string().max(250).optional(), + term_1: yup + .object() + .shape({ + number_of_days: yup + .number() + .default(null) + .typeError( + t(i18n)`To add a discount you need to fill out all the fields` + ) + .test( + 'less-than-due', + t( + i18n + )`The number of days in Discount must be less than of Due days`, + (value) => lessThanDue(value, getFormState) + ), + discount: yup + .number() + .typeError( + t(i18n)`To add a discount you need to fill out all the fields` + ), + }) + .default(undefined) + .optional(), + term_2: yup + .object() + .shape({ + number_of_days: yup + .number() + .typeError( + t(i18n)`To add a discount you need to fill out all the fields` + ) + .test( + 'less-than-due', + t( + i18n + )`The number of days in Discount must be less than of Due days`, + (value) => lessThanDue(value, getFormState) + ) + .test( + 'larger-than-previous', + t( + i18n + )`The number of days in Discount 2 must be more than the number of Discount 1 days`, + (value) => largerThanPreviousDiscount(value, getFormState) + ), + discount: yup + .number() + .typeError( + t(i18n)`To add a discount you need to fill out all the fields` + ), + }) + .default(undefined) + .optional(), + }); diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/types.ts b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/types.ts new file mode 100644 index 000000000..2783c00d4 --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/types.ts @@ -0,0 +1,19 @@ +export interface PaymentTermsDiscountFields { + number_of_days: number | null; + discount: number | null; +} + +export interface PaymentTermsFields { + name: string; + description?: string; + term_final: { + number_of_days: number; + }; + term_1?: PaymentTermsDiscountFields | null; + term_2?: PaymentTermsDiscountFields | null; +} + +export enum TermField { + Term1 = 'term_1', + Term2 = 'term_2', +} diff --git a/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/usePaymentTermsApi.ts b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/usePaymentTermsApi.ts new file mode 100644 index 000000000..0ccd3a34c --- /dev/null +++ b/packages/sdk-react/src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/usePaymentTermsApi.ts @@ -0,0 +1,96 @@ +import { toast } from 'react-hot-toast'; + +import { components } from '@/api'; +import { useMoniteContext } from '@/core/context/MoniteContext'; +import { i18n } from '@lingui/core'; +import { t } from '@lingui/macro'; + +interface UsePaymentTermsApiParams { + onDelete?: () => void; + onCreationError?: () => void; + onSuccessfullChange?: ( + id?: components['schemas']['PaymentTermsResponse']['id'] + ) => void; +} + +export const usePaymentTermsApi = ({ + onCreationError, + onDelete, + onSuccessfullChange, +}: UsePaymentTermsApiParams = {}) => { + const { api, queryClient } = useMoniteContext(); + + const createMutation = api.paymentTerms.postPaymentTerms.useMutation( + undefined, + { + onSuccess: (paymentTerm) => { + api.paymentTerms.getPaymentTerms.invalidateQueries(queryClient); + toast.success(t(i18n)`Payment term created`); + onSuccessfullChange?.(paymentTerm.id); + }, + onError: () => { + onCreationError?.(); + }, + } + ); + + const updateMutation = api.paymentTerms.patchPaymentTermsId.useMutation( + undefined, + { + onSuccess: (paymentTerm) => { + api.paymentTerms.getPaymentTerms.invalidateQueries(queryClient); + toast.success(t(i18n)`Payment term updated`); + onSuccessfullChange?.(paymentTerm.id); + }, + onError: () => { + toast.error(t(i18n)`Error updating payment term`); + }, + } + ); + + const deleteMutation = api.paymentTerms.deletePaymentTermsId.useMutation( + undefined, + { + onSuccess: () => { + toast.success(t(i18n)`Payment term successfully deleted`); + onDelete?.(); + onSuccessfullChange?.(); + }, + onError: () => { + toast.error(t(i18n)`Error deleting payment term`); + }, + } + ); + + const createPaymentTerm = async ( + values: components['schemas']['PaymentTermsCreatePayload'] + ) => { + await createMutation.mutateAsync({ body: values }); + }; + + const updatePaymentTerm = async ( + id: string, + values: components['schemas']['PaymentTermsUpdatePayload'] + ) => { + await updateMutation.mutateAsync({ + path: { + payment_terms_id: id, + }, + body: values, + }); + }; + + const deletePaymentTerm = async (id: string) => { + await deleteMutation.mutateAsync({ + path: { + payment_terms_id: id, + }, + }); + }; + + return { + createPaymentTerm, + updatePaymentTerm, + deletePaymentTerm, + }; +}; diff --git a/packages/sdk-react/src/core/i18n/locales/en/messages.po b/packages/sdk-react/src/core/i18n/locales/en/messages.po index 2a4af2a28..332a0ce15 100644 --- a/packages/sdk-react/src/core/i18n/locales/en/messages.po +++ b/packages/sdk-react/src/core/i18n/locales/en/messages.po @@ -67,7 +67,7 @@ msgstr "{0, select, credit_note {Credit Note has been sent} invoice {Invoice has #: src/components/onboarding/OnboardingBankAccount/OnboardingBankAccount.tsx:81 #: src/components/onboarding/OnboardingPersonsReview/OnboardingAddressView/OnboardingAddressView.tsx:20 -#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:64 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:72 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/InvoiceRecurrenceCancelModal.tsx:89 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/ReceivableRecurrence/InvoiceRecurrenceDetails.tsx:37 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/ReceivableRecurrence/InvoiceRecurrenceDetails.tsx:46 @@ -84,6 +84,10 @@ msgstr "{0} - {1}" msgid "{0} ({code})" msgstr "{0} ({code})" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx:46 +msgid "{0} days" +msgstr "{0} days" + #: src/core/queries/useReceivables.ts:43 msgid "{0} has been created" msgstr "{0} has been created" @@ -100,6 +104,11 @@ msgstr "{0} has been updated" msgid "{0}/{1} issued" msgstr "{0}/{1} issued" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx:31 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx:39 +msgid "{0}% discount" +msgstr "{0}% discount" + #: src/core/utils/getBankAccountName.tsx:14 msgid "{bankAccountName} (Default)" msgstr "{bankAccountName} (Default)" @@ -313,6 +322,10 @@ msgstr "Add contact" msgid "Add contact person" msgstr "Add contact person" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:203 +msgid "Add discount" +msgstr "Add discount" + #: src/components/payables/PayableDetails/PayableLineItemsForm/PayableLineItemsForm.tsx:172 #: src/components/receivables/InvoiceDetails/CreateReceivable/components/ProductsTable.tsx:311 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/ItemsSection.tsx:400 @@ -1253,6 +1266,8 @@ msgstr "Canadian Dollar" #: src/components/products/ProductDetails/ProductEditForm/ProductEditForm.tsx:199 #: src/components/receivables/InvoiceDetails/CreateReceivable/components/ProductsTable.tsx:552 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/CreateCounterpartDialog.tsx:162 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DeletePaymentTerms.tsx:38 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsDialog.tsx:80 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/BeforeDueDateReminderForm.tsx:459 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/OverdueReminderForm.tsx:295 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/EditInvoiceDetails.tsx:168 @@ -1892,6 +1907,7 @@ msgstr "Court Costs, Including Alimony and Child Support - Courts of Law" #: src/components/counterparts/CounterpartDetails/CounterpartForm/CounterpartIndividualForm/CounterpartIndividualForm.tsx:395 #: src/components/counterparts/CounterpartDetails/CounterpartForm/CounterpartOrganizationForm/CounterpartOrganizationForm.tsx:403 #: src/components/products/ProductDetails/ProductCreate/CreateProduct.tsx:133 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsDialog.tsx:36 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/BeforeDueDateReminderForm.tsx:469 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/OverdueReminderForm.tsx:308 #: src/components/tags/TagFormModal/TagFormModal.test.tsx:21 @@ -2015,6 +2031,11 @@ msgstr "Create new tag" msgid "Create New Tag" msgstr "Create New Tag" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsDialog.tsx:39 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/PaymentSection.tsx:87 +msgid "Create payment term" +msgstr "Create payment term" + #: src/components/receivables/InvoiceDetails/CreateReceivable/components/ProductsTable.tsx:453 msgid "Create product or service" msgstr "Create product or service" @@ -2196,9 +2217,10 @@ msgstr "Dating/Escort Services" #~ msgid "day" #~ msgstr "day" -#: src/ui/DueDateCell/DueDateCell.tsx:42 -#~ msgid "days" -#~ msgstr "days" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DiscountForm.tsx:87 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:160 +msgid "days" +msgstr "days" #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/OverdueReminderForm.tsx:232 msgid "days after due date" @@ -2251,6 +2273,9 @@ msgstr "default" #: src/components/products/Products.test.tsx:202 #: src/components/products/Products.test.tsx:234 #: src/components/products/ProductsTable/ProductsTable.test.tsx:271 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DeletePaymentTerms.tsx:46 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DiscountForm.tsx:73 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsDialog.tsx:77 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/ReminderFormLayout.tsx:38 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/InvoiceDeleteModal.tsx:75 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/ExistingInvoiceDetails.tsx:274 @@ -2302,6 +2327,10 @@ msgstr "Delete Counterpart \"{0}\"?" msgid "Delete invoice" msgstr "Delete invoice" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DeletePaymentTerms.tsx:31 +msgid "Delete payment term?" +msgstr "Delete payment term?" + #: src/components/products/ProductDeleteModal/ProductDeleteModal.tsx:71 msgid "Delete Product \"{0}\"?" msgstr "Delete Product \"{0}\"?" @@ -2352,6 +2381,7 @@ msgstr "Department Stores" #: src/components/approvalPolicies/ApprovalPolicyDetails/ExistingApprovalPolicyDetailsAdvanced/ExistingApprovalPolicyDetailsAdvanced.tsx:84 #: src/components/products/ProductDetails/components/ProductForm/ProductForm.tsx:165 #: src/components/products/ProductDetails/validation.ts:48 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:165 msgid "Description" msgstr "Description" @@ -2428,6 +2458,10 @@ msgstr "Direct Marketing - Travel" msgid "Director" msgstr "Director" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DiscountForm.tsx:60 +msgid "Discount {0}" +msgstr "Discount {0}" + #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/BeforeDueDateReminderForm.tsx:290 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/BeforeDueDateReminderForm.tsx:429 msgid "Discount date 1" @@ -2534,7 +2568,7 @@ msgstr "Dry Cleaners" #: src/components/payables/PayableDetails/PayableDetailsForm/PayableDetailsForm.tsx:590 #: src/components/payables/PayableDetails/PayableDetailsInfo/PayableDetailsInfo.tsx:319 #: src/components/payables/PayablesTable/Filters/Filters.tsx:81 -#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:60 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:68 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/BeforeDueDateReminderForm.tsx:235 #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/ReminderForm/BeforeDueDateReminderForm.tsx:420 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/reminderCardTermsHelpers.tsx:45 @@ -2578,6 +2612,10 @@ msgstr "Duty Free Stores" #~ msgid "E-mail" #~ msgstr "E-mail" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:175 +msgid "Early payment discounts" +msgstr "Early payment discounts" + #: src/core/utils/currencies.ts:143 msgid "East Caribbean Dollar" msgstr "East Caribbean Dollar" @@ -2671,6 +2709,10 @@ msgstr "Edit invoice" #~ msgid "Edit invoice {0}" #~ msgstr "Edit invoice {0}" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsDialog.tsx:38 +msgid "Edit payment term" +msgstr "Edit payment term" + #: src/components/products/ProductDetails/ProductEditForm/ProductEditForm.tsx:96 #: src/components/products/ProductDetails/ProductEditForm/ProductEditForm.tsx:173 msgid "Edit Product" @@ -2701,6 +2743,10 @@ msgstr "Edit tag" msgid "Edit tag ”{0}”" msgstr "Edit tag ”{0}”" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx:48 +msgid "Edit term" +msgstr "Edit term" + #: src/components/userRoles/UserRoleDetails/UserRoleDetailsDialog/UserRoleDetailsDialog.tsx:363 msgid "Edit User Role" msgstr "Edit User Role" @@ -2865,11 +2911,19 @@ msgstr "Eritrea" msgid "Error creating approval policy" msgstr "Error creating approval policy" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/usePaymentTermsApi.ts:60 +msgid "Error deleting payment term" +msgstr "Error deleting payment term" + #: src/components/approvalPolicies/ApprovalPolicyDetails/ApprovalPolicyForm/ApprovalPolicyForm.tsx:250 #: src/components/approvalPolicies/ApprovalPolicyDetails/useApprovalPolicyDetails.tsx:71 msgid "Error updating approval policy" msgstr "Error updating approval policy" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/usePaymentTermsApi.ts:46 +msgid "Error updating payment term" +msgstr "Error updating payment term" + #: src/core/utils/countries.ts:86 msgid "Estonia" msgstr "Estonia" @@ -2894,6 +2948,10 @@ msgstr "Every first day of the month" msgid "Every last day of the month" msgstr "Every last day of the month" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:149 +msgid "Example: NET 30, –2%/10 days, –1%/20 days" +msgstr "Example: NET 30, –2%/10 days, –1%/20 days" + #: src/components/payables/PayableDetails/PayableDetailsInfo/PayableDetailsInfo.tsx:454 msgid "excl. Tax" msgstr "excl. Tax" @@ -2938,6 +2996,10 @@ msgstr "Failed to create payment link. Please try again." msgid "Failed to create payment record: {errorMessage}" msgstr "Failed to create payment record: {errorMessage}" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:58 +msgid "Failed to create payment term, please try again." +msgstr "Failed to create payment term, please try again." + #: src/components/products/ProductDetails/ProductCreate/CreateProduct.tsx:62 msgid "Failed to create product." msgstr "Failed to create product." @@ -3193,7 +3255,7 @@ msgstr "Front of your identity document" msgid "Fuel Dealers (Non Automotive)" msgstr "Fuel Dealers (Non Automotive)" -#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:94 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:102 #: src/components/receivables/InvoiceDetails/CreateReceivable/validation.ts:126 #: src/components/receivables/InvoiceDetails/CreateReceivable/validation.ts:177 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/sections/PreviewDetailsSection.tsx:30 @@ -3532,6 +3594,10 @@ msgstr "If the error recurs, contact support, please." msgid "If the error recurs, contact support." msgstr "If the error recurs, contact support." +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:60 +msgid "If this error recurs, contact your admin." +msgstr "If this error recurs, contact your admin." + #: src/components/receivables/InvoiceDetails/CreateReceivable/components/ProductsTable.tsx:357 msgid "If you switch the invoice currency and add corresponding items, it will automatically replace the previously added ones." msgstr "If you switch the invoice currency and add corresponding items, it will automatically replace the previously added ones." @@ -3783,7 +3849,7 @@ msgstr "Issue at" #: src/components/payables/PayableDetails/PayableDetailsForm/PayableDetailsForm.tsx:559 #: src/components/payables/PayableDetails/PayableDetailsInfo/PayableDetailsInfo.tsx:300 #: src/components/receivables/CreditNotesTable/CreditNotesTable.tsx:156 -#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:46 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:54 #: src/components/receivables/InvoicesTable/InvoicesTable.tsx:227 msgid "Issue date" msgstr "Issue date" @@ -4460,6 +4526,7 @@ msgstr "N/A" #: src/components/payables/PayableDetails/PayableLineItemsForm/PayableLineItemsForm.tsx:45 #: src/components/products/ProductDetails/components/ProductForm/ProductForm.tsx:73 #: src/components/receivables/InvoiceDetails/CreateReceivable/components/ProductsTable.tsx:123 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:145 #: src/components/receivables/InvoiceDetails/CreateReceivable/validation.ts:79 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/sections/PreviewItemsSection.tsx:98 #: src/components/receivables/InvoiceDetails/InvoiceItems/InvoiceItems.tsx:29 @@ -4830,6 +4897,10 @@ msgstr "Nurseries, Lawn and Garden Supply Stores" msgid "Nursing/Personal Care" msgstr "Nursing/Personal Care" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:179 +msgid "Offer a discount to your customer if they pay early." +msgstr "Offer a discount to your customer if they pay early." + #: src/components/onboarding/dicts/mccCodes.ts:1013 msgid "Office and Commercial Furniture" msgstr "Office and Commercial Furniture" @@ -5025,6 +5096,15 @@ msgctxt "InvoicesTableRowActionMenu" msgid "Pay" msgstr "Pay" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DiscountForm.tsx:79 +msgid "Pay in" +msgstr "Pay in" + +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx:30 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx:38 +msgid "Pay in the first {0} days" +msgstr "Pay in the first {0} days" + #: src/components/payables/PayablesTable/PayablesTable.tsx:521 #: src/components/userRoles/consts.ts:51 msgid "Payable" @@ -5094,6 +5174,11 @@ msgstr "Payment details" msgid "Payment Details" msgstr "Payment Details" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:153 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsSummary.tsx:45 +msgid "Payment due" +msgstr "Payment due" + #: src/components/payables/PayablesTable/components/PayablesTableAction.tsx:58 msgid "Payment not allowed" msgstr "Payment not allowed" @@ -5128,7 +5213,19 @@ msgstr "Payment reminder error" #~ msgid "Payment term" #~ msgstr "Payment term" -#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/PaymentSection.tsx:45 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/usePaymentTermsApi.ts:28 +msgid "Payment term created" +msgstr "Payment term created" + +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/usePaymentTermsApi.ts:55 +msgid "Payment term successfully deleted" +msgstr "Payment term successfully deleted" + +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/usePaymentTermsApi.ts:42 +msgid "Payment term updated" +msgstr "Payment term updated" + +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/PaymentSection.tsx:73 #: src/components/receivables/InvoiceDetails/CreateReceivable/validation.ts:143 #: src/components/receivables/InvoiceDetails/CreateReceivable/validation.ts:194 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/sections/PreviewPaymentDetailsSection.tsx:21 @@ -5936,7 +6033,7 @@ msgstr "Sales" msgid "Sales and Service Tax (Malaysia)" msgstr "Sales and Service Tax (Malaysia)" -#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:139 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:147 msgid "Same as invoice date" msgstr "Same as invoice date" @@ -5971,6 +6068,7 @@ msgstr "Saudi Riyal" #: src/components/payables/PayableDetails/PayableDetails.test.tsx:346 #: src/components/payables/PayableDetails/PayableDetails.test.tsx:372 #: src/components/payables/PayableDetails/PayableDetailsHeader/PayableDetailsHeader.tsx:76 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsDialog.tsx:36 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/ReceivableRecurrence/InvoiceRecurrenceForm.tsx:352 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/TabPanels/PaymentTabPanel/PaymentRecordForm.tsx:150 #: src/components/tags/TagFormModal/TagFormModal.test.tsx:155 @@ -6096,7 +6194,7 @@ msgstr "Services" msgid "Set a recurrence period to issue invoices automatically" msgstr "Set a recurrence period to issue invoices automatically" -#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:68 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:76 msgid "Set by payment term" msgstr "Set by payment term" @@ -6104,7 +6202,7 @@ msgstr "Set by payment term" #~ msgid "Set by payment terms" #~ msgstr "Set by payment terms" -#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:54 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/FullfillmentSummary.tsx:62 msgid "Set on issuance" msgstr "Set on issuance" @@ -6117,6 +6215,10 @@ msgstr "Set reminders" msgid "Set this counterpart as:" msgstr "Set this counterpart as:" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:140 +msgid "Settings" +msgstr "Settings" + #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/TabPanels/PaymentTabPanel/PaymentRecordRow.tsx:44 #: src/components/receivables/InvoiceDetails/ExistingInvoiceDetails/components/TabPanels/PaymentTabPanel/PaymentRecordRow.tsx:45 msgid "Settled" @@ -6622,6 +6724,15 @@ msgstr "The following fields are required:" msgid "The IBAN should correspond to the chosen country - {country}." msgstr "The IBAN should correspond to the chosen country - {country}." +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts:79 +msgid "The number of days in Discount 2 must be more than the number of Discount 1 days" +msgstr "The number of days in Discount 2 must be more than the number of Discount 1 days" + +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts:49 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts:72 +msgid "The number of days in Discount must be less than of Due days" +msgstr "The number of days in Discount must be less than of Due days" + #: src/components/onboarding/OnboardingPersonsReview/OnboardingPersonsReview.tsx:57 msgid "The review for the persons has been completed. You can proceed to the next step." msgstr "The review for the persons has been completed. You can proceed to the next step." @@ -6655,8 +6766,8 @@ msgid "There is no payable by provided id: {id}" msgstr "There is no payable by provided id: {id}" #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/PaymentSection.tsx:61 -msgid "There is no payment terms available. Please create one in the settings." -msgstr "There is no payment terms available. Please create one in the settings." +#~ msgid "There is no payment terms available. Please create one in the settings." +#~ msgstr "There is no payment terms available. Please create one in the settings." #: src/components/products/ProductDetails/ExistingProductDetails.tsx:109 msgid "There is no product by provided id: {id}" @@ -6757,6 +6868,21 @@ msgstr "To" msgid "To (including)" msgstr "To (including)" +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts:45 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts:57 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts:68 +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/paymentTermsValidation.ts:87 +msgid "To add a discount you need to fill out all the fields" +msgstr "To add a discount you need to fill out all the fields" + +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:83 +msgid "To create a preset you need to fill out all the required fields" +msgstr "To create a preset you need to fill out all the required fields" + +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DiscountForm.tsx:90 +msgid "to get discount" +msgstr "to get discount" + #: src/components/userRoles/consts.ts:44 msgid "Todo task" msgstr "Todo task" @@ -7592,6 +7718,10 @@ msgstr "You can not create receivable with a type other than \"{invoice}\"" msgid "You can set a different period length and the date of issuance. The starting date cannot be updated." msgstr "You can set a different period length and the date of issuance. The starting date cannot be updated." +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/PaymentTermsForm.tsx:181 +msgid "You can set up to 2 early payment discounts per invoice." +msgstr "You can set up to 2 early payment discounts per invoice." + #: src/ui/DataGridEmptyState/GetNoRowsOverlay.tsx:72 msgid "You don’t have any {0} yet." msgstr "You don’t have any {0} yet." @@ -7645,6 +7775,10 @@ msgstr "You don't have permission to issue this document. Please, contact your s msgid "You don’t have permissions to view this page.<0/>Contact your system administrator for details." msgstr "You don’t have permissions to view this page.<0/>Contact your system administrator for details." +#: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/PaymentTerms/DeletePaymentTerms.tsx:33 +msgid "You won’t be able to use it to create new invoices, but it won’t affect the existing invoices with this term and their future copies." +msgstr "You won’t be able to use it to create new invoices, but it won’t affect the existing invoices with this term and their future copies." + #: src/components/receivables/InvoiceDetails/CreateReceivable/sections/components/Billing/YourVatDetailsForm.tsx:83 msgid "Your Tax ID" msgstr "Your Tax ID" diff --git a/packages/sdk-react/src/mocks/paymentTerms/paymentTermsHandlers.ts b/packages/sdk-react/src/mocks/paymentTerms/paymentTermsHandlers.ts index daac68eae..26edc68ed 100644 --- a/packages/sdk-react/src/mocks/paymentTerms/paymentTermsHandlers.ts +++ b/packages/sdk-react/src/mocks/paymentTerms/paymentTermsHandlers.ts @@ -1,13 +1,98 @@ import { components } from '@/api'; import { paymentTermsFixtures } from '@/mocks/paymentTerms/paymentTermsFixtures'; +import { faker } from '@faker-js/faker'; -import { http, HttpResponse } from 'msw'; +import { http, HttpResponse, delay } from 'msw'; + +const paymentTermsPath = `*/payment_terms`; +const paymentTermsList = paymentTermsFixtures; + +interface ErrorResponse { + error: { + message: string; + }; +} export const paymentTermsHandlers = [ http.get<{}, undefined, components['schemas']['PaymentTermsListResponse']>( - `*/payment_terms`, + paymentTermsPath, () => { - return HttpResponse.json(paymentTermsFixtures); + return HttpResponse.json(paymentTermsList); + } + ), + + http.post< + {}, + components['schemas']['PaymentTermsCreatePayload'], + components['schemas']['PaymentTermsResponse'] + >(paymentTermsPath, async ({ request }) => { + const payload = await request.json(); + const createdPaymentTerm = { + id: faker.string.uuid(), + ...payload, + }; + + await delay(); + paymentTermsList.data?.push(createdPaymentTerm); + + return HttpResponse.json(createdPaymentTerm, { + status: 200, + }); + }), + + http.patch< + { id: string }, + components['schemas']['PaymentTermsUpdatePayload'], + components['schemas']['PaymentTermsResponse'] | ErrorResponse + >(`${paymentTermsPath}/:id`, async ({ request, params }) => { + const payload = await request.json(); + + await delay(); + + const paymentTermIndex = paymentTermsList.data?.findIndex( + (paymentTerm) => paymentTerm.id === params.id + ); + + if (!paymentTermsList.data) { + paymentTermsList.data = []; + } + + if (!paymentTermIndex) { + return HttpResponse.json( + { + error: { + message: 'Wrong paymentTerm id', + }, + }, + { + status: 403, + } + ); + } + + const { name, term_final } = paymentTermsList.data[paymentTermIndex] || {}; + const updatedPaymentTerm = { + ...payload, + name: payload.name || name, + term_final: payload.term_final || term_final, + id: params.id, + }; + + paymentTermsList.data[paymentTermIndex] = updatedPaymentTerm; + + return HttpResponse.json(updatedPaymentTerm, { + status: 200, + }); + }), + + http.delete<{ id: string }, {}>( + `${paymentTermsPath}/:id`, + async ({ params }) => { + paymentTermsList.data = paymentTermsList.data?.filter( + (paymentTerm) => paymentTerm.id !== params.id + ); + + return new HttpResponse(undefined, { status: 204 }); } ), ];