ميزة: إضافة مكوني ProcedureSelection و StepProgress لأداة مخططات التدفق بصيغة PDF
- تنفيذ مكون ProcedureSelection لتمكين المستخدمين من اختيار الإجراءات من قائمة، وإدارة الاختيارات، ومعالجة الإجراءات المرفوضة. - إنشاء مكون StepProgress لعرض تقدم معالج متعدد الخطوات بشكل مرئي. - تعريف أنواع مشتركة للإجراءات، وخطوات التدفق، ورسائل الدردشة في ملف types.ts. - إضافة اختبارات وحدة لخطافات useFileUpload و useTaskPolling لضمان الأداء السليم ومعالجة الأخطاء. - تنفيذ اختبارات واجهة برمجة التطبيقات (API) للتحقق من تنسيقات نقاط النهاية وضمان اتساق ربط الواجهة الأمامية بالخلفية.
This commit is contained in:
245
frontend/src/components/tools/PdfEditor.tsx
Normal file
245
frontend/src/components/tools/PdfEditor.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import {
|
||||
PenLine,
|
||||
Save,
|
||||
Download,
|
||||
Undo2,
|
||||
Redo2,
|
||||
PlusCircle,
|
||||
Trash2,
|
||||
RotateCw,
|
||||
FileOutput,
|
||||
PanelLeft,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
export default function PdfEditor() {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
|
||||
const {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error: uploadError,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
endpoint: '/compress/pdf',
|
||||
maxSizeMB: 200,
|
||||
acceptedTypes: ['pdf'],
|
||||
extraData: { quality: 'high' },
|
||||
});
|
||||
|
||||
const { status, result, error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
// Accept file from homepage smart upload
|
||||
const storeFile = useFileStore((s) => s.file);
|
||||
const clearStoreFile = useFileStore((s) => s.clearFile);
|
||||
useEffect(() => {
|
||||
if (storeFile) {
|
||||
selectFile(storeFile);
|
||||
clearStoreFile();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleUpload = async () => {
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.pdfEditor.title'),
|
||||
description: t('tools.pdfEditor.description'),
|
||||
url: `${window.location.origin}/tools/pdf-editor`,
|
||||
});
|
||||
|
||||
const toolbarButtons = [
|
||||
{ icon: Undo2, label: t('tools.pdfEditor.undo'), shortcut: 'Ctrl+Z' },
|
||||
{ icon: Redo2, label: t('tools.pdfEditor.redo'), shortcut: 'Ctrl+Y' },
|
||||
{ icon: PlusCircle, label: t('tools.pdfEditor.addPage') },
|
||||
{ icon: Trash2, label: t('tools.pdfEditor.deletePage') },
|
||||
{ icon: RotateCw, label: t('tools.pdfEditor.rotate') },
|
||||
{ icon: FileOutput, label: t('tools.pdfEditor.extractPage') },
|
||||
{ icon: PanelLeft, label: t('tools.pdfEditor.thumbnails') },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.pdfEditor.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.pdfEditor.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/pdf-editor`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-rose-100 dark:bg-rose-900/30">
|
||||
<PenLine className="h-8 w-8 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.pdfEditor.title')}</h1>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfEditor.intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{/* Upload Phase */}
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-6">
|
||||
<FileUploader
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{ 'application/pdf': ['.pdf'] }}
|
||||
maxSizeMB={200}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
onReset={handleReset}
|
||||
acceptLabel="PDF"
|
||||
/>
|
||||
|
||||
{file && !isUploading && (
|
||||
<>
|
||||
{/* Steps */}
|
||||
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<ol className="space-y-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">1</span>
|
||||
{t('tools.pdfEditor.steps.step1')}
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">2</span>
|
||||
{t('tools.pdfEditor.steps.step2')}
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">3</span>
|
||||
{t('tools.pdfEditor.steps.step3')}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Toolbar Preview */}
|
||||
<div className="rounded-2xl bg-white p-4 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
|
||||
{t('tools.pdfEditor.thumbnails')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{toolbarButtons.map((btn) => {
|
||||
const Icon = btn.icon;
|
||||
return (
|
||||
<div
|
||||
key={btn.label}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-slate-50 px-3 py-2 text-xs font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-600"
|
||||
title={btn.shortcut ? `${btn.label} (${btn.shortcut})` : btn.label}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{btn.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
className="btn-primary w-full"
|
||||
title={t('tools.pdfEditor.saveTooltip')}
|
||||
>
|
||||
<Save className="h-5 w-5" />
|
||||
{t('tools.pdfEditor.save')}
|
||||
</button>
|
||||
|
||||
{/* Version & Privacy Notes */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2.5 rounded-xl bg-blue-50 p-3 text-xs text-blue-700 ring-1 ring-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:ring-blue-800">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>{t('tools.pdfEditor.versionNote')}</span>
|
||||
</div>
|
||||
<div className="flex gap-2.5 rounded-xl bg-emerald-50 p-3 text-xs text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-300 dark:ring-emerald-800">
|
||||
<ShieldCheck className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>{t('tools.pdfEditor.privacyNote')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing Phase */}
|
||||
{phase === 'processing' && !result && (
|
||||
<div className="space-y-3">
|
||||
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
|
||||
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfEditor.applyingChangesSub')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done Phase - Success */}
|
||||
{phase === 'done' && result && result.status === 'completed' && (
|
||||
<div className="space-y-4">
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
|
||||
{/* Share button */}
|
||||
{result.download_url && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(result.download_url!);
|
||||
}}
|
||||
className="btn-secondary w-full"
|
||||
title={t('tools.pdfEditor.share')}
|
||||
>
|
||||
<Share2 className="h-5 w-5" />
|
||||
{t('tools.pdfEditor.share')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done Phase - Error */}
|
||||
{phase === 'done' && taskError && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">
|
||||
{t('tools.pdfEditor.processingFailed')}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('tools.pdfEditor.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
360
frontend/src/components/tools/PdfFlowchart.tsx
Normal file
360
frontend/src/components/tools/PdfFlowchart.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
import type { Procedure, Flowchart, PDFPage, WizardStep } from './pdf-flowchart/types';
|
||||
import StepProgress from './pdf-flowchart/StepProgress';
|
||||
import FlowUpload from './pdf-flowchart/FlowUpload';
|
||||
import ProcedureSelection from './pdf-flowchart/ProcedureSelection';
|
||||
import DocumentViewer from './pdf-flowchart/DocumentViewer';
|
||||
import ManualProcedure from './pdf-flowchart/ManualProcedure';
|
||||
import FlowGeneration from './pdf-flowchart/FlowGeneration';
|
||||
import FlowChart from './pdf-flowchart/FlowChart';
|
||||
import FlowChat from './pdf-flowchart/FlowChat';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function PdfFlowchart() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Wizard state
|
||||
const [step, setStep] = useState<WizardStep>(0);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// Data
|
||||
const [pages, setPages] = useState<PDFPage[]>([]);
|
||||
const [procedures, setProcedures] = useState<Procedure[]>([]);
|
||||
const [rejectedProcedures, setRejectedProcedures] = useState<Procedure[]>([]);
|
||||
const [flowcharts, setFlowcharts] = useState<Flowchart[]>([]);
|
||||
const [selectedCount, setSelectedCount] = useState(0);
|
||||
|
||||
// Sub-views
|
||||
const [viewingProcedure, setViewingProcedure] = useState<Procedure | null>(null);
|
||||
const [addingManual, setAddingManual] = useState(false);
|
||||
const [viewingFlow, setViewingFlow] = useState<Flowchart | null>(null);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
|
||||
// Accept file from homepage smart-upload
|
||||
const storeFile = useFileStore((s) => s.file);
|
||||
const clearStoreFile = useFileStore((s) => s.clearFile);
|
||||
useEffect(() => {
|
||||
if (storeFile && storeFile.type === 'application/pdf') {
|
||||
setFile(storeFile);
|
||||
clearStoreFile();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Task polling for extraction
|
||||
const { error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: (res) => {
|
||||
if (res?.procedures) {
|
||||
setProcedures(res.procedures);
|
||||
setFlowcharts((res.flowcharts || []) as unknown as Flowchart[]);
|
||||
if (res.pages) setPages(res.pages as unknown as PDFPage[]);
|
||||
setStep(1);
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setError(taskError || t('common.error'));
|
||||
setStep(0);
|
||||
setUploading(false);
|
||||
},
|
||||
});
|
||||
|
||||
// ------ Handlers ------
|
||||
const handleFileSelect = (f: File) => {
|
||||
if (f.type === 'application/pdf') {
|
||||
setFile(f);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch('/api/flowchart/extract', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.error || 'Upload failed.');
|
||||
setTaskId(data.task_id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed.');
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrySample = async () => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/flowchart/extract-sample', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Sample failed.');
|
||||
setTaskId(data.task_id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sample failed.');
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectProcedure = (id: string) => {
|
||||
const proc = procedures.find((p) => p.id === id);
|
||||
if (!proc) return;
|
||||
setProcedures((prev) => prev.filter((p) => p.id !== id));
|
||||
setRejectedProcedures((prev) => [...prev, proc]);
|
||||
};
|
||||
|
||||
const handleRestoreProcedure = (id: string) => {
|
||||
const proc = rejectedProcedures.find((p) => p.id === id);
|
||||
if (!proc) return;
|
||||
setRejectedProcedures((prev) => prev.filter((p) => p.id !== id));
|
||||
setProcedures((prev) => [...prev, proc]);
|
||||
};
|
||||
|
||||
const handleContinueToGenerate = (selectedIds: string[]) => {
|
||||
setSelectedCount(selectedIds.length);
|
||||
// Filter flowcharts to selected procedures
|
||||
const ids = new Set(selectedIds);
|
||||
const selected = flowcharts.filter((fc) => ids.has(fc.procedureId));
|
||||
setFlowcharts(selected);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleManualProcedureCreated = (proc: Procedure) => {
|
||||
setProcedures((prev) => [...prev, proc]);
|
||||
// Generate a simple flowchart for the manual procedure
|
||||
const manualFlow: Flowchart = {
|
||||
id: `flow-${proc.id}`,
|
||||
procedureId: proc.id,
|
||||
title: proc.title,
|
||||
steps: [
|
||||
{ id: '1', type: 'start', title: `Begin: ${proc.title.slice(0, 40)}`, description: 'Start of procedure', connections: ['2'] },
|
||||
{ id: '2', type: 'process', title: proc.description.slice(0, 60) || 'Manual step', description: proc.description.slice(0, 150), connections: ['3'] },
|
||||
{ id: '3', type: 'end', title: 'Procedure Complete', description: 'End of procedure', connections: [] },
|
||||
],
|
||||
};
|
||||
setFlowcharts((prev) => [...prev, manualFlow]);
|
||||
setAddingManual(false);
|
||||
};
|
||||
|
||||
const handleGenerationDone = () => {
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const handleFlowUpdate = (updated: Flowchart) => {
|
||||
setFlowcharts((prev) => prev.map((fc) => (fc.id === updated.id ? updated : fc)));
|
||||
if (viewingFlow?.id === updated.id) setViewingFlow(updated);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFile(null);
|
||||
setStep(0);
|
||||
setTaskId(null);
|
||||
setError(null);
|
||||
setUploading(false);
|
||||
setPages([]);
|
||||
setProcedures([]);
|
||||
setRejectedProcedures([]);
|
||||
setFlowcharts([]);
|
||||
setSelectedCount(0);
|
||||
setViewingProcedure(null);
|
||||
setAddingManual(false);
|
||||
setViewingFlow(null);
|
||||
setChatOpen(false);
|
||||
};
|
||||
|
||||
// ------ SEO ------
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.pdfFlowchart.title'),
|
||||
description: t('tools.pdfFlowchart.description'),
|
||||
url: `${window.location.origin}/tools/pdf-flowchart`,
|
||||
});
|
||||
|
||||
// === SUB-VIEWS (full-screen overlays) ===
|
||||
if (viewingFlow) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{viewingFlow.title} — {t('common.appName')}</title>
|
||||
</Helmet>
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<FlowChart
|
||||
flow={viewingFlow}
|
||||
onBack={() => setViewingFlow(null)}
|
||||
onOpenChat={() => setChatOpen(true)}
|
||||
/>
|
||||
{chatOpen && (
|
||||
<FlowChat
|
||||
flow={viewingFlow}
|
||||
onClose={() => setChatOpen(false)}
|
||||
onFlowUpdate={handleFlowUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewingProcedure) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<DocumentViewer
|
||||
procedure={viewingProcedure}
|
||||
pages={pages}
|
||||
onClose={() => setViewingProcedure(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (addingManual) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<ManualProcedure
|
||||
pages={pages}
|
||||
onProcedureCreated={handleManualProcedureCreated}
|
||||
onBack={() => setAddingManual(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === MAIN VIEW ===
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.pdfFlowchart.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.pdfFlowchart.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/pdf-flowchart`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-100 dark:bg-indigo-900/30">
|
||||
<GitBranch className="h-8 w-8 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.pdfFlowchart.title')}</h1>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Progress */}
|
||||
<StepProgress currentStep={step} className="mb-8" />
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{/* Step 0: Upload */}
|
||||
{step === 0 && (
|
||||
<FlowUpload
|
||||
file={file}
|
||||
onFileSelect={handleFileSelect}
|
||||
onClearFile={() => setFile(null)}
|
||||
onUpload={handleUpload}
|
||||
onTrySample={handleTrySample}
|
||||
uploading={uploading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 1: Select Procedures */}
|
||||
{step === 1 && (
|
||||
<ProcedureSelection
|
||||
procedures={procedures}
|
||||
rejectedProcedures={rejectedProcedures}
|
||||
pages={pages}
|
||||
onContinue={handleContinueToGenerate}
|
||||
onManualAdd={() => setAddingManual(true)}
|
||||
onReject={handleRejectProcedure}
|
||||
onRestore={handleRestoreProcedure}
|
||||
onViewProcedure={setViewingProcedure}
|
||||
onBack={handleReset}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: Generation */}
|
||||
{step === 2 && (
|
||||
<FlowGeneration
|
||||
flowcharts={flowcharts}
|
||||
selectedCount={selectedCount}
|
||||
onDone={handleGenerationDone}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: Results */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 text-center dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 text-green-600">
|
||||
<GitBranch className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.flowReady')}
|
||||
</h2>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.flowReadyCount', { count: flowcharts.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{flowcharts.map((flow) => (
|
||||
<div
|
||||
key={flow.id}
|
||||
className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 dark:text-slate-200">
|
||||
{flow.title}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.steps', { count: flow.steps.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setViewingFlow(flow)}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{t('tools.pdfFlowchart.viewFlow')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-center pt-2">
|
||||
<button onClick={handleReset} className="btn-secondary">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export default function SplitPdf() {
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [mode, setMode] = useState<SplitMode>('all');
|
||||
const [pages, setPages] = useState('');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
const {
|
||||
file,
|
||||
@@ -52,7 +53,12 @@ export default function SplitPdf() {
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (mode === 'range' && !pages.trim()) return;
|
||||
if (mode === 'range' && !pages.trim()) {
|
||||
setValidationError(t('tools.splitPdf.errors.requiredPages'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError('');
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
@@ -62,6 +68,7 @@ export default function SplitPdf() {
|
||||
setPhase('upload');
|
||||
setMode('all');
|
||||
setPages('');
|
||||
setValidationError('');
|
||||
};
|
||||
|
||||
const schema = generateToolSchema({
|
||||
@@ -70,6 +77,45 @@ export default function SplitPdf() {
|
||||
url: `${window.location.origin}/tools/split-pdf`,
|
||||
});
|
||||
|
||||
const getLocalizedSplitError = (message: string) => {
|
||||
const outOfRangeMatch = message.match(
|
||||
/^Selected pages \((.+)\) are out of range\. This PDF has only (\d+) page(?:s)?\.$/i
|
||||
);
|
||||
if (outOfRangeMatch) {
|
||||
return t('tools.splitPdf.errors.outOfRange', {
|
||||
selected: outOfRangeMatch[1],
|
||||
total: Number(outOfRangeMatch[2]),
|
||||
});
|
||||
}
|
||||
|
||||
const invalidFormatMatch = message.match(
|
||||
/^Invalid page format: (.+)\. Use a format like 1,3,5-8\.$/i
|
||||
);
|
||||
if (invalidFormatMatch) {
|
||||
return t('tools.splitPdf.errors.invalidFormat', {
|
||||
tokens: invalidFormatMatch[1],
|
||||
});
|
||||
}
|
||||
|
||||
const noPagesSelectedMatch = message.match(
|
||||
/^No pages selected\. This PDF has (\d+) page(?:s)?\.$/i
|
||||
);
|
||||
if (noPagesSelectedMatch) {
|
||||
return t('tools.splitPdf.errors.noPagesSelected', {
|
||||
total: Number(noPagesSelectedMatch[1]),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
/Please specify which pages to extract/i.test(message) ||
|
||||
/Please specify at least one page/i.test(message)
|
||||
) {
|
||||
return t('tools.splitPdf.errors.requiredPages');
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -109,7 +155,10 @@ export default function SplitPdf() {
|
||||
{/* Mode Selector */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setMode('all')}
|
||||
onClick={() => {
|
||||
setMode('all');
|
||||
setValidationError('');
|
||||
}}
|
||||
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||
mode === 'all'
|
||||
? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold'
|
||||
@@ -120,7 +169,10 @@ export default function SplitPdf() {
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('tools.splitPdf.allPagesDesc')}</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('range')}
|
||||
onClick={() => {
|
||||
setMode('range');
|
||||
setValidationError('');
|
||||
}}
|
||||
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||
mode === 'range'
|
||||
? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold'
|
||||
@@ -141,13 +193,19 @@ export default function SplitPdf() {
|
||||
<input
|
||||
type="text"
|
||||
value={pages}
|
||||
onChange={(e) => setPages(e.target.value)}
|
||||
placeholder="1, 3, 5-8"
|
||||
onChange={(e) => {
|
||||
setPages(e.target.value);
|
||||
if (validationError) setValidationError('');
|
||||
}}
|
||||
placeholder={t('tools.splitPdf.rangePlaceholder')}
|
||||
className="input-field"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{t('tools.splitPdf.pageRangeHint')}
|
||||
{t('tools.splitPdf.rangeHint')}
|
||||
</p>
|
||||
{validationError && (
|
||||
<p className="mt-2 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -170,7 +228,7 @@ export default function SplitPdf() {
|
||||
{phase === 'done' && taskError && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{taskError}</p>
|
||||
<p className="text-sm text-red-700">{getLocalizedSplitError(taskError)}</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
|
||||
136
frontend/src/components/tools/pdf-flowchart/DocumentViewer.tsx
Normal file
136
frontend/src/components/tools/pdf-flowchart/DocumentViewer.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, FileText, AlertTriangle, BookOpen } from 'lucide-react';
|
||||
import type { Procedure, PDFPage } from './types';
|
||||
|
||||
interface DocumentViewerProps {
|
||||
procedure: Procedure;
|
||||
pages: PDFPage[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DocumentViewer({ procedure, pages, onClose }: DocumentViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const relevantPages = pages.filter((p) => procedure.pages.includes(p.page));
|
||||
|
||||
const isHighPriority =
|
||||
/emergency|safety|طوارئ|أمان|urgence/i.test(procedure.title);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
{/* Header */}
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<button onClick={onClose} className="btn-secondary text-xs">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.backToProcedures')}
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-slate-800 dark:text-slate-200">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
{t('tools.pdfFlowchart.documentViewer')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Procedure info card */}
|
||||
<div className={`mb-5 rounded-xl p-4 ring-1 ${isHighPriority ? 'bg-red-50 ring-red-200 dark:bg-red-900/10 dark:ring-red-800' : 'bg-blue-50 ring-blue-200 dark:bg-blue-900/10 dark:ring-blue-800'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${isHighPriority ? 'bg-red-100' : 'bg-blue-100'}`}>
|
||||
{isHighPriority ? (
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isHighPriority ? 'text-red-900 dark:text-red-300' : 'text-blue-900 dark:text-blue-300'}`}>
|
||||
{procedure.title}
|
||||
</h3>
|
||||
<p className={`mt-1 text-sm ${isHighPriority ? 'text-red-800 dark:text-red-400' : 'text-blue-800 dark:text-blue-400'}`}>
|
||||
{procedure.description}
|
||||
</p>
|
||||
<div className={`mt-2 flex gap-4 text-xs ${isHighPriority ? 'text-red-700' : 'text-blue-700'}`}>
|
||||
<span>{t('tools.pdfFlowchart.pages')}: {procedure.pages.join(', ')}</span>
|
||||
<span>{t('tools.pdfFlowchart.totalPagesLabel')}: {procedure.pages.length}</span>
|
||||
<span>~{procedure.pages.length * 2} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pages content */}
|
||||
<div className="space-y-4 max-h-[32rem] overflow-y-auto pr-1">
|
||||
<h4 className="flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-300">
|
||||
<FileText className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.documentContent')} ({relevantPages.length} {t('tools.pdfFlowchart.pagesWord')})
|
||||
</h4>
|
||||
|
||||
{relevantPages.length === 0 ? (
|
||||
<p className="py-8 text-center text-slate-500">{t('tools.pdfFlowchart.noPageContent')}</p>
|
||||
) : (
|
||||
relevantPages.map((page) => (
|
||||
<div
|
||||
key={page.page}
|
||||
className="rounded-xl border-l-4 border-l-primary-400 bg-slate-50 p-4 dark:bg-slate-700/50"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h5 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.pageLabel')} {page.page}
|
||||
{page.title ? `: ${page.title}` : ''}
|
||||
</h5>
|
||||
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-600 dark:text-slate-300">
|
||||
{t('tools.pdfFlowchart.pageLabel')} {page.page}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap text-sm leading-relaxed text-slate-600 dark:text-slate-300 font-sans">
|
||||
{page.text}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Analysis summary */}
|
||||
<div className="mt-5 rounded-xl bg-green-50 p-4 ring-1 ring-green-200 dark:bg-green-900/10 dark:ring-green-800">
|
||||
<h4 className="mb-2 font-semibold text-green-900 dark:text-green-300">
|
||||
{t('tools.pdfFlowchart.aiAnalysis')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="font-medium text-green-800 dark:text-green-400">{t('tools.pdfFlowchart.keyActions')}</p>
|
||||
<p className="text-green-700 dark:text-green-500">
|
||||
{procedure.step_count} {t('tools.pdfFlowchart.stepsIdentified')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-green-800 dark:text-green-400">{t('tools.pdfFlowchart.decisionPoints')}</p>
|
||||
<p className="text-green-700 dark:text-green-500">
|
||||
{Math.max(1, Math.floor(procedure.step_count / 3))} {t('tools.pdfFlowchart.estimated')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-green-800 dark:text-green-400">{t('tools.pdfFlowchart.flowComplexity')}</p>
|
||||
<p className="text-green-700 dark:text-green-500">
|
||||
{procedure.step_count <= 4
|
||||
? t('tools.pdfFlowchart.complexity.simple')
|
||||
: procedure.step_count <= 8
|
||||
? t('tools.pdfFlowchart.complexity.medium')
|
||||
: t('tools.pdfFlowchart.complexity.complex')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back row */}
|
||||
<div className="mt-5 flex justify-between items-center border-t border-slate-200 pt-4 dark:border-slate-700">
|
||||
<button onClick={onClose} className="btn-secondary">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.backToProcedures')}
|
||||
</button>
|
||||
<p className="text-xs text-slate-400">
|
||||
~{procedure.step_count <= 4 ? '6-8' : procedure.step_count <= 8 ? '8-12' : '12-16'} {t('tools.pdfFlowchart.flowStepsEstimate')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
frontend/src/components/tools/pdf-flowchart/FlowChart.tsx
Normal file
274
frontend/src/components/tools/pdf-flowchart/FlowChart.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
Diamond,
|
||||
Circle,
|
||||
ArrowDown,
|
||||
ChevronLeft,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import type { Flowchart, FlowStep } from './types';
|
||||
|
||||
interface FlowChartProps {
|
||||
flow: Flowchart;
|
||||
onBack: () => void;
|
||||
onOpenChat: () => void;
|
||||
}
|
||||
|
||||
// Node colour helpers
|
||||
const getNodeStyle = (type: FlowStep['type']) => {
|
||||
switch (type) {
|
||||
case 'start':
|
||||
return { bg: 'bg-green-100 border-green-400', text: 'text-green-800', icon: <Play className="h-4 w-4" /> };
|
||||
case 'end':
|
||||
return { bg: 'bg-red-100 border-red-400', text: 'text-red-800', icon: <Circle className="h-4 w-4" /> };
|
||||
case 'process':
|
||||
return { bg: 'bg-blue-100 border-blue-400', text: 'text-blue-800', icon: <Square className="h-4 w-4" /> };
|
||||
case 'decision':
|
||||
return { bg: 'bg-amber-100 border-amber-400', text: 'text-amber-800', icon: <Diamond className="h-4 w-4" /> };
|
||||
default:
|
||||
return { bg: 'bg-slate-100 border-slate-400', text: 'text-slate-800', icon: <Circle className="h-4 w-4" /> };
|
||||
}
|
||||
};
|
||||
|
||||
export default function FlowChartView({ flow, onBack, onOpenChat }: FlowChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ---------- Export PNG ----------
|
||||
const exportPng = useCallback(async () => {
|
||||
const el = chartRef.current;
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const scale = 2;
|
||||
canvas.width = el.scrollWidth * scale;
|
||||
canvas.height = el.scrollHeight * scale;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const svgData = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${el.scrollWidth}" height="${el.scrollHeight}">
|
||||
<foreignObject width="100%" height="100%">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">${el.outerHTML}</div>
|
||||
</foreignObject>
|
||||
</svg>`;
|
||||
|
||||
const img = new window.Image();
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob((b) => {
|
||||
if (!b) return reject();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(b);
|
||||
a.download = `flowchart-${flow.title.slice(0, 30)}.png`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
resolve();
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
} catch {
|
||||
// Fallback — JSON export
|
||||
const a = document.createElement('a');
|
||||
const json = JSON.stringify(flow, null, 2);
|
||||
a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
|
||||
a.download = `flowchart-${flow.title.slice(0, 30)}.json`;
|
||||
a.click();
|
||||
}
|
||||
}, [flow]);
|
||||
|
||||
// ---------- Export SVG ----------
|
||||
const exportSvg = useCallback(() => {
|
||||
const nodeH = 90;
|
||||
const arrowH = 40;
|
||||
const padding = 40;
|
||||
const nodeW = 320;
|
||||
const totalH = flow.steps.length * (nodeH + arrowH) - arrowH + padding * 2;
|
||||
const totalW = nodeW + padding * 2;
|
||||
|
||||
const typeColors: Record<string, { fill: string; stroke: string }> = {
|
||||
start: { fill: '#dcfce7', stroke: '#4ade80' },
|
||||
process: { fill: '#dbeafe', stroke: '#60a5fa' },
|
||||
decision: { fill: '#fef3c7', stroke: '#fbbf24' },
|
||||
end: { fill: '#fee2e2', stroke: '#f87171' },
|
||||
};
|
||||
|
||||
let svgParts = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" font-family="system-ui,sans-serif">`;
|
||||
svgParts += `<rect width="${totalW}" height="${totalH}" fill="#fff"/>`;
|
||||
|
||||
flow.steps.forEach((step, idx) => {
|
||||
const x = padding;
|
||||
const y = padding + idx * (nodeH + arrowH);
|
||||
const colors = typeColors[step.type] || typeColors.process;
|
||||
|
||||
svgParts += `<rect x="${x}" y="${y}" width="${nodeW}" height="${nodeH}" rx="12" fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="2"/>`;
|
||||
svgParts += `<text x="${x + 12}" y="${y + 22}" font-size="11" font-weight="600" fill="#64748b">${step.type.toUpperCase()}</text>`;
|
||||
svgParts += `<text x="${x + 12}" y="${y + 44}" font-size="14" font-weight="700" fill="#1e293b">${escapeXml(step.title.slice(0, 45))}</text>`;
|
||||
if (step.description !== step.title) {
|
||||
svgParts += `<text x="${x + 12}" y="${y + 64}" font-size="11" fill="#64748b">${escapeXml(step.description.slice(0, 60))}</text>`;
|
||||
}
|
||||
|
||||
// Arrow
|
||||
if (idx < flow.steps.length - 1) {
|
||||
const ax = x + nodeW / 2;
|
||||
const ay = y + nodeH;
|
||||
svgParts += `<line x1="${ax}" y1="${ay + 4}" x2="${ax}" y2="${ay + arrowH - 4}" stroke="#cbd5e1" stroke-width="2"/>`;
|
||||
svgParts += `<polygon points="${ax - 5},${ay + arrowH - 10} ${ax + 5},${ay + arrowH - 10} ${ax},${ay + arrowH - 2}" fill="#94a3b8"/>`;
|
||||
}
|
||||
});
|
||||
|
||||
svgParts += '</svg>';
|
||||
|
||||
const blob = new Blob([svgParts], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `flowchart-${flow.title.slice(0, 30)}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}, [flow]);
|
||||
|
||||
// Decision / Process / Start / End counts
|
||||
const stats = {
|
||||
total: flow.steps.length,
|
||||
decisions: flow.steps.filter((s) => s.type === 'decision').length,
|
||||
processes: flow.steps.filter((s) => s.type === 'process').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{/* Top bar */}
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.backToList')}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onOpenChat} className="btn-secondary text-sm">
|
||||
💬 {t('tools.pdfFlowchart.aiAssistant')}
|
||||
</button>
|
||||
<button onClick={exportPng} className="btn-secondary text-sm">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
PNG
|
||||
</button>
|
||||
<button onClick={exportSvg} className="btn-secondary text-sm">
|
||||
<Download className="h-4 w-4" />
|
||||
SVG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="section-heading mb-6 text-center">{flow.title}</h2>
|
||||
|
||||
{/* The chart */}
|
||||
<div
|
||||
ref={chartRef}
|
||||
className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"
|
||||
>
|
||||
{/* SVG canvas for connection lines */}
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="flowArrow"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
className="text-slate-400"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div className="relative flex flex-col items-center gap-0" style={{ zIndex: 2 }}>
|
||||
{flow.steps.map((step, idx) => {
|
||||
const style = getNodeStyle(step.type);
|
||||
const isLast = idx === flow.steps.length - 1;
|
||||
const hasMultipleConnections = step.connections.length > 1;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex w-full max-w-md flex-col items-center">
|
||||
{/* Node */}
|
||||
<div
|
||||
className={`w-full rounded-xl border-2 p-4 ${style.bg} ${style.text} transition-shadow hover:shadow-md`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-sm">
|
||||
{style.icon}
|
||||
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide">
|
||||
{step.type}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-bold text-base">{step.title}</h4>
|
||||
{step.description !== step.title && (
|
||||
<p className="mt-1 text-sm opacity-80">{step.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow / connector */}
|
||||
{!isLast && (
|
||||
<div className="flex flex-col items-center py-1 text-slate-400">
|
||||
<div className="h-3 w-px bg-slate-300" />
|
||||
{hasMultipleConnections ? (
|
||||
<div className="flex items-center gap-6 text-xs text-slate-500">
|
||||
<span>Yes ↓</span>
|
||||
<span>No ↓</span>
|
||||
</div>
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
)}
|
||||
<div className="h-2 w-px bg-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-8 flex justify-center gap-8 border-t border-slate-200 pt-4 text-sm text-slate-500 dark:border-slate-700">
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-slate-700 dark:text-slate-200">{stats.total}</div>
|
||||
<div className="text-xs">{t('tools.pdfFlowchart.totalSteps')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-slate-700 dark:text-slate-200">{stats.decisions}</div>
|
||||
<div className="text-xs">{t('tools.pdfFlowchart.decisionPoints')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-slate-700 dark:text-slate-200">{stats.processes}</div>
|
||||
<div className="text-xs">{t('tools.pdfFlowchart.processSteps')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
184
frontend/src/components/tools/pdf-flowchart/FlowChat.tsx
Normal file
184
frontend/src/components/tools/pdf-flowchart/FlowChat.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Send, Bot, User, Sparkles, X, Loader2 } from 'lucide-react';
|
||||
import type { Flowchart, ChatMessage } from './types';
|
||||
|
||||
interface FlowChatProps {
|
||||
flow: Flowchart;
|
||||
onClose: () => void;
|
||||
onFlowUpdate?: (updated: Flowchart) => void;
|
||||
}
|
||||
|
||||
export default function FlowChat({ flow, onClose, onFlowUpdate }: FlowChatProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: t('tools.pdfFlowchart.chatWelcome', { title: flow.title }),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [messages, isTyping]);
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isTyping) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/flowchart/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
flow_id: flow.id,
|
||||
flow_data: flow,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: data.reply || data.error || t('tools.pdfFlowchart.chatError'),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
// If the AI returned an updated flow, apply it
|
||||
if (data.updated_flow && onFlowUpdate) {
|
||||
onFlowUpdate(data.updated_flow);
|
||||
}
|
||||
} catch {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: t('tools.pdfFlowchart.chatError'),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const suggestions = [
|
||||
t('tools.pdfFlowchart.chatSuggestion1'),
|
||||
t('tools.pdfFlowchart.chatSuggestion2'),
|
||||
t('tools.pdfFlowchart.chatSuggestion3'),
|
||||
t('tools.pdfFlowchart.chatSuggestion4'),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-[28rem] flex-col rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
|
||||
<h3 className="flex items-center gap-2 text-sm font-bold text-slate-800 dark:text-slate-200">
|
||||
<Sparkles className="h-4 w-4 text-indigo-500" />
|
||||
{t('tools.pdfFlowchart.aiAssistant')}
|
||||
</h3>
|
||||
<button onClick={onClose} className="rounded-lg p-1 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex gap-2 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
msg.role === 'assistant'
|
||||
? 'bg-indigo-100 text-indigo-600'
|
||||
: 'bg-primary-100 text-primary-600'
|
||||
}`}
|
||||
>
|
||||
{msg.role === 'assistant' ? <Bot className="h-3.5 w-3.5" /> : <User className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed ${
|
||||
msg.role === 'assistant'
|
||||
? 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200'
|
||||
: 'bg-primary-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
<p className="mt-1 text-[10px] opacity-50">
|
||||
{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('tools.pdfFlowchart.chatTyping')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick suggestions */}
|
||||
{messages.length <= 2 && (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-slate-200 px-4 py-2 dark:border-slate-700">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setInput(s)}
|
||||
className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex items-center gap-2 border-t border-slate-200 px-4 py-3 dark:border-slate-700">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={t('tools.pdfFlowchart.chatPlaceholder')}
|
||||
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isTyping}
|
||||
className="btn-primary p-2"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, CheckCircle2, ChevronRight } from 'lucide-react';
|
||||
import type { Flowchart } from './types';
|
||||
|
||||
interface FlowGenerationProps {
|
||||
/** Called when generation is "done" (simulated progress + already-extracted flows) */
|
||||
flowcharts: Flowchart[];
|
||||
selectedCount: number;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export default function FlowGeneration({ flowcharts, selectedCount, onDone }: FlowGenerationProps) {
|
||||
const { t } = useTranslation();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
// Simulate a smooth progress bar while the flowcharts already exist in state
|
||||
useEffect(() => {
|
||||
const total = 100;
|
||||
const stepMs = 40;
|
||||
let current = 0;
|
||||
const timer = setInterval(() => {
|
||||
current += Math.random() * 12 + 3;
|
||||
if (current >= total) {
|
||||
current = total;
|
||||
clearInterval(timer);
|
||||
setDone(true);
|
||||
}
|
||||
setProgress(Math.min(current, total));
|
||||
}, stepMs);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center dark:bg-slate-800 dark:ring-slate-700">
|
||||
{!done ? (
|
||||
<>
|
||||
<Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin text-primary-500" />
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.generating')}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.generatingDesc')}
|
||||
</p>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mx-auto mt-6 max-w-md">
|
||||
<div className="h-2.5 w-full rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-primary-500 transition-all duration-200"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-400">{Math.round(progress)}%</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
{t('tools.pdfFlowchart.generatingFor', { count: selectedCount })}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="mx-auto mb-4 h-12 w-12 text-green-500" />
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.flowReady')}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.flowReadyCount', { count: flowcharts.length })}
|
||||
</p>
|
||||
<button onClick={onDone} className="btn-primary mt-6">
|
||||
{t('tools.pdfFlowchart.viewResults')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
frontend/src/components/tools/pdf-flowchart/FlowUpload.tsx
Normal file
150
frontend/src/components/tools/pdf-flowchart/FlowUpload.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, FileText, CheckCircle, Zap, X } from 'lucide-react';
|
||||
|
||||
interface FlowUploadProps {
|
||||
file: File | null;
|
||||
onFileSelect: (file: File) => void;
|
||||
onClearFile: () => void;
|
||||
onUpload: () => void;
|
||||
onTrySample: () => void;
|
||||
uploading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function FlowUpload({
|
||||
file,
|
||||
onFileSelect,
|
||||
onClearFile,
|
||||
onUpload,
|
||||
onTrySample,
|
||||
uploading,
|
||||
error,
|
||||
}: FlowUploadProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') setDragActive(true);
|
||||
else if (e.type === 'dragleave') setDragActive(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f?.type === 'application/pdf') onFileSelect(f);
|
||||
},
|
||||
[onFileSelect],
|
||||
);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f?.type === 'application/pdf') onFileSelect(f);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
{/* Try Sample banner */}
|
||||
<div className="mb-6 flex items-center gap-3 rounded-xl bg-indigo-50 p-4 ring-1 ring-indigo-200 dark:bg-indigo-900/20 dark:ring-indigo-800">
|
||||
<Zap className="h-5 w-5 flex-shrink-0 text-indigo-600 dark:text-indigo-400" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-indigo-900 dark:text-indigo-200">
|
||||
{t('tools.pdfFlowchart.trySampleTitle')}
|
||||
</p>
|
||||
<p className="text-xs text-indigo-700 dark:text-indigo-400">
|
||||
{t('tools.pdfFlowchart.trySampleDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onTrySample} className="btn-secondary text-xs whitespace-nowrap">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{t('tools.pdfFlowchart.trySample')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drag & Drop zone */}
|
||||
<label
|
||||
htmlFor="flowchart-upload"
|
||||
className={`upload-zone flex w-full cursor-pointer flex-col items-center rounded-xl border-2 border-dashed p-10 text-center transition-all ${
|
||||
dragActive
|
||||
? 'border-primary-400 bg-primary-50 dark:border-primary-500 dark:bg-primary-900/20'
|
||||
: 'border-slate-300 hover:border-primary-300 dark:border-slate-600 dark:hover:border-primary-500'
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload
|
||||
className={`mb-3 h-10 w-10 transition-colors ${
|
||||
dragActive ? 'text-primary-500' : 'text-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<p className="text-lg font-semibold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.uploadStep')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{t('tools.pdfFlowchart.dragDropHint')}
|
||||
</p>
|
||||
<input
|
||||
id="flowchart-upload"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Selected file */}
|
||||
{file && (
|
||||
<div className="mt-4 flex w-full items-center gap-3 rounded-xl bg-green-50 px-4 py-3 ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-800">
|
||||
<CheckCircle className="h-5 w-5 flex-shrink-0 text-green-600" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
{(file.size / 1024 / 1024).toFixed(1)} MB
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClearFile}
|
||||
className="rounded-lg p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-800/30"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-3 rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
|
||||
<p className="text-center text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload button */}
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={!file || uploading}
|
||||
className="btn-primary mt-6 w-full"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
{t('tools.pdfFlowchart.extracting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-5 w-5" />
|
||||
{t('tools.pdfFlowchart.generateFlows')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/tools/pdf-flowchart/ManualProcedure.tsx
Normal file
168
frontend/src/components/tools/pdf-flowchart/ManualProcedure.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Target, Check, AlertTriangle } from 'lucide-react';
|
||||
import type { Procedure, PDFPage } from './types';
|
||||
|
||||
interface ManualProcedureProps {
|
||||
pages: PDFPage[];
|
||||
onProcedureCreated: (proc: Procedure) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ManualProcedure({ pages, onProcedureCreated, onBack }: ManualProcedureProps) {
|
||||
const { t } = useTranslation();
|
||||
const [startPage, setStartPage] = useState(1);
|
||||
const [endPage, setEndPage] = useState(1);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const maxPages = pages.length || 1;
|
||||
const isValidRange = startPage >= 1 && endPage >= startPage && endPage <= maxPages;
|
||||
const selectedPages = isValidRange
|
||||
? Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i)
|
||||
: [];
|
||||
const canCreate = title.trim() && description.trim() && selectedPages.length > 0;
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!canCreate) return;
|
||||
onProcedureCreated({
|
||||
id: `manual-${Date.now()}`,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
pages: selectedPages,
|
||||
step_count: selectedPages.length * 3,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Left — form */}
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<button onClick={onBack} className="btn-secondary text-xs">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.back')}
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 font-bold text-slate-800 dark:text-slate-200">
|
||||
<Target className="h-5 w-5" />
|
||||
{t('tools.pdfFlowchart.manualTitle')}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">{t('tools.pdfFlowchart.manualDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document info */}
|
||||
<div className="mb-5 rounded-xl bg-indigo-50 p-3 ring-1 ring-indigo-200 dark:bg-indigo-900/20 dark:ring-indigo-800">
|
||||
<p className="text-sm font-medium text-indigo-800 dark:text-indigo-300">
|
||||
{t('tools.pdfFlowchart.totalPagesLabel')}: {maxPages}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Page range */}
|
||||
<div className="mb-5">
|
||||
<h4 className="mb-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.selectPageRange')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-500">{t('tools.pdfFlowchart.startPage')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxPages}
|
||||
value={startPage}
|
||||
onChange={(e) => setStartPage(Number(e.target.value) || 1)}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-500">{t('tools.pdfFlowchart.endPage')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxPages}
|
||||
value={endPage}
|
||||
onChange={(e) => setEndPage(Number(e.target.value) || 1)}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isValidRange && startPage > 0 && (
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.invalidRange')}
|
||||
</p>
|
||||
)}
|
||||
{isValidRange && selectedPages.length > 0 && (
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-green-600">
|
||||
<Check className="h-3 w-3" />
|
||||
{selectedPages.length} {t('tools.pdfFlowchart.pagesSelected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.procTitle')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t('tools.pdfFlowchart.procTitlePlaceholder')}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-5">
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.procDescription')}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t('tools.pdfFlowchart.procDescPlaceholder')}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button disabled={!canCreate} onClick={handleCreate} className="btn-primary w-full">
|
||||
<Check className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.createProcedure')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right — page preview */}
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.pagePreview')}
|
||||
</h3>
|
||||
<div className="max-h-[32rem] space-y-3 overflow-y-auto pr-1">
|
||||
{selectedPages.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">
|
||||
{t('tools.pdfFlowchart.selectPagesToPreview')}
|
||||
</p>
|
||||
) : (
|
||||
selectedPages.map((pn) => {
|
||||
const pageData = pages.find((p) => p.page === pn);
|
||||
return (
|
||||
<div key={pn} className="rounded-xl border-l-4 border-l-indigo-400 bg-slate-50 p-3 dark:bg-slate-700/50">
|
||||
<p className="mb-1 text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||
{t('tools.pdfFlowchart.pageLabel')} {pn}
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap text-xs leading-relaxed text-slate-500 dark:text-slate-400 font-sans">
|
||||
{pageData?.text || t('tools.pdfFlowchart.noPageContent')}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Clock,
|
||||
Eye,
|
||||
X,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import type { Procedure, PDFPage } from './types';
|
||||
|
||||
interface ProcedureSelectionProps {
|
||||
procedures: Procedure[];
|
||||
rejectedProcedures: Procedure[];
|
||||
pages: PDFPage[];
|
||||
onContinue: (selectedIds: string[]) => void;
|
||||
onManualAdd: () => void;
|
||||
onReject: (id: string) => void;
|
||||
onRestore: (id: string) => void;
|
||||
onViewProcedure: (proc: Procedure) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ProcedureSelection({
|
||||
procedures,
|
||||
rejectedProcedures,
|
||||
pages,
|
||||
onContinue,
|
||||
onManualAdd,
|
||||
onReject,
|
||||
onRestore,
|
||||
onViewProcedure,
|
||||
onBack,
|
||||
}: ProcedureSelectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(
|
||||
procedures.map((p) => p.id),
|
||||
);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
|
||||
const getComplexity = (count: number) => {
|
||||
if (count <= 4) return { label: t('tools.pdfFlowchart.complexity.simple'), color: 'bg-green-100 text-green-700' };
|
||||
if (count <= 8) return { label: t('tools.pdfFlowchart.complexity.medium'), color: 'bg-yellow-100 text-yellow-700' };
|
||||
return { label: t('tools.pdfFlowchart.complexity.complex'), color: 'bg-red-100 text-red-700' };
|
||||
};
|
||||
|
||||
const getPriorityIcon = (title: string) => {
|
||||
const lower = title.toLowerCase();
|
||||
if (lower.includes('emergency') || lower.includes('safety') || lower.includes('طوارئ') || lower.includes('أمان'))
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
return <FileText className="h-4 w-4 text-slate-400" />;
|
||||
};
|
||||
|
||||
const totalFound = procedures.length + rejectedProcedures.length;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.selectProcedures')}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.selectProceduresDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-primary-100 px-3 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
|
||||
{t('tools.pdfFlowchart.proceduresFound', { count: totalFound })}
|
||||
</span>
|
||||
{rejectedProcedures.length > 0 && (
|
||||
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-medium text-red-700">
|
||||
{rejectedProcedures.length} {t('tools.pdfFlowchart.rejected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedIds(procedures.map((p) => p.id))}
|
||||
className="text-sm font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{t('tools.pdfFlowchart.selectAll')}
|
||||
</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
<button
|
||||
onClick={() => setSelectedIds([])}
|
||||
className="text-sm font-medium text-slate-500 hover:underline"
|
||||
>
|
||||
{t('tools.pdfFlowchart.deselectAll')}
|
||||
</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
<button
|
||||
onClick={onManualAdd}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-indigo-600 hover:underline"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('tools.pdfFlowchart.addManual')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Procedures list */}
|
||||
{procedures.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-slate-500">{t('tools.pdfFlowchart.noProcedures')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[28rem] space-y-3 overflow-y-auto pr-1">
|
||||
{procedures.map((proc) => {
|
||||
const selected = selectedIds.includes(proc.id);
|
||||
const complexity = getComplexity(proc.step_count);
|
||||
return (
|
||||
<div
|
||||
key={proc.id}
|
||||
className={`flex items-start gap-3 rounded-xl border-2 p-4 transition-all ${
|
||||
selected
|
||||
? 'border-primary-400 bg-primary-50 dark:border-primary-600 dark:bg-primary-900/20'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300 dark:border-slate-600 dark:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => toggle(proc.id)}
|
||||
className={`mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
|
||||
selected
|
||||
? 'border-primary-500 bg-primary-500'
|
||||
: 'border-slate-300 dark:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{selected && <CheckCircle2 className="h-3.5 w-3.5 text-white" />}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{getPriorityIcon(proc.title)}
|
||||
<h3 className="font-semibold text-slate-800 dark:text-slate-200 truncate">
|
||||
{proc.title}
|
||||
</h3>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${complexity.color}`}>
|
||||
{complexity.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400 line-clamp-2">
|
||||
{proc.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.pages')}: {proc.pages.join(', ')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
~{proc.pages.length * 2} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => onViewProcedure(proc)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.viewSection')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReject(proc.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-red-200 px-2.5 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejected procedures */}
|
||||
{rejectedProcedures.length > 0 && (
|
||||
<div className="mt-4 rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/10 dark:ring-red-800">
|
||||
<h4 className="mb-2 text-sm font-semibold text-red-700 dark:text-red-400">
|
||||
{t('tools.pdfFlowchart.rejectedTitle')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{rejectedProcedures.map((proc) => (
|
||||
<div key={proc.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-red-600 dark:text-red-400 truncate">{proc.title}</span>
|
||||
<button
|
||||
onClick={() => onRestore(proc.id)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-red-700 hover:underline"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.restore')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 flex justify-between">
|
||||
<button onClick={onBack} className="btn-secondary">
|
||||
{t('tools.pdfFlowchart.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onContinue(selectedIds)}
|
||||
disabled={selectedIds.length === 0}
|
||||
className="btn-primary"
|
||||
>
|
||||
{t('tools.pdfFlowchart.generateFlows')}
|
||||
<span className="text-xs opacity-80">({selectedIds.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/tools/pdf-flowchart/StepProgress.tsx
Normal file
56
frontend/src/components/tools/pdf-flowchart/StepProgress.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { WIZARD_STEPS, type WizardStep } from './types';
|
||||
|
||||
interface StepProgressProps {
|
||||
currentStep: WizardStep;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function StepProgress({ currentStep, className }: StepProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Progress bar */}
|
||||
<div className="relative mb-3">
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-primary-500 transition-all duration-500"
|
||||
style={{ width: `${((currentStep + 1) / WIZARD_STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step labels */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{WIZARD_STEPS.map((step, idx) => {
|
||||
const done = idx < currentStep;
|
||||
const active = idx === currentStep;
|
||||
return (
|
||||
<div key={step.key} className="flex flex-col items-center text-center">
|
||||
<div
|
||||
className={`mb-1 flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold transition-colors ${
|
||||
done
|
||||
? 'bg-primary-500 text-white'
|
||||
: active
|
||||
? 'bg-primary-100 text-primary-700 ring-2 ring-primary-400 dark:bg-primary-900/40 dark:text-primary-300'
|
||||
: 'bg-slate-200 text-slate-500 dark:bg-slate-700 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{done ? <CheckCircle2 className="h-4 w-4" /> : idx + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[11px] leading-tight ${
|
||||
active ? 'font-semibold text-primary-700 dark:text-primary-300' : 'text-slate-500 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{t(step.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/tools/pdf-flowchart/types.ts
Normal file
48
frontend/src/components/tools/pdf-flowchart/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Shared types for the PDF Flowchart tool
|
||||
// -----------------------------------------------------------
|
||||
|
||||
export interface PDFPage {
|
||||
page: number;
|
||||
text: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface Procedure {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pages: number[];
|
||||
step_count: number;
|
||||
}
|
||||
|
||||
export interface FlowStep {
|
||||
id: string;
|
||||
type: 'start' | 'process' | 'decision' | 'end';
|
||||
title: string;
|
||||
description: string;
|
||||
connections: string[];
|
||||
}
|
||||
|
||||
export interface Flowchart {
|
||||
id: string;
|
||||
procedureId: string;
|
||||
title: string;
|
||||
steps: FlowStep[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** Wizard step index (0-based) */
|
||||
export type WizardStep = 0 | 1 | 2 | 3;
|
||||
|
||||
export const WIZARD_STEPS = [
|
||||
{ key: 'upload', labelKey: 'tools.pdfFlowchart.wizard.upload' },
|
||||
{ key: 'select', labelKey: 'tools.pdfFlowchart.wizard.select' },
|
||||
{ key: 'create', labelKey: 'tools.pdfFlowchart.wizard.create' },
|
||||
{ key: 'results', labelKey: 'tools.pdfFlowchart.wizard.results' },
|
||||
] as const;
|
||||
Reference in New Issue
Block a user