diff --git a/locales/extracted/en.json b/locales/extracted/en.json index e261f431..f70f8f6f 100644 --- a/locales/extracted/en.json +++ b/locales/extracted/en.json @@ -29,6 +29,8 @@ "common.confirmDeletion": "Confirm Deletion", "common.copyMessage": "Copied to clipboard!", "common.create": "Create", + "common.credit": "Credit", + "common.debit": "Debit", "common.delete": "Delete", "common.edit": "Edit", "common.expand": "Expand", @@ -41,6 +43,9 @@ "common.metadata": "Metadata", "common.name": "Name", "common.noOptions": "No options found.", + "common.none": "None", + "common.operations": "Operations", + "common.optional": "Optional", "common.portfolio": "Portfolio", "common.records": "records", "common.remove": "Remove", @@ -59,6 +64,7 @@ "common.tooltipCopyText": "Click to copy", "common.type": "Type", "common.typePlaceholder": "Type...", + "common.value": "Value", "entity.account.currency": "Assets", "entity.account.name": "Account Name", "entity.address": "Address", @@ -88,6 +94,8 @@ "entity.portfolio.entityId": "Entity Id", "entity.portfolio.name": "Portfolio Name", "entity.product.name": "Product Name", + "entity.transaction.asset": "Asset", + "entity.transaction.value": "Value", "entity.user.email": "E-mail", "entity.user.name": "Name", "error.midaz.actionNotPermitted": "Error Midaz action not permitted", @@ -116,12 +124,14 @@ "errors.too_big.date.exact": "Date must be exactly {maximum}", "errors.too_big.date.inclusive": "Date must be before or equal to {maximum}", "errors.too_big.date.not_inclusive": "Date must be before {maximum}", + "errors.too_big.number.inclusive": "Field must be less than or equal to {maximum}", "errors.too_big.string.exact": "Field must contain exactly {maximum} {maximum, plural, =0 {characters} one {character} other {characters}}", "errors.too_big.string.inclusive": "Field must contain at most {maximum} {maximum, plural, =0 {characters} one {character} other {characters}}", "errors.too_big.string.not_inclusive": "Field must contain under {maximum} {maximum, plural, =0 {characters} one {character} other {characters}}", "errors.too_small.date.exact": "Date must be exactly {minimum}", "errors.too_small.date.inclusive": "Date must be after or equal to {minimum}", "errors.too_small.date.not_inclusive": "Date must be after {minimum}", + "errors.too_small.number.not_inclusive": "Field must be greater than {minimum}", "errors.too_small.string.exact": "Field must contain exactly {minimum} {minimum, plural, =0 {characters} one {character} other {characters}}", "errors.too_small.string.inclusive": "Field must contain at least {minimum} {minimum, plural, =0 {characters} one {character} other {characters}}", "errors.too_small.string.not_inclusive": "Field must contain over {minimum} {minimum, plural, =0 {characters} one {character} other {characters}}", @@ -290,5 +300,32 @@ "signIn.toast.error": "Invalid credentials.", "table.pagination.next": "Next", "table.pagination.previous": "Previous", - "tooltip.passwordInfo": "Contact the system administrator" + "tooltip.passwordInfo": "Contact the system administrator", + "transaction.create.cancel.description": "If you cancel this transaction, all filled data will be lost and cannot be recovered.", + "transaction.create.cancel.title": "Do you wish to cancel this transaction?", + "transaction.create.submit.description": "Your transaction will be executed according to the information you entered.", + "transaction.create.submit.title": "Create Transaction", + "transactions.create.button": "Create Transaction", + "transactions.create.field.chartOfAccounts": "Chart of accounts", + "transactions.create.field.chartOfAccountsGroupName": "Accounting route group", + "transactions.create.field.origin.placeholder": "Type ID or alias", + "transactions.create.metadata.accordion.description": "Fill in Value, Source and Destination to edit the Metadata.", + "transactions.create.operations.accordion.description": "Fill in Value, Source and Destination to edit the Operations.", + "transactions.create.paper.description": "Fill in the details of the Transaction you want to create.", + "transactions.create.review.button": "Go to Review", + "transactions.create.stepper.first": "Transaction Data", + "transactions.create.stepper.second": "Operations and Metadata", + "transactions.create.stepper.third": "Review", + "transactions.create.stepper.third.description": "Check the values ​​and parameters entered and confirm to create the transaction.", + "transactions.create.title": "New Transaction", + "transactions.destination": "Destination", + "transactions.errors.debit": "Total Debits do not match total Credits", + "transactions.field.description": "Transaction description", + "transactions.field.operation.description": "Operation description", + "transactions.metadata.title": "Transaction Metadata", + "transactions.operations.alert.description": "Fill in the value fields to adjust the amount transacted. Remember: the total Credits must equal the total Debits.", + "transactions.operations.alert.title": "Multiple origins and destinations", + "transactions.operations.metadata": "Operations Metadata", + "transactions.source": "Source", + "transactions.tab.create": "New Transaction" } \ No newline at end of file diff --git a/locales/extracted/pt.json b/locales/extracted/pt.json index 03bde42b..544933ea 100644 --- a/locales/extracted/pt.json +++ b/locales/extracted/pt.json @@ -286,9 +286,46 @@ "ledgers.accounts.showing": "Mostrando {count} {number, plural, =0 {contas} one {conta} other {contas}}.", "ledgers.portfolios.showing": "Mostrando {count} {number, plural, =0 {portfólios} one {portfólio} other {portfólios}}.", "organizations.showing": "Mostrando {count} {number, plural, =0 {organizações} one {organização} other {organizações}}.", - "common.itemsPerPage": "Itens por página", + "common.credit": "Crédito", + "common.debit": "Débito", + "common.none": "Nenhum", + "common.operations": "Operações", + "common.optional": "Opcional", "common.support": "Suporte", + "common.value": "Valor", + "entity.transaction.asset": "Ativo", + "entity.transaction.value": "Valor", + "errors.too_big.number.inclusive": "Campor deve ser menor ou igual a {maximum}", + "errors.too_small.number.not_inclusive": "Campo deve ser maior que {minimum}", + "transaction.create.cancel.description": "Se você cancelar esta transação, os datos preenchidos serão perdidos e não poderão ser recuperados.", + "transaction.create.cancel.title": "Deseja cancelar esta transação?", + "transaction.create.submit.description": "Sua transação será executada de acordo com as informações inseridas.", + "transaction.create.submit.title": "Criar Transação", + "transactions.create.button": "Criar Transação", + "transactions.create.field.chartOfAccounts": "Rota contábil", + "transactions.create.field.chartOfAccountsGroupName": "Grupo de rota contábil", + "transactions.create.field.origin.placeholder": "Digite ID ou alias", + "transactions.create.metadata.accordion.description": "Preencha Valor, Origem e Destino para editar os Metadados.", + "transactions.create.operations.accordion.description": "Preencha Valor, Origem e Destino para editar as Operações.", + "transactions.create.paper.description": "Preencha os dados da Transação que você deseja criar.", + "transactions.create.review.button": "Continuar para Revisão", + "transactions.create.stepper.first": "Dados da Transação", + "transactions.create.stepper.second": "Operações e Metadados", + "transactions.create.stepper.third": "Revisão", + "transactions.create.stepper.third.description": "Confira os valores e parâmetros inseridos e confirme para criar a transação.", + "transactions.create.title": "Nova Transação", + "transactions.destination": "Destino", + "transactions.errors.debit": "Total dos Débitos não bate com o total dos Créditos", + "transactions.field.description": "Descrição da Transação", + "transactions.metadata.title": "Metadados da Transação", + "transactions.operations.alert.description": "Preencha os campos de valor para ajustar o montante transacionado. Lembre-se: o total de Créditos deve ser igual ao total de Débitos.", + "transactions.operations.alert.title": "Múltiplas origens e destinos", + "transactions.operations.metadata": "Metadados da Operação", + "transactions.source": "Origem", + "transactions.tab.create": "Nova Transação", + "transactions.field.operation.description": "Descrição da Operação", + "common.itemsPerPage": "Itens por página", "ledgers.assets.count": "{count} ativos", "table.pagination.next": "Próxima página", "table.pagination.previous": "Página anterior" -} +} \ No newline at end of file diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/basic-information-paper.tsx b/src/app/(routes)/ledgers/[id]/transactions/create/basic-information-paper.tsx new file mode 100644 index 00000000..21d760f9 --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/basic-information-paper.tsx @@ -0,0 +1,100 @@ +import { useListAssets } from '@/client/assets' +import { InputField, SelectField } from '@/components/form' +import { Paper } from '@/components/ui/paper' +import { SelectItem } from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' +import { useOrganization } from '@/context/organization-provider/organization-provider-client' +import { Control } from 'react-hook-form' +import { useIntl } from 'react-intl' +import DolarSign from '/public/svg/dolar-sign.svg' +import Image from 'next/image' +import { useParams } from 'next/navigation' + +export type BasicInformationPaperProps = { + control: Control +} + +export const BasicInformationPaper = ({ + control +}: BasicInformationPaperProps) => { + const intl = useIntl() + const { id } = useParams<{ id: string }>() + const { currentOrganization } = useOrganization() + + const { data: assets } = useListAssets({ + organizationId: currentOrganization.id!, + ledgerId: id + }) + + return ( + +

+ {intl.formatMessage({ + id: 'transactions.create.paper.description', + defaultMessage: + 'Fill in the details of the Transaction you want to create.' + })} +

+ +
+ + +
+ +
+
+ +
+ + {assets?.items?.map((asset) => ( + + {asset.code} + + ))} + +
+ +
+
+
+ ) +} diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/layout.tsx b/src/app/(routes)/ledgers/[id]/transactions/create/layout.tsx new file mode 100644 index 00000000..ceedc6db --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/layout.tsx @@ -0,0 +1,49 @@ +'use client' + +import { Breadcrumb } from '@/components/breadcrumb' +import { TransactionProvider } from './transaction-form-provider' +import { PageHeader } from '@/components/page-header' +import { useIntl } from 'react-intl' + +export default function RootLayout({ + children +}: { + children: React.ReactNode +}) { + const intl = useIntl() + + return ( + + + + + + + + + + {children} + + ) +} diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/metadata-accordion.tsx b/src/app/(routes)/ledgers/[id]/transactions/create/metadata-accordion.tsx new file mode 100644 index 00000000..2701e1e9 --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/metadata-accordion.tsx @@ -0,0 +1,58 @@ +import { Separator } from '@/components/ui/separator' +import { + PaperCollapsible, + PaperCollapsibleBanner, + PaperCollapsibleContent +} from '@/components/transactions/primitives/paper-collapsible' +import { MetadataField } from '@/components/form' +import { Control } from 'react-hook-form' +import { useIntl } from 'react-intl' +import { Metadata } from '@/types/metadata-type' + +export type MetadataAccordionProps = { + name: string + values: Metadata + control: Control +} + +export const MetadataAccordion = ({ + name, + values, + control +}: MetadataAccordionProps) => { + const intl = useIntl() + + return ( + <> +
+ {intl.formatMessage({ + id: 'transactions.metadata.title', + defaultMessage: 'Transaction Metadata' + })} +
+ + + +

+ {intl.formatMessage( + { + id: 'organizations.organizationForm.metadataRegisterCountText', + defaultMessage: + '{count} added {count, plural, =0 {records} one {record} other {records}}' + }, + { + count: Object.entries(values || 0).length + } + )} +

+
+ + +
+ +
+
+
+ + ) +} diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/operation-accordion.tsx b/src/app/(routes)/ledgers/[id]/transactions/create/operation-accordion.tsx new file mode 100644 index 00000000..ec079952 --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/operation-accordion.tsx @@ -0,0 +1,202 @@ +import { ArrowLeft, ArrowRight, MinusCircle, PlusCircle } from 'lucide-react' +import { + PaperCollapsible, + PaperCollapsibleBanner, + PaperCollapsibleContent +} from '@/components/transactions/primitives/paper-collapsible' +import { Separator } from '@/components/ui/separator' +import { InputField, MetadataField } from '@/components/form' +import { useIntl } from 'react-intl' +import { Control } from 'react-hook-form' +import { Input } from '@/components/ui/input' +import { + FormControl, + FormField, + FormItem, + FormMessage +} from '@/components/ui/form' +import { cn } from '@/lib/utils' +import { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider +} from '@/components/ui/tooltip' +import { useTransactionForm } from './transaction-form-provider' +import { useState } from 'react' +import { TransactionSourceFormSchema } from './schemas' + +type ValueFieldProps = { + name: string + error?: string + control: Control +} + +const ValueField = ({ name, error, control }: ValueFieldProps) => { + const [open, setOpen] = useState(false) + + const handleOpen = (value: boolean) => { + if (error) { + setOpen(value) + } else { + setOpen(false) + } + } + + return ( + ( + + + + + + + + + {error} + + + + + )} + /> + ) +} + +export type OperationEmptyAccordionProps = { + title: string + description?: string +} + +export const OperationEmptyAccordion = ({ + title, + description +}: OperationEmptyAccordionProps) => { + return ( +
+
+

{title}

+

{description}

+
+
+ ) +} + +export type OperationAccordionProps = { + type?: 'debit' | 'credit' + name: string + asset?: string + values: TransactionSourceFormSchema[0] + valueEditable?: boolean + control: Control +} + +export const OperationAccordion = ({ + type = 'debit', + name, + asset, + values, + valueEditable, + control +}: OperationAccordionProps) => { + const intl = useIntl() + + const { errors } = useTransactionForm() + + return ( + + +
+ {type === 'debit' && } + {type === 'credit' && ( + + )} + +
+

+ {type === 'debit' + ? intl.formatMessage({ + id: 'common.debit', + defaultMessage: 'Debit' + }) + : intl.formatMessage({ + id: 'common.credit', + defaultMessage: 'Credit' + })} +

+

{values.account}

+
+
+
+ {type === 'debit' && } + {type === 'credit' && } + + {valueEditable ? ( + + ) : ( +

+ {values.value} +

+ )} +
+

{asset}

+
+
+
+ + +
+
+ + +
+
+
+ +
+

+ {intl.formatMessage({ + id: 'transactions.operations.metadata', + defaultMessage: 'Operations Metadata' + })} +

+ +
+ + + ) +} diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/operation-source-field.tsx b/src/app/(routes)/ledgers/[id]/transactions/create/operation-source-field.tsx new file mode 100644 index 00000000..42d29021 --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/operation-source-field.tsx @@ -0,0 +1,96 @@ +import { InputField } from '@/components/form' +import { Button } from '@/components/ui/button' +import { Paper } from '@/components/ui/paper' +import { zodResolver } from '@hookform/resolvers/zod' +import { Plus, Trash } from 'lucide-react' +import { Control, useFieldArray, useForm } from 'react-hook-form' +import { useIntl } from 'react-intl' +import { z } from 'zod' +import { transaction } from '@/schema/transactions' +import { TransactionFormSchema, TransactionSourceFormSchema } from './schemas' + +const formSchema = z.object({ + account: transaction.source.account +}) + +type FormSchema = z.infer + +const initialValues = { + account: '' +} + +export type OperationSourceFieldProps = { + name: string + label: string + values?: TransactionSourceFormSchema | [] + onSubmit?: (value: string) => void + control: Control +} + +export const OperationSourceField = ({ + name, + label, + values = [], + onSubmit, + control +}: OperationSourceFieldProps) => { + const intl = useIntl() + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: initialValues + }) + + const { remove } = useFieldArray({ + name: name as 'source' | 'destination', + control + }) + + const handleSubmit = (values: FormSchema) => { + onSubmit?.(values.account) + form.reset() + } + + return ( + +
+
+ +
+ +
+ {values?.map((field, index) => ( +
+
+ {field.account} +
+ +
+ ))} +
+ ) +} diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/page.tsx b/src/app/(routes)/ledgers/[id]/transactions/create/page.tsx new file mode 100644 index 00000000..eb008ead --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/page.tsx @@ -0,0 +1,205 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { Form } from '@/components/ui/form' +import { LoadingButton } from '@/components/ui/loading-button' +import { ArrowRight, Info } from 'lucide-react' +import { useIntl } from 'react-intl' +import { Stepper } from './stepper' +import { PageFooter, PageFooterSection } from '@/components/page-footer' +import Image from 'next/image' +import { + OperationAccordion, + OperationEmptyAccordion +} from './operation-accordion' +import { OperationSourceField } from './operation-source-field' +import { useTransactionForm } from './transaction-form-provider' +import { StepperContent } from '@/components/transactions/primitives/stepper' +import { MetadataAccordion } from './metadata-accordion' +import ArrowRightCircle from '/public/svg/arrow-right-circle.svg' +import { BasicInformationPaper } from './basic-information-paper' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { useRouter } from 'next/navigation' +import { useConfirmDialog } from '@/components/confirmation-dialog/use-confirm-dialog' +import ConfirmationDialog from '@/components/confirmation-dialog' + +export default function CreateTransactionPage() { + const intl = useIntl() + const router = useRouter() + + const { + form, + currentStep, + multipleSources, + values, + addSource, + addDestination, + handleReview + } = useTransactionForm() + + const { handleDialogOpen, dialogProps } = useConfirmDialog({ + onConfirm: () => router.push('/transactions') + }) + + return ( + <> + + +
+
+
+ + +
+ + + +
+ + + + + + + + +
+ {intl.formatMessage({ + id: 'common.operations', + defaultMessage: 'Operations' + })} +
+ + {multipleSources && ( + + + + {intl.formatMessage({ + id: 'transactions.operations.alert.title', + defaultMessage: 'Multiple origins and destinations' + })} + + + {intl.formatMessage({ + id: 'transactions.operations.alert.description', + defaultMessage: + 'Fill in the value fields to adjust the amount transacted. Remember: the total Credits must equal the total Debits.' + })} + + + )} + +
+ {values.source?.map((source, index) => ( + 1} + control={form.control} + /> + ))} + {values.destination?.map((destination, index) => ( + 1} + control={form.control} + /> + ))} +
+ + +
+
+ +
+
+ +
+
+
+ + 0}> + + + + + } + iconPlacement="end" + onClick={form.handleSubmit(handleReview)} + > + {intl.formatMessage({ + id: 'transactions.create.review.button', + defaultMessage: 'Go to Review' + })} + + + +
+ + ) +} diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/review/page.tsx b/src/app/(routes)/ledgers/[id]/transactions/create/review/page.tsx new file mode 100644 index 00000000..d6894e9b --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/review/page.tsx @@ -0,0 +1,229 @@ +'use client' + +import { SendHorizonal } from 'lucide-react' +import { useTransactionForm } from '../transaction-form-provider' +import { Stepper } from '../stepper' +import { Separator } from '@/components/ui/separator' +import { PageFooter, PageFooterSection } from '@/components/page-footer' +import { Button } from '@/components/ui/button' +import { LoadingButton } from '@/components/ui/loading-button' +import { useIntl } from 'react-intl' +import { useConfirmDialog } from '@/components/confirmation-dialog/use-confirm-dialog' +import ConfirmationDialog from '@/components/confirmation-dialog' +import { useParams, useRouter } from 'next/navigation' +import { + TransactionReceipt, + TransactionReceiptDescription, + TransactionReceiptItem, + TransactionReceiptOperation, + TransactionReceiptSubjects, + TransactionReceiptTicket, + TransactionReceiptValue +} from '@/components/transactions/primitives/transaction-receipt' +import ArrowRightLeftCircle from '/public/svg/arrow-right-left-circle.svg' +import Image from 'next/image' +import { isNil } from 'lodash' +import { useCreateTransaction } from '@/client/transactions' +import { useOrganization } from '@/context/organization-provider/organization-provider-client' + +export default function CreateTransactionReviewPage() { + const intl = useIntl() + const router = useRouter() + + const { id } = useParams<{ id: string }>() + + const { currentOrganization } = useOrganization() + + const { mutate: createTransaction, isPending: createLoading } = + useCreateTransaction({ + organizationId: currentOrganization.id!, + ledgerId: id, + onSuccess: () => router.push('/transactions') + }) + + const { values, currentStep, handleBack } = useTransactionForm() + + const { handleDialogOpen: handleCancelOpen, dialogProps: cancelDialogProps } = + useConfirmDialog({ + onConfirm: () => router.push('/transactions') + }) + + const { handleDialogOpen: handleSubmitOpen, dialogProps: submitDialogProps } = + useConfirmDialog({ + onConfirm: () => createTransaction(values) + }) + + return ( + <> + + + + +
+
+ +
+ +
+ + + +

Manual

+ source.account)} + destinations={values.destination?.map((source) => source.account)} + /> + {values.description && ( + + {values.description} + + )} +
+ + + + {values.source?.map((source, index) => ( +

+ {source.account} +

+ ))} +
+ } + /> + + {values.destination?.map((destination, index) => ( +

+ {destination.account} +

+ ))} +
+ } + /> + + + {values.source?.map((source, index) => ( + + ))} + {values.destination?.map((destination, index) => ( + + ))} + + + + + + +
+ + + + + + + + } + iconPlacement="end" + loading={createLoading} + onClick={() => handleSubmitOpen('')} + > + {intl.formatMessage({ + id: 'transactions.create.button', + defaultMessage: 'Create Transaction' + })} + + + + + ) +} diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/schemas.ts b/src/app/(routes)/ledgers/[id]/transactions/create/schemas.ts new file mode 100644 index 00000000..89c01664 --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/schemas.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' +import { transaction } from '@/schema/transactions' + +export const transactionSourceFormSchema = z + .array( + z.object({ + account: transaction.source.account, + value: transaction.value, + description: transaction.description.optional(), + chartOfAccounts: transaction.chartOfAccounts.optional(), + metadata: transaction.metadata + }) + ) + .nonempty() + .default([] as any) + +export const transactionFormSchema = z.object({ + description: transaction.description.optional(), + chartOfAccountsGroupName: transaction.chartOfAccounts.optional(), + asset: transaction.asset, + value: transaction.value, + source: transactionSourceFormSchema, + destination: transactionSourceFormSchema, + metadata: transaction.metadata +}) + +export type TransactionSourceFormSchema = z.infer< + typeof transactionSourceFormSchema +> + +export type TransactionFormSchema = z.infer + +export const initialValues = { + description: '', + chartOfAccountsGroupName: '', + value: '', + asset: '', + source: [], + destination: [], + metadata: {} +} + +export const sourceInitialValues = { + account: '', + value: 0, + description: '', + chartOfAccounts: '', + metadata: {} +} diff --git a/src/app/(routes)/transactions/create/stepper.tsx b/src/app/(routes)/ledgers/[id]/transactions/create/stepper.tsx similarity index 96% rename from src/app/(routes)/transactions/create/stepper.tsx rename to src/app/(routes)/ledgers/[id]/transactions/create/stepper.tsx index f0708cba..e04c1cfa 100644 --- a/src/app/(routes)/transactions/create/stepper.tsx +++ b/src/app/(routes)/ledgers/[id]/transactions/create/stepper.tsx @@ -4,7 +4,7 @@ import { StepperItem, StepperItemNumber, StepperItemText -} from '../primitives/stepper' +} from '@/components/transactions/primitives/stepper' export type StepperProps = { step?: number diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/transaction-form-provider.tsx b/src/app/(routes)/ledgers/[id]/transactions/create/transaction-form-provider.tsx new file mode 100644 index 00000000..7c33e8df --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/transaction-form-provider.tsx @@ -0,0 +1,130 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useParams, useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { createContext, PropsWithChildren, useContext } from 'react' +import { useFieldArray, UseFieldArrayReturn, useForm } from 'react-hook-form' +import { useTransactionFormControl } from './use-transaction-form-control' +import { + initialValues, + sourceInitialValues, + transactionFormSchema, + TransactionFormSchema +} from './schemas' +import { + TransactionFormErrors, + useTransactionFormErrors +} from './use-transaction-form-errors' + +type TransactionFormProviderContext = { + form: ReturnType> + errors: TransactionFormErrors + currentStep: number + multipleSources?: boolean + values: TransactionFormSchema + addSource: (account: string) => void + addDestination: (account: string) => void + handleReview: () => void + handleBack: () => void +} + +const TransactionFormProvider = createContext( + {} as never +) + +export const useTransactionForm = () => { + return useContext(TransactionFormProvider) +} + +export type TransactionProviderProps = PropsWithChildren & { + values?: TransactionFormSchema +} + +export const TransactionProvider = ({ + values, + children +}: TransactionProviderProps) => { + const { id } = useParams<{ id: string }>() + const router = useRouter() + const form = useForm({ + resolver: zodResolver(transactionFormSchema), + defaultValues: { ...initialValues, ...values } as TransactionFormSchema + }) + + const formValues = form.watch() + + const { step, handleNext, handlePrevious } = + useTransactionFormControl(formValues) + const { errors } = useTransactionFormErrors(formValues) + + const originFieldArray = useFieldArray({ + name: 'source', + control: form.control + }) + + const destinationFieldArray = useFieldArray({ + name: 'destination', + control: form.control + }) + + // Flag to represent if the transaction has multiple sources or destinations + const multipleSources = + originFieldArray.fields.length > 1 || + destinationFieldArray.fields.length > 1 + + // Add source or destination to the transaction + // The first entity uses the same value as the transaction + // Latter ones will start at 0 + const addSource = (fieldArray: UseFieldArrayReturn, account: string) => { + if (fieldArray.fields.length === 0) { + fieldArray.append({ + ...sourceInitialValues, + account, + value: formValues.value + }) + } else { + fieldArray.append({ + ...sourceInitialValues, + account + }) + } + } + + const handleReview = () => { + router.push(`/ledgers/${id}/transactions/create/review`) + handleNext() + } + + // In case the user adds more than 1 source or destination, + // And then removes to stay with only 1, we need to restore the original + // transaction value to the source or destination + useEffect(() => { + if (formValues.source.length === 1) { + form.setValue('source.0.value', formValues.value) + } + }, [formValues.value, formValues.source.length]) + + useEffect(() => { + if (formValues.destination.length === 1) { + form.setValue('destination.0.value', formValues.value) + } + }, [formValues.value, formValues.destination.length]) + + return ( + addSource(originFieldArray, account), + addDestination: (account: string) => + addSource(destinationFieldArray, account), + handleReview, + handleBack: handlePrevious + }} + > + {children} + + ) +} diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-control.test.ts b/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-control.test.ts new file mode 100644 index 00000000..47f4abc7 --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-control.test.ts @@ -0,0 +1,70 @@ +import { renderHook, act } from '@testing-library/react' +import { useTransactionFormControl } from './use-transaction-form-control' +import { TransactionFormSchema } from './schemas' + +describe('useTransactionFormControl', () => { + const initialValues: TransactionFormSchema = { + asset: '', + value: 0, + source: [], + destination: [] + } as any + + it('should initialize with step 0', () => { + const { result } = renderHook(() => + useTransactionFormControl(initialValues) + ) + expect(result.current.step).toBe(0) + }) + + it('should set step to 1 when value, asset, source, and destination are provided', () => { + const values: TransactionFormSchema = { + asset: 'BTC', + value: 100, + source: [{ account: 'source1' }], + destination: [{ account: 'destination1' }] + } as any + + const { result } = renderHook(() => useTransactionFormControl(values)) + + act(() => { + result.current.step + }) + + expect(result.current.step).toBe(1) + }) + + it('should set step to 0 when any of value, asset, source, or destination are missing', () => { + const values: TransactionFormSchema = { + asset: '', + value: 100, + source: [{ account: 'source1' }], + destination: [{ account: 'destination1' }] + } as any + + const { result } = renderHook(() => useTransactionFormControl(values)) + + act(() => { + result.current.step + }) + + expect(result.current.step).toBe(0) + }) + + it('should not change step if step is 2 or more', () => { + const values: TransactionFormSchema = { + asset: 'BTC', + value: 100, + source: [{ account: 'source1' }], + destination: [{ account: 'destination1' }] + } as any + + const { result } = renderHook(() => useTransactionFormControl(values)) + + act(() => { + result.current.setStep(2) + }) + + expect(result.current.step).toBe(2) + }) +}) diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-control.ts b/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-control.ts new file mode 100644 index 00000000..dfea8193 --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-control.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react' +import { useStepper } from '@/hooks/use-stepper' +import { TransactionFormSchema } from './schemas' + +export const useTransactionFormControl = (values: TransactionFormSchema) => { + const { step, setStep, ...props } = useStepper({ maxSteps: 3 }) + const { asset, value, source, destination } = values + + useEffect(() => { + if (step < 2) { + // If the user has filled the required fields, move to the next step + if (value && asset && source?.length > 0 && destination?.length > 0) { + setStep(1) + // Reset back to initial step if the user has removed the required fields + } else { + setStep(0) + } + } + }, [value, asset, source, destination]) + + return { step, setStep, ...props } +} diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-errors.test.ts b/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-errors.test.ts new file mode 100644 index 00000000..5810c182 --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-errors.test.ts @@ -0,0 +1,97 @@ +import { renderHook, act } from '@testing-library/react' +import { useIntl } from 'react-intl' +import { useTransactionFormErrors } from './use-transaction-form-errors' +import { TransactionFormSchema } from './schemas' + +jest.mock('react-intl', () => ({ + useIntl: jest.fn() +})) + +describe('useTransactionFormErrors', () => { + const intlMock = { + formatMessage: jest.fn(({ defaultMessage }) => defaultMessage) + } + + beforeEach(() => { + ;(useIntl as jest.Mock).mockReturnValue(intlMock) + }) + + it('should return no errors initially', () => { + const { result } = renderHook(() => + useTransactionFormErrors({ + value: 0, + source: [], + destination: [] + } as any) + ) + expect(result.current.errors).toEqual({}) + }) + + it('should add debit error if source sum does not match value', () => { + const { result } = renderHook(() => + useTransactionFormErrors({ + value: 100, + source: [{ value: 50 }], + destination: [] + } as any) + ) + + act(() => { + result.current.errors + }) + + expect(result.current.errors.debit).toBe( + 'Total Debits do not match total Credits' + ) + }) + + it('should add credit error if destination sum does not match value', () => { + const { result } = renderHook(() => + useTransactionFormErrors({ + value: '100', + source: [], + destination: [{ value: '50' }] + } as any) + ) + + act(() => { + result.current.errors + }) + + expect(result.current.errors.credit).toBe( + 'Total Debits do not match total Credits' + ) + }) + + it('should remove debit error if source sum matches value', () => { + const { result } = renderHook(() => + useTransactionFormErrors({ + value: '100', + source: [{ value: '100' }], + destination: [] + } as any) + ) + + act(() => { + result.current.errors + }) + + expect(result.current.errors.debit).toBeUndefined() + }) + + it('should remove credit error if destination sum matches value', () => { + const { result } = renderHook(() => + useTransactionFormErrors({ + value: '100', + source: [], + destination: [{ value: '100' }] + } as any) + ) + + act(() => { + result.current.errors + }) + + expect(result.current.errors.credit).toBeUndefined() + }) +}) diff --git a/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-errors.ts b/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-errors.ts new file mode 100644 index 00000000..9b2ddd06 --- /dev/null +++ b/src/app/(routes)/ledgers/[id]/transactions/create/use-transaction-form-errors.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react' +import { useIntl } from 'react-intl' +import { TransactionFormSchema, TransactionSourceFormSchema } from './schemas' + +export type TransactionFormErrors = Record + +export const useTransactionFormErrors = (values: TransactionFormSchema) => { + const intl = useIntl() + const [errors, setErrors] = useState({}) + const { value, source, destination } = values + + const sum = (source: TransactionSourceFormSchema) => + source.reduce((acc, curr) => acc + Number(curr.value), 0) + + const addError = (key: string, value: string) => { + setErrors((prev) => ({ ...prev, [key]: value })) + } + + const removeError = (key: string) => { + setErrors((prev) => { + const { [key]: _, ...rest } = prev + return rest + }) + } + + useEffect(() => { + const v = Number(value) + + if (v !== sum(source)) { + addError( + 'debit', + intl.formatMessage({ + id: 'transactions.errors.debit', + defaultMessage: 'Total Debits do not match total Credits' + }) + ) + } else { + removeError('debit') + } + + if (v !== sum(destination)) { + addError( + 'credit', + intl.formatMessage({ + id: 'transactions.errors.debit', + defaultMessage: 'Total Debits do not match total Credits' + }) + ) + } else { + removeError('credit') + } + }, [value, sum(source), sum(destination)]) + + return { errors } +} diff --git a/src/app/(routes)/transactions/create/operation-empty-accordion.tsx b/src/app/(routes)/transactions/create/operation-empty-accordion.tsx deleted file mode 100644 index 0d66dd1a..00000000 --- a/src/app/(routes)/transactions/create/operation-empty-accordion.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export type OperationEmptyAccordionProps = { - title: string - description?: string -} - -export const OperationEmptyAccordion = ({ - title, - description -}: OperationEmptyAccordionProps) => { - return ( -
-
-

{title}

-

{description}

-
-
- ) -} diff --git a/src/app/(routes)/transactions/create/page.tsx b/src/app/(routes)/transactions/create/page.tsx deleted file mode 100644 index 7bed5a89..00000000 --- a/src/app/(routes)/transactions/create/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function CreateTransactionPage() { - return ( -
-

Create Transactions

-
- ) -} diff --git a/src/app/globals.css b/src/app/globals.css index 765ba9a8..8921a649 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -82,3 +82,8 @@ @apply h-full overflow-y-auto bg-background text-foreground; } } + +.ticket { + mask: radial-gradient(21px 13px at 50% 102%, #0000 98%, #000) 50% + calc(100% - 16px) / 64px 100% repeat-x; +} diff --git a/src/client/transactions.ts b/src/client/transactions.ts index e69de29b..dcaac447 100644 --- a/src/client/transactions.ts +++ b/src/client/transactions.ts @@ -0,0 +1,22 @@ +import { postFetcher } from '@/lib/fetcher' +import { useMutation } from '@tanstack/react-query' + +export type UseCreateTransactionProps = { + organizationId: string + ledgerId: string + onSuccess: () => void +} + +export const useCreateTransaction = ({ + organizationId, + ledgerId, + ...options +}: UseCreateTransactionProps) => { + return useMutation({ + mutationKey: ['transactions', 'create'], + mutationFn: postFetcher( + `/api/organizations/${organizationId}/ledgers/${ledgerId}/transactions/json` + ), + ...options + }) +} diff --git a/src/app/(routes)/transactions/primitives/paper-collapsible.tsx b/src/components/transactions/primitives/paper-collapsible.tsx similarity index 100% rename from src/app/(routes)/transactions/primitives/paper-collapsible.tsx rename to src/components/transactions/primitives/paper-collapsible.tsx diff --git a/src/app/(routes)/transactions/primitives/stepper.tsx b/src/components/transactions/primitives/stepper.tsx similarity index 100% rename from src/app/(routes)/transactions/primitives/stepper.tsx rename to src/components/transactions/primitives/stepper.tsx diff --git a/src/components/transactions/primitives/transaction-receipt.tsx b/src/components/transactions/primitives/transaction-receipt.tsx new file mode 100644 index 00000000..328416ed --- /dev/null +++ b/src/components/transactions/primitives/transaction-receipt.tsx @@ -0,0 +1,190 @@ +import { cn } from '@/lib/utils' +import { AlignLeft, ArrowRight } from 'lucide-react' +import { forwardRef, HTMLAttributes, ReactNode } from 'react' +import { useIntl } from 'react-intl' + +export type TransactionReceiptProps = HTMLAttributes & { + type?: 'main' | 'ticket' +} + +export const TransactionReceipt = forwardRef< + HTMLDivElement, + TransactionReceiptProps +>(({ className, type = 'main', ...props }, ref) => ( +
+)) +TransactionReceipt.displayName = 'TransactionReceipt' + +export type TransactionReceiptValueProps = + HTMLAttributes & { + asset: string + value: string | number + } + +export const TransactionReceiptValue = forwardRef< + HTMLDivElement, + TransactionReceiptValueProps +>(({ className, asset, value, children, ...props }, ref) => ( +

+ {asset} {value} +

+)) +TransactionReceiptValue.displayName = 'TransactionReceiptValue' + +export const TransactionReceiptDescription = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, children, ...props }, ref) => ( +
+ + {children} +
+)) +TransactionReceiptDescription.displayName = 'TransactionReceiptDescription' + +export const TransactionReceiptAction = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TransactionReceiptAction.displayName = 'TransactionReceiptAction' + +export type TransactionReceiptSubjectsProps = HTMLAttributes & { + sources: string[] + destinations: string[] +} + +export const TransactionReceiptSubjects = forwardRef< + HTMLDivElement, + TransactionReceiptSubjectsProps +>(({ className, sources, destinations, children, ...props }, ref) => ( +
+
+ {sources.map((source, index) => ( +

{source}

+ ))} +
+ +
+ {destinations.map((source, index) => ( +

{source}

+ ))} +
+
+)) +TransactionReceiptSubjects.displayName = 'TransactionReceiptSubjects' + +export type TransactionReceiptItemProps = HTMLAttributes & { + label: string + value: ReactNode +} + +export const TransactionReceiptItem = forwardRef< + HTMLDivElement, + TransactionReceiptItemProps +>(({ className, label, value, children, ...props }, ref) => ( +
+

{label}

+ {value} +
+)) +TransactionReceiptItem.displayName = 'TransactionReceiptTicket' + +export type TransactionReceiptOperationProps = + HTMLAttributes & { + type: 'debit' | 'credit' + account: string + asset: string + value: string | number + } + +export const TransactionReceiptOperation = forwardRef< + HTMLDivElement, + TransactionReceiptOperationProps +>(({ className, type, account, asset, value, children, ...props }, ref) => { + const intl = useIntl() + + return ( +
+
+

+ {type === 'debit' + ? intl.formatMessage({ + id: 'common.debit', + defaultMessage: 'Debit' + }) + : intl.formatMessage({ + id: 'common.credit', + defaultMessage: 'Credit' + })} +

+
+

{account}

+

+ {type === 'debit' ? '-' : '+'} {asset} {value} +

+
+
+
+ ) +}) +TransactionReceiptOperation.displayName = 'TransactionReceiptOperation' + +export const TransactionReceiptTicket = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TransactionReceiptTicket.displayName = 'TransactionReceiptTicket' diff --git a/src/hooks/use-stepper.test.tsx b/src/hooks/use-stepper.test.tsx new file mode 100644 index 00000000..7bcf0503 --- /dev/null +++ b/src/hooks/use-stepper.test.tsx @@ -0,0 +1,48 @@ +import { renderHook, act } from '@testing-library/react' +import { useStepper } from './use-stepper' + +describe('useStepper', () => { + it('should initialize with the correct step', () => { + const { result } = renderHook(() => useStepper({})) + expect(result.current.step).toBe(0) + }) + + it('should go to the next step', () => { + const { result } = renderHook(() => useStepper({})) + act(() => { + result.current.handleNext() + }) + expect(result.current.step).toBe(1) + }) + + it('should go to the previous step', () => { + const { result } = renderHook(() => useStepper({ defaultStep: 1 })) + act(() => { + result.current.handlePrevious() + }) + expect(result.current.step).toBe(0) + }) + + it('should not go below step 0', () => { + const { result } = renderHook(() => useStepper({})) + act(() => { + result.current.handlePrevious() + }) + expect(result.current.step).toBe(0) + }) + + it('should not exceed the maximum steps', () => { + const { result } = renderHook(() => useStepper({ maxSteps: 2 })) + expect(result.current.step).toBe(0) + act(() => { + result.current.handleNext() + }) + act(() => { + result.current.handleNext() + }) + act(() => { + result.current.handleNext() + }) + expect(result.current.step).toBe(1) + }) +}) diff --git a/src/hooks/use-stepper.ts b/src/hooks/use-stepper.ts new file mode 100644 index 00000000..11975029 --- /dev/null +++ b/src/hooks/use-stepper.ts @@ -0,0 +1,38 @@ +import { useState } from 'react' + +export type UseStepperProps = { + defaultStep?: number + maxSteps?: number +} + +export const useStepper = ({ + defaultStep = 0, + maxSteps = 2 +}: UseStepperProps) => { + const [step, setStep] = useState(defaultStep) + + const handleNext = () => { + // If maxSteps is defined and the current step is the last step, do nothing + if (maxSteps && step >= maxSteps - 1) { + return + } + + setStep((prev) => prev + 1) + } + + const handlePrevious = () => { + // If the current step is the first step, do nothing + if (step <= 0) { + return + } + + setStep((prev) => prev - 1) + } + + return { + step, + setStep, + handleNext, + handlePrevious + } +}