Files
ihaleist/src/components/tools/NotifySubscriptions.jsx
T
hikmettanandClaude Fable 5.1 df89eaac33 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>
2026-09-03 22:58:13 +03:00

275 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/**
* Otomatik e-posta bildirimi — KONU odaklı.
* Kullanıcı hangi konularda ihale alabildiğini seçer; il/kurum gibi daraltmalar
* isteğe bağlıdır (varsayılan: tüm Türkiye).
*/
export const NotifySubscriptions = () => {
const { authUser, favoriteKurumlar, userProfile, sektorListesi } = useApp();
const [abonelikler, setAbonelikler] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [konular, setKonular] = useState([]);
const [ozelKonu, setOzelKonu] = useState('');
const [gelismis, setGelismis] = useState(false);
const [daralt, setDaralt] = useState({ sehir: '', alimTuru: '', favoriKurumlar: false });
// Site tarama
const [site, setSite] = useState(userProfile?.website || '');
const [tarama, setTarama] = useState(null);
const [taraniyor, setTaraniyor] = useState(false);
const [taramaHata, setTaramaHata] = useState(null);
const yukle = () => {
if (!authUser) return;
fetch('/api/notify/subscriptions', { headers: { Authorization: `Bearer ${getToken()}` } })
.then((r) => r.json())
.then((d) => setAbonelikler(d.abonelikler || []))
.catch(() => setAbonelikler([]));
};
useEffect(yukle, [authUser]);
const konuEkle = (k) => {
const t = String(k || '').trim();
if (!t || konular.some((x) => x.toLocaleLowerCase('tr-TR') === t.toLocaleLowerCase('tr-TR'))) return;
setKonular([...konular, t]);
};
const konuCikar = (k) => setKonular(konular.filter((x) => x !== k));
const siteyiTara = async () => {
setTaraniyor(true); setTaramaHata(null); setTarama(null);
try {
const res = await fetch('/api/company/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ url: site }),
});
const d = await res.json();
if (!d.success) throw new Error(d.error);
setTarama(d);
} catch (e) {
setTaramaHata(e.message);
} finally { setTaraniyor(false); }
};
const kaydet = async () => {
if (!konular.length) { setError('En az bir konu seçin.'); return; }
setBusy(true); setError(null);
try {
const res = await fetch('/api/notify/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({
kriter: {
konular,
sehir: daralt.sehir,
alimTuru: daralt.alimTuru,
kurumlar: daralt.favoriKurumlar ? favoriteKurumlar : [],
},
}),
});
const d = await res.json();
if (!d.success) throw new Error(d.error);
setAbonelikler(d.abonelikler);
setKonular([]); setDaralt({ sehir: '', alimTuru: '', favoriKurumlar: false });
} catch (e) { setError(e.message); }
finally { setBusy(false); }
};
const sil = async (id) => {
setBusy(true);
try {
const res = await fetch(`/api/notify/subscriptions?id=${id}`, {
method: 'DELETE', headers: { Authorization: `Bearer ${getToken()}` },
});
const d = await res.json();
setAbonelikler(d.abonelikler || []);
} catch (e) { setError(e.message); }
finally { setBusy(false); }
};
const ozet = (a) => [
a.konular?.length ? a.konular.join(' · ') : null,
a.sehir && `📍 ${a.sehir}`,
a.alimTuru && `(${a.alimTuru})`,
a.kurumlar?.length && `${a.kurumlar.length} favori kurum`,
].filter(Boolean).join(' ') || 'Tüm ihaleler';
if (!authUser) return null;
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">
<BellRing className="w-4 h-4 text-emerald-600" /> Otomatik E-posta Bildirimi
</h3>
{abonelikler && (
<span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">
{abonelikler.length} kayıt
</span>
)}
</div>
<p className="text-xs text-slate-600 leading-relaxed">
Hangi <strong>konularda</strong> ihale alabiliyorsanız onları seçin — <strong>tüm Türkiye</strong> taranır,
yalnızca size uyan yeni ihaleler <strong>{authUser.email}</strong> adresine gelir. Gereksiz ilan gönderilmez.
</p>
{/* Mevcut kayıtlar */}
{abonelikler === null ? (
<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>
) : abonelikler.length > 0 && (
<div className="space-y-2">
{abonelikler.map((a) => (
<div key={a.id} className="flex items-start justify-between gap-2 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2">
<div className="min-w-0">
<p className="text-xs font-bold text-slate-800">{ozet(a)}</p>
<p className="text-[10px] text-slate-400 mt-0.5">
{a.sonGonderim ? `son gönderim: ${new Date(a.sonGonderim).toLocaleString('tr-TR')}` : 'ilk tur bekleniyor'}
</p>
</div>
<button onClick={() => sil(a.id)} disabled={busy} className="text-slate-400 hover:text-red-500 shrink-0 mt-0.5" title="Sil">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Site tarama → konu önerisi */}
<div className="bg-blue-50/60 border border-blue-200 rounded-xl p-3.5 space-y-2.5">
<p className="text-[11px] font-bold text-blue-900 flex items-center gap-1.5">
<Globe className="w-3.5 h-3.5" /> Sitenizi tarayıp konuları otomatik önerelim
</p>
<div className="flex gap-2">
<input value={site} onChange={(e) => setSite(e.target.value)}
placeholder="firmasitesi.com"
className="flex-1 bg-white border border-blue-200 rounded-lg px-2.5 py-2 text-xs" />
<button onClick={siteyiTara} disabled={taraniyor || !site.trim()}
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-bold text-xs px-3.5 rounded-lg flex items-center gap-1.5">
{taraniyor ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
{taraniyor ? 'Taranıyor…' : 'Tara'}
</button>
</div>
{taramaHata && <p className="text-[11px] text-red-700">⚠ {taramaHata}</p>}
{tarama && (
<div className="space-y-2">
<p className="text-[11px] text-blue-900">
<strong>{tarama.tarananSayfa} sayfa</strong> tarandı ({tarama.karakter.toLocaleString('tr-TR')} karakter).
{tarama.sonuc?.ozet && <> {tarama.sonuc.ozet}</>}
</p>
{oneriler.length > 0 && (
<>
<p className="text-[10px] text-blue-700 font-semibold">Önerilen konular — eklemek için tıklayın:</p>
<div className="flex flex-wrap gap-1.5">
{oneriler.map((k) => (
<button key={k} onClick={() => konuEkle(k)}
className="text-[11px] bg-white hover:bg-blue-100 border border-blue-300 text-blue-900 px-2 py-1 rounded-md font-medium">
+ {k}
</button>
))}
</div>
</>
)}
</div>
)}
</div>
{/* Seçilen konular */}
<div className="space-y-2">
<p className="text-[11px] font-bold text-slate-700 uppercase tracking-wide">Seçilen konular</p>
{konular.length === 0 ? (
<p className="text-[11px] text-slate-400">Henüz konu seçilmedi.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{konular.map((k) => (
<span key={k} className="text-[11px] bg-emerald-50 border border-emerald-300 text-emerald-900 px-2 py-1 rounded-md font-semibold flex items-center gap-1">
{k}
<button onClick={() => konuCikar(k)} className="text-emerald-700 hover:text-red-600"><X className="w-3 h-3" /></button>
</span>
))}
</div>
)}
<div className="flex gap-2 pt-1">
<input value={ozelKonu} onChange={(e) => setOzelKonu(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { konuEkle(ozelKonu); setOzelKonu(''); } }}
placeholder="Kendi konunuzu yazın ve Enter'a basın"
className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs" />
<button onClick={() => { konuEkle(ozelKonu); setOzelKonu(''); }}
className="bg-slate-800 hover:bg-slate-700 text-white font-bold text-xs px-3 rounded-lg">Ekle</button>
</div>
<p className="text-[10px] text-slate-400 pt-1">Hazır sektörler:</p>
<div className="flex flex-wrap gap-1.5">
{sektorListesi.map((s) => (
<button key={s} onClick={() => konuEkle(s)}
className="text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 px-2 py-1 rounded-md">
+ {s}
</button>
))}
</div>
</div>
{/* Gelişmiş daraltma (isteğe bağlı) */}
<div>
<button onClick={() => setGelismis(!gelismis)} className="text-[11px] font-bold text-slate-500 hover:text-slate-800 flex items-center gap-1">
{gelismis ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Daha da daralt (isteğe bağlı) — varsayılan: tüm Türkiye
</button>
{gelismis && (
<div className="grid grid-cols-2 gap-2 mt-2">
<input value={daralt.sehir} onChange={(e) => setDaralt({ ...daralt, sehir: e.target.value })}
placeholder="Yalnızca bu il" className="bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs" />
<input value={daralt.alimTuru} onChange={(e) => setDaralt({ ...daralt, alimTuru: e.target.value })}
placeholder="Alım türü (ör. Doğrudan Temin)" className="bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs" />
{favoriteKurumlar?.length > 0 && (
<label className="col-span-2 flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
<input type="checkbox" checked={daralt.favoriKurumlar}
onChange={(e) => setDaralt({ ...daralt, favoriKurumlar: e.target.checked })}
className="w-3.5 h-3.5 accent-emerald-600 rounded" />
Yalnızca favori kurumlarım ({favoriteKurumlar.length})
</label>
)}
</div>
)}
</div>
<button onClick={kaydet} disabled={busy || !konular.length}
className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white font-bold text-xs py-2.5 rounded-xl flex items-center justify-center gap-2">
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
Bildirimi Kaydet
</button>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
<p className="text-[11px] text-red-800">{error}</p>
</div>
)}
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3 flex gap-2">
<Info className="w-3.5 h-3.5 text-slate-500 shrink-0 mt-0.5" />
<p className="text-[11px] text-slate-600 leading-relaxed">
İlk kayıtta mevcut ihaleler <strong>bildirilmez</strong> — yalnızca temel alınır, sonrasında çıkan
yeni ihaleler gönderilir. Aynı ihale iki kez gönderilmez; sonuçlanmış arşiv kayıtları bildirime girmez.
</p>
</div>
</div>
</>
);
};