Files
ihaleist/src/components/tools/DocumentVault.jsx
T

356 lines
19 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getToken } from '../../services/auth';
import { sessionGuard } from '../../services/sessionGuard';
import { readableDocument, retryDocumentExtraction } from '../../services/storedDocument';
import { documentProcessingLabel } from '../../services/documentProcessing';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../../context/AppContext';
import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt, updateDocumentMeta } from '../../services/account';
import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive, Layers, Package, CheckCircle2, XCircle, CalendarClock } from 'lucide-react';
const KATEGORILER = ['Yasal Belgeler', 'Kalite Belgeleri', 'Mali Belgeler', 'Resmi Kayıtlar', 'Teknik Belgeler', 'Diğer'];
/**
* Gerçek belge deposu — dosyalar sunucudaki kalıcı diske yazılır,
* boyut gerçek bayt cinsindendir ve plan kotası sunucu tarafında uygulanır.
* (Önceden dosya hiç yüklenmiyordu; boyut Math.random ile uyduruluyordu.)
*/
export const DocumentVault = () => {
const { authUser } = useApp();
return <DocumentVaultSession key={`${authUser?.email || 'signed-out'}:${getToken()}`} signedIn={Boolean(authUser)} />;
};
const DocumentVaultSession = ({signedIn}) => {
const live = useRef(true);
const readLifetime = useRef(new AbortController());
const session = useRef(sessionGuard(getToken));
const current = () => live.current && session.current();
useEffect(() => { live.current = true; readLifetime.current = new AbortController(); return () => { live.current = false; readLifetime.current.abort(); }; }, []);
const useOwnedState = (initial) => {
const [value, setValue] = useState(initial);
const setOwned = useCallback((next) => { if (current()) setValue(next); }, []);
return [value, setOwned];
};
const [durum, setDurum] = useOwnedState(null);
const [busy, setBusy] = useOwnedState(false);
const [hata, setHata] = useOwnedState(null);
const [islemeBilgi, setIslemeBilgi] = useOwnedState(null);
const [kategori, setKategori] = useOwnedState(KATEGORILER[0]);
const dosyaRef = useRef(null);
const downloads = useRef(new Set());
const [downloading,setDownloading] = useOwnedState([]);
const indir = async file => {
if (!current() || downloads.current.has(file.id)) return;
downloads.current.add(file.id);setDownloading([...downloads.current]);setHata(null);
try { await downloadDocument(file.id,file.ad,{signal:readLifetime.current.signal}); }
catch(error) { if(!readLifetime.current.signal.aborted)setHata(error.message); }
finally { downloads.current.delete(file.id);setDownloading([...downloads.current]); }
};
const yukle = () => {
if (!signedIn || !current()) return;
setHata(null);
listDocuments().then(setDurum).catch((e) => setHata(e.message));
};
useEffect(yukle, [signedIn]);
const yenidenIsle = async (document) => {
if (!current()) return;
setBusy(true); setHata(null); setIslemeBilgi(null);
try {
const result = await retryDocumentExtraction(document,{signal:readLifetime.current.signal});
setIslemeBilgi(`${document.ad}: ${result.sayi || 0} kalem çıkarıldı.`);
} catch (error) { setHata(error.message); }
finally {
try { if (current()) setDurum(await listDocuments()); }
catch { setHata(previous => [previous, 'Belge listesi yenilenemedi.'].filter(Boolean).join(' ')); }
setBusy(false);
}
};
const dosyaSecildi = async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setBusy(true); setHata(null);
try {
setDurum(await uploadDocument(f, kategori));
} catch (err) {
setHata(err.message);
} finally {
setBusy(false);
if (dosyaRef.current) dosyaRef.current.value = '';
}
};
// Toplu ihale dokümanı: her dosyanın İKN'si METNİNDEN bulunur, kullanıcıya
// sorulmaz; kalemler otomatik havuza yazılır. docx/xlsx/zip sunucuda,
// PDF tarayıcıda (pdfjs) okunur. Onlarca şartnameyi tek seferde atmak için.
const topluRef = useRef(null);
const [toplu, setToplu] = useOwnedState([]); // [{ad, durum, ikn, kalem, hata}]
const topluSecildi = async (e) => {
const dosyalar = [...(e.target.files || [])];
if (!dosyalar.length) return;
setBusy(true); setHata(null);
setToplu(dosyalar.map((f) => ({ ad: f.name, durum: 'bekliyor' })));
const guncelle = (i, veri) => setToplu((o) => o.map((x, j) => (j === i ? { ...x, ...veri } : x)));
for (let i = 0; i < dosyalar.length; i++) {
if (!current()) break;
const f = dosyalar[i];
guncelle(i, { durum: 'yukleniyor' });
try {
if (f.size > 10 * 1024 * 1024) throw new Error('10 MB sınırı');
const pdfGibi = /\.(pdf|txt|md)$/i.test(f.name);
let ikn = null;
let metin = null;
if (pdfGibi) {
const { extractText } = await import('../../services/pdfText');
const { iknCikar } = await import('../../services/kalem');
metin = (await extractText(f)).text;
ikn = iknCikar(metin);
}
if (!current()) break;
const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn, { otoIkn: true, documentType:'ihale' });
if (!current()) break;
ikn = cevap?.ikn || ikn;
let kalem = cevap?.kalem?.sayi || null;
let kalemHata = cevap.processing?.status === 'error' ? cevap.processing.error : null;
if (ikn && !kalem && metin) {
try {
const { kalemCikar } = await import('../../services/kalem');
if (!current()) break;
kalem = (await kalemCikar({ ikn, metin, dosya: f.name, documentId:cevap.id })).sayi || null;
} catch (error) { kalemHata = error.message; }
}
guncelle(i, { durum: 'tamam', ikn, kalem, kalemHata });
setDurum(cevap);
try { if (current()) setDurum(await listDocuments()); }
catch { setHata('Dosya kayıtlı; güncel belge listesi alınamadı. Listeyi yeniden yükleyin.'); }
} catch (err) {
guncelle(i, { durum: 'hata', hata: err.message });
}
}
setBusy(false);
if (topluRef.current) topluRef.current.value = '';
};
// Şirket belgesi süre takibi: imza sirküleri, oda kayıt, borcu yoktur —
// hepsinin süresi dolar, kimse teklif hazırlanana kadar fark etmez.
const sureYaz = async (id, tarih) => {
if (!current()) return;
setHata(null);
try { setDurum(await updateDocumentMeta(id, { sonKullanma: tarih || null })); }
catch (e) { setHata(e.message); }
};
const kalanGun = (b) => {
const value=String(b.son_kullanma || '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date=new Date(value+'T00:00:00Z');
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0,10)!==value) return null;
return Math.ceil((date.getTime() - Date.now()) / 86400000);
};
const gecersizSonKullanma = (b) => Boolean(b.son_kullanma) && kalanGun(b) === null;
const suresiDolan = (durum?.belgeler || []).filter((b) => kalanGun(b) != null && kalanGun(b) < 0);
const yakinda = (durum?.belgeler || []).filter((b) => kalanGun(b) != null && kalanGun(b) >= 0 && kalanGun(b) <= 30);
const sil = async (id) => {
if (!current()) return;
setBusy(true); setHata(null);
try { setDurum(await deleteDocument(id)); }
catch (e) { setHata(e.message); }
finally { setBusy(false); }
};
const k = durum?.kullanim;
const h = durum?.haklar;
const doluluk = h?.bayt ? Math.min(100, Math.round(((k?.bayt || 0) / h.bayt) * 100)) : 0;
return (
<div className="space-y-6 animate-fade-in">
{hata && <button onClick={yukle} className="px-4 py-2 border rounded-lg">Yeniden dene</button>}
<div className="bg-linear-to-r from-slate-900 via-teal-950 to-cyan-900 text-white p-6 rounded-2xl shadow-lg border border-teal-800/40">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-teal-600 flex items-center justify-center shadow-md">
<FolderKanban className="w-6 h-6" />
</div>
<div>
<h2 className="font-extrabold text-xl">Belge Kasası</h2>
<p className="text-xs text-teal-200 mt-0.5">
İhale dosyalarınıza her cihazdan erişin — belgeler sunucuda saklanır, tarayıcınızda değil.
</p>
</div>
</div>
</div>
{/* Kullanım */}
{durum && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-5 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-slate-800 flex items-center gap-1.5">
<HardDrive className="w-3.5 h-3.5 text-teal-600" /> Kullanım
</span>
<span className="text-slate-600 font-semibold">
{k.adet}{h?.adet != null ? ` / ${h.adet}` : ''} belge · {formatBayt(k.bayt)}
{h?.bayt != null && ` / ${formatBayt(h.bayt)}`}
</span>
</div>
{h?.bayt != null && (
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden">
<div className={`h-2 rounded-full transition-all ${doluluk > 90 ? 'bg-red-500' : doluluk > 70 ? 'bg-amber-500' : 'bg-teal-600'}`}
style={{ width: `${doluluk}%` }} />
</div>
)}
</div>
)}
{/* Yükleme */}
<div className="bg-white rounded-2xl border-2 border-dashed border-teal-300 p-6 text-center space-y-3">
<div className="w-12 h-12 rounded-2xl bg-teal-50 text-teal-600 flex items-center justify-center mx-auto">
<Upload className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-sm text-slate-800">Belge Yükleyin</h3>
<p className="text-xs text-slate-500">İmza sirküleri, sertifikalar, vergi levhası… (en fazla 10 MB)</p>
</div>
<div className="flex justify-center gap-2 flex-wrap">
<select value={kategori} onChange={(e) => setKategori(e.target.value)}
className="bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs">
{KATEGORILER.map((c) => <option key={c}>{c}</option>)}
</select>
<label className={`font-extrabold text-xs px-5 py-2.5 rounded-xl shadow-md flex items-center gap-2 ${
busy ? 'bg-slate-300 text-slate-500 cursor-not-allowed' : 'bg-teal-600 hover:bg-teal-500 text-white cursor-pointer'
}`}>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
{busy ? 'Yükleniyor…' : 'Dosya Seç'}
<input ref={dosyaRef} type="file" className="hidden" disabled={busy} onChange={dosyaSecildi} />
</label>
</div>
</div>
{/* Süre uyarısı */}
{(suresiDolan.length > 0 || yakinda.length > 0) && (
<div className={`rounded-2xl border-2 p-4 flex gap-3 ${suresiDolan.length ? 'bg-red-50 border-red-300' : 'bg-amber-50 border-amber-300'}`}>
<CalendarClock className={`w-5 h-5 shrink-0 mt-0.5 ${suresiDolan.length ? 'text-red-600' : 'text-amber-600'}`} />
<div className="text-xs leading-relaxed">
{suresiDolan.length > 0 && (
<p className="font-bold text-red-900">Süresi dolmuş: {suresiDolan.map((b) => b.ad).join(', ')}</p>
)}
{yakinda.length > 0 && (
<p className="font-semibold text-amber-900">30 gün içinde doluyor: {yakinda.map((b) => `${b.ad} (${kalanGun(b)} gün)`).join(', ')}</p>
)}
<p className="text-slate-600 mt-1">Teklif zarfına süresi dolmuş belge girerse teklif değerlendirme dışı kalır. Yenileyip yeni tarihi girin.</p>
</div>
</div>
)}
{/* Toplu ihale dokümanı yükleme */}
<div className="bg-linear-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-2xl p-5 space-y-3">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-400 text-slate-950 flex items-center justify-center shrink-0">
<Layers className="w-5 h-5" />
</div>
<div className="min-w-0">
<h3 className="font-black text-sm text-white">İhale Dokümanlarını Toplu Yükle</h3>
<p className="text-[11px] text-slate-300 leading-relaxed">
EKAP'tan indirdiğiniz şartname/doküman dosyalarını (PDF, DOCX, XLSX, ZIP) hepsini birden seçin.
Her dosyanın <strong className="text-white">İKN'si metninden bulunur</strong>, ihaleye bağlanır ve
teklif cetveli kalemleri otomatik çıkarılır — o ihale herkesin kartında kalemleriyle görünür.
</p>
</div>
</div>
<label className={`inline-flex font-black text-xs px-5 py-2.5 rounded-xl items-center gap-2 ${
busy ? 'bg-slate-600 text-slate-300 cursor-not-allowed' : 'bg-amber-400 hover:bg-amber-300 text-slate-950 cursor-pointer'
}`}>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
{busy ? 'İşleniyor…' : 'Dosyaları Seç (çoklu)'}
<input ref={topluRef} type="file" multiple accept=".pdf,.docx,.xlsx,.zip,.txt" className="hidden" disabled={busy} onChange={topluSecildi} />
</label>
{toplu.length > 0 && (
<ul className="space-y-1">
{toplu.map((t, i) => (
<li key={i} className="bg-black/30 rounded-lg px-3 py-2 flex items-center gap-2 text-[11px]">
{t.durum === 'tamam' ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
: t.durum === 'hata' ? <XCircle className="w-3.5 h-3.5 text-red-400 shrink-0" />
: <Loader2 className={`w-3.5 h-3.5 text-slate-400 shrink-0 ${t.durum === 'yukleniyor' ? 'animate-spin' : ''}`} />}
<span className="text-slate-200 truncate flex-1">{t.ad}</span>
{t.durum === 'tamam' && (
<span className="shrink-0 flex items-center gap-2">
{t.ikn
? <span className="font-mono font-bold text-amber-300">{t.ikn}</span>
: <span className="text-amber-200/80">İKN bulunamadı — ihaleye bağlanmadı</span>}
{t.kalemHata && <span role="alert" className="text-amber-200">Dosya kayıtlı; kalem aktarımı başarısız: {t.kalemHata}</span>}
{t.kalem ? <span className="text-emerald-300 font-bold flex items-center gap-1"><Package className="w-3 h-3" />{t.kalem} kalem</span> : null}
</span>
)}
{t.durum === 'hata' && <span className="text-red-300 shrink-0">{t.hata}</span>}
</li>
))}
</ul>
)}
</div>
{hata && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
<p className="text-xs text-red-800 leading-relaxed">{hata}</p>
</div>
)}
{/* Liste */}
{!durum && !hata && !hata ? (
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
) : durum.belgeler.length === 0 ? (
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">
<FileText className="w-8 h-8 text-slate-300 mx-auto mb-3" />
<p className="text-sm font-bold text-slate-500">Henüz belge yüklemediniz</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{islemeBilgi && <p role="status" className="text-xs text-slate-700">{islemeBilgi}</p>}
{durum.belgeler.map((d) => (
<div key={d.id} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-bold text-slate-900 truncate">{d.ad}</p>
<p className="text-[11px] text-slate-500 mt-0.5">
{d.tur === 'ihale' ? 'İhale dokümanı' : 'Özel firma belgesi'} · {d.kategori || 'Diğer'} · <strong>{formatBayt(d.boyut)}</strong>
</p>
<p className="text-[10px] text-slate-400 mt-0.5">
{new Date(d.olusturma).toLocaleString('tr-TR')}
<span className="block">{documentProcessingLabel(d.isleme)}</span>
</p>
<label className="flex items-center gap-1.5 mt-1.5 text-[10px] text-slate-500">
<CalendarClock className={`w-3 h-3 ${kalanGun(d) != null && kalanGun(d) < 0 ? 'text-red-600' : kalanGun(d) != null && kalanGun(d) <= 30 ? 'text-amber-600' : 'text-slate-400'}`} />
<span>Son kullanma</span>
<input type="date" value={d.son_kullanma || ''} onChange={(e) => sureYaz(d.id, e.target.value)}
className="border border-slate-200 rounded px-1 py-0.5 text-[10px]" />
{kalanGun(d) != null && (
<span className={`font-bold ${kalanGun(d) < 0 ? 'text-red-700' : kalanGun(d) <= 30 ? 'text-amber-700' : 'text-emerald-700'}`}>
{kalanGun(d) < 0 ? `${-kalanGun(d)} gün önce doldu` : `${kalanGun(d)} gün kaldı`}
</span>
)}
{gecersizSonKullanma(d) && <span role="alert" className="font-bold text-red-700">Tarih doğrulanmalı</span>}
</label>
</div>
<div className="flex flex-wrap items-center gap-1">
{d.tur === 'ihale' && d.ikn && readableDocument(d.ad) && (
<button onClick={() => yenidenIsle(d)} disabled={busy || !d.erisilebilir}
aria-label={`${d.ad} için kalem çıkarımını yeniden dene`}
className="text-[10px] text-blue-700 font-bold disabled:opacity-40">
Kalemleri yeniden çıkar
</button>
)}
<button onClick={() => indir(d)} disabled={downloading.includes(d.id) || d.erisilebilir===false}
aria-label={`${d.ad} indir`} aria-busy={downloading.includes(d.id)}
className="text-slate-400 hover:text-teal-600 p-1 disabled:opacity-40" title="İndir">
{downloading.includes(d.id) ? <span role="status" className="text-xs">İndiriliyor…</span> : <Download className="w-4 h-4" />}
</button>
<button onClick={() => sil(d.id)} disabled={busy} className="text-slate-400 hover:text-red-500 p-1" title="Sil">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};