ميزة: إضافة مكوني ProcedureSelection و StepProgress لأداة مخططات التدفق بصيغة PDF

- تنفيذ مكون ProcedureSelection لتمكين المستخدمين من اختيار الإجراءات من قائمة، وإدارة الاختيارات، ومعالجة الإجراءات المرفوضة.

- إنشاء مكون StepProgress لعرض تقدم معالج متعدد الخطوات بشكل مرئي.

- تعريف أنواع مشتركة للإجراءات، وخطوات التدفق، ورسائل الدردشة في ملف types.ts.

- إضافة اختبارات وحدة لخطافات useFileUpload و useTaskPolling لضمان الأداء السليم ومعالجة الأخطاء.

- تنفيذ اختبارات واجهة برمجة التطبيقات (API) للتحقق من تنسيقات نقاط النهاية وضمان اتساق ربط الواجهة الأمامية بالخلفية.
This commit is contained in:
Your Name
2026-03-06 17:16:09 +02:00
parent 2e97741d60
commit cfbcc8bd79
62 changed files with 10567 additions and 101 deletions

View 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>
</>
);
}

View 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>
</>
);
}

View File

@@ -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')}

View 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>
);
}

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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;