+
+
+ Telegram Bildirimi
+
+ {d.bagli
+ ? bağlı{d.ad ? ` · ${d.ad}` : ''}
+ : bağlı değil}
+
+
+
+ Yeni ihale özetleri, takipteki ihalede zeyilname/düzeltme ilanı ve sonuç
+ ("X firması Y'ye aldı, %Z kırım") e-postanın yanında Telegram'a da gelir.
+
+
+ {!d.yapilandirilmis ? (
+
+
Sunucuda Telegram botu henüz tanımlı değil. Kurulum (yönetici, bir kez):
+
+ - Telegram'da @BotFather'a yazın:
/newbot → bir ad ve …bot ile biten kullanıcı adı verin.
+ - BotFather'ın verdiği tokenı Coolify → ihaleist → Environment'a
TELEGRAM_BOT_TOKEN olarak ekleyin, Redeploy.
+ - Bu sayfa yeniden açıldığında aşağıdaki bağlama adımları görünür.
+
+
Token yalnızca sunucuda durur; uygulamaya girilmez.
+
+ ) : d.bagli ? (
+
+
+
+
+ ) : (
+
+
Bağlamak için 3 adım:
+
+ -
+ Telegram'da botu açın:{' '}
+ {d.bot
+ ? @{d.bot}
+ : (bot adı alınamadı — yöneticiye sorun)}
+ {' '}ve Başlat'a basın.
+
+ - Bota şu kodu yazıp gönderin:
{kod}
+ - Sonra buraya dönüp Doğrula'ya basın.
+
+
+
+ )}
+
+ {mesaj && (
+
+ {mesaj.tur === 'ok' ?
:
}
+
{mesaj.metin}
+
+ )}
+
+ );
+};
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx
index f7ad0ba..15824b4 100644
--- a/src/context/AppContext.jsx
+++ b/src/context/AppContext.jsx
@@ -4,7 +4,7 @@ import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
import { getToken } from '../services/auth';
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
-import { getAllUserData, setUserData, getPlan } from '../services/account';
+import { getAllUserData, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account';
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
const AppContext = createContext();
@@ -563,6 +563,88 @@ export const AppProvider = ({ children }) => {
}));
};
+ // Takipteki İKN'ler sunucuya yazılır: zeyilname/düzeltme ilanı ve sonuç
+ // bildirimi sunucudaki zamanlayıcıda üretiliyor, tarayıcı kapalıyken de.
+ const takipIknler = useMemo(
+ () => tenders.filter((t) => t.takipte || t.gorev).map((t) => t.ikn).filter(Boolean).sort(),
+ [tenders],
+ );
+ const takipImza = takipIknler.join('|');
+ useEffect(() => {
+ if (veriSenkron) sunucuyaYaz('takip', takipIknler);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [takipImza, veriSenkron]);
+
+ /**
+ * İhaleden hazırlık görevi: teslim tarihi ihale saatidir, panoya "Teklif
+ * 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 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 } };
+ }));
+ };
+
+ // Sunucu bildirimleri (zeyilname / sonuç): 5 dk'da bir çek, yenileri göster
+ useEffect(() => {
+ if (!veriSenkron) return;
+ let iptal = false;
+ const cek = async () => {
+ try {
+ const liste = await bildirimleriGetir();
+ const yeni = liste.filter((b) => !b.okundu);
+ if (iptal || !yeni.length) return;
+ for (const b of yeni.slice(0, 5)) notify(b.baslik, b.metin);
+ await bildirimOkundu(yeni.map((b) => b.id));
+ } catch { /* sessiz */ }
+ };
+ cek();
+ const z = setInterval(cek, 5 * 60 * 1000);
+ return () => { iptal = true; clearInterval(z); };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [veriSenkron]);
+
+ // Şirket belgesi süre uyarısı: süresi dolmuş / 30 gün içinde dolacak
+ // belgeler için günde bir kez bildirim (kimse teklif hazırlanana kadar
+ // fark etmiyor — fikir: MOBIT-DURT).
+ useEffect(() => {
+ if (!veriSenkron) return;
+ const bugun = new Date().toISOString().slice(0, 10);
+ try { if (localStorage.getItem('ihaleist_belge_uyari_gunu') === bugun) return; } catch { /* yoksay */ }
+ listDocuments().then((d) => {
+ const simdi = Date.now();
+ const dolan = [], yakin = [];
+ for (const b of d?.belgeler || []) {
+ if (!b.son_kullanma) continue;
+ const kalan = Math.ceil((new Date(b.son_kullanma).getTime() - simdi) / 86400000);
+ if (kalan < 0) dolan.push(b.ad); else if (kalan <= 30) yakin.push(`${b.ad} (${kalan} gün)`);
+ }
+ if (dolan.length) notify(`${dolan.length} belgenin süresi dolmuş`, dolan.slice(0, 4).join(', '));
+ if (yakin.length) notify(`${yakin.length} belgenin süresi 30 gün içinde doluyor`, yakin.slice(0, 4).join(', '));
+ try { localStorage.setItem('ihaleist_belge_uyari_gunu', bugun); } catch { /* yoksay */ }
+ }).catch(() => {});
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [veriSenkron]);
+
const updateKanbanStatus = (tenderId, newStatus, assignedUser = null) => {
setTenders(prev => prev.map(t => {
if (t.id === tenderId) {
@@ -695,6 +777,8 @@ export const AppProvider = ({ children }) => {
removeTeamMember,
toggleTrackTender,
updateKanbanStatus,
+ gorevAc,
+ gorevKontrol,
clearAllFilters,
plan,
planYenile,
diff --git a/src/services/account.js b/src/services/account.js
index 555d737..cdeae5e 100644
--- a/src/services/account.js
+++ b/src/services/account.js
@@ -88,3 +88,17 @@ export function formatBayt(b) {
// --- Abonelik iptali ---
export const cancelSubscription = (neden, aciklama) =>
iste('/api/billing/cancel', { method: 'POST', body: JSON.stringify({ neden, aciklama }) });
+
+// --- Belge süre takibi ---
+export const updateDocumentMeta = (id, { sonKullanma, kategori } = {}) =>
+ iste('/api/me/documents/meta', { method: 'PATCH', body: JSON.stringify({ id, sonKullanma, kategori }) });
+
+// --- Telegram bağlantısı ---
+export const telegramDurum = () => iste('/api/me/telegram');
+export const telegramBagla = (kod) => iste('/api/me/telegram', { method: 'POST', body: JSON.stringify({ kod }) });
+export const telegramTest = () => iste('/api/me/telegram', { method: 'POST', body: JSON.stringify({ islem: 'test' }) });
+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 }) });
diff --git a/src/services/pdfText.js b/src/services/pdfText.js
index f825cd2..d58a1a7 100644
--- a/src/services/pdfText.js
+++ b/src/services/pdfText.js
@@ -28,8 +28,47 @@ export async function extractPlainText(file) {
return { text: text.trim(), pageCount: 1, usedPages: 1 };
}
-export async function extractText(file) {
+/**
+ * Taranmış (görüntü) PDF için OCR — tesseract.js, Türkçe dil paketi,
+ * tarayıcıda. Ölçüldü: sentetik teklif cetvelinde güven 87, sayfa başı
+ * ~0,3 sn (Node). Yalnızca metin katmanı boşsa devreye girer; ilk 8 sayfa.
+ */
+export async function ocrPdfText(file, { maxPages = 8, onProgress } = {}) {
+ const pdfjs = await import('pdfjs-dist');
+ const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
+ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
+ const { createWorker } = await import('tesseract.js');
+ const doc = await pdfjs.getDocument({ data: await file.arrayBuffer() }).promise;
+ const pages = Math.min(doc.numPages, maxPages);
+ const worker = await createWorker('tur');
+ const parts = [];
+ try {
+ for (let i = 1; i <= pages; i++) {
+ onProgress?.({ sayfa: i, toplam: pages });
+ const page = await doc.getPage(i);
+ const viewport = page.getViewport({ scale: 2 }); // ~150 dpi: OCR için yeterli, hızlı
+ const canvas = document.createElement('canvas');
+ canvas.width = viewport.width; canvas.height = viewport.height;
+ await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
+ const { data } = await worker.recognize(canvas);
+ parts.push(data.text);
+ }
+ } finally {
+ await worker.terminate();
+ }
+ const text = parts.join('\n\n').replace(/[ \t]+/g, ' ').trim();
+ return { text, pageCount: doc.numPages, usedPages: pages, ocr: true };
+}
+
+export async function extractText(file, { ocr = true, onProgress } = {}) {
const name = (file.name || '').toLowerCase();
- if (name.endsWith('.pdf') || file.type === 'application/pdf') return extractPdfText(file);
+ if (name.endsWith('.pdf') || file.type === 'application/pdf') {
+ const r = await extractPdfText(file);
+ // Metin katmanı yoksa taranmış PDF'tir — OCR'a düş
+ if (ocr && (r.text || '').replace(/\s/g, '').length < 100) {
+ try { return await ocrPdfText(file, { onProgress }); } catch { return r; }
+ }
+ return r;
+ }
return extractPlainText(file);
}