feat: ortak kalem havuzu — teklif cetveli kalemleri kartta gorunur ve aranabilir

Firmalar ihalenin sadece basligina bakarak kendi urunlerinin icinde olup
olmadigini goremiyordu ("148 KALEM KIRTASIYE MALZEME ALIMI"). Artik bir
kullanici ihale dokumanini yukledinde teklif cetveli kalemleri ayristirilip
IKN'ye baglaniyor ve o ihale HERKESIN kartinda kalemleriyle gorunuyor.

NEDEN YUKLEMEYE DAYALI: kalem listesi EKAP'in programatik yuzeyinde yok.
- Arama/detay API'si (ihtiyacKalemiOkasList) yalnizca OKAS sinifi veriyor:
  "KALP AKCIGER POMPASI CIHAZI ALIMI" icin tek satir "Tibbi cihazlar"
- Ilan metni yalnizca sayiyi veriyor: "148 KALEM ... Ayrintili bilgiye EKAP'ta
  yer alan ihale dokumani icinde bulunan idari sartnameden ulasilabilir"
- Dokuman sayfasi CAPTCHA ile korunuyor (ctl00_btnCaptchaProtect, guvenlik
  resmi). Gercek tarayiciyla (Playwright) da acildi, donen tek metin: "Dogrula"

OTOMATIK, AI'SIZ: kalem cikarimi yukleme aninda kendiliginden calisir,
kullanicidan tiklama beklemez ve model cagirmaz (saf regex). AI analizi ayri
dugmede, kullanici isterse.

- server/kalem-cikar.mjs: teklif cetveli ayristirici. " | " tablolarindan
  (docx/xlsx/zip) sutun basligini cozerek; ayracsiz PDF metninde sezgisel
  satir kalibiyla. Miktar+birim tek hucrede olabiliyor ("25000 TEST").
  Birim listesinde 'test' var — tibbi kitlerde kalem miktari boyle yaziliyor.
- server/kalem-store.mjs: kalem.sqlite, IKN bazli, Turkce duyarli arama.
  Bos ayristirma mevcut iyi veriyi silmez.
- Uclar: POST /api/kalem/cikar (tarayicidan gelen PDF metni), GET /api/kalem,
  GET /api/kalem/ara, GET /api/health/kalem
- /api/tenders payloadu kalemSayisi + ilk kalemleri tasiyor
- Kartta kalem rozeti ve kalem seridi; aramada eslesen kalemler one alinir
- Ust arama kutusu kalem havuzunda da ariyor (350ms debounce, sunucu tarafi)
- Detay modalinda tam kalem listesi + kalem ici arama

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 11:51:39 +03:00
co-authored by Claude Opus 5
parent b41bc338af
commit 8826ff8e68
10 changed files with 650 additions and 8 deletions
+69 -1
View File
@@ -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 });
}
+178
View File
@@ -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' };
}
+157
View File
@@ -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,
};
}
+106
View File
@@ -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 (
<p className="text-xs text-slate-500 flex items-center gap-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Teklif cetveli kalemleri okunuyor…
</p>
);
}
if (!durum.kalemler.length) {
return (
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<h3 className="font-bold text-sm text-slate-900 flex items-center gap-2">
<Package className="w-4 h-4 text-indigo-600" /> Teklif Cetveli Kalemleri
</h3>
<p className="text-xs text-slate-600 leading-relaxed mt-1.5">
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 <strong>tüm kullanıcılarda</strong> kalemleriyle listelenir.
</p>
</div>
);
}
const q = suzgec.trim().toLocaleLowerCase('tr-TR');
const gorunen = q
? durum.kalemler.filter((k) => (k.ad || '').toLocaleLowerCase('tr-TR').includes(q))
: durum.kalemler;
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h3 className="font-bold text-sm text-slate-900 flex items-center gap-2">
<Package className="w-4 h-4 text-indigo-600" />
Teklif Cetveli Kalemleri ({durum.kalemler.length})
</h3>
<label className="relative">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
value={suzgec}
onChange={(e) => setSuzgec(e.target.value)}
placeholder="Kalem ara…"
className="text-xs pl-8 pr-3 py-1.5 rounded-lg border border-slate-300 w-44 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</label>
</div>
<div className="border border-slate-200 rounded-xl overflow-hidden shadow-2xs max-h-96 overflow-y-auto">
<table className="w-full text-left text-xs">
<thead className="bg-slate-100 text-slate-700 font-bold border-b border-slate-200 sticky top-0">
<tr>
<th className="p-2.5 w-12">#</th>
<th className="p-2.5">Kalem adı</th>
<th className="p-2.5 text-right w-32">Miktar</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{gorunen.map((k, i) => (
<tr key={i} className="hover:bg-indigo-50/40">
<td className="p-2.5 font-bold text-slate-400">{k.sira ?? i + 1}</td>
<td className="p-2.5 font-semibold text-slate-800">{k.ad}</td>
<td className="p-2.5 text-right font-bold text-slate-900 whitespace-nowrap">
{k.miktar != null ? k.miktar.toLocaleString('tr-TR') : '—'} {k.birim || ''}
</td>
</tr>
))}
{gorunen.length === 0 && (
<tr><td colSpan={3} className="p-3 text-center text-slate-400">Bu aramayla eşleşen kalem yok.</td></tr>
)}
</tbody>
</table>
</div>
{durum.kaynak?.dosya && (
<p className="text-[10px] text-slate-400">
Kaynak: <strong>{durum.kaynak.dosya}</strong> — 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.
</p>
)}
</div>
);
};
+47 -2
View File
@@ -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 }) => {
<span>İhale: {tender.tarih}{tender.saat ? ` ${tender.saat}` : ''}</span>
</span>
)}
{tender.kalemSayisi > 0 && (
<span
className="bg-indigo-50 text-indigo-700 border border-indigo-200 px-2 py-0.5 rounded-full text-[10px] font-bold flex items-center gap-1"
title="Teklif cetveli kalemleri — bir kullanıcının yüklediği ihale dokümanından çıkarıldı"
>
<Package className="w-3 h-3 text-indigo-600" />
<span>{tender.kalemSayisi} kalem</span>
</span>
)}
{tender.eklenmeZamani && !ilanTarihiGoster && (
<span className="bg-slate-100 text-slate-600 border border-slate-200 px-2 py-0.5 rounded-full text-[10px] font-bold flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
@@ -150,8 +161,42 @@ export const TenderCard = ({ tender }) => {
)}
</div>
{/* Teklif cetveli kalemleri (ortak havuz) — firmanın kendi ürününü
şartnameyi indirmeden görebilmesi için kartta gösterilir. */}
{tender.kalemSayisi > 0 && (
<div
onClick={() => 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"
>
<p className="text-[10px] font-black uppercase tracking-wider text-indigo-700/70">
{eslesenKalemler ? 'Aramanızla eşleşen kalemler' : 'Teklif cetveli kalemleri'}
</p>
{(eslesenKalemler
? eslesenKalemler.slice(0, 3).map((k) => k)
: (tender.kalemler || []).slice(0, 3).map((ad) => ({ ad }))
).map((k, idx) => (
<div key={idx} className="flex items-center justify-between gap-2 text-xs font-semibold text-slate-700">
<span className="flex items-center gap-2 truncate">
<Package className="w-3.5 h-3.5 text-indigo-500 shrink-0" />
<span className="truncate">{k.ad}</span>
</span>
{k.miktar != null && (
<span className="bg-white border border-indigo-200 px-2 py-0.5 rounded text-[11px] font-bold text-slate-900 shrink-0">
{k.miktar.toLocaleString('tr-TR')} {k.birim || ''}
</span>
)}
</div>
))}
{tender.kalemSayisi > 3 && (
<p className="text-[10px] font-bold text-indigo-600 hover:underline pt-0.5 text-right">
+ {tender.kalemSayisi - 3} kalem daha — tümünü gör
</p>
)}
</div>
)}
{/* Material Items Preview Box */}
{tender.items && tender.items.length > 0 && (
{!tender.kalemSayisi && tender.items && tender.items.length > 0 && (
<div
onClick={() => 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"
+6
View File
@@ -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`;
</div>
</div>
{/* Ortak havuzdan teklif cetveli kalemleri */}
<TeklifCetveliKalemleri ikn={tender.ikn} />
{/* Material Table */}
{tender.items?.length > 0 && (
<div className="space-y-3">
<h3 className="font-bold text-sm text-slate-900">Malzeme / Kalem Listesi ({tender.items?.length || 0} Kalem)</h3>
<div className="border border-slate-200 rounded-xl overflow-hidden shadow-2xs">
@@ -243,6 +248,7 @@ END:VCALENDAR`;
</table>
</div>
</div>
)}
{/* Team Notes Area */}
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-4">
+21 -3
View File
@@ -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 }) => {
<p className="text-[11px] text-slate-500">
{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 && <><strong>{bilgi.yuklendi}</strong> yüklendi ve bu ihaleye bağlandı.</>}
{bilgi.yuklendi && (
<>
<strong>{bilgi.yuklendi}</strong> yüklendi ve bu ihaleye bağlandı.
{bilgi.kalem
? <> Teklif cetvelinden <strong>{bilgi.kalem} kalem</strong> otomatik çıkarıldı; bu ihale artık kalemleriyle listeleniyor.</>
: <> Bu dosyadan teklif cetveli kalemi çıkarılamadı.</>}
</>
)}
</p>
)}
+6 -2
View File
@@ -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;
+21
View File
@@ -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,
+39
View File
@@ -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;
}