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>
275 lines
12 KiB
React
275 lines
12 KiB
React
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>
|
||
</>
|
||
);
|
||
};
|