|
|
import React, { useState, useCallback, useEffect } from 'react'; |
|
|
import { Classification, DocumentFile, Innovation, MeetingDoc } from './types'; |
|
|
import { backendService } from './services/backendService'; |
|
|
import FilterBar from './components/FilterBar'; |
|
|
import DocFilterBar from './components/DocFilterBar'; |
|
|
import StatsDashboard from './components/StatsDashboard'; |
|
|
import FileContentCard from './components/FileContentCard'; |
|
|
import InnovationCard from './components/InnovationCard'; |
|
|
import { LayoutDashboard, FileText, Settings, Play, CheckCircle2, CircleDashed } from 'lucide-react'; |
|
|
import { useLiveQuery } from 'dexie-react-hooks'; |
|
|
|
|
|
const App: React.FC = () => { |
|
|
|
|
|
const [files, setFiles] = useState<DocumentFile[]>([]); |
|
|
const [innovations, setInnovations] = useState<Innovation[]>([]); |
|
|
|
|
|
const [isFetchingDocs, setIsFetchingDocs] = useState(false); |
|
|
const [isProcessing, setIsProcessing] = useState(false); |
|
|
|
|
|
|
|
|
const [processedCount, setProcessedCount] = useState(0); |
|
|
const [currentProcessingFile, setCurrentProcessingFile] = useState<string>(""); |
|
|
|
|
|
const [currentView, setCurrentView] = useState<'list' | 'dashboard'>('list'); |
|
|
|
|
|
|
|
|
const [rawDocs, setRawDocs] = useState<MeetingDoc[]>([]); |
|
|
|
|
|
|
|
|
const [patterns, setPatterns] = useState<any[]>([]); |
|
|
const [selectedPatternId, setSelectedPatternId] = useState<number | null>(null); |
|
|
const [isAnalyzing, setIsAnalyzing] = useState(false); |
|
|
|
|
|
const stopRef = React.useRef(false); |
|
|
|
|
|
|
|
|
const [selectedWG, setSelectedWG] = useState<string>(""); |
|
|
const [selectedMeeting, setSelectedMeeting] = useState<string>(""); |
|
|
|
|
|
useEffect(() => { |
|
|
const fetchPatterns = async () => { |
|
|
const data = await backendService.getPatterns(); |
|
|
setPatterns(data); |
|
|
if (data.length > 0) { |
|
|
setSelectedPatternId(data[0].pattern_id); |
|
|
} |
|
|
}; |
|
|
fetchPatterns(); |
|
|
}, []); |
|
|
|
|
|
|
|
|
const handleFetchDocuments = useCallback(async (wg: string, meeting: string) => { |
|
|
setIsFetchingDocs(true); |
|
|
setFiles([]); |
|
|
setInnovations([]); |
|
|
setRawDocs([]); |
|
|
setProcessedCount(0); |
|
|
setSelectedWG(wg); |
|
|
setSelectedMeeting(meeting); |
|
|
|
|
|
try { |
|
|
const response = await fetch('https://organizedprogrammers-docxtract.hf.space/docs/get_meeting_docs', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ "working_group": wg, "meeting": meeting, "custom_url": null }) |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
if (data.data && Array.isArray(data.data)) { |
|
|
setRawDocs(data.data); |
|
|
} |
|
|
} else { |
|
|
console.error("Failed to fetch docs"); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error("Error fetching docs:", error); |
|
|
} finally { |
|
|
setIsFetchingDocs(false); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
const handleFilterChange = useCallback((filteredDocs: MeetingDoc[]) => { |
|
|
|
|
|
const newFiles: DocumentFile[] = filteredDocs.map((doc, index) => ({ |
|
|
id: doc.TDoc || `doc-${index}`, |
|
|
filename: doc.TDoc ? `${doc.TDoc}.zip` : `Document ${index}`, |
|
|
size: 'Unknown', |
|
|
type: doc.Type, |
|
|
uploaded: false, |
|
|
status: doc["TDoc Status"], |
|
|
agendaItem: doc["Agenda item description"], |
|
|
url: doc.URL |
|
|
})); |
|
|
|
|
|
setFiles(newFiles); |
|
|
setProcessedCount(0); |
|
|
setInnovations([]); |
|
|
}, []); |
|
|
|
|
|
const handleStop = () => { |
|
|
stopRef.current = true; |
|
|
}; |
|
|
|
|
|
|
|
|
const handleExtractInnovations = async () => { |
|
|
if (files.length === 0) return; |
|
|
setIsProcessing(true); |
|
|
|
|
|
let startIndex = 0; |
|
|
|
|
|
|
|
|
if (processedCount > 0 && processedCount < files.length) { |
|
|
startIndex = processedCount; |
|
|
} else { |
|
|
|
|
|
setInnovations([]); |
|
|
setProcessedCount(0); |
|
|
startIndex = 0; |
|
|
} |
|
|
|
|
|
stopRef.current = false; |
|
|
|
|
|
|
|
|
for (let i = startIndex; i < files.length; i++) { |
|
|
if (stopRef.current) break; |
|
|
|
|
|
const file = files[i]; |
|
|
setCurrentProcessingFile(file.filename); |
|
|
|
|
|
|
|
|
const result = await backendService.processDocument(file, selectedWG, selectedMeeting); |
|
|
|
|
|
if (result) { |
|
|
setInnovations(prev => [...prev, result]); |
|
|
} |
|
|
|
|
|
setProcessedCount(prev => prev + 1); |
|
|
} |
|
|
|
|
|
setIsProcessing(false); |
|
|
setCurrentProcessingFile(""); |
|
|
stopRef.current = false; |
|
|
}; |
|
|
|
|
|
const handleClassify = async (id: string, classification: Classification) => { |
|
|
|
|
|
const numId = Number(id); |
|
|
const targetInv = innovations.find(i => i.id === id || (!isNaN(numId) && i.result_id === numId)); |
|
|
|
|
|
if (!targetInv) return; |
|
|
|
|
|
|
|
|
setInnovations(prev => prev.map(inv => |
|
|
inv.id === targetInv.id ? { ...inv, classification } : inv |
|
|
)); |
|
|
|
|
|
|
|
|
if (targetInv.result_id) { |
|
|
await backendService.saveClassification(targetInv.result_id, classification); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
|
const file = e.target.files?.[0]; |
|
|
if (file && file.type === 'text/plain') { |
|
|
const reader = new FileReader(); |
|
|
reader.onload = (event) => { |
|
|
const text = event.target?.result as string; |
|
|
const newInnovation: Innovation = { |
|
|
id: `uploaded-${Date.now()}`, |
|
|
file_name: file.name, |
|
|
answer: text, |
|
|
classification: Classification.UNCLASSIFIED |
|
|
}; |
|
|
setInnovations(prev => [...prev, newInnovation]); |
|
|
}; |
|
|
reader.readAsText(file); |
|
|
} else { |
|
|
alert("Please upload a .txt file"); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleAnalyze = async () => { |
|
|
if (!selectedPatternId) { |
|
|
alert("Please select a pattern"); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (innovations.length === 0) { |
|
|
alert("No documents to analyze."); |
|
|
return; |
|
|
} |
|
|
|
|
|
setIsAnalyzing(true); |
|
|
stopRef.current = false; |
|
|
|
|
|
try { |
|
|
|
|
|
const total = innovations.length; |
|
|
|
|
|
for (let i = 0; i < total; i++) { |
|
|
if (stopRef.current) break; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const inv = innovations[i]; |
|
|
|
|
|
|
|
|
if (inv.classification === Classification.DELETE) continue; |
|
|
|
|
|
let analysisData: any = null; |
|
|
|
|
|
|
|
|
if (inv.id.startsWith('uploaded-')) { |
|
|
const res = await backendService.analyzeContent(selectedPatternId, undefined, inv.answer); |
|
|
if (res) analysisData = res; |
|
|
} else { |
|
|
|
|
|
const res = await backendService.analyzeContent(selectedPatternId, inv.id); |
|
|
if (res) analysisData = res; |
|
|
} |
|
|
|
|
|
if (analysisData) { |
|
|
|
|
|
setInnovations(current => |
|
|
current.map(item => |
|
|
item.id === inv.id |
|
|
? { |
|
|
...item, |
|
|
analysis_result: analysisData.content, |
|
|
result_id: analysisData.result_id, |
|
|
methodology: analysisData.methodology, |
|
|
context: analysisData.context, |
|
|
problem: analysisData.problem, |
|
|
pattern_name: analysisData.pattern_name |
|
|
} |
|
|
: item |
|
|
) |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
} catch (error) { |
|
|
console.error("Error during analysis:", error); |
|
|
} finally { |
|
|
setIsAnalyzing(false); |
|
|
stopRef.current = false; |
|
|
} |
|
|
}; |
|
|
|
|
|
const pendingInnovations = innovations.filter(i => i.classification !== Classification.DELETE); |
|
|
const progressPercent = files.length > 0 ? (processedCount / files.length) * 100 : 0; |
|
|
|
|
|
return ( |
|
|
<div className="min-h-screen bg-slate-50 font-sans text-slate-900"> |
|
|
|
|
|
{/* 1. Header */} |
|
|
<header className="bg-slate-900 text-white shadow-md sticky top-0 z-50"> |
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
|
|
<div className="flex justify-between h-16 items-center"> |
|
|
<div className="flex items-center"> |
|
|
<div className="bg-blue-500 p-1.5 rounded mr-3"> |
|
|
<FileText className="h-5 w-5 text-white" /> |
|
|
</div> |
|
|
<div> |
|
|
<h1 className="text-lg font-bold tracking-tight">3GPP Innovation Extractor</h1> |
|
|
<p className="text-xs text-slate-400">SQLite Backend Powered</p> |
|
|
</div> |
|
|
</div> |
|
|
<nav className="flex space-x-4"> |
|
|
<button |
|
|
onClick={() => setCurrentView('list')} |
|
|
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${currentView === 'list' ? 'bg-slate-800 text-white' : 'text-slate-300 hover:text-white hover:bg-slate-800'}`} |
|
|
> |
|
|
Extraction |
|
|
</button> |
|
|
<button |
|
|
onClick={() => setCurrentView('dashboard')} |
|
|
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${currentView === 'dashboard' ? 'bg-slate-800 text-white' : 'text-slate-300 hover:text-white hover:bg-slate-800'}`} |
|
|
> |
|
|
Analytics |
|
|
</button> |
|
|
</nav> |
|
|
</div> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> |
|
|
|
|
|
{/* View: Extraction List */} |
|
|
<div className={currentView === 'list' ? 'animate-fade-in' : 'hidden'}> |
|
|
<FilterBar onFetch={handleFetchDocuments} isFetching={isFetchingDocs} /> |
|
|
|
|
|
{rawDocs.length > 0 && ( |
|
|
<DocFilterBar docs={rawDocs} onFilterChange={handleFilterChange} /> |
|
|
)} |
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8"> |
|
|
|
|
|
{/* Left Column: Files & Status */} |
|
|
<div className="lg:col-span-4 space-y-6"> |
|
|
|
|
|
{/* File Stats Card */} |
|
|
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-5"> |
|
|
<div className="flex items-center justify-between mb-4"> |
|
|
<h2 className="font-semibold text-slate-800">Documents</h2> |
|
|
<span className="text-xs font-mono bg-slate-100 px-2 py-1 rounded text-slate-600"> |
|
|
{files.length} Files |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
{files.length === 0 ? ( |
|
|
<div className="text-center py-8 text-slate-400 text-sm border-2 border-dashed border-slate-100 rounded-lg"> |
|
|
No documents selected |
|
|
</div> |
|
|
) : ( |
|
|
<div className="mb-4"> |
|
|
<div className="flex justify-between text-xs text-slate-500 mb-1"> |
|
|
<span>Processing Progress</span> |
|
|
<span>{processedCount} / {files.length}</span> |
|
|
</div> |
|
|
<div className="h-2 w-full bg-slate-100 rounded-full overflow-hidden"> |
|
|
<div className="h-full bg-blue-500 transition-all duration-300" style={{ width: `${progressPercent}%` }}></div> |
|
|
</div> |
|
|
{isProcessing && ( |
|
|
<p className="text-xs text-blue-500 mt-2 animate-pulse"> |
|
|
Processing: {currentProcessingFile} |
|
|
</p> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<div className="mt-4 flex space-x-2"> |
|
|
{!isProcessing && !isAnalyzing ? ( |
|
|
<button |
|
|
onClick={handleExtractInnovations} |
|
|
disabled={files.length === 0} |
|
|
className="w-full bg-slate-900 hover:bg-slate-800 text-white font-medium py-3 rounded-lg shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center" |
|
|
> |
|
|
<Play className="w-4 h-4 mr-2 fill-current" /> Start Extraction |
|
|
</button> |
|
|
) : ( |
|
|
<button |
|
|
onClick={handleStop} |
|
|
className="w-full bg-red-600 hover:bg-red-700 text-white font-medium py-3 rounded-lg shadow-md transition-all flex items-center justify-center animate-pulse" |
|
|
> |
|
|
<CircleDashed className="w-4 h-4 mr-2 animate-spin" /> Stop {isProcessing ? "Extraction" : "Analysis"} |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Pattern Analysis Card */} |
|
|
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-5"> |
|
|
<h2 className="font-semibold text-slate-800 mb-4">Pattern Analysis</h2> |
|
|
|
|
|
<div className="space-y-4"> |
|
|
<div> |
|
|
<label className="block text-xs font-medium text-slate-500 mb-1">Select Pattern</label> |
|
|
<select |
|
|
className="w-full bg-slate-50 border border-slate-200 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" |
|
|
value={selectedPatternId || ''} |
|
|
onChange={(e) => setSelectedPatternId(Number(e.target.value))} |
|
|
> |
|
|
{patterns.map(p => ( |
|
|
<option key={p.pattern_id} value={p.pattern_id}>{p.pattern_name}</option> |
|
|
))} |
|
|
</select> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label className="block text-xs font-medium text-slate-500 mb-1">Upload .txt File (Optional)</label> |
|
|
<input |
|
|
type="file" |
|
|
accept=".txt" |
|
|
onChange={handleFileUpload} |
|
|
className="w-full text-xs text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-xs file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" |
|
|
/> |
|
|
{/* Upload status is now handled in the main list */} |
|
|
</div> |
|
|
|
|
|
{!isAnalyzing && !isProcessing ? ( |
|
|
<button |
|
|
onClick={handleAnalyze} |
|
|
disabled={innovations.length === 0} |
|
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 rounded-lg shadow-sm transition-all disabled:opacity-50 flex items-center justify-center text-sm" |
|
|
> |
|
|
<Play className="w-4 h-4 mr-2 fill-current" /> Analyse |
|
|
</button> |
|
|
) : isAnalyzing ? ( |
|
|
<button |
|
|
onClick={handleStop} |
|
|
className="w-full bg-red-600 hover:bg-red-700 text-white font-medium py-2 rounded-lg shadow-sm transition-all flex items-center justify-center text-sm animate-pulse" |
|
|
> |
|
|
<CircleDashed className="w-4 h-4 mr-2 animate-spin" /> Stop Analysis |
|
|
</button> |
|
|
) : ( |
|
|
<button disabled className="w-full bg-slate-300 text-white font-medium py-2 rounded-lg shadow-sm cursor-not-allowed flex items-center justify-center text-sm"> |
|
|
Processing... |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Right Column: Results */} |
|
|
<div className="lg:col-span-8"> |
|
|
<div className="flex items-center justify-between mb-4"> |
|
|
<h2 className="text-xl font-bold text-slate-800">Files content refined</h2> |
|
|
{innovations.length > 0 && ( |
|
|
<span className="text-sm text-slate-500"> |
|
|
Showing {pendingInnovations.length} potential items |
|
|
</span> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
{innovations.length === 0 ? ( |
|
|
<div className="bg-white rounded-xl border border-dashed border-slate-300 p-12 text-center"> |
|
|
<div className="mx-auto bg-slate-50 w-16 h-16 rounded-full flex items-center justify-center mb-4"> |
|
|
<Settings className="w-8 h-8 text-slate-400" /> |
|
|
</div> |
|
|
<h3 className="text-lg font-medium text-slate-900">Ready to Process</h3> |
|
|
<p className="text-slate-500 max-w-sm mx-auto mt-2"> |
|
|
Load documents and start the extraction engine. The Data will be mocked and stored in your local SQLite database. |
|
|
</p> |
|
|
</div> |
|
|
) : ( |
|
|
<div className="space-y-4"> |
|
|
{pendingInnovations.map(inv => ( |
|
|
<div key={inv.id}> |
|
|
<FileContentCard |
|
|
innovation={inv} |
|
|
onClassify={handleClassify} |
|
|
/> |
|
|
{inv.analysis_result && ( |
|
|
<div className="ml-8 mt-2"> |
|
|
<InnovationCard |
|
|
innovation={{ |
|
|
id: inv.result_id || 0, |
|
|
file_name: inv.file_name, |
|
|
content: inv.analysis_result || "", |
|
|
classification: inv.classification, |
|
|
methodology: inv.methodology || "N/A", |
|
|
context: inv.context || "N/A", |
|
|
problem: inv.problem || "N/A", |
|
|
}} |
|
|
onClassify={handleClassify} |
|
|
/> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
))} |
|
|
{pendingInnovations.length === 0 && innovations.length > 0 && ( |
|
|
<div className="text-center py-10 text-slate-500"> |
|
|
All items have been deleted. Check the Analytics tab for stats. |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
{/* View: Analytics Dashboard */} |
|
|
<div className={currentView === 'dashboard' ? 'animate-fade-in' : 'hidden'}> |
|
|
<div className="flex items-center justify-between mb-6"> |
|
|
<h2 className="text-2xl font-bold text-slate-900">Classification Analytics</h2> |
|
|
</div> |
|
|
|
|
|
<StatsDashboard innovations={[]} isVisible={currentView === 'dashboard'} /> |
|
|
|
|
|
</div> |
|
|
|
|
|
</main> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default App; |