diff --git a/package-lock.json b/package-lock.json index 9b303e8..45ddae2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "canvas-confetti": "^1.9.4", + "jspdf": "^4.2.1", "lucide-react": "^0.469.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1568,6 +1569,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1575,6 +1582,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", @@ -1596,6 +1610,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1617,6 +1638,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", @@ -1695,6 +1726,26 @@ "url": "https://www.paypal.me/kirilvatev" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1711,6 +1762,31 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1882,6 +1958,16 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.402", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", @@ -1970,6 +2056,17 @@ "node": ">=6.0.0" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1988,6 +2085,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2020,6 +2123,20 @@ "dev": true, "license": "ISC" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2029,6 +2146,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2071,6 +2194,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2424,6 +2564,29 @@ "node": ">=0.10.0" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2537,6 +2700,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2642,6 +2815,23 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -2717,6 +2907,26 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2738,6 +2948,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -2792,6 +3012,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", diff --git a/package.json b/package.json index b825f25..1f2084a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "canvas-confetti": "^1.9.4", + "jspdf": "^4.2.1", "lucide-react": "^0.469.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/components/KanbanBoard.jsx b/src/components/KanbanBoard.jsx index 612da16..a5beeab 100644 --- a/src/components/KanbanBoard.jsx +++ b/src/components/KanbanBoard.jsx @@ -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; diff --git a/src/components/LeftSidebar.jsx b/src/components/LeftSidebar.jsx index 4a86d9e..1aca722 100644 --- a/src/components/LeftSidebar.jsx +++ b/src/components/LeftSidebar.jsx @@ -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 }, diff --git a/src/components/RightSidebar.jsx b/src/components/RightSidebar.jsx index ebc3846..b1d4363 100644 --- a/src/components/RightSidebar.jsx +++ b/src/components/RightSidebar.jsx @@ -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 = () => { + {/* Ekip Yönetimi — gerçek (localStorage) */} +
+

+ Ekip Yönetimi ({teamMembers.length}) +

+
+ {teamMembers.map((m) => ( +
+
+
{m.name}
+
{m.role}{m.email ? ` · ${m.email}` : ''}
+
+ {teamMembers.length > 1 && ( + + )} +
+ ))} +
+
+ 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" /> +
+ 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" /> + +
+ +
+
+ ); }; diff --git a/src/components/TenderCard.jsx b/src/components/TenderCard.jsx index 6b06844..c5d25f5 100644 --- a/src/components/TenderCard.jsx +++ b/src/components/TenderCard.jsx @@ -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 }) => { {tender.kurum} + diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index 1b76e09..e5b4494 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -350,7 +350,11 @@ END:VCALENDAR`; Teklif cetvelinizi doldurun ve Dijital Kaşenizi sürükleyerek belgenin altına yerleştirin. + {/* GERÇEK: Piyasadaki Kazanan Firmalar (canlı veriden hesaplanır) */} +
+
+

+ Kazanan Firma Analizi — Canlı Veri +

+ {awardCount} sonuçlanmış ihale · toplam {formatTL(totalMarket)} +
+ {firms.length === 0 ? ( +
+ 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. +
+ ) : ( +
+ {firms.slice(0, 12).map((f, i) => ( +
+
+
+ {i + 1} + {f.name} +
+
+ 🏆 {f.wins} ihale · 💰 {formatTL(f.total)} {f.share ? `· %${f.share} pay` : ''} {f.sectors[0] ? `· ${f.sectors[0]}` : ''} +
+
+ +
+ ))} +
+ )} +
+ {/* Add New Competitor Form */}
diff --git a/src/components/tools/PriceAnalysis.jsx b/src/components/tools/PriceAnalysis.jsx index b79cc79..1cc319a 100644 --- a/src/components/tools/PriceAnalysis.jsx +++ b/src/components/tools/PriceAnalysis.jsx @@ -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 (
- - {/* Top Banner */}
-

Birim Fiyat Analizi & Trend Grafikleri

-

Ürün bazında geçmiş ihalelerdeki birim fiyat trendini ve ortalama teklif marjlarını inceleyin.

+

Kalem Bazlı Fiyat Analizi

+

+ Toplanan ihalelerdeki gerçek birim fiyatlardan hesaplanır · {uniqueItems} farklı kalem, {itemCount} fiyat kaydı. +

-
- {/* Main Grid */} -
- - {/* Left Column: Product Selector */} -
-

- Ürün / Malzeme Seçimi -

- -
- {productsList.map(prod => ( + {items.length === 0 ? ( +
+ + 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. +
+ ) : ( +
+
+

+ Kalemler ({items.length}) +

+ {items.slice(0, 60).map((it) => ( ))}
-
- {/* Right 2 Columns: Recharts Chart & AI Assistant */} -
- - {/* Recharts Line Chart */} -
-
-

- {selectedProduct} — 8 Aylık Birim Fiyat Değişim Grafiği (TL) -

- - Canlı EKAP Verisi - +
+
+
+

{selected?.ad} — Birim Fiyat Dağılımı (₺)

+ {selected?.count} gerçek kayıt +
+
+ + + + + + formatTL(v)} /> + + {chartData.map((d, i) => )} + + + +
+
+ {chartData.map((d) => ( +
+
{d.label}
+
{formatTL(d.value)}
+
+ ))} +
-
- - - - - - - - - - - +
+
+ Teklif Önerisi (gerçek veriye dayalı) +
+

+ {selected?.ad} için toplanan {selected?.count} gerçek kayda göre rekabetçi birim fiyat aralığınız{' '} + {formatTL(selected?.min)} – {formatTL(selected?.median)} arasıdır. + Medyanın altında teklif vermek kazanma olasılığını artırır; ortalamanın ({formatTL(selected?.avg)}) üstü riskli olabilir. +

- - {/* AI Pricing Advice Box */} -
-
- - Yapay Zeka Teklif Öneri Motoru -
-

- {selectedProduct} için son 8 aydaki ihale kazanma verilerine göre optimal teklif birim fiyatınız: ₺155 - ₺165 aralığıdır. Bu aralıkta kazanma olasılığınız %88 olarak hesaplanmıştır. -

-
-
- -
- + )}
); }; diff --git a/src/components/tools/PwaSettings.jsx b/src/components/tools/PwaSettings.jsx index 375aafa..1744d9f 100644 --- a/src/components/tools/PwaSettings.jsx +++ b/src/components/tools/PwaSettings.jsx @@ -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 = () => {
- {/* Mobile Push Notifications */} + {/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}

- Anlık Mobil Push Bildirimleri + Anlık Bildirimler

- - İzin Verildi + + {permission === 'granted' ? 'İzin Verildi' : permission === 'denied' ? 'Reddedildi' : permission === 'unsupported' ? 'Desteklenmiyor' : 'İzin Bekliyor'}

- İ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.

- + {permission !== 'granted' ? ( + + ) : ( + + )} + + {/* Sessiz saatler — gerçek */} +
+ + {reminderSettings.quietEnabled && ( +
+ setQuiet({ quietStart: e.target.value })} + className="bg-slate-50 border border-slate-300 rounded-lg px-2 py-1.5 flex-1" /> + → + setQuiet({ quietEnd: e.target.value })} + className="bg-slate-50 border border-slate-300 rounded-lg px-2 py-1.5 flex-1" /> +
+ )} + {reminderSettings.quietEnabled && ( +

+ {isQuietNow() ? '🔕 Şu an sessiz saatte — tarayıcı bildirimi gösterilmez, uyarılar listeye eklenir.' : '🔔 Şu an aktif — bildirimler gösterilir.'} +

+ )} +
{/* Install PWA Application */} diff --git a/src/components/tools/TurkeyHeatMap.jsx b/src/components/tools/TurkeyHeatMap.jsx new file mode 100644 index 0000000..8901474 --- /dev/null +++ b/src/components/tools/TurkeyHeatMap.jsx @@ -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 ( +
+
+
+

+ İl Bazlı İhale Yoğunluk Haritası — Canlı +

+

Yüklü ihalelerin illere dağılımı. Bir ile tıklayınca liste o ile filtrelenir. {withData.length}/81 ilde aktif ihale (%{topPct}).

+
+
+ Az + + + + + + Çok +
+
+ +
+ {ILLER.map((il) => { + const c = density.get(normProvince(il)) || 0; + const { bg, fg } = heat(c, max); + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 2902691..d894424 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -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 diff --git a/src/services/analytics.js b/src/services/analytics.js new file mode 100644 index 0000000..268c7fa --- /dev/null +++ b/src/services/analytics.js @@ -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); +} diff --git a/src/services/pdf.js b/src/services/pdf.js new file mode 100644 index 0000000..dc8fc87 --- /dev/null +++ b/src/services/pdf.js @@ -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; +}