/** * DocumentUploadPanel — Upload PDF do projektu + RAG re-ingest * * Flow: * 1. Drag & drop / wybór pliku * 2. POST /api/projects/{id}/documents → 202 Accepted * 3. Polling GET co 3s gdy status=processing * 4. Lista wgranych dokumentów z ikonami statusu */ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Upload, FileText, CheckCircle, XCircle, Loader2, RefreshCw, Trash2, AlertTriangle, Database, FileSearch } from 'lucide-react'; import toast from 'react-hot-toast'; interface ProjectDocument { doc_id: string; filename: string; file_size_bytes: number; status: 'uploaded' | 'processing' | 'indexed' | 'error'; parser_used: string | null; chunks_count: number | null; error_message: string | null; uploaded_at: string; indexed_at: string | null; } interface QuotaInfo { current: number; limit: number; plan: string; can_upload: boolean; } interface Props { projectId: string; token?: string; } const API = import.meta.env.VITE_API_URL || ''; // Kolor + ikona dla statusu indeksacji function StatusBadge({ status }: { status: ProjectDocument['status'] }) { const map = { uploaded: { label: 'Oczekuje', color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', Icon: Loader2 }, processing: { label: 'Indeksowanie…', color: '#3b82f6', bg: 'rgba(59,130,246,0.1)', Icon: Loader2 }, indexed: { label: 'Gotowy', color: '#10b981', bg: 'rgba(16,185,129,0.1)', Icon: CheckCircle }, error: { label: 'Błąd parsera', color: '#ef4444', bg: 'rgba(239,68,68,0.1)', Icon: XCircle }, }; const { label, color, bg, Icon } = map[status] ?? map.uploaded; const spinning = status === 'processing' || status === 'uploaded'; return ( {label} ); } function fmtBytes(b: number) { if (b < 1024) return `${b} B`; if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`; return `${(b / (1024 * 1024)).toFixed(1)} MB`; } export default function DocumentUploadPanel({ projectId, token }: Props) { const [docs, setDocs] = useState([]); const [quota, setQuota] = useState(null); const [dragging, setDragging] = useState(false); const [uploading, setUploading] = useState(false); const [loadingList, setLoadingList] = useState(true); const [docType, setDocType] = useState<'knowledge_base' | 'external_grant'>('knowledge_base'); const fileInputRef = useRef(null); const pollingRef = useRef | null>(null); const tokenParam = token ? `?token=${token}` : ''; /* ── Fetch listy dokumentów ────────────────────────────────────────────── */ const fetchDocs = useCallback(async () => { try { const res = await fetch(`${API}/api/projects/${projectId}/documents${tokenParam}`); if (!res.ok) return; const data = await res.json(); setDocs(data.documents || []); if (data.quota) setQuota(data.quota); } catch { // sieć } finally { setLoadingList(false); } }, [projectId, tokenParam]); /* ── Polling co 3s gdy jest document w trakcie przetwarzania ──────────── */ useEffect(() => { fetchDocs(); }, [fetchDocs]); useEffect(() => { const hasPending = docs.some(d => d.status === 'processing' || d.status === 'uploaded'); if (hasPending && !pollingRef.current) { pollingRef.current = setInterval(fetchDocs, 3000); } else if (!hasPending && pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } return () => { if (pollingRef.current) clearInterval(pollingRef.current); }; }, [docs, fetchDocs]); /* ── Upload ────────────────────────────────────────────────────────────── */ const uploadFile = async (file: File) => { if (!file.name.toLowerCase().endsWith('.pdf')) { toast.error('Obsługiwane są wyłącznie pliki PDF.'); return; } if (file.size > 20 * 1024 * 1024) { toast.error('Plik przekracza limit 20 MB.'); return; } // Sprawdź kwotę po stronie klienta przed wysłaniem if (quota && !quota.can_upload) { toast.error( quota.plan === 'free' ? `Osiągnięto limit ${quota.limit} plików na planie Free. Przejdź na plan Pro.` : `Osiągnięto limit ${quota.limit} plików dla tego projektu.`, { duration: 5000 } ); return; } setUploading(true); const form = new FormData(); form.append('file', file); const urlParams = new URLSearchParams(); if (token) urlParams.append('token', token); urlParams.append('doc_type', docType); try { const res = await fetch( `${API}/api/projects/${projectId}/documents?${urlParams.toString()}`, { method: 'POST', body: form } ); const data = await res.json(); if (res.status === 429) { // Limit planu const msg = typeof data.detail === 'object' ? data.detail.message : data.detail; toast.error(msg || 'Przekroczono limit plików dla tego planu.', { duration: 6000 }); // Odśwież kwotę await fetchDocs(); } else if (!res.ok) { toast.error(data.detail || 'Błąd uploadu.'); } else { toast.success(`📄 "${file.name}" wgrany. Przetwarzanie w toku…`); await fetchDocs(); } } catch { toast.error('Błąd połączenia z serwerem.'); } finally { setUploading(false); } }; const onFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) uploadFile(file); e.target.value = ''; }; const onDrop = (e: React.DragEvent) => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files?.[0]; if (file) uploadFile(file); }; /* ── Re-ingest ─────────────────────────────────────────────────────────── */ const reingest = async (docId: string) => { try { const res = await fetch( `${API}/api/projects/${projectId}/documents/${docId}/reingest${tokenParam}`, { method: 'POST' } ); if (res.ok) { toast.success('Ponowna indeksacja uruchomiona.'); fetchDocs(); } } catch { toast.error('Błąd ponownej indeksacji.'); } }; /* ── Delete ────────────────────────────────────────────────────────────── */ const deleteDoc = async (docId: string, filename: string) => { if (!window.confirm(`Usunąć "${filename}" z projektu?`)) return; try { const res = await fetch( `${API}/api/projects/${projectId}/documents/${docId}${tokenParam}`, { method: 'DELETE' } ); if (res.ok) { toast.success('Dokument usunięty.'); setDocs(d => d.filter(x => x.doc_id !== docId)); } } catch { toast.error('Błąd usuwania.'); } }; /* ── Render ────────────────────────────────────────────────────────────── */ return (
{/* Header */}

Dokumenty i Audyt Zewnętrzny

Wgraj regulaminy do bazy RAG, lub gotowy wniosek z biura doradczego do weryfikacji.

{/* Licznik kwoty */} {quota && (
{quota.current}/{quota.limit} PDF · {quota.plan.toUpperCase()}
)}
{/* Drop Zone */} {quota && !quota.can_upload ? (

Limit pliku osiągnięty ({quota.current}/{quota.limit} · {quota.plan.toUpperCase()})

Usuń stary dokument lub{' '} przejdź na plan Pro {' '}aby dodać więcej plików.

) : (
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={onDrop} onClick={() => !uploading && fileInputRef.current?.click()} style={{ border: `2px dashed ${dragging ? '#a78bfa' : 'rgba(255,255,255,0.1)'}`, borderRadius: 16, padding: '2.5rem 2rem', textAlign: 'center', cursor: uploading ? 'not-allowed' : 'pointer', background: dragging ? 'rgba(139,92,246,0.04)' : 'rgba(255,255,255,0.01)', transition: 'all 0.2s', position: 'relative', }} > {uploading ? (
Wysyłanie pliku…
) : (

Przeciągnij plik PDF lub kliknij aby wybrać

Maksymalnie 20 MB · PDF

)}
)} {/* Typ dokumentu */}
{docType === 'knowledge_base' ? (
Po wgraniu plik jest automatycznie parsowany (LlamaParse → PyPDF) i dzielony na chunki §/Art./Rozdział. Generator AI będzie używał tych danych jako kontekst prawny dla Twojego programu dotacyjnego.
) : (
Wgrywasz gotowy dokument do skontrolowania (Reverse-Audit). AI odczyta całą treść bez dzielenia na chunki i przygotuje wniosek do finalnej kontroli w module Audytu Wniosku.
)} {/* Lista dokumentów */}
{loadingList && (
)} {!loadingList && docs.length === 0 && (

Brak wgranych dokumentów. Wgraj regulamin dotacyjny aby poprawić kontekst AI.
)} {docs.map((doc: any) => (
{/* Ikona */}
{/* Info */}
{doc.filename}
{doc.file_size_bytes && ( {fmtBytes(doc.file_size_bytes)} )} {(doc as any).doc_type === 'external_grant' ? 'Reverse-Audit' : 'Baza RAG'} {doc.chunks_count && doc.status === 'indexed' && ( {doc.chunks_count} chunk{doc.chunks_count !== 1 ? 'ów' : ''} )} {doc.parser_used && doc.status === 'indexed' && ( via {doc.parser_used} )} {doc.uploaded_at && ( {new Date(doc.uploaded_at).toLocaleDateString('pl-PL')} )}
{doc.error_message && (
{doc.error_message.slice(0, 120)}
)}
{/* Akcje */}
{(doc.status === 'error' || doc.status === 'indexed') && ( )}
))}
); }