feat: demo özelliklerini gerçekleştir — canlı veri + localStorage

- analytics.js: firmStats/itemStats/provinceDensity (gerçek hesaplama)
- Rakip Radar: canlı veriden kazanan firma analizi (mock 3 firma kaldırıldı)
- Fiyat Analizi: gerçek kalem min/medyan/ort/maks + CSV export
- TurkeyHeatMap: 81 il gerçek interaktif yoğunluk haritası (tıkla→filtrele)
- Favori kurumlar: kart yıldızı + hızlı filtre (localStorage)
- Ekip yönetimi: gerçek CRUD (RightSidebar) + Kanban ataması ekipten
- Bildirimler: gerçek Notification API + izin + test + sessiz saatler
- PDF: jsPDF ile gerçek birim fiyat teklif cetveli (kaşe/imza alanı)
This commit is contained in:
2026-08-28 01:58:46 +03:00
parent 512d529af2
commit 0ab74143c0
16 changed files with 895 additions and 154 deletions
+118
View File
@@ -0,0 +1,118 @@
/**
* Gerçek ihale verisinden (context'teki tenders dizisi) hesaplanan analizler.
* Sabit mock yok — ne yüklüyse ondan üretir. Veri arttıkça (DT worker, sonuç
* ilanları) analizler otomatik zenginleşir.
*/
// "22.800,00 TRY" / "1.234.567,89 TL" gibi metinleri sayıya çevir
export function parseTL(v) {
if (v == null) return null;
if (typeof v === 'number') return isFinite(v) ? v : null;
const s = String(v).replace(/[^\d.,]/g, '');
if (!s) return null;
const norm = s.replace(/\./g, '').replace(',', '.');
const n = parseFloat(norm);
return isFinite(n) ? n : null;
}
export function formatTL(n) {
if (n == null || !isFinite(n)) return '—';
return n.toLocaleString('tr-TR', { maximumFractionDigits: 0 }) + ' ₺';
}
// İl plaka koduna göre density için normalize edilmiş il adı
export function normProvince(name) {
return (name || '').toLocaleUpperCase('tr-TR').trim();
}
// --- Kazanan firma / rakip analizi ---
export function firmStats(tenders) {
const map = new Map();
let awardCount = 0;
for (const t of tenders) {
if (!t.kazananFirma) continue;
awardCount++;
const name = t.kazananFirma.trim();
const val = parseTL(t.sozlesmeBedeli);
const cur = map.get(name) || { name, wins: 0, total: 0, sectors: new Set(), cities: new Set() };
cur.wins++;
if (val != null) cur.total += val;
if (t.sektor) cur.sectors.add(t.sektor);
if (t.sehir) cur.cities.add(t.sehir);
map.set(name, cur);
}
const firms = [...map.values()]
.map((f) => ({ ...f, sectors: [...f.sectors], cities: [...f.cities] }))
.sort((a, b) => b.wins - a.wins || b.total - a.total);
const totalMarket = firms.reduce((s, f) => s + f.total, 0);
return {
firms: firms.map((f) => ({ ...f, share: totalMarket ? Math.round((f.total / totalMarket) * 100) : 0 })),
awardCount,
totalMarket,
};
}
// --- Kalem bazlı fiyat analizi ---
export function itemStats(tenders) {
const map = new Map();
let itemCount = 0;
for (const t of tenders) {
for (const it of t.items || []) {
const bf = parseTL(it.birimFiyat);
if (bf == null) continue;
itemCount++;
const key = (it.ad || 'Diğer').trim().toLocaleUpperCase('tr-TR');
const cur = map.get(key) || { ad: it.ad, birim: it.birim, count: 0, prices: [] };
cur.count++;
cur.prices.push(bf);
map.set(key, cur);
}
}
const items = [...map.values()]
.map((i) => {
const sorted = [...i.prices].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return {
ad: i.ad,
birim: i.birim,
count: i.count,
min: sorted[0],
max: sorted[sorted.length - 1],
median: sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2,
avg: i.prices.reduce((s, p) => s + p, 0) / i.prices.length,
};
})
.sort((a, b) => b.count - a.count);
return { items, itemCount, uniqueItems: items.length };
}
// --- İl bazlı yoğunluk (harita için) ---
export function provinceDensity(tenders) {
const map = new Map();
for (const t of tenders) {
const p = normProvince(t.sehir);
if (!p) continue;
map.set(p, (map.get(p) || 0) + 1);
}
return map; // İL_ADI(upper) -> sayı
}
// --- Sektör dağılımı ---
export function sectorStats(tenders) {
const map = new Map();
for (const t of tenders) {
const s = t.sektor || 'Genel';
map.set(s, (map.get(s) || 0) + 1);
}
return [...map.entries()].map(([sektor, count]) => ({ sektor, count })).sort((a, b) => b.count - a.count);
}
// --- Alım türü dağılımı ---
export function alimTuruStats(tenders) {
const map = new Map();
for (const t of tenders) {
const a = t.alimTuru || 'Diğer';
map.set(a, (map.get(a) || 0) + 1);
}
return [...map.entries()].map(([tur, count]) => ({ tur, count })).sort((a, b) => b.count - a.count);
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Gerçek PDF üretimi (jsPDF) — Birim Fiyat Teklif Cetveli.
* İhalenin kalemlerini tabloya döker, girilen birim fiyatlarla toplamı hesaplar,
* firma bilgisi + kaşe/imza alanı ekler ve indirir.
*/
import { jsPDF } from 'jspdf';
const money = (n) =>
(Number(n) || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
export function generateBidSheetPDF(tender, { firma = 'Firma Adı', prices = {}, stamp = true } = {}) {
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
const W = 210;
const M = 15;
let y = 18;
doc.setFont('helvetica', 'bold');
doc.setFontSize(15);
doc.text('BIRIM FIYAT TEKLIF CETVELI', W / 2, y, { align: 'center' });
y += 8;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
const ikn = tender.ikn || '';
const kurum = (tender.kurum || '').slice(0, 90);
doc.text(`Idare: ${kurum}`, M, y); y += 5;
doc.text(`Isin Adi: ${(tender.baslik || '').slice(0, 95)}`, M, y); y += 5;
doc.text(`IKN / DTN: ${ikn} Tarih: ${tender.tarih || ''}`, M, y); y += 5;
doc.text(`Teklif Veren: ${firma}`, M, y); y += 8;
// Tablo başlığı
const cols = [
{ t: 'No', x: M, w: 12, a: 'left' },
{ t: 'Mal / Hizmet Adi', x: M + 12, w: 88, a: 'left' },
{ t: 'Miktar', x: M + 100, w: 20, a: 'right' },
{ t: 'Birim', x: M + 120, w: 18, a: 'left' },
{ t: 'B.Fiyat', x: M + 138, w: 20, a: 'right' },
{ t: 'Tutar', x: M + 158, w: 22, a: 'right' },
];
doc.setFillColor(30, 58, 138);
doc.setTextColor(255);
doc.setFont('helvetica', 'bold');
doc.rect(M, y - 4.5, W - 2 * M, 7, 'F');
cols.forEach((c) => doc.text(c.t, c.a === 'right' ? c.x + c.w - 1 : c.x + 1, y, { align: c.a }));
y += 5;
doc.setTextColor(20);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8.5);
const items = tender.items && tender.items.length ? tender.items : [{ id: 'x1', ad: '(Kalem girilmemis)', miktar: '', birim: '' }];
let total = 0;
items.forEach((it, i) => {
const bf = Number(prices[it.id] ?? it.birimFiyat ?? 0);
const miktar = parseFloat(String(it.miktar).replace(',', '.')) || 0;
const tutar = bf * miktar;
total += tutar;
if (i % 2 === 0) { doc.setFillColor(245, 247, 250); doc.rect(M, y - 4, W - 2 * M, 6, 'F'); }
doc.text(String(i + 1), M + 1, y);
doc.text(String(it.ad || '').slice(0, 60), M + 13, y);
doc.text(String(it.miktar || ''), M + 120 - 1, y, { align: 'right' });
doc.text(String(it.birim || ''), M + 120 + 1, y);
doc.text(bf ? money(bf) : '—', M + 158 - 1, y, { align: 'right' });
doc.text(tutar ? money(tutar) : '—', W - M - 1, y, { align: 'right' });
y += 6;
if (y > 250) { doc.addPage(); y = 20; }
});
// Toplam
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.setDrawColor(200);
doc.line(M, y - 2, W - M, y - 2);
y += 4;
doc.text('GENEL TOPLAM (KDV Haric):', M + 90, y, { align: 'right' });
doc.text(`${money(total)} TL`, W - M - 1, y, { align: 'right' });
y += 16;
// Kaşe / imza alanı
if (stamp) {
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setDrawColor(150);
doc.roundedRect(W - M - 60, y, 60, 30, 2, 2);
doc.text('Kase ve Imza', W - M - 30, y + 17, { align: 'center' });
doc.setTextColor(120);
doc.setFontSize(7.5);
doc.text(firma, W - M - 30, y + 23, { align: 'center' });
}
doc.setTextColor(150);
doc.setFontSize(7);
doc.text('Ihaleist ile olusturulmustur — ihaleist.seymata.com', M, 288);
const safe = (ikn || tender.baslik || 'teklif').replace(/[^\w]+/g, '_').slice(0, 40);
doc.save(`teklif_cetveli_${safe}.pdf`);
return total;
}