diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..29683b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +dist +backend/dist +.git +.gitignore +.env +.env.* +*.md +.DS_Store +coverage +.vscode +.cursor +**/*.test.ts +**/*.spec.ts diff --git a/.env.deploy.example b/.env.deploy.example new file mode 100644 index 0000000..9ed8703 --- /dev/null +++ b/.env.deploy.example @@ -0,0 +1,12 @@ +# Copy to `.env.deploy` in the repo root (gitignored) and fill in. +# Only simple VAR=value lines — no spaces around `=`, no commands. +# Do not put Gemini/M365 secrets here; use `.env` for the app only. + +PROJECT_ID=YOUR_PROJECT_ID +REGION=YOUR_REGION + +# Optional — if omitted, REPO, SERVICE, and LOCAL_IMAGE default to PROJECT_ID: +# REPO=your-artifact-registry-repo-name +# SERVICE=your-cloud-run-service-name +# LOCAL_IMAGE=local-docker-image-tag +# IMAGE_TAG=latest diff --git a/.gitignore b/.gitignore index a547bf3..08e971c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr *.local +.env +.env.deploy # Editor directories and files .vscode/* diff --git a/App.tsx b/App.tsx index 5a922a1..11648cb 100644 --- a/App.tsx +++ b/App.tsx @@ -1,17 +1,38 @@ -import React, { useState, useMemo } from 'react'; -import { UploadCloud, File as FileIcon, Loader2, Download, Layers, Users, X, CheckCircle2, FileText, Eye, UserPen } from 'lucide-react'; +import React, { useState, useMemo, useRef, useEffect } from 'react'; +import { UploadCloud, File as FileIcon, Loader2, Download, Layers, Users, X, CheckCircle2, FileText, Eye, UserPen, Save, FolderOpen, AlertTriangle, ArrowLeftRight, Wand2, Package, Pencil, Check } from 'lucide-react'; import { v4 as uuidv4 } from 'uuid'; import JSZip from 'jszip'; -import { ExtractedSignaturePage, GroupingMode, ProcessedDocument } from './types'; -import { getPageCount, renderPageToImage, generateGroupedPdfs, findSignaturePageCandidates, extractSinglePagePdf } from './services/pdfService'; -import { analyzePage } from './services/geminiService'; +import { ExtractedSignaturePage, GroupingMode, ProcessedDocument, SavedConfiguration, AppMode, ExecutedUpload, ExecutedSignaturePage, AssemblyMatch } from './types'; +import { getPageCount, renderPageToImage, generateGroupedPdfs, findSignaturePageCandidates, extractSinglePagePdf, assembleAllDocuments } from './services/pdfService'; +import { analyzePage, analyzeExecutedPage } from './services/geminiService'; +import { autoMatch, createManualMatch } from './services/matchingService'; +import { convertDocxToPdf } from './services/docxService'; import SignatureCard from './components/SignatureCard'; import PdfPreviewModal from './components/PdfPreviewModal'; import InstructionsModal from './components/InstructionsModal'; +import CompletionChecklist from './components/CompletionChecklist'; +import ExecutedPageCard from './components/ExecutedPageCard'; +import MatchPickerModal from './components/MatchPickerModal'; // Concurrency Constants for AI - Keeping AI limit per doc to avoid rate limits, but unlimited docs const CONCURRENT_AI_REQUESTS_PER_DOC = 5; +/** When the PDF text layer has no signature keywords (common for DocuSign/scanned exports), scan every page with vision. */ +function allPageIndices(pageCount: number): number[] { + return Array.from({ length: pageCount }, (_, i) => i); +} + +const FALLBACK_AI_PAGE_WARN_THRESHOLD = 60; + +/** Preview URLs may use `blob:…#page=N`; revoke must use the blob URL only. */ +function revokePreviewBlobUrl(url: string | null | undefined) { + if (!url) return; + URL.revokeObjectURL(url.split('#')[0]); +} + +type SupportedSourceFormat = 'pdf' | 'docx'; +type NormalizedUpload = { sourceFile: File; pdfFile: File | null; errorMessage?: string }; + const App: React.FC = () => { const [documents, setDocuments] = useState([]); const [isProcessing, setIsProcessing] = useState(false); @@ -20,6 +41,51 @@ const App: React.FC = () => { // Grouping & Filtering State const [groupingMode, setGroupingMode] = useState('agreement'); + // App Mode State + const [appMode, setAppMode] = useState('extract'); + + // Assembly State + const [executedUploads, setExecutedUploads] = useState([]); + const [assemblyMatches, setAssemblyMatches] = useState([]); + /** Executed pages that must not be auto-matched again after user removes an auto-match or deletes an auto-matched blank. Cleared when user manually matches that executed page. */ + const [autoMatchExcludedExecutedIds, setAutoMatchExcludedExecutedIds] = useState([]); + const [isDraggingExecuted, setIsDraggingExecuted] = useState(false); + + // Match Picker Modal State + const [matchPickerState, setMatchPickerState] = useState<{ + isOpen: boolean; + blankPageId: string | null; + currentMatch: AssemblyMatch | null; + initialExecutedPageId: string | null; + }>({ isOpen: false, blankPageId: null, currentMatch: null, initialExecutedPageId: null }); + const normalizeForMatch = (value: string | null | undefined): string => + (value ?? '').toLowerCase().trim().replace(/\s+/g, ' '); + + const pickBestBlankForExecuted = (executed: ExecutedSignaturePage): ExtractedSignaturePage | null => { + const matchedBlankIds = new Set(assemblyMatches.map(m => m.blankPageId)); + const candidates = allPages.filter(p => !matchedBlankIds.has(p.id)); + if (candidates.length === 0) return null; + + const edoc = normalizeForMatch(executed.extractedDocumentName); + const eparty = normalizeForMatch(executed.extractedPartyName); + const esig = normalizeForMatch(executed.extractedSignatoryName); + + const score = (blank: ExtractedSignaturePage): number => { + const bdoc = normalizeForMatch(blank.documentName); + const bparty = normalizeForMatch(blank.partyName); + const bsig = normalizeForMatch(blank.signatoryName); + let s = 0; + if (edoc && bdoc && (edoc === bdoc || edoc.includes(bdoc) || bdoc.includes(edoc))) s += 5; + if (eparty && bparty && (eparty === bparty || eparty.includes(bparty) || bparty.includes(eparty))) s += 3; + if (esig && bsig && (esig === bsig || esig.includes(bsig) || bsig.includes(esig))) s += 2; + return s; + }; + + const ranked = [...candidates].sort((a, b) => score(b) - score(a)); + return ranked[0] ?? null; + }; + + // Drag & Drop State const [isDragging, setIsDragging] = useState(false); @@ -32,41 +98,185 @@ const App: React.FC = () => { // Instructions Modal State const [isInstructionsOpen, setIsInstructionsOpen] = useState(false); + const [renamingDocId, setRenamingDocId] = useState(null); + const [renamingExecutedId, setRenamingExecutedId] = useState(null); + const [renameDraft, setRenameDraft] = useState(''); + + // Ref for load-config hidden file input + const loadConfigInputRef = useRef(null); + const replaceDocInputRef = useRef(null); + const [replaceTargetDocId, setReplaceTargetDocId] = useState(null); + /** Assembly: filter for “Missing pages” ZIP (`__all__` or exact signatory label). */ + const [missingPackSignatoryFilter, setMissingPackSignatoryFilter] = useState('__all__'); + + // Guard against duplicate restore runs (StrictMode double-invoke, rapid re-uploads) + const restoringIds = useRef>(new Set()); // --- Handlers --- + const getSupportedSourceFormat = (file: File): SupportedSourceFormat | null => { + const lowerName = file.name.toLowerCase(); + if (file.type === 'application/pdf' || lowerName.endsWith('.pdf')) return 'pdf'; + if ( + file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || + lowerName.endsWith('.docx') + ) return 'docx'; + return null; + }; + + const normalizeUploadToPdf = async (file: File): Promise => { + const format = getSupportedSourceFormat(file); + if (!format) return null; + if (format === 'pdf') return file; + return convertDocxToPdf(file); + }; + + const getErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof Error && error.message) return error.message; + if (typeof error === 'string' && error.trim()) return error; + return fallback; + }; + const handleFileUpload = async (files: FileList | null) => { if (!files || files.length === 0) return; - // Check for API Key - if (!process.env.API_KEY) { - alert("API_KEY is missing from environment. Please provide a valid key."); - return; + const uploadedFiles = Array.from(files); + setCurrentStatus('Preparing uploads...'); + + const normalizedUploads: NormalizedUpload[] = await Promise.all(uploadedFiles.map(async (f) => { + try { + const pdfFile = await normalizeUploadToPdf(f); + if (!pdfFile) { + return { sourceFile: f, pdfFile: null, errorMessage: 'Unsupported file type. Please upload PDF or DOCX.' }; + } + return { sourceFile: f, pdfFile }; + } catch (error) { + console.error(`Failed to normalize file ${f.name}`, error); + return { sourceFile: f, pdfFile: null, errorMessage: getErrorMessage(error, 'DOCX conversion failed') }; + } + })); + setCurrentStatus(''); + + // Snapshot current docs before setState to find version updates by filename. + const versionUpdates: ProcessedDocument[] = []; + for (const normalized of normalizedUploads) { + if (!normalized.pdfFile) continue; + const matched = documents.find(d => d.name === normalized.pdfFile.name); + if (matched && !restoringIds.current.has(matched.id)) { + restoringIds.current.add(matched.id); + versionUpdates.push({ + ...matched, + file: normalized.pdfFile, + status: 'pending', + wasRestored: true, + savedPages: matched.extractedPages + }); + } } - const newDocs: ProcessedDocument[] = Array.from(files).map(f => { - // Validation: Strict PDF Check - const isPdf = f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'); - - return { + setDocuments(prev => { + const updatedDocs = [...prev]; + const newDocs: ProcessedDocument[] = []; + + for (const normalized of normalizedUploads) { + const file = normalized.pdfFile; + const existingIdx = file ? updatedDocs.findIndex(d => d.name === file.name) : -1; + + if (existingIdx !== -1) { + updatedDocs[existingIdx] = { + ...updatedDocs[existingIdx], + file, + status: 'pending', + errorMessage: undefined, + wasRestored: true, + savedPages: updatedDocs[existingIdx].extractedPages, + }; + } else { + newDocs.push({ id: uuidv4(), - name: f.name, - file: f, + name: file?.name ?? normalized.sourceFile.name, + file, pageCount: 0, - status: isPdf ? 'pending' : 'error', - extractedPages: [] - }; + status: file ? 'pending' : 'error', + errorMessage: normalized.errorMessage, + extractedPages: [], + }); + } + } + + return [...updatedDocs, ...newDocs]; }); - setDocuments(prev => [...prev, ...newDocs]); - - // Process all valid pending docs immediately - // const validDocsToProcess = newDocs.filter(d => d.status === 'pending'); - // processAllDocuments(validDocsToProcess); + if (versionUpdates.length > 0) { + await processVersionUpdatedDocuments(versionUpdates); + versionUpdates.forEach(d => restoringIds.current.delete(d.id)); + } + }; + + const handleReplaceDocumentClick = (docId: string) => { + setReplaceTargetDocId(docId); + replaceDocInputRef.current?.click(); + }; + + const handleReplaceDocumentSelected = async (file: File | null) => { + if (!file || !replaceTargetDocId) { + if (replaceDocInputRef.current) replaceDocInputRef.current.value = ''; + setReplaceTargetDocId(null); + return; + } + + const targetDoc = documents.find(d => d.id === replaceTargetDocId); + if (!targetDoc) { + if (replaceDocInputRef.current) replaceDocInputRef.current.value = ''; + setReplaceTargetDocId(null); + return; + } + + setCurrentStatus(`Preparing replacement for '${targetDoc.name}'...`); + + let normalizedFile: File | null = null; + try { + normalizedFile = await normalizeUploadToPdf(file); + } catch (error) { + setCurrentStatus(getErrorMessage(error, 'Replacement conversion failed')); + setTimeout(() => setCurrentStatus(''), 3500); + if (replaceDocInputRef.current) replaceDocInputRef.current.value = ''; + setReplaceTargetDocId(null); + return; + } + + if (!normalizedFile) { + setCurrentStatus('Unsupported file type. Please upload PDF or DOCX.'); + setTimeout(() => setCurrentStatus(''), 3000); + if (replaceDocInputRef.current) replaceDocInputRef.current.value = ''; + setReplaceTargetDocId(null); + return; + } + + if (!restoringIds.current.has(targetDoc.id)) { + restoringIds.current.add(targetDoc.id); + } + + const versionUpdate: ProcessedDocument = { + ...targetDoc, + file: normalizedFile, + status: 'pending', + errorMessage: undefined, + wasRestored: true, + savedPages: targetDoc.extractedPages, + }; + + setDocuments(prev => prev.map(d => d.id === targetDoc.id ? versionUpdate : d)); + await processVersionUpdatedDocuments([versionUpdate]); + restoringIds.current.delete(targetDoc.id); + + if (replaceDocInputRef.current) replaceDocInputRef.current.value = ''; + setReplaceTargetDocId(null); }; const handleProcessPending = () => { - const pendingDocs = documents.filter(d => d.status === 'pending'); + // Only process truly new pending docs — restored ones auto-rescan via useEffect + const pendingDocs = documents.filter(d => d.status === 'pending' && d.file !== null && !d.wasRestored); processAllDocuments(pendingDocs); }; @@ -75,7 +285,7 @@ const App: React.FC = () => { */ const processAllDocuments = async (docsToProcess: ProcessedDocument[]) => { if (docsToProcess.length === 0) return; - + setIsProcessing(true); setCurrentStatus(`Processing ${docsToProcess.length} documents...`); @@ -86,9 +296,207 @@ const App: React.FC = () => { setCurrentStatus(''); }; + /** + * Re-process an updated version of existing documents while preserving prior + * extracted page edits. Existing extracted pages are retained by pageIndex; + * newly detected signature pages are appended. + */ + const processVersionUpdatedDocuments = async (updatedDocs: ProcessedDocument[]) => { + if (updatedDocs.length === 0) return; + + setIsProcessing(true); + setCurrentStatus(`Updating ${updatedDocs.length} document version${updatedDocs.length > 1 ? 's' : ''}...`); + + await Promise.all(updatedDocs.map(doc => processSingleDocumentWithMerge(doc))); + + setIsProcessing(false); + setCurrentStatus(''); + }; + + /** + * Re-processes a version-updated document: + * 1) preserves prior extracted pages by pageIndex (including user edits) + * 2) refreshes their thumbnails from the new file when page indices still exist + * 3) scans for newly added signature pages and appends them + */ + const processSingleDocumentWithMerge = async (doc: ProcessedDocument) => { + // savedPages was snapshotted onto the doc object at upload time, before we clear extractedPages + const savedPages: ExtractedSignaturePage[] = doc.savedPages ?? doc.extractedPages; + + setDocuments(prev => prev.map(d => d.id === doc.id ? { + ...d, + status: 'processing', + progress: 0, + errorMessage: undefined, + extractedPages: [], + savedPages: undefined + } : d)); + + try { + const file = doc.file!; + const pageCount = await getPageCount(file); + + // First pass: refresh thumbnails for known extracted pages + const uniquePageIndices = Array.from(new Set(savedPages.map(p => p.pageIndex))).sort((a, b) => a - b); + const totalKnown = uniquePageIndices.length; + const freshPages: ExtractedSignaturePage[] = []; + const knownIndexSet = new Set(uniquePageIndices); + const changedKnownIndices = new Set(); + + for (let i = 0; i < uniquePageIndices.length; i++) { + const pageIndex = uniquePageIndices[i]; + if (pageIndex >= pageCount) { + // Page no longer exists in latest version; keep prior extraction. + savedPages.filter(sp => sp.pageIndex === pageIndex).forEach(saved => freshPages.push(saved)); + } else { + try { + const { dataUrl, width, height } = await renderPageToImage(file, pageIndex); + const pagesAtIndex = savedPages.filter(sp => sp.pageIndex === pageIndex); + const wasChanged = pagesAtIndex.some(saved => saved.thumbnailUrl !== dataUrl); + if (wasChanged) changedKnownIndices.add(pageIndex); + pagesAtIndex.forEach(saved => { + freshPages.push({ ...saved, thumbnailUrl: dataUrl, originalWidth: width, originalHeight: height }); + }); + } catch (err) { + console.error(`Error rendering page ${pageIndex} of ${doc.name}`, err); + // Keep saved page as-is if render fails (stale thumbnail better than nothing) + savedPages.filter(sp => sp.pageIndex === pageIndex).forEach(saved => freshPages.push(saved)); + } + } + const progressKnown = totalKnown === 0 ? 40 : Math.round(((i + 1) / totalKnown) * 40); + setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, progress: progressKnown } : d)); + } + + // Second pass: detect newly added signature pages + const candidateIndices = await findSignaturePageCandidates(file, (curr, total) => { + const progress = 40 + Math.round((curr / total) * 30); + setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, progress } : d)); + }); + let effectiveCandidates = candidateIndices; + const mergeHeuristicFallback = candidateIndices.length === 0 && pageCount > 0; + if (mergeHeuristicFallback) { + effectiveCandidates = allPageIndices(pageCount); + } + const changedIndices = Array.from(changedKnownIndices).filter(pageIndex => pageIndex < pageCount); + const newCandidateIndices = effectiveCandidates.filter(pageIndex => !knownIndexSet.has(pageIndex)); + const indicesToAnalyze = Array.from(new Set([...newCandidateIndices, ...changedIndices])).sort((a, b) => a - b); + + if (mergeHeuristicFallback && indicesToAnalyze.length > 0) { + if (pageCount >= FALLBACK_AI_PAGE_WARN_THRESHOLD) { + console.warn( + `[Signature scan] No keyword matches in "${doc.name}" (merge) — analyzing ${indicesToAnalyze.length} page(s) with AI (full-doc fallback).` + ); + } + setCurrentStatus(`No keyword matches — scanning pages with AI for updates…`); + } + + if (indicesToAnalyze.length > 0) { + let processedNew = 0; + const totalNew = indicesToAnalyze.length; + const changedIndexResults = new Map(); + const changedIndexErrors = new Set(); + + for (let i = 0; i < indicesToAnalyze.length; i += CONCURRENT_AI_REQUESTS_PER_DOC) { + const chunk = indicesToAnalyze.slice(i, i + CONCURRENT_AI_REQUESTS_PER_DOC); + const chunkPromises = chunk.map(async (pageIndex) => { + try { + const { dataUrl, width, height } = await renderPageToImage(file, pageIndex); + const analysis = await analyzePage(dataUrl); + if (analysis.isSignaturePage) { + const pages = analysis.signatures.map(sig => ({ + id: uuidv4(), + documentId: doc.id, + documentName: doc.name, + pageIndex, + pageNumber: pageIndex + 1, + partyName: sig.partyName || "Unknown Party", + signatoryName: sig.signatoryName || "", + capacity: sig.capacity || "Signatory", + copies: 1, + thumbnailUrl: dataUrl, + originalWidth: width, + originalHeight: height + })); + return { pageIndex, pages, failed: false }; + } + return { pageIndex, pages: [] as ExtractedSignaturePage[], failed: false }; + } catch (err) { + console.error(`Error analyzing updated page ${pageIndex} of ${doc.name}`, err); + return { pageIndex, pages: [] as ExtractedSignaturePage[], failed: true }; + } + }); + + const chunkResults = await Promise.all(chunkPromises); + chunkResults.forEach(result => { + const isChangedIndex = changedKnownIndices.has(result.pageIndex); + if (isChangedIndex) { + if (result.failed) { + changedIndexErrors.add(result.pageIndex); + } else { + changedIndexResults.set(result.pageIndex, result.pages); + } + return; + } + result.pages.forEach(p => freshPages.push(p)); + }); + + processedNew += chunk.length; + const aiProgress = 70 + Math.round((processedNew / totalNew) * 30); + setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, progress: aiProgress } : d)); + } + + // Replace changed pages with newly analyzed content when analysis succeeded. + changedIndices.forEach(pageIndex => { + if (changedIndexErrors.has(pageIndex)) { + // Keep previously preserved entries if analysis failed. + return; + } + const replacementPages = changedIndexResults.get(pageIndex) ?? []; + const withoutOld = freshPages.filter(p => p.pageIndex !== pageIndex); + withoutOld.push(...replacementPages); + freshPages.length = 0; + withoutOld.forEach(p => freshPages.push(p)); + }); + } + + setDocuments(prev => prev.map(d => d.id === doc.id ? { + ...d, + status: 'completed', + progress: 100, + errorMessage: undefined, + pageCount, + extractedPages: freshPages, + wasRestored: undefined, + savedPages: undefined, + } : d)); + + restoringIds.current.delete(doc.id); + if (mergeHeuristicFallback && indicesToAnalyze.length > 0) { + setCurrentStatus(`Updated '${doc.name}' — kept prior pages; used full-document AI scan for new pages`); + setTimeout(() => setCurrentStatus(''), 4000); + } else { + setCurrentStatus(`Updated '${doc.name}' — kept prior pages, added new detections`); + setTimeout(() => setCurrentStatus(''), 3000); + } + + } catch (error) { + console.error(`Error restoring ${doc.name}`, error); + restoringIds.current.delete(doc.id); + setDocuments(prev => prev.map(d => d.id === doc.id ? { + ...d, + status: 'error', + errorMessage: getErrorMessage(error, 'Failed to restore this document'), + wasRestored: undefined, + savedPages: undefined + } : d)); + } + }; + const processSingleDocument = async (doc: ProcessedDocument) => { + if (!doc.file) return; // Safety guard — should not happen for normal pending docs + // Update status to processing - setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, status: 'processing', progress: 0 } : d)); + setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, status: 'processing', progress: 0, errorMessage: undefined } : d)); try { const pageCount = await getPageCount(doc.file); @@ -100,19 +508,29 @@ const App: React.FC = () => { setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, progress } : d)); }); + let indicesForAI = candidateIndices; + if (candidateIndices.length === 0 && pageCount > 0) { + indicesForAI = allPageIndices(pageCount); + if (pageCount >= FALLBACK_AI_PAGE_WARN_THRESHOLD) { + console.warn( + `[Signature scan] No keyword matches in "${doc.name}" — analyzing all ${pageCount} pages with AI (slower, higher API use).` + ); + } + setCurrentStatus(`No keyword matches — scanning all ${pageCount} pages with AI…`); + } + // 2. Visual AI Analysis on Candidate Pages (Parallelized) const extractedPages: ExtractedSignaturePage[] = []; - if (candidateIndices.length === 0) { - console.log(`No signature candidates found in ${doc.name} via regex.`); + if (indicesForAI.length === 0) { setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, progress: 100 } : d)); } else { // Process candidates in chunks to respect AI concurrency limit PER DOC let processedCount = 0; - const totalCandidates = candidateIndices.length; + const totalCandidates = indicesForAI.length; - for (let i = 0; i < candidateIndices.length; i += CONCURRENT_AI_REQUESTS_PER_DOC) { - const chunk = candidateIndices.slice(i, i + CONCURRENT_AI_REQUESTS_PER_DOC); + for (let i = 0; i < indicesForAI.length; i += CONCURRENT_AI_REQUESTS_PER_DOC) { + const chunk = indicesForAI.slice(i, i + CONCURRENT_AI_REQUESTS_PER_DOC); const chunkPromises = chunk.map(async (pageIndex) => { try { @@ -159,13 +577,22 @@ const App: React.FC = () => { ...d, status: 'completed', progress: 100, + errorMessage: undefined, pageCount, extractedPages } : d)); + if (candidateIndices.length === 0 && pageCount > 0) { + setTimeout(() => setCurrentStatus(''), 4000); + } + } catch (error) { console.error(`Error processing doc ${doc.name}`, error); - setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, status: 'error' } : d)); + setDocuments(prev => prev.map(d => d.id === doc.id ? { + ...d, + status: 'error', + errorMessage: getErrorMessage(error, 'Failed to process this document') + } : d)); } }; @@ -198,16 +625,509 @@ const App: React.FC = () => { }; const handleDeletePage = (pageId: string) => { - setDocuments(prev => prev.map(doc => ({ - ...doc, - extractedPages: doc.extractedPages.filter(p => p.id !== pageId) - }))); + const match = assemblyMatches.find(m => m.blankPageId === pageId); + if (match?.status === 'auto-matched') { + setAutoMatchExcludedExecutedIds(prev => + prev.includes(match.executedPageId) ? prev : [...prev, match.executedPageId] + ); + } + setAssemblyMatches(prev => prev.filter(m => m.blankPageId !== pageId)); + setDocuments(prev => prev.map(doc => ({ + ...doc, + extractedPages: doc.extractedPages.filter(p => p.id !== pageId) + }))); }; const removeDocument = (docId: string) => { + const doc = documents.find(d => d.id === docId); + const blankIds = new Set(doc?.extractedPages.map(p => p.id) ?? []); + const toExclude = assemblyMatches + .filter(m => blankIds.has(m.blankPageId) && m.status === 'auto-matched') + .map(m => m.executedPageId); + if (toExclude.length > 0) { + setAutoMatchExcludedExecutedIds(prev => [...new Set([...prev, ...toExclude])]); + } + setAssemblyMatches(prev => prev.filter(m => !blankIds.has(m.blankPageId))); setDocuments(prev => prev.filter(d => d.id !== docId)); }; + const beginRenameDocument = (doc: ProcessedDocument) => { + setRenamingDocId(doc.id); + setRenameDraft(doc.name); + }; + + const saveRenameDocument = (docId: string) => { + const nextName = renameDraft.trim(); + if (!nextName) { + setRenamingDocId(null); + setRenameDraft(''); + return; + } + + setDocuments(prev => prev.map(doc => doc.id === docId ? { + ...doc, + name: nextName, + extractedPages: doc.extractedPages.map(p => ({ ...p, documentName: nextName })) + } : doc)); + setAssemblyMatches(prev => prev.map(m => m.documentId === docId ? { ...m, documentName: nextName } : m)); + setRenamingDocId(null); + setRenameDraft(''); + }; + + const beginRenameExecutedUpload = (upload: ExecutedUpload) => { + setRenamingExecutedId(upload.id); + setRenameDraft(upload.fileName); + }; + + const saveRenameExecutedUpload = (uploadId: string) => { + const nextName = renameDraft.trim(); + if (!nextName) { + setRenamingExecutedId(null); + setRenameDraft(''); + return; + } + + setExecutedUploads(prev => prev.map(upload => upload.id === uploadId ? { + ...upload, + fileName: nextName, + executedPages: upload.executedPages.map(page => ({ ...page, sourceFileName: nextName })) + } : upload)); + setRenamingExecutedId(null); + setRenameDraft(''); + }; + + // --- Save / Load Configuration --- + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Strip the data:...;base64, prefix to store raw base64 + resolve(result.split(',')[1]); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + + const handleSaveConfiguration = async () => { + const pages = documents.flatMap(d => d.extractedPages); + if (pages.length === 0) return; + + setIsProcessing(true); + setCurrentStatus('Bundling PDFs into config...'); + + try { + // Convert document PDFs to base64 + const docEntries = await Promise.all(documents.map(async ({ id, name, pageCount, file }) => { + const entry: { id: string; name: string; pageCount: number; pdfBase64?: string } = { id, name, pageCount }; + if (file) { + entry.pdfBase64 = await fileToBase64(file); + } + return entry; + })); + + // Convert executed upload PDFs to base64 + const execEntries = await Promise.all( + executedUploads + .filter(u => u.status === 'completed') + .map(async ({ id, fileName, pageCount, executedPages, file }) => { + const entry: { id: string; fileName: string; pageCount: number; executedPages: typeof executedPages; pdfBase64?: string } = { id, fileName, pageCount, executedPages }; + if (file) { + entry.pdfBase64 = await fileToBase64(file); + } + return entry; + }) + ); + + const config: SavedConfiguration = { + version: 1, + savedAt: new Date().toISOString(), + groupingMode, + documents: docEntries, + extractedPages: pages, + executedUploads: execEntries, + assemblyMatches, + }; + + const blob = new Blob([JSON.stringify(config)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `SignatureConfig_${new Date().toISOString().slice(0, 10)}.json`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + console.error('Error saving configuration:', e); + alert('Failed to save configuration.'); + } finally { + setIsProcessing(false); + setCurrentStatus(''); + } + }; + + const base64ToFile = (base64: string, fileName: string): File => { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return new File([bytes], fileName, { type: 'application/pdf' }); + }; + + const handleLoadConfiguration = (file: File | null) => { + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const raw = e.target?.result as string; + const config = JSON.parse(raw) as SavedConfiguration; + + // Basic validation + if (config.version !== 1 || !Array.isArray(config.documents) || !Array.isArray(config.extractedPages)) { + alert('Invalid configuration file.'); + return; + } + + // Build restored documents, distributing extractedPages back by documentId + const pagesByDocId = new Map(); + for (const page of config.extractedPages) { + const arr = pagesByDocId.get(page.documentId) ?? []; + arr.push(page); + pagesByDocId.set(page.documentId, arr); + } + + const hasBundledPdfs = config.documents.some(d => !!d.pdfBase64); + + const restoredDocs: ProcessedDocument[] = config.documents.map(d => { + const pdfFile = d.pdfBase64 ? base64ToFile(d.pdfBase64, d.name) : null; + return { + id: d.id, + name: d.name, + file: pdfFile, + pageCount: d.pageCount, + status: pdfFile ? 'completed' as const : 'restored' as const, + extractedPages: pagesByDocId.get(d.id) ?? [], + }; + }); + + setDocuments(restoredDocs); + setGroupingMode(config.groupingMode); + + // Restore assembly state if present + if (config.executedUploads && config.executedUploads.length > 0) { + const restoredUploads: ExecutedUpload[] = config.executedUploads.map(u => ({ + ...u, + file: u.pdfBase64 ? base64ToFile(u.pdfBase64, u.fileName) : null as unknown as File, + status: 'completed' as const, + })); + setExecutedUploads(restoredUploads); + } + if (config.assemblyMatches && config.assemblyMatches.length > 0) { + setAssemblyMatches(config.assemblyMatches); + } + + if (hasBundledPdfs) { + setCurrentStatus('Configuration loaded with bundled PDFs'); + } else { + setCurrentStatus('Configuration loaded — re-upload PDFs to enable pack download'); + } + setTimeout(() => setCurrentStatus(''), 4000); + } catch { + alert('Could not read configuration file. Make sure it is a valid Signature Packet IDE JSON.'); + } + }; + reader.readAsText(file); + + // Reset so the same file can be re-loaded later + if (loadConfigInputRef.current) loadConfigInputRef.current.value = ''; + }; + + // --- Assembly Mode Handlers --- + + const allExecutedPages = useMemo(() => { + return executedUploads.flatMap(u => u.executedPages); + }, [executedUploads]); + + const handleExecutedFileUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return; + + const uploadedFiles = Array.from(files); + setCurrentStatus('Preparing executed uploads...'); + + const normalizedUploads: NormalizedUpload[] = await Promise.all(uploadedFiles.map(async (f) => { + try { + const pdfFile = await normalizeUploadToPdf(f); + if (!pdfFile) { + return { sourceFile: f, pdfFile: null, errorMessage: 'Unsupported file type. Please upload PDF or DOCX.' }; + } + return { sourceFile: f, pdfFile }; + } catch (error) { + console.error(`Failed to normalize executed file ${f.name}`, error); + return { sourceFile: f, pdfFile: null, errorMessage: getErrorMessage(error, 'DOCX conversion failed') }; + } + })); + setCurrentStatus(''); + + // Create ExecutedUpload entries + const newUploads: ExecutedUpload[] = normalizedUploads + .map(item => ({ + id: uuidv4(), + file: item.pdfFile ?? item.sourceFile, + fileName: (item.pdfFile?.name ?? item.sourceFile.name), + pageCount: 0, + status: item.pdfFile ? 'pending' as const : 'error' as const, + errorMessage: item.errorMessage, + executedPages: [], + })) + .filter(u => u.status === 'pending' || u.status === 'error'); + + // Check for duplicate filenames + const existingNames = new Set(executedUploads.map(u => `${u.fileName}_${u.file.size}`)); + const deduped = newUploads.filter(u => { + const key = `${u.fileName}_${u.file.size}`; + if (existingNames.has(key)) { + console.warn(`Skipping duplicate executed upload: ${u.fileName}`); + return false; + } + return true; + }); + + if (deduped.length === 0) return; + + setExecutedUploads(prev => [...prev, ...deduped]); + + // Process each upload + for (const upload of deduped.filter(u => u.status === 'pending')) { + await processExecutedUpload(upload); + } + }; + + const processExecutedUpload = async (upload: ExecutedUpload) => { + setExecutedUploads(prev => prev.map(u => u.id === upload.id ? { ...u, status: 'processing', progress: 0, errorMessage: undefined } : u)); + + try { + const pageCount = await getPageCount(upload.file); + const executedPages: ExecutedSignaturePage[] = []; + + for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) { + try { + const { dataUrl, width, height } = await renderPageToImage(upload.file, pageIndex); + const analysis = await analyzeExecutedPage(dataUrl); + + if (analysis.signatures.length > 0) { + // Create an ExecutedSignaturePage for each signature block found + for (const sig of analysis.signatures) { + executedPages.push({ + id: uuidv4(), + sourceUploadId: upload.id, + sourceFileName: upload.fileName, + pageIndexInSource: pageIndex, + pageNumber: pageIndex + 1, + extractedDocumentName: analysis.documentName || '', + extractedPartyName: sig.partyName || '', + extractedSignatoryName: sig.signatoryName || '', + extractedCapacity: sig.capacity || '', + isConfirmedExecuted: analysis.isExecuted, + thumbnailUrl: dataUrl, + originalWidth: width, + originalHeight: height, + matchedBlankPageId: null, + matchConfidence: null, + }); + } + } else { + // No signatures found, but still create an entry for the page + executedPages.push({ + id: uuidv4(), + sourceUploadId: upload.id, + sourceFileName: upload.fileName, + pageIndexInSource: pageIndex, + pageNumber: pageIndex + 1, + extractedDocumentName: analysis.documentName || '', + extractedPartyName: '', + extractedSignatoryName: '', + extractedCapacity: '', + isConfirmedExecuted: analysis.isExecuted, + thumbnailUrl: dataUrl, + originalWidth: width, + originalHeight: height, + matchedBlankPageId: null, + matchConfidence: null, + }); + } + } catch (err) { + console.error(`Error processing executed page ${pageIndex} of ${upload.fileName}`, err); + } + + // Update progress + const progress = Math.round(((pageIndex + 1) / pageCount) * 100); + setExecutedUploads(prev => prev.map(u => u.id === upload.id ? { ...u, progress } : u)); + } + + setExecutedUploads(prev => prev.map(u => u.id === upload.id ? { + ...u, + status: 'completed', + progress: 100, + errorMessage: undefined, + pageCount, + executedPages, + } : u)); + + } catch (error) { + console.error(`Error processing executed upload ${upload.fileName}`, error); + setExecutedUploads(prev => prev.map(u => u.id === upload.id ? { + ...u, + status: 'error', + errorMessage: getErrorMessage(error, 'Failed to process this executed upload') + } : u)); + } + }; + + const handleAutoMatch = () => { + const newMatches = autoMatch( + allPages, + allExecutedPages, + assemblyMatches, + autoMatchExcludedExecutedIds + ); + if (newMatches.length === 0) { + setCurrentStatus('No new matches found'); + setTimeout(() => setCurrentStatus(''), 2000); + return; + } + + // Merge: keep existing confirmed/overridden matches, replace auto-matches, add new ones + setAssemblyMatches(prev => { + const preserved = prev.filter(m => m.status === 'user-confirmed' || m.status === 'user-overridden'); + return [...preserved, ...newMatches]; + }); + + setCurrentStatus(`Auto-matched ${newMatches.length} page${newMatches.length > 1 ? 's' : ''}`); + setTimeout(() => setCurrentStatus(''), 3000); + }; + + const handleManualMatch = (blankPageId: string, executedPageId: string) => { + const blank = allPages.find(p => p.id === blankPageId); + const executed = allExecutedPages.find(p => p.id === executedPageId); + if (!blank || !executed) return; + + const match = createManualMatch(blank, executed); + + setAssemblyMatches(prev => { + // Remove any existing match for this blank page + const filtered = prev.filter(m => m.blankPageId !== blankPageId); + return [...filtered, match]; + }); + setAutoMatchExcludedExecutedIds(prev => prev.filter(id => id !== executedPageId)); + }; + + const handleUnmatch = (blankPageId: string) => { + const match = assemblyMatches.find(m => m.blankPageId === blankPageId); + if (match?.status === 'auto-matched') { + setAutoMatchExcludedExecutedIds(prev => + prev.includes(match.executedPageId) ? prev : [...prev, match.executedPageId] + ); + } + setAssemblyMatches(prev => prev.filter(m => m.blankPageId !== blankPageId)); + }; + + const handleUnmatchByExecutedId = (executedPageId: string) => { + const match = assemblyMatches.find(m => m.executedPageId === executedPageId); + if (match?.status === 'auto-matched') { + setAutoMatchExcludedExecutedIds(prev => + prev.includes(executedPageId) ? prev : [...prev, executedPageId] + ); + } + setAssemblyMatches(prev => prev.filter(m => m.executedPageId !== executedPageId)); + }; + + const handleChecklistCellClick = (blankPageId: string, currentMatch: AssemblyMatch | null) => { + setMatchPickerState({ + isOpen: true, + blankPageId, + currentMatch, + initialExecutedPageId: null, + }); + }; + + const handleMatchFromExecutedCard = (executedPageId: string) => { + const executed = allExecutedPages.find(p => p.id === executedPageId); + if (!executed) return; + const targetBlank = pickBestBlankForExecuted(executed); + if (!targetBlank) { + setCurrentStatus('No unmatched blank signature pages available to match'); + setTimeout(() => setCurrentStatus(''), 2500); + return; + } + setMatchPickerState({ + isOpen: true, + blankPageId: targetBlank.id, + currentMatch: assemblyMatches.find(m => m.blankPageId === targetBlank.id) ?? null, + initialExecutedPageId: executedPageId, + }); + }; + + const handleAssembleDocuments = async () => { + if (assemblyMatches.length === 0) return; + + // Warn about unmatched pages + const unmatchedCount = allPages.length - assemblyMatches.length; + if (unmatchedCount > 0) { + const proceed = window.confirm( + `${unmatchedCount} signature page${unmatchedCount > 1 ? 's' : ''} still unmatched. ` + + `Unmatched pages will keep the original (blank) signature page in the assembled document. Continue?` + ); + if (!proceed) return; + } + + setIsProcessing(true); + setCurrentStatus('Assembling documents...'); + let assemblyFailed = false; + + try { + const assembledPdfs = await assembleAllDocuments(documents, assemblyMatches, executedUploads); + + const zip = new JSZip(); + for (const [filename, data] of Object.entries(assembledPdfs)) { + zip.file(filename, data); + } + + const zipContent = await zip.generateAsync({ type: 'blob' }); + const url = window.URL.createObjectURL(zipContent); + const link = document.createElement('a'); + link.href = url; + link.download = `Assembled_Documents_${new Date().toISOString().slice(0, 10)}.zip`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + + } catch (e) { + assemblyFailed = true; + console.error('Assembly error:', e); + const message = getErrorMessage(e, 'Failed to assemble documents'); + setCurrentStatus(`Assembly failed: ${message}`); + setTimeout(() => setCurrentStatus(''), 8000); + alert(`Failed to assemble documents.\n\n${message}`); + } finally { + setIsProcessing(false); + if (!assemblyFailed) setCurrentStatus(''); + } + }; + + const removeExecutedUpload = (uploadId: string) => { + // Also remove any matches that reference pages from this upload + setExecutedUploads(prev => { + const upload = prev.find(u => u.id === uploadId); + if (upload) { + const pageIds = new Set(upload.executedPages.map(p => p.id)); + setAssemblyMatches(matches => matches.filter(m => !pageIds.has(m.executedPageId))); + } + return prev.filter(u => u.id !== uploadId); + }); + }; + // --- Preview Logic --- const openPreview = (url: string, title: string) => { @@ -215,14 +1135,31 @@ const App: React.FC = () => { }; const closePreview = () => { - if (previewState.url) { - URL.revokeObjectURL(previewState.url); - } + revokePreviewBlobUrl(previewState.url); setPreviewState({ isOpen: false, url: null, title: '' }); }; + /** Escape closes the top overlay: PDF preview (z above match picker), then Reassign / Match dialog. */ + useEffect(() => { + if (!previewState.isOpen && !matchPickerState.isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + if (previewState.isOpen) { + revokePreviewBlobUrl(previewState.url); + setPreviewState({ isOpen: false, url: null, title: '' }); + return; + } + if (matchPickerState.isOpen) { + setMatchPickerState({ isOpen: false, blankPageId: null, currentMatch: null, initialExecutedPageId: null }); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [previewState.isOpen, previewState.url, matchPickerState.isOpen]); + const handlePreviewDocument = async (doc: ProcessedDocument) => { - if (doc.status === 'error') return; // Don't preview errored files + if (doc.status === 'error' || doc.status === 'restored' || !doc.file) return; const url = URL.createObjectURL(doc.file); openPreview(url, doc.name); }; @@ -230,16 +1167,39 @@ const App: React.FC = () => { const handlePreviewSignaturePage = async (page: ExtractedSignaturePage) => { // Find the original document file const parentDoc = documents.find(d => d.id === page.documentId); - if (!parentDoc) return; + if (!parentDoc || !parentDoc.file) return; try { const pdfBytes = await extractSinglePagePdf(parentDoc.file, page.pageIndex); - const blob = new Blob([pdfBytes], { type: 'application/pdf' }); + const blob = new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }); const url = URL.createObjectURL(blob); openPreview(url, `${page.documentName} - Page ${page.pageNumber}`); } catch (e) { - console.error("Preview error", e); - alert("Could not generate preview."); + const message = e instanceof Error ? e.message : String(e); + console.error('[Preview] Single-page extract failed (blank). Using full PDF + #page.', e); + const blobUrl = URL.createObjectURL(parentDoc.file); + openPreview(`${blobUrl}#page=${page.pageNumber}`, `${page.documentName} - Page ${page.pageNumber}`); + setCurrentStatus(`Preview: full document at page ${page.pageNumber} (extract: ${message})`); + setTimeout(() => setCurrentStatus(''), 7000); + } + }; + + const handlePreviewExecutedPage = async (page: ExecutedSignaturePage) => { + const sourceUpload = executedUploads.find(u => u.id === page.sourceUploadId); + if (!sourceUpload) return; + + try { + const pdfBytes = await extractSinglePagePdf(sourceUpload.file, page.pageIndexInSource); + const blob = new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + openPreview(url, `${page.sourceFileName} - Page ${page.pageNumber}`); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error('[Preview] Single-page extract failed (executed). Using full PDF + #page.', e); + const blobUrl = URL.createObjectURL(sourceUpload.file); + openPreview(`${blobUrl}#page=${page.pageNumber}`, `${page.sourceFileName} - Page ${page.pageNumber}`); + setCurrentStatus(`Preview: full document at page ${page.pageNumber} (extract: ${message})`); + setTimeout(() => setCurrentStatus(''), 7000); } }; @@ -289,6 +1249,46 @@ const App: React.FC = () => { return Array.from(groups); }, [displayedPages, groupingMode]); + /** Blank signature pages with no assembly match (copies > 0). */ + const missingBlankPages = useMemo(() => { + const matched = new Set(assemblyMatches.map((m) => m.blankPageId)); + return allPages.filter((p) => p.copies > 0 && !matched.has(p.id)); + }, [allPages, assemblyMatches]); + + /** Subset we can export (parent document still has PDF on disk). */ + const { missingDownloadablePages, missingSkippedNoSourceFile } = useMemo(() => { + const downloadable: ExtractedSignaturePage[] = []; + let skipped = 0; + for (const p of missingBlankPages) { + const doc = documents.find((d) => d.id === p.documentId); + if (doc?.file) downloadable.push(p); + else skipped += 1; + } + return { missingDownloadablePages: downloadable, missingSkippedNoSourceFile: skipped }; + }, [missingBlankPages, documents]); + + const missingSignatoryOptions = useMemo(() => { + const names = new Set(); + for (const p of missingDownloadablePages) { + names.add(p.signatoryName?.trim() || 'Unknown Signatory'); + } + return Array.from(names).sort((a, b) => a.localeCompare(b)); + }, [missingDownloadablePages]); + + useEffect(() => { + if (missingPackSignatoryFilter === '__all__') return; + if (!missingSignatoryOptions.includes(missingPackSignatoryFilter)) { + setMissingPackSignatoryFilter('__all__'); + } + }, [missingSignatoryOptions, missingPackSignatoryFilter]); + + const pagesForMissingPack = useMemo(() => { + if (missingPackSignatoryFilter === '__all__') return missingDownloadablePages; + return missingDownloadablePages.filter( + (p) => (p.signatoryName?.trim() || 'Unknown Signatory') === missingPackSignatoryFilter, + ); + }, [missingDownloadablePages, missingPackSignatoryFilter]); + const scrollToGroup = (groupName: string) => { const id = `group-${groupName.replace(/[^a-zA-Z0-9]/g, '_')}`; const element = document.getElementById(id); @@ -333,6 +1333,49 @@ const App: React.FC = () => { } }; + /** + * ZIP of blank signature pages that are still unmatched — for chasing a signatory or counsel. + * PDFs are grouped by agreement name (stable in Assembly when grouping toggles are hidden). + */ + const handleDownloadMissingPack = async () => { + if (pagesForMissingPack.length === 0) return; + setIsProcessing(true); + setCurrentStatus('Generating missing-pages pack...'); + + try { + const pdfs = await generateGroupedPdfs(documents, pagesForMissingPack, 'agreement'); + + const zip = new JSZip(); + for (const [filename, data] of Object.entries(pdfs)) { + zip.file(filename, data); + } + + const zipContent = await zip.generateAsync({ type: 'blob' }); + const url = window.URL.createObjectURL(zipContent); + const link = document.createElement('a'); + link.href = url; + const who = + missingPackSignatoryFilter === '__all__' + ? 'all' + : missingPackSignatoryFilter.replace(/[/\\?%*:|"<>]/g, '_').slice(0, 80); + link.download = `SignaturePack_missing_${who}_${new Date().toISOString().slice(0, 10)}.zip`; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1500); + + if (missingSkippedNoSourceFile > 0) { + window.alert( + `${missingSkippedNoSourceFile} unmatched page(s) were omitted because the source document has no PDF on disk (re-upload from a saved config to include them).`, + ); + } + } catch (e) { + console.error(e); + window.alert('Failed to generate missing-pages pack'); + } finally { + setIsProcessing(false); + setCurrentStatus(''); + } + }; + // --- Render --- return ( @@ -353,10 +1396,48 @@ const App: React.FC = () => { pages={displayedPages} /> + {/* Match Picker Modal */} + setMatchPickerState({ isOpen: false, blankPageId: null, currentMatch: null, initialExecutedPageId: null })} + blankPage={allPages.find(p => p.id === matchPickerState.blankPageId) || null} + currentMatch={matchPickerState.currentMatch} + initialExecutedPageId={matchPickerState.initialExecutedPageId} + executedPages={allExecutedPages} + allMatches={assemblyMatches} + onConfirmMatch={handleManualMatch} + onUnmatch={handleUnmatch} + onPreviewBlank={handlePreviewSignaturePage} + onPreviewExecuted={handlePreviewExecutedPage} + /> + + {/* Replace Version Input */} + handleReplaceDocumentSelected(e.target.files?.[0] ?? null)} + /> + {/* Header */}
-
S
+
+
+ +
+

Signature Packet IDE

Automated Signature Page Extraction

@@ -368,11 +1449,37 @@ const App: React.FC = () => { {documents.length} Docs {allPages.length} Sig Pages Found
+ {/* Save / Load Config */} +
+ handleLoadConfiguration(e.target.files?.[0] ?? null)} + /> + + +
-
- +
+ {/* Sidebar: Documents */}
@@ -390,7 +1497,7 @@ const App: React.FC = () => { handleFileUpload(e.target.files)} @@ -398,7 +1505,7 @@ const App: React.FC = () => {
@@ -414,48 +1521,122 @@ const App: React.FC = () => {
{documents.map(doc => ( -
-
+
+
-

{doc.name}

+ {renamingDocId === doc.id ? ( + setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') saveRenameDocument(doc.id); + if (e.key === 'Escape') { setRenamingDocId(null); setRenameDraft(''); } + }} + className="w-full text-sm px-2 py-1 rounded border border-slate-300 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + ) : ( +

{doc.name}

+ )}
{doc.status === 'processing' && <> Processing...} {doc.status === 'completed' && <> {doc.extractedPages.length} sig pages} - {doc.status === 'error' && PDF only} + {doc.status === 'error' && ( + + {doc.errorMessage || 'PDF or DOCX only'} + + )} {doc.status === 'pending' && 'Queued'} + {doc.status === 'restored' && ( + + Needs file + + )}
+ {doc.status === 'restored' && ( + {doc.extractedPages.length} sig pages (saved) + )} {doc.status === 'processing' && doc.progress !== undefined && (
-
)}
- + {/* Document Actions */}
- {doc.status !== 'error' && ( - )} - + {renamingDocId === doc.id ? ( + <> + + + + ) : ( + <> + + {doc.status !== 'processing' && ( + + )} + + + )}
@@ -466,109 +1647,300 @@ const App: React.FC = () => { No documents uploaded yet.
)} + + {/* Executed Uploads Section (Assembly Mode) */} + {appMode === 'assembly' && ( + <> +
+
+

Executed Pages

+
{ e.preventDefault(); setIsDraggingExecuted(true); }} + onDragLeave={() => setIsDraggingExecuted(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDraggingExecuted(false); + handleExecutedFileUpload(e.dataTransfer.files); + }} + > + handleExecutedFileUpload(e.target.files)} + /> + +
+
+ + {/* Executed uploads list */} + {executedUploads.map(upload => ( +
+
+ +
+
+ {renamingExecutedId === upload.id ? ( + setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') saveRenameExecutedUpload(upload.id); + if (e.key === 'Escape') { setRenamingExecutedId(null); setRenameDraft(''); } + }} + className="w-full text-sm px-2 py-1 rounded border border-slate-300 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + ) : ( +

{upload.fileName}

+ )} +
+
+ {upload.status === 'processing' && <> Analyzing...} + {upload.status === 'completed' && <> {upload.executedPages.filter(p => p.isConfirmedExecuted).length} signed pages} + {upload.status === 'error' && ( + + {upload.errorMessage || 'Error'} + + )} + {upload.status === 'pending' && 'Queued'} +
+ {upload.status === 'processing' && upload.progress !== undefined && ( +
+
+
+ )} +
+
+
+ {renamingExecutedId === upload.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ))} + + )}
{/* Main Content: Review Grid */} -
+
{/* Toolbar */}
+ {/* Mode Toggle */}
- - - + +
+ + {/* Grouping toggle (only in extract mode) */} + {appMode === 'extract' && ( +
+ + + +
+ )}
{/* Content Area with Nav */} -
+
{/* Grid Area */} -
- - {displayedPages.length === 0 ? ( -
-
- -
-

No signature pages found yet

-

Upload agreements (PDF) to begin extraction.

-
+
+ + {appMode === 'extract' ? ( + // --- Extract Mode Content --- + <> + {displayedPages.length === 0 ? ( +
+
+ +
+

No signature pages found yet

+

Upload agreements (PDF or DOCX) to begin extraction.

+
+ ) : ( +
+ {/* Render grouping headers based on current mode */} + {displayedPages.reduce((acc: React.ReactNode[], page, idx, arr) => { + const prev = arr[idx-1]; + let shouldInsertHeader = false; + let headerText = ''; + let HeaderIcon = Layers; + + if (groupingMode === 'agreement') { + shouldInsertHeader = !prev || prev.documentName !== page.documentName; + headerText = page.documentName; + HeaderIcon = FileText; + } else if (groupingMode === 'counterparty') { + shouldInsertHeader = !prev || prev.partyName !== page.partyName; + headerText = page.partyName; + HeaderIcon = Users; + } else { + // Signatory + const currentSig = page.signatoryName || 'Unknown Signatory'; + const prevSig = prev?.signatoryName || 'Unknown Signatory'; + shouldInsertHeader = !prev || prevSig !== currentSig; + headerText = currentSig; + HeaderIcon = UserPen; + } + + if (shouldInsertHeader) { + const headerId = `group-${headerText.replace(/[^a-zA-Z0-9]/g, '_')}`; + acc.push( +
+ +

{headerText}

+
+ ); + } + + acc.push( + p !== 'All')} + onUpdateCopies={handleUpdateCopies} + onUpdateParty={handleUpdateParty} + onUpdateSignatory={handleUpdateSignatory} + onUpdateCapacity={handleUpdateCapacity} + onDelete={handleDeletePage} + onPreview={handlePreviewSignaturePage} + /> + ); + return acc; + }, [])} +
+ )} + ) : ( -
- {/* Render grouping headers based on current mode */} - {displayedPages.reduce((acc: React.ReactNode[], page, idx, arr) => { - const prev = arr[idx-1]; - let shouldInsertHeader = false; - let headerText = ''; - let HeaderIcon = Layers; - - if (groupingMode === 'agreement') { - shouldInsertHeader = !prev || prev.documentName !== page.documentName; - headerText = page.documentName; - HeaderIcon = FileText; - } else if (groupingMode === 'counterparty') { - shouldInsertHeader = !prev || prev.partyName !== page.partyName; - headerText = page.partyName; - HeaderIcon = Users; - } else { - // Signatory - const currentSig = page.signatoryName || 'Unknown Signatory'; - const prevSig = prev?.signatoryName || 'Unknown Signatory'; - shouldInsertHeader = !prev || prevSig !== currentSig; - headerText = currentSig; - HeaderIcon = UserPen; - } - - if (shouldInsertHeader) { - const headerId = `group-${headerText.replace(/[^a-zA-Z0-9]/g, '_')}`; - acc.push( -
- -

{headerText}

-
- ); - } - - acc.push( - p !== 'All')} - onUpdateCopies={handleUpdateCopies} - onUpdateParty={handleUpdateParty} - onUpdateSignatory={handleUpdateSignatory} - onUpdateCapacity={handleUpdateCapacity} - onDelete={handleDeletePage} - onPreview={handlePreviewSignaturePage} - /> - ); - return acc; - }, [])} -
+ // --- Assembly Mode Content --- +
+ {/* Completion Checklist Grid */} + + + {/* Executed Pages Cards */} + {allExecutedPages.length > 0 && ( +
+

+ + Uploaded Executed Pages ({allExecutedPages.filter(p => p.isConfirmedExecuted).length} signed) +

+
+ {allExecutedPages.map(ep => ( + m.executedPageId === ep.id) || null} + onUnmatch={handleUnmatchByExecutedId} + onPreview={handlePreviewExecutedPage} + onMatchNow={handleMatchFromExecutedCard} + /> + ))} +
+
+ )} + + {allExecutedPages.length === 0 && ( +
+
+ +
+

No executed pages yet

+

Upload signed PDFs or DOCX files in the sidebar to begin matching.

+
+ )} +
)}
- {/* Right Nav Rail */} - {displayedPages.length > 0 && ( + {/* Right Nav Rail (Extract mode only) */} + {appMode === 'extract' && displayedPages.length > 0 && (

Jump to {groupingMode === 'counterparty' ? 'Party' : groupingMode === 'signatory' ? 'Signatory' : 'Agreement'} @@ -576,7 +1948,7 @@ const App: React.FC = () => {
    {navigationGroups.map(g => (
  • -

- {/* Floating Action Bar */} - {displayedPages.length > 0 && ( + {/* Floating Action Bar — Extract Mode */} + {appMode === 'extract' && displayedPages.length > 0 && (
-
-
)} + {/* Floating Action Bar — Assembly Mode */} + {appMode === 'assembly' && allPages.length > 0 && ( +
+ +
+ {missingSignatoryOptions.length > 0 && ( + + )} + +
+ +
+ )} + {/* Status Toast */} {currentStatus && (
diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d7d95e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# syntax=docker/dockerfile:1 + +# --- Build: Vite client + compiled Express API --- +# Gemini uses GEMINI_API_KEY at runtime (e.g. Cloud Run Secret Manager), not at image build time. +FROM node:20-bookworm-slim AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build && npm run build:server + +# --- Runtime: production deps + static + server JS --- +FROM node:20-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY --from=build /app/dist ./dist +COPY --from=build /app/backend/dist ./backend/dist + +EXPOSE 8080 +CMD ["node", "backend/dist/server.js"] diff --git a/README.md b/README.md index 7785943..14de804 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ https://github.com/user-attachments/assets/1b2ab8e4-0129-4319-aa13-05d31d714266 ## Features +### Extraction - **AI-Powered Extraction**: Uses Google Gemini 2.5 Flash to visually identify signature pages and extract: - **Party Name** (Entity bound by the contract) - **Signatory Name** (Human signing the document) @@ -18,12 +19,24 @@ https://github.com/user-attachments/assets/1b2ab8e4-0129-4319-aa13-05d31d714266 - **Agreement** (e.g., all pages for the SPA) - **Counterparty** (e.g., all pages for "Acme Corp" across all docs) - **Signatory** (e.g., all pages "Jane Smith" needs to sign) -- **Privacy-First**: Documents are processed in-memory. No file storage persistence. -- **Batch Processing**: Upload multiple transaction documents (PDF) at once. +- **Batch Processing**: Upload multiple transaction documents (PDF or DOCX) at once. +- **DOCX Conversion**: `.docx` uploads are converted to PDF through a server-side converter endpoint backed by Microsoft Graph (M365) to preserve layout fidelity. - **Integrated Preview**: View original PDFs and extracted signature pages instantly. -- **Automatic Instructions**: Generates a clear signing table/instruction sheet for clients. +- **Automatic Instructions**: Generates a per-signatory signing table (with party and capacity) to send to your client. - **Print-Ready Export**: Downloads a ZIP file containing perfectly sorted PDF packets for each party or agreement. +### Document Assembly +- **Executed Page Matching**: Upload signed/executed PDFs and let the AI identify which signature pages they correspond to. +- **Auto-Match**: Automatically matches executed pages to blank signature pages by document name, party, and signatory. +- **Assembly Progress Grid**: Visual checklist organized by signatory (columns) and document (rows) showing match status at a glance. Columns are drag-to-reorder. +- **Manual Override**: Click any cell to manually assign or reassign an executed page. +- **Assemble & Download**: Produces final assembled PDFs with blank signature pages swapped for their executed counterparts. + +### Configuration +- **Save/Load Config**: Save your entire session (extracted pages, edits, assembly matches) to a `.json` file. +- **Bundled PDFs**: Saved configs embed the original PDF files so you can restore a full session without re-uploading anything. +- **Privacy-First**: PDF extraction and matching happen in-browser. If DOCX upload is enabled, DOCX files are sent only to your configured conversion endpoint. + ## Tech Stack - **Frontend**: React 19, Tailwind CSS, Lucide Icons @@ -35,7 +48,7 @@ https://github.com/user-attachments/assets/1b2ab8e4-0129-4319-aa13-05d31d714266 1. **Clone the repository**: ```bash - git clone https://github.com/yourusername/signature-packet-ide.git + git clone https://github.com/jamietso/signature-packet-ide.git cd signature-packet-ide ``` @@ -45,26 +58,70 @@ https://github.com/user-attachments/assets/1b2ab8e4-0129-4319-aa13-05d31d714266 ``` 3. **Environment Configuration**: - Create a `.env` file in the root directory and add your Google Gemini API Key: + Create a `.env` file in the root directory and add your Google Gemini API Key. + For DOCX conversion via M365, add Microsoft Graph app credentials: ```env - API_KEY=your_google_gemini_api_key_here + GEMINI_API_KEY=your_google_gemini_api_key_here + M365_TENANT_ID=your_microsoft_tenant_id + M365_CLIENT_ID=your_app_registration_client_id + M365_CLIENT_SECRET=your_app_registration_client_secret + M365_USER_ID=user-object-id-or-upn-for-conversion-drive + # Optional temporary folder in that user's OneDrive + M365_UPLOAD_FOLDER=SignaturePacketIDE-Temp + # Optional (defaults to /api/docx-to-pdf; works with Vite proxy) + VITE_DOCX_CONVERTER_URL=/api/docx-to-pdf + # Optional backend port (default 8787) + DOCX_CONVERTER_PORT=8787 ``` -4. **Run the application**: +4. **Run the full stack (frontend + DOCX converter backend)**: ```bash - npm start + npm run dev:full ``` ## Usage Guide -1. **Upload**: Drag and drop your transaction documents (PDFs) into the sidebar. -2. **Review**: The AI will extract signature pages. Review the "Party", "Signatory", and "Capacity" fields in the card view. +### DOCX Conversion Endpoint Contract +- Method: `POST` +- URL: `VITE_DOCX_CONVERTER_URL` (defaults to `/api/docx-to-pdf`) +- Request: `multipart/form-data` with a `file` field containing `.docx` +- Response: `200` with `Content-Type: application/pdf` and raw PDF bytes +- Auth: this app sends `credentials: include`, so cookie/session-based auth is supported + +### Local backend +- Backend entrypoint: `backend/server.ts` +- Health check: `GET /api/health` +- Converter route: `POST /api/docx-to-pdf` +- Uses Microsoft Graph conversion (`.../content?format=pdf`) for high-fidelity Office-to-PDF rendering. + +### M365 permissions +- Register an app in Azure/Microsoft Entra and create a client secret. +- Add Microsoft Graph **Application** permissions: + - `Files.ReadWrite.All` (for upload + conversion + cleanup) +- Grant admin consent for your tenant. +- Set `M365_USER_ID` to a user/service account whose OneDrive will hold temporary uploads. + +### Extract Mode +1. **Upload**: Drag and drop your transaction documents (PDFs or DOCX files) into the sidebar, then click **Extract**. +2. **Review**: The AI will identify signature pages. Review the "Party", "Signatory", and "Capacity" fields for each page. 3. **Adjust**: - Use the **Grouping Toggles** (Agreement / Party / Signatory) to change how pages are sorted. - Edit the **Copies** counter if a party needs to sign multiple originals. -4. **Instructions**: Click "Instructions" to view and copy a signing table to send to your client. +4. **Instructions**: Click "Instructions" to view and copy a per-signatory signing table to send to your client. 5. **Download**: Click "Download ZIP" to get the organized PDF packets. +### Assembly Mode +1. Switch to the **Assembly** tab in the toolbar. +2. **Upload Signed Pages**: Drop executed/scanned PDFs or DOCX files into the "Executed Pages" section of the sidebar. +3. **Auto-Match**: Click **Auto-Match** to let the AI match executed pages to their corresponding blank signature pages. +4. **Review**: The Assembly Progress grid shows each document (rows) × signatory (columns). Green = matched, amber = pending. +5. **Manual Override**: Click any cell to manually assign or reassign a page. +6. **Assemble & Download**: Click **Assemble & Download** to generate final PDFs with executed pages inserted. + +### Save & Restore +- Click **Save Config** at any time to export your session (including all PDFs) to a `.json` file. +- Click **Load Config** to restore a previous session instantly — no re-uploading required. + ## Contributing Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. diff --git a/backend/geminiAnalyze.ts b/backend/geminiAnalyze.ts new file mode 100644 index 0000000..0293844 --- /dev/null +++ b/backend/geminiAnalyze.ts @@ -0,0 +1,450 @@ +/** + * Server-only Gemini calls. Uses GEMINI_API_KEY from the environment (e.g. Secret Manager on Cloud Run). + * + * **Important:** `@google/genai` reads `GOOGLE_GENAI_USE_VERTEXAI` from the environment. If it is `true`, + * the client targets Vertex (`aiplatform.googleapis.com`), which does **not** accept API keys → 401. + * We pass `vertexai: false` explicitly so API-key mode always uses the Gemini Developer API, even when + * Cloud Run still has Vertex env vars from a prior deploy. + * + * Verbose extraction logging (PII-heavy): set DEBUG_GEMINI=1. Default is quiet for production logs. + */ +import { GoogleGenAI, Type, Schema } from '@google/genai'; + +const debugGemini = (): boolean => process.env.DEBUG_GEMINI === '1'; + +export interface SignatureBlockExtraction { + isSignaturePage: boolean; + signatures: Array<{ + partyName: string; + signatoryName: string; + capacity: string; + }>; +} + +export interface ExecutedPageExtraction { + isExecuted: boolean; + documentName: string; + signatures: Array<{ + partyName: string; + signatoryName: string; + capacity: string; + }>; +} + +const SYSTEM_INSTRUCTION = ` +You are a specialized legal AI assistant for transaction lawyers. +Your task is to analyze an image of a document page and identify if it is a "Signature Page" (or "Execution Page"). + +### CRITICAL DEFINITIONS FOR EXTRACTION + +1. **PARTY**: The legal entity OR individual person who is a party to the contract. + - For COMPANIES: Found in headings like "EXECUTED by ABC HOLDINGS LIMITED" or "ABC CORP:". The company name is the party. + - For INDIVIDUALS: The label above the signature line (e.g. "KEY HOLDER:", "FOUNDER:", "GUARANTOR:", "INVESTOR:") is a ROLE, NOT the party name. The party is the INDIVIDUAL'S NAME printed below or beside the signature line. + - NEVER use a role label like "Key Holder", "Founder", "Guarantor", "Investor" as the party name when a person's name is present. + - If the only name present is a person's name (e.g. "John Smith"), use that as the party name. + +2. **SIGNATORY**: The human being physically signing the page. + - For companies: the named officer/director signing on behalf of the company (found under "Name:", "By:", or "Signed by:"). + - For individuals signing in their personal capacity: the signatory IS the same person as the party. Use their name for BOTH partyName and signatoryName. + - A company name (e.g. "Acme Corp") can NEVER be a signatory. + +3. **CAPACITY**: The role or authority of the signatory. + - For company signatories: "Director", "CEO", "Authorised Signatory", "General Partner", etc. + - For individuals signing personally: use the label from the block (e.g. "Key Holder", "Founder", "Guarantor") as the capacity, NOT as the party name. + +### COMMON INDIVIDUAL SIGNATURE BLOCK PATTERN (very important): +\`\`\` +KEY HOLDER: + +______________________________ +John Smith +\`\`\` +→ partyName: "John Smith", signatoryName: "John Smith", capacity: "Key Holder" + +### COMMON COMPANY SIGNATURE BLOCK PATTERN: +\`\`\` +ACME CORP: + +By: ______________________________ +Name: Jane Smith +Title: Director +\`\`\` +→ partyName: "Acme Corp", signatoryName: "Jane Smith", capacity: "Director" + +### MULTI-LEVEL ENTITY SIGNATURE BLOCKS (funds, LPs, trusts) +Many entities sign through a chain of intermediaries. The pattern looks like: + +\`\`\` +[ROLE LABEL] (if an entity): +Name of [Role]: [Top-Level Entity], L.P. +By: [Intermediate Entity], L.L.C., its general partner + By: ______________________________ + Name: [Individual Name] + Title: [Title] +\`\`\` + +Rules for multi-level entities: +- The PARTY is always the TOP-LEVEL named entity (the fund, LP, or trust — e.g. "[Fund] IX, L.P.") +- The SIGNATORY is always the INDIVIDUAL PERSON who physically signs (the innermost "Name:" line) +- The CAPACITY should describe the signing chain, e.g. "Member of [GP Entity], L.L.C., its General Partner" +- The role label before the block (e.g. "HOLDER", "INVESTOR") is NOT the party name — it goes in capacity if relevant +- Look for "Name of Holder:", "Name of Investor:", etc. as the source of the party name +- Intermediate entities (the "By: [Entity], its general partner" lines) are NOT the party — they are part of the signing authority chain + +Example: +\`\`\` +HOLDER (if an entity): +Name of Holder: Sequoia Capital Fund XV, L.P. +By: SC XV Management, L.L.C., its general partner + By: ______________________________ + Name: Jane Smith + Title: Managing Member +\`\`\` +→ partyName: "Sequoia Capital Fund XV, L.P.", signatoryName: "Jane Smith", capacity: "Managing Member of SC XV Management, L.L.C., its General Partner" + +Example with deeper nesting: +\`\`\` +INVESTOR: +Name of Investor: Acme Growth Partners III, L.P. +By: Acme Growth GP III, L.L.C., its general partner +By: Acme Capital Holdings, Inc., its managing member + By: ______________________________ + Name: John Doe + Title: President +\`\`\` +→ partyName: "Acme Growth Partners III, L.P.", signatoryName: "John Doe", capacity: "President of Acme Capital Holdings, Inc." + +### RULES +1. If this is a signature page, set isSignaturePage to true. +2. Extract ALL signature blocks found on the page. +3. For each block, strictly separate the **Party Name** (Entity or Individual), **Signatory Name** (Human), and **Capacity** (Title/Role). +4. When you see nested "By:" lines, always trace to the TOP-LEVEL entity for partyName and the BOTTOM-LEVEL individual for signatoryName. +5. Look for "Name of [Role]:" patterns (e.g. "Name of Holder:", "Name of Investor:") as a reliable indicator of the top-level party name. +6. If a field is blank (e.g. "Name: _______"), leave the extracted value as empty string. +7. If it is NOT a signature page (e.g. text clauses only), set isSignaturePage to false. +`; + +const RESPONSE_SCHEMA: Schema = { + type: Type.OBJECT, + properties: { + isSignaturePage: { + type: Type.BOOLEAN, + description: 'True if the page contains a signature block for execution.', + }, + signatures: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + partyName: { + type: Type.STRING, + description: + "The legal entity or individual who is a party to the contract. For companies: the company name. For multi-level entities (LPs, funds): the TOP-LEVEL entity name from 'Name of Holder/Investor:' lines, NOT intermediate entities. For individuals: the person's actual name (e.g. 'John Smith'), NOT their role label (e.g. NOT 'Key Holder' or 'Founder').", + }, + signatoryName: { + type: Type.STRING, + description: + "The human name of the person physically signing. For multi-level entity chains, this is the individual at the bottom of the 'By:' chain. For individuals signing personally, this is the same as partyName. Never use a company name here.", + }, + capacity: { + type: Type.STRING, + description: + "The title or role of the signatory. For multi-level entity chains, include the signing authority context (e.g. 'Managing Member of [GP], its General Partner'). For company signatories: 'Director', 'CEO', etc. For individuals signing personally: use their block label, e.g. 'Key Holder', 'Founder', 'Guarantor'.", + }, + }, + }, + }, + }, + required: ['isSignaturePage', 'signatures'], +}; + +const EXECUTED_PAGE_SYSTEM_INSTRUCTION = ` +You are a specialized legal AI assistant for transaction lawyers. +Your task is to analyze an image of an EXECUTED (signed) signature page from a legal document. + +### YOUR TASK +1. Determine if this page contains an actual signature (handwritten ink, electronic signature, DocuSign stamp, or similar). +2. Extract the following information: + +**documentName**: The name of the agreement/contract this signature page belongs to. +- Look for text like "Signature Page to [Agreement Name]" or "[Signature Page]" headers/footers. +- Look for running headers, footers, or watermarks that reference the agreement name. +- Common patterns: "Signature Page to Amended and Restated Investors' Rights Agreement" +- If you find it, extract ONLY the agreement name (e.g. "Amended and Restated Investors' Rights Agreement"), not the "Signature Page to" prefix. +- If you cannot determine the document name, return an empty string. + +**partyName**: The legal entity or individual who signed. +- Apply the same rules as for blank pages: company name for companies, individual's actual name for individuals. +- NEVER use role labels ("Key Holder", "Founder") as the party name. +- For multi-level entities (LPs, funds, trusts): use the TOP-LEVEL entity name from "Name of Holder/Investor:" lines, NOT intermediate entities. + +**signatoryName**: The human being who physically signed. +- For individuals signing personally, this is the same as partyName. +- For multi-level entity chains, this is the individual at the bottom of the "By:" chain. + +**capacity**: The role/title of the signatory. +- For individuals signing personally, use their role label (e.g. "Key Holder", "Founder"). +- For multi-level entity chains, include the signing authority context (e.g. "Managing Member of [GP Entity], its General Partner"). + +**isExecuted**: Whether this page appears to actually be signed/executed. +- true if there is a visible signature (ink, electronic, stamp, DocuSign completion marker). +- false if the signature line is blank/unsigned. + +### MULTI-LEVEL ENTITY SIGNATURE BLOCKS (funds, LPs, trusts) +Many entities sign through a chain of intermediaries: + +\`\`\` +[ROLE LABEL] (if an entity): +Name of [Role]: [Top-Level Entity], L.P. +By: [Intermediate Entity], L.L.C., its general partner + By: ______________________________ + Name: [Individual Name] + Title: [Title] +\`\`\` + +- The PARTY is the TOP-LEVEL named entity (the fund/LP/trust) +- The SIGNATORY is the INDIVIDUAL who physically signed (innermost "Name:" line) +- The CAPACITY describes the signing chain +- "Name of Holder:", "Name of Investor:" etc. indicate the top-level party name +- Intermediate "By: [Entity], its general partner" lines are NOT the party + +Example: +\`\`\` +HOLDER (if an entity): +Name of Holder: Sequoia Capital Fund XV, L.P. +By: SC XV Management, L.L.C., its general partner + By: [signature] + Name: Jane Smith + Title: Managing Member +\`\`\` +→ partyName: "Sequoia Capital Fund XV, L.P.", signatoryName: "Jane Smith", capacity: "Managing Member of SC XV Management, L.L.C., its General Partner" + +### RULES +1. The documentName is CRITICAL for matching this executed page to its agreement. Look carefully for it. +2. If this is a scanned page, OCR the text to extract all information. +3. If multiple signature blocks appear on one page, extract all of them. +4. Apply the same Party vs Signatory vs Capacity distinction rules as for blank signature pages. +5. When you see nested "By:" lines, always trace to the TOP-LEVEL entity for partyName and the BOTTOM-LEVEL individual for signatoryName. +6. Look for "Name of [Role]:" patterns as a reliable indicator of the top-level party name. +`; + +const EXECUTED_PAGE_RESPONSE_SCHEMA: Schema = { + type: Type.OBJECT, + properties: { + isExecuted: { + type: Type.BOOLEAN, + description: 'True if the page appears to contain an actual signature (not blank/unsigned).', + }, + documentName: { + type: Type.STRING, + description: + "The name of the agreement this signature page belongs to, extracted from page text (e.g. 'Amended and Restated Investors Rights Agreement'). Empty string if not determinable.", + }, + signatures: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + partyName: { + type: Type.STRING, + description: + "The legal entity or individual party. For multi-level entities (LPs, funds): the TOP-LEVEL entity name from 'Name of Holder/Investor:' lines, NOT intermediate entities. For individuals: their actual name, NOT their role label.", + }, + signatoryName: { + type: Type.STRING, + description: + "The human name of the person who signed. For multi-level entity chains, this is the individual at the bottom of the 'By:' chain. For individuals signing personally, same as partyName.", + }, + capacity: { + type: Type.STRING, + description: + "The title or role of the signatory. For multi-level entity chains, include the signing authority context (e.g. 'Managing Member of [GP], its General Partner'). For individuals: use their block label (e.g. 'Key Holder').", + }, + }, + }, + }, + }, + required: ['isExecuted', 'documentName', 'signatures'], +}; + +const needsRetry = (signatures: Array<{ partyName: string; signatoryName: string; capacity: string }>): boolean => + signatures.some((s) => !s.partyName || !s.signatoryName); + +export function getGeminiApiKey(): string | undefined { + const k = process.env.GEMINI_API_KEY?.trim() || process.env.API_KEY?.trim(); + return k || undefined; +} + +/** Gemini Developer API (API key). `vertexai: false` overrides GOOGLE_GENAI_USE_VERTEXAI in the environment. */ +function createGeminiApiKeyClient(apiKey: string): GoogleGenAI { + return new GoogleGenAI({ apiKey, vertexai: false }); +} + +function stripDataUrlPrefix(dataUrlOrRaw: string): string { + if (dataUrlOrRaw.startsWith('data:') && dataUrlOrRaw.includes(',')) { + return dataUrlOrRaw.split(',')[1] ?? dataUrlOrRaw; + } + return dataUrlOrRaw; +} + +export async function analyzeSignaturePageWithGemini( + base64Image: string, + modelName: string = 'gemini-2.5-flash', +): Promise { + const apiKey = getGeminiApiKey(); + if (!apiKey) { + console.error('[gemini] Missing GEMINI_API_KEY'); + return { isSignaturePage: false, signatures: [] }; + } + + const cleanBase64 = stripDataUrlPrefix(base64Image); + + const callAI = async (promptText: string): Promise => { + const ai = createGeminiApiKeyClient(apiKey); + const response = await ai.models.generateContent({ + model: modelName, + contents: { + parts: [ + { inlineData: { mimeType: 'image/jpeg', data: cleanBase64 } }, + { text: promptText }, + ], + }, + config: { + systemInstruction: SYSTEM_INSTRUCTION, + responseMimeType: 'application/json', + responseSchema: RESPONSE_SCHEMA, + }, + }); + const text = response.text; + if (!text) throw new Error('No response from AI'); + return JSON.parse(text) as SignatureBlockExtraction; + }; + + try { + const result = await callAI( + 'Analyze this page. Is it a signature page? Extract the Party, Signatory, and Capacity according to the definitions.', + ); + + if (result.isSignaturePage && debugGemini()) { + console.log('[analyzeSignaturePage] Raw extraction:', JSON.stringify(result.signatures, null, 2)); + } + + if (result.isSignaturePage && needsRetry(result.signatures)) { + const problematic = result.signatures.filter((s) => !s.partyName || !s.signatoryName); + if (debugGemini()) { + console.warn( + '[analyzeSignaturePage] Missing party/signatory — retrying. Problematic blocks:', + problematic, + ); + } else { + console.warn( + `[analyzeSignaturePage] Missing party/signatory — retrying (${problematic.length} incomplete block(s))`, + ); + } + try { + const retry = await callAI( + 'IMPORTANT: One or more signature blocks on this page returned an empty partyName or signatoryName. ' + + 'Look very carefully at the FULL signature block structure. ' + + "If you see a pattern like 'Name of Holder: [Fund Name]' or 'Name of Investor: [Fund Name]', " + + 'that IS the partyName — use it even if it is a long entity name with L.P., L.L.C., etc. ' + + "Trace all nested 'By:' lines to find the individual's name at the bottom — that is the signatoryName. " + + 'Re-extract ALL signature blocks, ensuring partyName and signatoryName are never empty.', + ); + if (debugGemini()) { + console.log('[analyzeSignaturePage] Retry result:', JSON.stringify(retry.signatures, null, 2)); + } + if (retry.isSignaturePage && retry.signatures.length > 0) { + return retry; + } + } catch (retryErr) { + console.error('analyzeSignaturePage retry failed:', retryErr); + } + } + + return result; + } catch (error) { + console.error('Gemini Analysis Error:', error); + return { isSignaturePage: false, signatures: [] }; + } +} + +export async function analyzeExecutedPageWithGemini( + base64Image: string, + modelName: string = 'gemini-2.5-flash', +): Promise { + const apiKey = getGeminiApiKey(); + if (!apiKey) { + console.error('[gemini] Missing GEMINI_API_KEY'); + return { isExecuted: false, documentName: '', signatures: [] }; + } + + const cleanBase64 = stripDataUrlPrefix(base64Image); + + const callAI = async (promptText: string): Promise => { + const ai = createGeminiApiKeyClient(apiKey); + const response = await ai.models.generateContent({ + model: modelName, + contents: { + parts: [ + { inlineData: { mimeType: 'image/jpeg', data: cleanBase64 } }, + { text: promptText }, + ], + }, + config: { + systemInstruction: EXECUTED_PAGE_SYSTEM_INSTRUCTION, + responseMimeType: 'application/json', + responseSchema: EXECUTED_PAGE_RESPONSE_SCHEMA, + }, + }); + const text = response.text; + if (!text) throw new Error('No response from AI'); + return JSON.parse(text) as ExecutedPageExtraction; + }; + + try { + const result = await callAI( + 'Analyze this executed signature page. Is it actually signed? Extract the document name, party, signatory, and capacity.', + ); + + if (result.isExecuted && debugGemini()) { + console.log( + '[analyzeExecutedPage] Raw extraction:', + JSON.stringify({ documentName: result.documentName, signatures: result.signatures }, null, 2), + ); + } + + if (result.isExecuted && needsRetry(result.signatures)) { + const problematic = result.signatures.filter((s) => !s.partyName || !s.signatoryName); + if (debugGemini()) { + console.warn('[analyzeExecutedPage] Missing party/signatory — retrying. Problematic blocks:', problematic); + } else { + console.warn( + `[analyzeExecutedPage] Missing party/signatory — retrying (${problematic.length} incomplete block(s))`, + ); + } + try { + const retry = await callAI( + 'IMPORTANT: One or more signature blocks returned an empty partyName or signatoryName. ' + + 'Look very carefully at the full signature block. ' + + "If you see 'Name of Holder:', 'Name of Investor:', or similar, that fund/entity name IS the partyName. " + + "Trace all nested 'By:' lines to the individual at the bottom — that name is the signatoryName. " + + "Also look for the agreement name in headers or footers (e.g. 'Signature Page to [Agreement Name]'). " + + 'Re-extract everything, ensuring partyName and signatoryName are never empty.', + ); + if (debugGemini()) { + console.log('[analyzeExecutedPage] Retry result:', JSON.stringify(retry.signatures, null, 2)); + } + if (retry.isExecuted && retry.signatures.length > 0) { + return retry; + } + } catch (retryErr) { + console.error('analyzeExecutedPage retry failed:', retryErr); + } + } + + return result; + } catch (error) { + console.error('Gemini Executed Page Analysis Error:', error); + return { isExecuted: false, documentName: '', signatures: [] }; + } +} diff --git a/backend/server.ts b/backend/server.ts new file mode 100644 index 0000000..9357e36 --- /dev/null +++ b/backend/server.ts @@ -0,0 +1,249 @@ +import 'dotenv/config'; +import express from 'express'; +import multer from 'multer'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + analyzeExecutedPageWithGemini, + analyzeSignaturePageWithGemini, + getGeminiApiKey, +} from './geminiAnalyze.js'; + +const app = express(); + +/** Large JSON bodies for base64 page images → Gemini */ +const geminiJsonParser = express.json({ limit: '35mb' }); +// Cloud Run sets PORT; local dev may use DOCX_CONVERTER_PORT +const port = Number(process.env.PORT || process.env.DOCX_CONVERTER_PORT || 8787); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** Vite `dist/`: next to `backend/` when using `tsx`; two levels up when running compiled `backend/dist/server.js`. */ +const distPath = (() => { + const nextToBackend = path.resolve(__dirname, '../dist'); + if (fs.existsSync(path.join(nextToBackend, 'index.html'))) { + return nextToBackend; + } + return path.resolve(__dirname, '../../dist'); +})(); +const graphBaseUrl = process.env.M365_GRAPH_BASE_URL || 'https://graph.microsoft.com/v1.0'; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 30 * 1024 * 1024 }, +}); + +const requiredEnv = ['M365_TENANT_ID', 'M365_CLIENT_ID', 'M365_CLIENT_SECRET', 'M365_USER_ID'] as const; + +const requireConfig = () => { + const missing = requiredEnv.filter((key) => !process.env[key]?.trim()); + if (missing.length > 0) { + throw new Error(`Missing M365 converter config: ${missing.join(', ')}`); + } + return { + tenantId: process.env.M365_TENANT_ID!, + clientId: process.env.M365_CLIENT_ID!, + clientSecret: process.env.M365_CLIENT_SECRET!, + userId: process.env.M365_USER_ID!, + folder: process.env.M365_UPLOAD_FOLDER || 'SignaturePacketIDE-Temp', + }; +}; + +const getGraphToken = async (): Promise => { + const { tenantId, clientId, clientSecret } = requireConfig(); + const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + scope: 'https://graph.microsoft.com/.default', + }); + + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Token request failed (${response.status}): ${detail}`); + } + + const json = await response.json() as { access_token?: string }; + if (!json.access_token) { + throw new Error('Token response missing access_token'); + } + + return json.access_token; +}; + +const buildUserDrivePathUrl = (userId: string, folder: string, fileName: string): string => { + const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_'); + const fullPath = `${folder}/${randomUUID()}-${safeName}`; + return `${graphBaseUrl}/users/${encodeURIComponent(userId)}/drive/root:/${fullPath}:/content`; +}; + +app.get('/api/health', (_req, res) => { + res.status(200).json({ ok: true }); +}); + +app.get('/api/gemini/health', (_req, res) => { + res.status(200).json({ ok: true, geminiConfigured: Boolean(getGeminiApiKey()) }); +}); + +app.post('/api/gemini/analyze-signature-page', geminiJsonParser, async (req, res) => { + try { + if (!getGeminiApiKey()) { + res.status(503).json({ error: 'GEMINI_API_KEY is not configured on the server' }); + return; + } + const base64Image = req.body?.base64Image; + if (typeof base64Image !== 'string' || !base64Image.trim()) { + res.status(400).json({ error: 'Missing or invalid base64Image' }); + return; + } + const modelName = + typeof req.body?.modelName === 'string' && req.body.modelName.trim() + ? req.body.modelName.trim() + : 'gemini-2.5-flash'; + const result = await analyzeSignaturePageWithGemini(base64Image, modelName); + res.status(200).json(result); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown Gemini error'; + console.error('Gemini signature-page route failed:', message); + res.status(500).json({ error: 'Gemini analysis failed', detail: message }); + } +}); + +app.post('/api/gemini/analyze-executed-page', geminiJsonParser, async (req, res) => { + try { + if (!getGeminiApiKey()) { + res.status(503).json({ error: 'GEMINI_API_KEY is not configured on the server' }); + return; + } + const base64Image = req.body?.base64Image; + if (typeof base64Image !== 'string' || !base64Image.trim()) { + res.status(400).json({ error: 'Missing or invalid base64Image' }); + return; + } + const modelName = + typeof req.body?.modelName === 'string' && req.body.modelName.trim() + ? req.body.modelName.trim() + : 'gemini-2.5-flash'; + const result = await analyzeExecutedPageWithGemini(base64Image, modelName); + res.status(200).json(result); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown Gemini error'; + console.error('Gemini executed-page route failed:', message); + res.status(500).json({ error: 'Gemini analysis failed', detail: message }); + } +}); + +app.post('/api/docx-to-pdf', upload.single('file'), async (req, res) => { + try { + const uploaded = req.file; + if (!uploaded) { + res.status(400).json({ error: 'No file uploaded. Use multipart/form-data with field "file".' }); + return; + } + + const looksLikeDocx = + uploaded.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || + uploaded.originalname.toLowerCase().endsWith('.docx'); + + if (!looksLikeDocx) { + res.status(400).json({ error: 'Only .docx files are supported for this endpoint.' }); + return; + } + + const token = await getGraphToken(); + const { userId, folder } = requireConfig(); + const uploadUrl = buildUserDrivePathUrl(userId, folder, uploaded.originalname); + + // 1) Upload DOCX to service account OneDrive + const uploadResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': uploaded.mimetype || 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + body: uploaded.buffer, + }); + + if (!uploadResponse.ok) { + const detail = await uploadResponse.text(); + throw new Error(`Graph upload failed (${uploadResponse.status}): ${detail}`); + } + + const uploadedItem = await uploadResponse.json() as { id?: string }; + if (!uploadedItem.id) { + throw new Error('Graph upload response missing item id'); + } + + // 2) Request converted content as PDF + const convertUrl = + `${graphBaseUrl}/users/${encodeURIComponent(userId)}/drive/items/${encodeURIComponent(uploadedItem.id)}/content?format=pdf`; + const pdfResponse = await fetch(convertUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!pdfResponse.ok) { + const detail = await pdfResponse.text(); + throw new Error(`Graph conversion failed (${pdfResponse.status}): ${detail}`); + } + + const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer()); + + // 3) Best-effort cleanup of temp file + const deleteUrl = `${graphBaseUrl}/users/${encodeURIComponent(userId)}/drive/items/${encodeURIComponent(uploadedItem.id)}`; + void fetch(deleteUrl, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }).catch((cleanupErr) => { + console.warn('Cleanup warning:', cleanupErr); + }); + + const safeName = uploaded.originalname.replace(/\.docx$/i, '.pdf'); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${safeName}"`); + res.status(200).send(pdfBuffer); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown conversion error'; + console.error('DOCX conversion failed:', message); + res.status(500).json({ error: 'DOCX conversion failed', detail: message }); + } +}); + +// --- Production: serve Vite static app + SPA fallback (same origin as /api) --- +if (fs.existsSync(distPath)) { + app.use(express.static(distPath)); + app.get(/.*/, (req, res, next) => { + if (req.path.startsWith('/api')) { + next(); + return; + } + const indexFile = path.join(distPath, 'index.html'); + if (!fs.existsSync(indexFile)) { + next(); + return; + } + res.sendFile(indexFile); + }); +} + +// Unknown API paths → JSON 404 (avoid sending index.html for /api/*) +app.use('/api', (_req, res) => { + res.status(404).json({ error: 'Not found' }); +}); + +app.listen(port, '0.0.0.0', () => { + const mode = fs.existsSync(distPath) ? 'app + API' : 'API only'; + console.log(`Listening on 0.0.0.0:${port} (${mode})`); +}); diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..afc268a --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "noEmit": false + }, + "include": ["./server.ts", "./geminiAnalyze.ts"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..f3ac492 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/components/CompletionChecklist.tsx b/components/CompletionChecklist.tsx new file mode 100644 index 0000000..51945fb --- /dev/null +++ b/components/CompletionChecklist.tsx @@ -0,0 +1,385 @@ +import React, { useEffect, useState, useRef, useMemo } from 'react'; +import { ExtractedSignaturePage, AssemblyMatch, ExecutedSignaturePage } from '../types'; +import { CheckCircle2, AlertTriangle, Minus, Printer, ChevronLeft, ChevronRight } from 'lucide-react'; + +interface CompletionChecklistProps { + blankPages: ExtractedSignaturePage[]; + matches: AssemblyMatch[]; + executedPages: ExecutedSignaturePage[]; + onCellClick: (blankPageId: string, currentMatch: AssemblyMatch | null) => void; +} + +interface CellData { + blankPage: ExtractedSignaturePage; + match: AssemblyMatch | null; +} + +const CompletionChecklist: React.FC = ({ + blankPages, + matches, + executedPages, + onCellClick, +}) => { + // Build unique document names (rows) + const docNameSet = new Set(); + blankPages.forEach(p => docNameSet.add(p.documentName)); + const documentNames = Array.from(docNameSet).sort(); + + // Unique signatory columns — stable key avoids resetting column order on unrelated parent re-renders. + const signatorySetKey = useMemo( + () => + Array.from(new Set(blankPages.map((p) => p.signatoryName || '(Unnamed)'))) + .sort() + .join('\0'), + [blankPages], + ); + const derivedSignatories = useMemo( + () => Array.from(new Set(blankPages.map((p) => p.signatoryName || '(Unnamed)'))).sort(), + [signatorySetKey, blankPages], + ); + const [signatoryOrder, setSignatoryOrder] = useState(() => derivedSignatories); + const lastSignatoryKeyRef = useRef(signatorySetKey); + useEffect(() => { + if (lastSignatoryKeyRef.current === signatorySetKey) return; + lastSignatoryKeyRef.current = signatorySetKey; + setSignatoryOrder(derivedSignatories); + }, [signatorySetKey, derivedSignatories]); + const signatoryNames = signatoryOrder; + + // Party names per signatory for header sub-labels + const partiesBySignatory = (sigName: string): string[] => + Array.from(new Set(blankPages + .filter(p => (p.signatoryName || '(Unnamed)') === sigName) + .map(p => p.partyName) + )).sort(); + + // Drag state + const dragIndex = useRef(null); + const scrollContainerRef = useRef(null); + const tableSizerRef = useRef(null); + const [maxScrollLeft, setMaxScrollLeft] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + const TABLE_DOC_COL_WIDTH = 260; + const TABLE_SIG_COL_WIDTH = 220; + const tablePixelWidth = TABLE_DOC_COL_WIDTH + (signatoryNames.length * TABLE_SIG_COL_WIDTH); + + // Build match lookup: blankPageId → AssemblyMatch + const matchByBlankId = new Map(); + for (const match of matches) { + matchByBlankId.set(match.blankPageId, match); + } + + // For a (document, signatory) cell, return ALL blank pages for that combination (across all parties) + const getCells = (docName: string, signatoryName: string): CellData[] => { + const blanks = blankPages.filter( + p => p.documentName === docName && (p.signatoryName || '(Unnamed)') === signatoryName + ); + return blanks.map(blank => ({ blankPage: blank, match: matchByBlankId.get(blank.id) || null })); + }; + + // Summary counts + const totalRequired = blankPages.length; + const totalMatched = matches.length; + const progressPct = totalRequired > 0 ? Math.round((totalMatched / totalRequired) * 100) : 0; + + const handlePrint = () => { + const printWindow = window.open('', '_blank'); + if (!printWindow) return; + + const now = new Date().toLocaleString(); + + // Build table rows for print + const headerCells = signatoryNames.map(s => { + const parties = partiesBySignatory(s).map(p => `${p}`).join(''); + return `${s}${parties ? `
${parties}` : ''}`; + }).join(''); + + const bodyRows = documentNames.map(docName => { + const cells = signatoryNames.map(sigName => { + const cellData = getCells(docName, sigName); + if (cellData.length === 0) return `—`; + const allMatched = cellData.every(c => !!c.match); + const anyMatched = cellData.some(c => !!c.match); + if (allMatched) { + const label = cellData[0].match!.status === 'auto-matched' ? 'Auto' : 'Manual'; + return `✓ ${label}`; + } + if (anyMatched) return `⚠ Partial`; + return `⚠ Pending`; + }).join(''); + return `${docName}${cells}`; + }).join(''); + + printWindow.document.write(` + + + + Signature Packet Checklist + + + +

Signature Packet Checklist

+
Generated ${now}
+
${totalMatched}/${totalRequired} signature pages matched
+ + ${headerCells} + ${bodyRows} +
Document
+ + + `); + printWindow.document.close(); + printWindow.focus(); + setTimeout(() => printWindow.print(), 300); + }; + + const handleChecklistWheel: React.WheelEventHandler = (event) => { + const container = scrollContainerRef.current; + if (!container) return; + + // Let trackpads handle natural horizontal scroll, but map vertical wheel + // to horizontal movement when this grid can scroll sideways. + const canScrollHorizontally = container.scrollWidth > container.clientWidth; + if (!canScrollHorizontally) return; + + const dominantVertical = Math.abs(event.deltaY) > Math.abs(event.deltaX); + if (dominantVertical && event.deltaY !== 0) { + container.scrollLeft += event.deltaY; + event.preventDefault(); + } + }; + + useEffect(() => { + const localViewport = scrollContainerRef.current; + const sizer = tableSizerRef.current; + if (!localViewport || !sizer) return; + + const updateScrollMetrics = () => { + const width = Math.max(tablePixelWidth, sizer.scrollWidth); + const max = Math.max(0, width - localViewport.clientWidth); + setMaxScrollLeft(max); + setScrollLeft(Math.min(localViewport.scrollLeft, max)); + }; + + updateScrollMetrics(); + const observer = new ResizeObserver(updateScrollMetrics); + observer.observe(localViewport); + observer.observe(sizer); + const onScroll = () => { + setScrollLeft(localViewport.scrollLeft); + }; + localViewport.addEventListener('scroll', onScroll, { passive: true }); + return () => { + observer.disconnect(); + localViewport.removeEventListener('scroll', onScroll); + }; + }, [blankPages.length, matches.length, signatoryNames.length, documentNames.length, tablePixelWidth]); + + const handleSliderChange: React.ChangeEventHandler = (event) => { + const next = Number(event.currentTarget.value); + const host = scrollContainerRef.current; + if (host) host.scrollLeft = next; + setScrollLeft(next); + }; + + const scrollByAmount = (delta: number) => { + const host = scrollContainerRef.current; + if (!host) return; + host.scrollLeft += delta; + setScrollLeft(host.scrollLeft); + }; + + return ( +
+ {/* Summary bar */} +
+
+
+ + Assembly Progress + +
+ 0 ? 'text-green-600' : 'text-slate-600'}`}> + {totalMatched}/{totalRequired} matched + + +
+
+
+
0 + ? 'bg-green-500' + : 'bg-blue-500' + }`} + style={{ width: `${progressPct}%` }} + /> +
+
+
+ + {/* Grid */} +
+
+
+ + +
+ +
+
+ +
+
+ + + + + {signatoryNames.map((sigName, idx) => ( + + ))} + + + + {documentNames.map((docName, rowIdx) => ( + + + {signatoryNames.map(sigName => { + const cellData = getCells(docName, sigName); + + if (cellData.length === 0) { + return ( + + ); + } + + return ( + + ); + })} + + ))} + +
+ Document + { dragIndex.current = idx; }} + onDragOver={e => e.preventDefault()} + onDrop={() => { + const from = dragIndex.current; + if (from === null || from === idx) return; + const next = [...signatoryNames]; + next.splice(idx, 0, next.splice(from, 1)[0]); + setSignatoryOrder(next); + dragIndex.current = null; + }} + className="text-center px-3 py-3 font-medium text-slate-600 min-w-[220px] w-[220px] max-w-[220px] cursor-grab select-none" + > +
{sigName}
+
+ {partiesBySignatory(sigName).map(party => ( + {party} + ))} +
+
+ + {docName} + + +
+ +
+
+
+ {cellData.map(cell => { + const isMatched = !!cell.match; + return ( + + ); + })} +
+
+
+
+
+ ); +}; + +export default CompletionChecklist; diff --git a/components/ExecutedPageCard.tsx b/components/ExecutedPageCard.tsx new file mode 100644 index 0000000..c6edc9e --- /dev/null +++ b/components/ExecutedPageCard.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { ExecutedSignaturePage, AssemblyMatch } from '../types'; +import { CheckCircle2, AlertCircle, FileText, Users, UserPen, Briefcase, Eye } from 'lucide-react'; + +interface ExecutedPageCardProps { + page: ExecutedSignaturePage; + match: AssemblyMatch | null; + onUnmatch?: (executedPageId: string) => void; + onPreview?: (page: ExecutedSignaturePage) => void; + onMatchNow?: (executedPageId: string) => void; +} + +const ExecutedPageCard: React.FC = ({ page, match, onUnmatch, onPreview, onMatchNow }) => { + const isMatched = !!match; + + return ( +
+ {/* Thumbnail */} +
onPreview(page) : undefined} + > + {`Executed + {onPreview && ( +
+
+ View PDF +
+
+ )} +
+ Pg {page.pageNumber} +
+ {/* Status badge */} +
+ {isMatched ? 'Matched' : page.isConfirmedExecuted ? 'Unmatched' : 'Not signed'} +
+
+ + {/* Content */} +
+ {/* Source file */} +
+ From: {page.sourceFileName} +
+ + {/* Extracted metadata */} +
+ {page.extractedDocumentName && ( +
+ + + {page.extractedDocumentName} + +
+ )} + {page.extractedPartyName && ( +
+ + {page.extractedPartyName} +
+ )} + {page.extractedSignatoryName && ( +
+ + {page.extractedSignatoryName} +
+ )} + {page.extractedCapacity && ( +
+ + {page.extractedCapacity} +
+ )} +
+ + {/* Match info */} + {isMatched && match && ( +
+
+ + + Matched to {match.documentName} — {match.partyName} + +
+ {onUnmatch && ( + + )} +
+ )} + + {!isMatched && page.isConfirmedExecuted && ( +
+
+ + Awaiting match +
+ {onMatchNow && ( + + )} +
+ )} + + {!page.isConfirmedExecuted && ( +
+ + This page does not appear to be signed +
+ )} +
+
+ ); +}; + +export default ExecutedPageCard; diff --git a/components/InstructionsModal.tsx b/components/InstructionsModal.tsx index 856bd96..066c7d2 100644 --- a/components/InstructionsModal.tsx +++ b/components/InstructionsModal.tsx @@ -13,25 +13,24 @@ const InstructionsModal: React.FC = ({ isOpen, onClose, if (!isOpen) return null; - // Group by Party - const parties: string[] = (Array.from(new Set(pages.map(p => p.partyName))) as string[]).sort(); + // Group by Signatory + const signatories: string[] = (Array.from(new Set(pages.map(p => p.signatoryName || '(No Signatory)'))) as string[]).sort(); const groupedPages: Record = {}; - - parties.forEach(party => { - groupedPages[party] = pages.filter(p => p.partyName === party && p.copies > 0); + + signatories.forEach(signatory => { + groupedPages[signatory] = pages.filter(p => (p.signatoryName || '(No Signatory)') === signatory && p.copies > 0); }); const handleCopy = () => { let text = `SIGNING INSTRUCTIONS\nGenerated by Signature Packet IDE\n\n`; - - Object.entries(groupedPages).forEach(([party, partyPages]) => { - if (partyPages.length === 0) return; - text += `${party.toUpperCase()}\n`; - text += '-'.repeat(party.length) + '\n'; - partyPages.forEach(p => { + + Object.entries(groupedPages).forEach(([signatory, signatoryPages]) => { + if (signatoryPages.length === 0) return; + text += `${signatory.toUpperCase()}\n`; + text += '-'.repeat(signatory.length) + '\n'; + signatoryPages.forEach(p => { text += `• ${p.documentName}\n`; - const sigText = p.signatoryName ? ` by ${p.signatoryName}` : ''; - text += ` Sign as: ${p.capacity}${sigText} (${p.copies} ${p.copies === 1 ? 'copy' : 'copies'})\n`; + text += ` Sign as: ${p.capacity} (${p.copies} ${p.copies === 1 ? 'copy' : 'copies'})\n`; }); text += '\n'; }); @@ -68,30 +67,30 @@ const InstructionsModal: React.FC = ({ isOpen, onClose, {/* Content */}
- {parties.map(party => { - const partyPages = groupedPages[party]; - if (partyPages.length === 0) return null; + {signatories.map(signatory => { + const signatoryPages = groupedPages[signatory]; + if (signatoryPages.length === 0) return null; return ( -
+
- {party} + {signatory}
+ - - {partyPages.map(p => ( + {signatoryPages.map(p => ( + - ))} diff --git a/components/MatchPickerModal.tsx b/components/MatchPickerModal.tsx new file mode 100644 index 0000000..5b73fe2 --- /dev/null +++ b/components/MatchPickerModal.tsx @@ -0,0 +1,295 @@ +import React, { useEffect, useState } from 'react'; +import { X, CheckCircle2, FileText, Users, UserPen, Briefcase, ArrowRight, Unlink, Eye } from 'lucide-react'; +import { ExtractedSignaturePage, ExecutedSignaturePage, AssemblyMatch } from '../types'; + +interface MatchPickerModalProps { + isOpen: boolean; + onClose: () => void; + blankPage: ExtractedSignaturePage | null; + currentMatch: AssemblyMatch | null; + initialExecutedPageId?: string | null; + executedPages: ExecutedSignaturePage[]; + allMatches: AssemblyMatch[]; + onConfirmMatch: (blankPageId: string, executedPageId: string) => void; + onUnmatch: (blankPageId: string) => void; + onPreviewBlank: (page: ExtractedSignaturePage) => void; + onPreviewExecuted: (page: ExecutedSignaturePage) => void; +} + +const MatchPickerModal: React.FC = ({ + isOpen, + onClose, + blankPage, + currentMatch, + initialExecutedPageId, + executedPages, + allMatches, + onConfirmMatch, + onUnmatch, + onPreviewBlank, + onPreviewExecuted, +}) => { + const [selectedExecutedId, setSelectedExecutedId] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setSelectedExecutedId(currentMatch?.executedPageId ?? initialExecutedPageId ?? null); + }, [isOpen, currentMatch?.executedPageId, initialExecutedPageId]); + + if (!isOpen || !blankPage) return null; + + // Determine which executed pages are available (not matched to other blanks) + const matchedExecutedIds = new Set( + allMatches + .filter(m => m.blankPageId !== blankPage.id) // exclude THIS blank's match + .map(m => m.executedPageId) + ); + + const availableExecuted = executedPages.filter( + ep => ep.isConfirmedExecuted && !matchedExecutedIds.has(ep.id) + ); + + // Also show the currently matched executed page at the top if it exists + const currentMatchedPage = currentMatch + ? executedPages.find(ep => ep.id === currentMatch.executedPageId) + : null; + + const handleConfirm = () => { + if (selectedExecutedId && blankPage) { + onConfirmMatch(blankPage.id, selectedExecutedId); + setSelectedExecutedId(null); + onClose(); + } + }; + + const handleUnmatch = () => { + if (blankPage) { + onUnmatch(blankPage.id); + setSelectedExecutedId(null); + onClose(); + } + }; + + return ( +
+
+ {/* Header */} +
+

+ {currentMatch ? 'Reassign Match' : 'Match Executed Page'} +

+ +
+ + {/* Content */} +
+ {/* Left: Blank signature page info */} +
+

+ Blank Signature Page +

+
onPreviewBlank(blankPage)} + title="View full page" + > + {`Blank +
+
+ View PDF +
+
+
+
+
+ + + {blankPage.documentName} + +
+
+ + {blankPage.partyName} +
+ {blankPage.signatoryName && ( +
+ + {blankPage.signatoryName} +
+ )} + {blankPage.capacity && ( +
+ + {blankPage.capacity} +
+ )} +
+ Page {blankPage.pageNumber} in original document +
+
+ + {/* Current match info */} + {currentMatch && currentMatchedPage && ( +
+
Currently matched to:
+
+ +
+ {currentMatchedPage.extractedPartyName} — {currentMatchedPage.sourceFileName} pg {currentMatchedPage.pageNumber} +
+
+ +
+ )} +
+ + {/* Arrow divider */} +
+ +
+ + {/* Right: Available executed pages */} +
+

+ Select Executed Page ({availableExecuted.length} available) +

+ + {availableExecuted.length === 0 ? ( +
+ No unmatched executed pages available. +
+ Upload more signed pages or unmatch existing ones. +
+ ) : ( +
+ {availableExecuted.map(ep => { + const isSelected = selectedExecutedId === ep.id; + + return ( +
setSelectedExecutedId(isSelected ? null : ep.id)} + className={`w-full flex items-start gap-3 p-3 rounded-lg border text-left transition-colors cursor-pointer ${ + isSelected + ? 'border-blue-400 bg-blue-50 ring-1 ring-blue-400' + : 'border-slate-200 bg-white hover:bg-slate-50' + }`} + > + {/* Thumbnail */} + + + {/* Info */} +
+
+ {ep.sourceFileName} +
+ {ep.extractedDocumentName && ( +
+ {ep.extractedDocumentName} +
+ )} + {ep.extractedPartyName && ( +
{ep.extractedPartyName}
+ )} + {ep.extractedSignatoryName && ( +
{ep.extractedSignatoryName}
+ )} +
+ + {/* Selection indicator */} +
+ + {isSelected && ( + + )} +
+
+ ); + })} +
+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default MatchPickerModal; diff --git a/components/PdfPreviewModal.tsx b/components/PdfPreviewModal.tsx index 068103f..cb5cf08 100644 --- a/components/PdfPreviewModal.tsx +++ b/components/PdfPreviewModal.tsx @@ -12,7 +12,7 @@ const PdfPreviewModal: React.FC = ({ isOpen, onClose, pdfU if (!isOpen || !pdfUrl) return null; return ( -
+
{/* Header */}
diff --git a/components/SignatureCard.tsx b/components/SignatureCard.tsx index 5b5a27c..bcd0095 100644 --- a/components/SignatureCard.tsx +++ b/components/SignatureCard.tsx @@ -48,7 +48,7 @@ const SignatureCard: React.FC = ({
- {page.documentName} + {page.documentName}
DocumentParty CapacitySignatory Copies
{p.documentName}{p.partyName} {p.capacity}{p.signatoryName || '-'} {p.copies}