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>
This commit is contained in:
@@ -89,10 +89,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 +137,42 @@ 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.
|
||||||
|
*/
|
||||||
|
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((t) => ({
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 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 });
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email
|
|||||||
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 { kalemleriCikar } from './kalem-cikar.mjs';
|
||||||
|
import { searchInDocuments } from './ekap-v2.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 * 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';
|
||||||
@@ -751,6 +755,31 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const sonuc = kalemStore.kalemAra(q);
|
const sonuc = kalemStore.kalemAra(q);
|
||||||
return json(res, 200, { success: true, adet: sonuc.length, sonuclar: sonuc });
|
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();
|
||||||
|
if (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 = `${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 = 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (url.pathname === '/api/health/kalem') {
|
if (url.pathname === '/api/health/kalem') {
|
||||||
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
|
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState } 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ü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 } = 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 });
|
||||||
|
|
||||||
|
if (!isUrunAramaOpen) return null;
|
||||||
|
|
||||||
|
const ara = async (sayfa = 0) => {
|
||||||
|
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 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="flex gap-2">
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -234,6 +234,8 @@ 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);
|
||||||
|
|
||||||
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
|
||||||
@@ -587,6 +589,8 @@ export const AppProvider = ({ children }) => {
|
|||||||
scanCompanyWebsite,
|
scanCompanyWebsite,
|
||||||
updateCompanyCapabilities,
|
updateCompanyCapabilities,
|
||||||
isAiSpecAnalyzerOpen,
|
isAiSpecAnalyzerOpen,
|
||||||
|
isUrunAramaOpen,
|
||||||
|
setIsUrunAramaOpen,
|
||||||
setIsAiSpecAnalyzerOpen,
|
setIsAiSpecAnalyzerOpen,
|
||||||
analyzeSpecPdfWithGemini,
|
analyzeSpecPdfWithGemini,
|
||||||
aiAnalysisResults,
|
aiAnalysisResults,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Ü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 } = {}) {
|
||||||
|
const p = new URLSearchParams({ q, skip: String(skip), take: String(take) });
|
||||||
|
if (teknik) p.set('teknik', '1');
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user