diff --git a/server/index.js b/server/index.js index d4212f2..a0692b1 100644 --- a/server/index.js +++ b/server/index.js @@ -22,6 +22,8 @@ import { loginWithGoogle, userFromAuthHeader } from './auth.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs'; import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs'; +import { kalemleriCikar } from './kalem-cikar.mjs'; +import * as kalemStore from './kalem-store.mjs'; import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.mjs'; import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs'; import * as store from './user-store.mjs'; @@ -630,6 +632,20 @@ async function tendersPayload(force = false) { for (const t of c.tenders) ilanTazele(t); // isYeni + "x saat önce" gün değişse de doğru c.todayCount = bugunEklenenSayisi(c.tenders); + // Ortak kalem havuzu: bir kullanıcı dokümanı yüklediyse o ihalenin teklif + // cetveli kalemleri herkesin kartında görünsün. + try { + const ozet = kalemStore.kalemOzetleri(); + if (ozet.size) { + for (const t of [...c.tenders, ...dt.tenders, ...hist.tenders]) { + const k = t.ikn && ozet.get(t.ikn); + if (k) { t.kalemSayisi = k.sayi; t.kalemler = k.ilkler; } + } + } + } catch (e) { + console.warn('[kalem] özet uygulanamadı:', e.message); + } + return { success: true, source: sources.join(' + '), @@ -704,6 +720,41 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { tarayiciVar: await browserAvailable() }); } + // --- Ortak kalem havuzu --- + // PDF metni tarayıcıda çıkarılıyor; istemci buraya gönderir. + if (url.pathname === '/api/kalem/cikar' && req.method === 'POST') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + try { + const body = await readBody(req, 4 * 1024 * 1024); + if (!body.ikn) return json(res, 400, { success: false, error: 'ikn gerekli' }); + const { kalemler, yontem } = kalemleriCikar(body.metin || ''); + if (!kalemler.length) { + return json(res, 200, { success: true, kalemler: [], yontem, not: 'Metinde teklif cetveli tablosu bulunamadı.' }); + } + kalemStore.kalemYaz(String(body.ikn), kalemler, { + dosya: body.dosya ? String(body.dosya).slice(0, 200) : null, yontem, ekleyen: user.email, + }); + return json(res, 200, { success: true, kalemler, yontem, sayi: kalemler.length }); + } catch (e) { + return json(res, 400, { success: false, error: e.message }); + } + } + if (url.pathname === '/api/kalem') { + const ikn = url.searchParams.get('ikn'); + if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' }); + return json(res, 200, { success: true, ...kalemStore.kalemOku(ikn) }); + } + if (url.pathname === '/api/kalem/ara') { + const q = url.searchParams.get('q') || ''; + if (q.trim().length < 2) return json(res, 400, { success: false, error: 'en az 2 karakter' }); + const sonuc = kalemStore.kalemAra(q); + return json(res, 200, { success: true, adet: sonuc.length, sonuclar: sonuc }); + } + if (url.pathname === '/api/health/kalem') { + return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() }); + } + // --- KİK kurul kararları --- if (url.pathname === '/api/kik/search') { try { @@ -792,7 +843,24 @@ const server = http.createServer(async (req, res) => { sonKullanma: body.sonKullanma || null, ikn: body.ikn ? String(body.ikn).slice(0, 40) : null, // ihaleye bağlı belge }); - return json(res, 200, { success: true, id, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) }); + // Yükleme anında otomatik kalem çıkarımı — AI kullanmaz, kullanıcıdan + // tıklama beklemez. PDF'ler tarayıcıda çözüldüğü için istemci + // metni /api/kalem/cikar'a gönderir. + let kalemBilgi = null; + if (body.ikn && sunucuOkuyabilir(body.ad)) { + try { + const metin = arsivMetni(String(body.ad), buf); + const { kalemler, yontem } = kalemleriCikar(metin); + if (kalemler.length) { + kalemStore.kalemYaz(String(body.ikn), kalemler, { dosya: String(body.ad), yontem, ekleyen: user.email }); + kalemBilgi = { sayi: kalemler.length, yontem }; + console.log(`[kalem] ${body.ikn} → ${kalemler.length} kalem (${yontem}, ${body.ad})`); + } + } catch (e) { + console.warn('[kalem] çıkarılamadı:', e.message); + } + } + return json(res, 200, { success: true, id, kalem: kalemBilgi, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) }); } catch (e) { return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null }); } diff --git a/server/kalem-cikar.mjs b/server/kalem-cikar.mjs new file mode 100644 index 0000000..e7fa045 --- /dev/null +++ b/server/kalem-cikar.mjs @@ -0,0 +1,178 @@ +/** + * Birim fiyat teklif cetveli kalem çıkarımı. + * + * NEDEN: Firmalar ihalenin sadece başlığına bakarak kendi ürünlerinin içinde + * olup olmadığını göremiyor ("148 KALEM KIRTASİYE MALZEME ALIMI"). Kalem + * listesi EKAP'ın API'sinde yok — yalnızca ihale dokümanının içinde. Kullanıcı + * dokümanı yükleyince buradan ayrıştırılıp İKN'ye bağlanıyor; o ihale artık + * herkesin kartında kalemleriyle görünüyor. + * + * GİRDİ BİÇİMLERİ: + * - xlsx/docx → office-text.mjs tabloları " | " ayraçlı verir (asıl hedef) + * - pdf → tarayıcıda pdfjs düz metin verir, ayraç yok (sezgisel yol) + */ + +import { normalizeTr } from './siniflandir.mjs'; + +const MAX_KALEM = 500; +const MAX_AD = 300; + +// Teklif cetvellerinde geçen birimler. "test" tıbbi kitlerde kalem miktarı +// olarak kullanılıyor (ör. "25.000 TEST"), kullanıcı özellikle bunu istedi. +const BIRIMLER = [ + 'adet', 'test', 'kutu', 'paket', 'kalem', 'takım', 'takim', 'çift', 'cift', + 'koli', 'flakon', 'tablet', 'ampul', 'şişe', 'sise', 'rulo', 'top', 'poşet', 'poset', + 'kg', 'gr', 'gram', 'ton', 'lt', 'litre', 'ml', 'mg', 'cc', + 'metre', 'mt', 'm', 'm2', 'm3', 'cm', 'mm', + 'saat', 'gün', 'gun', 'ay', 'yıl', 'yil', 'kişi', 'kisi', 'sefer', 'öğün', 'ogun', + 'kwh', 'kva', 'litre/gün', +]; +const BIRIM_RE = new RegExp(`^(?:${BIRIMLER.map((b) => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`, 'i'); + +// Kalem OLMAYAN satırlar — toplam/ara toplam/KDV vb. +const ATLA_RE = /^(genel\s+)?(ara\s+)?toplam|^kdv|^tutar$|^teklif\s+edilen|^i̇?mza|^kaşe|^sıra\s*no?$|^açıklama$/i; + +const bosluk = (s) => String(s ?? '').replace(/\s+/g, ' ').trim(); + +/** "1.500,50" → 1500.5 ; "25000" → 25000 ; okunamazsa null */ +export function sayiyaCevir(s) { + const t = bosluk(s).replace(/[^\d.,-]/g, ''); + if (!t) return null; + // Türkçe biçim: nokta binlik, virgül ondalık + const norm = t.includes(',') ? t.replace(/\./g, '').replace(',', '.') : t.replace(/\.(?=\d{3}\b)/g, ''); + const n = Number(norm); + return Number.isFinite(n) ? n : null; +} + +const HEADER_AD = /(mal|hizmet|i̇?ş|is)\s*(kalemi|kalem)?|kalem\s*(ad|tanım)|açıklama|aciklama|cinsi|malzeme\s*ad|ürün|urun/i; +const HEADER_MIKTAR = /miktar/i; +const HEADER_BIRIM = /birim/i; +const HEADER_SIRA = /sıra|sira|s\.?\s*no|^no$/i; + +/** Bir " | " satırının başlık satırı olup olmadığına ve sütun eşlemesine karar verir. */ +function basligiCoz(hucreler) { + const idx = { sira: -1, ad: -1, miktar: -1, birim: -1 }; + hucreler.forEach((h, i) => { + const t = bosluk(h); + if (!t) return; + // "Teklif edilen birim fiyat" bir birim sütunu DEĞİL — önce eleriz. + const birimFiyat = /birim\s*fiyat/i.test(t); + if (idx.miktar < 0 && HEADER_MIKTAR.test(t) && !birimFiyat) idx.miktar = i; + else if (idx.birim < 0 && HEADER_BIRIM.test(t) && !birimFiyat) idx.birim = i; + else if (idx.ad < 0 && HEADER_AD.test(t) && !birimFiyat) idx.ad = i; + else if (idx.sira < 0 && HEADER_SIRA.test(t)) idx.sira = i; + }); + // Ad sütunu ve (miktar ya da birim) yoksa bu bir kalem tablosu başlığı değil. + if (idx.ad < 0) return null; + if (idx.miktar < 0 && idx.birim < 0) return null; + return idx; +} + +function hucreleriAyir(satir) { + return satir.split('|').map((h) => bosluk(h)); +} + +function kalemGecerli(ad) { + const t = bosluk(ad); + if (t.length < 3 || t.length > MAX_AD) return false; + if (ATLA_RE.test(t)) return false; + if (/^[\d.,\s%-]+$/.test(t)) return false; // sadece sayı + return true; +} + +/** " | " ayraçlı tablolardan kalem çıkarır (xlsx/docx yolu). */ +function tablodanCikar(satirlar) { + const kalemler = []; + let idx = null; + let basliktanBeri = 0; + + for (const ham of satirlar) { + const satir = ham.trim(); + if (!satir) { if (idx) basliktanBeri++; continue; } + + if (satir.includes('|')) { + const h = hucreleriAyir(satir); + const yeniBaslik = h.length >= 2 ? basligiCoz(h) : null; + if (yeniBaslik) { idx = yeniBaslik; basliktanBeri = 0; continue; } + + if (idx) { + const ad = h[idx.ad] ?? ''; + if (!kalemGecerli(ad)) { basliktanBeri++; continue; } + const miktarHam = idx.miktar >= 0 ? h[idx.miktar] : ''; + const birimHam = idx.birim >= 0 ? h[idx.birim] : ''; + // Miktar ve birim tek hücrede olabilir: "25000 TEST". sayiyaCevir + // harfleri attığı için önce hücrenin salt sayı olup olmadığına bakılır; + // yoksa sayı+birim olarak ayrıştırılır. + let birim = bosluk(birimHam); + let miktar; + if (/^[\d.,\s]+$/.test(bosluk(miktarHam))) { + miktar = sayiyaCevir(miktarHam); + } else { + const m = /([\d][\d.,]*)\s*([\p{L}/²³]+)?/u.exec(miktarHam || ''); + miktar = m ? sayiyaCevir(m[1]) : null; + if (!birim && m?.[2]) birim = m[2]; + } + kalemler.push({ + sira: idx.sira >= 0 ? sayiyaCevir(h[idx.sira]) : null, + ad: bosluk(ad), + miktar, + birim: birim || null, + }); + basliktanBeri = 0; + if (kalemler.length >= MAX_KALEM) break; + } + } else if (idx) { + // Tablo dışı düz metin: birkaç satır sonra tablo bitmiş sayılır. + if (++basliktanBeri > 3) idx = null; + } + } + return kalemler; +} + +// "12 ELDİVEN LATEKS PUDRASIZ M 50.000 ADET" +const SATIR_RE = new RegExp( + `^(\\d{1,4})[.)]?\\s+(.{3,${MAX_AD}}?)\\s+([\\d][\\d.,]*)\\s*(${BIRIMLER.join('|')})\\b`, + 'i', +); + +/** Ayraçsız düz metinden (pdf) sezgisel kalem çıkarımı. */ +function duzMetindenCikar(satirlar) { + const kalemler = []; + for (const ham of satirlar) { + const satir = bosluk(ham); + if (!satir || satir.includes('|')) continue; + const m = SATIR_RE.exec(satir); + if (!m) continue; + if (!kalemGecerli(m[2])) continue; + kalemler.push({ sira: sayiyaCevir(m[1]), ad: bosluk(m[2]), miktar: sayiyaCevir(m[3]), birim: m[4] }); + if (kalemler.length >= MAX_KALEM) break; + } + return kalemler; +} + +function tekilles(kalemler) { + const gorulen = new Set(); + const out = []; + for (const k of kalemler) { + const anahtar = normalizeTr(k.ad) + '|' + (k.miktar ?? '') + '|' + normalizeTr(k.birim || ''); + if (gorulen.has(anahtar)) continue; + gorulen.add(anahtar); + out.push(k); + } + return out; +} + +/** + * Doküman metninden teklif cetveli kalemlerini çıkarır. + * Önce tablo yolu denenir; hiç kalem bulunamazsa düz metin sezgisine düşer. + */ +export function kalemleriCikar(metin) { + const satirlar = String(metin || '').split('\n'); + let kalemler = tekilles(tablodanCikar(satirlar)); + let yontem = 'tablo'; + if (kalemler.length === 0) { + kalemler = tekilles(duzMetindenCikar(satirlar)); + yontem = 'duz-metin'; + } + return { kalemler: kalemler.slice(0, MAX_KALEM), yontem: kalemler.length ? yontem : 'yok' }; +} diff --git a/server/kalem-store.mjs b/server/kalem-store.mjs new file mode 100644 index 0000000..1c15c44 --- /dev/null +++ b/server/kalem-store.mjs @@ -0,0 +1,157 @@ +/** + * Ortak kalem havuzu (SQLite). + * + * NEDEN: Teklif cetveli kalemleri EKAP'ın API'sinde yok, yalnızca ihale + * dokümanının içinde — ve doküman sayfası CAPTCHA ile korunuyor, sunucudan + * otomatik indirilemiyor. Kullanıcı dokümanı kendi tarayıcısından indirip + * yükleyince kalemler buraya yazılıyor ve o ihale HERKESİN kartında + * kalemleriyle görünür oluyor. İlk yükleyen herkes için açıyor. + * + * Kalıcı anahtar İKN — EKAP'ın `id` alanı anlık token, saklanmaz. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { normalizeTr } from './siniflandir.mjs'; + +let DatabaseSync = null; +try { + ({ DatabaseSync } = await import('node:sqlite')); +} catch { + console.warn('[kalem-store] node:sqlite yok — kalem havuzu devre dışı'); +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = path.join(__dirname, 'data'); +const DB_PATH = path.join(DATA_DIR, 'kalem.sqlite'); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS ihale_kalem ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ikn TEXT NOT NULL, + sira INTEGER, + ad TEXT NOT NULL, + ad_norm TEXT NOT NULL, + miktar REAL, + birim TEXT +); +CREATE INDEX IF NOT EXISTS idx_kalem_ikn ON ihale_kalem(ikn); +CREATE INDEX IF NOT EXISTS idx_kalem_norm ON ihale_kalem(ad_norm); + +CREATE TABLE IF NOT EXISTS kalem_kaynak ( + ikn TEXT PRIMARY KEY, + kalem_sayisi INTEGER NOT NULL DEFAULT 0, + dosya TEXT, + yontem TEXT, + ekleyen TEXT, + zaman TEXT +); +`; + +let db = null; +export function available() { + return !!DatabaseSync; +} + +function open() { + if (db) return db; + if (!DatabaseSync) return null; + fs.mkdirSync(DATA_DIR, { recursive: true }); + db = new DatabaseSync(DB_PATH); + db.exec('PRAGMA journal_mode = WAL;'); + db.exec(SCHEMA); + return db; +} + +/** + * Bir ihalenin kalemlerini yazar (varsa eskisini değiştirir). + * Boş liste yazılmaz — hatalı bir ayrıştırma mevcut iyi veriyi silmesin. + */ +export function kalemYaz(ikn, kalemler, { dosya, yontem, ekleyen } = {}) { + const d = open(); + if (!d || !ikn || !Array.isArray(kalemler) || kalemler.length === 0) return 0; + + const sil = d.prepare('DELETE FROM ihale_kalem WHERE ikn = ?'); + const ekle = d.prepare( + 'INSERT INTO ihale_kalem (ikn, sira, ad, ad_norm, miktar, birim) VALUES (?, ?, ?, ?, ?, ?)', + ); + const ozet = d.prepare( + `INSERT INTO kalem_kaynak (ikn, kalem_sayisi, dosya, yontem, ekleyen, zaman) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(ikn) DO UPDATE SET + kalem_sayisi = excluded.kalem_sayisi, dosya = excluded.dosya, + yontem = excluded.yontem, ekleyen = excluded.ekleyen, zaman = excluded.zaman`, + ); + + d.exec('BEGIN'); + try { + sil.run(ikn); + for (const k of kalemler) { + ekle.run(ikn, k.sira ?? null, String(k.ad).slice(0, 300), normalizeTr(k.ad).slice(0, 300), + k.miktar ?? null, k.birim ? String(k.birim).slice(0, 30) : null); + } + ozet.run(ikn, kalemler.length, dosya || null, yontem || null, ekleyen || null, new Date().toISOString()); + d.exec('COMMIT'); + } catch (e) { + d.exec('ROLLBACK'); + throw e; + } + return kalemler.length; +} + +export function kalemOku(ikn) { + const d = open(); + if (!d || !ikn) return { kalemler: [], kaynak: null }; + const kalemler = d.prepare( + 'SELECT sira, ad, miktar, birim FROM ihale_kalem WHERE ikn = ? ORDER BY COALESCE(sira, 999999), id', + ).all(ikn); + const kaynak = d.prepare('SELECT kalem_sayisi, dosya, yontem, zaman FROM kalem_kaynak WHERE ikn = ?').get(ikn) || null; + return { kalemler, kaynak }; +} + +/** Havuzdaki tüm ihalelerin kalem özeti: ikn → { sayi, ilkler[] } */ +export function kalemOzetleri(limit = 20000) { + const d = open(); + if (!d) return new Map(); + const ozet = d.prepare('SELECT ikn, kalem_sayisi FROM kalem_kaynak LIMIT ?').all(limit); + const harita = new Map(); + if (!ozet.length) return harita; + const ilk = d.prepare('SELECT ad FROM ihale_kalem WHERE ikn = ? ORDER BY COALESCE(sira, 999999), id LIMIT 6'); + for (const o of ozet) { + harita.set(o.ikn, { sayi: o.kalem_sayisi, ilkler: ilk.all(o.ikn).map((r) => r.ad) }); + } + return harita; +} + +/** + * Kalem metninde arama. Firmanın kendi ürününü ("eldiven", "ortopedik vida") + * hangi ihalelerde geçtiğini bulması için. + */ +export function kalemAra(q, { limit = 400 } = {}) { + const d = open(); + const terim = normalizeTr(q || '').trim(); + if (!d || terim.length < 2) return []; + const satirlar = d.prepare( + `SELECT ikn, ad, miktar, birim FROM ihale_kalem + WHERE ad_norm LIKE ? ORDER BY ikn LIMIT ?`, + ).all(`%${terim}%`, limit * 5); + + const harita = new Map(); + for (const s of satirlar) { + if (!harita.has(s.ikn)) harita.set(s.ikn, { ikn: s.ikn, eslesen: [] }); + const e = harita.get(s.ikn); + if (e.eslesen.length < 5) e.eslesen.push({ ad: s.ad, miktar: s.miktar, birim: s.birim }); + if (harita.size >= limit) break; + } + return [...harita.values()]; +} + +export function kalemSayilari() { + const d = open(); + if (!d) return { ihale: 0, kalem: 0 }; + return { + ihale: d.prepare('SELECT COUNT(*) n FROM kalem_kaynak').get().n, + kalem: d.prepare('SELECT COUNT(*) n FROM ihale_kalem').get().n, + }; +} diff --git a/src/components/TeklifCetveliKalemleri.jsx b/src/components/TeklifCetveliKalemleri.jsx new file mode 100644 index 0000000..98e9ad1 --- /dev/null +++ b/src/components/TeklifCetveliKalemleri.jsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState } from 'react'; +import { Package, Loader2, Search } from 'lucide-react'; +import { kalemleriGetir } from '../services/kalem'; + +/** + * Teklif cetveli kalemleri — ortak havuzdan. + * + * Kalemler EKAP API'sinde yok; doküman sayfası CAPTCHA ile korunduğu için + * sunucu otomatik indiremiyor. Bir kullanıcı ihale dokümanını yüklediğinde + * kalemler ayrıştırılıp havuza yazılıyor ve bu liste herkese görünür oluyor. + */ +export const TeklifCetveliKalemleri = ({ ikn }) => { + const [durum, setDurum] = useState({ yukleniyor: true, kalemler: [], kaynak: null }); + const [suzgec, setSuzgec] = useState(''); + + useEffect(() => { + if (!ikn) { setDurum({ yukleniyor: false, kalemler: [], kaynak: null }); return; } + let iptal = false; + setDurum((d) => ({ ...d, yukleniyor: true })); + kalemleriGetir(ikn) + .then((d) => { if (!iptal) setDurum({ yukleniyor: false, kalemler: d.kalemler || [], kaynak: d.kaynak }); }) + .catch(() => { if (!iptal) setDurum({ yukleniyor: false, kalemler: [], kaynak: null }); }); + return () => { iptal = true; }; + }, [ikn]); + + if (durum.yukleniyor) { + return ( +

+ Teklif cetveli kalemleri okunuyor… +

+ ); + } + + if (!durum.kalemler.length) { + return ( +
+

+ Teklif Cetveli Kalemleri +

+

+ Bu ihalenin kalem listesi henüz havuzda yok. Kalemler EKAP'ın API'sinde yayımlanmıyor; + yalnızca ihale dokümanının içinde bulunuyor. Aşağıdan dokümanı yüklerseniz kalemler + otomatik çıkarılır ve bu ihale tüm kullanıcılarda kalemleriyle listelenir. +

+
+ ); + } + + const q = suzgec.trim().toLocaleLowerCase('tr-TR'); + const gorunen = q + ? durum.kalemler.filter((k) => (k.ad || '').toLocaleLowerCase('tr-TR').includes(q)) + : durum.kalemler; + + return ( +
+
+

+ + Teklif Cetveli Kalemleri ({durum.kalemler.length}) +

+ +
+ +
+ + + + + + + + + + {gorunen.map((k, i) => ( + + + + + + ))} + {gorunen.length === 0 && ( + + )} + +
#Kalem adıMiktar
{k.sira ?? i + 1}{k.ad} + {k.miktar != null ? k.miktar.toLocaleString('tr-TR') : '—'} {k.birim || ''} +
Bu aramayla eşleşen kalem yok.
+
+ + {durum.kaynak?.dosya && ( +

+ Kaynak: {durum.kaynak.dosya} — bir kullanıcının yüklediği ihale dokümanından + otomatik çıkarıldı. Teklif vermeden önce şartnamenin aslıyla karşılaştırın. +

+ )} +
+ ); +}; diff --git a/src/components/TenderCard.jsx b/src/components/TenderCard.jsx index dabc1df..870c9ab 100644 --- a/src/components/TenderCard.jsx +++ b/src/components/TenderCard.jsx @@ -3,7 +3,9 @@ import { useApp } from '../context/AppContext'; import { Star, MapPin, Clock, Tag, Package, Building2, AlertTriangle, Sparkles, CalendarDays, Megaphone } from 'lucide-react'; export const TenderCard = ({ tender }) => { - const { toggleTrackTender, setActiveTenderDetail, favoriteKurumlar, toggleFavoriteKurum } = useApp(); + const { toggleTrackTender, setActiveTenderDetail, favoriteKurumlar, toggleFavoriteKurum, kalemEslesen, searchQuery } = useApp(); + // Aramada eşleşen kalemler öne alınır — firma kendi ürününü hemen görsün. + const eslesenKalemler = (kalemEslesen && tender.ikn && kalemEslesen.get(tender.ikn)) || null; const isFavKurum = favoriteKurumlar?.includes((tender.kurum || '').trim()); const [day, monthYear] = tender.tarih ? tender.tarih.split('.') : ['01', '01.2026']; @@ -106,6 +108,15 @@ export const TenderCard = ({ tender }) => { İhale: {tender.tarih}{tender.saat ? ` ${tender.saat}` : ''} )} + {tender.kalemSayisi > 0 && ( + + + {tender.kalemSayisi} kalem + + )} {tender.eklenmeZamani && !ilanTarihiGoster && ( @@ -150,8 +161,42 @@ export const TenderCard = ({ tender }) => { )} + {/* Teklif cetveli kalemleri (ortak havuz) — firmanın kendi ürününü + şartnameyi indirmeden görebilmesi için kartta gösterilir. */} + {tender.kalemSayisi > 0 && ( +
setActiveTenderDetail(tender)} + className="bg-indigo-50/60 hover:bg-indigo-50 rounded-lg p-2.5 border border-indigo-200 space-y-1.5 cursor-pointer transition-colors" + > +

+ {eslesenKalemler ? 'Aramanızla eşleşen kalemler' : 'Teklif cetveli kalemleri'} +

+ {(eslesenKalemler + ? eslesenKalemler.slice(0, 3).map((k) => k) + : (tender.kalemler || []).slice(0, 3).map((ad) => ({ ad })) + ).map((k, idx) => ( +
+ + + {k.ad} + + {k.miktar != null && ( + + {k.miktar.toLocaleString('tr-TR')} {k.birim || ''} + + )} +
+ ))} + {tender.kalemSayisi > 3 && ( +

+ + {tender.kalemSayisi - 3} kalem daha — tümünü gör +

+ )} +
+ )} + {/* Material Items Preview Box */} - {tender.items && tender.items.length > 0 && ( + {!tender.kalemSayisi && tender.items && tender.items.length > 0 && (
setActiveTenderDetail(tender)} className="bg-slate-50 hover:bg-slate-100/80 rounded-lg p-2.5 border border-slate-200 space-y-1.5 cursor-pointer transition-colors" diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index 55e547f..4aefad4 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { useApp } from '../context/AppContext'; import { TenderDocuments } from './TenderDocuments'; +import { TeklifCetveliKalemleri } from './TeklifCetveliKalemleri'; import { X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare, DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck, @@ -213,7 +214,11 @@ END:VCALENDAR`;
+ {/* Ortak havuzdan teklif cetveli kalemleri */} + + {/* Material Table */} + {tender.items?.length > 0 && (

Malzeme / Kalem Listesi ({tender.items?.length || 0} Kalem)

@@ -243,6 +248,7 @@ END:VCALENDAR`;
+ )} {/* Team Notes Area */}
diff --git a/src/components/TenderDocuments.jsx b/src/components/TenderDocuments.jsx index 6858e08..4a0927a 100644 --- a/src/components/TenderDocuments.jsx +++ b/src/components/TenderDocuments.jsx @@ -40,9 +40,20 @@ export const TenderDocuments = ({ tender }) => { } setYukleniyor(true); setError(null); try { - await uploadDocument(f, 'İhale Dokümanı', ikn); + // Sunucu docx/xlsx/zip için teklif cetveli kalemlerini yüklemede + // otomatik çıkarır. PDF tarayıcıda çözüldüğü için metni biz göndeririz. + const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn); + let kalem = cevap?.kalem?.sayi || null; + if (!kalem && ikn && /\.(pdf|txt|md)$/i.test(f.name)) { + try { + const { text } = await extractText(f); + const { kalemCikar } = await import('../services/kalem'); + const d = await kalemCikar({ ikn, metin: text, dosya: f.name }); + kalem = d.sayi || null; + } catch { /* kalem çıkmazsa yükleme yine de başarılı sayılır */ } + } belgeleriYukle(); - setBilgi({ yuklendi: f.name }); + setBilgi({ yuklendi: f.name, kalem }); } catch (err) { setError(err.kod === 'KOTA_ASILDI' ? err.message : `Yüklenemedi: ${err.message}`); } finally { setYukleniyor(false); } @@ -233,7 +244,14 @@ export const TenderDocuments = ({ tender }) => {

{bilgi.dokumanSayisi != null && <>EKAP'ta {bilgi.dokumanSayisi} doküman kayıtlı. } {bilgi.karakter != null && <>{bilgi.ilanSayisi} ilan · {bilgi.okasSayisi} kalem · {bilgi.karakter.toLocaleString('tr-TR')} karakter metin analize gönderildi. } - {bilgi.yuklendi && <>{bilgi.yuklendi} yüklendi ve bu ihaleye bağlandı.} + {bilgi.yuklendi && ( + <> + {bilgi.yuklendi} yüklendi ve bu ihaleye bağlandı. + {bilgi.kalem + ? <> Teklif cetvelinden {bilgi.kalem} kalem otomatik çıkarıldı; bu ihale artık kalemleriyle listeleniyor. + : <> Bu dosyadan teklif cetveli kalemi çıkarılamadı.} + + )}

)} diff --git a/src/components/TenderList.jsx b/src/components/TenderList.jsx index f40be43..61501de 100644 --- a/src/components/TenderList.jsx +++ b/src/components/TenderList.jsx @@ -13,6 +13,7 @@ export const TenderList = () => { const { tenders, searchQuery, + kalemEslesen, selectedCity, selectedAlimTuru, selectedSektorler, @@ -46,7 +47,10 @@ export const TenderList = () => { const matchKurum = (tender.kurum || '').toLowerCase().includes(q); const matchIkn = (tender.ikn || '').toLowerCase().includes(q); const matchItems = tender.items && tender.items.some(i => (i.ad || '').toLowerCase().includes(q)); - if (!matchTitle && !matchKurum && !matchIkn && !matchItems) return false; + // Kartta taşınan ilk kalemler + sunucudaki tam kalem araması + const matchKalem = tender.kalemler && tender.kalemler.some(a => (a || '').toLowerCase().includes(q)); + const matchHavuz = kalemEslesen && tender.ikn && kalemEslesen.has(tender.ikn); + if (!matchTitle && !matchKurum && !matchIkn && !matchItems && !matchKalem && !matchHavuz) return false; } // Geçmiş ihaleler: varsayılan olarak GİZLİ. Kullanıcı "geçmişi de göster" @@ -71,7 +75,7 @@ export const TenderList = () => { if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih); return 0; }); - }, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]); + }, [tenders, searchQuery, kalemEslesen, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]); const gorunenTenders = filteredTenders.slice(0, gosterilen); const dahaVar = gosterilen < filteredTenders.length; diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index e43ac72..d67311f 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -49,6 +49,26 @@ export const AppProvider = ({ children }) => { const [liveStats, setLiveStats] = useState({ toplamIhale: 0, bugunEklenen: 0, arsiv: 0 }); const [searchQuery, setSearchQuery] = useState(''); + + // Kalem havuzunda arama: firmanın kendi ürününü ("eldiven", "ortopedik vida") + // hangi ihalelerin teklif cetvelinde geçtiğini bulması için. Kartta yalnızca + // ilk birkaç kalem taşınıyor, tam arama sunucuda yapılıyor. + const [kalemEslesen, setKalemEslesen] = useState(null); // Map(ikn → eşleşen kalemler) + useEffect(() => { + const q = searchQuery.trim(); + if (q.length < 2) { setKalemEslesen(null); return; } + let iptal = false; + const zaman = setTimeout(() => { + import('../services/kalem') + .then(({ kalemAra }) => kalemAra(q)) + .then((sonuclar) => { + if (iptal) return; + setKalemEslesen(sonuclar.length ? new Map(sonuclar.map((s) => [s.ikn, s.eslesen])) : null); + }) + .catch(() => { if (!iptal) setKalemEslesen(null); }); + }, 350); + return () => { iptal = true; clearTimeout(zaman); }; + }, [searchQuery]); const [selectedCity, setSelectedCity] = useState(''); const [selectedAlimTuru, setSelectedAlimTuru] = useState(''); const [selectedKurumTipi, setSelectedKurumTipi] = useState(''); @@ -531,6 +551,7 @@ export const AppProvider = ({ children }) => { handleLogout, liveStats, searchQuery, + kalemEslesen, setSearchQuery, selectedCity, setSelectedCity, diff --git a/src/services/kalem.js b/src/services/kalem.js new file mode 100644 index 0000000..35e0082 --- /dev/null +++ b/src/services/kalem.js @@ -0,0 +1,39 @@ +/** + * Ortak kalem havuzu istemcisi. + * + * Teklif cetveli kalemleri EKAP API'sinde yok (doküman sayfası CAPTCHA ile + * korunuyor). Bir kullanıcı dokümanı yükleyince kalemler havuza yazılıyor ve + * o ihale herkesin kartında kalemleriyle görünüyor. + */ +import { getToken } from './auth'; + +/** Bir ihalenin tüm kalemleri. */ +export async function kalemleriGetir(ikn) { + const r = await fetch(`/api/kalem?ikn=${encodeURIComponent(ikn)}`); + const d = await r.json().catch(() => ({})); + if (!d.success) throw new Error(d.error || 'kalemler alınamadı'); + return d; +} + +/** Kalem metninde arama — eşleşen İKN'ler. */ +export async function kalemAra(q) { + const r = await fetch(`/api/kalem/ara?q=${encodeURIComponent(q)}`); + const d = await r.json().catch(() => ({})); + if (!d.success) return []; + return d.sonuclar || []; +} + +/** + * Tarayıcıda çıkarılmış metinden (PDF) kalem ayrıştırıp havuza yazar. + * docx/xlsx/zip yüklemede sunucu bunu zaten kendisi yapıyor. + */ +export async function kalemCikar({ ikn, metin, dosya }) { + const r = await fetch('/api/kalem/cikar', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ ikn, metin, dosya }), + }); + const d = await r.json().catch(() => ({})); + if (!d.success) throw new Error(d.error || 'kalem çıkarılamadı'); + return d; +}