diff --git a/apps/backoffice-v2/package.json b/apps/backoffice-v2/package.json index a3498efd02..9e3a4a03db 100644 --- a/apps/backoffice-v2/package.json +++ b/apps/backoffice-v2/package.json @@ -98,7 +98,7 @@ "i18next-http-backend": "^2.1.1", "leaflet": "^1.9.4", "libphonenumber-js": "^1.10.49", - "lucide-react": "^0.239.0", + "lucide-react": "^0.445.0", "match-sorter": "^6.3.1", "msw": "^1.0.0", "posthog-js": "^1.154.2", diff --git a/apps/backoffice-v2/public/locales/en/toast.json b/apps/backoffice-v2/public/locales/en/toast.json index 5809b1b785..bce138fba5 100644 --- a/apps/backoffice-v2/public/locales/en/toast.json +++ b/apps/backoffice-v2/public/locales/en/toast.json @@ -81,6 +81,10 @@ "pdf_certificate": { "error": "Failed to open PDF certificate." }, + "document_ocr": { + "success": "OCR performed successfully.", + "error": "Failed to perform OCR on the document." + }, "business_report_creation": { "success": "Merchant check created successfully.", "error": "Error occurred while creating a merchant check.", diff --git a/apps/backoffice-v2/src/common/components/molecules/ImageOCR/ImageOCR.tsx b/apps/backoffice-v2/src/common/components/molecules/ImageOCR/ImageOCR.tsx new file mode 100644 index 0000000000..f4e6259e44 --- /dev/null +++ b/apps/backoffice-v2/src/common/components/molecules/ImageOCR/ImageOCR.tsx @@ -0,0 +1,35 @@ +import { ctw } from '@/common/utils/ctw/ctw'; +import { ComponentProps, FunctionComponent } from 'react'; +import { Loader2, ScanTextIcon } from 'lucide-react'; + +export interface IImageOCR extends ComponentProps<'button'> { + onOcrPressed?: () => void; + isOcrDisabled: boolean; + isLoadingOCR?: boolean; +} + +export const ImageOCR: FunctionComponent = ({ + isOcrDisabled, + onOcrPressed, + className, + isLoadingOCR, + ...props +}) => ( + +); diff --git a/apps/backoffice-v2/src/domains/customer/fetchers.ts b/apps/backoffice-v2/src/domains/customer/fetchers.ts index ae20cbd752..94587404af 100644 --- a/apps/backoffice-v2/src/domains/customer/fetchers.ts +++ b/apps/backoffice-v2/src/domains/customer/fetchers.ts @@ -27,6 +27,7 @@ const CustomerSchema = z.object({ createBusinessReportBatch: z .object({ enabled: z.boolean().default(false), options: createBusinessReportOptions }) .optional(), + isDocumentOcrEnabled: z.boolean().default(false).optional(), }) .nullable(), config: z diff --git a/apps/backoffice-v2/src/domains/entities/hooks/mutations/useDocumentOcr/useDocumentOcr.ts b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useDocumentOcr/useDocumentOcr.ts new file mode 100644 index 0000000000..21b8f389ca --- /dev/null +++ b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useDocumentOcr/useDocumentOcr.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { fetchWorkflowDocumentOCRResult } from '@/domains/workflows/fetchers'; +import { toast } from 'sonner'; +import { t } from 'i18next'; +import { workflowsQueryKeys } from '@/domains/workflows/query-keys'; +import { useFilterId } from '@/common/hooks/useFilterId/useFilterId'; + +export const useDocumentOcr = ({ workflowId }: { workflowId: string }) => { + const filterId = useFilterId(); + const workflowById = workflowsQueryKeys.byId({ workflowId, filterId }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ documentId }: { documentId: string }) => { + return fetchWorkflowDocumentOCRResult({ + workflowRuntimeId: workflowId, + documentId, + }); + }, + onSuccess: (data, variables) => { + void queryClient.invalidateQueries(workflowsQueryKeys._def); + toast.success(t('toast:document_ocr.success')); + }, + onError: (error, variables) => { + console.error('OCR error:', error, 'for document:', variables.documentId); + void queryClient.invalidateQueries(workflowsQueryKeys._def); + toast.error(t('toast:document_ocr.error')); + }, + }); +}; diff --git a/apps/backoffice-v2/src/domains/workflows/fetchers.ts b/apps/backoffice-v2/src/domains/workflows/fetchers.ts index 47cd387770..663359a8ab 100644 --- a/apps/backoffice-v2/src/domains/workflows/fetchers.ts +++ b/apps/backoffice-v2/src/domains/workflows/fetchers.ts @@ -298,3 +298,21 @@ export const createWorkflowRequest = async ({ return handleZodError(error, workflow); }; + +export const fetchWorkflowDocumentOCRResult = async ({ + workflowRuntimeId, + documentId, +}: { + workflowRuntimeId: string; + documentId: string; +}) => { + const [workflow, error] = await apiClient({ + method: Method.GET, + url: `${getOriginUrl( + env.VITE_API_URL, + )}/api/v1/internal/workflows/${workflowRuntimeId}/documents/${documentId}/run-ocr`, + schema: z.any(), + }); + + return handleZodError(error, workflow); +}; diff --git a/apps/backoffice-v2/src/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic.tsx b/apps/backoffice-v2/src/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic.tsx index 1677b129f8..dd7b4718b5 100644 --- a/apps/backoffice-v2/src/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic.tsx +++ b/apps/backoffice-v2/src/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic.tsx @@ -1,4 +1,3 @@ -import { CommonWorkflowEvent } from '@ballerine/common'; import { ComponentProps, FunctionComponent, useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { useApproveTaskByIdMutation } from '../../../../../../domains/entities/hooks/mutations/useApproveTaskByIdMutation/useApproveTaskByIdMutation'; diff --git a/apps/backoffice-v2/src/lib/blocks/components/Details/Details.tsx b/apps/backoffice-v2/src/lib/blocks/components/Details/Details.tsx index 77b7a34163..6f85036c0f 100644 --- a/apps/backoffice-v2/src/lib/blocks/components/Details/Details.tsx +++ b/apps/backoffice-v2/src/lib/blocks/components/Details/Details.tsx @@ -13,6 +13,7 @@ export const Details: FunctionComponent> = ({ workflowId, documents = [], onSubmit, + isSaveDisabled, props, }) => { if (!value.data?.length) { @@ -38,6 +39,7 @@ export const Details: FunctionComponent> = ({ documents={documents} title={value?.title} data={sortedData} + isSaveDisabled={isSaveDisabled} contextUpdateMethod={contextUpdateMethod} onSubmit={onSubmit} /> diff --git a/apps/backoffice-v2/src/lib/blocks/components/EditableDetails/EditableDetails.tsx b/apps/backoffice-v2/src/lib/blocks/components/EditableDetails/EditableDetails.tsx index b967d6b1ea..85199220a6 100644 --- a/apps/backoffice-v2/src/lib/blocks/components/EditableDetails/EditableDetails.tsx +++ b/apps/backoffice-v2/src/lib/blocks/components/EditableDetails/EditableDetails.tsx @@ -103,6 +103,7 @@ export const EditableDetails: FunctionComponent = ({ documents, title, workflowId, + isSaveDisabled, contextUpdateMethod = 'base', onSubmit: onSubmitCallback, }) => { @@ -427,7 +428,11 @@ export const EditableDetails: FunctionComponent = ({
{data?.some(({ isEditable }) => isEditable) && ( - )} diff --git a/apps/backoffice-v2/src/lib/blocks/components/EditableDetails/interfaces.ts b/apps/backoffice-v2/src/lib/blocks/components/EditableDetails/interfaces.ts index c1c2cc0d23..4da7f1351a 100644 --- a/apps/backoffice-v2/src/lib/blocks/components/EditableDetails/interfaces.ts +++ b/apps/backoffice-v2/src/lib/blocks/components/EditableDetails/interfaces.ts @@ -25,6 +25,7 @@ export interface IEditableDetails { documents: IEditableDetailsDocument[]; title: string; workflowId: string; + isSaveDisabled?: boolean; contextUpdateMethod?: 'base' | 'director'; onSubmit?: (document: AnyObject) => void; } diff --git a/apps/backoffice-v2/src/lib/blocks/components/MultiDocuments/MultiDocuments.tsx b/apps/backoffice-v2/src/lib/blocks/components/MultiDocuments/MultiDocuments.tsx index 167ee82ca7..c053fff65a 100644 --- a/apps/backoffice-v2/src/lib/blocks/components/MultiDocuments/MultiDocuments.tsx +++ b/apps/backoffice-v2/src/lib/blocks/components/MultiDocuments/MultiDocuments.tsx @@ -7,7 +7,13 @@ export const MultiDocuments: FunctionComponent = ({ value return (
- +
); }; diff --git a/apps/backoffice-v2/src/lib/blocks/components/MultiDocuments/interfaces.ts b/apps/backoffice-v2/src/lib/blocks/components/MultiDocuments/interfaces.ts index fe92eb3c6e..2e3bbc0132 100644 --- a/apps/backoffice-v2/src/lib/blocks/components/MultiDocuments/interfaces.ts +++ b/apps/backoffice-v2/src/lib/blocks/components/MultiDocuments/interfaces.ts @@ -1,6 +1,9 @@ export interface IMultiDocumentsProps { value: { isLoading: boolean; + onOcrPressed: () => void; + isLoadingOCR: boolean; + isDocumentEditable: boolean; data: Array<{ imageUrl: string; title: string; diff --git a/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/types.ts b/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/types.ts index 28075eb23b..a94904702d 100644 --- a/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/types.ts +++ b/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/types.ts @@ -115,6 +115,7 @@ export type TDetailsCell = { hideSeparator?: boolean; documents?: IEditableDetailsDocument[]; contextUpdateMethod?: 'base' | 'director'; + isSaveDisabled?: boolean; value: { id: string; title: string; diff --git a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks.tsx b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks.tsx index dcc7d9eaa4..8af41a490d 100644 --- a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks.tsx +++ b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks.tsx @@ -1,7 +1,7 @@ import { MotionButton } from '@/common/components/molecules/MotionButton/MotionButton'; import { checkIsBusiness } from '@/common/utils/check-is-business/check-is-business'; import { ctw } from '@/common/utils/ctw/ctw'; -import { CommonWorkflowStates, StateTag, valueOrNA } from '@ballerine/common'; +import { CommonWorkflowStates, isObject, StateTag, valueOrNA } from '@ballerine/common'; import { useApproveTaskByIdMutation } from '@/domains/entities/hooks/mutations/useApproveTaskByIdMutation/useApproveTaskByIdMutation'; import { useRejectTaskByIdMutation } from '@/domains/entities/hooks/mutations/useRejectTaskByIdMutation/useRejectTaskByIdMutation'; import { useRemoveDecisionTaskByIdMutation } from '@/domains/entities/hooks/mutations/useRemoveDecisionTaskByIdMutation/useRemoveDecisionTaskByIdMutation'; @@ -29,6 +29,7 @@ import { X } from 'lucide-react'; import * as React from 'react'; import { FunctionComponent, useCallback, useMemo } from 'react'; import { toTitleCase } from 'string-ts'; +import { useDocumentOcr } from '@/domains/entities/hooks/mutations/useDocumentOcr/useDocumentOcr'; export const useDocumentBlocks = ({ workflow, @@ -79,6 +80,14 @@ export const useDocumentBlocks = ({ const { mutate: mutateApproveTaskById, isLoading: isLoadingApproveTaskById } = useApproveTaskByIdMutation(workflow?.id); + const { + mutate: mutateOCRDocument, + isLoading: isLoadingOCRDocument, + data: ocrResult, + } = useDocumentOcr({ + workflowId: workflow?.id, + }); + const { isLoading: isLoadingRejectTaskById } = useRejectTaskByIdMutation(workflow?.id); const { comment, onClearComment, onCommentChange } = useCommentInputLogic(); @@ -358,6 +367,25 @@ export const useDocumentBlocks = ({ }) .cellAt(0, 0); + const documentEntries = Object.entries( + { + ...additionalProperties, + ...propertiesSchema?.properties, + } ?? {}, + ).map(([title, formattedValue]) => { + if (isObject(formattedValue)) { + return [ + title, + { + ...formattedValue, + value: formattedValue.value || ocrResult?.parsedData?.[title], + }, + ]; + } + + return [title, formattedValue]; + }); + const detailsCell = createBlocksTyped() .addBlock() .addCell({ @@ -370,12 +398,7 @@ export const useDocumentBlocks = ({ value: { id, title: `${category} - ${docType}`, - data: Object.entries( - { - ...additionalProperties, - ...propertiesSchema?.properties, - } ?? {}, - )?.map( + data: documentEntries?.map( ([ title, { @@ -441,6 +464,7 @@ export const useDocumentBlocks = ({ }, }, workflowId: workflow?.id, + isSaveDisabled: isLoadingOCRDocument, documents: workflow?.context?.documents, }) .addCell(decisionCell) @@ -455,6 +479,9 @@ export const useDocumentBlocks = ({ type: 'multiDocuments', value: { isLoading: storageFilesQueryResult?.some(({ isLoading }) => isLoading), + onOcrPressed: () => mutateOCRDocument({ documentId: id }), + isDocumentEditable: caseState.writeEnabled, + isLoadingOCR: isLoadingOCRDocument, data: documents?.[docIndex]?.pages?.map( ({ type, fileName, metadata, ballerineFileId }, pageIndex) => ({ diff --git a/apps/backoffice-v2/src/pages/Entity/components/Case/Case.Documents.Toolbar.tsx b/apps/backoffice-v2/src/pages/Entity/components/Case/Case.Documents.Toolbar.tsx index d2dc82764c..fed645e15e 100644 --- a/apps/backoffice-v2/src/pages/Entity/components/Case/Case.Documents.Toolbar.tsx +++ b/apps/backoffice-v2/src/pages/Entity/components/Case/Case.Documents.Toolbar.tsx @@ -5,6 +5,7 @@ import { ImageViewer } from '@/common/components/organisms/ImageViewer/ImageView import { ctw } from '@/common/utils/ctw/ctw'; import { isPdf } from '@/common/utils/is-pdf/is-pdf'; import { useDocumentsToolbarLogic } from '@/pages/Entity/components/Case/hooks/useDocumentsToolbarLogic/useDocumentsToolbarLogic'; +import { ImageOCR } from '@/common/components/molecules/ImageOCR/ImageOCR'; export const DocumentsToolbar: FunctionComponent<{ image: { id: string; imageUrl: string; fileType: string; fileName: string }; @@ -13,6 +14,9 @@ export const DocumentsToolbar: FunctionComponent<{ onRotateDocument: () => void; onOpenDocumentInNewTab: (id: string) => void; shouldDownload: boolean; + onOcrPressed?: () => void; + isOCREnabled: boolean; + isLoadingOCR: boolean; fileToDownloadBase64: string; }> = ({ image, @@ -20,7 +24,10 @@ export const DocumentsToolbar: FunctionComponent<{ hideOpenExternalButton, onRotateDocument, onOpenDocumentInNewTab, + onOcrPressed, shouldDownload, + isLoadingOCR, + isOCREnabled, fileToDownloadBase64, }) => { const { onOpenInNewTabClick } = useDocumentsToolbarLogic({ @@ -31,6 +38,11 @@ export const DocumentsToolbar: FunctionComponent<{ return (
+ {!hideOpenExternalButton && !isLoading && image?.id && (