feat: demo özelliklerini gerçekleştir — canlı veri + localStorage
- analytics.js: firmStats/itemStats/provinceDensity (gerçek hesaplama) - Rakip Radar: canlı veriden kazanan firma analizi (mock 3 firma kaldırıldı) - Fiyat Analizi: gerçek kalem min/medyan/ort/maks + CSV export - TurkeyHeatMap: 81 il gerçek interaktif yoğunluk haritası (tıkla→filtrele) - Favori kurumlar: kart yıldızı + hızlı filtre (localStorage) - Ekip yönetimi: gerçek CRUD (RightSidebar) + Kanban ataması ekipten - Bildirimler: gerçek Notification API + izin + test + sessiz saatler - PDF: jsPDF ile gerçek birim fiyat teklif cetveli (kaşe/imza alanı)
This commit is contained in:
@@ -3,7 +3,7 @@ import { useApp } from '../context/AppContext';
|
||||
import { Kanban, User, Calendar, MapPin, Star, Plus, ArrowRight, Eye } from 'lucide-react';
|
||||
|
||||
export const KanbanBoard = () => {
|
||||
const { tenders, updateKanbanStatus, setActiveTenderDetail } = useApp();
|
||||
const { tenders, updateKanbanStatus, setActiveTenderDetail, teamMembers } = useApp();
|
||||
const [selectedPersonel, setSelectedPersonel] = useState('');
|
||||
const [draggedTenderId, setDraggedTenderId] = useState(null);
|
||||
|
||||
@@ -16,7 +16,11 @@ export const KanbanBoard = () => {
|
||||
{ id: 'kaybedildi', title: 'Kaybedildi', color: 'bg-rose-50 border-rose-200 text-rose-900' }
|
||||
];
|
||||
|
||||
const personnelList = ['Hikmet Tanrıverdi', 'Ahmet Yılmaz', 'Zeynep Kaya', 'Mehmet Demir'];
|
||||
// Gerçek ekip üyelerinden (Ekip Yönetimi paneli) + atanmış eski isimler
|
||||
const personnelList = [...new Set([
|
||||
...teamMembers.map((m) => m.name),
|
||||
...tenders.map((t) => t.atananPersonel).filter(Boolean),
|
||||
])];
|
||||
|
||||
const filteredTenders = tenders.filter(t => {
|
||||
if (!t.takipte && !t.kanbanStatus) return false;
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useApp } from '../context/AppContext';
|
||||
import { SECTORS_LIST, INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/mockTenders';
|
||||
import {
|
||||
Compass, Zap, Wrench, Calendar, Eye, EyeOff, FileText, CheckCircle2, XCircle, Archive,
|
||||
Bot, PlusCircle, Trash2, ChevronDown, ChevronUp, Filter, MapPin, Building2, Tag
|
||||
Bot, PlusCircle, Trash2, ChevronDown, ChevronUp, Filter, MapPin, Building2, Tag, Star
|
||||
} from 'lucide-react';
|
||||
|
||||
export const LeftSidebar = () => {
|
||||
const {
|
||||
favoriteKurumlar,
|
||||
quickFilter,
|
||||
setQuickFilter,
|
||||
setActiveTab,
|
||||
@@ -37,6 +38,7 @@ export const LeftSidebar = () => {
|
||||
|
||||
const quickNavItems = [
|
||||
{ id: 'all', label: 'Güncel Tüm İhaleler', icon: Compass },
|
||||
{ id: 'favori_kurumlar', label: 'Favori Kurumlarım', icon: Star, badge: favoriteKurumlar?.length ? String(favoriteKurumlar.length) : null },
|
||||
{ id: 'dogrudan_temin', label: 'Doğrudan Temin', icon: Zap, badge: 'Popüler' },
|
||||
{ id: 'ihaleler', label: 'İhaleler (Açık/Pazarlık)', icon: Wrench },
|
||||
{ id: 'bugun_eklenenler', label: 'Bugün Eklenenler', icon: Calendar },
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { MOCK_COMPANY_STATS, COMPETITOR_DATA } from '../data/mockTenders';
|
||||
import { Briefcase, FileText, Calendar, History, Trophy, Eye, Kanban, Radar, ArrowRight } from 'lucide-react';
|
||||
import { Briefcase, FileText, Calendar, History, Trophy, Eye, Kanban, Radar, ArrowRight, Users, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
export const RightSidebar = () => {
|
||||
const { setActiveTab, setQuickFilter, tenders } = useApp();
|
||||
const { setActiveTab, setQuickFilter, tenders, teamMembers, addTeamMember, removeTeamMember } = useApp();
|
||||
const [tmName, setTmName] = useState('');
|
||||
const [tmEmail, setTmEmail] = useState('');
|
||||
const [tmRole, setTmRole] = useState('Uzman');
|
||||
|
||||
const trackedCount = tenders.filter(t => t.takipte).length;
|
||||
|
||||
const handleAddMember = (e) => {
|
||||
e.preventDefault();
|
||||
if (!tmName.trim()) return;
|
||||
addTeamMember({ name: tmName.trim(), email: tmEmail.trim(), role: tmRole });
|
||||
setTmName(''); setTmEmail('');
|
||||
};
|
||||
|
||||
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' },
|
||||
@@ -106,6 +116,43 @@ export const RightSidebar = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ekip Yönetimi — gerçek (localStorage) */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-4 space-y-3">
|
||||
<h3 className="font-extrabold text-xs uppercase tracking-wider text-slate-800 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-indigo-600" /> Ekip Yönetimi ({teamMembers.length})
|
||||
</h3>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{teamMembers.map((m) => (
|
||||
<div key={m.id} className="flex items-center justify-between gap-2 bg-slate-50 rounded-lg px-2.5 py-1.5 border border-slate-100">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold text-slate-800 truncate">{m.name}</div>
|
||||
<div className="text-[10px] text-slate-500 truncate">{m.role}{m.email ? ` · ${m.email}` : ''}</div>
|
||||
</div>
|
||||
{teamMembers.length > 1 && (
|
||||
<button onClick={() => removeTeamMember(m.id)} className="text-slate-400 hover:text-red-500 shrink-0" title="Çıkar">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={handleAddMember} className="space-y-1.5 pt-1 border-t border-slate-100">
|
||||
<input value={tmName} onChange={(e) => setTmName(e.target.value)} placeholder="Ad Soyad"
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs" />
|
||||
<div className="flex gap-1.5">
|
||||
<input value={tmEmail} onChange={(e) => setTmEmail(e.target.value)} placeholder="E-posta (ops.)"
|
||||
className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs" />
|
||||
<select value={tmRole} onChange={(e) => setTmRole(e.target.value)}
|
||||
className="bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 text-xs">
|
||||
<option>Uzman</option><option>Yönetici</option><option>Saha</option><option>Muhasebe</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs py-2 rounded-lg flex items-center justify-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" /> Ekip Üyesi Ekle
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useApp } from '../context/AppContext';
|
||||
import { Star, MapPin, Clock, Tag, Package, Building2, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
|
||||
export const TenderCard = ({ tender }) => {
|
||||
const { toggleTrackTender, setActiveTenderDetail } = useApp();
|
||||
const { toggleTrackTender, setActiveTenderDetail, favoriteKurumlar, toggleFavoriteKurum } = useApp();
|
||||
const isFavKurum = favoriteKurumlar?.includes((tender.kurum || '').trim());
|
||||
|
||||
const [day, monthYear] = tender.tarih ? tender.tarih.split('.') : ['01', '01.2026'];
|
||||
const monthYearFormatted = monthYear ? `${monthYear}` : '08.2026';
|
||||
@@ -62,6 +63,13 @@ export const TenderCard = ({ tender }) => {
|
||||
<span className="flex items-center gap-1 text-slate-700 font-semibold truncate max-w-md">
|
||||
<Building2 className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="truncate">{tender.kurum}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleFavoriteKurum(tender.kurum); }}
|
||||
title={isFavKurum ? 'Favori kurumdan çıkar' : 'Kurumu favorilere ekle'}
|
||||
className="shrink-0 p-0.5"
|
||||
>
|
||||
<Star className={`w-3.5 h-3.5 ${isFavKurum ? 'text-amber-500 fill-amber-500' : 'text-slate-300 hover:text-amber-400'}`} />
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-slate-600">
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0" />
|
||||
|
||||
@@ -350,7 +350,11 @@ END:VCALENDAR`;
|
||||
<span>Teklif cetvelinizi doldurun ve Dijital Kaşenizi sürükleyerek belgenin altına yerleştirin.</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => alert('Doldurulmuş ve Kaşelenmiş PDF başarıyla indirildi.')}
|
||||
onClick={() => {
|
||||
import('../services/pdf').then(({ generateBidSheetPDF }) => {
|
||||
generateBidSheetPDF(tender, { firma: userProfile.company, stamp: true });
|
||||
});
|
||||
}}
|
||||
className="bg-amber-600 hover:bg-amber-500 text-white font-bold px-4 py-2 rounded-lg flex items-center gap-1.5 shadow-2xs"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Kaşeli PDF İndir
|
||||
|
||||
@@ -13,7 +13,8 @@ export const TenderList = () => {
|
||||
selectedSektor,
|
||||
selectedKurumTipi,
|
||||
quickFilter,
|
||||
onlyValidOnly
|
||||
onlyValidOnly,
|
||||
favoriteKurumlar
|
||||
} = useApp();
|
||||
|
||||
const [sortBy, setSortBy] = useState('newest'); // 'newest', 'urgent', 'distant'
|
||||
@@ -34,6 +35,7 @@ export const TenderList = () => {
|
||||
}
|
||||
|
||||
// Quick Filter
|
||||
if (quickFilter === 'favori_kurumlar' && !(favoriteKurumlar || []).includes((tender.kurum || '').trim())) return false;
|
||||
if (quickFilter === 'dogrudan_temin' && tender.alimTuru !== 'Doğrudan Temin') return false;
|
||||
if (quickFilter === 'bugun_eklenenler' && !(tender.eklenmeZamani.includes('önce') || tender.eklenmeZamani.includes('az'))) return false;
|
||||
if (quickFilter === 'incelenenler' && !tender.incelendi) return false;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { BarChart3, TrendingUp, Trophy, Target, MapPin, Building2, Award, ArrowUpRight, DollarSign } from 'lucide-react';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, PieChart, Pie, Cell } from 'recharts';
|
||||
import { TurkeyHeatMap } from './TurkeyHeatMap';
|
||||
|
||||
export const AnalyticsDashboard = () => {
|
||||
const { tenders, userProfile } = useApp();
|
||||
@@ -148,32 +149,8 @@ export const AnalyticsDashboard = () => {
|
||||
|
||||
</div>
|
||||
|
||||
{/* City Price Heatmap Section */}
|
||||
<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">
|
||||
<div>
|
||||
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-red-600" /> Şehir & Kurum Bazlı İhale Yoğunluk Isı Haritası
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">Bölgelere göre ihale sayıları ve ortalama idare yaklaşık maliyet seviyeleri.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{cityHeatmap.map(item => (
|
||||
<div key={item.city} className="bg-slate-50 p-4 rounded-xl border border-slate-200 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-extrabold text-sm text-slate-900">{item.city}</span>
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full ${item.color}`}>
|
||||
{item.yogunluk}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 font-medium">İhale Sayısı: <strong className="text-slate-900">{item.ihaleSayisi}</strong></p>
|
||||
<p className="text-xs text-slate-600 font-medium">Ort. Bedel: <strong className="text-indigo-900">{item.ortalamaMaliyet}</strong></p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* İl bazlı gerçek yoğunluk haritası */}
|
||||
<TurkeyHeatMap />
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { Radar, Bell, Plus, ShieldAlert, Award, FileText, CheckCircle2, Trash2, ArrowRight } from 'lucide-react';
|
||||
import { Radar, Bell, Plus, ShieldAlert, Award, FileText, CheckCircle2, Trash2, ArrowRight, TrendingUp } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { firmStats, formatTL } from '../../services/analytics';
|
||||
|
||||
export const CompetitorRadar = () => {
|
||||
const { competitorWatchlist, setCompetitorWatchlist, setActiveTab } = useApp();
|
||||
const { competitorWatchlist, setCompetitorWatchlist, setActiveTab, tenders } = useApp();
|
||||
const [newCompetitorName, setNewCompetitorName] = useState('');
|
||||
const [newSector, setNewSector] = useState('Medikal & Tıbbi Malzeme');
|
||||
|
||||
// Gerçek kazanan-firma analizi (canlı veriden)
|
||||
const { firms, awardCount, totalMarket } = useMemo(() => firmStats(tenders), [tenders]);
|
||||
const watched = new Set(competitorWatchlist.map((c) => c.name.trim().toLocaleLowerCase('tr-TR')));
|
||||
const addFromReal = (f) => {
|
||||
if (watched.has(f.name.trim().toLocaleLowerCase('tr-TR'))) return;
|
||||
setCompetitorWatchlist((prev) => [
|
||||
{ id: 'cw-' + Date.now(), name: f.name, sector: f.sectors[0] || 'Genel', winCount: f.wins, totalRevenue: f.total, marketShare: f.share, status: 'Aktif İzleniyor', alertOnBid: true, alertOnWin: true },
|
||||
...prev,
|
||||
]);
|
||||
confetti({ particleCount: 40 });
|
||||
};
|
||||
|
||||
const handleAddCompetitor = (e) => {
|
||||
e.preventDefault();
|
||||
if (!newCompetitorName.trim()) return;
|
||||
@@ -67,6 +80,44 @@ export const CompetitorRadar = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* GERÇEK: Piyasadaki Kazanan Firmalar (canlı veriden hesaplanır) */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-600" /> Kazanan Firma Analizi — Canlı Veri
|
||||
</h3>
|
||||
<span className="text-xs text-slate-500">{awardCount} sonuçlanmış ihale · toplam {formatTL(totalMarket)}</span>
|
||||
</div>
|
||||
{firms.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-slate-500">
|
||||
Henüz kazanan firma verisi birikmedi. Doğrudan temin / sonuç ilanları geldikçe bu tablo gerçek kazanan firmalar ve sözleşme tutarlarıyla otomatik dolar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{firms.slice(0, 12).map((f, i) => (
|
||||
<div key={f.name} className="px-6 py-3 flex items-center justify-between gap-3 hover:bg-slate-50/70">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-black text-slate-400 w-5">{i + 1}</span>
|
||||
<span className="font-bold text-sm text-slate-900 truncate">{f.name}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 ml-7">
|
||||
🏆 {f.wins} ihale · 💰 {formatTL(f.total)} {f.share ? `· %${f.share} pay` : ''} {f.sectors[0] ? `· ${f.sectors[0]}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addFromReal(f)}
|
||||
disabled={watched.has(f.name.trim().toLocaleLowerCase('tr-TR'))}
|
||||
className="text-[11px] font-bold px-3 py-1.5 rounded-lg border border-indigo-200 text-indigo-700 hover:bg-indigo-50 disabled:opacity-40 shrink-0"
|
||||
>
|
||||
{watched.has(f.name.trim().toLocaleLowerCase('tr-TR')) ? 'İzleniyor' : 'Radara Ekle'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add New Competitor Form */}
|
||||
<form onSubmit={handleAddCompetitor} className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs flex flex-col md:flex-row gap-3 items-end">
|
||||
<div className="flex-1 space-y-1.5 w-full">
|
||||
|
||||
@@ -1,130 +1,137 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
|
||||
import { TrendingUp, Search, Download, Sparkles, AlertCircle } from 'lucide-react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell } from 'recharts';
|
||||
import { TrendingUp, Download, Sparkles, Inbox } from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { itemStats, formatTL } from '../../services/analytics';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export const PriceAnalysis = () => {
|
||||
const [selectedProduct, setSelectedProduct] = useState('35*50 TUVAL');
|
||||
const { tenders } = useApp();
|
||||
const { items, itemCount, uniqueItems } = useMemo(() => itemStats(tenders), [tenders]);
|
||||
const [selectedKey, setSelectedKey] = useState(null);
|
||||
|
||||
const priceHistoryData = [
|
||||
{ month: 'Ocak 2026', minFiyat: 110, ortFiyat: 125, maksFiyat: 140 },
|
||||
{ month: 'Şubat 2026', minFiyat: 115, ortFiyat: 130, maksFiyat: 145 },
|
||||
{ month: 'Mart 2026', minFiyat: 120, ortFiyat: 135, maksFiyat: 155 },
|
||||
{ month: 'Nisan 2026', minFiyat: 125, ortFiyat: 140, maksFiyat: 160 },
|
||||
{ month: 'Mayıs 2026', minFiyat: 130, ortFiyat: 145, maksFiyat: 170 },
|
||||
{ month: 'Haziran 2026', minFiyat: 135, ortFiyat: 150, maksFiyat: 180 },
|
||||
{ month: 'Temmuz 2026', minFiyat: 140, ortFiyat: 155, maksFiyat: 185 },
|
||||
{ month: 'Ağustos 2026', minFiyat: 145, ortFiyat: 160, maksFiyat: 190 }
|
||||
];
|
||||
const selected = useMemo(() => {
|
||||
if (!items.length) return null;
|
||||
const found = items.find((i) => i.ad === selectedKey);
|
||||
return found || items[0];
|
||||
}, [items, selectedKey]);
|
||||
|
||||
const productsList = [
|
||||
'35*50 TUVAL',
|
||||
'50*70 TUVAL',
|
||||
'1 Gözlü Yemek Kabı',
|
||||
'Kablosuz Panik Butonu',
|
||||
'G4 Panel Filtre',
|
||||
'Çamaşır Suyu 20 L',
|
||||
'Revizyon Kalça Protez Femoral Stem'
|
||||
];
|
||||
const chartData = selected
|
||||
? [
|
||||
{ label: 'En Düşük', value: Math.round(selected.min), renk: '#2563eb' },
|
||||
{ label: 'Medyan', value: Math.round(selected.median), renk: '#059669' },
|
||||
{ label: 'Ortalama', value: Math.round(selected.avg), renk: '#f59e0b' },
|
||||
{ label: 'En Yüksek', value: Math.round(selected.max), renk: '#dc2626' },
|
||||
]
|
||||
: [];
|
||||
|
||||
const handleDownloadReport = () => {
|
||||
confetti({ particleCount: 70 });
|
||||
alert(`${selectedProduct} Fiyat Trend Analizi Raporu (PDF/CSV) İndirildi.`);
|
||||
if (!items.length) return;
|
||||
const headers = ['Kalem', 'Birim', 'Adet(kayıt)', 'Min', 'Medyan', 'Ortalama', 'Maks'];
|
||||
const rows = items.map((i) => [
|
||||
`"${(i.ad || '').replace(/"/g, '""')}"`, i.birim || '', i.count,
|
||||
Math.round(i.min), Math.round(i.median), Math.round(i.avg), Math.round(i.max),
|
||||
]);
|
||||
const csv = 'data:text/csv;charset=utf-8,' + [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
|
||||
const link = document.createElement('a');
|
||||
link.href = encodeURI(csv);
|
||||
link.download = `ihaleist_fiyat_analizi_${Date.now()}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
confetti({ particleCount: 60 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 my-4 animate-fade-in">
|
||||
|
||||
{/* Top Banner */}
|
||||
<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-emerald-600 text-white flex items-center justify-center shadow-md">
|
||||
<TrendingUp className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-slate-900">Birim Fiyat Analizi & Trend Grafikleri</h1>
|
||||
<p className="text-xs text-slate-500">Ürün bazında geçmiş ihalelerdeki birim fiyat trendini ve ortalama teklif marjlarını inceleyin.</p>
|
||||
<h1 className="text-xl font-black text-slate-900">Kalem Bazlı Fiyat Analizi</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
Toplanan ihalelerdeki gerçek birim fiyatlardan hesaplanır · <strong>{uniqueItems}</strong> farklı kalem, <strong>{itemCount}</strong> fiyat kaydı.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownloadReport}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs px-4 py-2 rounded-lg flex items-center gap-2 shadow-xs shrink-0"
|
||||
disabled={!items.length}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2 rounded-lg flex items-center gap-2 shadow-xs shrink-0"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Rapor İndir (PDF/CSV)
|
||||
<Download className="w-4 h-4" /> CSV İndir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Left Column: Product Selector */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs space-y-4 h-fit">
|
||||
<h3 className="font-bold text-xs uppercase tracking-wider text-slate-800 border-b border-slate-100 pb-2">
|
||||
Ürün / Malzeme Seçimi
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{productsList.map(prod => (
|
||||
{items.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-10 text-center text-sm text-slate-500 flex flex-col items-center gap-2">
|
||||
<Inbox className="w-8 h-8 text-slate-300" />
|
||||
Henüz kalem fiyat verisi birikmedi. Kalem/birim fiyat içeren ihaleler (doğrudan temin sözleşme kalemleri) geldikçe bu analiz gerçek fiyatlarla otomatik dolar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs space-y-2 h-fit max-h-[520px] overflow-y-auto">
|
||||
<h3 className="font-bold text-xs uppercase tracking-wider text-slate-800 border-b border-slate-100 pb-2 sticky top-0 bg-white">
|
||||
Kalemler ({items.length})
|
||||
</h3>
|
||||
{items.slice(0, 60).map((it) => (
|
||||
<button
|
||||
key={prod}
|
||||
onClick={() => setSelectedProduct(prod)}
|
||||
className={`w-full text-left p-3 rounded-lg text-xs font-semibold transition-all ${
|
||||
selectedProduct === prod
|
||||
? 'bg-emerald-600 text-white shadow-xs'
|
||||
: 'bg-slate-50 hover:bg-slate-100 text-slate-700 border border-slate-200'
|
||||
key={it.ad}
|
||||
onClick={() => setSelectedKey(it.ad)}
|
||||
className={`w-full text-left p-2.5 rounded-lg text-xs font-semibold transition-all ${
|
||||
selected?.ad === it.ad ? 'bg-emerald-600 text-white' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border border-slate-200'
|
||||
}`}
|
||||
>
|
||||
📦 {prod}
|
||||
<div className="truncate">📦 {it.ad}</div>
|
||||
<div className={`text-[10px] ${selected?.ad === it.ad ? 'text-emerald-100' : 'text-slate-400'}`}>{it.count} kayıt · medyan {formatTL(it.median)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right 2 Columns: Recharts Chart & AI Assistant */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
|
||||
{/* Recharts Line Chart */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-bold text-sm text-slate-900">
|
||||
{selectedProduct} — 8 Aylık Birim Fiyat Değişim Grafiği (TL)
|
||||
</h3>
|
||||
<span className="text-[10px] bg-emerald-100 text-emerald-800 font-extrabold px-2 py-0.5 rounded-full">
|
||||
Canlı EKAP Verisi
|
||||
</span>
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h3 className="font-bold text-sm text-slate-900 truncate">{selected?.ad} — Birim Fiyat Dağılımı (₺)</h3>
|
||||
<span className="text-[10px] bg-emerald-100 text-emerald-800 font-extrabold px-2 py-0.5 rounded-full">{selected?.count} gerçek kayıt</span>
|
||||
</div>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v) => formatTL(v)} />
|
||||
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
|
||||
{chartData.map((d, i) => <Cell key={i} fill={d.renk} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-center">
|
||||
{chartData.map((d) => (
|
||||
<div key={d.label} className="bg-slate-50 rounded-lg p-2 border border-slate-100">
|
||||
<div className="text-[10px] text-slate-500">{d.label}</div>
|
||||
<div className="text-sm font-black" style={{ color: d.renk }}>{formatTL(d.value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-72 w-full pt-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={priceHistoryData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="ortFiyat" stroke="#059669" strokeWidth={3} name="Ortalama Fiyat" />
|
||||
<Line type="monotone" dataKey="minFiyat" stroke="#2563eb" strokeWidth={2} strokeDasharray="5 5" name="En Düşük Fiyat" />
|
||||
<Line type="monotone" dataKey="maksFiyat" stroke="#dc2626" strokeWidth={2} strokeDasharray="5 5" name="En Yüksek Fiyat" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="bg-linear-to-r from-slate-900 to-emerald-950 text-white p-5 rounded-xl space-y-2 shadow-md border border-emerald-900/40">
|
||||
<div className="flex items-center gap-2 font-bold text-sm text-emerald-400">
|
||||
<Sparkles className="w-5 h-5 text-amber-400" /> Teklif Önerisi (gerçek veriye dayalı)
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
<strong>{selected?.ad}</strong> için toplanan {selected?.count} gerçek kayda göre rekabetçi birim fiyat aralığınız{' '}
|
||||
<strong className="text-amber-300">{formatTL(selected?.min)} – {formatTL(selected?.median)}</strong> arasıdır.
|
||||
Medyanın altında teklif vermek kazanma olasılığını artırır; ortalamanın ({formatTL(selected?.avg)}) üstü riskli olabilir.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Pricing Advice Box */}
|
||||
<div className="bg-linear-to-r from-slate-900 to-emerald-950 text-white p-5 rounded-xl space-y-3 shadow-md border border-emerald-900/40">
|
||||
<div className="flex items-center gap-2 font-bold text-sm text-emerald-400">
|
||||
<Sparkles className="w-5 h-5 text-amber-400" />
|
||||
<span>Yapay Zeka Teklif Öneri Motoru</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
<strong>{selectedProduct}</strong> için son 8 aydaki ihale kazanma verilerine göre optimal teklif birim fiyatınız: <strong className="text-amber-300 text-sm">₺155 - ₺165</strong> aralığıdır. Bu aralıkta kazanma olasılığınız <strong>%88</strong> olarak hesaplanmıştır.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, ShieldCheck, Zap, Sparkles } from 'lucide-react';
|
||||
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, ShieldCheck, Zap, Sparkles, Moon } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export const PwaSettings = () => {
|
||||
const { userProfile } = useApp();
|
||||
const { userProfile, reminderSettings, setReminderSettings, requestPushPermission, notify, isQuietNow } = useApp();
|
||||
const [offlineMode, setOfflineMode] = useState(true);
|
||||
const [pushSubscribed, setPushSubscribed] = useState(true);
|
||||
const [installed, setInstalled] = useState(false);
|
||||
const [permission, setPermission] = useState(typeof Notification !== 'undefined' ? Notification.permission : 'unsupported');
|
||||
|
||||
const handleEnablePush = async () => {
|
||||
const p = await requestPushPermission();
|
||||
setPermission(p);
|
||||
if (p === 'granted') {
|
||||
setReminderSettings((s) => ({ ...s, pushEnabled: true }));
|
||||
notify('Bildirimler açıldı', 'Yeni ihale ve son tarih uyarıları buradan gelecek.');
|
||||
confetti({ particleCount: 50 });
|
||||
}
|
||||
};
|
||||
const setQuiet = (patch) => setReminderSettings((s) => ({ ...s, ...patch }));
|
||||
|
||||
const handleInstallPwa = () => {
|
||||
setInstalled(true);
|
||||
@@ -67,30 +78,60 @@ export const PwaSettings = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Push Notifications */}
|
||||
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
|
||||
<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">
|
||||
<Bell className="w-4 h-4 text-amber-500" /> Anlık Mobil Push Bildirimleri
|
||||
<Bell className="w-4 h-4 text-amber-500" /> Anlık Bildirimler
|
||||
</h3>
|
||||
<span className="bg-amber-100 text-amber-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">
|
||||
İzin Verildi
|
||||
<span className={`font-extrabold text-[10px] px-2 py-0.5 rounded-full ${
|
||||
permission === 'granted' ? 'bg-emerald-100 text-emerald-800' : permission === 'denied' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{permission === 'granted' ? 'İzin Verildi' : permission === 'denied' ? 'Reddedildi' : permission === 'unsupported' ? 'Desteklenmiyor' : 'İzin Bekliyor'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-600 leading-relaxed">
|
||||
İhale son başvuru saatine 3 saat kala mobil cihazınıza sesli ve titreşimli anlık uyarı bildirimi gönderilir.
|
||||
Tarayıcı bildirimlerine izin verin; yeni ihale ve son tarih uyarıları gerçek bildirim olarak gelir.
|
||||
</p>
|
||||
|
||||
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-200 cursor-pointer text-xs font-bold text-slate-800">
|
||||
<span>Mobil Push Uyarısı</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pushSubscribed}
|
||||
onChange={() => setPushSubscribed(!pushSubscribed)}
|
||||
className="w-4 h-4 accent-cyan-600 rounded"
|
||||
/>
|
||||
</label>
|
||||
{permission !== 'granted' ? (
|
||||
<button onClick={handleEnablePush} disabled={permission === 'unsupported'}
|
||||
className="w-full bg-amber-500 hover:bg-amber-400 disabled:opacity-40 text-white font-bold text-xs py-2.5 rounded-xl">
|
||||
Bildirimlere İzin Ver
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => notify('Test bildirimi', isQuietNow() ? 'Sessiz saatte — sadece listeye eklendi.' : 'Bildirimler çalışıyor ✓')}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2.5 rounded-xl">
|
||||
Test Bildirimi Gönder
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Sessiz saatler — gerçek */}
|
||||
<div className="pt-2 border-t border-slate-100 space-y-2">
|
||||
<label className="flex items-center justify-between text-xs font-bold text-slate-800 cursor-pointer">
|
||||
<span className="flex items-center gap-1.5"><Moon className="w-3.5 h-3.5 text-indigo-500" /> Sessiz Saatler</span>
|
||||
<input type="checkbox" checked={reminderSettings.quietEnabled}
|
||||
onChange={(e) => setQuiet({ quietEnabled: e.target.checked })}
|
||||
className="w-4 h-4 accent-indigo-600 rounded" />
|
||||
</label>
|
||||
{reminderSettings.quietEnabled && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<input type="time" value={reminderSettings.quietStart}
|
||||
onChange={(e) => setQuiet({ quietStart: e.target.value })}
|
||||
className="bg-slate-50 border border-slate-300 rounded-lg px-2 py-1.5 flex-1" />
|
||||
<span className="text-slate-400">→</span>
|
||||
<input type="time" value={reminderSettings.quietEnd}
|
||||
onChange={(e) => setQuiet({ quietEnd: e.target.value })}
|
||||
className="bg-slate-50 border border-slate-300 rounded-lg px-2 py-1.5 flex-1" />
|
||||
</div>
|
||||
)}
|
||||
{reminderSettings.quietEnabled && (
|
||||
<p className="text-[10px] text-slate-400">
|
||||
{isQuietNow() ? '🔕 Şu an sessiz saatte — tarayıcı bildirimi gösterilmez, uyarılar listeye eklenir.' : '🔔 Şu an aktif — bildirimler gösterilir.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Install PWA Application */}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import { provinceDensity, normProvince } from '../../services/analytics';
|
||||
|
||||
const ILLER = [
|
||||
'Adana','Adıyaman','Afyonkarahisar','Ağrı','Amasya','Ankara','Antalya','Artvin','Aydın','Balıkesir',
|
||||
'Bilecik','Bingöl','Bitlis','Bolu','Burdur','Bursa','Çanakkale','Çankırı','Çorum','Denizli',
|
||||
'Diyarbakır','Edirne','Elazığ','Erzincan','Erzurum','Eskişehir','Gaziantep','Giresun','Gümüşhane','Hakkari',
|
||||
'Hatay','Isparta','Mersin','İstanbul','İzmir','Kars','Kastamonu','Kayseri','Kırklareli','Kırşehir',
|
||||
'Kocaeli','Konya','Kütahya','Malatya','Manisa','Kahramanmaraş','Mardin','Muğla','Muş','Nevşehir',
|
||||
'Niğde','Ordu','Rize','Sakarya','Samsun','Siirt','Sinop','Sivas','Tekirdağ','Tokat',
|
||||
'Trabzon','Tunceli','Şanlıurfa','Uşak','Van','Yozgat','Zonguldak','Aksaray','Bayburt','Karaman',
|
||||
'Kırıkkale','Batman','Şırnak','Bartın','Ardahan','Iğdır','Yalova','Karabük','Kilis','Osmaniye','Düzce',
|
||||
];
|
||||
|
||||
function heat(count, max) {
|
||||
if (!count) return { bg: '#f1f5f9', fg: '#94a3b8' };
|
||||
const r = count / (max || 1);
|
||||
if (r > 0.66) return { bg: '#b91c1c', fg: '#fff' };
|
||||
if (r > 0.4) return { bg: '#ef4444', fg: '#fff' };
|
||||
if (r > 0.2) return { bg: '#f59e0b', fg: '#111' };
|
||||
if (r > 0.08) return { bg: '#fcd34d', fg: '#111' };
|
||||
return { bg: '#dbeafe', fg: '#1e40af' };
|
||||
}
|
||||
|
||||
export const TurkeyHeatMap = () => {
|
||||
const { tenders, setActiveTab, setSelectedCity } = useApp();
|
||||
const density = useMemo(() => provinceDensity(tenders), [tenders]);
|
||||
const max = Math.max(1, ...density.values());
|
||||
const withData = ILLER.filter((il) => density.get(normProvince(il)));
|
||||
const topPct = Math.round((withData.length / 81) * 100);
|
||||
|
||||
const openCity = (il) => {
|
||||
setSelectedCity(il);
|
||||
setActiveTab('list');
|
||||
};
|
||||
|
||||
return (
|
||||
<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 flex-wrap gap-2">
|
||||
<div>
|
||||
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-red-600" /> İl Bazlı İhale Yoğunluk Haritası — Canlı
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">Yüklü ihalelerin illere dağılımı. Bir ile tıklayınca liste o ile filtrelenir. {withData.length}/81 ilde aktif ihale (%{topPct}).</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-slate-500">
|
||||
<span>Az</span>
|
||||
<span className="w-4 h-3 rounded" style={{ background: '#dbeafe' }} />
|
||||
<span className="w-4 h-3 rounded" style={{ background: '#fcd34d' }} />
|
||||
<span className="w-4 h-3 rounded" style={{ background: '#f59e0b' }} />
|
||||
<span className="w-4 h-3 rounded" style={{ background: '#ef4444' }} />
|
||||
<span className="w-4 h-3 rounded" style={{ background: '#b91c1c' }} />
|
||||
<span>Çok</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 md:grid-cols-9 gap-1.5">
|
||||
{ILLER.map((il) => {
|
||||
const c = density.get(normProvince(il)) || 0;
|
||||
const { bg, fg } = heat(c, max);
|
||||
return (
|
||||
<button
|
||||
key={il}
|
||||
onClick={() => c && openCity(il)}
|
||||
disabled={!c}
|
||||
title={`${il}: ${c} ihale`}
|
||||
className={`rounded-lg p-2 text-center transition-transform ${c ? 'hover:scale-105 cursor-pointer' : 'cursor-default opacity-70'}`}
|
||||
style={{ background: bg, color: fg }}
|
||||
>
|
||||
<div className="text-[10px] font-bold leading-tight truncate">{il}</div>
|
||||
<div className="text-[13px] font-black leading-none mt-0.5">{c || '·'}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -113,6 +113,35 @@ export const AppProvider = ({ children }) => {
|
||||
{ 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 }
|
||||
]);
|
||||
|
||||
// --- Favori kurumlar (localStorage, gerçek) ---
|
||||
const [favoriteKurumlar, setFavoriteKurumlar] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('ihaleist_favori_kurumlar') || '[]'); } catch { return []; }
|
||||
});
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ihaleist_favori_kurumlar', JSON.stringify(favoriteKurumlar));
|
||||
}, [favoriteKurumlar]);
|
||||
const toggleFavoriteKurum = (kurum) => {
|
||||
const ad = (kurum || '').trim();
|
||||
if (!ad) return;
|
||||
setFavoriteKurumlar((prev) => (prev.includes(ad) ? prev.filter((k) => k !== ad) : [...prev, ad]));
|
||||
};
|
||||
|
||||
// --- Ekip yönetimi (localStorage, gerçek CRUD) ---
|
||||
const [teamMembers, setTeamMembers] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('ihaleist_ekip');
|
||||
if (saved) return JSON.parse(saved);
|
||||
} catch { /* yoksay */ }
|
||||
return [
|
||||
{ id: 'tm-1', name: 'Hikmet Tanrıverdi', email: 'hikmet044@gmail.com', role: 'Yönetici' },
|
||||
];
|
||||
});
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ihaleist_ekip', JSON.stringify(teamMembers));
|
||||
}, [teamMembers]);
|
||||
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' },
|
||||
@@ -121,16 +150,32 @@ export const AppProvider = ({ children }) => {
|
||||
{ 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' }
|
||||
]);
|
||||
|
||||
const [reminderSettings, setReminderSettings] = useState({
|
||||
emailEnabled: true,
|
||||
smsEnabled: true,
|
||||
pushEnabled: true,
|
||||
notify3DaysBefore: true,
|
||||
notify1DayBefore: true,
|
||||
notify3HoursBefore: true,
|
||||
googleCalendarSync: true,
|
||||
outlookSync: false
|
||||
const [reminderSettings, setReminderSettings] = useState(() => {
|
||||
const def = {
|
||||
emailEnabled: true, smsEnabled: true, pushEnabled: true,
|
||||
notify3DaysBefore: true, notify1DayBefore: true, notify3HoursBefore: true,
|
||||
googleCalendarSync: true, outlookSync: false,
|
||||
quietEnabled: false, quietStart: '22:00', quietEnd: '08:00',
|
||||
};
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('ihaleist_reminder') || 'null');
|
||||
return saved ? { ...def, ...saved } : def;
|
||||
} catch { return def; }
|
||||
});
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ihaleist_reminder', JSON.stringify(reminderSettings));
|
||||
}, [reminderSettings]);
|
||||
|
||||
// Şu an sessiz saat aralığında mıyız (gece yarısını geçen aralıkları da destekler)
|
||||
const isQuietNow = () => {
|
||||
if (!reminderSettings.quietEnabled) return false;
|
||||
const now = new Date();
|
||||
const cur = now.getHours() * 60 + now.getMinutes();
|
||||
const [sh, sm] = reminderSettings.quietStart.split(':').map(Number);
|
||||
const [eh, em] = reminderSettings.quietEnd.split(':').map(Number);
|
||||
const start = sh * 60 + sm, end = eh * 60 + em;
|
||||
return start <= end ? cur >= start && cur < end : cur >= start || cur < end;
|
||||
};
|
||||
|
||||
const [webhooksConfig, setWebhooksConfig] = useState({
|
||||
slackWebhookUrl: 'https://hooks.slack.com/services/T00/B00/XXXXXX',
|
||||
@@ -279,6 +324,24 @@ export const AppProvider = ({ children }) => {
|
||||
{ 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 }
|
||||
]);
|
||||
|
||||
// Gerçek bildirim: listeye ekler; sessiz saat dışında ve push açıksa tarayıcı
|
||||
// bildirimi (Notification API) da gösterir.
|
||||
const notify = (title, text) => {
|
||||
setNotifications((prev) => [{ id: 'n-' + Date.now(), title, text, time: 'az önce', read: false }, ...prev].slice(0, 30));
|
||||
if (isQuietNow() || !reminderSettings.pushEnabled) return;
|
||||
try {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(title, { body: text, icon: '/favicon.ico' });
|
||||
}
|
||||
} catch { /* yoksay */ }
|
||||
};
|
||||
const requestPushPermission = async () => {
|
||||
if (!('Notification' in window)) return 'unsupported';
|
||||
if (Notification.permission === 'granted') return 'granted';
|
||||
try { return await Notification.requestPermission(); } catch { return 'denied'; }
|
||||
};
|
||||
const markNotificationsRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
|
||||
// Persist state to localstorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ihaleist_tenders', JSON.stringify(tenders));
|
||||
@@ -410,6 +473,15 @@ export const AppProvider = ({ children }) => {
|
||||
activeAiModel,
|
||||
userProfile,
|
||||
notifications,
|
||||
notify,
|
||||
requestPushPermission,
|
||||
markNotificationsRead,
|
||||
isQuietNow,
|
||||
favoriteKurumlar,
|
||||
toggleFavoriteKurum,
|
||||
teamMembers,
|
||||
addTeamMember,
|
||||
removeTeamMember,
|
||||
toggleTrackTender,
|
||||
updateKanbanStatus,
|
||||
clearAllFilters
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Gerçek ihale verisinden (context'teki tenders dizisi) hesaplanan analizler.
|
||||
* Sabit mock yok — ne yüklüyse ondan üretir. Veri arttıkça (DT worker, sonuç
|
||||
* ilanları) analizler otomatik zenginleşir.
|
||||
*/
|
||||
|
||||
// "22.800,00 TRY" / "1.234.567,89 TL" gibi metinleri sayıya çevir
|
||||
export function parseTL(v) {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return isFinite(v) ? v : null;
|
||||
const s = String(v).replace(/[^\d.,]/g, '');
|
||||
if (!s) return null;
|
||||
const norm = s.replace(/\./g, '').replace(',', '.');
|
||||
const n = parseFloat(norm);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function formatTL(n) {
|
||||
if (n == null || !isFinite(n)) return '—';
|
||||
return n.toLocaleString('tr-TR', { maximumFractionDigits: 0 }) + ' ₺';
|
||||
}
|
||||
|
||||
// İl plaka koduna göre density için normalize edilmiş il adı
|
||||
export function normProvince(name) {
|
||||
return (name || '').toLocaleUpperCase('tr-TR').trim();
|
||||
}
|
||||
|
||||
// --- Kazanan firma / rakip analizi ---
|
||||
export function firmStats(tenders) {
|
||||
const map = new Map();
|
||||
let awardCount = 0;
|
||||
for (const t of tenders) {
|
||||
if (!t.kazananFirma) continue;
|
||||
awardCount++;
|
||||
const name = t.kazananFirma.trim();
|
||||
const val = parseTL(t.sozlesmeBedeli);
|
||||
const cur = map.get(name) || { name, wins: 0, total: 0, sectors: new Set(), cities: new Set() };
|
||||
cur.wins++;
|
||||
if (val != null) cur.total += val;
|
||||
if (t.sektor) cur.sectors.add(t.sektor);
|
||||
if (t.sehir) cur.cities.add(t.sehir);
|
||||
map.set(name, cur);
|
||||
}
|
||||
const firms = [...map.values()]
|
||||
.map((f) => ({ ...f, sectors: [...f.sectors], cities: [...f.cities] }))
|
||||
.sort((a, b) => b.wins - a.wins || b.total - a.total);
|
||||
const totalMarket = firms.reduce((s, f) => s + f.total, 0);
|
||||
return {
|
||||
firms: firms.map((f) => ({ ...f, share: totalMarket ? Math.round((f.total / totalMarket) * 100) : 0 })),
|
||||
awardCount,
|
||||
totalMarket,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Kalem bazlı fiyat analizi ---
|
||||
export function itemStats(tenders) {
|
||||
const map = new Map();
|
||||
let itemCount = 0;
|
||||
for (const t of tenders) {
|
||||
for (const it of t.items || []) {
|
||||
const bf = parseTL(it.birimFiyat);
|
||||
if (bf == null) continue;
|
||||
itemCount++;
|
||||
const key = (it.ad || 'Diğer').trim().toLocaleUpperCase('tr-TR');
|
||||
const cur = map.get(key) || { ad: it.ad, birim: it.birim, count: 0, prices: [] };
|
||||
cur.count++;
|
||||
cur.prices.push(bf);
|
||||
map.set(key, cur);
|
||||
}
|
||||
}
|
||||
const items = [...map.values()]
|
||||
.map((i) => {
|
||||
const sorted = [...i.prices].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return {
|
||||
ad: i.ad,
|
||||
birim: i.birim,
|
||||
count: i.count,
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
median: sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2,
|
||||
avg: i.prices.reduce((s, p) => s + p, 0) / i.prices.length,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count);
|
||||
return { items, itemCount, uniqueItems: items.length };
|
||||
}
|
||||
|
||||
// --- İl bazlı yoğunluk (harita için) ---
|
||||
export function provinceDensity(tenders) {
|
||||
const map = new Map();
|
||||
for (const t of tenders) {
|
||||
const p = normProvince(t.sehir);
|
||||
if (!p) continue;
|
||||
map.set(p, (map.get(p) || 0) + 1);
|
||||
}
|
||||
return map; // İL_ADI(upper) -> sayı
|
||||
}
|
||||
|
||||
// --- Sektör dağılımı ---
|
||||
export function sectorStats(tenders) {
|
||||
const map = new Map();
|
||||
for (const t of tenders) {
|
||||
const s = t.sektor || 'Genel';
|
||||
map.set(s, (map.get(s) || 0) + 1);
|
||||
}
|
||||
return [...map.entries()].map(([sektor, count]) => ({ sektor, count })).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
// --- Alım türü dağılımı ---
|
||||
export function alimTuruStats(tenders) {
|
||||
const map = new Map();
|
||||
for (const t of tenders) {
|
||||
const a = t.alimTuru || 'Diğer';
|
||||
map.set(a, (map.get(a) || 0) + 1);
|
||||
}
|
||||
return [...map.entries()].map(([tur, count]) => ({ tur, count })).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Gerçek PDF üretimi (jsPDF) — Birim Fiyat Teklif Cetveli.
|
||||
* İhalenin kalemlerini tabloya döker, girilen birim fiyatlarla toplamı hesaplar,
|
||||
* firma bilgisi + kaşe/imza alanı ekler ve indirir.
|
||||
*/
|
||||
import { jsPDF } from 'jspdf';
|
||||
|
||||
const money = (n) =>
|
||||
(Number(n) || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
export function generateBidSheetPDF(tender, { firma = 'Firma Adı', prices = {}, stamp = true } = {}) {
|
||||
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
|
||||
const W = 210;
|
||||
const M = 15;
|
||||
let y = 18;
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(15);
|
||||
doc.text('BIRIM FIYAT TEKLIF CETVELI', W / 2, y, { align: 'center' });
|
||||
y += 8;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
const ikn = tender.ikn || '';
|
||||
const kurum = (tender.kurum || '').slice(0, 90);
|
||||
doc.text(`Idare: ${kurum}`, M, y); y += 5;
|
||||
doc.text(`Isin Adi: ${(tender.baslik || '').slice(0, 95)}`, M, y); y += 5;
|
||||
doc.text(`IKN / DTN: ${ikn} Tarih: ${tender.tarih || ''}`, M, y); y += 5;
|
||||
doc.text(`Teklif Veren: ${firma}`, M, y); y += 8;
|
||||
|
||||
// Tablo başlığı
|
||||
const cols = [
|
||||
{ t: 'No', x: M, w: 12, a: 'left' },
|
||||
{ t: 'Mal / Hizmet Adi', x: M + 12, w: 88, a: 'left' },
|
||||
{ t: 'Miktar', x: M + 100, w: 20, a: 'right' },
|
||||
{ t: 'Birim', x: M + 120, w: 18, a: 'left' },
|
||||
{ t: 'B.Fiyat', x: M + 138, w: 20, a: 'right' },
|
||||
{ t: 'Tutar', x: M + 158, w: 22, a: 'right' },
|
||||
];
|
||||
doc.setFillColor(30, 58, 138);
|
||||
doc.setTextColor(255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.rect(M, y - 4.5, W - 2 * M, 7, 'F');
|
||||
cols.forEach((c) => doc.text(c.t, c.a === 'right' ? c.x + c.w - 1 : c.x + 1, y, { align: c.a }));
|
||||
y += 5;
|
||||
|
||||
doc.setTextColor(20);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8.5);
|
||||
const items = tender.items && tender.items.length ? tender.items : [{ id: 'x1', ad: '(Kalem girilmemis)', miktar: '', birim: '' }];
|
||||
let total = 0;
|
||||
items.forEach((it, i) => {
|
||||
const bf = Number(prices[it.id] ?? it.birimFiyat ?? 0);
|
||||
const miktar = parseFloat(String(it.miktar).replace(',', '.')) || 0;
|
||||
const tutar = bf * miktar;
|
||||
total += tutar;
|
||||
if (i % 2 === 0) { doc.setFillColor(245, 247, 250); doc.rect(M, y - 4, W - 2 * M, 6, 'F'); }
|
||||
doc.text(String(i + 1), M + 1, y);
|
||||
doc.text(String(it.ad || '').slice(0, 60), M + 13, y);
|
||||
doc.text(String(it.miktar || ''), M + 120 - 1, y, { align: 'right' });
|
||||
doc.text(String(it.birim || ''), M + 120 + 1, y);
|
||||
doc.text(bf ? money(bf) : '—', M + 158 - 1, y, { align: 'right' });
|
||||
doc.text(tutar ? money(tutar) : '—', W - M - 1, y, { align: 'right' });
|
||||
y += 6;
|
||||
if (y > 250) { doc.addPage(); y = 20; }
|
||||
});
|
||||
|
||||
// Toplam
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(10);
|
||||
doc.setDrawColor(200);
|
||||
doc.line(M, y - 2, W - M, y - 2);
|
||||
y += 4;
|
||||
doc.text('GENEL TOPLAM (KDV Haric):', M + 90, y, { align: 'right' });
|
||||
doc.text(`${money(total)} TL`, W - M - 1, y, { align: 'right' });
|
||||
y += 16;
|
||||
|
||||
// Kaşe / imza alanı
|
||||
if (stamp) {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setDrawColor(150);
|
||||
doc.roundedRect(W - M - 60, y, 60, 30, 2, 2);
|
||||
doc.text('Kase ve Imza', W - M - 30, y + 17, { align: 'center' });
|
||||
doc.setTextColor(120);
|
||||
doc.setFontSize(7.5);
|
||||
doc.text(firma, W - M - 30, y + 23, { align: 'center' });
|
||||
}
|
||||
|
||||
doc.setTextColor(150);
|
||||
doc.setFontSize(7);
|
||||
doc.text('Ihaleist ile olusturulmustur — ihaleist.seymata.com', M, 288);
|
||||
|
||||
const safe = (ikn || tender.baslik || 'teklif').replace(/[^\w]+/g, '_').slice(0, 40);
|
||||
doc.save(`teklif_cetveli_${safe}.pdf`);
|
||||
return total;
|
||||
}
|
||||
Reference in New Issue
Block a user