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