fix: persist tender preparation and correct calendar export

This commit is contained in:
2026-09-06 08:30:32 +03:00
parent 909ebae7c2
commit c91631d99f
19 changed files with 569 additions and 182 deletions
+20 -5
View File
@@ -35,7 +35,7 @@ const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
export const BidPackageBuilder = ({ results, tender }) => {
const { userProfile } = useApp();
const [acik, setAcik] = useState(false);
const [acik, setAcik] = useState(true);
const [hata, setHata] = useState(null);
const [firma, setFirma] = useState(() => {
@@ -49,11 +49,14 @@ export const BidPackageBuilder = ({ results, tender }) => {
});
const [teklifTuru, setTeklifTuru] = useState('birim');
const [gecerlilikGun, setGecerlilikGun] = useState('60');
const kaynakGecerlilik = /(?:geçerlilik[^.]{0,100}?|ihale tarihinden itibaren\s*)(\d+)\s*(?:takvim\s*)?gün/i.exec(tender?.aciklama || '');
const [gecerlilikGun, setGecerlilikGun] = useState(kaynakGecerlilik?.[1] || '');
const [kisim, setKisim] = useState('');
const [goturuBedel, setGoturuBedel] = useState('');
const kalemler = Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : [];
const kalemler = tender?.items?.length
? tender.items.map(k => ({name:k.ad,quantity:`${k.miktar} ${k.birim || ''}`,keySpecs:[]}))
: (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []);
const [fiyatlar, setFiyatlar] = useState({});
const alanYaz = (k, v) => {
@@ -78,7 +81,13 @@ export const BidPackageBuilder = ({ results, tender }) => {
const toplam = teklifTuru === 'goturu' ? (Number(goturuBedel) || 0) : kalemToplami;
const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
const hazir = eksikler.length === 0 && toplam > 0;
const kalemEksik = teklifTuru === 'birim' && (!satirlar.length || satirlar.some(r => {
const {miktar,birim} = miktarAyristir(r.quantity);
return !r.name || !(miktar > 0) || !birim || !(Number(r.birimFiyat) > 0);
}));
const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || '');
const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1]));
const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik;
const formVerisi = () => ({
...firma,
@@ -94,6 +103,7 @@ export const BidPackageBuilder = ({ results, tender }) => {
const uret = (tip) => {
setHata(null);
try {
if (!hazir) throw new Error('Kalem, miktar, birim, fiyat, firma ve geçerlilik süresindeki eksikleri giderin.');
const f = formVerisi();
if (tip === 'mektup') belgeYazdir('Teklif Mektubu', teklifMektubuHtml(f, toplam));
if (tip === 'cetvel') belgeYazdir('Birim Fiyat Teklif Cetveli', teklifCetveliHtml(f, satirlar, toplam));
@@ -262,7 +272,7 @@ export const BidPackageBuilder = ({ results, tender }) => {
</div>
) : (
<p className="text-xs text-slate-500 bg-slate-50 border border-slate-200 rounded-lg p-3">
Analizde kalem dökümü çıkmadı. Götürü bedel seçip toplam teklifinizi doğrudan girebilirsiniz.
Kalem dökümü bulunamadı. Resmi teklif cetvelini İhale Dosyaları bölümünden yükleyin. Kalem eksikliği teklif türünü değiştirmez.
</p>
)}
@@ -284,6 +294,11 @@ export const BidPackageBuilder = ({ results, tender }) => {
</div>
)}
{(kalemEksik || turCeliskisi || sureEksik) && <p role="alert" className="text-xs bg-amber-50 p-3 text-amber-900">
{kalemEksik && 'Seçilen her kalemde ad, miktar, birim ve fiyat gerekli. '}
{turCeliskisi && 'İlan birim fiyat teklif belirtiyor; götürü bedel seçilemez. '}
{sureEksik && 'Teklif geçerlilik süresini kaynak dokümana göre doğrulayın. '}
</p>}
{/* Belgeler */}
<div className="space-y-2">
<h5 className="font-extrabold text-xs text-slate-900">Hazırlanacak belgeler</h5>
+18 -99
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import { BidPackageBuilder } from './BidPackageBuilder';
import { TenderDocuments } from './TenderDocuments';
import { TeklifAsistani } from './TeklifAsistani';
import { TeklifCetveliKalemleri } from './TeklifCetveliKalemleri';
@@ -7,13 +8,13 @@ import {
X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare,
DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck,
Send, Stamp, ArrowLeft, ExternalLink, Printer, PenLine } from 'lucide-react';
import confetti from 'canvas-confetti';
import { createTenderICS } from '../services/tenderCalendar';
export const TenderDetailModal = () => {
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders } = useApp();
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders, mutateTender, savingCount, saveError } = useApp();
const [activeTab, setActiveTab] = useState('details'); // 'details', 'pdf_stamp', 'ai_assistant', 'checklist', 'kurum_tenders'
const [noteText, setNoteText] = useState('');
const [savedNotes, setSavedNotes] = useState([]);
const [actionError, setActionError] = useState(null);
// Kur Farkı Calculator State
const [bidAmount, setBidAmount] = useState(15000);
@@ -28,30 +29,23 @@ export const TenderDetailModal = () => {
// Havuzdaki güncel kayıt: takip/görev değişince düğme etiketleri de değişsin.
const tender = (activeTenderDetail && tenders.find((t) => t.id === activeTenderDetail.id)) || activeTenderDetail;
const handleAddNote = (e) => {
const savedNotes = tender.notes || [];
const handleAddNote = async (e) => {
e.preventDefault();
if (!noteText.trim()) return;
setSavedNotes([...savedNotes, {
const saved = await mutateTender(tender.id, {notes: [...savedNotes, {
id: Date.now(),
author: userProfile.name,
text: noteText.trim(),
date: 'Az önce'
}]);
setNoteText('');
date: new Date().toLocaleString('tr-TR')
}]});
if (saved) setNoteText('');
};
const handleExportICS = () => {
confetti({ particleCount: 50 });
const icsData = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//İhaleist//İhale Takvimi//TR
BEGIN:VEVENT
SUMMARY:İhale Son Teslim: ${tender.baslik}
DESCRIPTION:İKN: ${tender.ikn} - ${tender.kurum}
DTSTART:20260810T120000Z
DTEND:20260810T130000Z
END:VEVENT
END:VCALENDAR`;
let icsData;
try { icsData = createTenderICS(tender); setActionError(null); }
catch (e) { setActionError(e.message); return; }
const blob = new Blob([icsData], { type: 'text/calendar;charset=utf-8' });
const link = document.createElement('a');
@@ -60,6 +54,7 @@ END:VCALENDAR`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
const handleStampMouseDown = () => setIsDraggingStamp(true);
@@ -77,6 +72,8 @@ END:VCALENDAR`;
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/70 backdrop-blur-xs flex justify-center p-2 sm:p-4 animate-fade-in">
<div className="bg-white w-full max-w-6xl rounded-2xl shadow-2xl border border-slate-200 overflow-hidden my-auto flex flex-col max-h-[92vh]">
{(actionError || saveError) && <p role="alert" className="p-3 bg-red-50 text-red-900">{actionError || saveError}</p>}
{savingCount > 0 && <p role="status" className="p-2 bg-blue-50">Kaydediliyor…</p>}
{/* Top Header Controls */}
<div className="bg-[#1f2937] text-white px-6 py-3.5 flex items-center justify-between border-b border-slate-700 shrink-0">
<button
@@ -383,86 +380,8 @@ END:VCALENDAR`;
{/* Tab 2: PDF Birim Fiyat Cetveli & Kaşe Editörü */}
{activeTab === 'pdf_stamp' && (
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 text-amber-900 p-4 rounded-xl text-xs flex items-center justify-between">
<div className="flex items-center gap-2 font-semibold">
<Stamp className="w-5 h-5 text-amber-600" />
<span>Teklif cetvelinizi doldurun ve Dijital Kaşenizi sürükleyerek belgenin altına yerleştirin.</span>
</div>
<button
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
</button>
</div>
{/* Interactive PDF Sheet Canvas Simulator */}
<div
onMouseMove={handleStampMouseMove}
onMouseUp={handleStampMouseUp}
className="bg-slate-200 p-6 rounded-2xl border border-slate-300 relative select-none min-h-[500px] flex justify-center"
>
{/* Paper sheet */}
<div className="bg-white w-full max-w-2xl p-8 rounded-lg shadow-xl border border-slate-300 relative space-y-6 text-slate-800">
<div className="text-center border-b-2 border-slate-800 pb-4 space-y-1">
<p className="font-extrabold text-sm uppercase">{tender.kurum}</p>
<p className="font-bold text-xs text-blue-900">BİRİM FİYAT TEKLİF CETVELİ — İKN: {tender.ikn}</p>
</div>
<table className="w-full text-left text-xs border border-slate-400">
<thead className="bg-slate-100 font-bold border-b border-slate-400">
<tr>
<th className="p-2 border-r border-slate-400">Sıra</th>
<th className="p-2 border-r border-slate-400">Malzeme Adı</th>
<th className="p-2 border-r border-slate-400">Miktar</th>
<th className="p-2 border-r border-slate-400">Birim Fiyat (TL)</th>
<th className="p-2">Toplam (TL)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-300">
{tender.items?.map((item, idx) => (
<tr key={idx}>
<td className="p-2 border-r border-slate-400 font-bold text-center">{idx + 1}</td>
<td className="p-2 border-r border-slate-400 font-semibold">{item.ad}</td>
<td className="p-2 border-r border-slate-400">{item.miktar} {item.birim}</td>
<td className="p-2 border-r border-slate-400 bg-yellow-50 font-bold text-blue-900">
{item.birimFiyat || 150} TL
</td>
<td className="p-2 font-mono font-bold text-emerald-800">
{((item.birimFiyat || 150) * parseFloat(item.miktar)).toLocaleString('tr-TR')} TL
</td>
</tr>
))}
</tbody>
</table>
<div className="pt-12 text-right">
<p className="font-bold text-xs text-slate-800">{userProfile.company}</p>
<p className="text-[10px] text-slate-500">Yetkili İmza ve Kaşe Alanı</p>
</div>
{/* Draggable Stamp Element */}
<div
onMouseDown={handleStampMouseDown}
style={{ left: `${stampPos.x}px`, top: `${stampPos.y}px` }}
className="absolute cursor-grab active:cursor-grabbing border-2 border-dashed border-red-500 bg-red-500/10 p-3 rounded-lg text-center transform -rotate-6 shadow-md transition-shadow hover:shadow-lg select-none"
>
<div className="w-32 h-16 border-2 border-red-700 rounded flex flex-col items-center justify-center p-1 text-red-700 font-black text-[9px] uppercase tracking-tighter">
<span>{userProfile.company.slice(0, 25)}</span>
<span className="text-[8px] text-red-600 font-bold mt-0.5">MÜDÜR İMZA & KAŞE</span>
<span className="text-[7px] text-slate-500">2026-08-07 ONAYLI</span>
</div>
<span className="text-[9px] font-extrabold text-red-600 block mt-1">↕ Sürükleyip Bırakın</span>
</div>
</div>
</div>
</div>
<BidPackageBuilder key={tender.ikn || tender.id} tender={tender}
results={aiAnalysisResults?.tenderKey === (tender.ikn || tender.id) ? aiAnalysisResults : null} />
)}
{/* Tab 3: AI Asistanı */}
+3 -1
View File
@@ -20,6 +20,7 @@ export const ApiWebhooks = () => {
const yukle = () => {
if (!authUser) return;
setHata(null);
listApiKeys().then(setDurum).catch((e) => setHata(e.message));
};
useEffect(yukle, [authUser]);
@@ -53,6 +54,7 @@ export const ApiWebhooks = () => {
return (
<div className="space-y-6 animate-fade-in">
{hata && <button onClick={yukle} className="px-4 py-2 border rounded-lg">Yeniden dene</button>}
<div className="bg-linear-to-r from-slate-900 via-violet-950 to-indigo-900 text-white p-6 rounded-2xl shadow-lg border border-violet-800/40">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-violet-600 flex items-center justify-center shadow-md">
@@ -111,7 +113,7 @@ export const ApiWebhooks = () => {
)}
</div>
{!durum ? (
{!durum && !hata && !hata ? (
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
) : durum.anahtarlar.length === 0 ? (
<p className="text-xs text-slate-400">Henüz anahtar yok.</p>
+3 -1
View File
@@ -20,6 +20,7 @@ export const DocumentVault = () => {
const yukle = () => {
if (!authUser) return;
setHata(null);
listDocuments().then(setDurum).catch((e) => setHata(e.message));
};
useEffect(yukle, [authUser]);
@@ -106,6 +107,7 @@ export const DocumentVault = () => {
return (
<div className="space-y-6 animate-fade-in">
{hata && <button onClick={yukle} className="px-4 py-2 border rounded-lg">Yeniden dene</button>}
<div className="bg-linear-to-r from-slate-900 via-teal-950 to-cyan-900 text-white p-6 rounded-2xl shadow-lg border border-teal-800/40">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-teal-600 flex items-center justify-center shadow-md">
@@ -234,7 +236,7 @@ export const DocumentVault = () => {
)}
{/* Liste */}
{!durum ? (
{!durum && !hata && !hata ? (
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
) : durum.belgeler.length === 0 ? (
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">