Spaces:
Running
Running
| /** | |
| * 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 ( | |
| <span style={{ | |
| display: 'inline-flex', alignItems: 'center', gap: 4, | |
| padding: '2px 8px', borderRadius: 6, fontSize: '0.72rem', | |
| fontWeight: 700, background: bg, color, | |
| }}> | |
| <Icon size={11} style={spinning ? { animation: 'spin 1s linear infinite' } : {}} /> | |
| {label} | |
| </span> | |
| ); | |
| } | |
| 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<ProjectDocument[]>([]); | |
| const [quota, setQuota] = useState<QuotaInfo | null>(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<HTMLInputElement>(null); | |
| const pollingRef = useRef<ReturnType<typeof setInterval> | 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<HTMLInputElement>) => { | |
| 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 ( | |
| <div style={{ maxWidth: 820, margin: '2rem auto', padding: '0 1rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> | |
| {/* Header */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> | |
| <div style={{ width: 44, height: 44, borderRadius: 12, background: 'rgba(139,92,246,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |
| <Database size={20} color="#a78bfa" /> | |
| </div> | |
| <div> | |
| <h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, color: 'var(--text-primary)' }}> | |
| Dokumenty i Audyt Zewnętrzny | |
| </h2> | |
| <p style={{ margin: '0.2rem 0 0', color: 'var(--text-muted)', fontSize: '0.83rem' }}> | |
| Wgraj regulaminy do bazy RAG, lub gotowy wniosek z biura doradczego do weryfikacji. | |
| </p> | |
| </div> | |
| <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.5rem' }}> | |
| {/* Licznik kwoty */} | |
| {quota && ( | |
| <div style={{ | |
| padding: '0.3rem 0.75rem', | |
| borderRadius: 8, | |
| background: quota.can_upload ? 'rgba(16,185,129,0.08)' : 'rgba(239,68,68,0.1)', | |
| border: `1px solid ${quota.can_upload ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'}`, | |
| fontSize: '0.75rem', | |
| fontWeight: 700, | |
| color: quota.can_upload ? '#34d399' : '#f87171', | |
| whiteSpace: 'nowrap', | |
| }}> | |
| {quota.current}/{quota.limit} PDF · {quota.plan.toUpperCase()} | |
| </div> | |
| )} | |
| <button | |
| onClick={fetchDocs} | |
| style={{ background: 'transparent', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, padding: '0.4rem 0.75rem', color: 'var(--text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.8rem' }} | |
| > | |
| <RefreshCw size={13} /> Odśwież | |
| </button> | |
| </div> | |
| </div> | |
| {/* Drop Zone */} | |
| {quota && !quota.can_upload ? ( | |
| <div style={{ | |
| border: '2px dashed rgba(239,68,68,0.25)', | |
| borderRadius: 16, | |
| padding: '2.5rem 2rem', | |
| textAlign: 'center', | |
| background: 'rgba(239,68,68,0.03)', | |
| }}> | |
| <XCircle size={36} color="#f87171" style={{ marginBottom: 12, opacity: 0.6 }} /> | |
| <p style={{ margin: 0, fontWeight: 700, color: '#f87171', fontSize: '0.95rem' }}> | |
| Limit pliku osiągnięty ({quota.current}/{quota.limit} · {quota.plan.toUpperCase()}) | |
| </p> | |
| <p style={{ margin: '0.5rem 0 0', color: 'var(--text-muted)', fontSize: '0.8rem' }}> | |
| Usuń stary dokument lub{' '} | |
| <a href="/cennik" style={{ color: '#a78bfa', textDecoration: 'underline' }}>przejdź na plan Pro</a> | |
| {' '}aby dodać więcej plików. | |
| </p> | |
| </div> | |
| ) : ( | |
| <div | |
| onDragOver={e => { 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 ? ( | |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}> | |
| <Loader2 size={36} color="#a78bfa" style={{ animation: 'spin 1s linear infinite' }} /> | |
| <span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Wysyłanie pliku…</span> | |
| </div> | |
| ) : ( | |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}> | |
| <div style={{ width: 52, height: 52, borderRadius: 12, background: 'rgba(139,92,246,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |
| <Upload size={24} color="#a78bfa" /> | |
| </div> | |
| <div> | |
| <p style={{ margin: 0, fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.95rem' }}> | |
| Przeciągnij plik PDF lub kliknij aby wybrać | |
| </p> | |
| <p style={{ margin: '0.3rem 0 0', color: 'var(--text-muted)', fontSize: '0.78rem' }}> | |
| Maksymalnie 20 MB · PDF | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".pdf,application/pdf" | |
| style={{ display: 'none' }} | |
| onChange={onFileChange} | |
| /> | |
| </div> | |
| )} | |
| {/* Typ dokumentu */} | |
| <div style={{ display: 'flex', gap: '1rem', marginBottom: '0.5rem' }}> | |
| <button | |
| onClick={() => setDocType('knowledge_base')} | |
| style={{ | |
| flex: 1, padding: '1rem', borderRadius: 12, border: `2px solid ${docType === 'knowledge_base' ? 'var(--accent-blue)' : 'rgba(255,255,255,0.05)'}`, | |
| background: docType === 'knowledge_base' ? 'rgba(59,130,246,0.05)' : 'rgba(255,255,255,0.02)', cursor: 'pointer', textAlign: 'left', transition: '0.2s' | |
| }} | |
| > | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}> | |
| <Database size={16} color={docType === 'knowledge_base' ? 'var(--accent-blue)' : 'var(--text-secondary)'} /> | |
| <strong style={{ color: docType === 'knowledge_base' ? 'var(--accent-blue)' : 'var(--text-primary)' }}>Baza Wiedzy (RAG)</strong> | |
| </div> | |
| <div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Regulaminy, FAQs, Instrukcje wypełniania.</div> | |
| </button> | |
| <button | |
| onClick={() => setDocType('external_grant')} | |
| style={{ | |
| flex: 1, padding: '1rem', borderRadius: 12, border: `2px solid ${docType === 'external_grant' ? '#a78bfa' : 'rgba(255,255,255,0.05)'}`, | |
| background: docType === 'external_grant' ? 'rgba(139,92,246,0.05)' : 'rgba(255,255,255,0.02)', cursor: 'pointer', textAlign: 'left', transition: '0.2s' | |
| }} | |
| > | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}> | |
| <FileSearch size={16} color={docType === 'external_grant' ? '#a78bfa' : 'var(--text-secondary)'} /> | |
| <strong style={{ color: docType === 'external_grant' ? '#a78bfa' : 'var(--text-primary)' }}>Gotowy wniosek (Reverse-Audit)</strong> | |
| </div> | |
| <div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Wniosek wypełniony poza platformą (do audytu).</div> | |
| </button> | |
| </div> | |
| {docType === 'knowledge_base' ? ( | |
| <div style={{ background: 'rgba(59,130,246,0.06)', border: '1px solid rgba(59,130,246,0.15)', borderRadius: 10, padding: '0.75rem 1rem', fontSize: '0.8rem', color: '#93c5fd', display: 'flex', gap: 8, alignItems: 'flex-start' }}> | |
| <AlertTriangle size={14} style={{ flexShrink: 0, marginTop: 2 }} /> | |
| <span> | |
| 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. | |
| </span> | |
| </div> | |
| ) : ( | |
| <div style={{ background: 'rgba(139,92,246,0.06)', border: '1px solid rgba(139,92,246,0.15)', borderRadius: 10, padding: '0.75rem 1rem', fontSize: '0.8rem', color: '#c4b5fd', display: 'flex', gap: 8, alignItems: 'flex-start' }}> | |
| <FileSearch size={14} style={{ flexShrink: 0, marginTop: 2 }} /> | |
| <span> | |
| 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. | |
| </span> | |
| </div> | |
| )} | |
| {/* Lista dokumentów */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> | |
| {loadingList && ( | |
| <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}> | |
| <Loader2 size={24} style={{ animation: 'spin 1s linear infinite' }} /> | |
| </div> | |
| )} | |
| {!loadingList && docs.length === 0 && ( | |
| <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)', fontSize: '0.88rem' }}> | |
| <FileText size={36} style={{ opacity: 0.2, marginBottom: 8 }} /> | |
| <br />Brak wgranych dokumentów. Wgraj regulamin dotacyjny aby poprawić kontekst AI. | |
| </div> | |
| )} | |
| {docs.map((doc: any) => ( | |
| <div key={doc.doc_id} style={{ | |
| background: 'rgba(255,255,255,0.025)', | |
| border: `1px solid ${doc.status === 'error' ? 'rgba(239,68,68,0.2)' : 'rgba(255,255,255,0.06)'}`, | |
| borderRadius: 12, | |
| padding: '1rem 1.25rem', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '1rem', | |
| }}> | |
| {/* Ikona */} | |
| <div style={{ width: 38, height: 38, borderRadius: 8, background: 'rgba(255,255,255,0.04)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> | |
| <FileText size={18} color={doc.status === 'indexed' ? '#10b981' : '#94a3b8'} /> | |
| </div> | |
| {/* Info */} | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: '0.88rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> | |
| {doc.filename} | |
| </div> | |
| <div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap', marginTop: 4, alignItems: 'center' }}> | |
| <StatusBadge status={doc.status} /> | |
| {doc.file_size_bytes && ( | |
| <span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>{fmtBytes(doc.file_size_bytes)}</span> | |
| )} | |
| <span style={{ fontSize: '0.72rem', color: (doc as any).doc_type === 'external_grant' ? '#a78bfa' : '#3b82f6', fontWeight: 600, padding: '2px 6px', borderRadius: '4px', background: 'rgba(255,255,255,0.05)' }}> | |
| {(doc as any).doc_type === 'external_grant' ? 'Reverse-Audit' : 'Baza RAG'} | |
| </span> | |
| {doc.chunks_count && doc.status === 'indexed' && ( | |
| <span style={{ fontSize: '0.72rem', color: '#6366f1' }}> | |
| {doc.chunks_count} chunk{doc.chunks_count !== 1 ? 'ów' : ''} | |
| </span> | |
| )} | |
| {doc.parser_used && doc.status === 'indexed' && ( | |
| <span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>via {doc.parser_used}</span> | |
| )} | |
| {doc.uploaded_at && ( | |
| <span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}> | |
| {new Date(doc.uploaded_at).toLocaleDateString('pl-PL')} | |
| </span> | |
| )} | |
| </div> | |
| {doc.error_message && ( | |
| <div style={{ fontSize: '0.75rem', color: '#fca5a5', marginTop: 4 }}> | |
| {doc.error_message.slice(0, 120)} | |
| </div> | |
| )} | |
| </div> | |
| {/* Akcje */} | |
| <div style={{ display: 'flex', gap: 6, flexShrink: 0 }}> | |
| {(doc.status === 'error' || doc.status === 'indexed') && ( | |
| <button | |
| onClick={() => reingest(doc.doc_id)} | |
| title="Ponowna indeksacja" | |
| style={{ background: 'rgba(99,102,241,0.1)', border: '1px solid rgba(99,102,241,0.2)', borderRadius: 7, padding: '0.4rem 0.6rem', cursor: 'pointer', color: '#818cf8' }} | |
| > | |
| <RefreshCw size={13} /> | |
| </button> | |
| )} | |
| <button | |
| onClick={() => deleteDoc(doc.doc_id, doc.filename)} | |
| title="Usuń dokument" | |
| style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)', borderRadius: 7, padding: '0.4rem 0.6rem', cursor: 'pointer', color: '#f87171' }} | |
| > | |
| <Trash2 size={13} /> | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <style>{` | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| `}</style> | |
| </div> | |
| ); | |
| } | |