diff --git a/docs/quality/qa-saved-filter-application.json b/docs/quality/qa-saved-filter-application.json new file mode 100644 index 0000000..44b5e7f --- /dev/null +++ b/docs/quality/qa-saved-filter-application.json @@ -0,0 +1,15 @@ +{ + "date": "2026-09-06", + "environment": "localhost:5182 five synthetic tenders", + "checks": { + "filteredOne": true, + "regionVisible": true, + "clearRestoresFive": true, + "reapplyAfterReload": true + }, + "limits": [ + "Region-only saved filter tested in browser", + "Five criteria preservation and province coverage tested in unit suite", + "No notification delivery, no real source accuracy proof" + ] +} diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 506f4dd..e4aada6 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -917,3 +917,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Bildirim aboneliği oluşturmayan işlemdeki anında haber/bot çalıştırma iddiaları kaldırıldı. E-posta aboneliğinin ayrı kurulduğu açıklanır. Boş isim için alert yerine native required kullanılır; form alanları ve silme düğmeleri etiketlendi. - 206 test ve derleme geçti. Yerel tarayıcıda kaydetme onayı, yenilemede kayıt, silme ve yenilemede silinmiş kalma olmak üzere 4 kontrol geçti (qa-saved-filter-lifecycle.json). Bu tur kayıt hatası enjeksiyonu yapılmadı; çok cihazlı eşzamanlı düzenleme kabulü açık. - Ayrı eksik: kayıtlı filtre kartını arama ölçütlerine uygulama akışı mevcut değildi ve bu tur eklenmedi; bölge/şehir/sektör/usul ölçütleriyle uygulama sonraki iş olarak açık. Filtre yönetimi henüz tam uçtan uca kabul edilmiş sayılmaz. + +### Kayıtlı filtreyi aramaya uygulama — 6 Eylül 2026 +- Kayıtlı filtre adları hem sol panelde hem filtre yönetiminde uygulanabilir düğme oldu. Uygulama şehir, bölge, sektör, kurum tipi ve usulü yükler; eski arama/CPV/hızlı filtreyi temizleyip ilan listesine geçer. Tanınmayan bölge geçersiz filtre hatası olarak gösterilir. +- İl bazlı yedi bölge eşlemesi eklendi; 81 ilin her biri tam bir kez kapsanır. Aktif bölge listede açıkça görünür; tek başına veya tüm filtrelerle kaldırılabilir. Bölge şehir ölçütüyle birlikte uygulanır ve bilinmeyen şehir seçili bölgeye dahil edilmez. +- Usul alanı explicit usul/ihaleUsulu/alimTuru değerlerinden eşleşir; Mal Alımı değerinden Açık İhale tahmini yapılmaz. Kaynakta usul yoksa usule göre filtrelenmiş sonuçta gösterilmez. +- 209 otomatik test ve derleme geçti. 81 il kapsamı, Türkçe normalizasyon, beş ölçütün taşınması, geçersiz bölge ve usul/kategori ayrımı test edildi. Yerel 5 sentetik ihalede bölge filtresi 1 Giresun kaydına daralttı; bölgeyi kaldırınca 5 kayıt geri geldi; yenileme sonrası sol panelden tekrar uygulandı. 4 kontrol qa-saved-filter-application.json içinde. +- İl bazında bölgeleme coğrafi sınırın ilçelerdeki ayrıntısını temsil etmez. Gerçek kaynak usul/şehir doğruluğu ve bütün ölçütlerin birleşik tarayıcı senaryosu ayrıca kabul edilmelidir. Bu işlem bildirim aboneliği oluşturmaz. diff --git a/src/components/LeftSidebar.jsx b/src/components/LeftSidebar.jsx index 2734e82..99fa5c8 100644 --- a/src/components/LeftSidebar.jsx +++ b/src/components/LeftSidebar.jsx @@ -12,7 +12,7 @@ export const LeftSidebar = ({onOpenProductSearch} = {}) => { quickFilter, setQuickFilter, setActiveTab, - savedFilters, + savedFilters, applySavedFilter, selectedRegion, deleteSavedFilter, savingCount, selectedAlimTuru, @@ -89,7 +89,7 @@ export const LeftSidebar = ({onOpenProductSearch} = {}) => { Hızlı Erişim - {(quickFilter !== 'all' || selectedSektorler.length || selectedCpvler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && ( + {(selectedRegion || quickFilter !== 'all' || selectedSektorler.length || selectedCpvler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && (

👤 {filter.user} {filter.sehir && `• 📍 ${filter.sehir}`}

diff --git a/src/components/SmartFilterBuilder.jsx b/src/components/SmartFilterBuilder.jsx index e69663c..2319452 100644 --- a/src/components/SmartFilterBuilder.jsx +++ b/src/components/SmartFilterBuilder.jsx @@ -4,7 +4,7 @@ import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants'; import { Bot, ArrowLeft, CheckCircle2, HelpCircle, Building2, MapPin, Tag } from 'lucide-react'; export const SmartFilterBuilder = () => { - const { savedFilters, addSavedFilter, deleteSavedFilter, setActiveTab, sektorListesi, savingCount, saveError } = useApp(); + const { savedFilters, applySavedFilter, addSavedFilter, deleteSavedFilter, setActiveTab, sektorListesi, savingCount, saveError } = useApp(); const [filterName, setFilterName] = useState(''); const [selectedKurumTip, setSelectedKurumTip] = useState(''); @@ -57,7 +57,7 @@ export const SmartFilterBuilder = () => {

Kayıtlı Filtreler

-

Arama ölçütlerinizi hesabınıza kaydedin. E-posta bildirimleri Yönetim → Mobil PWA bölümünden ayrıca kurulur.

+

Arama ölçütlerinizi kaydedin; soldaki filtre adına tıklayarak ihale listesine uygulayın. E-posta bildirimleri Yönetim → Mobil PWA bölümünden ayrıca kurulur.

} {/* Header bar above list */}
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 7de80c3..0ce6101 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,3 +1,4 @@ +import {savedFilterCriteria} from '../services/savedFilterCriteria'; import {mergeServerNotifications,acknowledgeNotifications} from '../services/notifications'; import { searchTender } from '../services/searchTender'; import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch'; @@ -80,6 +81,7 @@ export const AppProvider = ({ children }) => { return () => { iptal = true; controller.abort(); clearTimeout(zaman); }; }, [searchQuery,kalemRetry]); const [selectedCity, setSelectedCity] = useState(''); + const [selectedRegion,setSelectedRegion]=useState(''); const [selectedAlimTuru, setSelectedAlimTuru] = useState(''); const [selectedKurumTipi, setSelectedKurumTipi] = useState(''); // Çoklu sektör seçimi. Tek seçimlik eski API (selectedSektor) uyumluluk @@ -689,7 +691,19 @@ export const AppProvider = ({ children }) => { const addSavedFilter = newFilter => persistSavedFilters([{...newFilter,id:'sf-'+crypto.randomUUID(),user:userProfile.username},...savedFilters]); const deleteSavedFilter = id => persistSavedFilters(savedFilters.filter(filter=>filter.id!==id)); + const applySavedFilter = filter => { + try { + const criteria=savedFilterCriteria(filter); + setSearchQuery('');setSelectedCpvler([]);setQuickFilter('all'); + setSelectedCity(criteria.city);setSelectedRegion(criteria.region); + setSelectedAlimTuru(criteria.procedure);setSelectedKurumTipi(criteria.institution); + setSelectedSektorler(criteria.sector?[criteria.sector]:[]);setActiveTab('list');setSaveError(null); + return true; + }catch(error){setSaveError(error.message);return false;} + }; + const clearAllFilters = () => { + setSelectedRegion(''); setSearchQuery(''); setSelectedCity(''); setSelectedAlimTuru(''); @@ -723,7 +737,7 @@ export const AppProvider = ({ children }) => { searchQuery, kalemEslesen, itemSearchState, retryItemSearch, setSearchQuery, - selectedCity, + selectedCity, selectedRegion,setSelectedRegion,applySavedFilter, setSelectedCity, selectedAlimTuru, setSelectedAlimTuru, diff --git a/src/services/savedFilterCriteria.js b/src/services/savedFilterCriteria.js new file mode 100644 index 0000000..5f80fc1 --- /dev/null +++ b/src/services/savedFilterCriteria.js @@ -0,0 +1,19 @@ +const norm=value=>String(value || '').normalize('NFC').trim().toLocaleUpperCase('tr-TR'); +export const REGION_CITIES={ + 'MARMARA BÖLGESİ':'Balıkesir,Bilecik,Bursa,Çanakkale,Edirne,İstanbul,Kırklareli,Kocaeli,Sakarya,Tekirdağ,Yalova', + 'EGE BÖLGESİ':'Afyonkarahisar,Aydın,Denizli,İzmir,Kütahya,Manisa,Muğla,Uşak', + 'AKDENİZ BÖLGESİ':'Adana,Antalya,Burdur,Hatay,Isparta,Kahramanmaraş,Mersin,Osmaniye', + 'İÇ ANADOLU BÖLGESİ':'Aksaray,Ankara,Çankırı,Eskişehir,Karaman,Kayseri,Kırıkkale,Kırşehir,Konya,Nevşehir,Niğde,Sivas,Yozgat', + 'KARADENİZ BÖLGESİ':'Amasya,Artvin,Bartın,Bayburt,Bolu,Çorum,Düzce,Giresun,Gümüşhane,Karabük,Kastamonu,Ordu,Rize,Samsun,Sinop,Tokat,Trabzon,Zonguldak', + 'DOĞU ANADOLU BÖLGESİ':'Ağrı,Ardahan,Bingöl,Bitlis,Elazığ,Erzincan,Erzurum,Hakkari,Iğdır,Kars,Malatya,Muş,Tunceli,Van', + 'GÜNEYDOĞU ANADOLU BÖLGESİ':'Adıyaman,Batman,Diyarbakır,Gaziantep,Kilis,Mardin,Siirt,Şanlıurfa,Şırnak', +}; +const cityRegions=new Map(Object.entries(REGION_CITIES).flatMap(([region,cities])=>cities.split(',').map(city=>[norm(city),region]))); +export function matchesRegion(tender,region){return !region || cityRegions.get(norm(tender.sehir))===norm(region);} +export function matchesProcurement(tender,procedure){return !procedure || [tender.usul,tender.ihaleUsulu,tender.alimTuru].some(value=>norm(value)===norm(procedure));} +export function savedFilterCriteria(filter){ + const read=key=>{if(filter[key]!=null&&typeof filter[key]!=='string')throw new Error('Kayıtlı filtre ölçütü geçersiz.');return (filter[key] || '').trim();}; + const region=read('bolge'); + if(region&&!Object.keys(REGION_CITIES).includes(norm(region)))throw new Error('Kayıtlı bölge tanınmadı; filtreyi yeniden düzenleyin.'); + return {city:read('sehir'),region:region?norm(region):'',sector:read('sektor'),institution:read('kurumTipi'),procedure:read('usul')}; +} diff --git a/tests/saved-filter-criteria.test.mjs b/tests/saved-filter-criteria.test.mjs new file mode 100644 index 0000000..72da459 --- /dev/null +++ b/tests/saved-filter-criteria.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {REGION_CITIES,matchesRegion,matchesProcurement,savedFilterCriteria} from '../src/services/savedFilterCriteria.js'; +import {CITIES_LIST} from '../src/data/constants.js'; +test('province-based regions cover all 81 cities exactly once and normalize Turkish names',()=>{ + const cities=Object.values(REGION_CITIES).flatMap(value=>value.split(',')); + assert.equal(cities.length,81);assert.deepEqual([...cities].sort(),[...CITIES_LIST].sort());assert.equal(new Set(cities).size,81); + assert.equal(matchesRegion({sehir:' giresun '},'KARADENİZ BÖLGESİ'),true); + assert.equal(matchesRegion({sehir:'Kayseri'},'KARADENİZ BÖLGESİ'),false); + assert.equal(matchesRegion({sehir:''},'KARADENİZ BÖLGESİ'),false); +}); +test('saved criteria preserves all five fields and rejects unrecognized regions',()=>{ + assert.deepEqual(savedFilterCriteria({sehir:' Giresun ',bolge:'karadeniz bölgesi',sektor:'Laboratuvar',kurumTipi:'Hastaneler',usul:'Açık İhale'}),{city:'Giresun',region:'KARADENİZ BÖLGESİ',sector:'Laboratuvar',institution:'Hastaneler',procedure:'Açık İhale'}); + assert.throws(()=>savedFilterCriteria({bolge:'Bilinmeyen'}),/tanınmadı/); + assert.throws(()=>savedFilterCriteria({sehir:[]}),/geçersiz/); +}); +test('procedure filter uses explicit source procedure, not inferred purchase category',()=>{ + assert.equal(matchesProcurement({alimTuru:'Mal Alımı',ihaleUsulu:'Açık İhale'},'Açık İhale'),true); + assert.equal(matchesProcurement({alimTuru:'Mal Alımı'},'Açık İhale'),false); + assert.equal(matchesProcurement({alimTuru:'Doğrudan Temin'},'Doğrudan Temin'),true); +});