fix: export filtered tender list as real XLSX
This commit is contained in:
@@ -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.
|
- 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ı.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ 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';
|
||||||
import { Download, SlidersHorizontal, Layers, Loader2 } from 'lucide-react';
|
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 { useDeadlineClock } from '../hooks/useDeadlineClock';
|
||||||
import { acikIhale, hizliFiltreGecer, GECMIS_FILTRELER, compareTenderDeadlines } from '../services/ihaleTarih';
|
import { acikIhale, hizliFiltreGecer, GECMIS_FILTRELER, compareTenderDeadlines } from '../services/ihaleTarih';
|
||||||
|
|
||||||
@@ -107,29 +108,27 @@ export const TenderList = () => {
|
|||||||
return () => gozlemci.disconnect();
|
return () => gozlemci.disconnect();
|
||||||
}, [dahaVar, dahaGoster]);
|
}, [dahaVar, dahaGoster]);
|
||||||
|
|
||||||
const handleExportExcel = () => {
|
const [exporting,setExporting] = useState(false);
|
||||||
confetti({ particleCount: 80, spread: 60, origin: { y: 0.6 } });
|
const [exportError,setExportError] = useState(null);
|
||||||
|
const exportPending = useRef(false);
|
||||||
const headers = ['IKN', 'Baslik', 'Kurum', 'Sehir', 'Tarih', 'Alim Turu', 'Sektor', 'CPV'];
|
const mounted = useRef(true);
|
||||||
const rows = filteredTenders.map(t => [
|
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||||
t.ikn,
|
const handleExportExcel = async () => {
|
||||||
`"${t.baslik.replace(/"/g, '""')}"`,
|
if (exportPending.current) return;
|
||||||
`"${t.kurum.replace(/"/g, '""')}"`,
|
exportPending.current = true; setExporting(true); setExportError(null);
|
||||||
t.sehir,
|
const session = getToken();
|
||||||
t.tarih,
|
// Export the full filtered result at click time, including rows below the fold.
|
||||||
t.alimTuru,
|
const selected = filteredTenders.map(t=>({...t}));
|
||||||
t.sektor,
|
try {
|
||||||
`"${(t.cpvLabel || (t.cpv || []).join(', ') || '').replace(/"/g, '""')}"`
|
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 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');
|
const link = document.createElement('a');
|
||||||
link.setAttribute('href', encodedUri);
|
link.href = url; link.download = `ihaleist_ihale_listesi_${Date.now()}.xlsx`;
|
||||||
link.setAttribute('download', `ihaleist_ihale_listesi_${Date.now()}.csv`);
|
document.body.appendChild(link); link.click(); link.remove();
|
||||||
document.body.appendChild(link);
|
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||||
link.click();
|
} catch (error) { if (mounted.current && getToken() === session) setExportError(error.message); }
|
||||||
document.body.removeChild(link);
|
finally { exportPending.current=false; if (mounted.current) setExporting(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -171,15 +170,16 @@ export const TenderList = () => {
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleExportExcel}
|
onClick={handleExportExcel} disabled={exporting || !filteredTenders.length}
|
||||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs px-3.5 py-2 rounded-lg flex items-center gap-1.5 transition-colors shadow-xs shrink-0"
|
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs px-3.5 py-2 rounded-lg flex items-center gap-1.5 transition-colors shadow-xs shrink-0"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
<span>Excel İndir</span>
|
<span>{exporting ? 'Excel hazırlanıyor…' : 'Excel İndir'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{exportError && <p role="alert" className="text-sm text-red-700">{exportError}</p>}
|
||||||
{/* Tender Cards List */}
|
{/* Tender Cards List */}
|
||||||
{gorunenTenders.length === 0 ? (
|
{gorunenTenders.length === 0 ? (
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center shadow-xs">
|
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center shadow-xs">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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ı/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user