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
+3 -1
View File
@@ -39,12 +39,14 @@ import { LoginPage } from './components/LoginPage';
import { LandingPage } from './components/LandingPage';
const MainContent = () => {
const { activeTab, isAdmin } = useApp();
const { activeTab, isAdmin, saveError, savingCount } = useApp();
return (
<main className="max-w-[1600px] mx-auto px-4 pb-12">
{/* 3-Column Main View */}
<KotaUyarisi />
{savingCount > 0 && <p role="status" className="p-3 bg-blue-50 text-blue-900">Kaydediliyor…</p>}
{saveError && <p role="alert" className="p-3 bg-red-50 text-red-900 border border-red-200 rounded-lg">{saveError} Değişiklik kaydedilmedi; tekrar deneyin.</p>}
{activeTab === 'list' && (
<>
+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">
+77 -49
View File
@@ -5,6 +5,8 @@ import { getToken } from '../services/auth';
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
import { getAllUserData, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account';
import { applyTenderStates, tenderStateKey, tenderSnapshot } from '../services/tenderState';
import { getTenderStates, saveTenderState } from '../services/account';
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
const AppContext = createContext();
@@ -42,7 +44,7 @@ export const AppProvider = ({ children }) => {
try {
const saved = localStorage.getItem(TENDER_CACHE_KEY);
const parsed = saved ? JSON.parse(saved) : null;
return Array.isArray(parsed) ? parsed : [];
return Array.isArray(parsed) ? parsed.map(t => ({...t,takipte:false,gorev:null,notes:[],checklist:[],incelendi:false,atananPersonel:null,kanbanStatus:'incelenecek',bidDraft:null})) : [];
} catch {
try { localStorage.removeItem(TENDER_CACHE_KEY); } catch { /* yok say */ }
return [];
@@ -151,16 +153,26 @@ export const AppProvider = ({ children }) => {
useEffect(() => {
if (!authUser) { setVeriSenkron(false); setPlan(null); return; }
planYenile();
getAllUserData()
.then((v) => {
if (v.favori_kurumlar) setFavoriteKurumlar(v.favori_kurumlar);
if (v.ekip) setTeamMembers(v.ekip);
if (v.saved_filters) setSavedFilters(v.saved_filters);
let cancelled = false;
setVeriSenkron(false);
ilkYazmaAtlanan.current.clear();
savedTenderStates.current = {};
Promise.all([getAllUserData(), getTenderStates()])
.then(([v, records]) => {
if (cancelled) return;
setFavoriteKurumlar(v.favori_kurumlar || []);
setTeamMembers(v.ekip || []);
setSavedFilters(v.saved_filters || []);
if (v.reminder) setReminderSettings((s) => ({ ...s, ...v.reminder }));
if (v.rakip_izleme) setCompetitorWatchlist(v.rakip_izleme);
setCompetitorWatchlist(v.rakip_izleme || []);
if (v.firma_profil) setUserProfile(p => ({...p,...v.firma_profil}));
savedTenderStates.current = records;
setTenders(prev => applyTenderStates(prev, records));
setVeriSenkron(true);
setSaveError(null);
})
.catch((e) => console.warn('[veri] sunucudan alınamadı:', e.message))
.finally(() => setVeriSenkron(true));
.catch((e) => { if (!cancelled) setSaveError('Hesap verileri yüklenemedi: ' + e.message); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authUser?.email]);
@@ -168,6 +180,38 @@ export const AppProvider = ({ children }) => {
// localStorage yalnızca çevrimdışı önbellek; kaynak doğruluk sunucuda.
const [plan, setPlan] = useState(null);
const [veriSenkron, setVeriSenkron] = useState(false);
const savedTenderStates = useRef({});
const inFlight = useRef(new Set());
const [saveError, setSaveError] = useState(null);
const [savingCount, setSavingCount] = useState(0);
const mutateTender = async (tenderId, patch) => {
const t = tenders.find(t => t.id === tenderId);
if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; }
const key = tenderStateKey(t);
if (inFlight.current.has(key)) return false;
const owner = authUser.email;
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
try {
const record = await saveTenderState(key, patch, tenderSnapshot(t), savedTenderStates.current[key]?.version || 0);
if (getStoredUser()?.email !== owner) return false;
savedTenderStates.current = {...savedTenderStates.current,[key]:record};
setTenders(prev => applyTenderStates(prev, savedTenderStates.current));
return true;
} catch (e) {
setSaveError(e.message);
if (e.kod === 'VERSION_CONFLICT') {
try {
const records = await getTenderStates();
if (getStoredUser()?.email === owner) {
savedTenderStates.current = records;
setTenders(prev => applyTenderStates(prev, records));
}
} catch { /* retain visible error */ }
}
return false;
} finally { inFlight.current.delete(key); setSavingCount(n => n - 1); }
};
// Sunucu verisi yüklendiğinde her effect bir kez tetiklenir; bu ilk turda
// yazmak gereksiz (aynı veri) ve boş istemci state'iyle ezme riski taşır.
const ilkYazmaAtlanan = useRef(new Set());
@@ -182,7 +226,7 @@ export const AppProvider = ({ children }) => {
if (!authUser) return;
setUserData(anahtar, deger).catch((e) => {
if (e.kod === 'KOTA_ASILDI') setKotaUyarisi(e.message);
else console.warn('[veri] sunucuya yazılamadı:', e.message);
else setSaveError('Kaydedilemedi: ' + e.message);
});
};
const [kotaUyarisi, setKotaUyarisi] = useState(null);
@@ -193,6 +237,9 @@ export const AppProvider = ({ children }) => {
const handleLogout = () => {
clearSession();
setAuthUser(null);
savedTenderStates.current = {};
setTenders([]);
setActiveTenderDetail(null);
};
// Canlı ihale verisini (ilan.gov.tr) çeker; kullanıcının kanban/takip durumunu korur.
@@ -201,7 +248,7 @@ export const AppProvider = ({ children }) => {
setLiveDataError(null);
try {
const data = await fetchLiveTenders({ refresh });
setTenders(prev => mergeUserState(data.tenders, prev));
setTenders(prev => applyTenderStates(mergeUserState(data.tenders, prev), savedTenderStates.current));
setLiveDataSource({ source: data.source, fetchedAt: data.fetchedAt });
setLiveStats({
toplamIhale: data.totalCount ?? 0,
@@ -401,6 +448,7 @@ export const AppProvider = ({ children }) => {
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode });
const result = {
...data.result,
tenderKey: tender?.ikn || tender?.id || null,
mode: data.mode || (mode === 'derin' ? 'derin' : 'standart'),
fileName,
model: `${data.model.label} (${data.model.provider})`,
@@ -564,12 +612,8 @@ export const AppProvider = ({ children }) => {
// gerçek /api/stats verisinden gelir.)
const toggleTrackTender = (tenderId) => {
setTenders(prev => prev.map(t => {
if (t.id === tenderId) {
return { ...t, takipte: !t.takipte };
}
return t;
}));
const t = tenders.find(t => t.id === tenderId);
return mutateTender(tenderId, {takipte: !t?.takipte});
};
// Takipteki İKN'ler sunucuya yazılır: zeyilname/düzeltme ilanı ve sonuç
@@ -589,28 +633,21 @@ export const AppProvider = ({ children }) => {
* Hazırlanıyor" kolonunda düşer, takibe alınır — sonuç yayımlanınca bildirim
* gelir. (Fikir: MOBIT-DURT "ilandan göreve, görevden sonuca".)
*/
const gorevAc = (tenderId, { kontrolListesi = [] } = {}) => {
setTenders((prev) => prev.map((t) => {
if (t.id !== tenderId) return t;
return {
...t,
takipte: true,
kanbanStatus: t.kanbanStatus && t.kanbanStatus !== 'incelenecek' ? t.kanbanStatus : 'teklif_hazirlaniyor',
gorev: {
acildi: new Date().toISOString(),
teslim: t.tarih ? `${t.tarih}${t.saat ? ' ' + t.saat : ''}` : null,
kontrolListesi: kontrolListesi.map((k) => ({ ad: typeof k === 'string' ? k : k.document, tamam: false })),
},
};
}));
notify('Hazırlık görevi açıldı', 'Teslim tarihi ihale saati; pano "Teklif Hazırlanıyor" kolonunda.');
const gorevAc = async (tenderId, { kontrolListesi = [] } = {}) => {
const t = tenders.find(t => t.id === tenderId);
if (!t || t.gorev) return;
const ok = await mutateTender(tenderId, {
takipte: true,
kanbanStatus: t.kanbanStatus && t.kanbanStatus !== 'incelenecek' ? t.kanbanStatus : 'teklif_hazirlaniyor',
gorev: { acildi: new Date().toISOString(), teslim: t.tarih ? `${t.tarih} ${t.saat || ''}`.trim() : null,
kontrolListesi: kontrolListesi.map(k => ({ad:typeof k === 'string' ? k : k.document,tamam:false})) },
});
if (ok) notify('Hazırlık görevi kaydedildi', 'Görev sunucuda saklandı; Kanban panosundan devam edebilirsiniz.');
};
const gorevKontrol = (tenderId, idx) => {
setTenders((prev) => prev.map((t) => {
if (t.id !== tenderId || !t.gorev) return t;
const liste = t.gorev.kontrolListesi.map((k, i) => (i === idx ? { ...k, tamam: !k.tamam } : k));
return { ...t, gorev: { ...t.gorev, kontrolListesi: liste } };
}));
const t = tenders.find(t => t.id === tenderId);
if (!t?.gorev) return;
return mutateTender(tenderId, {gorev:{...t.gorev,kontrolListesi:t.gorev.kontrolListesi.map((k,i) => i === idx ? {...k,tamam:!k.tamam} : k)}});
};
// Sunucu bildirimleri (zeyilname / sonuç): 5 dk'da bir çek, yenileri göster
@@ -655,17 +692,8 @@ export const AppProvider = ({ children }) => {
}, [veriSenkron]);
const updateKanbanStatus = (tenderId, newStatus, assignedUser = null) => {
setTenders(prev => prev.map(t => {
if (t.id === tenderId) {
return {
...t,
kanbanStatus: newStatus,
atananPersonel: assignedUser !== null ? assignedUser : t.atananPersonel,
takipte: true
};
}
return t;
}));
const t = tenders.find(t => t.id === tenderId);
return mutateTender(tenderId, {kanbanStatus:newStatus,atananPersonel:assignedUser ?? t?.atananPersonel ?? null,takipte:true});
};
const addSavedFilter = (newFilter) => {
@@ -793,7 +821,7 @@ export const AppProvider = ({ children }) => {
planYenile,
kotaUyarisi,
setKotaUyarisi,
veriSenkron
veriSenkron, saveError, savingCount, mutateTender
}}>
{children}
</AppContext.Provider>
+2 -7
View File
@@ -1,3 +1,4 @@
import { IL_PLAKA } from '../../server/il-plaka.mjs';
/**
* Sabit referans listeleri (mock veri DEĞİL — açılır menü/filtre seçenekleri).
* Sektörler, kurum türleri ve iller Türkiye kamu ihale sisteminin gerçek
@@ -44,10 +45,4 @@ export const INSTITUTION_TYPES_LIST = [
"Ağız ve Diş Sağlığı Merkezleri",
];
export const CITIES_LIST = [
"Adana", "Adıyaman", "Afyonkarahisar", "Aksaray", "Amasya", "Ankara", "Antalya", "Ardahan", "Artvin",
"Aydın", "Balıkesir", "Bolu", "Bursa", "Çanakkale", "Denizli", "Diyarbakır", "Edirne", "Eskişehir",
"Gaziantep", "Giresun", "Isparta", "İstanbul", "İzmir", "Kahramanmaraş", "Kars", "Kastamonu", "Kayseri",
"Kocaeli", "Konya", "Kütahya", "Malatya", "Manisa", "Mardin", "Muğla", "Ordu", "Rize", "Sakarya",
"Samsun", "Sivas", "Şanlıurfa", "Tekirdağ", "Trabzon", "Van", "Zonguldak",
];
export const CITIES_LIST = Object.values(IL_PLAKA).sort((a,b) => a.localeCompare(b, 'tr'));
+4
View File
@@ -102,3 +102,7 @@ export const telegramSil = () => iste('/api/me/telegram', { method: 'DELETE' });
// --- Takip bildirimleri (zeyilname / sonuç) ---
export const bildirimleriGetir = () => iste('/api/me/bildirimler').then((d) => d.bildirimler || []);
export const bildirimOkundu = (idler) => iste('/api/me/bildirimler', { method: 'POST', body: JSON.stringify({ idler }) });
export const getTenderStates = () => iste('/api/me/tender-state').then(d => d.records || {});
export const saveTenderState = (key, patch, snapshot, version) =>
iste('/api/me/tender-state', {method:'PUT',body:JSON.stringify({key,patch,snapshot,version})}).then(d => d.record);
+4 -3
View File
@@ -27,8 +27,9 @@ export function yaziylaTL(n) {
if (!isFinite(sayi)) return '';
const negatif = sayi < 0;
const mutlak = Math.abs(sayi);
const tam = Math.floor(mutlak);
const kurus = Math.round((mutlak - tam) * 100);
const toplamKurus = Math.round((mutlak + Number.EPSILON) * 100);
const tam = Math.floor(toplamKurus / 100);
const kurus = toplamKurus % 100;
const gruplar = [];
let kalan = tam;
@@ -126,7 +127,7 @@ sınırlama yapılmadan bütün koşullarıyla kabul edilmiştir.</p>
<p>İhale dokümanında yer alan bütün şartları kabul ederek, söz konusu ${bedelCumle}
kabul ve taahhüt ediyoruz.</p>
<p>Teklifimizin, ihale tarihinden itibaren <strong>${kacir(f.gecerlilikGun || '60')}</strong>
<p>Teklifimizin, ihale tarihinden itibaren <strong>${kacir(f.gecerlilikGun || '[DOĞRULAYIN]')}</strong>
takvim günü süreyle geçerli olduğunu beyan ediyoruz.</p>
${f.kisim ? `<p>Teklifimiz <strong>${kacir(f.kisim)}</strong> kısmına/kısımlarına ilişkindir.</p>` : ''}
+2 -2
View File
@@ -21,7 +21,7 @@ export async function fetchTenderDetail(tenderId) {
}
// Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir.
const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel'];
const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel', 'gorev', 'notes', 'checklist', 'bidDraft'];
export function mergeUserState(liveTenders, previousTenders) {
const prevById = new Map((previousTenders || []).map((t) => [t.id, t]));
@@ -30,7 +30,7 @@ export function mergeUserState(liveTenders, previousTenders) {
if (!prev) return tender;
const merged = { ...tender };
for (const f of USER_FIELDS) {
if (prev[f] !== undefined && prev[f] !== null && prev[f] !== false) merged[f] = prev[f];
if (prev[f] !== undefined) merged[f] = prev[f];
}
if (prev.aciklama && !merged.aciklama) merged.aciklama = prev.aciklama;
return merged;
+38
View File
@@ -0,0 +1,38 @@
// Calendar dates in Turkish procurement announcements are Europe/Istanbul (+03:00).
// Never manufacture a deadline when the source omitted its time.
export function tenderDeadline(t) {
const source = String(t.aciklama || '');
const date = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(String(t.tarih || ''));
if (!date) throw new Error('İhale tarihi doğrulanamadı; takvime eklenemedi.');
const [,day,month,year] = date;
const time = /^(\d{2}):(\d{2})/.exec(String(t.saat || '').trim())
|| new RegExp(`${day}\\.${month}\\.${year}\\s*[-–]?\\s*(\\d{2}):(\\d{2})`).exec(source);
if (!time) throw new Error('İhale saati kaynakta bulunamadı; önce tarih ve saati doğrulayın.');
const hour = Number(time[1]), minute = Number(time[2]);
const calendarDate = new Date(Date.UTC(Number(year), Number(month)-1, Number(day)));
if (hour > 23 || minute > 59 || calendarDate.getUTCFullYear() !== Number(year)
|| calendarDate.getUTCMonth()+1 !== Number(month) || calendarDate.getUTCDate() !== Number(day))
throw new Error('Geçersiz ihale tarihi veya saati.');
return new Date(Date.UTC(Number(year),Number(month)-1,Number(day),hour-3,minute));
}
const escapeICS = v => String(v || '').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/;/g,'\\;').replace(/,/g,'\\,');
const stamp = date => date.toISOString().replace(/[-:]/g,'').replace(/\.\d{3}Z$/,'Z');
function fold(line) {
const result = []; let current = '';
for (const char of line) {
if (new TextEncoder().encode(current + char).length > 75) { result.push(current); current = ' '; }
current += char;
}
result.push(current); return result.join('\r\n');
}
export function createTenderICS(t, now = new Date()) {
const start = tenderDeadline(t);
const uid = encodeURIComponent(String(t.ikn || t.id || '').trim());
if (!uid) throw new Error('İhale kimliği gerekli.');
return ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Ihaleist//Tender Calendar//TR','CALSCALE:GREGORIAN',
'BEGIN:VEVENT',`UID:${uid}@ihaleist.com`,`DTSTAMP:${stamp(now)}`,
`DTSTART:${stamp(start)}`,`DTEND:${stamp(start)}`,
`SUMMARY:${escapeICS('İhale son teklif: '+t.baslik)}`,
`DESCRIPTION:${escapeICS('İKN: '+t.ikn+' — '+t.kurum)}`,
'END:VEVENT','END:VCALENDAR',''].map(fold).join('\r\n');
}
+18
View File
@@ -0,0 +1,18 @@
export const tenderStateKey = t => String(t?.ikn || t?.id || '').trim();
export const USER_STATE_FIELDS = ['takipte', 'incelendi', 'kanbanStatus', 'atananPersonel', 'gorev', 'notes', 'checklist', 'bidDraft'];
export function applyTenderStates(tenders, records = {}) {
const seen = new Set();
const result = tenders.map(t => {
const key = tenderStateKey(t); seen.add(key);
const record = records[key];
return record ? { ...t, ...record.data } : t;
});
for (const [key, record] of Object.entries(records)) {
if (!seen.has(key) && record.snapshot && (record.data.takipte || record.data.gorev))
result.push({ ...record.snapshot, ...record.data });
}
return result;
}
export function tenderSnapshot(t) {
return Object.fromEntries(['id','ikn','baslik','kurum','tarih','saat','sehir','alimTuru'].map(k => [k,t[k] ?? '']));
}