fix: combine detailed tender search fields

This commit is contained in:
2026-09-06 15:59:01 +03:00
parent 9b751296ed
commit 3a5b06cee2
8 changed files with 78 additions and 35 deletions
+8
View File
@@ -1232,3 +1232,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Ürün araması native dialog üst katmanında, kılavuz ise özel portal penceresindeydi. ? ile kılavuz açılınca ortak izolasyon ürün aramasını inert yapıyor fakat native üst katman nedeniyle odak kılavuza geçemiyordu. Bu çakışma tarayıcıda tekrar üretildi. Ürün araması diğer pencerelerle aynı modal yığınını kullanıyor; gövde kaydırma kilidi ve arama durumunun korunması devam ediyor. - Ürün araması native dialog üst katmanında, kılavuz ise özel portal penceresindeydi. ? ile kılavuz açılınca ortak izolasyon ürün aramasını inert yapıyor fakat native üst katman nedeniyle odak kılavuza geçemiyordu. Bu çakışma tarayıcıda tekrar üretildi. Ürün araması diğer pencerelerle aynı modal yığınını kullanıyor; gövde kaydırma kilidi ve arama durumunun korunması devam ediyor.
- On iki tarayıcı kabulü geçti: detaylı arama ilk odağı/ileri-geri Tab, görünüm sınırları, beş alan etiketi, Kayseri+laboratuvar tek sonucu; ürün araması odağı/Tab; ürün üstünde kılavuz ve geri dönüş; sentetik ürün sonucundan doğru ihale detayına geçiş; detay kapanınca başlıktaki ÜRÜN ARA düğmesine dönüş; yeniden açıldığında önceki sorgu ve sonucun korunması. Kanıt `rapor/qa-search-modals-focus.json`. Kılavuz kapanış ölçümü QA düzeneği sıcak yenilenirken alındığı için temiz oturumda tekrarlandı; son ölçüm geçti. - On iki tarayıcı kabulü geçti: detaylı arama ilk odağı/ileri-geri Tab, görünüm sınırları, beş alan etiketi, Kayseri+laboratuvar tek sonucu; ürün araması odağı/Tab; ürün üstünde kılavuz ve geri dönüş; sentetik ürün sonucundan doğru ihale detayına geçiş; detay kapanınca başlıktaki ÜRÜN ARA düğmesine dönüş; yeniden açıldığında önceki sorgu ve sonucun korunması. Kanıt `rapor/qa-search-modals-focus.json`. Kılavuz kapanış ölçümü QA düzeneği sıcak yenilenirken alındığı için temiz oturumda tekrarlandı; son ölçüm geçti.
- QA sunucusu yalnız “QA pencere testi” sorgusuna bir sentetik ürün sonucu döndürüyor; bu kaynak arama kalitesinin testi değildir. 275 test ve build başarılı. Native dialog yerine ortak yığın kullanımı nedeniyle mobil tarayıcı/gerçek ekran okuyucu kabulü ayrıca gereklidir. Genel plan açık. - QA sunucusu yalnız “QA pencere testi” sorgusuna bir sentetik ürün sonucu döndürüyor; bu kaynak arama kalitesinin testi değildir. 275 test ve build başarılı. Native dialog yerine ortak yığın kullanımı nedeniyle mobil tarayıcı/gerçek ekran okuyucu kabulü ayrıca gereklidir. Genel plan açık.
### 2026-09-06 — Detaylı arama alanlarını bağımsız ve birlikte uygulama
- Detaylı arama kurum adını veya İKN'yi ana anahtar kelime alanına kopyalıyordu. Kurum doluysa İKN ve anahtar kelime, yalnız İKN doluysa anahtar kelime sessizce yok sayılıyordu. Anahtar kelime, kurum adı ve kayıt numarası artık ayrı durumlarda tutuluyor ve AND koşuluyla birlikte uygulanıyor. Türkçe karakter normalizasyonu üç aramada da korunuyor.
- Pencere içindeki anahtar kelime, şehir, alım tipi, sektörler, kurum tipi, açık ihale seçimi, kurum adı ve İKN taslak olarak düzenleniyor. Kullanıcı İhaleleri Ara demeden kapatırsa etkin liste değişmiyor; yeniden açıldığında sunucuda uygulanmış filtreler yükleniyor. Temizle önce yalnız formu temizliyor, İhaleleri Ara ile bütün form ölçütleri listeye uygulanıyor.
- Kurum adı ve İKN etkin olduğunda liste üstünde ayrı görünür ölçütler ve ayrı kaldırma düğmeleri var. Ana başlıktaki anahtar kelime temizleme düğmesine erişilebilir ad eklendi. Sol menüdeki Filtreleri Sıfırla düğmesi ayrıntılı kurum/İKN alanlarını da kapsıyor. Kaydedilmiş filtre uygulanınca eski kurum/İKN ölçütleri temizleniyor.
- On beş tarayıcı kontrolü geçti: uygulanmamış taslağın listeyi değiştirmemesi; üç alanın tek Kayseri sonucu vermesi; çelişen kurum + doğru İKN'nin sıfır sonuç vermesi; ölçütlerin bağımsız kaldırılması; sol menü sıfırlaması; kurum tipi alanı; altı ölçütün birlikte uygulanması ve yerel Temizle akışı. Kanıt `rapor/qa-detailed-search-fields.json`. Tarayıcı konsolunda hata yoktu.
- `matchesDetailedSearch` için kurum ve İKN'nin birlikte/bağımsız eşleşmesini doğrulayan regresyon testi eklendi. 276 test ve build başarılı. Test verisi beş sentetik ihaledir; resmi kaynak alanlarının doğruluğunu kanıtlamaz. Genel plan açık.
+41 -29
View File
@@ -5,37 +5,34 @@ import { CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
import { SlidersHorizontal, Search, RotateCcw, X } from 'lucide-react'; import { SlidersHorizontal, Search, RotateCcw, X } from 'lucide-react';
export const DetailedSearchModal = () => { export const DetailedSearchModal = () => {
const { const {isDetailedSearchOpen}=useApp();
isDetailedSearchOpen, return isDetailedSearchOpen?<DetailedSearchForm/>:null;
setIsDetailedSearchOpen, };
searchQuery,
setSearchQuery, const DetailedSearchForm = () => {
selectedCity, const context=useApp();
setSelectedCity, const {setIsDetailedSearchOpen,setActiveTab,sektorListesi}=context;
selectedAlimTuru,
setSelectedAlimTuru,
selectedSektorler,
setSelectedSektorler,
toggleSektor,
sektorListesi,
selectedKurumTipi,
setSelectedKurumTipi,
onlyValidOnly,
setOnlyValidOnly,
setActiveTab
} = useApp();
const titleId=useId(); const titleId=useId();
const modalFocus=useModalFocus(isDetailedSearchOpen,()=>setIsDetailedSearchOpen(false)); const modalFocus=useModalFocus(true,()=>setIsDetailedSearchOpen(false));
const [searchQuery,setSearchQuery]=useState(context.searchQuery);
const [iknQuery, setIknQuery] = useState(''); const [selectedCity,setSelectedCity]=useState(context.selectedCity);
const [kurumQuery, setKurumQuery] = useState(''); const [selectedAlimTuru,setSelectedAlimTuru]=useState(context.selectedAlimTuru);
const [selectedKurumTipi,setSelectedKurumTipi]=useState(context.selectedKurumTipi);
if (!isDetailedSearchOpen) return null; const [selectedSektorler,setSelectedSektorler]=useState(context.selectedSektorler);
const [onlyValidOnly,setOnlyValidOnly]=useState(context.onlyValidOnly);
const [iknQuery,setIknQuery]=useState(context.detailedQueries.registration);
const [kurumQuery,setKurumQuery]=useState(context.detailedQueries.institution);
const toggleSektor=sector=>setSelectedSektorler(old=>old.includes(sector)?old.filter(value=>value!==sector):[...old,sector]);
const handleApplySearch = (e) => { const handleApplySearch = (e) => {
e.preventDefault(); e.preventDefault();
if (kurumQuery) setSearchQuery(kurumQuery); context.setSearchQuery(searchQuery.trim());
else if (iknQuery) setSearchQuery(iknQuery); context.setDetailedQueries({institution:kurumQuery.trim(),registration:iknQuery.trim()});
context.setSelectedCity(selectedCity);
context.setSelectedAlimTuru(selectedAlimTuru);
context.setSelectedKurumTipi(selectedKurumTipi);
context.setSelectedSektorler(selectedSektorler);
context.setOnlyValidOnly(onlyValidOnly);
setActiveTab('list'); setActiveTab('list');
setIsDetailedSearchOpen(false); setIsDetailedSearchOpen(false);
@@ -45,8 +42,9 @@ export const DetailedSearchModal = () => {
setSearchQuery(''); setSearchQuery('');
setSelectedCity(''); setSelectedCity('');
setSelectedAlimTuru(''); setSelectedAlimTuru('');
setSelectedSektorler([]);
setSelectedKurumTipi(''); setSelectedKurumTipi('');
setSelectedSektorler([]);
setOnlyValidOnly(true);
setIknQuery(''); setIknQuery('');
setKurumQuery(''); setKurumQuery('');
}; };
@@ -72,6 +70,7 @@ export const DetailedSearchModal = () => {
{/* Form Body */} {/* Form Body */}
<form onSubmit={handleApplySearch} className="p-6 space-y-4 text-xs"> <form onSubmit={handleApplySearch} className="p-6 space-y-4 text-xs">
<p className="text-slate-600">Doldurduğunuz alanlar birlikte uygulanır. Değişiklikler İhaleleri Ara düğmesine bastığınızda listeye yansır.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -82,7 +81,7 @@ export const DetailedSearchModal = () => {
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Başlık, açıklama içinde aranır..." placeholder="Başlık, kurum, İKN veya kalemde aranır..."
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-800 focus:ring-2 focus:ring-blue-500" className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-800 focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
@@ -168,6 +167,19 @@ export const DetailedSearchModal = () => {
</select> </select>
</div> </div>
<div>
<label className="block font-bold text-slate-700 mb-1">Kurum Tipi</label>
<select
aria-label="Kurum Tipi"
value={selectedKurumTipi}
onChange={(e) => setSelectedKurumTipi(e.target.value)}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-800"
>
<option value="">Tümü</option>
{INSTITUTION_TYPES_LIST.map(type => <option key={type} value={type}>{type}</option>)}
</select>
</div>
</div> </div>
<div className="pt-2"> <div className="pt-2">
+1
View File
@@ -67,6 +67,7 @@ export const Header = () => {
{searchQuery && ( {searchQuery && (
<button <button
type="button" type="button"
aria-label="Anahtar kelime aramasını temizle"
onClick={() => setSearchQuery('')} onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white text-xs" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white text-xs"
> >
+2 -2
View File
@@ -31,7 +31,7 @@ export const LeftSidebar = ({onOpenProductSearch} = {}) => {
setSelectedKurumTipi, setSelectedKurumTipi,
selectedCity, selectedCity,
setSelectedCity, setSelectedCity,
clearAllFilters clearAllFilters,detailedQueries
} = useApp(); } = useApp();
const [sektorAra, setSektorAra] = useState(''); const [sektorAra, setSektorAra] = useState('');
@@ -89,7 +89,7 @@ export const LeftSidebar = ({onOpenProductSearch} = {}) => {
<span className="font-bold text-xs uppercase tracking-wider text-slate-700 flex items-center gap-1.5"> <span className="font-bold text-xs uppercase tracking-wider text-slate-700 flex items-center gap-1.5">
<Compass className="w-4 h-4 text-blue-600" /> Hızlı Erişim <Compass className="w-4 h-4 text-blue-600" /> Hızlı Erişim
</span> </span>
{(selectedRegion || quickFilter !== 'all' || selectedSektorler.length || selectedCpvler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && ( {(detailedQueries.institution || detailedQueries.registration || selectedRegion || quickFilter !== 'all' || selectedSektorler.length || selectedCpvler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && (
<button <button
onClick={clearAllFilters} onClick={clearAllFilters}
className="text-[11px] font-medium text-blue-600 hover:underline" className="text-[11px] font-medium text-blue-600 hover:underline"
+9 -3
View File
@@ -1,5 +1,5 @@
import {matchesRegion,matchesProcurement} from '../services/savedFilterCriteria'; import {matchesRegion,matchesProcurement} from '../services/savedFilterCriteria';
import { matchesTenderSearch } from '../services/tenderSearch'; import { matchesTenderSearch, matchesDetailedSearch } from '../services/tenderSearch';
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { TenderCard } from './TenderCard'; import { TenderCard } from './TenderCard';
@@ -17,6 +17,7 @@ export const TenderList = () => {
const { const {
tenders, tenders,
searchQuery, searchQuery,
detailedQueries,setDetailedQueries,
kalemEslesen, itemSearchState, retryItemSearch, kalemEslesen, itemSearchState, retryItemSearch,
uyumEsigi, uyumEsigi,
selectedCity,selectedRegion,setSelectedRegion, selectedCity,selectedRegion,setSelectedRegion,
@@ -46,6 +47,7 @@ export const TenderList = () => {
return tenders.filter(tender => { return tenders.filter(tender => {
// Search Query // Search Query
if (!matchesTenderSearch(tender,searchQuery,kalemEslesen)) return false; if (!matchesTenderSearch(tender,searchQuery,kalemEslesen)) return false;
if (!matchesDetailedSearch(tender,detailedQueries)) 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.
@@ -71,7 +73,7 @@ export const TenderList = () => {
if (sortBy === 'distant') return compareTenderDeadlines(a,b,-1); if (sortBy === 'distant') return compareTenderDeadlines(a,b,-1);
return 0; return 0;
}); });
}, [tenders, searchQuery, kalemEslesen, uyumEsigi, selectedCity, selectedRegion, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, now, favoriteKurumlar, oncekiGiris]); }, [tenders, searchQuery, detailedQueries, kalemEslesen, uyumEsigi, selectedCity, selectedRegion, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, now, favoriteKurumlar, oncekiGiris]);
const gorunenTenders = filteredTenders.slice(0, gosterilen); const gorunenTenders = filteredTenders.slice(0, gosterilen);
const dahaVar = gosterilen < filteredTenders.length; const dahaVar = gosterilen < filteredTenders.length;
@@ -80,7 +82,7 @@ export const TenderList = () => {
// dizisine bağlansaydı havuz her yenilendiğinde (10 dk) kullanıcı listenin // dizisine bağlansaydı havuz her yenilendiğinde (10 dk) kullanıcı listenin
// başına fırlardı. // başına fırlardı.
useEffect(() => { setGosterilen(SAYFA_ADIM); }, [ useEffect(() => { setGosterilen(SAYFA_ADIM); }, [
searchQuery, selectedCity, selectedRegion, selectedAlimTuru, selectedSektorler, searchQuery, detailedQueries, selectedCity, selectedRegion, selectedAlimTuru, selectedSektorler,
selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly,
]); ]);
@@ -129,6 +131,10 @@ export const TenderList = () => {
return ( return (
<div className="w-full min-w-0 flex-1 space-y-4"> <div className="w-full min-w-0 flex-1 space-y-4">
{Object.entries(detailedQueries).filter(([,value])=>value).map(([key,value])=><div key={key} className="flex gap-3 items-center flex-wrap text-sm bg-blue-50 border rounded p-3">
<span>{key==='institution'?'Kurum adı':'İKN / DT no'}: {value}</span>
<button className="underline text-blue-800" onClick={()=>setDetailedQueries(old=>({...old,[key]:''}))}>{key==='institution'?'Kurum adı filtresini kaldır':'İKN filtresini kaldır'}</button>
</div>)}
{selectedRegion&&<div className="flex gap-3 items-center flex-wrap text-sm bg-blue-50 border rounded p-3"><span>İl bazında bölge filtresi: {selectedRegion}</span><button className="underline text-blue-800" onClick={()=>setSelectedRegion('')}>Bölge filtresini kaldır</button></div>} {selectedRegion&&<div className="flex gap-3 items-center flex-wrap text-sm bg-blue-50 border rounded p-3"><span>İl bazında bölge filtresi: {selectedRegion}</span><button className="underline text-blue-800" onClick={()=>setSelectedRegion('')}>Bölge filtresini kaldır</button></div>}
{/* Header bar above list */} {/* Header bar above list */}
+4
View File
@@ -56,6 +56,7 @@ 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('');
const [detailedQueries,setDetailedQueries]=useState({institution:'',registration:''});
// Kalem havuzunda arama: firmanın kendi ürününü ("eldiven", "ortopedik vida") // 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 // hangi ihalelerin teklif cetvelinde geçtiğini bulması için. Kartta yalnızca
@@ -734,6 +735,7 @@ export const AppProvider = ({ children }) => {
const applySavedFilter = filter => { const applySavedFilter = filter => {
try { try {
const criteria=savedFilterCriteria(filter); const criteria=savedFilterCriteria(filter);
setDetailedQueries({institution:'',registration:''});
setSearchQuery('');setSelectedCpvler([]);setQuickFilter('all'); setSearchQuery('');setSelectedCpvler([]);setQuickFilter('all');
setSelectedCity(criteria.city);setSelectedRegion(criteria.region); setSelectedCity(criteria.city);setSelectedRegion(criteria.region);
setSelectedAlimTuru(criteria.procedure);setSelectedKurumTipi(criteria.institution); setSelectedAlimTuru(criteria.procedure);setSelectedKurumTipi(criteria.institution);
@@ -743,6 +745,7 @@ export const AppProvider = ({ children }) => {
}; };
const clearAllFilters = () => { const clearAllFilters = () => {
setDetailedQueries({institution:'',registration:''});
setSelectedRegion(''); setSelectedRegion('');
setSearchQuery(''); setSearchQuery('');
setSelectedCity(''); setSelectedCity('');
@@ -775,6 +778,7 @@ export const AppProvider = ({ children }) => {
handleLogout, handleLogout,
liveStats, liveStats,
searchQuery, searchQuery,
detailedQueries,setDetailedQueries,
kalemEslesen, itemSearchState, retryItemSearch, kalemEslesen, itemSearchState, retryItemSearch,
setSearchQuery, setSearchQuery,
selectedCity, selectedRegion,setSelectedRegion,applySavedFilter, selectedCity, selectedRegion,setSelectedRegion,applySavedFilter,
+5
View File
@@ -5,6 +5,11 @@ export function matchingItemResults(result, query) {
return result?.query === normalizeTenderSearch(query) ? result.matches : null; return result?.query === normalizeTenderSearch(query) ? result.matches : null;
} }
export function matchesDetailedSearch(tender, {institution='',registration=''} = {}) {
return normalizeTenderSearch(tender.kurum).includes(normalizeTenderSearch(institution)) &&
normalizeTenderSearch(tender.ikn).includes(normalizeTenderSearch(registration));
}
export function matchesTenderSearch(tender, query, itemMatches) { export function matchesTenderSearch(tender, query, itemMatches) {
const q=normalizeTenderSearch(query); const q=normalizeTenderSearch(query);
if (!q) return true; if (!q) return true;
+8 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {normalizeTenderSearch,matchesTenderSearch,matchingItemResults} from '../src/services/tenderSearch.js'; import {normalizeTenderSearch,matchesTenderSearch,matchingItemResults,matchesDetailedSearch} from '../src/services/tenderSearch.js';
test('Turkish search treats case, decomposed characters and spacing consistently across fields',()=>{ test('Turkish search treats case, decomposed characters and spacing consistently across fields',()=>{
for(const value of ['IĞDIR','Iğdır','igdir',' IĞDIR ']) assert.equal(normalizeTenderSearch(value),'igdir'); for(const value of ['IĞDIR','Iğdır','igdir',' IĞDIR ']) assert.equal(normalizeTenderSearch(value),'igdir');
for(const field of ['baslik','kurum']) assert.equal(matchesTenderSearch({[field]:'IĞDIR İL SAĞLIK'},'ığdır'),true); for(const field of ['baslik','kurum']) assert.equal(matchesTenderSearch({[field]:'IĞDIR İL SAĞLIK'},'ığdır'),true);
@@ -10,6 +10,13 @@ test('Turkish search treats case, decomposed characters and spacing consistently
assert.equal(matchesTenderSearch({},' '),true); assert.equal(matchesTenderSearch({},' '),true);
assert.equal(matchesTenderSearch({baslik:'Mikroskop'},'hemogram'),false); assert.equal(matchesTenderSearch({baslik:'Mikroskop'},'hemogram'),false);
}); });
test('detailed institution and registration fields are independent and combine with AND',()=>{
const tender={kurum:'IĞDIR İL SAĞLIK MÜDÜRLÜĞÜ',ikn:'2026/1582991'};
assert.equal(matchesDetailedSearch(tender,{institution:'ığdır',registration:'1582991'}),true);
assert.equal(matchesDetailedSearch(tender,{institution:'ığdır',registration:'1534665'}),false);
assert.equal(matchesDetailedSearch(tender,{institution:'kayseri',registration:'1582991'}),false);
assert.equal(matchesDetailedSearch(tender,{institution:'',registration:''}),true);
});
test('old item matches are unavailable immediately when a new query is entered',()=>{ test('old item matches are unavailable immediately when a new query is entered',()=>{
const result={query:normalizeTenderSearch('hemogram'),matches:new Map([['2026/1',[{ad:'Hemogram'}]]])}; const result={query:normalizeTenderSearch('hemogram'),matches:new Map([['2026/1',[{ad:'Hemogram'}]]])};
assert.equal(matchingItemResults(result,'HEMOGRAM'),result.matches); assert.equal(matchingItemResults(result,'HEMOGRAM'),result.matches);