grantforge-api / frontend-react /src /components /project /DocumentUploadPanel.tsx
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
13.1 kB
/**
* 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>
);
}