feat: takip izleme (zeyilname + sonuc bildirimi), hazirlik gorevi, belge sure uyarisi, Telegram, OCR, yasakli sorgu

28 depo taramasindan alinan alti ozellik. Kod kopyalanmadi (depolar
lisanssiz), fikirler uygulandi.

1) Zeyilname / duzeltme ilani takibi (server/zeyilname.mjs): takipteki
   IKN'ler sunucuya yazilir; 3 saatte bir EKAP detayindan ilan sayisi ve
   ihale tarihi karsilastirilir, degisince "ne degisti" bildirimi (uygulama
   ici + e-posta + Telegram). Test: anlik kaydi eskitilince tur "yeni ilan
   yayimlandi; ihale tarihi 01.01.2026 → 08.10.2026" bildirimi uretti.
2) Sonuc bildirimi: takipteki ihale sonuclaninca "X firmasi Y'ye aldi, %Z
   kirim". Yalniz takiptekiler; kismi ihalede oran yazilmaz.
3) Ihaleden goreve (MOBIT-DURT): detayda "Hazirlik Gorevi Ac" → teslim
   tarihi ihale saati, pano "Teklif Hazirlaniyor", derin analizden gelen
   belge kontrol listesi gorevde; Kanban kartinda geri sayim rozeti.
4) Sirket belgesi sure takibi: Belge Kasasi'nda son kullanma tarihi girisi,
   dolmus / 30 gun icinde dolacak uyarisi; girise gunde bir bildirim.
   PATCH /api/me/documents/meta.
5) Telegram (server/telegram.mjs): TELEGRAM_BOT_TOKEN sunucuda; kullanici
   6 haneli kodu bota yazar, "Dogrula" → getUpdates ile chat id baglanir.
   Arayuzde adim adim kurulum rehberi (BotFather → env → Redeploy → kod).
   Ihale ozeti, zeyilname ve sonuc bildirimleri Telegram'a da gider.
6) OCR (tesseract.js, tur): PDF metin katmani bossa (taranmis) ilk 8 sayfa
   OCR. Olcum: sentetik cetvelde guven 87, ~0,3 sn/sayfa; IKN ve kalemler
   dogru okundu. Kalem havuzu ve derin analiz artik taranmis PDF'ten de
   besleniyor.
7) Rakip Radari'nda KIK yasakli sorgusu baglantisi (firma adi panoya
   kopyalanir; EKAP sorguyu API'den vermiyor).

Sinir deger hesaplayici KURULMADI: Kamu Ihale Genel Tebligi'ne ulasilamadi
(mevzuat.gov.tr / ihale.gov.tr bu makineden erisilemiyor); formul kaynaksiz
yazilmadi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 22:58:13 +03:00
co-authored by Claude Fable 5.1
parent 1716b6365c
commit df89eaac33
18 changed files with 802 additions and 10 deletions
+10
View File
@@ -127,6 +127,16 @@ export const KanbanBoard = () => {
<span className="text-[10px] font-extrabold bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
#{t.ikn}
</span>
{t.gorev && (() => {
// Teslim = ihale saati; kalan gün geri sayım (gecikme eskalasyonu kendiliğinden)
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(t.gorev.teslim || '');
if (!m) return null;
const kalan = Math.ceil((new Date(+m[3], +m[2] - 1, +m[1], +(m[4] || 23), +(m[5] || 59)) - Date.now()) / 86400000);
const renk = kalan < 0 ? 'bg-slate-200 text-slate-600' : kalan <= 2 ? 'bg-red-100 text-red-800' : kalan <= 7 ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800';
return <span className={`text-[10px] font-extrabold px-2 py-0.5 rounded ${renk}`} title={`Teslim: ${t.gorev.teslim}`}>
{kalan < 0 ? 'süresi geçti' : kalan === 0 ? 'BUGÜN' : `${kalan} gün`}
</span>;
})()}
<button
onClick={() => setActiveTenderDetail(t)}
className="text-slate-400 hover:text-blue-600 p-0.5"
+34 -1
View File
@@ -10,7 +10,7 @@ import {
import confetti from 'canvas-confetti';
export const TenderDetailModal = () => {
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen } = useApp();
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults } = useApp();
const [activeTab, setActiveTab] = useState('details'); // 'details', 'pdf_stamp', 'ai_assistant', 'checklist', 'kurum_tenders'
const [noteText, setNoteText] = useState('');
const [savedNotes, setSavedNotes] = useState([
@@ -99,6 +99,22 @@ END:VCALENDAR`;
<Star className={`w-4 h-4 ${tender.takipte ? 'fill-slate-900' : ''}`} />
<span>{tender.takipte ? 'Takipte' : 'Takip Et'}</span>
</button>
{/* İhaleden göreve: teslim tarihi ihale saati; sonuç yayımlanınca bildirim */}
{tender.gorev ? (
<span className="flex items-center gap-1.5 font-bold px-3 py-1.5 rounded-lg text-xs bg-emerald-500/20 text-emerald-200 border border-emerald-400/40"
title={`Hazırlık görevi açık · teslim: ${tender.gorev.teslim || '—'}`}>
✓ Görev açık{tender.gorev.teslim ? ` · ${tender.gorev.teslim}` : ''}
</span>
) : (
<button
type="button"
onClick={() => gorevAc(tender.id, { kontrolListesi: aiAnalysisResults?.documentChecklist || [] })}
title="Hazırlık görevi aç: teslim tarihi ihale saati olur, pano 'Teklif Hazırlanıyor' kolonuna düşer, sonuç yayımlanınca bildirim gelir"
className="flex items-center gap-1.5 font-bold px-3 py-1.5 rounded-lg text-xs transition-colors bg-amber-400 text-slate-950 hover:bg-amber-300"
>
🗂 Hazırlık Görevi Aç
</button>
)}
<button
onClick={() => setActiveTenderDetail(null)}
className="text-slate-400 hover:text-white p-1"
@@ -214,6 +230,23 @@ END:VCALENDAR`;
</div>
</div>
{tender.gorev?.kontrolListesi?.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 space-y-2">
<h3 className="font-bold text-sm text-slate-900">Hazırlık görevi — belge kontrol listesi</h3>
<ul className="space-y-1">
{tender.gorev.kontrolListesi.map((k, i) => (
<li key={i}>
<button type="button" onClick={() => gorevKontrol(tender.id, i)}
className={`text-xs text-left flex items-center gap-2 ${k.tamam ? 'line-through text-slate-400' : 'text-slate-800'}`}>
<span className={`w-3.5 h-3.5 rounded border ${k.tamam ? 'bg-emerald-500 border-emerald-500' : 'border-slate-400'}`} />
{k.ad}
</button>
</li>
))}
</ul>
</div>
)}
{/* Ortak havuzdan teklif cetveli kalemleri */}
<TeklifCetveliKalemleri ikn={tender.ikn} />
+6
View File
@@ -118,6 +118,12 @@ export const CompetitorRadar = () => {
⚔ ort. {f.ortTeklif} teklif
</span>
)}
<a href="https://ekapv2.kik.gov.tr/sorgulamalar/yasak-sorgulama" target="_blank" rel="noopener noreferrer"
onClick={() => { try { navigator.clipboard?.writeText(f.name); } catch { /* yoksay */ } }}
title="KİK yasaklılar sorgusunu açar; firma adı panoya kopyalanır (EKAP sorguyu API'den vermiyor)"
className="bg-slate-100 text-slate-700 border border-slate-200 px-1.5 py-0.5 rounded font-bold hover:bg-slate-200">
⛔ yasaklı sorgula
</a>
{f.tekTeklifOrani != null && f.tekTeklifOrani > 0 && (
<span className="bg-amber-50 text-amber-800 border border-amber-200 px-1.5 py-0.5 rounded font-bold"
title="Tek teklifle (rakipsiz) kazandığı ihalelerin oranı">
+40 -2
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { useApp } from '../../context/AppContext';
import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt } from '../../services/account';
import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive, Layers, Package, CheckCircle2, XCircle } from 'lucide-react';
import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt, updateDocumentMeta } from '../../services/account';
import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive, Layers, Package, CheckCircle2, XCircle, CalendarClock } from 'lucide-react';
const KATEGORILER = ['Yasal Belgeler', 'Kalite Belgeleri', 'Mali Belgeler', 'Resmi Kayıtlar', 'Teknik Belgeler', 'Diğer'];
@@ -82,6 +82,17 @@ export const DocumentVault = () => {
if (topluRef.current) topluRef.current.value = '';
};
// Şirket belgesi süre takibi: imza sirküleri, oda kayıt, borcu yoktur —
// hepsinin süresi dolar, kimse teklif hazırlanana kadar fark etmez.
const sureYaz = async (id, tarih) => {
setHata(null);
try { setDurum(await updateDocumentMeta(id, { sonKullanma: tarih || null })); }
catch (e) { setHata(e.message); }
};
const kalanGun = (b) => (b.son_kullanma ? Math.ceil((new Date(b.son_kullanma).getTime() - Date.now()) / 86400000) : null);
const suresiDolan = (durum?.belgeler || []).filter((b) => kalanGun(b) != null && kalanGun(b) < 0);
const yakinda = (durum?.belgeler || []).filter((b) => kalanGun(b) != null && kalanGun(b) >= 0 && kalanGun(b) <= 30);
const sil = async (id) => {
setBusy(true); setHata(null);
try { setDurum(await deleteDocument(id)); }
@@ -154,6 +165,22 @@ export const DocumentVault = () => {
</div>
</div>
{/* Süre uyarısı */}
{(suresiDolan.length > 0 || yakinda.length > 0) && (
<div className={`rounded-2xl border-2 p-4 flex gap-3 ${suresiDolan.length ? 'bg-red-50 border-red-300' : 'bg-amber-50 border-amber-300'}`}>
<CalendarClock className={`w-5 h-5 shrink-0 mt-0.5 ${suresiDolan.length ? 'text-red-600' : 'text-amber-600'}`} />
<div className="text-xs leading-relaxed">
{suresiDolan.length > 0 && (
<p className="font-bold text-red-900">Süresi dolmuş: {suresiDolan.map((b) => b.ad).join(', ')}</p>
)}
{yakinda.length > 0 && (
<p className="font-semibold text-amber-900">30 gün içinde doluyor: {yakinda.map((b) => `${b.ad} (${kalanGun(b)} gün)`).join(', ')}</p>
)}
<p className="text-slate-600 mt-1">Teklif zarfına süresi dolmuş belge girerse teklif değerlendirme dışı kalır. Yenileyip yeni tarihi girin.</p>
</div>
</div>
)}
{/* Toplu ihale dokümanı yükleme */}
<div className="bg-linear-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-2xl p-5 space-y-3">
<div className="flex items-start gap-3">
@@ -226,6 +253,17 @@ export const DocumentVault = () => {
<p className="text-[10px] text-slate-400 mt-0.5">
{new Date(d.olusturma).toLocaleString('tr-TR')}
</p>
<label className="flex items-center gap-1.5 mt-1.5 text-[10px] text-slate-500">
<CalendarClock className={`w-3 h-3 ${kalanGun(d) != null && kalanGun(d) < 0 ? 'text-red-600' : kalanGun(d) != null && kalanGun(d) <= 30 ? 'text-amber-600' : 'text-slate-400'}`} />
<span>Son kullanma</span>
<input type="date" value={d.son_kullanma || ''} onChange={(e) => sureYaz(d.id, e.target.value)}
className="border border-slate-200 rounded px-1 py-0.5 text-[10px]" />
{kalanGun(d) != null && (
<span className={`font-bold ${kalanGun(d) < 0 ? 'text-red-700' : kalanGun(d) <= 30 ? 'text-amber-700' : 'text-emerald-700'}`}>
{kalanGun(d) < 0 ? `${-kalanGun(d)} gün önce doldu` : `${kalanGun(d)} gün kaldı`}
</span>
)}
</label>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => downloadDocument(d.id, d.ad).catch((e) => setHata(e.message))}
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useApp } from '../../context/AppContext';
import { TelegramBaglanti } from './TelegramBaglanti';
import { getToken } from '../../services/auth';
import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, Globe, Sparkles, ChevronDown, ChevronUp, X } from 'lucide-react';
@@ -106,6 +107,8 @@ export const NotifySubscriptions = () => {
const oneriler = tarama?.sonuc?.ihaleKonulari || [];
return (
<>
<TelegramBaglanti />
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-5">
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
@@ -266,5 +269,6 @@ export const NotifySubscriptions = () => {
</p>
</div>
</div>
</>
);
};
+96
View File
@@ -0,0 +1,96 @@
import React, { useEffect, useState } from 'react';
import { Send, CheckCircle2, AlertTriangle, Loader2, Unplug } from 'lucide-react';
import { telegramDurum, telegramBagla, telegramTest, telegramSil } from '../../services/account';
/**
* Telegram bildirim bağlantısı — kullanıcı kendi kurar.
* Bot tokenı sunucuda (TELEGRAM_BOT_TOKEN); kullanıcı 6 haneli kodu bota yazar,
* "Doğrula" deyince sunucu kodu görüp sohbeti hesaba bağlar. Token bizden geçmez.
*/
const kodUret = () => String(Math.floor(100000 + Math.random() * 900000));
export const TelegramBaglanti = () => {
const [d, setD] = useState(null);
const [kod] = useState(kodUret);
const [busy, setBusy] = useState(null);
const [mesaj, setMesaj] = useState(null);
const yenile = () => telegramDurum().then(setD).catch((e) => setMesaj({ tur: 'hata', metin: e.message }));
useEffect(() => { yenile(); }, []);
const calistir = async (ad, fn, basari) => {
setBusy(ad); setMesaj(null);
try { await fn(); setMesaj({ tur: 'ok', metin: basari }); await yenile(); }
catch (e) { setMesaj({ tur: 'hata', metin: e.message }); }
finally { setBusy(null); }
};
if (!d) return null;
return (
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
<Send className="w-4 h-4 text-sky-600" /> Telegram Bildirimi
</h3>
{d.bagli
? <span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">bağlı{d.ad ? ` · ${d.ad}` : ''}</span>
: <span className="bg-slate-100 text-slate-600 font-extrabold text-[10px] px-2 py-0.5 rounded-full">bağlı değil</span>}
</div>
<p className="text-xs text-slate-600 leading-relaxed">
Yeni ihale özetleri, takipteki ihalede <strong>zeyilname/düzeltme ilanı</strong> ve <strong>sonuç</strong>
("X firması Y'ye aldı, %Z kırım") e-postanın yanında Telegram'a da gelir.
</p>
{!d.yapilandirilmis ? (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 space-y-2 text-xs text-amber-900 leading-relaxed">
<p className="font-bold">Sunucuda Telegram botu henüz tanımlı değil. Kurulum (yönetici, bir kez):</p>
<ol className="list-decimal pl-5 space-y-1">
<li>Telegram'da <strong>@BotFather</strong>'a yazın: <code className="bg-amber-100 px-1 rounded">/newbot</code> → bir ad ve <code className="bg-amber-100 px-1 rounded">…bot</code> ile biten kullanıcı adı verin.</li>
<li>BotFather'ın verdiği tokenı Coolify → ihaleist → Environment'a <code className="bg-amber-100 px-1 rounded">TELEGRAM_BOT_TOKEN</code> olarak ekleyin, Redeploy.</li>
<li>Bu sayfa yeniden açıldığında aşağıdaki bağlama adımları görünür.</li>
</ol>
<p className="text-[11px] text-amber-800">Token yalnızca sunucuda durur; uygulamaya girilmez.</p>
</div>
) : d.bagli ? (
<div className="flex flex-wrap gap-2">
<button onClick={() => calistir('test', telegramTest, 'Test mesajı gönderildi — Telegram\'ı kontrol edin.')} disabled={!!busy}
className="bg-sky-600 hover:bg-sky-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2 rounded-lg flex items-center gap-1.5">
{busy === 'test' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />} Test mesajı gönder
</button>
<button onClick={() => calistir('sil', telegramSil, 'Telegram bağlantısı kaldırıldı.')} disabled={!!busy}
className="bg-white hover:bg-slate-50 disabled:opacity-40 text-slate-700 font-bold text-xs px-4 py-2 rounded-lg border border-slate-300 flex items-center gap-1.5">
<Unplug className="w-3.5 h-3.5" /> Bağlantıyı kaldır
</button>
</div>
) : (
<div className="bg-sky-50 border border-sky-200 rounded-xl p-4 space-y-3 text-xs text-slate-800">
<p className="font-bold">Bağlamak için 3 adım:</p>
<ol className="list-decimal pl-5 space-y-1.5 leading-relaxed">
<li>
Telegram'da botu açın:{' '}
{d.bot
? <a href={`https://t.me/${d.bot}`} target="_blank" rel="noopener noreferrer" className="font-bold text-sky-700 underline">@{d.bot}</a>
: <span className="font-bold">(bot adı alınamadı — yöneticiye sorun)</span>}
{' '}ve <strong>Başlat</strong>'a basın.
</li>
<li>Bota şu kodu yazıp gönderin: <code className="bg-white border border-sky-300 px-2 py-0.5 rounded font-mono text-sm font-black tracking-widest">{kod}</code></li>
<li>Sonra buraya dönüp <strong>Doğrula</strong>'ya basın.</li>
</ol>
<button onClick={() => calistir('bagla', () => telegramBagla(kod), 'Bağlandı! Telegram\'a onay mesajı gönderildi.')} disabled={!!busy}
className="bg-sky-600 hover:bg-sky-500 disabled:opacity-40 text-white font-bold text-xs px-5 py-2 rounded-lg flex items-center gap-1.5">
{busy === 'bagla' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle2 className="w-3.5 h-3.5" />} Doğrula
</button>
</div>
)}
{mesaj && (
<div className={`rounded-lg p-3 flex gap-2 text-xs ${mesaj.tur === 'ok' ? 'bg-emerald-50 border border-emerald-200 text-emerald-900' : 'bg-red-50 border border-red-200 text-red-800'}`}>
{mesaj.tur === 'ok' ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <AlertTriangle className="w-4 h-4 shrink-0" />}
<span>{mesaj.metin}</span>
</div>
)}
</div>
);
};