diff --git a/server/email.mjs b/server/email.mjs new file mode 100644 index 0000000..2746197 --- /dev/null +++ b/server/email.mjs @@ -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 '; +} + +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) => ` + ${escapeHtml(t.baslik || '')} + ${escapeHtml(t.sehir || '')} + ${escapeHtml(t.tarih || '')} + ` + ) + .join(''); + return `
+

İhaleist

+

Merhaba ${escapeHtml(name || '')}, takip kriterlerinize uyan ihaleler:

+ + + + + + + ${rows || ''} +
İhaleİlTarih
Yeni ihale yok.
+

Bu e-posta İhaleist tarafından gönderildi. Bildirim tercihlerinizi uygulama ayarlarından değiştirebilirsiniz.

+
`; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} diff --git a/server/index.js b/server/index.js index 0333b45..d9df838 100644 --- a/server/index.js +++ b/server/index.js @@ -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); diff --git a/src/components/DetailedSearchModal.jsx b/src/components/DetailedSearchModal.jsx index dd7baea..e1aa40d 100644 --- a/src/components/DetailedSearchModal.jsx +++ b/src/components/DetailedSearchModal.jsx @@ -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 = () => { diff --git a/src/components/LeftSidebar.jsx b/src/components/LeftSidebar.jsx index 1aca722..c168a27 100644 --- a/src/components/LeftSidebar.jsx +++ b/src/components/LeftSidebar.jsx @@ -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 diff --git a/src/components/LiveStatsBar.jsx b/src/components/LiveStatsBar.jsx index 0d37161..78ab980 100644 --- a/src/components/LiveStatsBar.jsx +++ b/src/components/LiveStatsBar.jsx @@ -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 (
- - {/* Online Kullanıcı */} -
- - {liveStats.onlineKullanici.toLocaleString('tr-TR')} - Online Kullanıcı -
- - {/* Toplam Kalem */} -
- - {liveStats.toplamKalem.toLocaleString('tr-TR')} - Toplam Kalem -
- - {/* Bugün Eklenen */} -
- - {liveStats.bugunEklenen.toLocaleString('tr-TR')} - Bugün Eklenen -
- - {/* Aktif Takip */} -
- - {liveStats.aktifTakip.toLocaleString('tr-TR')} - Aktif Takip -
- + {stats.map((s) => { + const Icon = s.icon; + return ( +
+ {s.dot ? ( + + ) : ( + + )} + {(s.deger || 0).toLocaleString('tr-TR')} + {s.etiket} +
+ ); + })}
); diff --git a/src/components/RightSidebar.jsx b/src/components/RightSidebar.jsx index f63b0b4..2a2c36e 100644 --- a/src/components/RightSidebar.jsx +++ b/src/components/RightSidebar.jsx @@ -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 ( diff --git a/src/components/SmartFilterBuilder.jsx b/src/components/SmartFilterBuilder.jsx index b747dca..5285ff6 100644 --- a/src/components/SmartFilterBuilder.jsx +++ b/src/components/SmartFilterBuilder.jsx @@ -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 = () => { diff --git a/src/components/tools/AnalyticsDashboard.jsx b/src/components/tools/AnalyticsDashboard.jsx index e124b7e..00362c6 100644 --- a/src/components/tools/AnalyticsDashboard.jsx +++ b/src/components/tools/AnalyticsDashboard.jsx @@ -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 (
- + {/* Header Banner */}
@@ -43,108 +33,145 @@ export const AnalyticsDashboard = () => {

Analitik Dashboard & Fiyat Isı Haritası

-

{userProfile.company} firmasına özel kazanma oranı, ciro hedefleri ve il bazlı ihale yoğunluk analizi.

+

{userProfile.company} — kanban süreçlerinizden hesaplanan gerçek kazanma oranı ve il bazlı ihale yoğunluğu.

- {/* KPI Cards & Ciro Target */} + {/* KPI Cards & Ciro Target — gerçek (kanban/takip verisinden) */}
- +
Katıldığımız İhaleler
- 34 İhale + {cs.participated} İhale
+ Teklif verilen + sonuçlanan
İhale Kazanma Oranımız
- %68 - - +%12 - + %{cs.winRate} + {cs.resulted > 0 && ( + + {cs.won}/{cs.resulted} + + )}
+ {cs.resulted > 0 ? `${cs.resulted} sonuçlanan ihaleden` : 'Henüz sonuçlanan ihale yok'}
Toplam Kazanılan Bedel
- ₺3.400.000 + {formatTL(cs.wonTotal)}
+ {cs.contracts} sözleşme
- {/* Target Progress Card */} + {/* Ciro Hedefi — kullanıcı belirler */}
- 2026 Ciro Hedefi + Ciro Hedefi - %68 -
- ₺3.4M / ₺5.0M -
-
+
+ {editing || hedef === 0 ? ( +
+ 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" + /> + +
+ ) : ( + <> + {formatTL(cs.wonTotal)} / {formatTL(hedef)} +
+
+
+ + )}
- {/* Recharts Row */} + {/* Recharts Row — gerçek veriden */}
- + {/* Monthly Revenue Chart */}

Aylık İhale Kazanım ve Teklif Hacmi (TL)

- - - - - `₺${val/1000}k`} /> - [`₺${value.toLocaleString('tr-TR')}`, '']} /> - - - - + {monthlyData.length === 0 ? ( +
+

Henüz teklif/sonuç verisi yok

+

Kanban'da ihaleleri "teklif verildi / kazanıldı / kaybedildi" durumuna aldıkça aylık hacim grafiği oluşur.

+
+ ) : ( + + + + + `₺${Math.round(val / 1000)}k`} /> + [`₺${value.toLocaleString('tr-TR')}`, '']} /> + + + + + )}
{/* Sector Distribution Pie */}
-

Sektörel İhale Kazanım Dağılımı

-
- - - - {pieData.map((entry, index) => ( - - ))} - - [`%${value}`, 'Oran']} /> - - -
-
- {pieData.map(item => ( -
- - - {item.name} - - %{item.value} +

Sektörel İhale Dağılımı

+ {pieData.length === 0 ? ( +
+

Sektör dağılımı, ihale verisi yüklendikçe hesaplanır.

+
+ ) : ( + <> +
+ + + + {pieData.map((entry, index) => ( + + ))} + + [`${value} ihale`, 'Adet']} /> + +
- ))} -
+
+ {pieData.map((item) => ( +
+ + + {item.name} + + %{pieToplam ? Math.round((item.value / pieToplam) * 100) : 0} +
+ ))} +
+ + )}
diff --git a/src/components/tools/ContractSearch.jsx b/src/components/tools/ContractSearch.jsx index 079bc1b..2ff3c67 100644 --- a/src/components/tools/ContractSearch.jsx +++ b/src/components/tools/ContractSearch.jsx @@ -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 (
- +
@@ -19,7 +22,7 @@ export const ContractSearch = () => {

Sözleşme Bedeli & Rakip Analizi

-

Rakip firmaların kamu ihalelerinden aldığı toplam iş hacmini ve sözleşme bedellerini sorgulayın.

+

Kazanan firmaların sözleşme hacmini ve pazar payını canlı veriden sorgulayın.

@@ -35,43 +38,53 @@ export const ContractSearch = () => {
- {/* Competitor Cards */} -
- {filteredCompetitors.map((comp, idx) => ( -
-
+ {filtered.length === 0 ? ( +
+ +

Henüz kazanan firma verisi yok

+

+ Kazanan firma ve sözleşme bedeli verisi, doğrudan temin sonuçları ve EKAP sonuç ilanları + biriktikçe otomatik olarak burada listelenir. +

+
+ ) : ( +
+ {filtered.map((comp, idx) => ( +
+
+
+ + Sıra #{idx + 1} + +

{comp.name}

+
+ +
+ +
+
+ Toplam Sözleşme + {formatTL(comp.total)} +
+
+ Kazandığı İhale + {comp.wins} İhale +
+
+
- - Sektör Sırası #{idx + 1} - -

{comp.name}

-
- -
- -
-
- Toplam İş Hacmi - {comp.totalVolume} -
-
- Kazandığı İhale - {comp.winCount} Adet İhale +
+ Pazar Payı (hacim) + %{comp.share} +
+
+
+
- -
-
- Tahmini Pazar Payı - %{comp.share} -
-
-
-
-
-
- ))} -
+ ))} +
+ )}
); diff --git a/src/components/tools/PwaSettings.jsx b/src/components/tools/PwaSettings.jsx index 1744d9f..5c7db6c 100644 --- a/src/components/tools/PwaSettings.jsx +++ b/src/components/tools/PwaSettings.jsx @@ -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 = () => {
+ {/* E-posta Bildirimi (Resend) — gerçek gönderim */} +
+
+

+ E-posta Bildirimi +

+ + {emailCfg === true ? 'Aktif (Resend)' : emailCfg === false ? 'Yapılandırılmadı' : 'Kontrol ediliyor…'} + +
+ +

+ {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.'} +

+ + + + + + {emailResult && ( +

+ {emailResult.ok ? '✓ ' : '⚠ '}{emailResult.msg} +

+ )} +
+ {/* Install PWA Application */}
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index d894424..dce14f6 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -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 => { diff --git a/src/data/constants.js b/src/data/constants.js new file mode 100644 index 0000000..c402453 --- /dev/null +++ b/src/data/constants.js @@ -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", +]; diff --git a/src/data/mockTenders.js b/src/data/mockTenders.js deleted file mode 100644 index 4fb559f..0000000 --- a/src/data/mockTenders.js +++ /dev/null @@ -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 } -]; diff --git a/src/services/analytics.js b/src/services/analytics.js index 268c7fa..8cdbe5f 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -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();