mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
7a94ee3b83
Use Gemini to handle detection of recipients and fields within documents. Opt in using organisation or team settings. Replaces #2128 since the branch was cursed and would include dependencies that weren't even in the lock file. https://github.com/user-attachments/assets/e6cbb58f-62b9-4079-a9ae-7af5c4f2e4ec
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
import { lazy, useEffect, useMemo, useState } from 'react';
|
|
|
|
import type { MessageDescriptor } from '@lingui/core';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
|
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
|
import { Link, useSearchParams } from 'react-router';
|
|
import { isDeepEqual } from 'remeda';
|
|
import { match } from 'ts-pattern';
|
|
|
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
|
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
|
import {
|
|
FIELD_META_DEFAULT_VALUES,
|
|
type TCheckboxFieldMeta,
|
|
type TDateFieldMeta,
|
|
type TDropdownFieldMeta,
|
|
type TEmailFieldMeta,
|
|
type TFieldMetaSchema,
|
|
type TInitialsFieldMeta,
|
|
type TNameFieldMeta,
|
|
type TNumberFieldMeta,
|
|
type TRadioFieldMeta,
|
|
type TSignatureFieldMeta,
|
|
type TTextFieldMeta,
|
|
} from '@documenso/lib/types/field-meta';
|
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import { Separator } from '@documenso/ui/primitives/separator';
|
|
|
|
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
|
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
|
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
|
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
|
import { EditorFieldEmailForm } from '~/components/forms/editor/editor-field-email-form';
|
|
import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-initials-form';
|
|
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
|
|
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
|
|
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
|
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
|
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
|
import { useCurrentTeam } from '~/providers/team';
|
|
|
|
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
|
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
|
|
|
const EnvelopeEditorFieldsPageRenderer = lazy(
|
|
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
|
|
);
|
|
|
|
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
|
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
|
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
|
[FieldType.TEXT]: msg`Text Settings`,
|
|
[FieldType.DATE]: msg`Date Settings`,
|
|
[FieldType.EMAIL]: msg`Email Settings`,
|
|
[FieldType.NAME]: msg`Name Settings`,
|
|
[FieldType.INITIALS]: msg`Initials Settings`,
|
|
[FieldType.NUMBER]: msg`Number Settings`,
|
|
[FieldType.RADIO]: msg`Radio Settings`,
|
|
[FieldType.CHECKBOX]: msg`Checkbox Settings`,
|
|
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
|
};
|
|
|
|
export const EnvelopeEditorFieldsPage = () => {
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const team = useCurrentTeam();
|
|
|
|
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
|
|
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
|
|
|
const { _ } = useLingui();
|
|
|
|
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
|
|
|
const selectedField = useMemo(
|
|
() => structuredClone(editorFields.selectedField),
|
|
[editorFields.selectedField],
|
|
);
|
|
|
|
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
|
|
if (!selectedField) {
|
|
return;
|
|
}
|
|
|
|
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
|
|
|
|
// Todo: Envelopes - Clean up console logs.
|
|
if (!isMetaSame) {
|
|
console.log('TRIGGER UPDATE');
|
|
editorFields.updateFieldByFormId(selectedField.formId, {
|
|
fieldMeta,
|
|
});
|
|
} else {
|
|
console.log('DATA IS SAME, NO UPDATE');
|
|
}
|
|
};
|
|
|
|
const onFieldDetectionComplete = (fields: NormalizedFieldWithContext[]) => {
|
|
for (const field of fields) {
|
|
editorFields.addField({
|
|
height: field.height,
|
|
width: field.width,
|
|
positionX: field.positionX,
|
|
positionY: field.positionY,
|
|
type: field.type,
|
|
envelopeItemId: field.envelopeItemId,
|
|
recipientId: field.recipientId,
|
|
page: field.pageNumber,
|
|
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[field.type]),
|
|
});
|
|
}
|
|
|
|
setIsAiFieldDialogOpen(false);
|
|
};
|
|
|
|
/**
|
|
* Set the selected recipient to the first recipient in the envelope.
|
|
*/
|
|
useEffect(() => {
|
|
const firstSelectableRecipient = envelope.recipients.find(
|
|
(recipient) =>
|
|
recipient.role === RecipientRole.SIGNER || recipient.role === RecipientRole.APPROVER,
|
|
);
|
|
|
|
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="relative flex h-full">
|
|
<div className="flex w-full flex-col overflow-y-auto">
|
|
{/* Horizontal envelope item selector */}
|
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
|
|
|
{/* Document View */}
|
|
<div className="mt-4 flex flex-col items-center justify-center">
|
|
{envelope.recipients.length === 0 && (
|
|
<Alert
|
|
variant="neutral"
|
|
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
<AlertTitle>
|
|
<Trans>Missing Recipients</Trans>
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
<Trans>You need at least one recipient to add fields</Trans>
|
|
</AlertDescription>
|
|
</div>
|
|
|
|
<Button asChild variant="outline">
|
|
<Link to={`${relativePath.editorPath}`}>
|
|
<Trans>Add Recipients</Trans>
|
|
</Link>
|
|
</Button>
|
|
</Alert>
|
|
)}
|
|
|
|
{currentEnvelopeItem !== null ? (
|
|
<PDFViewerKonvaLazy
|
|
renderer="editor"
|
|
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-32">
|
|
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
|
|
<p className="mt-1 text-sm text-foreground">
|
|
<Trans>No documents found</Trans>
|
|
</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
<Trans>Please upload a document to continue</Trans>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Section - Form Fields Panel */}
|
|
{currentEnvelopeItem && envelope.recipients.length > 0 && (
|
|
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-border bg-background py-4">
|
|
{/* Recipient selector section. */}
|
|
<section className="px-4">
|
|
<h3 className="mb-2 text-sm font-semibold text-foreground">
|
|
<Trans>Selected Recipient</Trans>
|
|
</h3>
|
|
|
|
<EnvelopeRecipientSelector
|
|
selectedRecipient={editorFields.selectedRecipient}
|
|
onSelectedRecipientChange={(recipient) =>
|
|
editorFields.setSelectedRecipient(recipient.id)
|
|
}
|
|
recipients={envelope.recipients}
|
|
fields={envelope.fields}
|
|
className="w-full"
|
|
align="end"
|
|
/>
|
|
|
|
{editorFields.selectedRecipient &&
|
|
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
|
|
<Alert className="mt-4" variant="warning">
|
|
<AlertDescription>
|
|
<Trans>
|
|
This recipient can no longer be modified as they have signed a field, or
|
|
completed the document.
|
|
</Trans>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</section>
|
|
|
|
<Separator className="my-4" />
|
|
|
|
{/* Add fields section. */}
|
|
<section className="px-4">
|
|
<h3 className="mb-2 text-sm font-semibold text-foreground">
|
|
<Trans>Add Fields</Trans>
|
|
</h3>
|
|
|
|
<EnvelopeEditorFieldDragDrop
|
|
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
|
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
|
/>
|
|
|
|
{team.preferences.aiFeaturesEnabled && (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-4 w-full"
|
|
onClick={() => setIsAiFieldDialogOpen(true)}
|
|
disabled={envelope.status !== DocumentStatus.DRAFT}
|
|
title={
|
|
envelope.status !== DocumentStatus.DRAFT
|
|
? _(msg`You can only detect fields in draft envelopes`)
|
|
: undefined
|
|
}
|
|
>
|
|
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
|
<Trans>Detect with AI</Trans>
|
|
</Button>
|
|
|
|
<AiFieldDetectionDialog
|
|
open={isAiFieldDialogOpen}
|
|
onOpenChange={setIsAiFieldDialogOpen}
|
|
onComplete={onFieldDetectionComplete}
|
|
envelopeId={envelope.id}
|
|
teamId={envelope.teamId}
|
|
/>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{/* Field details section. */}
|
|
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
|
{selectedField && (
|
|
<section>
|
|
<Separator className="my-4" />
|
|
|
|
{searchParams.get('devmode') && (
|
|
<>
|
|
<div className="px-4">
|
|
<h3 className="mb-3 text-sm font-semibold text-foreground">
|
|
<Trans>Developer Mode</Trans>
|
|
</h3>
|
|
|
|
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">Pos X: </span>
|
|
{selectedField.positionX.toFixed(2)}
|
|
</p>
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">Pos Y: </span>
|
|
{selectedField.positionY.toFixed(2)}
|
|
</p>
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">Width: </span>
|
|
{selectedField.width.toFixed(2)}
|
|
</p>
|
|
<p>
|
|
<span className="min-w-12 text-muted-foreground">Height: </span>
|
|
{selectedField.height.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className="my-4" />
|
|
</>
|
|
)}
|
|
|
|
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
|
|
<h3 className="text-sm font-semibold">
|
|
{_(FieldSettingsTypeTranslations[selectedField.type])}
|
|
</h3>
|
|
|
|
{match(selectedField.type)
|
|
.with(FieldType.SIGNATURE, () => (
|
|
<EditorFieldSignatureForm
|
|
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.CHECKBOX, () => (
|
|
<EditorFieldCheckboxForm
|
|
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.DATE, () => (
|
|
<EditorFieldDateForm
|
|
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.DROPDOWN, () => (
|
|
<EditorFieldDropdownForm
|
|
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.EMAIL, () => (
|
|
<EditorFieldEmailForm
|
|
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.INITIALS, () => (
|
|
<EditorFieldInitialsForm
|
|
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.NAME, () => (
|
|
<EditorFieldNameForm
|
|
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.NUMBER, () => (
|
|
<EditorFieldNumberForm
|
|
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.RADIO, () => (
|
|
<EditorFieldRadioForm
|
|
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.TEXT, () => (
|
|
<EditorFieldTextForm
|
|
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.otherwise(() => null)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</AnimateGenericFadeInOut>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|