fix: combine detailed tender search fields
This commit is contained in:
@@ -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.
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -5,37 +5,34 @@ import { CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||||
import { SlidersHorizontal, Search, RotateCcw, X } from 'lucide-react';
|
||||
|
||||
export const DetailedSearchModal = () => {
|
||||
const {
|
||||
isDetailedSearchOpen,
|
||||
setIsDetailedSearchOpen,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
selectedCity,
|
||||
setSelectedCity,
|
||||
selectedAlimTuru,
|
||||
setSelectedAlimTuru,
|
||||
selectedSektorler,
|
||||
setSelectedSektorler,
|
||||
toggleSektor,
|
||||
sektorListesi,
|
||||
selectedKurumTipi,
|
||||
setSelectedKurumTipi,
|
||||
onlyValidOnly,
|
||||
setOnlyValidOnly,
|
||||
setActiveTab
|
||||
} = useApp();
|
||||
const {isDetailedSearchOpen}=useApp();
|
||||
return isDetailedSearchOpen?<DetailedSearchForm/>:null;
|
||||
};
|
||||
|
||||
const DetailedSearchForm = () => {
|
||||
const context=useApp();
|
||||
const {setIsDetailedSearchOpen,setActiveTab,sektorListesi}=context;
|
||||
const titleId=useId();
|
||||
const modalFocus=useModalFocus(isDetailedSearchOpen,()=>setIsDetailedSearchOpen(false));
|
||||
|
||||
const [iknQuery, setIknQuery] = useState('');
|
||||
const [kurumQuery, setKurumQuery] = useState('');
|
||||
|
||||
if (!isDetailedSearchOpen) return null;
|
||||
const modalFocus=useModalFocus(true,()=>setIsDetailedSearchOpen(false));
|
||||
const [searchQuery,setSearchQuery]=useState(context.searchQuery);
|
||||
const [selectedCity,setSelectedCity]=useState(context.selectedCity);
|
||||
const [selectedAlimTuru,setSelectedAlimTuru]=useState(context.selectedAlimTuru);
|
||||
const [selectedKurumTipi,setSelectedKurumTipi]=useState(context.selectedKurumTipi);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (kurumQuery) setSearchQuery(kurumQuery);
|
||||
else if (iknQuery) setSearchQuery(iknQuery);
|
||||
context.setSearchQuery(searchQuery.trim());
|
||||
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');
|
||||
setIsDetailedSearchOpen(false);
|
||||
@@ -45,8 +42,9 @@ export const DetailedSearchModal = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedCity('');
|
||||
setSelectedAlimTuru('');
|
||||
setSelectedSektorler([]);
|
||||
setSelectedKurumTipi('');
|
||||
setSelectedSektorler([]);
|
||||
setOnlyValidOnly(true);
|
||||
setIknQuery('');
|
||||
setKurumQuery('');
|
||||
};
|
||||
@@ -72,6 +70,7 @@ export const DetailedSearchModal = () => {
|
||||
|
||||
{/* Form Body */}
|
||||
<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">
|
||||
|
||||
@@ -82,7 +81,7 @@ export const DetailedSearchModal = () => {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -168,6 +167,19 @@ export const DetailedSearchModal = () => {
|
||||
</select>
|
||||
</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 className="pt-2">
|
||||
|
||||
@@ -67,6 +67,7 @@ export const Header = () => {
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Anahtar kelime aramasını temizle"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white text-xs"
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const LeftSidebar = ({onOpenProductSearch} = {}) => {
|
||||
setSelectedKurumTipi,
|
||||
selectedCity,
|
||||
setSelectedCity,
|
||||
clearAllFilters
|
||||
clearAllFilters,detailedQueries
|
||||
} = useApp();
|
||||
|
||||
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">
|
||||
<Compass className="w-4 h-4 text-blue-600" /> Hızlı Erişim
|
||||
</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
|
||||
onClick={clearAllFilters}
|
||||
className="text-[11px] font-medium text-blue-600 hover:underline"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useApp } from '../context/AppContext';
|
||||
import { TenderCard } from './TenderCard';
|
||||
@@ -17,6 +17,7 @@ export const TenderList = () => {
|
||||
const {
|
||||
tenders,
|
||||
searchQuery,
|
||||
detailedQueries,setDetailedQueries,
|
||||
kalemEslesen, itemSearchState, retryItemSearch,
|
||||
uyumEsigi,
|
||||
selectedCity,selectedRegion,setSelectedRegion,
|
||||
@@ -46,6 +47,7 @@ export const TenderList = () => {
|
||||
return tenders.filter(tender => {
|
||||
// Search Query
|
||||
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"
|
||||
// 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);
|
||||
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 dahaVar = gosterilen < filteredTenders.length;
|
||||
@@ -80,7 +82,7 @@ export const TenderList = () => {
|
||||
// dizisine bağlansaydı havuz her yenilendiğinde (10 dk) kullanıcı listenin
|
||||
// başına fırlardı.
|
||||
useEffect(() => { setGosterilen(SAYFA_ADIM); }, [
|
||||
searchQuery, selectedCity, selectedRegion, selectedAlimTuru, selectedSektorler,
|
||||
searchQuery, detailedQueries, selectedCity, selectedRegion, selectedAlimTuru, selectedSektorler,
|
||||
selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly,
|
||||
]);
|
||||
|
||||
@@ -129,6 +131,10 @@ export const TenderList = () => {
|
||||
|
||||
return (
|
||||
<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>}
|
||||
{/* Header bar above list */}
|
||||
|
||||
@@ -56,6 +56,7 @@ export const AppProvider = ({ children }) => {
|
||||
|
||||
const [liveStats, setLiveStats] = useState({ toplamIhale: 0, bugunEklenen: 0, arsiv: 0 });
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [detailedQueries,setDetailedQueries]=useState({institution:'',registration:''});
|
||||
|
||||
// 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
|
||||
@@ -734,6 +735,7 @@ export const AppProvider = ({ children }) => {
|
||||
const applySavedFilter = filter => {
|
||||
try {
|
||||
const criteria=savedFilterCriteria(filter);
|
||||
setDetailedQueries({institution:'',registration:''});
|
||||
setSearchQuery('');setSelectedCpvler([]);setQuickFilter('all');
|
||||
setSelectedCity(criteria.city);setSelectedRegion(criteria.region);
|
||||
setSelectedAlimTuru(criteria.procedure);setSelectedKurumTipi(criteria.institution);
|
||||
@@ -743,6 +745,7 @@ export const AppProvider = ({ children }) => {
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setDetailedQueries({institution:'',registration:''});
|
||||
setSelectedRegion('');
|
||||
setSearchQuery('');
|
||||
setSelectedCity('');
|
||||
@@ -775,6 +778,7 @@ export const AppProvider = ({ children }) => {
|
||||
handleLogout,
|
||||
liveStats,
|
||||
searchQuery,
|
||||
detailedQueries,setDetailedQueries,
|
||||
kalemEslesen, itemSearchState, retryItemSearch,
|
||||
setSearchQuery,
|
||||
selectedCity, selectedRegion,setSelectedRegion,applySavedFilter,
|
||||
|
||||
@@ -5,6 +5,11 @@ export function matchingItemResults(result, query) {
|
||||
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) {
|
||||
const q=normalizeTenderSearch(query);
|
||||
if (!q) return true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
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',()=>{
|
||||
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);
|
||||
@@ -10,6 +10,13 @@ test('Turkish search treats case, decomposed characters and spacing consistently
|
||||
assert.equal(matchesTenderSearch({},' '),true);
|
||||
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',()=>{
|
||||
const result={query:normalizeTenderSearch('hemogram'),matches:new Map([['2026/1',[{ad:'Hemogram'}]]])};
|
||||
assert.equal(matchingItemResults(result,'HEMOGRAM'),result.matches);
|
||||
|
||||
Reference in New Issue
Block a user