From f593d5e372e6bcbd317f9ca037d1f45065f2d53c Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 09:19:41 +0300 Subject: [PATCH] fix: export filtered tender list as real XLSX --- docs/quality/uygulama-durumu.md | 7 +++++ src/components/TenderList.jsx | 52 ++++++++++++++++----------------- src/services/tenderWorkbook.js | 21 +++++++++++++ tests/tender-workbook.test.mjs | 21 +++++++++++++ 4 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 src/services/tenderWorkbook.js create mode 100644 tests/tender-workbook.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 1dd08b0..71192dd 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -221,3 +221,10 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - Ana liste, özet kartları ve canlı üst sayacın açık ihale hesabı ortak deadlineInfo ile çalışıyor; 30 saniyelik saat güncellemesi sayfa açıkken süre dolmasını yansıtıyor. İptal/sonuç/sözleşme ve kazananı bilinen kayıtlar açık kabul edilmiyor. Tarih veya saat bilinmiyorsa uydurulmuyor. - Filtre etiketi ve açıklaması eksik tarihli kaydın kesin açık olduğu izlenimini azaltacak şekilde güncellendi; sıralama alanına erişilebilir ad eklendi. Bugün/önceki ziyaret gün anahtarı Türkiye saatine bağlandı. - Kanıt: 61/61 test ve build geçti. Ay/yıl geçişi, aynı gün saat sırası, bilinmeyen tarih, tam son teklif anı, eksik saatte Türkiye gece yarısı ve kapalı durumlar test edildi. Yeni eklenenler sıralamasının kayıt kimliği kullanımı, tüm kaynakların ilan zamanı doğrulaması ve tarayıcıda saat ilerletme kabulü hâlâ açık. + +### D03 / D09 — İhale listesinde gerçek Excel çıktısı + +- Ana listedeki Excel düğmesinin CSV/data-URI çıktısı gerçek XLSX ile değiştirildi. Tıklama anındaki filtrelenmiş listenin tamamı, yalnız görünür ilk 20 kayıtla sınırlanmadan aktarılıyor; ihale saati ve kaynak bağlantısı da çıktı alanlarına eklendi. +- Türkçe metin, virgül, satır sonu, #, & ve İKN metin olarak korunuyor. Kaynak metni Excel formülüne dönüştürülmüyor. Başlık sabitleme ve otomatik filtre var. +- Çıktı hazırlanırken düğme bekleme gösteriyor; hata görünür. Ekran kapatılır veya oturum değişirse geç gelen dosya indirmesi iptal ediliyor. Boş liste dışa aktarımı engelleniyor. +- Kanıt: 125 satırın gerçek XLSX yeniden okunması, ilk/son kayıt, saat, CPV, kaynak ve = ile başlayan metin hücresi testi geçti. 62/62 test ve build geçti. Tarayıcıdan dosya indirme ve masaüstü Excel açılışı bu adımda yapılmadı; tüm çıktı kabulü henüz tamamlanmış sayılmıyor. diff --git a/src/components/TenderList.jsx b/src/components/TenderList.jsx index 66cc026..02c4dd2 100644 --- a/src/components/TenderList.jsx +++ b/src/components/TenderList.jsx @@ -2,7 +2,8 @@ import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { useApp } from '../context/AppContext'; import { TenderCard } from './TenderCard'; import { Download, SlidersHorizontal, Layers, Loader2 } from 'lucide-react'; -import confetti from 'canvas-confetti'; +import { createTenderWorkbook } from '../services/tenderWorkbook'; +import { getToken } from '../services/auth'; import { useDeadlineClock } from '../hooks/useDeadlineClock'; import { acikIhale, hizliFiltreGecer, GECMIS_FILTRELER, compareTenderDeadlines } from '../services/ihaleTarih'; @@ -107,29 +108,27 @@ export const TenderList = () => { return () => gozlemci.disconnect(); }, [dahaVar, dahaGoster]); - const handleExportExcel = () => { - confetti({ particleCount: 80, spread: 60, origin: { y: 0.6 } }); - - const headers = ['IKN', 'Baslik', 'Kurum', 'Sehir', 'Tarih', 'Alim Turu', 'Sektor', 'CPV']; - const rows = filteredTenders.map(t => [ - t.ikn, - `"${t.baslik.replace(/"/g, '""')}"`, - `"${t.kurum.replace(/"/g, '""')}"`, - t.sehir, - t.tarih, - t.alimTuru, - t.sektor, - `"${(t.cpvLabel || (t.cpv || []).join(', ') || '').replace(/"/g, '""')}"` - ]); - - const csvContent = 'data:text/csv;charset=utf-8,\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); - const encodedUri = encodeURI(csvContent); - const link = document.createElement('a'); - link.setAttribute('href', encodedUri); - link.setAttribute('download', `ihaleist_ihale_listesi_${Date.now()}.csv`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const [exporting,setExporting] = useState(false); + const [exportError,setExportError] = useState(null); + const exportPending = useRef(false); + const mounted = useRef(true); + useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]); + const handleExportExcel = async () => { + if (exportPending.current) return; + exportPending.current = true; setExporting(true); setExportError(null); + const session = getToken(); + // Export the full filtered result at click time, including rows below the fold. + const selected = filteredTenders.map(t=>({...t})); + try { + const bytes = await createTenderWorkbook(selected); + if (!mounted.current || getToken() !== session) return; + const url = URL.createObjectURL(new Blob([bytes], {type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'})); + const link = document.createElement('a'); + link.href = url; link.download = `ihaleist_ihale_listesi_${Date.now()}.xlsx`; + document.body.appendChild(link); link.click(); link.remove(); + setTimeout(()=>URL.revokeObjectURL(url),1000); + } catch (error) { if (mounted.current && getToken() === session) setExportError(error.message); } + finally { exportPending.current=false; if (mounted.current) setExporting(false); } }; return ( @@ -171,15 +170,16 @@ export const TenderList = () => { + {exportError &&

{exportError}

} {/* Tender Cards List */} {gorunenTenders.length === 0 ? (
diff --git a/src/services/tenderWorkbook.js b/src/services/tenderWorkbook.js new file mode 100644 index 0000000..3756950 --- /dev/null +++ b/src/services/tenderWorkbook.js @@ -0,0 +1,21 @@ +// User/source text stays a string cell, including leading '=' and embedded newlines. +export async function createTenderWorkbook(tenders) { + if (!Array.isArray(tenders) || !tenders.length) throw new Error('Dışa aktarılacak ihale bulunamadı.'); + const {default:ExcelJS} = await import('exceljs'); + const book = new ExcelJS.Workbook(); + book.creator = 'İhaleist'; + const sheet = book.addWorksheet('İhale Listesi'); + sheet.columns = [ + ['İKN',20],['Başlık',65],['Kurum',45],['Şehir',20],['İhale tarihi',18], + ['İhale saati',15],['Alım türü',25],['Sektör',30],['CPV',35],['Kaynak',55], + ].map(([header,width])=>({header,width})); + for (const t of tenders) { + sheet.addRow([t.ikn,t.baslik,t.kurum,t.sehir,t.tarih,t.saat,t.alimTuru,t.sektor, + t.cpvLabel || (Array.isArray(t.cpv) ? t.cpv.join(', ') : t.cpv),t.kaynakUrl].map(v=>String(v ?? ''))); + } + sheet.views = [{state:'frozen',ySplit:1}]; + sheet.autoFilter = {from:'A1',to:`J${tenders.length+1}`}; + sheet.getRow(1).font = {bold:true}; + sheet.eachRow(row=>{row.alignment={vertical:'top',wrapText:true};}); + return book.xlsx.writeBuffer(); +} diff --git a/tests/tender-workbook.test.mjs b/tests/tender-workbook.test.mjs new file mode 100644 index 0000000..a508573 --- /dev/null +++ b/tests/tender-workbook.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import ExcelJS from 'exceljs'; +import {createTenderWorkbook} from '../src/services/tenderWorkbook.js'; +test('list XLSX roundtrip preserves all rows, Turkish text, special characters and text-only cells',async()=>{ + const rows=Array.from({length:125},(_,i)=>({ikn:`2026/00${i}`,baslik:i===0?'=1+1':'İğdır, "Kit"\nTest # &', + kurum:'Sağlık',sehir:'Iğdır',tarih:'01.10.2026',saat:'09:30',cpv:['001','002'],kaynakUrl:'https://example.test/?a=1&b=2#ilan'})); + const bytes=await createTenderWorkbook(rows); + assert.equal(Buffer.from(bytes).subarray(0,2).toString(),'PK'); + const book=new ExcelJS.Workbook();await book.xlsx.load(bytes); + const sheet=book.getWorksheet('İhale Listesi'); + assert.equal(sheet.rowCount,126); + assert.equal(sheet.getCell('A2').value,'2026/000'); + assert.equal(sheet.getCell('B2').value,'=1+1'); + assert.equal(sheet.getCell('B2').type,ExcelJS.ValueType.String); + assert.equal(sheet.getCell('B126').value,rows[124].baslik); + assert.equal(sheet.getCell('F2').value,'09:30'); + assert.equal(sheet.getCell('I2').value,'001, 002'); + assert.equal(sheet.getCell('J2').value,rows[0].kaynakUrl); + await assert.rejects(()=>createTenderWorkbook([]),/bulunamadı/); +});