fix: persist tender preparation and correct calendar export
This commit is contained in:
+3
-1
@@ -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' && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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ı */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>` : ''}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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] ?? '']));
|
||||
}
|
||||
Reference in New Issue
Block a user