Spaces:
Running
Running
| /** | |
| * 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 ( | |
| <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 }: 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); | |
| /* ── 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<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 { | |
| 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 ( | |
| <div style={{ maxWidth: 820, margin: '2rem auto', padding: '0 1rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> | |
| <Database size={20} color="#3b82f6" /> | |
| <div> | |
| <h3 style={{ margin: 0, fontSize: '1.05rem' }}>Baza wiedzy projektu (RAG)</h3> | |
| <p style={{ margin: '4px 0 0', fontSize: '0.8rem', color: '#64748b' }}> | |
| PDF regulaminów i dokumentów firmowych — indeksowane do dopasowań i generatora. | |
| </p> | |
| </div> | |
| </div> | |
| {quota && ( | |
| <div style={{ | |
| fontSize: '0.8rem', color: quota.can_upload ? '#64748b' : '#b45309', | |
| padding: '8px 12px', borderRadius: 8, | |
| background: quota.can_upload ? 'rgba(100,116,139,0.08)' : 'rgba(245,158,11,0.12)', | |
| }}> | |
| Limit: {quota.current}/{quota.limit} plików (plan {quota.plan}) | |
| </div> | |
| )} | |
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> | |
| <button | |
| type="button" | |
| onClick={() => setDocType('knowledge_base')} | |
| style={{ | |
| padding: '6px 12px', borderRadius: 8, border: '1px solid #e2e8f0', | |
| background: docType === 'knowledge_base' ? '#eff6ff' : '#fff', | |
| fontWeight: 600, fontSize: '0.8rem', cursor: 'pointer', | |
| }} | |
| > | |
| <FileSearch size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} /> | |
| Baza wiedzy | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setDocType('external_grant')} | |
| style={{ | |
| padding: '6px 12px', borderRadius: 8, border: '1px solid #e2e8f0', | |
| background: docType === 'external_grant' ? '#eff6ff' : '#fff', | |
| fontWeight: 600, fontSize: '0.8rem', cursor: 'pointer', | |
| }} | |
| > | |
| <FileText size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} /> | |
| Wniosek / regulamin zewnętrzny | |
| </button> | |
| </div> | |
| <div | |
| onDragOver={(e) => { 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', | |
| }} | |
| > | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="application/pdf,.pdf" | |
| style={{ display: 'none' }} | |
| onChange={onFileChange} | |
| /> | |
| {uploading ? ( | |
| <Loader2 size={28} style={{ animation: 'spin 1s linear infinite', color: '#3b82f6' }} /> | |
| ) : ( | |
| <Upload size={28} color="#64748b" /> | |
| )} | |
| <p style={{ margin: '12px 0 0', fontWeight: 600 }}> | |
| {uploading ? 'Wysyłanie…' : 'Upuść PDF lub kliknij, aby wybrać'} | |
| </p> | |
| <p style={{ margin: '6px 0 0', fontSize: '0.75rem', color: '#94a3b8' }}> | |
| Max 20 MB · API: {baseHint || 'relative'} | |
| </p> | |
| </div> | |
| {loadingList ? ( | |
| <div style={{ color: '#94a3b8', fontSize: '0.85rem' }}>Ładowanie listy…</div> | |
| ) : docs.length === 0 ? ( | |
| <div style={{ | |
| display: 'flex', alignItems: 'center', gap: 8, | |
| padding: 12, borderRadius: 8, background: 'rgba(245,158,11,0.08)', color: '#92400e', | |
| fontSize: '0.85rem', | |
| }}> | |
| <AlertTriangle size={16} /> | |
| Brak wgranych dokumentów. Dodaj regulamin programu dla wiarygodnej generacji. | |
| </div> | |
| ) : ( | |
| <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}> | |
| {docs.map((d) => ( | |
| <li | |
| key={d.doc_id} | |
| style={{ | |
| display: 'flex', alignItems: 'center', gap: 12, | |
| padding: '10px 12px', borderRadius: 10, border: '1px solid #e2e8f0', background: '#fff', | |
| }} | |
| > | |
| <FileText size={18} color="#64748b" /> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ fontWeight: 600, fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> | |
| {d.filename} | |
| </div> | |
| <div style={{ fontSize: '0.72rem', color: '#94a3b8' }}> | |
| {fmtBytes(d.file_size_bytes || 0)} | |
| {d.chunks_count != null ? ` · ${d.chunks_count} chunków` : ''} | |
| {d.parser_used ? ` · ${d.parser_used}` : ''} | |
| </div> | |
| {d.error_message && ( | |
| <div style={{ fontSize: '0.72rem', color: '#ef4444' }}>{d.error_message}</div> | |
| )} | |
| </div> | |
| <StatusBadge status={d.status} /> | |
| <button type="button" title="Re-ingest" onClick={() => reingest(d.doc_id)} | |
| style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 4 }}> | |
| <RefreshCw size={15} color="#64748b" /> | |
| </button> | |
| <button type="button" title="Usuń" onClick={() => deleteDoc(d.doc_id, d.filename)} | |
| style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 4 }}> | |
| <Trash2 size={15} color="#ef4444" /> | |
| </button> | |
| </li> | |
| ))} | |
| </ul> | |
| )} | |
| </div> | |
| ); | |
| } | |