/** * DocumentUploadPanel — Upload PDF do projektu + RAG re-ingest * * Auth: always via apiClient (Clerk Bearer from ApiInterceptor). * No query-string tokens (P0 security). */ 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'; import { apiClient, resolveApiBaseUrl } from '../../api/client'; 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; /** @deprecated ignored — auth via Clerk/apiClient only */ token?: string; } // 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 }: 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); /* ── Fetch listy dokumentów ────────────────────────────────────────────── */ const fetchDocs = useCallback(async () => { try { const { data } = await apiClient.get(`/api/projects/${projectId}/documents`); setDocs(data.documents || []); if (data.quota) setQuota(data.quota); } catch { // sieć / 401 handled by interceptor } finally { setLoadingList(false); } }, [projectId]); /* ── 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; } 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); try { const { data, status } = await apiClient.post( `/api/projects/${projectId}/documents`, form, { params: { doc_type: docType }, headers: { 'Content-Type': 'multipart/form-data' }, validateStatus: (s) => s < 500, } ); if (status === 429) { const msg = typeof data?.detail === 'object' ? data.detail.message : data?.detail; toast.error(msg || 'Przekroczono limit plików dla tego planu.', { duration: 6000 }); await fetchDocs(); } else if (status >= 400) { toast.error( (typeof data?.detail === 'string' ? data.detail : data?.detail?.message) || '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 { await apiClient.post(`/api/projects/${projectId}/documents/${docId}/reingest`); 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 { await apiClient.delete(`/api/projects/${projectId}/documents/${docId}`); toast.success('Dokument usunięty.'); setDocs(d => d.filter(x => x.doc_id !== docId)); } catch { toast.error('Błąd usuwania.'); } }; /* ── Render ────────────────────────────────────────────────────────────── */ const baseHint = resolveApiBaseUrl(import.meta.env.VITE_API_URL); return (

Baza wiedzy projektu (RAG)

PDF regulaminów i dokumentów firmowych — indeksowane do dopasowań i generatora.

{quota && (
Limit: {quota.current}/{quota.limit} plików (plan {quota.plan})
)}
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={onDrop} onClick={() => fileInputRef.current?.click()} style={{ border: `2px dashed ${dragging ? '#3b82f6' : '#cbd5e1'}`, borderRadius: 12, padding: '2rem', textAlign: 'center', background: dragging ? 'rgba(59,130,246,0.06)' : '#f8fafc', cursor: uploading ? 'wait' : 'pointer', }} > {uploading ? ( ) : ( )}

{uploading ? 'Wysyłanie…' : 'Upuść PDF lub kliknij, aby wybrać'}

Max 20 MB · API: {baseHint || 'relative'}

{loadingList ? (
Ładowanie listy…
) : docs.length === 0 ? (
Brak wgranych dokumentów. Dodaj regulamin programu dla wiarygodnej generacji.
) : (
    {docs.map((d) => (
  • {d.filename}
    {fmtBytes(d.file_size_bytes || 0)} {d.chunks_count != null ? ` · ${d.chunks_count} chunków` : ''} {d.parser_used ? ` · ${d.parser_used}` : ''}
    {d.error_message && (
    {d.error_message}
    )}
  • ))}
)}
); }