feat: tüm mock veriyi kaldır + Resend e-posta entegrasyonu

- mockTenders.js silindi; referans listeleri (sektör/il/kurum) constants.js'e taşındı
- AppContext: tenders/liveStats/competitorWatchlist/vaultDocuments/notifications/
  savedFilters/apiKeys/scrapedCapabilities → gerçek/boş; sahte Math.random online
  sayacı kaldırıldı; competitor & belge kasası artık localStorage
- LiveStatsBar: gerçek alanlar (canlı havuz/yüklü/bugün/kalem/takibimdeki)
- AnalyticsDashboard: KPI'lar (katılım/kazanma oranı/kazanılan bedel) + aylık hacim
  + sektörel dağılım gerçek companyStats/monthlyStats/sectorStats'tan; ciro hedefi
  kullanıcı tarafından belirlenir; veri yoksa dürüst boş durum
- ContractSearch & Rakip Radar: gerçek firmStats
- analytics.js: companyStats/monthlyStats/parseDateTR eklendi
- Resend: server/email.mjs + /api/notify/email/config + /api/notify/email/test
  (auth'lu, kullanıcının kendi adresine); PwaSettings gerçek e-posta test kartı
This commit is contained in:
2026-08-28 08:49:10 +03:00
parent 7521f798f6
commit 3e129c2988
14 changed files with 497 additions and 555 deletions
+73
View File
@@ -0,0 +1,73 @@
/**
* Resend e-posta gönderimi — bağımlılık yok (Node 18+ built-in fetch).
*
* Env:
* RESEND_API_KEY — Resend API anahtarı (re_...). Yoksa e-posta kapalıdır.
* EMAIL_FROM — Gönderen adres. Üretimde Resend'de DNS (SPF/DKIM) ile
* doğrulanmış kendi domaininiz olmalı. Doğrulanana kadar
* Resend'in paylaşımlı test adresi kullanılır.
*
* Kaynak/Endpoint: POST https://api.resend.com/emails (Bearer auth)
*/
const RESEND_ENDPOINT = 'https://api.resend.com/emails';
export function emailConfigured() {
return Boolean(process.env.RESEND_API_KEY);
}
export function emailFrom() {
return process.env.EMAIL_FROM || 'İhaleist <onboarding@resend.dev>';
}
export async function sendEmail({ to, subject, html, text }) {
const key = process.env.RESEND_API_KEY;
if (!key) throw new Error('E-posta yapılandırılmamış (RESEND_API_KEY yok)');
if (!to || !subject || (!html && !text)) throw new Error('Eksik alan: to, subject ve html/text gerekli');
const res = await fetch(RESEND_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from: emailFrom(), to: Array.isArray(to) ? to : [to], subject, html, text }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.message || `Resend hatası: HTTP ${res.status}`);
}
return { id: data.id };
}
// İhale bildirimi için basit, markasız HTML gövdesi
export function tenderDigestHtml({ name, tenders }) {
const rows = (tenders || [])
.slice(0, 15)
.map(
(t) => `<tr>
<td style="padding:8px 12px;border-bottom:1px solid #eef1f5;font-size:13px;color:#0e1526">${escapeHtml(t.baslik || '')}</td>
<td style="padding:8px 12px;border-bottom:1px solid #eef1f5;font-size:12px;color:#586074;white-space:nowrap">${escapeHtml(t.sehir || '')}</td>
<td style="padding:8px 12px;border-bottom:1px solid #eef1f5;font-size:12px;color:#586074;white-space:nowrap">${escapeHtml(t.tarih || '')}</td>
</tr>`
)
.join('');
return `<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;margin:0 auto">
<h2 style="color:#2563eb;margin:0 0 4px">İhaleist</h2>
<p style="color:#586074;font-size:14px;margin:0 0 16px">Merhaba ${escapeHtml(name || '')}, takip kriterlerinize uyan ihaleler:</p>
<table style="border-collapse:collapse;width:100%;border:1px solid #eef1f5;border-radius:8px;overflow:hidden">
<thead><tr style="background:#f6f8fb">
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#586074">İhale</th>
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#586074">İl</th>
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#586074">Tarih</th>
</tr></thead>
<tbody>${rows || '<tr><td style="padding:12px;color:#8b93a4;font-size:13px" colspan="3">Yeni ihale yok.</td></tr>'}</tbody>
</table>
<p style="color:#8b93a4;font-size:11px;margin-top:16px">Bu e-posta İhaleist tarafından gönderildi. Bildirim tercihlerinizi uygulama ayarlarından değiştirebilirsiniz.</p>
</div>`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
+24
View File
@@ -17,6 +17,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readDtTenders } from './dt-store.mjs';
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
@@ -336,6 +337,29 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { success: true, user });
}
// --- E-posta bildirimi (Resend) ---
if (url.pathname === '/api/notify/email/config') {
return json(res, 200, { configured: emailConfigured() });
}
// Giriş yapan kullanıcının KENDİ e-postasına test/özet gönderir (kötüye kullanımı önler).
if (url.pathname === '/api/notify/email/test' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!emailConfigured()) return json(res, 503, { success: false, error: 'E-posta yapılandırılmamış (RESEND_API_KEY yok)' });
try {
const body = await readBody(req).catch(() => ({}));
const html = tenderDigestHtml({ name: user.name || user.email, tenders: body.tenders || [] });
const { id } = await sendEmail({
to: user.email,
subject: body.subject || 'İhaleist — bildirim testi',
html,
});
return json(res, 200, { success: true, id, to: user.email });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if (url.pathname === '/api/tenders') {
const force = url.searchParams.get('refresh') === '1';
const c = await getTenders(force);
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import { SECTORS_LIST, CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/mockTenders';
import { SECTORS_LIST, CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
import { SlidersHorizontal, Search, RotateCcw, X } from 'lucide-react';
export const DetailedSearchModal = () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import { SECTORS_LIST, INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/mockTenders';
import { SECTORS_LIST, INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants';
import {
Compass, Zap, Wrench, Calendar, Eye, EyeOff, FileText, CheckCircle2, XCircle, Archive,
Bot, PlusCircle, Trash2, ChevronDown, ChevronUp, Filter, MapPin, Building2, Tag, Star
+28 -31
View File
@@ -1,42 +1,39 @@
import React from 'react';
import { useApp } from '../context/AppContext';
import { Database, PlusCircle, Eye } from 'lucide-react';
import { Database, PlusCircle, Eye, Layers } from 'lucide-react';
export const LiveStatsBar = () => {
const { liveStats } = useApp();
const { liveStats, tenders } = useApp();
const yukluIhale = tenders.length;
const takibimdeki = tenders.filter((t) => t.takipte).length;
const kalemSayisi = tenders.reduce((s, t) => s + (t.items?.length || 0), 0);
const stats = [
{ deger: liveStats.toplamIhale, etiket: 'Canlı İhale Havuzu', renk: 'text-white', dot: true },
{ deger: yukluIhale, etiket: 'Yüklü İhale', renk: 'text-white', icon: Database, iconRenk: 'text-blue-400' },
{ deger: liveStats.bugunEklenen, etiket: 'Bugün Eklenen', renk: 'text-emerald-400', icon: PlusCircle, iconRenk: 'text-emerald-400' },
{ deger: kalemSayisi, etiket: 'Kalem', renk: 'text-white', icon: Layers, iconRenk: 'text-indigo-400' },
{ deger: takibimdeki, etiket: 'Takibimdeki', renk: 'text-white', icon: Eye, iconRenk: 'text-amber-400' },
];
return (
<div className="bg-[#111827] text-slate-300 py-1.5 px-4 text-xs font-medium border-b border-slate-800 shadow-inner">
<div className="max-w-[1600px] mx-auto flex items-center justify-center gap-6 sm:gap-10 flex-wrap">
{/* Online Kullanıcı */}
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span className="font-bold text-white tracking-wide">{liveStats.onlineKullanici.toLocaleString('tr-TR')}</span>
<span className="text-slate-400">Online Kullanıcı</span>
</div>
{/* Toplam Kalem */}
<div className="flex items-center gap-1.5">
<Database className="w-3.5 h-3.5 text-blue-400" />
<span className="font-bold text-white tracking-wide">{liveStats.toplamKalem.toLocaleString('tr-TR')}</span>
<span className="text-slate-400">Toplam Kalem</span>
</div>
{/* Bugün Eklenen */}
<div className="flex items-center gap-1.5">
<PlusCircle className="w-3.5 h-3.5 text-emerald-400" />
<span className="font-bold text-emerald-400 tracking-wide">{liveStats.bugunEklenen.toLocaleString('tr-TR')}</span>
<span className="text-slate-400">Bugün Eklenen</span>
</div>
{/* Aktif Takip */}
<div className="flex items-center gap-1.5">
<Eye className="w-3.5 h-3.5 text-amber-400" />
<span className="font-bold text-white tracking-wide">{liveStats.aktifTakip.toLocaleString('tr-TR')}</span>
<span className="text-slate-400">Aktif Takip</span>
</div>
{stats.map((s) => {
const Icon = s.icon;
return (
<div key={s.etiket} className="flex items-center gap-1.5">
{s.dot ? (
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
) : (
<Icon className={`w-3.5 h-3.5 ${s.iconRenk}`} />
)}
<span className={`font-bold ${s.renk} tracking-wide`}>{(s.deger || 0).toLocaleString('tr-TR')}</span>
<span className="text-slate-400">{s.etiket}</span>
</div>
);
})}
</div>
</div>
);
+6 -9
View File
@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import { MOCK_COMPANY_STATS } from '../data/mockTenders';
import { firmStats } from '../services/analytics';
import { firmStats, companyStats } from '../services/analytics';
import { Briefcase, FileText, Calendar, History, Trophy, Eye, Kanban, Radar, ArrowRight, Users, Plus, Trash2 } from 'lucide-react';
export const RightSidebar = () => {
@@ -10,7 +9,7 @@ export const RightSidebar = () => {
const [tmEmail, setTmEmail] = useState('');
const [tmRole, setTmRole] = useState('Uzman');
const trackedCount = tenders.filter(t => t.takipte).length;
const cs = companyStats(tenders);
const topFirms = firmStats(tenders).firms.slice(0, 3);
const handleAddMember = (e) => {
@@ -21,12 +20,10 @@ export const RightSidebar = () => {
};
const records = [
{ label: 'Notlarım', count: MOCK_COMPANY_STATS.notlarimCount, icon: FileText, filterKey: 'all' },
{ label: 'Ajanda', count: MOCK_COMPANY_STATS.ajandaCount, icon: Calendar, filterKey: 'all' },
{ label: 'İncelediklerim', count: MOCK_COMPANY_STATS.incelediklerimCount, icon: History, filterKey: 'incelenenler' },
{ label: 'Sözleşmelerim', count: MOCK_COMPANY_STATS.sozlesmelerimCount, icon: FileText, filterKey: 'sozlesmeler' },
{ label: 'Sonuçlarım', count: MOCK_COMPANY_STATS.sonuclarimCount, icon: Trophy, filterKey: 'sonuclananlar' },
{ label: 'Takibimdekiler', count: trackedCount, icon: Eye, filterKey: 'takiptekiler' }
{ label: 'İncelediklerim', count: cs.reviewed, icon: History, filterKey: 'incelenenler' },
{ label: 'Sözleşmelerim', count: cs.contracts, icon: FileText, filterKey: 'sozlesmeler' },
{ label: 'Sonuçlarım', count: cs.resulted, icon: Trophy, filterKey: 'sonuclananlar' },
{ label: 'Takibimdekiler', count: cs.tracked, icon: Eye, filterKey: 'takiptekiler' }
];
return (
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import { INSTITUTION_TYPES_LIST, CITIES_LIST, SECTORS_LIST } from '../data/mockTenders';
import { INSTITUTION_TYPES_LIST, CITIES_LIST, SECTORS_LIST } from '../data/constants';
import { Bot, ArrowLeft, CheckCircle2, HelpCircle, Building2, MapPin, Tag } from 'lucide-react';
export const SmartFilterBuilder = () => {
+104 -77
View File
@@ -1,40 +1,30 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useApp } from '../../context/AppContext';
import { BarChart3, TrendingUp, Trophy, Target, MapPin, Building2, Award, ArrowUpRight, DollarSign } from 'lucide-react';
import { BarChart3, TrendingUp, Trophy, Target, Award, ArrowUpRight, Pencil } from 'lucide-react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, PieChart, Pie, Cell } from 'recharts';
import { TurkeyHeatMap } from './TurkeyHeatMap';
import { companyStats, monthlyStats, sectorStats, formatTL } from '../../services/analytics';
const PIE_COLORS = ['#2563eb', '#059669', '#7c3aed', '#d97706', '#dc2626', '#0891b2', '#db2777', '#65a30d'];
export const AnalyticsDashboard = () => {
const { tenders, userProfile } = useApp();
const monthlyData = [
{ month: 'Ocak', kazanim: 380000, katilim: 550000 },
{ month: 'Şubat', kazanim: 420000, katilim: 600000 },
{ month: 'Mart', kazanim: 510000, katilim: 720000 },
{ month: 'Nisan', kazanim: 490000, katilim: 680000 },
{ month: 'Mayıs', kazanim: 610000, katilim: 850000 },
{ month: 'Haziran', kazanim: 580000, katilim: 810000 },
{ month: 'Temmuz', kazanim: 410000, katilim: 590000 }
];
const cs = companyStats(tenders);
const monthlyData = monthlyStats(tenders);
const sektorler = sectorStats(tenders);
const pieData = sektorler.slice(0, 6).map((s, i) => ({ name: s.sektor, value: s.count, color: PIE_COLORS[i % PIE_COLORS.length] }));
const pieToplam = pieData.reduce((s, p) => s + p.value, 0);
const pieData = [
{ name: 'Tıbbi Malzeme', value: 45, color: '#2563eb' },
{ name: 'Kırtasiye & Büro', value: 25, color: '#059669' },
{ name: 'Temizlik & Hijyen', value: 18, color: '#7c3aed' },
{ name: 'Bilişim & Donanım', value: 12, color: '#d97706' }
];
const cityHeatmap = [
{ city: 'Ankara', ihaleSayisi: 1420, ortalamaMaliyet: '₺240.000', yogunluk: 'Çok Yüksek (%88)', color: 'bg-red-500 text-white' },
{ city: 'Kayseri', ihaleSayisi: 850, ortalamaMaliyet: '₺180.000', yogunluk: 'Yüksek (%74)', color: 'bg-amber-500 text-white' },
{ city: 'İstanbul', ihaleSayisi: 2100, ortalamaMaliyet: '₺410.000', yogunluk: 'Çok Yüksek (%94)', color: 'bg-red-600 text-white' },
{ city: 'İzmir', ihaleSayisi: 980, ortalamaMaliyet: '₺210.000', yogunluk: 'Orta (%62)', color: 'bg-emerald-500 text-white' },
{ city: 'Antalya', ihaleSayisi: 640, ortalamaMaliyet: '₺165.000', yogunluk: 'Orta (%55)', color: 'bg-blue-500 text-white' }
];
// Ciro hedefi — kullanıcı belirler (localStorage)
const [hedef, setHedef] = useState(() => Number(localStorage.getItem('ihaleist_ciro_hedefi') || 0));
const [editing, setEditing] = useState(false);
useEffect(() => { localStorage.setItem('ihaleist_ciro_hedefi', String(hedef)); }, [hedef]);
const hedefYuzde = hedef > 0 ? Math.min(Math.round((cs.wonTotal / hedef) * 100), 100) : 0;
return (
<div className="space-y-6 animate-fade-in">
{/* Header Banner */}
<div className="bg-linear-to-r from-slate-900 via-purple-950 to-indigo-900 text-white p-6 rounded-2xl shadow-lg border border-purple-800/40 flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -43,108 +33,145 @@ export const AnalyticsDashboard = () => {
</div>
<div>
<h2 className="font-extrabold text-xl">Analitik Dashboard & Fiyat Isı Haritası</h2>
<p className="text-xs text-purple-200 mt-0.5">{userProfile.company} firmasına özel kazanma oranı, ciro hedefleri ve il bazlı ihale yoğunluk analizi.</p>
<p className="text-xs text-purple-200 mt-0.5">{userProfile.company} — kanban süreçlerinizden hesaplanan gerçek kazanma oranı ve il bazlı ihale yoğunluğu.</p>
</div>
</div>
</div>
{/* KPI Cards & Ciro Target */}
{/* KPI Cards & Ciro Target — gerçek (kanban/takip verisinden) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-2">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Katıldığımız İhaleler</span>
<div className="flex items-center justify-between">
<span className="text-2xl font-black text-slate-900">34 İhale</span>
<span className="text-2xl font-black text-slate-900">{cs.participated} İhale</span>
<div className="w-8 h-8 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center">
<Trophy className="w-4 h-4" />
</div>
</div>
<span className="text-[11px] text-slate-400">Teklif verilen + sonuçlanan</span>
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-2">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">İhale Kazanma Oranımız</span>
<div className="flex items-center justify-between">
<span className="text-2xl font-black text-emerald-600">%68</span>
<span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full flex items-center gap-0.5">
<ArrowUpRight className="w-3 h-3" /> +%12
</span>
<span className="text-2xl font-black text-emerald-600">%{cs.winRate}</span>
{cs.resulted > 0 && (
<span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full flex items-center gap-0.5">
<ArrowUpRight className="w-3 h-3" /> {cs.won}/{cs.resulted}
</span>
)}
</div>
<span className="text-[11px] text-slate-400">{cs.resulted > 0 ? `${cs.resulted} sonuçlanan ihaleden` : 'Henüz sonuçlanan ihale yok'}</span>
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-2">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Toplam Kazanılan Bedel</span>
<div className="flex items-center justify-between">
<span className="text-2xl font-black text-indigo-900">₺3.400.000</span>
<span className="text-2xl font-black text-indigo-900">{formatTL(cs.wonTotal)}</span>
<div className="w-8 h-8 rounded-lg bg-indigo-50 text-indigo-600 flex items-center justify-center">
<Award className="w-4 h-4" />
</div>
</div>
<span className="text-[11px] text-slate-400">{cs.contracts} sözleşme</span>
</div>
{/* Target Progress Card */}
{/* Ciro Hedefi — kullanıcı belirler */}
<div className="bg-linear-to-br from-blue-900 to-indigo-900 text-white p-5 rounded-2xl shadow-xs space-y-2">
<div className="flex justify-between items-center text-xs font-bold">
<span className="text-blue-200 uppercase tracking-wider flex items-center gap-1">
<Target className="w-3.5 h-3.5 text-amber-300" /> 2026 Ciro Hedefi
<Target className="w-3.5 h-3.5 text-amber-300" /> Ciro Hedefi
</span>
<span className="text-amber-300 font-black">%68</span>
</div>
<span className="text-xl font-black text-white block">₺3.4M / ₺5.0M</span>
<div className="w-full bg-blue-950 rounded-full h-2 overflow-hidden border border-blue-700">
<div className="bg-amber-400 h-2 rounded-full" style={{ width: '68%' }} />
<button onClick={() => setEditing((e) => !e)} className="text-blue-200 hover:text-white" title="Hedefi düzenle">
{hedef > 0 ? <span className="text-amber-300 font-black">%{hedefYuzde}</span> : <Pencil className="w-3.5 h-3.5" />}
</button>
</div>
{editing || hedef === 0 ? (
<div className="flex items-center gap-1.5 pt-1">
<input
type="number"
value={hedef || ''}
onChange={(e) => setHedef(Number(e.target.value) || 0)}
placeholder="Hedef bedel (₺)"
className="w-full bg-blue-950/60 border border-blue-700 rounded-lg px-2 py-1.5 text-xs text-white placeholder:text-blue-300"
/>
<button onClick={() => setEditing(false)} className="bg-amber-400 text-blue-950 font-black text-xs px-2.5 py-1.5 rounded-lg">Kaydet</button>
</div>
) : (
<>
<span className="text-lg font-black text-white block">{formatTL(cs.wonTotal)} / {formatTL(hedef)}</span>
<div className="w-full bg-blue-950 rounded-full h-2 overflow-hidden border border-blue-700">
<div className="bg-amber-400 h-2 rounded-full transition-all" style={{ width: `${hedefYuzde}%` }} />
</div>
</>
)}
</div>
</div>
{/* Recharts Row */}
{/* Recharts Row — gerçek veriden */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Monthly Revenue Chart */}
<div className="lg:col-span-2 bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-4">
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-600" /> Aylık İhale Kazanım ve Teklif Hacmi (TL)
</h3>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 10 }} width={60} tickFormatter={(val) => `₺${val/1000}k`} />
<Tooltip formatter={(value) => [`₺${value.toLocaleString('tr-TR')}`, '']} />
<Bar dataKey="katilim" fill="#94a3b8" name="Toplam Verilen Teklif" radius={[4, 4, 0, 0]} />
<Bar dataKey="kazanim" fill="#2563eb" name="Kazanılan İhale Hacmi" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
{monthlyData.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center border-2 border-dashed border-slate-200 rounded-xl">
<p className="text-sm font-bold text-slate-400">Henüz teklif/sonuç verisi yok</p>
<p className="text-xs text-slate-400 mt-1 max-w-xs">Kanban'da ihaleleri "teklif verildi / kazanıldı / kaybedildi" durumuna aldıkça aylık hacim grafiği oluşur.</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 10 }} width={60} tickFormatter={(val) => `₺${Math.round(val / 1000)}k`} />
<Tooltip formatter={(value) => [`₺${value.toLocaleString('tr-TR')}`, '']} />
<Bar dataKey="katilim" fill="#94a3b8" name="Toplam Verilen Teklif" radius={[4, 4, 0, 0]} />
<Bar dataKey="kazanim" fill="#2563eb" name="Kazanılan İhale Hacmi" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Sector Distribution Pie */}
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-4">
<h3 className="font-extrabold text-sm text-slate-900">Sektörel İhale Kazanım Dağılımı</h3>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={40} outerRadius={70} paddingAngle={4}>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(value) => [`%${value}`, 'Oran']} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="space-y-1.5 pt-2">
{pieData.map(item => (
<div key={item.name} className="flex justify-between items-center text-xs font-semibold">
<span className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: item.color }}></span>
<span>{item.name}</span>
</span>
<span className="font-bold text-slate-900">%{item.value}</span>
<h3 className="font-extrabold text-sm text-slate-900">Sektörel İhale Dağılımı</h3>
{pieData.length === 0 ? (
<div className="h-48 flex items-center justify-center text-center border-2 border-dashed border-slate-200 rounded-xl">
<p className="text-xs text-slate-400 max-w-[180px]">Sektör dağılımı, ihale verisi yüklendikçe hesaplanır.</p>
</div>
) : (
<>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={40} outerRadius={70} paddingAngle={4}>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(value) => [`${value} ihale`, 'Adet']} />
</PieChart>
</ResponsiveContainer>
</div>
))}
</div>
<div className="space-y-1.5 pt-2">
{pieData.map((item) => (
<div key={item.name} className="flex justify-between items-center text-xs font-semibold">
<span className="flex items-center gap-2 min-w-0">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: item.color }}></span>
<span className="truncate">{item.name}</span>
</span>
<span className="font-bold text-slate-900 shrink-0">%{pieToplam ? Math.round((item.value / pieToplam) * 100) : 0}</span>
</div>
))}
</div>
</>
)}
</div>
</div>
+53 -40
View File
@@ -1,17 +1,20 @@
import React, { useState } from 'react';
import { COMPETITOR_DATA } from '../../data/mockTenders';
import { FileCheck2, Search, Building2, TrendingUp, Award, Shield } from 'lucide-react';
import { useApp } from '../../context/AppContext';
import { firmStats, formatTL } from '../../services/analytics';
import { FileCheck2, Search, Award } from 'lucide-react';
export const ContractSearch = () => {
const { tenders } = useApp();
const [searchTerm, setSearchTerm] = useState('');
const filteredCompetitors = COMPETITOR_DATA.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase())
const { firms } = firmStats(tenders);
const filtered = firms.filter((c) =>
c.name.toLocaleLowerCase('tr-TR').includes(searchTerm.toLocaleLowerCase('tr-TR'))
);
return (
<div className="space-y-4 my-4 animate-fade-in">
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center shadow-md">
@@ -19,7 +22,7 @@ export const ContractSearch = () => {
</div>
<div>
<h1 className="text-xl font-black text-slate-900">Sözleşme Bedeli & Rakip Analizi</h1>
<p className="text-xs text-slate-500">Rakip firmaların kamu ihalelerinden aldığı toplam iş hacmini ve sözleşme bedellerini sorgulayın.</p>
<p className="text-xs text-slate-500">Kazanan firmaların sözleşme hacmini ve pazar payını canlı veriden sorgulayın.</p>
</div>
</div>
@@ -35,43 +38,53 @@ export const ContractSearch = () => {
</div>
</div>
{/* Competitor Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredCompetitors.map((comp, idx) => (
<div key={idx} className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs hover:shadow-md transition-all space-y-4">
<div className="flex items-start justify-between border-b border-slate-100 pb-3">
{filtered.length === 0 ? (
<div className="bg-white rounded-xl border border-dashed border-slate-300 p-10 text-center">
<Award className="w-8 h-8 text-slate-300 mx-auto mb-3" />
<p className="text-sm font-bold text-slate-500">Henüz kazanan firma verisi yok</p>
<p className="text-xs text-slate-400 mt-1 max-w-md mx-auto leading-relaxed">
Kazanan firma ve sözleşme bedeli verisi, doğrudan temin sonuçları ve EKAP sonuç ilanları
biriktikçe otomatik olarak burada listelenir.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((comp, idx) => (
<div key={idx} className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs hover:shadow-md transition-all space-y-4">
<div className="flex items-start justify-between border-b border-slate-100 pb-3">
<div>
<span className="text-[10px] font-extrabold bg-indigo-100 text-indigo-800 px-2 py-0.5 rounded">
Sıra #{idx + 1}
</span>
<h3 className="font-extrabold text-base text-slate-900 mt-1">{comp.name}</h3>
</div>
<Award className="w-6 h-6 text-indigo-600 shrink-0" />
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
<span className="text-slate-400 block font-medium">Toplam Sözleşme</span>
<span className="text-sm font-black text-indigo-900 mt-0.5 block">{formatTL(comp.total)}</span>
</div>
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
<span className="text-slate-400 block font-medium">Kazandığı İhale</span>
<span className="text-sm font-black text-emerald-700 mt-0.5 block">{comp.wins} İhale</span>
</div>
</div>
<div>
<span className="text-[10px] font-extrabold bg-indigo-100 text-indigo-800 px-2 py-0.5 rounded">
Sektör Sırası #{idx + 1}
</span>
<h3 className="font-extrabold text-base text-slate-900 mt-1">{comp.name}</h3>
</div>
<Award className="w-6 h-6 text-indigo-600 shrink-0" />
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
<span className="text-slate-400 block font-medium">Toplam İş Hacmi</span>
<span className="text-sm font-black text-indigo-900 mt-0.5 block">{comp.totalVolume}</span>
</div>
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
<span className="text-slate-400 block font-medium">Kazandığı İhale</span>
<span className="text-sm font-black text-emerald-700 mt-0.5 block">{comp.winCount} Adet İhale</span>
<div className="flex justify-between text-xs font-semibold text-slate-700 mb-1">
<span>Pazar Payı (hacim)</span>
<span className="font-bold text-indigo-700">%{comp.share}</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden">
<div className="bg-indigo-600 h-2 rounded-full" style={{ width: `${Math.max(comp.share, 2)}%` }}></div>
</div>
</div>
</div>
<div>
<div className="flex justify-between text-xs font-semibold text-slate-700 mb-1">
<span>Tahmini Pazar Payı</span>
<span className="font-bold text-indigo-700">%{comp.share}</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden">
<div className="bg-indigo-600 h-2 rounded-full" style={{ width: `${comp.share}%` }}></div>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
);
+82 -3
View File
@@ -1,14 +1,50 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useApp } from '../../context/AppContext';
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, ShieldCheck, Zap, Sparkles, Moon } from 'lucide-react';
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, Moon, Mail, Send } from 'lucide-react';
import confetti from 'canvas-confetti';
import { getToken } from '../../services/auth';
export const PwaSettings = () => {
const { userProfile, reminderSettings, setReminderSettings, requestPushPermission, notify, isQuietNow } = useApp();
const { userProfile, authUser, tenders, reminderSettings, setReminderSettings, requestPushPermission, notify, isQuietNow } = useApp();
const [offlineMode, setOfflineMode] = useState(true);
const [installed, setInstalled] = useState(false);
const [permission, setPermission] = useState(typeof Notification !== 'undefined' ? Notification.permission : 'unsupported');
// E-posta (Resend) durumu + test gönderimi
const [emailCfg, setEmailCfg] = useState(null); // null=bilinmiyor, true/false
const [emailSending, setEmailSending] = useState(false);
const [emailResult, setEmailResult] = useState(null);
useEffect(() => {
fetch('/api/notify/email/config')
.then((r) => r.json())
.then((d) => setEmailCfg(Boolean(d.configured)))
.catch(() => setEmailCfg(false));
}, []);
const sendTestEmail = async () => {
setEmailSending(true);
setEmailResult(null);
try {
const yeniler = tenders.filter((t) => t.isYeni).slice(0, 15);
const res = await fetch('/api/notify/email/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ subject: 'İhaleist — bildirim testi', tenders: yeniler }),
});
const data = await res.json();
if (data.success) {
setEmailResult({ ok: true, msg: `E-posta gönderildi → ${data.to}` });
notify('E-posta gönderildi', `Test e-postası ${data.to} adresine iletildi.`);
} else {
setEmailResult({ ok: false, msg: data.error || 'Gönderilemedi' });
}
} catch (e) {
setEmailResult({ ok: false, msg: e.message });
} finally {
setEmailSending(false);
}
};
const handleEnablePush = async () => {
const p = await requestPushPermission();
setPermission(p);
@@ -134,6 +170,49 @@ export const PwaSettings = () => {
</div>
</div>
{/* E-posta Bildirimi (Resend) — gerçek gönderim */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
<Mail className="w-4 h-4 text-blue-500" /> E-posta Bildirimi
</h3>
<span className={`font-extrabold text-[10px] px-2 py-0.5 rounded-full ${
emailCfg === true ? 'bg-emerald-100 text-emerald-800' : emailCfg === false ? 'bg-amber-100 text-amber-800' : 'bg-slate-100 text-slate-600'
}`}>
{emailCfg === true ? 'Aktif (Resend)' : emailCfg === false ? 'Yapılandırılmadı' : 'Kontrol ediliyor…'}
</span>
</div>
<p className="text-xs text-slate-600 leading-relaxed">
{emailCfg === false
? 'E-posta göndermek için sunucuya RESEND_API_KEY ekleyin (Coolify → Environment). Ayrıca gönderen domainini Resend’de DNS ile doğrulayın.'
: 'Takip kriterlerinize uyan ihaleler, hesabınızın e-posta adresine özet olarak gönderilir. Aşağıdan kendinize test gönderebilirsiniz.'}
</p>
<label className="flex items-center justify-between text-xs font-bold text-slate-800 cursor-pointer">
<span>E-posta bildirimleri açık</span>
<input type="checkbox" checked={reminderSettings.emailEnabled}
onChange={(e) => setReminderSettings((s) => ({ ...s, emailEnabled: e.target.checked }))}
className="w-4 h-4 accent-blue-600 rounded" />
</label>
<button
onClick={sendTestEmail}
disabled={emailSending || emailCfg !== true || !authUser}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-bold text-xs py-2.5 rounded-xl flex items-center justify-center gap-2"
title={!authUser ? 'Giriş yapın' : emailCfg !== true ? 'RESEND_API_KEY gerekli' : 'Kendinize test e-postası gönder'}
>
<Send className="w-3.5 h-3.5" />
{emailSending ? 'Gönderiliyor…' : 'Kendime Test E-postası Gönder'}
</button>
{emailResult && (
<p className={`text-[11px] ${emailResult.ok ? 'text-emerald-600' : 'text-red-600'}`}>
{emailResult.ok ? '✓ ' : '⚠ '}{emailResult.msg}
</p>
)}
</div>
{/* Install PWA Application */}
<div className="bg-linear-to-br from-slate-900 to-cyan-950 text-white p-6 rounded-2xl shadow-xs space-y-4 border border-cyan-800/40">
<div className="flex items-center justify-between">
+32 -61
View File
@@ -1,5 +1,4 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { MOCK_TENDERS, MOCK_LIVE_STATS, MOCK_COMPANY_STATS } from '../data/mockTenders';
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
@@ -10,10 +9,10 @@ export const AppProvider = ({ children }) => {
const [activeTab, setActiveTab] = useState('list'); // 'list', 'bulten', 'sonuc', 'takvim', 'sozlesme', 'kdv', 'fiyat', 'kanban', 'smart_filter_builder'
const [tenders, setTenders] = useState(() => {
const saved = localStorage.getItem('ihaleist_tenders');
return saved ? JSON.parse(saved) : MOCK_TENDERS;
return saved ? JSON.parse(saved) : [];
});
const [liveStats, setLiveStats] = useState(MOCK_LIVE_STATS);
const [liveStats, setLiveStats] = useState({ toplamIhale: 0, bugunEklenen: 0 });
const [searchQuery, setSearchQuery] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [selectedAlimTuru, setSelectedAlimTuru] = useState('');
@@ -24,10 +23,7 @@ export const AppProvider = ({ children }) => {
const [savedFilters, setSavedFilters] = useState(() => {
const saved = localStorage.getItem('ihaleist_saved_filters');
return saved ? JSON.parse(saved) : [
{ id: 'sf-1', name: 'Sağlık Hizmeti İhaleleri', user: 'hikmet', kurumTipi: 'Eğitim ve Araştırma Hastaneleri', sehir: 'Ankara' },
{ id: 'sf-2', name: 'Kayseri Kırtasiye Alımları', user: 'hikmet', kurumTipi: 'Ceza İnfaz Kurumları', sehir: 'Kayseri' }
];
return saved ? JSON.parse(saved) : [];
});
const [isLiveMode, setIsLiveMode] = useState(true);
@@ -61,11 +57,10 @@ export const AppProvider = ({ children }) => {
const data = await fetchLiveTenders({ refresh });
setTenders(prev => mergeUserState(data.tenders, prev));
setLiveDataSource({ source: data.source, fetchedAt: data.fetchedAt });
setLiveStats(prev => ({
...prev,
bugunEklenen: data.todayCount ?? prev.bugunEklenen,
aktifTakip: data.totalCount ?? prev.aktifTakip
}));
setLiveStats({
toplamIhale: data.totalCount ?? 0,
bugunEklenen: data.todayCount ?? 0,
});
} catch (err) {
setLiveDataError(err.message);
} finally {
@@ -107,11 +102,13 @@ export const AppProvider = ({ children }) => {
const [aiAnalysisResults, setAiAnalysisResults] = useState(null);
// Enterprise Module States
const [competitorWatchlist, setCompetitorWatchlist] = useState([
{ id: 'cw-1', name: 'Bileşim Tıbbi Cihazlar A.Ş.', sector: 'Medikal & Tıbbi Malzeme', winCount: 24, totalRevenue: 14200000, marketShare: 32, status: 'Aktif İzleniyor', alertOnBid: true, alertOnWin: true },
{ id: 'cw-2', name: 'Atlas Sağlık ve Kırtasiye Ltd.', sector: 'Sağlık & Büro Malzemeleri', winCount: 18, totalRevenue: 9800000, marketShare: 24, status: 'Aktif İzleniyor', alertOnBid: true, alertOnWin: true },
{ id: 'cw-3', name: 'Tekno-Med Laboratuvar Sistemleri', sector: 'Laboratuvar & Bilişim', winCount: 12, totalRevenue: 6500000, marketShare: 16, status: 'Aktif İzleniyor', alertOnBid: false, alertOnWin: true }
]);
// Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (localStorage)
const [competitorWatchlist, setCompetitorWatchlist] = useState(() => {
try { return JSON.parse(localStorage.getItem('ihaleist_rakip_izleme') || '[]'); } catch { return []; }
});
useEffect(() => {
localStorage.setItem('ihaleist_rakip_izleme', JSON.stringify(competitorWatchlist));
}, [competitorWatchlist]);
// --- Favori kurumlar (localStorage, gerçek) ---
const [favoriteKurumlar, setFavoriteKurumlar] = useState(() => {
@@ -142,13 +139,13 @@ export const AppProvider = ({ children }) => {
const addTeamMember = (m) => setTeamMembers((prev) => [...prev, { id: 'tm-' + Date.now(), ...m }]);
const removeTeamMember = (id) => setTeamMembers((prev) => prev.filter((m) => m.id !== id));
const [vaultDocuments, setVaultDocuments] = useState([
{ id: 'doc-1', name: 'İmza Sirküleri 2026.pdf', category: 'Yasal Belgeler', size: '2.4 MB', updatedAt: '2026-01-15', expiresAt: '2027-01-15', status: 'Geçerli' },
{ id: 'doc-2', name: 'ISO 13485 Tıbbi Cihaz Sertifikası.pdf', category: 'Kalite Belgeleri', size: '1.8 MB', updatedAt: '2025-11-10', expiresAt: '2028-11-10', status: 'Geçerli' },
{ id: 'doc-3', name: 'Oda Faaliyet Belgesi.pdf', category: 'Resmi Kayıtlar', size: '1.1 MB', updatedAt: '2026-02-01', expiresAt: '2026-08-01', status: 'Geçerli' },
{ id: 'doc-4', name: 'ÜTS Firma Kayıt Belgesi.pdf', category: 'Tıbbi Cihaz Kaydı', size: '3.2 MB', updatedAt: '2026-03-20', expiresAt: '2027-03-20', status: 'Geçerli' },
{ id: 'doc-5', name: 'Vergi Levhası 2025.pdf', category: 'Mali Belgeler', size: '850 KB', updatedAt: '2025-05-30', expiresAt: '2026-05-30', status: 'Geçerli' }
]);
// Belge kasası — kullanıcı kendi belgelerini ekler (localStorage)
const [vaultDocuments, setVaultDocuments] = useState(() => {
try { return JSON.parse(localStorage.getItem('ihaleist_belgeler') || '[]'); } catch { return []; }
});
useEffect(() => {
localStorage.setItem('ihaleist_belgeler', JSON.stringify(vaultDocuments));
}, [vaultDocuments]);
const [reminderSettings, setReminderSettings] = useState(() => {
const def = {
@@ -178,8 +175,8 @@ export const AppProvider = ({ children }) => {
};
const [webhooksConfig, setWebhooksConfig] = useState({
slackWebhookUrl: 'https://hooks.slack.com/services/T00/B00/XXXXXX',
teamsWebhookUrl: 'https://outlook.office.com/webhook/XXXXXX',
slackWebhookUrl: '',
teamsWebhookUrl: '',
events: {
newTenderMatch: true,
tenderWon: true,
@@ -188,9 +185,7 @@ export const AppProvider = ({ children }) => {
}
});
const [apiKeys, setApiKeys] = useState([
{ id: 'key-1', name: 'ERP Entegrasyon Anahtarı', key: 'ih_live_8f92a19c3b88412e', createdAt: '2026-04-10', lastUsed: 'Bugün 12:45' }
]);
const [apiKeys, setApiKeys] = useState([]);
// ---------------------------------------------------------------------------
// AI Model Yönetimi (admin panelden seçilebilir + fallback zinciri)
@@ -273,23 +268,11 @@ export const AppProvider = ({ children }) => {
trialDaysLeft: 7,
role: 'Yönetici Tedarikçi',
scrapedCapabilities: {
scrapedAt: '2026-08-07 14:30',
extractedSectors: ['Tıbbi Cihaz ve Sarf Malzemeleri', 'Sağlık & Laboratuvar Teknolojileri'],
extractedProducts: [
'Ortopedik Cerrahi İmplantlar ve Protezler',
'Cerrahi Maske ve Steril Önlük Setleri',
'Tıbbi Ameliyathane & Yoğun Bakım Karyolaları',
'Dezenfektan ve Hastane Sterilizasyon Solüsyonları',
'Tek Kullanımlık Enjektör ve İğne Uçları',
'Laboratuvar Reaktif ve Biyokimya Test Kitleri'
],
extractedCertifications: [
'ISO 13485 Tıbbi Cihaz Kalite Yönetim Sertifikası',
'ÜTS (Ürün Takip Sistemi) Firma Kaydı & Barkod Yetkisi',
'CE Tıbbi Cihaz Uygunluk Beyanı',
'TSE HYB Belgesi (Medikal Cihaz Bakım-Onarım)'
],
matchScoreBoost: 20
scrapedAt: null,
extractedSectors: [],
extractedProducts: [],
extractedCertifications: [],
matchScoreBoost: 0
}
});
@@ -319,10 +302,7 @@ export const AppProvider = ({ children }) => {
}));
};
const [notifications, setNotifications] = useState([
{ id: 'n1', title: 'Yeni İhale Bildirimi', text: 'Sağlık Hizmeti İhaleleri filtrenize 3 yeni ihale eklendi.', time: '5dk önce', read: false },
{ id: 'n2', title: 'Yaklaşan İhale Süresi', text: 'KIRTASİYE MALZEMELERİ ALIMI ihaleniz için son 3 gün!', time: '1sa önce', read: false }
]);
const [notifications, setNotifications] = useState([]);
// Gerçek bildirim: listeye ekler; sessiz saat dışında ve push açıksa tarayıcı
// bildirimi (Notification API) da gösterir.
@@ -351,17 +331,8 @@ export const AppProvider = ({ children }) => {
localStorage.setItem('ihaleist_saved_filters', JSON.stringify(savedFilters));
}, [savedFilters]);
// Live counter animation effect
useEffect(() => {
const interval = setInterval(() => {
setLiveStats(prev => ({
...prev,
onlineKullanici: prev.onlineKullanici + (Math.floor(Math.random() * 5) - 2),
bugunEklenen: prev.bugunEklenen + (Math.random() > 0.7 ? 1 : 0)
}));
}, 4000);
return () => clearInterval(interval);
}, []);
// (Sahte "online kullanıcı" rastgele sayacı kaldırıldı — canlı istatistikler
// gerçek /api/stats verisinden gelir.)
const toggleTrackTender = (tenderId) => {
setTenders(prev => prev.map(t => {
+35
View File
@@ -0,0 +1,35 @@
/**
* Sabit referans listeleri (mock veri DEĞİL — açılır menü/filtre seçenekleri).
* Sektörler, kurum türleri ve iller Türkiye kamu ihale sisteminin gerçek
* kategori listeleridir; il-plaka.mjs gibi kanonik sabitlerdir.
*/
export const SECTORS_LIST = [
"Sağlık / Tıbbi Malzeme",
"Kırtasiye & Büro",
"Bilişim & Yazılım",
"Gıda & Catering",
"İnşaat & Bakım",
"Temizlik & Hijyen",
"Tekstil & Giyim",
"Otomotiv & Yedek Parça",
];
export const INSTITUTION_TYPES_LIST = [
"Eğitim ve Araştırma Hastaneleri",
"Belediyeler",
"Büyükşehir Belediyeleri",
"Ceza İnfaz Kurumları",
"Üniversiteler",
"Bakanlıklar",
"Kamu Şirketleri",
"Ağız ve Diş Sağlığı Merkezleri",
];
export const CITIES_LIST = [
"Adana", "Adıyaman", "Afyonkarahisar", "Aksaray", "Amasya", "Ankara", "Antalya", "Ardahan", "Artvin",
"Aydın", "Balıkesir", "Bolu", "Bursa", "Çanakkale", "Denizli", "Diyarbakır", "Edirne", "Eskişehir",
"Gaziantep", "Giresun", "Isparta", "İstanbul", "İzmir", "Kahramanmaraş", "Kars", "Kastamonu", "Kayseri",
"Kocaeli", "Konya", "Kütahya", "Malatya", "Manisa", "Mardin", "Muğla", "Ordu", "Rize", "Sakarya",
"Samsun", "Sivas", "Şanlıurfa", "Tekirdağ", "Trabzon", "Van", "Zonguldak",
];
-331
View File
@@ -1,331 +0,0 @@
export const MOCK_TENDERS = [
{
id: "tender-101",
ikn: "26DT1489671",
baslik: "KIRTASİYE MALZEMELERİ ALIMI KAYSERİ ÇOCUK CEZAEVİ",
kurum: "CEZA VE TEVKİFEVLERİ GENEL MÜDÜRLÜĞÜ KAYSERİ ÇOCUK KAPALI CEZA İNFAZ KURUMU MÜDÜRLÜĞÜ",
sehir: "Kayseri",
bolge: "İÇ ANADOLU BÖLGESİ",
tarih: "10.08.2026",
saat: "12:00",
alimTuru: "Doğrudan Temin",
usul: "Doğrudan Temin (4734 22/d)",
sektor: "Kırtasiye & Büro",
brans: "Sanatsal Kırtasiye ve El Becerisi Malzemeleri",
kurumTipi: "Ceza İnfaz Kurumları",
eklenmeZamani: "az önce eklendi",
isYeni: true,
aciliyetBadge: "⚠️ SON 3 GÜN",
items: [
{ id: "i1", ad: "35*50 TUVAL", miktar: "10", birim: "adet", birimFiyat: 145 },
{ id: "i2", ad: "50*70 TUVAL", miktar: "10", birim: "adet", birimFiyat: 220 },
{ id: "i3", ad: "AKRİLİK BOYA SETİ 12'Lİ", miktar: "25", birim: "set", birimFiyat: 185 },
{ id: "i4", ad: "RESİM FIRÇASI SETİ NO: 1-12", miktar: "30", birim: "set", birimFiyat: 95 }
],
aciklama: "Kayseri Çocuk Kapalı Ceza İnfaz Kurumu Müdürlüğü Hobi ve Atölye Çalışmaları İhtiyacı İçin 4 Kalem Kırtasiye ve Sanatsal Malzeme Doğrudan Temin Usulü İle Alınacaktır. Fiyat teklif cetveli kaşeli ve imzalı teslim edilecektir.",
yaklasikMaliyet: 18500,
teslimatSuresi: "7 Takvim Günü",
teslimYeri: "Kayseri Çocuk Cezaevi Deposu",
yeterlikKriterleri: ["KDV Hariç Teklif Cetveli", "İmza Sirküleri / Yetki Belgesi", "Faaliyet Belgesi"],
aiMatchScore: 94,
aiRiskScore: "Düşük",
aiBidRange: { min: 14200, max: 17800, recommended: 15600 },
kanbanStatus: "teklif_hazirlaniyor",
atananPersonel: "Ahmet Yılmaz",
durum: "Aktif",
incelendi: true,
takipte: true,
sozlesmeBedeli: null,
kazananFirma: null
},
{
id: "tender-102",
ikn: "26DT1489955",
baslik: "Yemek Kabı ve Üst Kapama Filmi Alımı",
kurum: "TARIM İŞLETMELERİ GENEL MÜDÜRLÜĞÜ KARACABEY TARIM İŞLETMESİ MÜDÜRLÜĞÜ",
sehir: "Bursa",
bolge: "MARMARA BÖLGESİ",
tarih: "10.08.2026",
saat: "18:00",
alimTuru: "Doğrudan Temin",
usul: "Doğrudan Temin (4734 22/d)",
sektor: "Gıda & Catering",
brans: "Ambalaj ve Paketleme Ürünleri",
kurumTipi: "Kamu Şirketleri",
eklenmeZamani: "az önce eklendi",
isYeni: true,
aciliyetBadge: "⚠️ SON 3 GÜN",
items: [
{ id: "i1", ad: "1 Gözlü Yemek Kabı", miktar: "1000", birim: "adet", birimFiyat: 2.80 },
{ id: "i2", ad: "2 Gözlü Yemek Kabı", miktar: "1000", birim: "adet", birimFiyat: 3.40 },
{ id: "i3", ad: "Sızdırmaz Üst Kapama Filmi (Rulo)", miktar: "50", birim: "rulo", birimFiyat: 410 }
],
aciklama: "Karacabey Tarım İşletmesi personel yemekhanesi için kullanılacak olan ısıya dayanıklı 1 ve 2 gözlü porsiyonluk kaplar ve üst kapatma film ruloları alınacaktır.",
yaklasikMaliyet: 26700,
teslimatSuresi: "5 İş Günü",
teslimYeri: "Karacabey Tarım İşletmesi Ambarı",
yeterlikKriterleri: ["Gıda Temas Uygunluk Sertifikası (ISO 22000)", "Teklif Mektubu"],
aiMatchScore: 88,
aiRiskScore: "Düşük",
aiBidRange: { min: 21000, max: 25500, recommended: 23200 },
kanbanStatus: "incelenecek",
atananPersonel: null,
durum: "Aktif",
incelendi: false,
takipte: false,
sozlesmeBedeli: null,
kazananFirma: null
},
{
id: "tender-103",
ikn: "2026/184201",
baslik: "193064-PANİK BUTON KURULUM İŞİ",
kurum: "ANKARA ÜNİVERSİTESİ REKTÖRLÜĞÜ SAĞLIK UYGULAMA VE ARAŞTIRMA HASTANELERİ BAŞHEKİMLİĞİ",
sehir: "Ankara",
bolge: "İÇ ANADOLU BÖLGESİ",
tarih: "11.08.2026",
saat: "10:30",
alimTuru: "Doğrudan Temin",
usul: "Doğrudan Temin (4734 22/d)",
sektor: "Bilişim & Yazılım",
brans: "Güvenlik Sistemleri ve Zayıf Akım",
kurumTipi: "Üniversiteler",
eklenmeZamani: "15 dakika önce",
isYeni: true,
aciliyetBadge: "🔥 BUGÜN KAPANIYOR",
items: [
{ id: "i1", ad: "Kablosuz Panik Butonu Verici Ünite", miktar: "15", birim: "adet", birimFiyat: 1250 },
{ id: "i2", ad: "Merkezi Alarm Alma Paneli (RF Receiver)", miktar: "2", birim: "adet", birimFiyat: 8400 },
{ id: "i3", ad: "Montaj ve Kablolama Hizmeti", miktar: "1", birim: "paket", birimFiyat: 6500 }
],
aciklama: "İbn-i Sina Araştırma Hastanesi Acil Servis ve Yoğun Bakım üniteleri için acil durum kablosuz panik buton sistemi temini ve devreye alınması işidir.",
yaklasikMaliyet: 42000,
teslimatSuresi: "10 Gün",
teslimYeri: "Ankara Üniversitesi İbn-i Sina Hastanesi",
yeterlikKriterleri: ["TSE HYB Belgesi", "Marka Tescil / Yetkili Satıcı Belgesi"],
aiMatchScore: 92,
aiRiskScore: "Orta",
aiBidRange: { min: 34500, max: 40800, recommended: 37200 },
kanbanStatus: "inceleniyor",
atananPersonel: "Zeynep Kaya",
durum: "Aktif",
incelendi: true,
takipte: true,
sozlesmeBedeli: null,
kazananFirma: null
},
{
id: "tender-104",
ikn: "26DT1490112",
baslik: "HASTAYA ÖZGÜ ORTOPEDİ MALZEME ALIMI (ZARİFE BALLI)",
kurum: "KONYA İL SAĞLIK MÜDÜRLÜĞÜ KONYA AKŞEHİR DEVLET HASTANESİ BAŞHEKİMLİĞİ",
sehir: "Konya",
bolge: "İÇ ANADOLU BÖLGESİ",
tarih: "12.08.2026",
saat: "14:00",
alimTuru: "Doğrudan Temin",
usul: "Doğrudan Temin (4734 22/f)",
sektor: "Sağlık / Tıbbi Malzeme",
brans: "Ortopedi ve Protez İmplanta Sistemleri",
kurumTipi: "Eğitim ve Araştırma Hastaneleri",
eklenmeZamani: "1 saat önce",
isYeni: false,
aciliyetBadge: null,
items: [
{ id: "i1", ad: "Revizyon Kalça Protez Femoral Stem (Titanyum)", miktar: "1", birim: "set", birimFiyat: 48500 },
{ id: "i2", ad: "Asetabular Kap ve Seramik Insert", miktar: "1", birim: "set", birimFiyat: 29000 },
{ id: "i3", ad: "Kemik Çimentosu Gentamisinli 40gr", miktar: "2", birim: "kutu", birimFiyat: 3200 }
],
aciklama: "Hastaya özel doğrudan temin usulü ile kalça revizyon protez seti alımıdır. Ürünlerin ÜTS (Ürün Takip Sistemi) kaydı ve SUT kodları eksiksiz olmalıdır.",
yaklasikMaliyet: 83900,
teslimatSuresi: "3 Gün (Hasta Ameliyat Tarihi)",
teslimYeri: "Akşehir Devlet Hastanesi Ameliyathane Deposu",
yeterlikKriterleri: ["ÜTS Barkod Eşleşme Çıktısı", "SUT Bildirimi", "FDA / CE Sertifikası"],
aiMatchScore: 96,
aiRiskScore: "Düşük",
aiBidRange: { min: 72000, max: 81000, recommended: 76500 },
kanbanStatus: "teklif_verildi",
atananPersonel: "Hikmet Tanrıverdi",
durum: "Aktif",
incelendi: true,
takipte: true,
sozlesmeBedeli: null,
kazananFirma: null
},
{
id: "tender-105",
ikn: "2026/178920",
baslik: "Klima Santrali Filtre Değişimi ve Bakım Hizmet Alımı",
kurum: "İZMİR BÜYÜKŞEHİR BELEDİYESİ ULAŞIM DARE BAŞKANLIĞI",
sehir: "İzmir",
bolge: "EGE BÖLGESİ",
tarih: "14.08.2026",
saat: "11:00",
alimTuru: "Açık İhale",
usul: "Açık İhale Usulü (4734 Madde 19)",
sektor: "İnşaat & Bakım",
brans: "HVAC ve İklimlendirme Bakımı",
kurumTipi: "Büyükşehir Belediyeleri",
eklenmeZamani: "3 saat önce",
isYeni: false,
aciliyetBadge: null,
items: [
{ id: "i1", ad: "G4 Panel Filtre 592x592x48mm", miktar: "120", birim: "adet", birimFiyat: 190 },
{ id: "i2", ad: "F7 Torba Filtre 592x592x535mm 6 Cepli", miktar: "80", birim: "adet", birimFiyat: 480 },
{ id: "i3", ad: "Hepa Filtre H13 610x610x292mm", miktar: "24", birim: "adet", birimFiyat: 2100 },
{ id: "i4", ad: "Periyodik Bakım ve Gaz Şarjı Hizmeti", miktar: "12", birim: "ay", birimFiyat: 15000 }
],
aciklama: "İzmir Büyükşehir Belediyesi Hizmet Binalarındaki 18 Adet Klima Santralinin 1 Yıllık Periyodik Filtre Değişimi ve Bakım Hizmeti Alımı İhalesidir.",
yaklasikMaliyet: 241000,
teslimatSuresi: "365 Gün",
teslimYeri: "İzmir Merkez Binaları",
yeterlikKriterleri: ["İş Deneyim Belgesi (Benzer İş %50)", "Geçici Teminat Mektubu %3", "Kapasite Raporu"],
aiMatchScore: 81,
aiRiskScore: "Orta",
aiBidRange: { min: 195000, max: 228000, recommended: 212000 },
kanbanStatus: "incelenecek",
atananPersonel: null,
durum: "Aktif",
incelendi: false,
takipte: false,
sozlesmeBedeli: null,
kazananFirma: null
},
{
id: "tender-106",
ikn: "26DT1490888",
baslik: "Genel Temizlik ve Dezenfeksiyon Sarf Malzemeleri Alımı",
kurum: "ANTALYA GENÇLİK VE SPOR İL MÜDÜRLÜĞÜ KYK YURTLARI",
sehir: "Antalya",
bolge: "AKDENİZ BÖLGESİ",
tarih: "15.08.2026",
saat: "15:30",
alimTuru: "Doğrudan Temin",
usul: "Doğrudan Temin (4734 22/d)",
sektor: "Temizlik & Hijyen",
brans: "Endüstriyel Temizlik Kimyasalları",
kurumTipi: "Bakanlıklar",
eklenmeZamani: "5 saat önce",
isYeni: false,
aciliyetBadge: null,
items: [
{ id: "i1", ad: "Çamaşır Suyu (Yüzey Dezenfektanı) 20 L", miktar: "80", birim: "bidon", birimFiyat: 340 },
{ id: "i2", ad: "Sıvı El Sabunu Sedefli 30 L", miktar: "60", birim: "bidon", birimFiyat: 520 },
{ id: "i3", ad: "Mikrofiber Temizlik Bezi 40x40 (100'lü)", miktar: "15", birim: "paket", birimFiyat: 680 }
],
aciklama: "Antalya KYK Kız ve Erkek Öğrenci Yurtları yeni eğitim-öğretim sezonu hazırlıkları için temizlik hijyen sarf ürünleri temin edilecektir.",
yaklasikMaliyet: 68600,
teslimatSuresi: "7 Gün",
teslimYeri: "Antalya Akdeniz Yurt Müdürlüğü Deposu",
yeterlikKriterleri: ["MSDS Güvenlik Bilgi Formları", "Sağlık Bakanlığı Ruhsat Fotokopisi"],
aiMatchScore: 89,
aiRiskScore: "Düşük",
aiBidRange: { min: 54000, max: 63000, recommended: 58500 },
kanbanStatus: "kazanildi",
atananPersonel: "Hikmet Tanrıverdi",
durum: "Sonuçlandı",
incelendi: true,
takipte: true,
sozlesmeBedeli: 57400,
kazananFirma: "Tanrıverdi Medikal & Hijyen Ltd. Şti."
},
{
id: "tender-107",
ikn: "2026/172340",
baslik: "Veri Merkezi Sunucu ve Depolama Ünitesi Alımı",
kurum: "HAVELSAN HAVA ELEKTRONİK SANAYİ A.Ş.",
sehir: "Ankara",
bolge: "İÇ ANADOLU BÖLGESİ",
tarih: "18.08.2026",
saat: "10:00",
alimTuru: "Açık İhale",
usul: "Açık İhale",
sektor: "Bilişim & Yazılım",
brans: "Sunucu ve Veri Depolama Donanımları",
kurumTipi: "Kamu Şirketleri",
eklenmeZamani: "1 gün önce",
isYeni: false,
aciliyetBadge: null,
items: [
{ id: "i1", ad: "Rack Sunucu 2U 2x Xeon Gold 6430 / 256GB RAM", miktar: "4", birim: "adet", birimFiyat: 145000 },
{ id: "i2", ad: "NVMe SAN Storage Unit 100TB Usable", miktar: "1", birim: "set", birimFiyat: 620000 }
],
aciklama: "Havelsan Teknoloji Kampüsü Veri Merkezi Genişletme Projesi kapsamında yüksek performanslı sunucu ve veri depolama üniteleri temin edilecektir.",
yaklasikMaliyet: 1200000,
teslimatSuresi: "30 Takvim Günü",
teslimYeri: "Havelsan ODTÜ Teknokent Binası",
yeterlikKriterleri: ["Üretici Yetki Belgesi (MAF)", "ISO 27001 Bilgi Güvenliği Belgesi"],
aiMatchScore: 76,
aiRiskScore: "Yüksek",
aiBidRange: { min: 980000, max: 1150000, recommended: 1040000 },
kanbanStatus: "kaybedildi",
atananPersonel: "Mehmet Demir",
durum: "Sonuçlandı",
incelendi: true,
takipte: true,
sozlesmeBedeli: 1025000,
kazananFirma: "Netaş Telekomünikasyon A.Ş."
}
];
export const MOCK_COMPANY_STATS = {
notlarimCount: 4,
ajandaCount: 2,
incelediklerimCount: 14,
sozlesmelerimCount: 8,
sonuclarimCount: 12,
takibimdekilerCount: 9,
kanbanStats: {
incelenecek: 3,
inceleniyor: 2,
teklif_hazirlaniyor: 4,
teklif_verildi: 3,
kazanildi: 8,
kaybedildi: 4
}
};
export const MOCK_LIVE_STATS = {
onlineKullanici: 17551,
toplamKalem: 99666,
bugunEklenen: 1445,
aktifTakip: 102473
};
export const SECTORS_LIST = [
"Sağlık / Tıbbi Malzeme",
"Kırtasiye & Büro",
"Bilişim & Yazılım",
"Gıda & Catering",
"İnşaat & Bakım",
"Temizlik & Hijyen",
"Tekstil & Giyim",
"Otomotiv & Yedek Parça"
];
export const INSTITUTION_TYPES_LIST = [
"Eğitim ve Araştırma Hastaneleri",
"Belediyeler",
"Büyükşehir Belediyeleri",
"Ceza İnfaz Kurumları",
"Üniversiteler",
"Bakanlıklar",
"Kamu Şirketleri",
"Ağız ve Diş Sağlığı Merkezleri"
];
export const CITIES_LIST = [
"Adana", "Adıyaman", "Afyonkarahisar", "Aksaray", "Amasya", "Ankara", "Antalya", "Ardahan", "Artvin",
"Aydın", "Balıkesir", "Bolu", "Bursa", "Çanakkale", "Denizli", "Diyarbakır", "Edirne", "Eskişehir",
"Gaziantep", "Giresun", "Isparta", "İstanbul", "İzmir", "Kahramanmaraş", "Kars", "Kastamonu", "Kayseri",
"Kocaeli", "Konya", "Kütahya", "Malatya", "Manisa", "Mardin", "Muğla", "Ordu", "Rize", "Sakarya",
"Samsun", "Sivas", "Şanlıurfa", "Tekirdağ", "Trabzon", "Van", "Zonguldak"
];
export const COMPETITOR_DATA = [
{ name: "Tanrıverdi Medikal & Hijyen Ltd.", winCount: 24, totalVolume: "4.850.000 TL", share: 32 },
{ name: "Atlas Büro & Kırtasiye A.Ş.", winCount: 19, totalVolume: "3.200.000 TL", share: 24 },
{ name: "ProTek Bilişim ve Sistemler", winCount: 15, totalVolume: "6.750.000 TL", share: 22 },
{ name: "Özkan Sağlık Hizmetleri", winCount: 11, totalVolume: "2.100.000 TL", share: 14 },
{ name: "Diğer Tedarikçiler", winCount: 8, totalVolume: "1.400.000 TL", share: 8 }
];
+57
View File
@@ -107,6 +107,63 @@ export function sectorStats(tenders) {
return [...map.entries()].map(([sektor, count]) => ({ sektor, count })).sort((a, b) => b.count - a.count);
}
// "10.08.2026" → Date
export function parseDateTR(s) {
if (!s) return null;
const m = String(s).match(/(\d{1,2})[.\/](\d{1,2})[.\/](\d{4})/);
if (!m) return null;
return new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
}
// --- Firmanın kendi performansı (kullanıcının kanban/takip durumundan) ---
const BID_STATES = ['teklif_verildi', 'kazanildi', 'kaybedildi'];
export function companyStats(tenders) {
const won = tenders.filter((t) => t.kanbanStatus === 'kazanildi');
const lost = tenders.filter((t) => t.kanbanStatus === 'kaybedildi');
const bid = tenders.filter((t) => BID_STATES.includes(t.kanbanStatus));
const wonTotal = won.reduce(
(s, t) => s + (parseTL(t.sozlesmeBedeli) ?? parseTL(t.yaklasikMaliyet) ?? 0),
0
);
const resulted = won.length + lost.length;
const kanban = {};
for (const st of ['incelenecek', 'inceleniyor', 'teklif_hazirlaniyor', 'teklif_verildi', 'kazanildi', 'kaybedildi']) {
kanban[st] = tenders.filter((t) => (t.kanbanStatus || '') === st).length;
}
return {
participated: bid.length,
won: won.length,
lost: lost.length,
resulted,
winRate: resulted ? Math.round((won.length / resulted) * 100) : 0,
wonTotal,
tracked: tenders.filter((t) => t.takipte).length,
reviewed: tenders.filter((t) => t.incelendi || (t.kanbanStatus && t.kanbanStatus !== 'incelenecek')).length,
contracts: won.length,
};
}
// --- Aylık kazanım / katılım hacmi (son 7 ay) ---
const AY_KISA = ['Oca', 'Şub', 'Mar', 'Nis', 'May', 'Haz', 'Tem', 'Ağu', 'Eyl', 'Eki', 'Kas', 'Ara'];
export function monthlyStats(tenders) {
const map = new Map();
for (const t of tenders) {
if (!BID_STATES.includes(t.kanbanStatus)) continue;
const d = parseDateTR(t.tarih);
if (!d) continue;
const key = `${d.getFullYear()}-${String(d.getMonth()).padStart(2, '0')}`;
const cur = map.get(key) || { y: d.getFullYear(), m: d.getMonth(), kazanim: 0, katilim: 0 };
const val = parseTL(t.sozlesmeBedeli) ?? parseTL(t.yaklasikMaliyet) ?? 0;
cur.katilim += val;
if (t.kanbanStatus === 'kazanildi') cur.kazanim += val;
map.set(key, cur);
}
return [...map.values()]
.sort((a, b) => a.y - b.y || a.m - b.m)
.slice(-7)
.map((x) => ({ month: AY_KISA[x.m], kazanim: x.kazanim, katilim: x.katilim }));
}
// --- Alım türü dağılımı ---
export function alimTuruStats(tenders) {
const map = new Map();