Compare commits

..
6 Commits
Author SHA1 Message Date
hikmettanandClaude Fable 5.1 1fce9df06f feat: OKAS kod aramasi, toplu ihale dokumani yukleme, kazanan istihbarati
Uc ozellik, 27+1 depo taramasinda gorulen fikirlerden; kod kopyalanmadi
(ilgili depolar lisanssiz), uygulamalar sifirdan yazildi.

1) OKAS koduyla arama (fikir: firatbadur/ihaletakip-api'nin kullandigi
   IhtiyacKalemleri/GetAll ucu). EKAP'in kendi OKAS katalogunda ad ile
   (TR/EN) arama: "ortoped" → 33141700 Ortopedik malzemeler, 33183000
   Ortopedik destek cihazlari. Secilen kodla EKAP'ta ihale aramasi
   (okasBransKodList). Olculen: 33141700 altinda 335 ihale, basliklari
   "VETERINER ILAC VE SARF MALZEME" — basliktan gorunmeyen turden.
   - ekap-v2.mjs: okasAra(), searchByOkas(); sonuc normalize ortak
   - GET /api/okas/ara?q= (1 sa onbellek); /api/urun-ara?okas=kod,kod
   - Urun Arama modalinda "OKAS koduyla" modu (katalog onerisi + cok kod)
   - Sol menude CPV bolumunun ustunde OKAS kutusu: kod secilince modal
     o kodla acilip hemen arar

2) Toplu ihale dokumani yukleme (fikir: barkem-tayyar/ihale'nin izlenen
   klasor + extractIkn deseni). Belge Kasasi'na coklu dosya secimi: her
   dosyanin IKN'si METNINDEN bulunur ("Ihale Kayit Numarasi (IKN):
   2026/1166496"), kullaniciya sorulmaz; ihaleye baglanir ve teklif
   cetveli kalemleri otomatik havuza yazilir. Onlarca sartnameyi tek
   seferde atarak kalem havuzunu hizla doldurmak icin.
   - kalem-cikar.mjs#iknCikar + istemci esi (PDF tarayicida okunuyor)
   - Yukleme ucu: otoIkn → ofis metninden IKN; donuste ikn alani
   - Dosya basina sonuc satiri: IKN / kalem sayisi / "IKN bulunamadi"
   Dogrulandi: ikn parametresiz docx → 2026/1588948 bulundu, 2 kalem.

3) Kazanan istihbarati (fikir: gulmezeren2-byte/ihalent, MIT). Rakip
   Radari'nda firma basina kirim orani (1 − sozlesme/yaklasik maliyet,
   ortalama), ortalama gecerli teklif sayisi (rekabet derinligi) ve tek
   teklifle kazanma orani. Veri yoksa null — 0 ile karismasin; bedel >
   maliyet×1,5 olan bozuk satirlar orana katilmiyor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:16:53 +03:00
hikmettanandClaude Opus 5 9c142fa0f8 feat: EKAP imzalama anahtari rotasyona girince kendini toparlasin
Anahtar sabit kodluydu (EKAP_R8FACT_KEY ile gecilebiliyordu). EKAP anahtari
donemsel degistiriyor; degistigi anda ihale havuzu tamamen karariyor ve birinin
elle yeni anahtari bulup env'e yazmasi gerekiyordu.

Artik kimlik dogrulama hatasindan sonra anahtar portalin kendi paketinden
kesfediliyor. Zincir canli EKAP'ta dogrulandi:
  /ekap/search      → <script src="main.<hash>.js">
  main.<hash>.js    → webpack chunk haritasi; 2076 ozel olarak "common" adiyla
                      yayimlaniyor: y=>(2076===y?"common":y)+"."+{...}
  common.<hash>.js  → r8fact:"<32 baytlik anahtar>"
Kesfedilen deger su anki sabit anahtarla birebir ayni cikti — zincir dogru.

- Kesif YALNIZCA yedek: normalde sabit/env anahtar kullanilir, kesif ancak
  401/403/5xx sonrasi ve istek basina BIR KEZ denenir (gercek sunucu hatasinda
  her denemede portalden uc dosya indirmeyelim)
- 5xx de tetikliyor: EKAP gecersiz anahtari bazen 401, bazen 500 ile bildiriyor
  (imza cozulemeyince sifre cozme katmani patliyor); yalnizca 401'e bakmak
  rotasyonun yarisini kacirirdi
- Basari 1 sa, basarisizlik 5 dk onbellekleniyor
- Anahtar 32 bayt degilse kullanilmiyor — sema degismis demektir, korlemesine
  denemek yerine mevcut anahtarla devam edilir
- SPA fallback tuzagi: bilinmeyen yol HTTP 200 + index.html donduruyor; JS
  beklenen isteklerde HTML gelirse hata veriliyor (sessiz basarisizlik olmasin)

Sahte rotasyon testi: bozuk anahtarla EKAP 500 dondu → kesif devreye girdi →
dogru anahtar bulundu → tekrar denendi → arama basarili (860 ms).

Bulgu kaynagi: saidsurucu/ihale-mcp taramasinda gorulen imza semasi ve
firatbadur/ihaletakip-api'nin belgeledigi rotasyon davranisi. Ilgili depo
lisanssiz oldugu icin kod kopyalanmadi, uygulama sifirdan yazildi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:04:11 +03:00
hikmettanandClaude Opus 5 ff797f5a91 feat: urun arama — urun adini ihale DOKUMANLARININ icinde aratir
saidsurucu/ihale-mcp deposu incelenirken EKAP arama API'sinin doküman ici
arama parametreleri oldugu goruldu: teklifCetvelindeAra, teknikSartnamedeAra,
idariSartnamedeAra, sozlesmeTasarisindaAra. EKAP dokumani kendi tarafinda
indeksliyor — yani teklif cetvelinde arama icin dokumani indirmeye (ve
CAPTCHA'ya) gerek yok.

Kullanicinin sikayetini olcen kanit:
  ELDIVEN         basliкta  1.135  → teklif cetvelinde 29.057
  ORTOPEDIK VIDA  baslikta      0  → teklif cetvelinde      3
  HEMOGRAM        baslikta    283  → teklif cetvelinde  2.529
"ORTOPEDIK VIDA" hicbir ihale BASLIGINDA gecmiyor; gectigi ihalelerin adi
"VETERINER ILAC ... SARF MALZEME". Basliga bakan firma bunlari bulamiyordu.

- ekap-v2.mjs: searchBody artik opts.kapsam ile daraltilabiliyor;
  searchInDocuments() yalnizca teklif cetvelinde (istege bagli teknik
  sartnamede) arar ve sonucu normalize eder
- GET /api/urun-ara?q=&skip=&take=&teknik= — 5 dk bellek onbellegi ile
  (EKAP'a uzak cagri, ayni sorgu kaynagi yormasin)
- Header'da "URUN ARA" dugmesi + UrunAramaModal: sayfalama, "sadece teklif
  verilebilir" suzgeci, havuzda acik olan ihalede detayi acma kisayolu

SINIR: EKAP hangi kalemin eslestigini dondurmuyor, yalnizca eslesen ihaleleri.
Kalem dokumu icin dokuman yuklemeye dayali ortak havuz yolu gecerli kaliyor;
arayuzde bu acikca yaziliyor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:55:36 +03:00
hikmettanandClaude Opus 5 8826ff8e68 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>
2026-09-03 11:51:39 +03:00
hikmettanandClaude Fable 5.1 b41bc338af fix: analiz icin dosya yuklenemiyordu — nginx varsayilan 1 MB govde siniri 413 veriyordu (arayuz 10 MB vaat ediyor); 20 MB + 300 sn zaman asimi, 413/504 icin anlasilir hata mesaji, 10 MB on kontrol
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:48:22 +03:00
hikmettanandClaude Fable 5.1 920a147126 fix: ust serit tiklaninca donuyordu — Kanban havuzdaki tum ihaleleri (8 bin+ kart) ciziyordu; artik yalnizca takipteki/kolona tasinan ihaleler, sekme gecisi transition ile aninda vurgulaniyor
- KanbanBoard: sunucu her ilana varsayilan 'incelenecek' verdigi icin bos pano 8.372 kart basiyordu; yalnizca takipte veya kolona tasinmis ihaleler kaliyor
- TopNavigation: useTransition + spinner; agir sekmelerde tiklama karsiliksiz kalmiyor
- sunucu: 'yeni liste eskisinden kucukse alma' kurali havuzu 30 Agustos zirvesinde dondurmustu (gunlerce yeni ilan yok); eksiksiz inen liste her zaman devrede
- sunucu: isYeni/bugun eklenen UTC-TR gun farkindan hep 0'di; Europe/Istanbul gunune gore ve her yanitta tazeleniyor (eklenmeZamani da donmuyor)
- ust kartlar: 'bugun eklenenler' tum ilanlari sayiyordu ('once' metnine bakiyordu), 'son giristen beri' sabit 14'tu; sayimlar liste filtresiyle ayni olcut (ihaleTarih.js), arsiv/sonuclanan filtreleri 'sadece acik' kapisina takilmiyor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:02:40 +03:00
29 changed files with 1688 additions and 78 deletions
+6
View File
@@ -6,6 +6,12 @@
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "3005", "--strictPort"], "runtimeArgs": ["run", "dev", "--", "--port", "3005", "--strictPort"],
"port": 3005 "port": 3005
},
{
"name": "ihaleist-api",
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"port": 3001
} }
] ]
} }
+6 -1
View File
@@ -13,7 +13,12 @@ server {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 60s; # Belge yükleme: arayüz 10 MB'a izin veriyor, base64 ile ~13,4 MB gövde.
# nginx varsayılanı 1 MB → 1 MB üstü her dosya 413 ile "yüklenemedi" oluyordu.
client_max_body_size 20m;
# Derin şartname analizi 1-2 dk sürebiliyor; 60 sn'de 504 kesiyordu.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 10s; proxy_connect_timeout 10s;
} }
+77
View File
@@ -0,0 +1,77 @@
/**
* EKAP imzalama anahtarının çalışma anında keşfi.
*
* NEDEN: güvenlik başlıkları (X-Ekap-Sec-*) AES anahtarıyla üretiliyor ve EKAP
* bu anahtarı dönemsel olarak değiştiriyor. Anahtar sabit kodlu kaldığı sürece
* her rotasyonda ihale havuzu tamamen kararıyor ve birinin elle yeni anahtarı
* bulup env'e yazması gerekiyor.
*
* ZİNCİR (canlı EKAP'ta doğrulandı):
* /ekap/search → <script src="main.<hash>.js">
* main.<hash>.js → webpack chunk haritası; 2076 özel olarak "common"
* adıyla yayımlanıyor: y=>(2076===y?"common":y)+"."+{...}
* common.<hash>.js → r8fact:"<32 baytlık anahtar>"
*
* Bu yol YALNIZCA yedektir: normal durumda sabit/env anahtar kullanılır,
* keşif ancak EKAP kimlik doğrulama hatası verdiğinde denenir.
*/
const BASE = 'https://ekapv2.kik.gov.tr';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36';
const BASARI_TTL = 60 * 60 * 1000; // 1 saat
const HATA_TTL = 5 * 60 * 1000; // başarısız keşfi 5 dk tekrar deneme
const MAX_BAYT = 4 * 1024 * 1024;
let onbellek = { anahtar: null, zaman: 0, hataZamani: 0 };
async function metin(url, { timeout = 15000, jsBekle = false } = {}) {
const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: '*/*' }, signal: AbortSignal.timeout(timeout) });
if (!r.ok) throw new Error(`${url.split('/').pop()} → HTTP ${r.status}`);
const t = await r.text();
if (t.length > MAX_BAYT) throw new Error('beklenenden büyük dosya');
// Bilinmeyen yol SPA fallback ile HTML döndürüyor; JS beklerken HTML almak
// parse'ı hatasız ama sonuçsuz bırakır. Kök sayfa zaten HTML — kontrol
// yalnızca JS beklenen isteklere uygulanır.
if (jsBekle && /^\s*<!doctype html/i.test(t)) throw new Error('JS yerine HTML döndü (SPA fallback)');
return t;
}
/**
* Portalın kendi paketinden güncel anahtarı çıkarır.
* Bulamazsa null döner — çağıran taraf mevcut anahtarla devam eder.
*/
export async function anahtarKesfet({ zorla = false } = {}) {
const simdi = Date.now();
if (!zorla) {
if (onbellek.anahtar && simdi - onbellek.zaman < BASARI_TTL) return onbellek.anahtar;
if (simdi - onbellek.hataZamani < HATA_TTL) return null;
}
try {
const kok = await metin(`${BASE}/ekap/search`);
const mainAd = /<script[^>]*src="([^"]*main[^"]*\.js)"/.exec(kok)?.[1];
if (!mainAd) throw new Error('main.js bulunamadı');
const main = await metin(`${BASE}/${mainAd.replace(/^\//, '')}`, { jsBekle: true });
// 2076 numaralı chunk "common" adıyla yayımlanıyor (webpack özel durumu)
const commonHash = /2076\s*:\s*["']([a-f0-9]{8,})["']/.exec(main)?.[1];
if (!commonHash) throw new Error('common chunk hash bulunamadı');
const common = await metin(`${BASE}/common.${commonHash}.js`, { jsBekle: true });
const anahtar = /r8fact\s*[:=]\s*["']([^"']{16,64})["']/.exec(common)?.[1];
if (!anahtar) throw new Error('r8fact bulunamadı');
// AES-256 32 bayt bekler; farklı uzunluk şema değişmiş demektir, körlemesine kullanma.
if (anahtar.length !== 32) throw new Error(`beklenmeyen anahtar uzunluğu: ${anahtar.length}`);
onbellek = { anahtar, zaman: simdi, hataZamani: 0 };
return anahtar;
} catch (e) {
onbellek.hataZamani = simdi;
console.warn('[ekap-key] anahtar keşfedilemedi:', e.message);
return null;
}
}
export function sonKesfedilen() {
return onbellek.anahtar;
}
+91 -3
View File
@@ -11,12 +11,21 @@
*/ */
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { anahtarKesfet } from './ekap-key.mjs';
const BASE = 'https://ekapv2.kik.gov.tr'; const BASE = 'https://ekapv2.kik.gov.tr';
const DEFAULT_KEY = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g'; const DEFAULT_KEY = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g';
// EKAP anahtarı döndürdüğünde portalın kendi paketinden keşfedilen değer.
// Yalnızca kimlik doğrulama hatasından sonra dolar; normalde null kalır.
let kesfedilenAnahtar = null;
function aktifAnahtar() {
return kesfedilenAnahtar || process.env.EKAP_R8FACT_KEY || DEFAULT_KEY;
}
function aesKey() { function aesKey() {
return Buffer.from(process.env.EKAP_R8FACT_KEY || DEFAULT_KEY, 'utf8'); return Buffer.from(aktifAnahtar(), 'utf8');
} }
function aesB64(plain, iv) { function aesB64(plain, iv) {
@@ -51,6 +60,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}) { export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}) {
let lastErr; let lastErr;
let anahtarDenendi = false;
for (let i = 0; i <= retries; i++) { for (let i = 0; i <= retries; i++) {
try { try {
const res = await fetch(BASE + path, { const res = await fetch(BASE + path, {
@@ -61,8 +71,20 @@ export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}
}); });
const raw = await res.text(); const raw = await res.text();
if (!res.ok) { if (!res.ok) {
// 401/1202 = timestamp kayması → yeni başlıkla tekrar dene // 401/1202 = timestamp kayması → yeni başlıkla tekrar dene.
if ((res.status === 401 || res.status >= 500) && i < retries) { // Anahtar rotasyonu da buradan geçiyor ve EKAP bunu bazen 401,
// bazen 500 ile bildiriyor (imza çözülemeyince şifre çözme katmanı
// patlıyor) — bu yüzden 5xx de anahtar yenilemeyi tetikliyor.
if ((res.status === 401 || res.status === 403 || res.status >= 500) && i < retries) {
if (!anahtarDenendi) {
anahtarDenendi = true; // istek başına bir kez: gerçek sunucu
// hatasında her denemede portalden üç dosya indirmeyelim
const yeni = await anahtarKesfet();
if (yeni && yeni !== aktifAnahtar()) {
console.warn('[ekap] imzalama anahtarı değişmiş — portalden yeni anahtar alındı');
kesfedilenAnahtar = yeni;
}
}
await sleep(500 * (i + 1)); await sleep(500 * (i + 1));
continue; continue;
} }
@@ -89,10 +111,14 @@ function searchBody(opts = {}) {
return { return {
searchText: opts.searchText || '', searchText: opts.searchText || '',
filterType: null, filterType: null,
// Arama kapsamı — EKAP metni ihale dokümanının İÇİNDE de arayabiliyor
// (teknik/idari şartname, sözleşme tasarısı, teklif cetveli). Varsayılan
// hepsi açık; opts.kapsam ile daraltılır (ör. yalnızca teklif cetveli).
ikNdeAra: true, ihaleAdindaAra: true, ihaleIlanindaAra: true, ikNdeAra: true, ihaleAdindaAra: true, ihaleIlanindaAra: true,
teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true, teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true,
isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true, isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true,
ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true, ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true,
...(opts.kapsam || {}),
searchType: 'GirdigimGibi', searchType: 'GirdigimGibi',
iknYili: opts.iknYili ?? null, iknYili: opts.iknYili ?? null,
iknSayi: opts.iknSayi ?? null, iknSayi: opts.iknSayi ?? null,
@@ -133,6 +159,68 @@ export async function searchTenders(opts = {}) {
return { list: data.list || [], totalCount: data.totalCount ?? 0 }; return { list: data.list || [], totalCount: data.totalCount ?? 0 };
} }
/**
* Ürün/kalem araması — metni YALNIZCA ihale dokümanının içinde arar.
*
* Firmalar başlığa bakarak kendi ürünlerini bulamıyor: "ORTOPEDİK VİDA"
* hiçbir ihale BAŞLIĞINDA geçmiyor, ama üç ihalenin TEKLİF CETVELİNDE var —
* ve o ihalelerin adı "VETERİNER İLAÇ ... SARF MALZEME". EKAP dokümanı
* kendi tarafında indekslediği için doküman indirmeye gerek yok.
*/
/**
* OKAS kataloğunda ad ile arama (TR/EN). Kaynak: EKAP'ın kendi kalem servisi,
* DevExtreme loadOptions gövdesiyle. "ortoped" → 33141700 Ortopedik malzemeler.
*/
export async function okasAra(q, { take = 30 } = {}) {
const terim = String(q || '').trim();
const filter = terim ? [['kalemAdi', 'contains', terim], 'or', ['kalemAdiEng', 'contains', terim]] : [];
const d = await ekapPost('/b_ihalearama/api/IhtiyacKalemleri/GetAll', {
loadOptions: {
filter: { sort: [], group: [], filter, totalSummary: [], groupSummary: [], select: [], preSelect: [], primaryKey: [] },
take,
},
});
const liste = d?.loadResult?.data || [];
return liste.map((k) => ({
kod: k.kod, ad: (k.kalemAdi || '').trim(), adEn: (k.kalemAdiEng || '').trim(),
seviye: k.kodLevel, altSayisi: k.childCount ?? 0,
}));
}
function normalizeKayit(t) {
return {
ikn: t.ikn, baslik: t.ihaleAdi, kurum: t.idareAdi, sehir: t.ihaleIlAdi,
tur: t.ihaleTipAciklama, usul: t.ihaleUsulAciklama, durum: t.ihaleDurumAciklama,
ihaleTarihSaat: t.ihaleTarihSaat, dokumanSayisi: t.dokumanSayisi ?? 0,
};
}
/** OKAS kodlarıyla EKAP araması — idarenin kendi sınıflandırmasına göre. */
export async function searchByOkas(kodlar, { skip = 0, take = 20 } = {}) {
const liste = (Array.isArray(kodlar) ? kodlar : [kodlar]).map(String).filter(Boolean);
if (!liste.length) return { totalCount: 0, list: [] };
const data = await ekapPost(
'/b_ihalearama/api/Ihale/GetListByParameters',
searchBody({ okasBransKodList: liste, skip, take }),
);
return { totalCount: data.totalCount ?? 0, list: (data.list || []).map(normalizeKayit) };
}
export async function searchInDocuments(metin, { teknikSartname = false, skip = 0, take = 20 } = {}) {
const kapsam = {
ikNdeAra: false, ihaleAdindaAra: false, ihaleIlanindaAra: false,
teknikSartnamedeAra: !!teknikSartname, idariSartnamedeAra: false,
benzerIsMaddesindeAra: false, isinYapilacagiYerMaddesindeAra: false,
nitelikTurMiktarMaddesindeAra: false, ihaleBilgilerindeAra: false,
sozlesmeTasarisindaAra: false, teklifCetvelindeAra: true,
};
const data = await ekapPost(
'/b_ihalearama/api/Ihale/GetListByParameters',
searchBody({ searchText: metin, kapsam, skip, take }),
);
return { totalCount: data.totalCount ?? 0, list: (data.list || []).map(normalizeKayit) };
}
// NOT: dönen `id` per-response şifreli token — kalıcı değil, aynı oturumda kullan. // NOT: dönen `id` per-response şifreli token — kalıcı değil, aynı oturumda kullan.
export async function getIlanlar(ihaleId) { export async function getIlanlar(ihaleId) {
const data = await ekapPost('/b_ihalearama/api/Ilan/GetList', { ihaleId }); const data = await ekapPost('/b_ihalearama/api/Ilan/GetList', { ihaleId });
+179 -14
View File
@@ -22,6 +22,13 @@ import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs'; import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs';
import { kalemleriCikar } from './kalem-cikar.mjs';
import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs';
import { iknCikar } from './kalem-cikar.mjs';
// Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma.
const urunAramaCache = new Map();
import * as kalemStore from './kalem-store.mjs';
import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.mjs'; import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.mjs';
import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs'; import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs';
import * as store from './user-store.mjs'; import * as store from './user-store.mjs';
@@ -109,6 +116,33 @@ function relativeTimeTr(iso) {
return `${Math.floor(hours / 24)} gün önce eklendi`; return `${Math.floor(hours / 24)} gün önce eklendi`;
} }
// Türkiye günü ("yyyy-aa-gg"). Sunucu konteyneri UTC'de çalışıyor; ilan.gov.tr
// yayın saati TR gece yarısı = "…T21:00:00Z" olduğundan UTC'ye göre gün DÜN
// çıkıyor ve "bugün eklenen" hep 0 görünüyordu.
const TR_GUN = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Istanbul', year: 'numeric', month: '2-digit', day: '2-digit',
});
function trGun(iso) {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? String(iso).slice(0, 10) : TR_GUN.format(d);
}
const bugunTR = () => TR_GUN.format(new Date());
// isYeni / eklenmeZamani ilan havuza girerken hesaplanıp donuyordu; havuz
// günlerce yaşayınca "22 saat önce" damgası ve "bugün" bayrağı bayatlıyordu.
// Her yanıtta tazelenir (3 bin kayıt için birkaç ms).
function ilanTazele(t) {
if (!t.isLiveFeed || t.kaynak !== 'ilan.gov.tr') return t;
t.isYeni = Boolean(t.ilanTarihi) && t.ilanTarihi === bugunTR();
if (t.yayinZamani) t.eklenmeZamani = relativeTimeTr(t.yayinZamani);
return t;
}
const bugunEklenenSayisi = (liste) => {
const bugun = bugunTR();
return (liste || []).filter((t) => t.kaynak === 'ilan.gov.tr' && t.ilanTarihi === bugun).length;
};
function daysUntil(ddmmyyyy) { function daysUntil(ddmmyyyy) {
if (!ddmmyyyy) return null; if (!ddmmyyyy) return null;
const [d, m, y] = ddmmyyyy.split('.').map(Number); const [d, m, y] = ddmmyyyy.split('.').map(Number);
@@ -130,8 +164,7 @@ function mapAd(ad) {
let aciliyetBadge = null; let aciliyetBadge = null;
if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN'; if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN';
const publishedToday = const ilanTarihi = trGun(ad.publishStartDate);
ad.publishStartDate && new Date(ad.publishStartDate).toDateString() === new Date().toDateString();
return { return {
id: `ilan-${ad.id}`, id: `ilan-${ad.id}`,
@@ -151,8 +184,9 @@ function mapAd(ad) {
brans: ihaleTuru || 'Genel', brans: ihaleTuru || 'Genel',
kurumTipi: classify(INSTITUTION_RULES, ad.advertiserName || '', 'Diğer Kamu Kurumları'), kurumTipi: classify(INSTITUTION_RULES, ad.advertiserName || '', 'Diğer Kamu Kurumları'),
eklenmeZamani: relativeTimeTr(ad.publishStartDate), eklenmeZamani: relativeTimeTr(ad.publishStartDate),
ilanTarihi: ad.publishStartDate ? String(ad.publishStartDate).slice(0, 10) : '', yayinZamani: ad.publishStartDate || null,
isYeni: Boolean(publishedToday), ilanTarihi,
isYeni: Boolean(ilanTarihi) && ilanTarihi === bugunTR(),
isLiveFeed: true, isLiveFeed: true,
aciliyetBadge, aciliyetBadge,
items: [], items: [],
@@ -259,7 +293,7 @@ async function ilanSayfalari(baslangic, adet) {
istekler.push( istekler.push(
ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE).catch((e) => { ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE).catch((e) => {
console.warn(`[ihaleist-api] sayfa ${page} alınamadı: ${e.message}`); console.warn(`[ihaleist-api] sayfa ${page} alınamadı: ${e.message}`);
return { ads: [] }; return { ads: [], hata: true };
}) })
); );
} }
@@ -366,20 +400,31 @@ async function arkaPlandaTamamla(numFound, ilkListe) {
const yeni = [...ilkListe]; const yeni = [...ilkListe];
const gerekliSayfa = Math.min(MAX_PAGES, Math.ceil(numFound / LIST_SIZE)); const gerekliSayfa = Math.min(MAX_PAGES, Math.ceil(numFound / LIST_SIZE));
let page = ILK_PARTI; let page = ILK_PARTI;
let eksiksiz = true; // her sayfa hatasız indi mi?
while (page < gerekliSayfa) { while (page < gerekliSayfa) {
const grup = Math.min(ES_ZAMANLI, gerekliSayfa - page); const grup = Math.min(ES_ZAMANLI, gerekliSayfa - page);
const sonuclar = await ilanSayfalari(page, grup); const sonuclar = await ilanSayfalari(page, grup);
if (sonuclar.some((r) => r.hata)) eksiksiz = false;
const ads = sonuclar.flatMap((r) => r.ads || []); const ads = sonuclar.flatMap((r) => r.ads || []);
if (!ads.length) break; if (!ads.length) { eksiksiz = false; break; }
const eklenen = ilanlariBirlestir(yeni, ads); const eklenen = ilanlariBirlestir(yeni, ads);
if (eklenen.length) yeni.push(...eklenen); if (eklenen.length) yeni.push(...eklenen);
page += grup; page += grup;
await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için
} }
// Yalnızca daha zengin bir liste elde ettiysek devreye al // EKSİKSİZ inen liste her zaman devreye girer — kaynaktaki ihale sayısı
if (yeni.length >= cache.tenders.length) { // düşse bile. Eskiden "yeni liste eskisinden küçükse alma" kuralı vardı;
// kaynak 3.372'lik zirveden 3.100'e inince havuz o zirve anında DONDU:
// günlerce yeni ilan eklenmedi, "bugün eklenen" 0 kaldı. Yarım inen liste
// ise yalnızca eldekinden zenginse alınır (kullanıcı eksik listeye düşmesin).
if (eksiksiz || yeni.length >= cache.tenders.length) {
if (yeni.length < cache.tenders.length) {
console.log(`[ihaleist-api] havuz küçüldü: ${cache.tenders.length} → ${yeni.length} (kaynak ${numFound}); güncel liste devrede`);
}
cache.tenders = yeni; cache.tenders = yeni;
cache.todayCount = yeni.filter((t) => t.isYeni).length; cache.todayCount = bugunEklenenSayisi(yeni);
} else {
console.warn(`[ihaleist-api] yenileme eksik indi (${yeni.length}/${numFound}); eldeki ${cache.tenders.length} ilan korunuyor`);
} }
console.log(`[ihaleist-api] havuz hazır: ${cache.tenders.length}/${numFound} ilan`); console.log(`[ihaleist-api] havuz hazır: ${cache.tenders.length}/${numFound} ilan`);
havuzKaydet(numFound, cache.tenders.length); havuzKaydet(numFound, cache.tenders.length);
@@ -513,7 +558,7 @@ async function getTenders(force = false) {
// Yenileme: eldeki dolu listeyi KORU, yenisi arka planda hazırlansın. // Yenileme: eldeki dolu listeyi KORU, yenisi arka planda hazırlansın.
if (!cache.tenders || cache.tenders.length === 0) { if (!cache.tenders || cache.tenders.length === 0) {
cache.tenders = ilkListe; cache.tenders = ilkListe;
cache.todayCount = ilkListe.filter((t) => t.isYeni).length; cache.todayCount = bugunEklenenSayisi(ilkListe);
console.log(`[ihaleist-api] ilk yükleme: ${ilkListe.length} ilan (havuz ${cache.totalCount}) — kalanı arka planda`); console.log(`[ihaleist-api] ilk yükleme: ${ilkListe.length} ilan (havuz ${cache.totalCount}) — kalanı arka planda`);
} else { } else {
console.log(`[ihaleist-api] yenileme başladı (mevcut ${cache.tenders.length} ilan sunulmaya devam ediyor)`); console.log(`[ihaleist-api] yenileme başladı (mevcut ${cache.tenders.length} ilan sunulmaya devam ediyor)`);
@@ -589,6 +634,23 @@ async function tendersPayload(force = false) {
if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker'); if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker');
if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)'); if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)');
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 { return {
success: true, success: true,
source: sources.join(' + '), source: sources.join(' + '),
@@ -663,6 +725,84 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { tarayiciVar: await browserAvailable() }); 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 });
}
// --- Ürün araması: metni ihale DOKÜMANININ içinde arar ---
// EKAP teklif cetvelini kendi tarafında indeksliyor; doküman indirmeye
// gerek yok. "ORTOPEDİK VİDA" hiçbir başlıkta geçmiyor ama teklif
// cetvellerinde geçiyor — firmanın kendi ürününü bulma yolu bu.
if (url.pathname === '/api/urun-ara') {
const q = (url.searchParams.get('q') || '').trim();
const okas = (url.searchParams.get('okas') || '').split(',').map((k) => k.trim()).filter(Boolean);
if (!okas.length && q.length < 3) return json(res, 400, { success: false, error: 'en az 3 karakter' });
const skip = Math.max(0, Number(url.searchParams.get('skip')) || 0);
const take = Math.min(50, Math.max(1, Number(url.searchParams.get('take')) || 20));
const teknik = url.searchParams.get('teknik') === '1';
const anahtar = `${okas.length ? 'okas:' + okas.join(',') : q}|${skip}|${take}|${teknik}`;
const simdi = Date.now();
const onbellek = urunAramaCache.get(anahtar);
if (onbellek && simdi - onbellek.zaman < 5 * 60 * 1000) {
return json(res, 200, { success: true, onbellekten: true, ...onbellek.veri });
}
try {
const out = okas.length
? await searchByOkas(okas, { skip, take })
: await searchInDocuments(q, { teknikSartname: teknik, skip, take });
urunAramaCache.set(anahtar, { zaman: simdi, veri: out });
if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için
if (url.pathname === '/api/okas/ara') {
const q = (url.searchParams.get('q') || '').trim();
if (q.length < 2) return json(res, 400, { success: false, error: 'en az 2 karakter' });
const anahtar = `okas|${q}`;
const onb = urunAramaCache.get(anahtar);
if (onb && Date.now() - onb.zaman < 60 * 60 * 1000) return json(res, 200, { success: true, kalemler: onb.veri });
try {
const kalemler = await okasAra(q, { take: 30 });
urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler });
return json(res, 200, { success: true, kalemler });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if (url.pathname === '/api/health/kalem') {
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
}
// --- KİK kurul kararları --- // --- KİK kurul kararları ---
if (url.pathname === '/api/kik/search') { if (url.pathname === '/api/kik/search') {
try { try {
@@ -731,7 +871,7 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'POST') { if (req.method === 'POST') {
try { try {
const body = await readBody(req, 12 * 1024 * 1024); // base64 şişmesi payı const body = await readBody(req, 16 * 1024 * 1024); // 10 MB dosya × 1,34 base64 + JSON payı
if (!body.ad || !body.icerikBase64) { if (!body.ad || !body.icerikBase64) {
return json(res, 400, { success: false, error: 'ad ve icerikBase64 gerekli' }); return json(res, 400, { success: false, error: 'ad ve icerikBase64 gerekli' });
} }
@@ -740,6 +880,15 @@ const server = http.createServer(async (req, res) => {
kotaKontrol(user.email, 'belgeAdet', kullanimDurumu.adet, 1, opts); kotaKontrol(user.email, 'belgeAdet', kullanimDurumu.adet, 1, opts);
kotaKontrol(user.email, 'belgeBayt', kullanimDurumu.bayt, buf.length, opts); kotaKontrol(user.email, 'belgeBayt', kullanimDurumu.bayt, buf.length, opts);
// Toplu yüklemede dosyanın hangi ihaleye ait olduğu metninden bulunur
// ("İhale Kayıt Numarası (İKN): 2026/1166496"); kullanıcıya sorulmaz.
// PDF tarayıcıda okunduğu için istemci ikn'yi kendisi gönderir.
let ikn = body.ikn ? String(body.ikn).slice(0, 40) : null;
let ofisMetni = null;
if (sunucuOkuyabilir(body.ad)) {
try { ofisMetni = arsivMetni(String(body.ad), buf); } catch { /* metin çıkmazsa devam */ }
if (!ikn && body.otoIkn && ofisMetni) ikn = iknCikar(ofisMetni);
}
const dosyaAdi = `${Date.now()}-${crypto.randomUUID()}`; const dosyaAdi = `${Date.now()}-${crypto.randomUUID()}`;
fs.writeFileSync(path.join(store.BELGE_DIR, dosyaAdi), buf); fs.writeFileSync(path.join(store.BELGE_DIR, dosyaAdi), buf);
const id = store.addDocument(user.email, { const id = store.addDocument(user.email, {
@@ -749,9 +898,25 @@ const server = http.createServer(async (req, res) => {
mime: body.mime || null, mime: body.mime || null,
dosya: dosyaAdi, dosya: dosyaAdi,
sonKullanma: body.sonKullanma || null, sonKullanma: body.sonKullanma || null,
ikn: body.ikn ? String(body.ikn).slice(0, 40) : null, // ihaleye bağlı belge ikn, // 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 (ikn && ofisMetni) {
try {
const { kalemler, yontem } = kalemleriCikar(ofisMetni);
if (kalemler.length) {
kalemStore.kalemYaz(ikn, kalemler, { dosya: String(body.ad), yontem, ekleyen: user.email });
kalemBilgi = { sayi: kalemler.length, yontem };
console.log(`[kalem] ${ikn} → ${kalemler.length} kalem (${yontem}, ${body.ad})`);
}
} catch (e) {
console.warn('[kalem] çıkarılamadı:', e.message);
}
}
return json(res, 200, { success: true, id, ikn, kalem: kalemBilgi, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) });
} catch (e) { } catch (e) {
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null }); return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
} }
@@ -1107,7 +1272,7 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { return json(res, 200, {
success: true, success: true,
toplamIlan: c.totalCount, toplamIlan: c.totalCount,
bugunEklenen: c.todayCount, bugunEklenen: bugunEklenenSayisi(c.tenders),
yuklenenIlan: c.tenders.length, yuklenenIlan: c.tenders.length,
kaynak: 'ilan.gov.tr', kaynak: 'ilan.gov.tr',
}); });
+197
View File
@@ -0,0 +1,197 @@
/**
* 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' };
}
/**
* Doküman metninden İKN çıkarır — toplu yüklemede dosya hangi ihaleye ait,
* kullanıcıya sormadan bulmak için. İhale dokümanları neredeyse her zaman
* "İhale Kayıt Numarası (İKN): 2026/1166496" ya da "İhale kayıt numarası :
* 2023/1105557" biçiminde başlar.
*/
export function iknCikar(metin) {
const duz = normalizeTr(String(metin || '').slice(0, 20000));
const kaliplar = [
/ihale\s+kayit\s+numarasi\s*(?:\(\s*ikn\s*\))?\s*:?\s*(\d{4}[/-]\d{3,8})/,
/\bikn\s*[:=]?\s*(\d{4}[/-]\d{3,8})/,
];
for (const re of kaliplar) {
const m = re.exec(duz);
if (m) return m[1].replace('-', '/');
}
return null;
}
+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,
};
}
+2
View File
@@ -13,6 +13,7 @@ import { DetailedSearchModal } from './components/DetailedSearchModal';
import { TenderDetailModal } from './components/TenderDetailModal'; import { TenderDetailModal } from './components/TenderDetailModal';
import { CompanyScannerModal } from './components/CompanyScannerModal'; import { CompanyScannerModal } from './components/CompanyScannerModal';
import { AiSpecAnalyzerModal } from './components/AiSpecAnalyzerModal'; import { AiSpecAnalyzerModal } from './components/AiSpecAnalyzerModal';
import { UrunAramaModal } from './components/UrunAramaModal';
import { KanbanBoard } from './components/KanbanBoard'; import { KanbanBoard } from './components/KanbanBoard';
import { DailyBulletin } from './components/tools/DailyBulletin'; import { DailyBulletin } from './components/tools/DailyBulletin';
@@ -78,6 +79,7 @@ const MainContent = () => {
<TenderDetailModal /> <TenderDetailModal />
<CompanyScannerModal /> <CompanyScannerModal />
<AiSpecAnalyzerModal /> <AiSpecAnalyzerModal />
<UrunAramaModal />
</main> </main>
); );
}; };
+11 -1
View File
@@ -1,12 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { Bot, Search, SlidersHorizontal, Gift, Bell, User, ChevronDown, CheckCircle, LogOut, Globe, Sparkles, RefreshCw } from 'lucide-react'; import { Package, Bot, Search, SlidersHorizontal, Gift, Bell, User, ChevronDown, CheckCircle, LogOut, Globe, Sparkles, RefreshCw } from 'lucide-react';
export const Header = () => { export const Header = () => {
const { const {
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
setIsDetailedSearchOpen, setIsDetailedSearchOpen,
setIsUrunAramaOpen,
setIsCompanyScannerOpen, setIsCompanyScannerOpen,
setIsAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
userProfile, userProfile,
@@ -79,6 +80,15 @@ export const Header = () => {
<Search className="w-4 h-4" /> <Search className="w-4 h-4" />
<span className="font-semibold text-xs tracking-wider">ARA</span> <span className="font-semibold text-xs tracking-wider">ARA</span>
</button> </button>
<button
type="button"
onClick={() => setIsUrunAramaOpen(true)}
title="Ürün Arama — ürün adınızı ihale dokümanlarının içinde arar"
className="bg-amber-400 hover:bg-amber-300 text-slate-950 font-black px-3 py-2 rounded-lg text-xs flex items-center gap-1.5 transition-colors shrink-0"
>
<Package className="w-4 h-4" />
<span className="hidden sm:inline tracking-wide">ÜRÜN ARA</span>
</button>
<button <button
type="button" type="button"
onClick={() => setIsDetailedSearchOpen(true)} onClick={() => setIsDetailedSearchOpen(true)}
+5 -1
View File
@@ -22,8 +22,12 @@ export const KanbanBoard = () => {
...tenders.map((t) => t.atananPersonel).filter(Boolean), ...tenders.map((t) => t.atananPersonel).filter(Boolean),
])]; ])];
// Panoda yalnızca takibe alınan ya da bir kolona taşınmış ihaleler durur.
// Sunucu HER ilana varsayılan 'incelenecek' durumu veriyor; onu "panoda"
// saymak 8 bin+ kartı tek seferde çizdirip sekmeyi saniyelerce donduruyordu.
const filteredTenders = tenders.filter(t => { const filteredTenders = tenders.filter(t => {
if (!t.takipte && !t.kanbanStatus) return false; const panoda = t.takipte || (t.kanbanStatus && t.kanbanStatus !== 'incelenecek');
if (!panoda) return false;
if (selectedPersonel && t.atananPersonel !== selectedPersonel) return false; if (selectedPersonel && t.atananPersonel !== selectedPersonel) return false;
return true; return true;
}); });
+41 -1
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, {useState, useEffect } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants'; import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants';
import { import {
@@ -24,6 +24,8 @@ export const LeftSidebar = () => {
setSelectedCpvler, setSelectedCpvler,
toggleCpv, toggleCpv,
cpvBolumListesi, cpvBolumListesi,
setIsUrunAramaOpen,
setUrunAramaBaslangic,
selectedKurumTipi, selectedKurumTipi,
setSelectedKurumTipi, setSelectedKurumTipi,
selectedCity, selectedCity,
@@ -33,6 +35,23 @@ export const LeftSidebar = () => {
const [sektorAra, setSektorAra] = useState(''); const [sektorAra, setSektorAra] = useState('');
const [cpvAra, setCpvAra] = useState(''); const [cpvAra, setCpvAra] = useState('');
// OKAS kataloğu: idarenin kendi kodu. "ortopedi" başlıkta geçmese de kod yakalar.
const [okasQ, setOkasQ] = useState('');
const [okasSonuc, setOkasSonuc] = useState([]);
useEffect(() => {
const t = okasQ.trim();
if (t.length < 2) { setOkasSonuc([]); return; }
let iptal = false;
const z = setTimeout(() => import('../services/kalem')
.then(({ okasAra }) => okasAra(t))
.then((k) => { if (!iptal) setOkasSonuc(k.slice(0, 12)); })
.catch(() => {}), 350);
return () => { iptal = true; clearTimeout(z); };
}, [okasQ]);
const okasIleAra = (k) => {
setUrunAramaBaslangic({ okasKod: k.kod, okasAd: k.ad });
setIsUrunAramaOpen(true);
};
const [openSections, setOpenSections] = useState({ const [openSections, setOpenSections] = useState({
alimTuru: true, alimTuru: true,
sektorler: true, sektorler: true,
@@ -264,6 +283,27 @@ export const LeftSidebar = () => {
<p className="text-[11px] text-slate-500 mb-2 leading-relaxed"> <p className="text-[11px] text-slate-500 mb-2 leading-relaxed">
CPV, AB'nin ihale konusunu sınıflandırdığı standart koddur. Türkiye'deki NACE/faaliyet kodunun yerine geçmez — NACE firmanızı, CPV ihalenin konusunu sınıflandırır. CPV, AB'nin ihale konusunu sınıflandırdığı standart koddur. Türkiye'deki NACE/faaliyet kodunun yerine geçmez — NACE firmanızı, CPV ihalenin konusunu sınıflandırır.
</p> </p>
{/* OKAS: EKAP'ta idarenin seçtiği kod. Bulunan kodla EKAP'ta arama açılır. */}
<div className="mb-2 relative">
<input
value={okasQ}
onChange={(e) => setOkasQ(e.target.value)}
placeholder="OKAS kodu bul: ortopedik, röntgen…"
className="w-full border border-indigo-200 bg-indigo-50/40 rounded-lg px-2 py-1.5 text-xs focus:outline-hidden focus:ring-2 focus:ring-indigo-500/30"
/>
{okasSonuc.length > 0 && (
<div className="absolute z-20 left-0 right-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-lg max-h-52 overflow-y-auto">
{okasSonuc.map((k) => (
<button key={k.kod} type="button" onClick={() => { okasIleAra(k); setOkasQ(''); }}
title="Bu OKAS koduyla EKAP'ta ara"
className="w-full text-left px-2 py-1.5 text-[11px] hover:bg-indigo-50 flex items-baseline gap-1.5">
<span className="font-mono font-bold text-indigo-700 shrink-0">{k.kod}</span>
<span className="text-slate-700 truncate">{k.ad}</span>
</button>
))}
</div>
)}
</div>
<input <input
value={cpvAra} value={cpvAra}
onChange={(e) => setCpvAra(e.target.value)} onChange={(e) => setCpvAra(e.target.value)}
+8 -1
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { Database, PlusCircle, Eye, Layers, Trophy, TrendingDown } from 'lucide-react'; import { Database, PlusCircle, Eye, Layers, Trophy, TrendingDown } from 'lucide-react';
import { bugunEklendi, acikIhale } from '../services/ihaleTarih';
export const LiveStatsBar = () => { export const LiveStatsBar = () => {
const { liveStats, tenders } = useApp(); const { liveStats, tenders } = useApp();
@@ -8,6 +9,12 @@ export const LiveStatsBar = () => {
const yukluIhale = tenders.length; const yukluIhale = tenders.length;
const takibimdeki = tenders.filter((t) => t.takipte).length; const takibimdeki = tenders.filter((t) => t.takipte).length;
const kalemSayisi = tenders.reduce((s, t) => s + (t.items?.length || 0), 0); const kalemSayisi = tenders.reduce((s, t) => s + (t.items?.length || 0), 0);
// Üst kartla (BUGÜN EKLENENLER) AYNI ölçüt: bugün yayımlanan ve süresi
// geçmemiş ilanlar (ilan.gov.tr + DT). İki bant farklı sayı göstermesin.
const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
const bugunEklenen = tenders.length
? tenders.filter((t) => acikIhale(t, bugun) && bugunEklendi(t)).length
: liveStats.bugunEklenen;
// Havuz sağlığı: kaynak (ilan.gov.tr) sayısı 7 GÜNLÜK ORTALAMANIN çok altındaysa // Havuz sağlığı: kaynak (ilan.gov.tr) sayısı 7 GÜNLÜK ORTALAMANIN çok altındaysa
// bunu açıkça söyleriz — kullanıcı "veri mi kayboldu?" diye düşünmesin. // bunu açıkça söyleriz — kullanıcı "veri mi kayboldu?" diye düşünmesin.
@@ -25,7 +32,7 @@ export const LiveStatsBar = () => {
const stats = [ const stats = [
{ deger: liveStats.toplamIhale, etiket: 'Canlı İhale Havuzu', renk: 'text-white', dot: true }, { deger: liveStats.toplamIhale, etiket: 'Canlı İhale Havuzu', renk: 'text-white', dot: true },
{ deger: yukluIhale, etiket: 'Yüklü İhale', renk: 'text-white', icon: Database, iconRenk: 'text-blue-400' }, { deger: yukluIhale, etiket: 'Yüklü İhale', renk: 'text-white', icon: Database, iconRenk: 'text-blue-400' },
{ deger: liveStats.bugunEklenen, etiket: 'Bugün Eklenen', renk: 'text-emerald-400', icon: PlusCircle, iconRenk: 'text-emerald-400' }, { deger: bugunEklenen, etiket: 'Bugün Eklenen', renk: 'text-emerald-400', icon: PlusCircle, iconRenk: 'text-emerald-400' },
{ deger: kalemSayisi, etiket: 'Kalem', renk: 'text-white', icon: Layers, iconRenk: 'text-indigo-400' }, { deger: kalemSayisi, etiket: 'Kalem', renk: 'text-white', icon: Layers, iconRenk: 'text-indigo-400' },
{ deger: liveStats.arsiv, etiket: 'Sonuç Arşivi', renk: 'text-white', icon: Trophy, iconRenk: 'text-emerald-400' }, { deger: liveStats.arsiv, etiket: 'Sonuç Arşivi', renk: 'text-white', icon: Trophy, iconRenk: 'text-emerald-400' },
{ deger: takibimdeki, etiket: 'Takibimdeki', renk: 'text-white', icon: Eye, iconRenk: 'text-amber-400' }, { deger: takibimdeki, etiket: 'Takibimdeki', renk: 'text-white', icon: Eye, iconRenk: 'text-amber-400' },
+28 -14
View File
@@ -1,49 +1,62 @@
import React from 'react'; import React, { useMemo } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { Layers, CalendarPlus, Star, BellRing, EyeOff } from 'lucide-react'; import { Layers, CalendarPlus, Star, BellRing, EyeOff } from 'lucide-react';
import { acikIhale, hizliFiltreGecer } from '../services/ihaleTarih';
export const SummaryCards = () => { export const SummaryCards = () => {
const { tenders, quickFilter, setQuickFilter, setActiveTab } = useApp(); const { tenders, quickFilter, setQuickFilter, setActiveTab, oncekiGiris, favoriteKurumlar } = useApp();
const activeCount = tenders.filter(t => t.durum === 'Aktif').length; // Sayımlar listedeki filtreyle AYNI ölçütü kullanır (ihaleTarih.js); karta
const todayCount = tenders.filter(t => (t.eklenmeZamani || '').includes('önce') || (t.eklenmeZamani || '').includes('az')).length; // tıklayınca gelen liste ile karttaki sayı birbirini tutar.
const trackedCount = tenders.filter(t => t.takipte).length; const sayim = useMemo(() => {
const sinceLastLoginCount = 14; // Mocked counter const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
const unreadCount = tenders.filter(t => !t.incelendi).length; const secenek = { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris };
const say = (id) => tenders.filter((t) => acikIhale(t, bugun) && hizliFiltreGecer(t, id, secenek)).length;
return {
all: say('all'),
bugun_eklenenler: say('bugun_eklenenler'),
takiptekiler: say('takiptekiler'),
son_giris: say('son_giris'),
incelenmedi: say('incelenmedi'),
};
}, [tenders, oncekiGiris, favoriteKurumlar]);
const cards = [ const cards = [
{ {
id: 'all', id: 'all',
label: 'AKTİF İHALELER', label: 'AKTİF İHALELER',
count: activeCount, count: sayim.all,
icon: Layers, icon: Layers,
color: 'blue' color: 'blue'
}, },
{ {
id: 'bugun_eklenenler', id: 'bugun_eklenenler',
label: 'BUGÜN EKLENENLER', label: 'BUGÜN EKLENENLER',
count: todayCount, count: sayim.bugun_eklenenler,
icon: CalendarPlus, icon: CalendarPlus,
color: 'emerald' color: 'emerald'
}, },
{ {
id: 'takiptekiler', id: 'takiptekiler',
label: 'TAKİP ETTİKLERİM', label: 'TAKİP ETTİKLERİM',
count: trackedCount, count: sayim.takiptekiler,
icon: Star, icon: Star,
color: 'amber' color: 'amber'
}, },
{ {
id: 'son_giris', id: 'son_giris',
label: 'SON GİRİŞTEN BERİ', label: 'SON GİRİŞTEN BERİ',
count: sinceLastLoginCount, count: sayim.son_giris,
icon: BellRing, icon: BellRing,
color: 'purple' color: 'purple',
title: oncekiGiris
? `Önceki ziyaretinizden (${new Date(oncekiGiris).toLocaleString('tr-TR')}) sonra yayımlanan ilanlar`
: 'İlk ziyaretiniz — bugün yayımlanan ilanlar gösteriliyor'
}, },
{ {
id: 'incelenmedi', id: 'incelenmedi',
label: 'İNCELENMEMİŞ', label: 'İNCELENMEMİŞ',
count: unreadCount, count: sayim.incelenmedi,
icon: EyeOff, icon: EyeOff,
color: 'rose' color: 'rose'
} }
@@ -58,6 +71,7 @@ export const SummaryCards = () => {
return ( return (
<div <div
key={card.id} key={card.id}
title={card.title}
onClick={() => { onClick={() => {
setQuickFilter(card.id); setQuickFilter(card.id);
setActiveTab('list'); setActiveTab('list');
@@ -70,7 +84,7 @@ export const SummaryCards = () => {
> >
<div> <div>
<p className="text-[11px] font-bold tracking-wider text-slate-500 uppercase">{card.label}</p> <p className="text-[11px] font-bold tracking-wider text-slate-500 uppercase">{card.label}</p>
<p className="text-2xl font-black text-slate-900 mt-1">{card.count}</p> <p className="text-2xl font-black text-slate-900 mt-1">{card.count.toLocaleString('tr-TR')}</p>
</div> </div>
<div className={`w-11 h-11 rounded-xl flex items-center justify-center transition-transform group-hover:scale-105 ${ <div className={`w-11 h-11 rounded-xl flex items-center justify-center transition-transform group-hover:scale-105 ${
card.color === 'blue' ? 'bg-blue-100 text-blue-600' : card.color === 'blue' ? 'bg-blue-100 text-blue-600' :
+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'; import { Star, MapPin, Clock, Tag, Package, Building2, AlertTriangle, Sparkles, CalendarDays, Megaphone } from 'lucide-react';
export const TenderCard = ({ tender }) => { 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 isFavKurum = favoriteKurumlar?.includes((tender.kurum || '').trim());
const [day, monthYear] = tender.tarih ? tender.tarih.split('.') : ['01', '01.2026']; 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>İhale: {tender.tarih}{tender.saat ? ` ${tender.saat}` : ''}</span>
</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 && ( {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"> <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" /> <Clock className="w-3 h-3 text-slate-500" />
@@ -150,8 +161,42 @@ export const TenderCard = ({ tender }) => {
)} )}
</div> </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 */} {/* Material Items Preview Box */}
{tender.items && tender.items.length > 0 && ( {!tender.kalemSayisi && tender.items && tender.items.length > 0 && (
<div <div
onClick={() => setActiveTenderDetail(tender)} 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" 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 React, { useState } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { TenderDocuments } from './TenderDocuments'; import { TenderDocuments } from './TenderDocuments';
import { TeklifCetveliKalemleri } from './TeklifCetveliKalemleri';
import { import {
X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare, X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare,
DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck, DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck,
@@ -213,7 +214,11 @@ END:VCALENDAR`;
</div> </div>
</div> </div>
{/* Ortak havuzdan teklif cetveli kalemleri */}
<TeklifCetveliKalemleri ikn={tender.ikn} />
{/* Material Table */} {/* Material Table */}
{tender.items?.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="font-bold text-sm text-slate-900">Malzeme / Kalem Listesi ({tender.items?.length || 0} Kalem)</h3> <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"> <div className="border border-slate-200 rounded-xl overflow-hidden shadow-2xs">
@@ -243,6 +248,7 @@ END:VCALENDAR`;
</table> </table>
</div> </div>
</div> </div>
)}
{/* Team Notes Area */} {/* Team Notes Area */}
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-4"> <div className="bg-white p-5 rounded-xl border border-slate-200 space-y-4">
+25 -3
View File
@@ -34,11 +34,26 @@ export const TenderDocuments = ({ tender }) => {
const f = e.target.files?.[0]; const f = e.target.files?.[0];
e.target.value = ''; e.target.value = '';
if (!f) return; if (!f) return;
if (f.size > 10 * 1024 * 1024) {
setError(`Yüklenemedi: ${f.name} ${(f.size / 1048576).toFixed(1)} MB — sınır 10 MB.`);
return;
}
setYukleniyor(true); setError(null); setYukleniyor(true); setError(null);
try { 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(); belgeleriYukle();
setBilgi({ yuklendi: f.name }); setBilgi({ yuklendi: f.name, kalem });
} catch (err) { } catch (err) {
setError(err.kod === 'KOTA_ASILDI' ? err.message : `Yüklenemedi: ${err.message}`); setError(err.kod === 'KOTA_ASILDI' ? err.message : `Yüklenemedi: ${err.message}`);
} finally { setYukleniyor(false); } } finally { setYukleniyor(false); }
@@ -229,7 +244,14 @@ export const TenderDocuments = ({ tender }) => {
<p className="text-[11px] text-slate-500"> <p className="text-[11px] text-slate-500">
{bilgi.dokumanSayisi != null && <>EKAP'ta {bilgi.dokumanSayisi} doküman kayıtlı. </>} {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.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> </p>
)} )}
+13 -25
View File
@@ -3,23 +3,17 @@ import { useApp } from '../context/AppContext';
import { TenderCard } from './TenderCard'; import { TenderCard } from './TenderCard';
import { Download, SlidersHorizontal, Layers, Loader2 } from 'lucide-react'; import { Download, SlidersHorizontal, Layers, Loader2 } from 'lucide-react';
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { acikIhale, hizliFiltreGecer, GECMIS_FILTRELER } from '../services/ihaleTarih';
// İl adları kaynaklar arasında farklı yazılabiliyor (Ankara / ANKARA); // İl adları kaynaklar arasında farklı yazılabiliyor (Ankara / ANKARA);
// filtre büyük-küçük harf ve Türkçe karakter duyarlılığına takılmasın. // filtre büyük-küçük harf ve Türkçe karakter duyarlılığına takılmasın.
const normTr = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR'); const normTr = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR');
// "gg.aa.yyyy" → Date. Ayrıştırılamayanı GEÇMİŞ SAYMAYIZ: tarihi belirsiz bir
// ihaleyi sessizce gizlemek, geçmiş bir ihaleyi göstermekten daha kötüdür.
const ihaleGunu = (v) => {
const m = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(String(v || '').trim());
if (!m) return null;
return new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
};
export const TenderList = () => { export const TenderList = () => {
const { const {
tenders, tenders,
searchQuery, searchQuery,
kalemEslesen,
selectedCity, selectedCity,
selectedAlimTuru, selectedAlimTuru,
selectedSektorler, selectedSektorler,
@@ -28,7 +22,8 @@ export const TenderList = () => {
quickFilter, quickFilter,
onlyValidOnly, onlyValidOnly,
setOnlyValidOnly, setOnlyValidOnly,
favoriteKurumlar favoriteKurumlar,
oncekiGiris
} = useApp(); } = useApp();
const [sortBy, setSortBy] = useState('newest'); // 'newest', 'urgent', 'distant' const [sortBy, setSortBy] = useState('newest'); // 'newest', 'urgent', 'distant'
@@ -52,26 +47,19 @@ export const TenderList = () => {
const matchKurum = (tender.kurum || '').toLowerCase().includes(q); const matchKurum = (tender.kurum || '').toLowerCase().includes(q);
const matchIkn = (tender.ikn || '').toLowerCase().includes(q); const matchIkn = (tender.ikn || '').toLowerCase().includes(q);
const matchItems = tender.items && tender.items.some(i => (i.ad || '').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" // Geçmiş ihaleler: varsayılan olarak GİZLİ. Kullanıcı "geçmişi de göster"
// derse (onlyValidOnly=false) hepsi gelir. Tarihi okunamayan kayıt gizlenmez. // derse (onlyValidOnly=false) hepsi gelir. Tarihi okunamayan kayıt gizlenmez.
if (onlyValidOnly) { // Arşiv / sonuçlananlar gibi geçmişi hedefleyen hızlı filtrelerde kapı kapalı.
const gun = ihaleGunu(tender.tarih); if (onlyValidOnly && !GECMIS_FILTRELER.has(quickFilter) && !acikIhale(tender, bugun)) return false;
if (gun && gun < bugun) return false;
}
// Quick Filter // Hızlı filtre (üst kartlar + sol menü) — ölçütler ihaleTarih.js'de tek yerde
if (quickFilter === 'favori_kurumlar' && !(favoriteKurumlar || []).includes((tender.kurum || '').trim())) return false; if (!hizliFiltreGecer(tender, quickFilter, { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris })) return false;
if (quickFilter === 'dogrudan_temin' && tender.alimTuru !== 'Doğrudan Temin') return false;
if (quickFilter === 'bugun_eklenenler' && !((tender.eklenmeZamani || '').includes('önce') || (tender.eklenmeZamani || '').includes('az'))) return false;
if (quickFilter === 'incelenenler' && !tender.incelendi) return false;
if (quickFilter === 'incelenmedi' && tender.incelendi) return false;
if (quickFilter === 'takiptekiler' && !tender.takipte) return false;
if (quickFilter === 'sozlesmeler' && tender.durum !== 'Sözleşme İmzalandı') return false;
if (quickFilter === 'sonuclananlar' && tender.durum !== 'Sonuçlandı') return false;
if (quickFilter === 'iptal' && tender.durum !== 'İptal') return false;
// Dropdown Filters // Dropdown Filters
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false; if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
@@ -87,7 +75,7 @@ export const TenderList = () => {
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih); if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
return 0; return 0;
}); });
}, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun]); }, [tenders, searchQuery, kalemEslesen, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]);
const gorunenTenders = filteredTenders.slice(0, gosterilen); const gorunenTenders = filteredTenders.slice(0, gosterilen);
const dahaVar = gosterilen < filteredTenders.length; const dahaVar = gosterilen < filteredTenders.length;
+22 -5
View File
@@ -1,10 +1,23 @@
import React, { useRef, useState, useEffect, useCallback } from 'react'; import React, { useRef, useState, useEffect, useCallback, useTransition } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu, ShieldAlert, Globe2, Gavel, CreditCard, ChevronLeft, ChevronRight } from 'lucide-react'; import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu, ShieldAlert, Globe2, Gavel, CreditCard, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
export const TopNavigation = () => { export const TopNavigation = () => {
const { activeTab, setActiveTab, isAdmin } = useApp(); const { activeTab, setActiveTab, isAdmin } = useApp();
// Ağır sekmeler (10 bin+ kayıtlı havuzda Kanban, analiz vb.) tıklamadan
// sonra saniyelerce hiçbir tepki vermiyordu — kullanıcı "tıklanmıyor" sanıyordu.
// Sekme değişimi geçiş (transition) olarak yapılır: tıklanan sekme ANINDA
// vurgulanır ve dönen simge gösterilir, ağır görünüm arka planda hazırlanır.
const [isPending, startTransition] = useTransition();
const [hedefTab, setHedefTab] = useState(null);
const sekmeSec = (id) => {
if (id === activeTab) return;
setHedefTab(id);
startTransition(() => setActiveTab(id));
};
const bekleyen = isPending ? hedefTab : null;
const navItems = [ const navItems = [
{ id: 'list', label: 'Ana İhale Listesi', icon: LayoutGrid }, { id: 'list', label: 'Ana İhale Listesi', icon: LayoutGrid },
{ id: 'bulten', label: 'Günlük Bülten', icon: Newspaper }, { id: 'bulten', label: 'Günlük Bülten', icon: Newspaper },
@@ -90,19 +103,23 @@ export const TopNavigation = () => {
<div ref={seritRef} className="flex items-center gap-1 overflow-x-auto py-1 scrollbar-none scroll-smooth"> <div ref={seritRef} className="flex items-center gap-1 overflow-x-auto py-1 scrollbar-none scroll-smooth">
{navItems.map(item => { {navItems.map(item => {
const Icon = item.icon; const Icon = item.icon;
const isActive = activeTab === item.id; const isActive = bekleyen ? bekleyen === item.id : activeTab === item.id;
const yukleniyor = bekleyen === item.id;
return ( return (
<button <button
key={item.id} key={item.id}
data-tab={item.id} data-tab={item.id}
onClick={() => setActiveTab(item.id)} aria-busy={yukleniyor || undefined}
onClick={() => sekmeSec(item.id)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-semibold whitespace-nowrap transition-all select-none ${ className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-semibold whitespace-nowrap transition-all select-none ${
isActive isActive
? 'bg-blue-50 text-blue-700 border border-blue-200 shadow-2xs' ? 'bg-blue-50 text-blue-700 border border-blue-200 shadow-2xs'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}`} }`}
> >
<Icon className={`w-4 h-4 ${isActive ? 'text-blue-600' : 'text-slate-400'}`} /> {yukleniyor
? <Loader2 className="w-4 h-4 text-blue-600 animate-spin" />
: <Icon className={`w-4 h-4 ${isActive ? 'text-blue-600' : 'text-slate-400'}`} />}
<span>{item.label}</span> <span>{item.label}</span>
{item.badge && ( {item.badge && (
<span className="bg-amber-100 text-amber-800 font-bold text-[10px] px-1.5 py-0.5 rounded-full border border-amber-200"> <span className="bg-amber-100 text-amber-800 font-bold text-[10px] px-1.5 py-0.5 rounded-full border border-amber-200">
+307
View File
@@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react';
import { useApp } from '../context/AppContext';
import {
X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info,
} from 'lucide-react';
import { urunAra } from '../services/urunArama';
import { okasAra } from '../services/kalem';
/**
* Ürün Arama — ürün adını ihale dokümanlarının içinde aratır.
*
* İhalelerin sadece başlığına bakmak yetersiz: "ORTOPEDİK VİDA" hiçbir
* başlıkta geçmiyor ama üç ihalenin teklif cetvelinde var, ve o ihalelerin
* adı "VETERİNER İLAÇ ... SARF MALZEME". Bu ekran o boşluğu kapatıyor.
*/
const SAYFA = 20;
const gecmisMi = (tarihSaat) => {
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(String(tarihSaat || ''));
if (!m) return false;
const [, g, a, y, ss = '23', dd = '59'] = m;
return new Date(+y, +a - 1, +g, +ss, +dd) < new Date();
};
export const UrunAramaModal = () => {
const {
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders,
urunAramaBaslangic, setUrunAramaBaslangic,
} = useApp();
const [q, setQ] = useState('');
const [teknik, setTeknik] = useState(false);
const [sadeceAcik, setSadeceAcik] = useState(true);
const [durum, setDurum] = useState({ yukleniyor: false, sonuc: null, hata: null, sayfa: 0 });
// OKAS modu: idarenin kendi sınıflandırma koduyla arama. Serbest metinle
// bulunamayan ("ortopedi" başlıkta hiç geçmiyor) ihaleleri kod yakalar.
const [mod, setMod] = useState('metin'); // 'metin' | 'okas'
const [okasQ, setOkasQ] = useState('');
const [okasAdaylar, setOkasAdaylar] = useState([]);
const [okasSecili, setOkasSecili] = useState([]); // [{kod, ad}]
// Sol menüden bir OKAS koduyla açıldıysa o kodla başla ve hemen ara
useEffect(() => {
if (!isUrunAramaOpen || !urunAramaBaslangic?.okasKod) return;
setMod('okas');
setOkasSecili([{ kod: urunAramaBaslangic.okasKod, ad: urunAramaBaslangic.okasAd || '' }]);
setUrunAramaBaslangic(null);
araOkas([urunAramaBaslangic.okasKod], 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isUrunAramaOpen, urunAramaBaslangic]);
// OKAS adı yazdıkça katalogda ara (350 ms)
useEffect(() => {
const t = okasQ.trim();
if (mod !== 'okas' || t.length < 2) { setOkasAdaylar([]); return; }
let iptal = false;
const z = setTimeout(() => okasAra(t).then((k) => { if (!iptal) setOkasAdaylar(k); }).catch(() => {}), 350);
return () => { iptal = true; clearTimeout(z); };
}, [okasQ, mod]);
if (!isUrunAramaOpen) return null;
const araOkas = async (kodlar, sayfa = 0) => {
if (!kodlar.length) { setDurum((d) => ({ ...d, hata: 'En az bir OKAS kodu seçin.' })); return; }
setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa });
try {
const d = await urunAra('', { skip: sayfa * SAYFA, take: SAYFA, okas: kodlar });
setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa });
} catch (e) {
setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa });
}
};
const ara = async (sayfa = 0) => {
if (mod === 'okas') return araOkas(okasSecili.map((k) => k.kod), sayfa);
const terim = q.trim();
if (terim.length < 3) { setDurum((d) => ({ ...d, hata: 'En az 3 karakter yazın.' })); return; }
setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa });
try {
const d = await urunAra(terim, { skip: sayfa * SAYFA, take: SAYFA, teknik });
setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa });
} catch (e) {
setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa });
}
};
const okasEkle = (k) => {
if (!okasSecili.some((x) => x.kod === k.kod)) setOkasSecili((o) => [...o, { kod: k.kod, ad: k.ad }]);
setOkasQ(''); setOkasAdaylar([]);
};
const sonuc = durum.sonuc;
const liste = (sonuc?.list || []).filter((t) => !sadeceAcik || !gecmisMi(t.ihaleTarihSaat));
const gizlenen = (sonuc?.list?.length || 0) - liste.length;
// Havuzdaki açık ihaleyle eşleşirse kartı doğrudan açabilelim
const havuzda = (ikn) => tenders?.find((t) => t.ikn === ikn) || null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/75 backdrop-blur-xs animate-fade-in">
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[92vh]">
<div className="bg-linear-to-r from-indigo-900 via-blue-900 to-slate-900 text-white px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-400 text-slate-900 flex items-center justify-center shadow-md">
<Package className="w-6 h-6" />
</div>
<div>
<h2 className="font-extrabold text-base">Ürün Arama</h2>
<p className="text-xs text-blue-200">
Ürün adınızı ihale <strong>dokümanlarının içinde</strong> arar — başlıkta geçmese bile bulur.
</p>
</div>
</div>
<button onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg">
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4 overflow-y-auto">
<form onSubmit={(e) => { e.preventDefault(); ara(0); }} className="space-y-3">
<div className="inline-flex items-center gap-1 bg-slate-100 border border-slate-200 rounded-xl p-1">
{[['metin', 'Ürün adıyla'], ['okas', 'OKAS koduyla']].map(([m, ad]) => (
<button key={m} type="button" onClick={() => setMod(m)}
className={`text-[11px] font-extrabold px-3 py-1.5 rounded-lg transition-colors ${
mod === m ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-white'}`}>
{ad}
</button>
))}
</div>
{mod === 'okas' && (
<div className="space-y-2">
<div className="relative">
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={okasQ}
onChange={(e) => setOkasQ(e.target.value)}
placeholder="OKAS adı yazın — örn. ortopedik, röntgen, eldiven…"
className="w-full text-sm pl-9 pr-3 py-2.5 rounded-xl border border-slate-300 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
{okasAdaylar.length > 0 && (
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-slate-200 rounded-xl shadow-lg max-h-56 overflow-y-auto">
{okasAdaylar.map((k) => (
<button key={k.kod} type="button" onClick={() => okasEkle(k)}
className="w-full text-left px-3 py-2 text-xs hover:bg-indigo-50 flex items-baseline gap-2">
<span className="font-mono font-bold text-indigo-700 shrink-0">{k.kod}</span>
<span className="text-slate-800 truncate">{k.ad}</span>
{k.altSayisi > 0 && <span className="text-[10px] text-slate-400 shrink-0">+{k.altSayisi} alt</span>}
</button>
))}
</div>
)}
</div>
{okasSecili.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{okasSecili.map((k) => (
<span key={k.kod} className="inline-flex items-center gap-1.5 bg-indigo-50 border border-indigo-200 text-indigo-800 text-[11px] font-bold px-2 py-1 rounded-lg">
<span className="font-mono">{k.kod}</span> {k.ad}
<button type="button" onClick={() => setOkasSecili((o) => o.filter((x) => x.kod !== k.kod))}
className="text-indigo-400 hover:text-red-600" aria-label="kaldır">✕</button>
</span>
))}
</div>
)}
</div>
)}
<div className="flex gap-2">
{mod === 'metin' && (
<div className="relative flex-1">
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Örn. ortopedik vida, hemogram kiti, kurşun önlük…"
className="w-full text-sm pl-9 pr-3 py-2.5 rounded-xl border border-slate-300 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
)}
{mod === 'okas' && <div className="flex-1" />}
<button
type="submit"
disabled={durum.yukleniyor}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white font-bold text-xs px-5 rounded-xl flex items-center gap-2"
>
{durum.yukleniyor ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
Ara
</button>
</div>
<div className="flex items-center gap-4 flex-wrap text-xs">
{mod === 'metin' && (
<label className="flex items-center gap-1.5 font-semibold text-slate-700 cursor-pointer">
<input type="checkbox" checked={teknik} onChange={(e) => setTeknik(e.target.checked)} className="accent-indigo-600" />
Teknik şartnamede de ara
</label>
)}
<label className="flex items-center gap-1.5 font-semibold text-slate-700 cursor-pointer">
<input type="checkbox" checked={sadeceAcik} onChange={(e) => setSadeceAcik(e.target.checked)} className="accent-indigo-600" />
Sadece teklif verilebilir ihaleler
</label>
</div>
</form>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 flex gap-2.5">
<Info className="w-4 h-4 text-blue-700 shrink-0 mt-0.5" />
<p className="text-[11px] text-blue-900 leading-relaxed">
Arama EKAP'ın doküman dizininde yapılır; hangi kalemin eşleştiğini EKAP döndürmüyor,
yalnızca eşleşen ihaleleri veriyor. Kalem dökümünü görmek için ihalenin dokümanını
yükleyin — kalemler çıkarılıp herkese açılır.
</p>
</div>
{durum.hata && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3 flex gap-2">
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
<p className="text-xs text-red-800">{durum.hata}</p>
</div>
)}
{sonuc && (
<>
<div className="flex items-baseline justify-between gap-2 flex-wrap">
<p className="text-xs font-bold text-slate-900">
{mod === 'okas'
? <>OKAS <span className="text-indigo-700">{okasSecili.map((k) => k.kod).join(', ')}</span> kodlu{' '}</>
: <>Teklif cetvelinde <span className="text-indigo-700">"{q.trim()}"</span> geçen{' '}</>}
<span className="text-indigo-700">{sonuc.totalCount.toLocaleString('tr-TR')}</span> ihale bulundu
</p>
{gizlenen > 0 && (
<p className="text-[11px] text-slate-500">{gizlenen} tanesi tarihi geçtiği için gizlendi</p>
)}
</div>
<div className="space-y-2">
{liste.map((t) => {
const acik = havuzda(t.ikn);
return (
<div key={t.ikn} className="border border-slate-200 rounded-xl p-3 hover:border-indigo-300 hover:bg-indigo-50/30 transition-colors">
<div className="flex items-start justify-between gap-3 flex-wrap">
<p className="font-bold text-xs text-slate-900 flex-1 min-w-0 leading-snug">{t.baslik}</p>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[10px] font-bold bg-slate-100 text-slate-700 px-2 py-0.5 rounded">{t.tur}</span>
<span className="text-[10px] font-mono text-slate-500">{t.ikn}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-600 mt-1.5">
<span className="flex items-center gap-1 truncate max-w-sm">
<Building2 className="w-3 h-3 text-slate-400 shrink-0" />
<span className="truncate">{t.kurum}</span>
</span>
{t.sehir && <span className="flex items-center gap-1"><MapPin className="w-3 h-3 text-rose-500" />{t.sehir}</span>}
{t.ihaleTarihSaat && (
<span className="flex items-center gap-1"><CalendarDays className="w-3 h-3 text-blue-600" />{t.ihaleTarihSaat}</span>
)}
<span className="text-slate-400">{t.durum}</span>
{acik && (
<button
onClick={() => { setActiveTenderDetail(acik); setIsUrunAramaOpen(false); }}
className="font-bold text-indigo-700 hover:underline"
>
Havuzda açık — detayı aç
</button>
)}
</div>
</div>
);
})}
{liste.length === 0 && (
<p className="text-xs text-slate-500 text-center py-6">
{sonuc.totalCount > 0
? 'Bu sayfadaki sonuçların tarihi geçmiş. "Sadece teklif verilebilir" kutusunu kapatarak görebilirsiniz.'
: 'Bu ürün adı hiçbir teklif cetvelinde bulunamadı. Daha kısa/genel bir terim deneyin.'}
</p>
)}
</div>
{sonuc.totalCount > SAYFA && (
<div className="flex items-center justify-center gap-3 pt-2">
<button
disabled={durum.sayfa === 0 || durum.yukleniyor}
onClick={() => ara(durum.sayfa - 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
>
← Önceki
</button>
<span className="text-xs text-slate-500">
{durum.sayfa + 1} / {Math.ceil(sonuc.totalCount / SAYFA).toLocaleString('tr-TR')}
</span>
<button
disabled={(durum.sayfa + 1) * SAYFA >= sonuc.totalCount || durum.yukleniyor}
onClick={() => ara(durum.sayfa + 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
>
Sonraki →
</button>
</div>
)}
</>
)}
</div>
</div>
</div>
);
};
+21
View File
@@ -104,6 +104,27 @@ export const CompetitorRadar = () => {
<div className="text-[11px] text-slate-500 ml-7"> <div className="text-[11px] text-slate-500 ml-7">
🏆 {f.wins} ihale · 💰 {formatTL(f.total)} {f.share ? `· %${f.share} pay` : ''} {f.sectors[0] ? `· ${f.sectors[0]}` : ''} 🏆 {f.wins} ihale · 💰 {formatTL(f.total)} {f.share ? `· %${f.share} pay` : ''} {f.sectors[0] ? `· ${f.sectors[0]}` : ''}
</div> </div>
{/* Kazanan istihbaratı: ne kadar aşağı veriyor, kaç rakibe karşı kazanıyor */}
<div className="text-[11px] mt-1 ml-7 flex flex-wrap gap-1.5">
{f.kirimOrani != null && (
<span className="bg-emerald-50 text-emerald-800 border border-emerald-200 px-1.5 py-0.5 rounded font-bold"
title="Sözleşme bedelinin yaklaşık maliyete göre ortalama kırımı">
↓ %{f.kirimOrani} kırım
</span>
)}
{f.ortTeklif != null && (
<span className="bg-blue-50 text-blue-800 border border-blue-200 px-1.5 py-0.5 rounded font-bold"
title="Kazandığı ihalelerde ortalama geçerli teklif sayısı">
⚔ ort. {f.ortTeklif} teklif
</span>
)}
{f.tekTeklifOrani != null && f.tekTeklifOrani > 0 && (
<span className="bg-amber-50 text-amber-800 border border-amber-200 px-1.5 py-0.5 rounded font-bold"
title="Tek teklifle (rakipsiz) kazandığı ihalelerin oranı">
%{f.tekTeklifOrani} tek teklif
</span>
)}
</div>
</div> </div>
<button <button
onClick={() => addFromReal(f)} onClick={() => addFromReal(f)}
+90 -1
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt } from '../../services/account'; import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt } from '../../services/account';
import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive } from 'lucide-react'; import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive, Layers, Package, CheckCircle2, XCircle } from 'lucide-react';
const KATEGORILER = ['Yasal Belgeler', 'Kalite Belgeleri', 'Mali Belgeler', 'Resmi Kayıtlar', 'Teknik Belgeler', 'Diğer']; const KATEGORILER = ['Yasal Belgeler', 'Kalite Belgeleri', 'Mali Belgeler', 'Resmi Kayıtlar', 'Teknik Belgeler', 'Diğer'];
@@ -38,6 +38,50 @@ export const DocumentVault = () => {
} }
}; };
// 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] = useState([]); // [{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++) {
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);
}
const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn, { otoIkn: true });
ikn = cevap?.ikn || ikn;
let kalem = cevap?.kalem?.sayi || null;
if (ikn && !kalem && metin) {
try {
const { kalemCikar } = await import('../../services/kalem');
kalem = (await kalemCikar({ ikn, metin, dosya: f.name })).sayi || null;
} catch { /* kalem çıkmazsa yükleme yine başarılı */ }
}
guncelle(i, { durum: 'tamam', ikn, kalem });
setDurum(cevap);
} catch (err) {
guncelle(i, { durum: 'hata', hata: err.message });
}
}
setBusy(false);
if (topluRef.current) topluRef.current.value = '';
};
const sil = async (id) => { const sil = async (id) => {
setBusy(true); setHata(null); setBusy(true); setHata(null);
try { setDurum(await deleteDocument(id)); } try { setDurum(await deleteDocument(id)); }
@@ -110,6 +154,51 @@ export const DocumentVault = () => {
</div> </div>
</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.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 && ( {hata && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3"> <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" /> <AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
+50
View File
@@ -49,6 +49,26 @@ export const AppProvider = ({ children }) => {
const [liveStats, setLiveStats] = useState({ toplamIhale: 0, bugunEklenen: 0, arsiv: 0 }); const [liveStats, setLiveStats] = useState({ toplamIhale: 0, bugunEklenen: 0, arsiv: 0 });
const [searchQuery, setSearchQuery] = useState(''); 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 [selectedCity, setSelectedCity] = useState('');
const [selectedAlimTuru, setSelectedAlimTuru] = useState(''); const [selectedAlimTuru, setSelectedAlimTuru] = useState('');
const [selectedKurumTipi, setSelectedKurumTipi] = useState(''); const [selectedKurumTipi, setSelectedKurumTipi] = useState('');
@@ -105,6 +125,26 @@ export const AppProvider = ({ children }) => {
.finally(() => setAuthReady(true)); .finally(() => setAuthReady(true));
}, []); }, []);
// "Son girişten beri" kartı için önceki ziyaretin zamanı. Ziyaret = tarayıcı
// sekmesi oturumu (sessionStorage): sayfa yenilemek sayacı sıfırlamaz, yeni
// sekme/gün açmak önceki damgayı "önceki giriş" yapar. Eskiden sabit 14'tü.
const [oncekiGiris, setOncekiGiris] = useState(null);
useEffect(() => {
if (!authUser) return;
try {
const anahtar = 'ihaleist_son_giris';
const onceki = Number(localStorage.getItem(anahtar)) || null;
const oturum = sessionStorage.getItem('ihaleist_oturum_giris');
if (oturum !== null) { // aynı sekmede yenileme: damga sabit kalır
setOncekiGiris(Number(oturum) || null);
return;
}
setOncekiGiris(onceki);
sessionStorage.setItem('ihaleist_oturum_giris', String(onceki || 0));
localStorage.setItem(anahtar, String(Date.now()));
} catch { /* depolama kapalıysa kart bugünü gösterir */ }
}, [authUser?.email]);
// Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer) // Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer)
useEffect(() => { useEffect(() => {
if (!authUser) { setVeriSenkron(false); setPlan(null); return; } if (!authUser) { setVeriSenkron(false); setPlan(null); return; }
@@ -194,6 +234,10 @@ export const AppProvider = ({ children }) => {
const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false); const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false);
const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false); const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false);
const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false); const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false);
// Ürün araması: metni ihale dokümanlarının içinde arar (EKAP indeksi)
const [isUrunAramaOpen, setIsUrunAramaOpen] = useState(false);
// Sol menüden OKAS koduyla açılınca modal bu kodla başlar
const [urunAramaBaslangic, setUrunAramaBaslangic] = useState(null);
const [aiAnalysisResults, setAiAnalysisResults] = useState(null); const [aiAnalysisResults, setAiAnalysisResults] = useState(null);
// Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da // Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da
@@ -511,6 +555,7 @@ export const AppProvider = ({ children }) => {
handleLogout, handleLogout,
liveStats, liveStats,
searchQuery, searchQuery,
kalemEslesen,
setSearchQuery, setSearchQuery,
selectedCity, selectedCity,
setSelectedCity, setSelectedCity,
@@ -530,6 +575,7 @@ export const AppProvider = ({ children }) => {
toggleCpv, toggleCpv,
cpvBolumListesi, cpvBolumListesi,
onlyValidOnly, onlyValidOnly,
oncekiGiris,
setOnlyValidOnly, setOnlyValidOnly,
quickFilter, quickFilter,
setQuickFilter, setQuickFilter,
@@ -545,6 +591,10 @@ export const AppProvider = ({ children }) => {
scanCompanyWebsite, scanCompanyWebsite,
updateCompanyCapabilities, updateCompanyCapabilities,
isAiSpecAnalyzerOpen, isAiSpecAnalyzerOpen,
isUrunAramaOpen,
setIsUrunAramaOpen,
urunAramaBaslangic,
setUrunAramaBaslangic,
setIsAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
analyzeSpecPdfWithGemini, analyzeSpecPdfWithGemini,
aiAnalysisResults, aiAnalysisResults,
+9 -3
View File
@@ -10,7 +10,13 @@ async function iste(url, opts = {}) {
const res = await fetch(url, { ...opts, headers: { ...auth(), ...(opts.headers || {}) } }); const res = await fetch(url, { ...opts, headers: { ...auth(), ...(opts.headers || {}) } });
const d = await res.json().catch(() => ({})); const d = await res.json().catch(() => ({}));
if (!d.success) { if (!d.success) {
const e = new Error(d.error || `İstek başarısız (HTTP ${res.status})`); // nginx/proxy hataları JSON değil HTML döner; kullanıcıya anlamlı mesaj ver.
const proxyMesaj = {
413: 'Dosya sunucunun kabul ettiği boyutu aşıyor (en fazla 10 MB).',
502: 'Sunucuya ulaşılamadı, biraz sonra tekrar deneyin.',
504: 'Sunucu zamanında yanıt vermedi; büyük dosyada tekrar deneyin.',
}[res.status];
const e = new Error(d.error || proxyMesaj || `İstek başarısız (HTTP ${res.status})`);
e.kod = d.kod; e.kod = d.kod;
throw e; throw e;
} }
@@ -38,7 +44,7 @@ export const revokeApiKey = (id) => iste(`/api/me/apikeys?id=${encodeURIComponen
export const listDocuments = () => iste('/api/me/documents'); export const listDocuments = () => iste('/api/me/documents');
export const deleteDocument = (id) => iste(`/api/me/documents?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); export const deleteDocument = (id) => iste(`/api/me/documents?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
export async function uploadDocument(file, kategori, ikn) { export async function uploadDocument(file, kategori, ikn, ek = {}) {
const icerikBase64 = await new Promise((resolve, reject) => { const icerikBase64 = await new Promise((resolve, reject) => {
const r = new FileReader(); const r = new FileReader();
r.onload = () => resolve(String(r.result).split(',')[1]); r.onload = () => resolve(String(r.result).split(',')[1]);
@@ -47,7 +53,7 @@ export async function uploadDocument(file, kategori, ikn) {
}); });
return iste('/api/me/documents', { return iste('/api/me/documents', {
method: 'POST', method: 'POST',
body: JSON.stringify({ ad: file.name, mime: file.type, kategori, ikn, icerikBase64 }), body: JSON.stringify({ ad: file.name, mime: file.type, kategori, ikn, icerikBase64, ...ek }),
}); });
} }
+7 -1
View File
@@ -20,6 +20,12 @@ export async function analyzeSpecText({ text, fileName, tender, chain, mode }) {
body: JSON.stringify({ text, fileName, tender, chain, mode }), body: JSON.stringify({ text, fileName, tender, chain, mode }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!data.success) throw new Error(data.error || `Analiz başarısız (HTTP ${res.status})`); if (!data.success) {
const proxyMesaj = {
413: 'Doküman metni sunucu sınırını aşıyor; daha küçük bir dosya veya yalnızca ilgili sayfaları deneyin.',
504: 'Analiz zaman aşımına uğradı; tekrar deneyin (derin analiz 1-2 dk sürebilir).',
}[res.status];
throw new Error(data.error || proxyMesaj || `Analiz başarısız (HTTP ${res.status})`);
}
return data; return data;
} }
+25 -2
View File
@@ -34,15 +34,38 @@ export function firmStats(tenders) {
awardCount++; awardCount++;
const name = t.kazananFirma.trim(); const name = t.kazananFirma.trim();
const val = parseTL(t.sozlesmeBedeli); const val = parseTL(t.sozlesmeBedeli);
const cur = map.get(name) || { name, wins: 0, total: 0, sectors: new Set(), cities: new Set() }; const cur = map.get(name) || {
name, wins: 0, total: 0, sectors: new Set(), cities: new Set(),
// Kazanan istihbaratı: kırım oranı (yaklaşık maliyete göre ne kadar
// aşağı verdi) ve rekabet derinliği (kaç istekliye karşı kazandı)
kirimToplam: 0, kirimAdet: 0, teklifToplam: 0, teklifAdet: 0, tekTeklif: 0,
};
cur.wins++; cur.wins++;
if (val != null) cur.total += val; if (val != null) cur.total += val;
const maliyet = parseTL(t.yaklasikMaliyet);
if (val != null && maliyet > 0 && val <= maliyet * 1.5) {
cur.kirimToplam += 1 - val / maliyet;
cur.kirimAdet++;
}
const teklif = Number(t.gecerliTeklifSayisi ?? t.teklifSayisi);
if (Number.isFinite(teklif) && teklif > 0) {
cur.teklifToplam += teklif;
cur.teklifAdet++;
if (teklif === 1) cur.tekTeklif++;
}
if (t.sektor) cur.sectors.add(t.sektor); if (t.sektor) cur.sectors.add(t.sektor);
if (t.sehir) cur.cities.add(t.sehir); if (t.sehir) cur.cities.add(t.sehir);
map.set(name, cur); map.set(name, cur);
} }
const firms = [...map.values()] const firms = [...map.values()]
.map((f) => ({ ...f, sectors: [...f.sectors], cities: [...f.cities] })) .map((f) => ({
...f,
sectors: [...f.sectors], cities: [...f.cities],
// yüzde; veri yoksa null — 0 ile karıştırılmasın
kirimOrani: f.kirimAdet ? Math.round((f.kirimToplam / f.kirimAdet) * 100) : null,
ortTeklif: f.teklifAdet ? Math.round((f.teklifToplam / f.teklifAdet) * 10) / 10 : null,
tekTeklifOrani: f.teklifAdet ? Math.round((f.tekTeklif / f.teklifAdet) * 100) : null,
}))
.sort((a, b) => b.wins - a.wins || b.total - a.total); .sort((a, b) => b.wins - a.wins || b.total - a.total);
const totalMarket = firms.reduce((s, f) => s + f.total, 0); const totalMarket = firms.reduce((s, f) => s + f.total, 0);
return { return {
+76
View File
@@ -0,0 +1,76 @@
/**
* İhale tarih yardımcıları — üst kartlar (SummaryCards) ile liste filtresi
* (TenderList) aynı ölçütü kullansın diye tek yerde.
*
* Kaynaklar tarihi farklı yazıyor:
* ilan.gov.tr → ilanTarihi "2026-09-02"
* DT (EKAP) → ilanTarihi "2026-09-02T00:00:00"
* arşiv → ilanTarihi "02.09.2026"
*/
// "gg.aa.yyyy" → Date. Ayrıştırılamayanı GEÇMİŞ SAYMAYIZ: tarihi belirsiz bir
// ihaleyi sessizce gizlemek, geçmiş bir ihaleyi göstermekten daha kötüdür.
export const ihaleGunu = (v) => {
const m = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(String(v || '').trim());
if (!m) return null;
return new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
};
// Her biçimi "yyyy-aa-gg" dizesine indirger (karşılaştırma için).
export const ilanTarihiISO = (t) => {
const v = String(t?.ilanTarihi || '').trim();
let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(v);
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
m = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(v);
if (m) return `${m[3]}-${m[2]}-${m[1]}`;
return '';
};
const iso = (d) => {
const y = d.getFullYear();
const a = String(d.getMonth() + 1).padStart(2, '0');
const g = String(d.getDate()).padStart(2, '0');
return `${y}-${a}-${g}`;
};
export const bugunISO = () => iso(new Date());
// Bugün yayımlanan ilan. Sunucunun isYeni bayrağı esas; o yoksa ilan tarihine bakılır.
export const bugunEklendi = (t) => Boolean(t?.isYeni) || (ilanTarihiISO(t) !== '' && ilanTarihiISO(t) === bugunISO());
// Önceki ziyaretten (ms) sonra yayımlanan ilan. Kayıt yoksa "bugün" kabul edilir.
export const girisSonrasiEklendi = (t, oncekiGirisMs) => {
const tarih = ilanTarihiISO(t);
if (!tarih) return false;
const esik = oncekiGirisMs ? iso(new Date(oncekiGirisMs)) : bugunISO();
return tarih >= esik;
};
// Süresi geçmemiş (veya tarihi okunamayan) ihale — listenin varsayılan görünümü.
export const acikIhale = (t, bugun) => {
const gun = ihaleGunu(t?.tarih);
return !(gun && gun < bugun);
};
// Bu hızlı filtreler doğası gereği GEÇMİŞ ihaleleri hedefler; "sadece açık
// ihaleler" kapısı bunlara uygulanmaz, yoksa hiç sonuç çıkmaz.
export const GECMIS_FILTRELER = new Set(['arsiv', 'sonuclananlar', 'sozlesmeler', 'iptal']);
// Hızlı filtre (üst kartlar + sol menü) ölçütü. Bilinmeyen id → her şey geçer.
export const hizliFiltreGecer = (t, id, { favoriteKurumlar = [], oncekiGirisMs = null } = {}) => {
switch (id) {
case 'favori_kurumlar': return favoriteKurumlar.includes((t.kurum || '').trim());
case 'dogrudan_temin': return t.alimTuru === 'Doğrudan Temin';
case 'ihaleler': return t.alimTuru !== 'Doğrudan Temin' && t.durum !== 'Sonuçlandı';
case 'bugun_eklenenler': return bugunEklendi(t);
case 'son_giris': return girisSonrasiEklendi(t, oncekiGirisMs);
case 'incelenenler': return Boolean(t.incelendi);
case 'incelenmedi': return !t.incelendi;
case 'takiptekiler': return Boolean(t.takipte);
case 'sozlesmeler': return t.durum === 'Sözleşme İmzalandı';
case 'sonuclananlar': return t.durum === 'Sonuçlandı';
case 'arsiv': return t.durum === 'Sonuçlandı';
case 'iptal': return t.durum === 'İptal';
default: return true;
}
};
+59
View File
@@ -0,0 +1,59 @@
/**
* 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;
}
/**
* Doküman metninden İKN — toplu yüklemede PDF tarayıcıda okunduğu için
* sunucudaki kalem-cikar.mjs#iknCikar'ın istemci eşi.
*/
export function iknCikar(metin) {
const duz = String(metin || '').slice(0, 20000).toLocaleLowerCase('tr-TR')
.replace(/ı/g, 'i').replace(/ş/g, 's').replace(/ğ/g, 'g').replace(/ç/g, 'c').replace(/ö/g, 'o').replace(/ü/g, 'u');
const m = /ihale\s+kayit\s+numarasi\s*(?:\(\s*ikn\s*\))?\s*:?\s*(\d{4}[/-]\d{3,8})/.exec(duz)
|| /\bikn\s*[:=]?\s*(\d{4}[/-]\d{3,8})/.exec(duz);
return m ? m[1].replace('-', '/') : null;
}
/** OKAS kataloğunda ad ile arama — "ortoped" → 33141700 Ortopedik malzemeler */
export async function okasAra(q) {
const r = await fetch(`/api/okas/ara?q=${encodeURIComponent(q)}`);
const d = await r.json().catch(() => ({}));
if (!d.success) throw new Error(d.error || 'OKAS araması başarısız');
return d.kalemler || [];
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Ürün araması — metni ihale DOKÜMANININ içinde arar.
*
* EKAP teklif cetvelini kendi tarafında indeksliyor, yani doküman indirmeye
* (ve CAPTCHA'ya) gerek yok. Firmanın kendi ürününü bulma yolu bu:
* "ORTOPEDİK VİDA" hiçbir ihale başlığında geçmiyor ama teklif cetvellerinde
* geçiyor — o ihalelerin adı "VETERİNER İLAÇ ... SARF MALZEME".
*/
export async function urunAra(q, { skip = 0, take = 20, teknik = false, okas = [] } = {}) {
const p = new URLSearchParams({ q: q || '', skip: String(skip), take: String(take) });
if (teknik) p.set('teknik', '1');
if (okas.length) p.set('okas', okas.join(','));
const r = await fetch(`/api/urun-ara?${p}`);
const d = await r.json().catch(() => ({}));
if (!d.success) throw new Error(d.error || 'arama başarısız');
return d;
}