grantforge-api / frontend-react /src /components /project /DocumentUploadPanel.tsx
GrantForge Bot
Deploy to Hugging Face
afd56bc
/**
* 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>
);
}