feat: geçmiş veri motoru + gerçek AI + KİK kararları + TED + kırmızı bayrak + planlar

Roadmap'in 6 maddesi de gerçek çalışır halde:

1) Geçmiş veri motoru (EKAP v2)
   - server/ekap-v2.mjs: AES-256-CBC imzalı EKAP v2 istemcisi (zero-dep),
     ihale arama + sonuç ilanı parse (kazanan firma, sözleşme bedeli,
     yaklaşık maliyet, teklif sayısı), TR firma adı normalize
   - server/history-store.mjs: SQLite deposu (IKN+kısım anahtarlı, upsert)
   - server/ekap-scan.mjs: tarih aralıklı artımlı tarayıcı (6 paralel)
   - server/scheduler.mjs: sunucu içi periyodik tarama (SCAN_* env ile)
   - /api/tenders artık geçmiş sonuçları da birleştiriyor → kazanan firma,
     rakip ve kırmızı bayrak analizleri gerçek veriyle doluyor

2) Gerçek AI şartname analizi
   - server/ai.mjs: 6 sağlayıcılı fallback zinciri (GLM/DeepSeek/Gemini/
     Grok/GPT/Claude), JSON şemalı çıktı, uydurma yasak promptu
   - pdfjs-dist ile tarayıcıda PDF metin çıkarma (dinamik import)
   - AiSpecAnalyzerModal: sahte sonuç kaldırıldı, gerçek dosya + dürüst hata

3) KİK kurul kararları
   - server/kik.mjs: EKAP v2 JSON API (Sec-1..6 imzalı), uyuşmazlık/
     düzenleyici/mahkeme; karar tam metni (ayrı AES anahtarıyla URL)
   - KikDecisions sekmesi: arama + takipteki idarelerden hızlı öneri

4) Fiyatlandırma + ödeme
   - server/billing.mjs: Stripe Checkout (zero-dep, form-encoded)
   - Pricing sekmesi: 3 plan, aylık/yıllık, yapılandırılmadıysa dürüst uyarı

5) TED (AB ihaleleri)
   - server/ted.mjs: anonim TED v3 API, 3 mod (açık/tüm TR/AB'de kazanan TR)
   - rate-limit korumalı (429 HTML gövdesi ele alınır), çok dilli alan çözümü

6) Kırmızı bayrak & şeffaflık
   - analytics.js: redFlags (tek teklif, düşük kırım, tahmin üstü bedel,
     aşırı düşük teklif, tedarikçi yoğunlaşması) + marketConcentration (HHI)
   - RedFlags sekmesi: seviyeli uyarılar + yöntem şeffaflığı notu
This commit is contained in:
2026-08-28 10:37:50 +03:00
parent 3e129c2988
commit 168052c267
26 changed files with 3121 additions and 219 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
{ {
"name": "ihaleist-dev", "name": "ihaleist-dev",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev", "--", "--port", "3005", "--strictPort"],
"port": 3000 "port": 3005
} }
] ]
} }
+6
View File
@@ -18,6 +18,12 @@ COPY --from=build /app/nginx.conf /etc/nginx/http.d/default.conf
WORKDIR /app WORKDIR /app
COPY --from=build /app/server ./server COPY --from=build /app/server ./server
# Geçmiş sonuç veritabanı (SQLite). Deploy'lar arası kalıcılık için Coolify'da
# /app/server/data yoluna bir volume bağlanmalı; bağlanmazsa tarayıcı veriyi
# her yeniden dağıtımdan sonra yeniden biriktirir.
RUN mkdir -p /app/server/data
VOLUME ["/app/server/data"]
# Başlangıç: Node backend'i (sabit 3001) arka planda + nginx (80) ön planda. # Başlangıç: Node backend'i (sabit 3001) arka planda + nginx (80) ön planda.
# Coolify container'a PORT=80 verir; backend'i 3001'e sabitleyip nginx ile # Coolify container'a PORT=80 verir; backend'i 3001'e sabitleyip nginx ile
# çakışmasını önlüyoruz (nginx /api → 127.0.0.1:3001 proxy'ler). # çakışmasını önlüyoruz (nginx /api → 127.0.0.1:3001 proxy'ler).
+278
View File
@@ -11,6 +11,7 @@
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"pdfjs-dist": "^6.2.108",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.15.0" "recharts": "^2.15.0"
@@ -808,6 +809,271 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.8.tgz",
"integrity": "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.8",
"@napi-rs/canvas-darwin-arm64": "1.0.8",
"@napi-rs/canvas-darwin-x64": "1.0.8",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.8",
"@napi-rs/canvas-linux-arm64-musl": "1.0.8",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.8",
"@napi-rs/canvas-linux-x64-gnu": "1.0.8",
"@napi-rs/canvas-linux-x64-musl": "1.0.8",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.8",
"@napi-rs/canvas-win32-x64-msvc": "1.0.8"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.8.tgz",
"integrity": "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.8.tgz",
"integrity": "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.8.tgz",
"integrity": "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.8.tgz",
"integrity": "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.8.tgz",
"integrity": "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.8.tgz",
"integrity": "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.8.tgz",
"integrity": "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.8.tgz",
"integrity": "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.8.tgz",
"integrity": "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.8.tgz",
"integrity": "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.8.tgz",
"integrity": "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -2580,6 +2846,18 @@
], ],
"license": "(MIT AND Zlib)" "license": "(MIT AND Zlib)"
}, },
"node_modules/pdfjs-dist": {
"version": "6.2.108",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/performance-now": { "node_modules/performance-now": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+1
View File
@@ -13,6 +13,7 @@
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"pdfjs-dist": "^6.2.108",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.15.0" "recharts": "^2.15.0"
+206
View File
@@ -0,0 +1,206 @@
/**
* Gerçek AI şartname analizi — çoklu sağlayıcı + fallback zinciri.
* Bağımlılık yok (Node 18+ built-in fetch).
*
* Env (hangisi varsa o sağlayıcı kullanılabilir olur):
* ZAI_API_KEY → GLM (Z.ai)
* DEEPSEEK_API_KEY → DeepSeek
* GEMINI_API_KEY → Gemini (Google)
* XAI_API_KEY → Grok (xAI)
* OPENAI_API_KEY → GPT (OpenAI)
* ANTHROPIC_API_KEY → Claude (Anthropic)
*
* Model kimlikleri env ile değiştirilebilir (ör. GLM_MODEL=glm-5.3).
*/
export const PROVIDERS = {
glm: {
label: 'GLM', provider: 'Z.ai', envKey: 'ZAI_API_KEY',
model: () => process.env.GLM_MODEL || 'glm-5.3',
url: 'https://api.z.ai/api/paas/v4/chat/completions', style: 'openai',
},
deepseek: {
label: 'DeepSeek', provider: 'DeepSeek', envKey: 'DEEPSEEK_API_KEY',
model: () => process.env.DEEPSEEK_MODEL || 'deepseek-chat',
url: 'https://api.deepseek.com/chat/completions', style: 'openai',
},
gemini: {
label: 'Gemini', provider: 'Google', envKey: 'GEMINI_API_KEY',
model: () => process.env.GEMINI_MODEL || 'gemini-flash-latest',
style: 'gemini',
},
grok: {
label: 'Grok', provider: 'xAI', envKey: 'XAI_API_KEY',
model: () => process.env.XAI_MODEL || 'grok-4',
url: 'https://api.x.ai/v1/chat/completions', style: 'openai',
},
gpt: {
label: 'GPT', provider: 'OpenAI', envKey: 'OPENAI_API_KEY',
model: () => process.env.OPENAI_MODEL || 'gpt-5.2',
url: 'https://api.openai.com/v1/chat/completions', style: 'openai',
},
claude: {
label: 'Claude', provider: 'Anthropic', envKey: 'ANTHROPIC_API_KEY',
model: () => process.env.ANTHROPIC_MODEL || 'claude-sonnet-5',
url: 'https://api.anthropic.com/v1/messages', style: 'anthropic',
},
};
export function availableProviders() {
return Object.entries(PROVIDERS)
.filter(([, p]) => Boolean(process.env[p.envKey]))
.map(([id, p]) => ({ id, label: p.label, provider: p.provider, model: p.model() }));
}
export function aiConfigured() {
return availableProviders().length > 0;
}
const SYSTEM_PROMPT = `Sen Türkiye kamu ihale mevzuatı (4734/4735 sayılı kanunlar) konusunda uzman bir ihale danışmanısın.
Sana verilen teknik şartname / ihale ilanı metnini analiz et.
YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema:
{
"summary": "şartnamenin 2-3 cümlelik özeti",
"qualificationCriteria": ["istenen yeterlik belgeleri, madde madde"],
"criticalRequirements": ["dikkat edilmesi gereken kritik teknik/idari şartlar"],
"risksAndPenalties": [{"level":"Yüksek|Orta|Düşük","clause":"madde no veya bölüm","text":"risk açıklaması"}],
"bidAnalysis": {
"estimatedCost": sayı_veya_null,
"recommendedBid": sayı_veya_null,
"minBidLimit": sayı_veya_null,
"maxBidLimit": sayı_veya_null,
"winProbability": 0-100_arası_sayı_veya_null,
"competitionLevel": "rekabet seviyesi yorumu",
"notes": "teklif stratejisi notu"
}
}
Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.`;
function buildUserPrompt({ text, fileName, tender }) {
const parts = [];
if (fileName) parts.push(`Dosya: ${fileName}`);
if (tender) {
parts.push(`İhale başlığı: ${tender.baslik || '-'}`);
parts.push(`İdare: ${tender.kurum || '-'}`);
parts.push(`Şehir: ${tender.sehir || '-'}`);
parts.push(`Usul: ${tender.usul || tender.alimTuru || '-'}`);
if (tender.yaklasikMaliyet) parts.push(`Yaklaşık maliyet: ${tender.yaklasikMaliyet}`);
if (Array.isArray(tender.items) && tender.items.length) {
parts.push('Kalemler: ' + tender.items.map((i) => `${i.ad} (${i.miktar} ${i.birim})`).join('; '));
}
}
parts.push('\n--- ŞARTNAME / İLAN METNİ ---\n' + String(text || '').slice(0, 60000));
return parts.join('\n');
}
async function callOpenAiStyle(p, key, userPrompt, signal) {
const res = await fetch(p.url, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: p.model(),
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
temperature: 0.2,
max_tokens: 4000,
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.choices?.[0]?.message?.content || '';
}
async function callGemini(p, key, userPrompt, signal) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${p.model()}:generateContent?key=${encodeURIComponent(key)}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: 4000, responseMimeType: 'application/json' },
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.candidates?.[0]?.content?.parts?.map((x) => x.text).join('') || '';
}
async function callAnthropic(p, key, userPrompt, signal) {
const res = await fetch(p.url, {
method: 'POST',
headers: {
'x-api-key': key,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: p.model(),
max_tokens: 4000,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }],
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.content?.map((c) => c.text || '').join('') || '';
}
function extractJson(raw) {
if (!raw) throw new Error('Model boş yanıt döndürdü');
let s = String(raw).trim();
// ```json ... ``` sarmalını temizle
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) s = fence[1].trim();
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start === -1 || end === -1) throw new Error('Yanıtta JSON bulunamadı');
return JSON.parse(s.slice(start, end + 1));
}
/**
* Zinciri sırayla dener; ilk başarılı sonucu döndürür.
* @returns {{result:object, model:object, attempts:Array}}
*/
export async function analyzeSpec({ text, fileName, tender, chain }) {
const order = (Array.isArray(chain) && chain.length ? chain : ['glm', 'deepseek', 'gemini', 'grok', 'gpt', 'claude'])
.filter((id) => PROVIDERS[id]);
const userPrompt = buildUserPrompt({ text, fileName, tender });
const attempts = [];
for (const id of order) {
const p = PROVIDERS[id];
const key = process.env[p.envKey];
if (!key) {
attempts.push({ id, ok: false, error: `${p.envKey} tanımlı değil` });
continue;
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 90000);
try {
let raw;
if (p.style === 'gemini') raw = await callGemini(p, key, userPrompt, ac.signal);
else if (p.style === 'anthropic') raw = await callAnthropic(p, key, userPrompt, ac.signal);
else raw = await callOpenAiStyle(p, key, userPrompt, ac.signal);
const result = extractJson(raw);
attempts.push({ id, ok: true });
return {
result,
model: { id, label: p.label, provider: p.provider, model: p.model() },
attempts,
};
} catch (e) {
attempts.push({ id, ok: false, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message });
} finally {
clearTimeout(timer);
}
}
const err = new Error('Tüm AI sağlayıcıları başarısız oldu: ' + attempts.map((a) => `${a.id}(${a.error})`).join(', '));
err.attempts = attempts;
throw err;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Abonelik / ödeme — Stripe Checkout (zero-dependency, form-encoded REST).
*
* Env:
* STRIPE_SECRET_KEY — sk_live_... / sk_test_...
* STRIPE_PRICE_PRO — Pro planın Stripe Price ID'si (price_...)
* STRIPE_PRICE_KURUMSAL — Kurumsal planın Price ID'si
* APP_URL — başarı/iptal dönüş adresi (örn. https://ihaleist.seymata.com)
*
* Not: Abonelik hakkının kalıcı tanınması için Stripe webhook'u + kullanıcı
* kaydı gerekir (bkz. /api/billing/webhook). Anahtar yoksa uçlar "yapılandırılmadı"
* döner; uygulama ücretsiz katmanda çalışmaya devam eder.
*/
export const PLANS = [
{
id: 'ucretsiz',
ad: 'Ücretsiz',
fiyat: 0,
periyot: 'ay',
ozet: 'İhaleleri keşfedin, takibe alın.',
ozellikler: [
'Canlı ihale + doğrudan temin listesi',
'Gelişmiş arama ve filtreler',
'Favori kurumlar',
'10 ihale takibi',
'Tarayıcı bildirimleri',
],
sinirlar: { takip: 10, ekip: 1 },
},
{
id: 'pro',
ad: 'Pro',
fiyat: 899,
periyot: 'ay',
vurgu: true,
rozet: 'En popüler',
ozet: 'Teklif hazırlayan firmalar için tam analiz.',
ozellikler: [
'Ücretsiz plandaki her şey',
'Sınırsız ihale takibi',
'Kanban süreç yönetimi',
'Kazanan firma & rakip analizi',
'Kalem bazlı fiyat analizi',
'AI şartname analizi',
'PDF teklif cetveli (kaşe/imza)',
'E-posta bildirimleri',
'5 ekip üyesi',
],
sinirlar: { takip: null, ekip: 5 },
priceEnv: 'STRIPE_PRICE_PRO',
},
{
id: 'kurumsal',
ad: 'Kurumsal',
fiyat: 2490,
periyot: 'ay',
ozet: 'Çok kullanıcılı ekipler ve entegrasyon.',
ozellikler: [
'Pro plandaki her şey',
'Sınırsız ekip üyesi',
'Kırmızı bayrak & şeffaflık analizi',
'KİK kurul kararı eşleştirme',
'AB ihaleleri (TED) katmanı',
'API erişimi & webhook',
'Öncelikli destek',
],
sinirlar: { takip: null, ekip: null },
priceEnv: 'STRIPE_PRICE_KURUMSAL',
},
];
export function billingConfigured() {
return Boolean(process.env.STRIPE_SECRET_KEY);
}
export function planCatalog() {
return PLANS.map((p) => ({
...p,
satinAlinabilir: Boolean(p.priceEnv && process.env[p.priceEnv] && billingConfigured()),
}));
}
function formEncode(obj) {
const parts = [];
const walk = (prefix, val) => {
if (val === undefined || val === null) return;
if (Array.isArray(val)) val.forEach((v, i) => walk(`${prefix}[${i}]`, v));
else if (typeof val === 'object') Object.entries(val).forEach(([k, v]) => walk(`${prefix}[${k}]`, v));
else parts.push(`${encodeURIComponent(prefix)}=${encodeURIComponent(String(val))}`);
};
Object.entries(obj).forEach(([k, v]) => walk(k, v));
return parts.join('&');
}
export async function createCheckoutSession({ planId, email }) {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error('Ödeme yapılandırılmamış (STRIPE_SECRET_KEY yok)');
const plan = PLANS.find((p) => p.id === planId);
if (!plan || !plan.priceEnv) throw new Error('Geçersiz plan');
const price = process.env[plan.priceEnv];
if (!price) throw new Error(`${plan.ad} planı için ${plan.priceEnv} tanımlı değil`);
const appUrl = process.env.APP_URL || 'https://ihaleist.seymata.com';
const body = formEncode({
mode: 'subscription',
'line_items[0][price]': price,
'line_items[0][quantity]': 1,
success_url: `${appUrl}/?odeme=basarili&plan=${planId}`,
cancel_url: `${appUrl}/?odeme=iptal`,
customer_email: email,
allow_promotion_codes: 'true',
'metadata[planId]': planId,
'metadata[email]': email || '',
});
const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `Stripe hatası: HTTP ${res.status}`);
return { url: data.url, id: data.id };
}
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* EKAP geçmiş sonuç tarayıcısı — kazanan firma + sözleşme bedeli + teklif sayısı biriktirir.
*
* Kullanım:
* node server/ekap-scan.mjs --gun 7 # son 7 günün sonuçlananları
* node server/ekap-scan.mjs --bas 2026-01-01 --son 2026-06-30
* node server/ekap-scan.mjs --gun 1 --limit 200 # cron için (günlük artımlı)
*
* Notlar:
* - EKAP arama indeksi sonuç ilanlarını kapsamaz; bu yüzden sonuçlanmış ihaleler
* sayfalanır ve HER ihalenin ilanları ayrıca çekilir (N+1 kaçınılmaz).
* - Eşzamanlılık 6 (repo referansı 8) — EKAP'ı yormamak için.
* - Zaten kayıtlı IKN'ler atlanır, böylece tekrar çalıştırmak ucuzdur.
*/
import {
searchTenders, fetchResults, normalizeCompanyName, DURUM_SONUC_ILANI,
} from './ekap-v2.mjs';
import { openDb, saveResults, hasIkn, logScan, available } from './history-store.mjs';
const args = process.argv.slice(2);
const arg = (n, d = null) => {
const i = args.indexOf(`--${n}`);
return i >= 0 && args[i + 1] ? args[i + 1] : d;
};
const has = (n) => args.includes(`--${n}`);
const iso = (d) => d.toISOString().slice(0, 10);
const gun = Number(arg('gun', 0));
let bas = arg('bas');
let son = arg('son');
if (!bas || !son) {
const bugun = new Date();
const geri = new Date(bugun.getTime() - (gun || 7) * 86400000);
bas = bas || iso(geri);
son = son || iso(bugun);
}
const LIMIT = Number(arg('limit', 500));
const SAYFA = 50;
const ESZAMANLI = Number(arg('paralel', 6));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function havuz(items, worker, n) {
const sonuc = [];
let i = 0;
const calisanlar = Array.from({ length: Math.min(n, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
try { sonuc.push(await worker(items[idx])); }
catch (e) { sonuc.push({ hata: e.message }); }
}
});
await Promise.all(calisanlar);
return sonuc;
}
async function main() {
if (!available()) {
console.error('[ekap-scan] node:sqlite yok — bu Node sürümünde tarama yapılamaz.');
process.exit(1);
}
const db = openDb({ create: true });
const baslangic = new Date().toISOString();
console.log(`[ekap-scan] aralık ${bas} → ${son}, limit ${LIMIT}, paralel ${ESZAMANLI}`);
let skip = 0;
let taranan = 0;
let eklenen = 0;
let hata = 0;
let toplamHavuz = null;
while (taranan < LIMIT) {
let sayfa;
try {
sayfa = await searchTenders({
ihaleDurumIdList: [DURUM_SONUC_ILANI],
ihaleTarihBaslangic: bas,
ihaleTarihBitis: son,
orderBy: 'ihaleTarihi',
siralamaTipi: 'desc',
skip,
take: Math.min(SAYFA, LIMIT - taranan),
});
} catch (e) {
console.error('[ekap-scan] arama hatası:', e.message);
hata++;
break;
}
if (toplamHavuz == null) {
toplamHavuz = sayfa.totalCount;
console.log(`[ekap-scan] aralıkta ${toplamHavuz} sonuçlanmış ihale bulundu`);
}
if (!sayfa.list.length) break;
// Zaten kayıtlı olanları atla (IKN kalıcı anahtar)
const yeni = sayfa.list.filter((t) => !hasIkn(db, t.ikn));
const atlanan = sayfa.list.length - yeni.length;
const sonuclar = await havuz(yeni, async (t) => {
const rs = await fetchResults(t.id);
if (!rs.length) return { bos: true };
for (const r of rs) r.kazananNorm = normalizeCompanyName(r.kazananFirma);
const n = saveResults(db, t, rs);
return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma };
}, ESZAMANLI);
for (const s of sonuclar) {
if (s?.hata) hata++;
else if (s?.kayit) eklenen += s.kayit;
}
taranan += sayfa.list.length;
skip += sayfa.list.length;
console.log(`[ekap-scan] ${taranan}/${Math.min(LIMIT, toplamHavuz)} tarandı · ${eklenen} kayıt · ${atlanan} atlandı · ${hata} hata`);
if (skip >= toplamHavuz) break;
await sleep(300); // EKAP'ı yormamak için nazik ara
}
logScan(db, {
baslangic, bitis: new Date().toISOString(),
aralikBas: bas, aralikSon: son, taranan, eklenen, hata,
not: `havuz=${toplamHavuz}`,
});
db.close();
console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı yazıldı, ${hata} hata.`);
}
main().catch((e) => { console.error('[ekap-scan] ölümcül hata:', e); process.exit(1); });
+235
View File
@@ -0,0 +1,235 @@
/**
* EKAP v2 API istemcisi — ihale arama + sonuç ilanı (kazanan firma, sözleşme bedeli).
* Zero-dependency: node:crypto + built-in fetch.
*
* Güvenlik başlıkları AES-256-CBC ile üretilir. Anahtar EKAP tarafından
* dönemsel olarak değiştirilir; değişirse EKAP_R8FACT_KEY env'i ile geçilebilir.
* (Yeni anahtar ekapv2.kik.gov.tr/ekap/search konsolunda environment.r8fact içinde.)
*
* Sunucu hata kodları: 1200 = başlık eksik, 1202 = timestamp penceresi dışı (±30sn),
* 1204 = guid uyuşmuyor.
*/
import crypto from 'node:crypto';
const BASE = 'https://ekapv2.kik.gov.tr';
const DEFAULT_KEY = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g';
function aesKey() {
return Buffer.from(process.env.EKAP_R8FACT_KEY || DEFAULT_KEY, 'utf8');
}
function aesB64(plain, iv) {
const c = crypto.createCipheriv('aes-256-cbc', aesKey(), iv);
return Buffer.concat([c.update(plain, 'utf8'), c.final()]).toString('base64');
}
// Her istekte yeniden üretilmeli (timestamp penceresi ±30 sn)
export function securityHeaders() {
const guid = crypto.randomUUID();
const iv = crypto.randomBytes(16); // Sec-1 ve Sec-4 AYNI IV'yi kullanmalı
const ts = String(Date.now()); // milisaniye zorunlu
return {
'X-Ekap-Sec-1': aesB64(guid, iv),
'X-Ekap-Sec-2': iv.toString('base64'),
'X-Ekap-Sec-3': guid,
'X-Ekap-Sec-4': aesB64(ts, iv),
};
}
const BASE_HEADERS = {
Accept: 'application/json',
'Accept-Language': 'tr',
'Content-Type': 'application/json',
Origin: BASE,
Referer: `${BASE}/ekap/search`,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
'api-version': 'v1',
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}) {
let lastErr;
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(BASE + path, {
method: 'POST',
headers: { ...BASE_HEADERS, ...securityHeaders() },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
const raw = await res.text();
if (!res.ok) {
// 401/1202 = timestamp kayması → yeni başlıkla tekrar dene
if ((res.status === 401 || res.status >= 500) && i < retries) {
await sleep(500 * (i + 1));
continue;
}
throw new Error(`EKAP ${res.status}: ${raw.slice(0, 200)}`);
}
return JSON.parse(raw);
} catch (e) {
lastErr = e;
if (i >= retries) break;
await sleep(500 * (i + 1));
}
}
throw lastErr;
}
// --- İhale arama ---------------------------------------------------------
// EKAP ihale durumu: 15 = Sonuç İlanı Yayımlanmış
export const DURUM_SONUC_ILANI = 15;
// İlan tipi: 1=Ön İlan 2=İhale İlanı 3=İptal 4=Sonuç İlanı 5=Ön Yeterlik 6=Düzeltme
export const ILAN_TIP_SONUC = '4';
function searchBody(opts = {}) {
return {
searchText: opts.searchText || '',
filterType: null,
ikNdeAra: true, ihaleAdindaAra: true, ihaleIlanindaAra: true,
teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true,
isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true,
ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true,
searchType: 'GirdigimGibi',
iknYili: opts.iknYili ?? null,
iknSayi: opts.iknSayi ?? null,
ihaleTarihSaatBaslangic: opts.ihaleTarihBaslangic ?? null, // "YYYY-MM-DD"
ihaleTarihSaatBitis: opts.ihaleTarihBitis ?? null,
ilanTarihSaatBaslangic: opts.ilanTarihBaslangic ?? null,
ilanTarihSaatBitis: opts.ilanTarihBitis ?? null,
yasaKapsami4734List: [],
ihaleTuruIdList: opts.ihaleTuruIdList || [],
ihaleUsulIdList: [], ihaleUsulAltIdList: [],
ihaleIlIdList: opts.ihaleIlIdList || [], // EKAP il id (245-325) — PLAKA DEĞİL
ihaleDurumIdList: opts.ihaleDurumIdList || [],
idareIdList: opts.idareIdList || [],
ihaleIlanTuruIdList: opts.ihaleIlanTuruIdList || [],
teklifTuruIdList: [], asiriDusukTeklifIdList: [], istisnaMaddeIdList: [],
okasBransKodList: opts.okasBransKodList || [],
okasBransAdiList: [], titubbKodList: [], gmdnKodList: [],
eIhale: null, eEksiltmeYapilacakMi: null, ortakAlimMi: null, kismiTeklifMi: null,
fiyatDisiUnsurVarmi: null,
ekonomikVeMaliYeterlilikBelgeleriIsteniyorMu: null,
meslekiTeknikYeterlilikBelgeleriIsteniyorMu: null,
isDeneyimiGosterenBelgelerIsteniyorMu: null,
yerliIstekliyeFiyatAvantajiUgulaniyorMu: null,
yabanciIsteklilereIzinVeriliyorMu: null,
alternatifTeklifVerilebilirMi: null, konsorsiyumKatilabilirMi: null,
altYukleniciCalistirilabilirMi: null, fiyatFarkiVerilecekMi: null,
avansVerilecekMi: null, cerceveAnlasmaMi: null,
personelCalistirilmasinaDayaliMi: null,
orderBy: opts.orderBy || 'ihaleTarihi',
siralamaTipi: opts.siralamaTipi || 'desc',
paginationSkip: opts.skip ?? 0,
paginationTake: opts.take ?? 20,
};
}
export async function searchTenders(opts = {}) {
const data = await ekapPost('/b_ihalearama/api/Ihale/GetListByParameters', searchBody(opts));
return { list: data.list || [], totalCount: data.totalCount ?? 0 };
}
// NOT: dönen `id` per-response şifreli token — kalıcı değil, aynı oturumda kullan.
export async function getIlanlar(ihaleId) {
const data = await ekapPost('/b_ihalearama/api/Ilan/GetList', { ihaleId });
return data.list || [];
}
export async function getIhaleDetay(ihaleId) {
const data = await ekapPost('/b_ihalearama/api/IhaleDetay/GetByIhaleIdIhaleDetay', { ihaleId });
return data.item || data;
}
// --- Sonuç ilanı parse ---------------------------------------------------
const ENTITIES = { '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&apos;': "'" };
// EKAP'ın veriHtml tablosunu, Python tarafındaki markdown ile aynı "| hücre |"
// biçimine indirger; böylece aynı regex'ler çalışır.
export function htmlToPipeText(html) {
if (!html) return '';
return String(html)
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
.replace(/<\s*\/\s*t[dh]\s*>/gi, ' | ')
.replace(/<\s*\/\s*tr\s*>/gi, ' |\n')
.replace(/<\s*br\s*\/?\s*>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z#0-9]+;/gi, (m) => ENTITIES[m.toLowerCase()] ?? ' ')
.replace(/[ \t]+/g, ' ');
}
function cleanCell(v) {
if (v == null) return null;
const t = String(v).replace(/-->/g, ' ').replace(/\*\*/g, ' ').replace(/\s+/g, ' ').trim();
return t || null;
}
function findField(text, labelPattern) {
if (!text) return null;
const re = new RegExp(`${labelPattern}\\s*\\**\\s*\\|\\s*\\**:?\\**\\s*\\|([^|\\n]*)`, 'i');
const m = re.exec(text);
return m ? cleanCell(m[1]) : null;
}
function parseAmount(v) {
if (!v) return null;
const m = /([\d.]+,\d+|\d+)/.exec(v);
if (!m) return null;
const n = parseFloat(m[1].replace(/\./g, '').replace(',', '.'));
return Number.isNaN(n) ? null : n;
}
function parseIntSafe(v) {
if (!v) return null;
const m = /\d+/.exec(v);
return m ? parseInt(m[0], 10) : null;
}
/** Sonuç ilanı HTML'inden kazanan firma / bedel / teklif sayısı çıkarır. */
export function parseResultAnnouncement(veriHtml) {
const text = htmlToPipeText(veriHtml);
const amountRaw = findField(text, 'Bedeli');
const cur = amountRaw ? (/\b([A-Z]{3})\b/.exec(amountRaw)?.[1] ?? null) : null;
return {
ikn: findField(text, 'İhale kayıt numarası'),
kazananFirma: findField(text, 'Yüklenici(?:si)?'),
kazananUyruk: findField(text, 'Yüklenicinin uyruğu'),
kazananAdres: findField(text, 'Yüklenicinin adresi'),
sozlesmeBedeli: parseAmount(amountRaw),
paraBirimi: cur,
sozlesmeTarihi: findField(text, '4- Sözleşmenin[\\s\\S]{0,200}?Tarihi'),
sozlesmeSuresi: findField(text, '4- Sözleşmenin[\\s\\S]{0,400}?Süresi'),
yaklasikMaliyet: parseAmount(findField(text, '(?<!Kısımlarının )Yaklaşık Maliyeti')),
teklifSayisi: parseIntSafe(findField(text, 'Toplam Teklif Sayısı')),
gecerliTeklifSayisi: parseIntSafe(findField(text, 'Toplam Geçerli Teklif Sayısı')),
};
}
// --- Firma adı normalize (kazanan eşleştirme için) -----------------------
const TR_FOLD = { 'Ç': 'C', 'Ğ': 'G', 'İ': 'I', 'I': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U', 'ç': 'C', 'ğ': 'G', 'ı': 'I', 'i': 'I', 'ö': 'O', 'ş': 'S', 'ü': 'U' };
const SUFFIX = new Set(['LIMITED', 'LTD', 'SIRKETI', 'SIRKET', 'STI', 'ANONIM', 'AS', 'A', 'S', 'SANAYI', 'SAN', 'TICARET', 'TIC', 'VE', 'KOLLEKTIF', 'KOMANDIT']);
export function normalizeCompanyName(n) {
if (!n) return '';
return String(n)
.replace(/[ÇĞİIÖŞÜçğıiöşü]/g, (c) => TR_FOLD[c] || c)
.toUpperCase()
.replace(/[^0-9A-Z]+/g, ' ')
.split(/\s+/)
.filter((t) => t && !SUFFIX.has(t))
.join(' ');
}
/** Bir ihalenin sonuç ilan(lar)ını çeker ve parse eder (kısmi teklifte birden çok olur). */
export async function fetchResults(ihaleId) {
const ilanlar = await getIlanlar(ihaleId);
return ilanlar
.filter((i) => String(i.ilanTip) === ILAN_TIP_SONUC)
.map((i, idx) => ({ ...parseResultAnnouncement(i.veriHtml), ilanTarihi: i.ilanTarihi, kisimIndex: idx }))
.filter((r) => r.kazananFirma || r.sozlesmeBedeli != null);
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Geçmiş ihale sonuçları deposu (SQLite).
* ekap-scan.mjs doldurur; server/index.js okur.
*
* Kalıcı anahtar: IKN (EKAP'ın döndürdüğü `id` per-response token'dır, saklanmaz).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
let DatabaseSync = null;
try {
({ DatabaseSync } = await import('node:sqlite'));
} catch {
console.warn('[history-store] node:sqlite yok — geçmiş veri deposu devre dışı');
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, 'data');
const DB_PATH = path.join(DATA_DIR, 'history.sqlite');
const SCHEMA = `
CREATE TABLE IF NOT EXISTS sonuclar (
ikn TEXT NOT NULL,
kisim INTEGER NOT NULL DEFAULT 0,
ihale_adi TEXT,
idare_adi TEXT,
il TEXT,
ihale_tipi TEXT,
ihale_tarihi TEXT,
ilan_tarihi TEXT,
kazanan_firma TEXT,
kazanan_norm TEXT,
kazanan_adres TEXT,
sozlesme_bedeli REAL,
para_birimi TEXT,
sozlesme_tarihi TEXT,
yaklasik_maliyet REAL,
teklif_sayisi INTEGER,
gecerli_teklif_sayisi INTEGER,
cekilme_zamani TEXT,
PRIMARY KEY (ikn, kisim)
);
CREATE INDEX IF NOT EXISTS idx_kazanan ON sonuclar(kazanan_norm);
CREATE INDEX IF NOT EXISTS idx_idare ON sonuclar(idare_adi);
CREATE INDEX IF NOT EXISTS idx_il ON sonuclar(il);
CREATE INDEX IF NOT EXISTS idx_tarih ON sonuclar(ihale_tarihi);
CREATE TABLE IF NOT EXISTS tarama_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
baslangic TEXT, bitis TEXT,
aralik_bas TEXT, aralik_son TEXT,
taranan INTEGER, eklenen INTEGER, hata INTEGER, not_ TEXT
);
`;
export function available() {
return Boolean(DatabaseSync);
}
export function openDb({ create = false } = {}) {
if (!DatabaseSync) return null;
if (!fs.existsSync(DB_PATH)) {
if (!create) return null;
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const db = new DatabaseSync(DB_PATH);
db.exec(SCHEMA);
return db;
}
export function saveResults(db, tender, results) {
const stmt = db.prepare(`
INSERT INTO sonuclar (ikn,kisim,ihale_adi,idare_adi,il,ihale_tipi,ihale_tarihi,ilan_tarihi,
kazanan_firma,kazanan_norm,kazanan_adres,sozlesme_bedeli,para_birimi,sozlesme_tarihi,
yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,cekilme_zamani)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ikn,kisim) DO UPDATE SET
kazanan_firma=excluded.kazanan_firma, kazanan_norm=excluded.kazanan_norm,
sozlesme_bedeli=excluded.sozlesme_bedeli, yaklasik_maliyet=excluded.yaklasik_maliyet,
teklif_sayisi=excluded.teklif_sayisi, gecerli_teklif_sayisi=excluded.gecerli_teklif_sayisi,
cekilme_zamani=excluded.cekilme_zamani
`);
const now = new Date().toISOString();
let n = 0;
for (const r of results) {
stmt.run(
r.ikn || tender.ikn, r.kisimIndex ?? 0,
tender.ihaleAdi || null, tender.idareAdi || null, tender.ihaleIlAdi || null,
tender.ihaleTipAciklama || null, tender.ihaleTarihSaat || null, r.ilanTarihi || null,
r.kazananFirma || null, r.kazananNorm || null, r.kazananAdres || null,
r.sozlesmeBedeli ?? null, r.paraBirimi || null, r.sozlesmeTarihi || null,
r.yaklasikMaliyet ?? null, r.teklifSayisi ?? null, r.gecerliTeklifSayisi ?? null,
now
);
n++;
}
return n;
}
export function hasIkn(db, ikn) {
const row = db.prepare('SELECT 1 FROM sonuclar WHERE ikn=? LIMIT 1').get(ikn);
return Boolean(row);
}
// --- Okuma tarafı (API için) --------------------------------------------
function rowToTender(r) {
return {
id: `hist-${r.ikn}-${r.kisim}`,
kaynak: 'EKAP Sonuç İlanı',
ikn: r.ikn,
baslik: r.ihale_adi || 'İhale',
kurum: r.idare_adi || '—',
sehir: r.il || '',
tarih: r.ihale_tarihi || '',
alimTuru: r.ihale_tipi || 'İhale',
sektor: r.ihale_tipi || 'Genel',
kazananFirma: r.kazanan_firma,
sozlesmeBedeli: r.sozlesme_bedeli,
paraBirimi: r.para_birimi,
sozlesmeTarihi: r.sozlesme_tarihi,
yaklasikMaliyet: r.yaklasik_maliyet,
teklifSayisi: r.teklif_sayisi,
gecerliTeklifSayisi: r.gecerli_teklif_sayisi,
durum: 'Sonuçlandı',
items: [],
};
}
export function readHistory(limit = 500, { firma, idare, il } = {}) {
const db = openDb();
if (!db) return { tenders: [], totalCount: 0, available: false };
try {
const where = [];
const args = [];
if (firma) { where.push('kazanan_norm LIKE ?'); args.push(`%${firma}%`); }
if (idare) { where.push('idare_adi LIKE ?'); args.push(`%${idare}%`); }
if (il) { where.push('il LIKE ?'); args.push(`%${il}%`); }
const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
const total = db.prepare(`SELECT COUNT(*) c FROM sonuclar ${w}`).all(...args)[0]?.c ?? 0;
const rows = db.prepare(`SELECT * FROM sonuclar ${w} ORDER BY cekilme_zamani DESC LIMIT ?`).all(...args, limit);
return { tenders: rows.map(rowToTender), totalCount: total, available: true };
} catch (e) {
console.warn('[history-store] okuma hatası:', e.message);
return { tenders: [], totalCount: 0, available: false };
} finally {
db.close();
}
}
export function historyStats() {
const db = openDb();
if (!db) return { available: false };
try {
const c = (q) => db.prepare(q).all()[0] || {};
const toplam = c('SELECT COUNT(*) v FROM sonuclar').v ?? 0;
const firma = c('SELECT COUNT(DISTINCT kazanan_norm) v FROM sonuclar WHERE kazanan_norm IS NOT NULL').v ?? 0;
const idare = c('SELECT COUNT(DISTINCT idare_adi) v FROM sonuclar').v ?? 0;
const hacim = c('SELECT SUM(sozlesme_bedeli) v FROM sonuclar').v ?? 0;
const tekTeklif = c('SELECT COUNT(*) v FROM sonuclar WHERE gecerli_teklif_sayisi=1').v ?? 0;
const sonTarama = db.prepare('SELECT * FROM tarama_log ORDER BY id DESC LIMIT 1').all()[0] || null;
return { available: true, toplam, firma, idare, hacim, tekTeklif, sonTarama };
} catch {
return { available: false };
} finally {
db.close();
}
}
export function logScan(db, row) {
db.prepare(`INSERT INTO tarama_log (baslangic,bitis,aralik_bas,aralik_son,taranan,eklenen,hata,not_)
VALUES (?,?,?,?,?,?,?,?)`)
.run(row.baslangic, row.bitis, row.aralikBas, row.aralikSon, row.taranan, row.eklenen, row.hata, row.not || null);
}
+122 -1
View File
@@ -18,6 +18,12 @@ import { fileURLToPath } from 'node:url';
import { readDtTenders } from './dt-store.mjs'; import { readDtTenders } from './dt-store.mjs';
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs'; import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
import { billingConfigured, planCatalog, createCheckoutSession } from './billing.mjs';
import { searchTed } from './ted.mjs';
import { readHistory, historyStats } from './history-store.mjs';
import { startScheduler } from './scheduler.mjs';
import { searchKik, kararMetni } from './kik.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz. // zinciri tamamlamak için PEM'i yerelden ekliyoruz.
@@ -337,6 +343,111 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { success: true, user }); return json(res, 200, { success: true, user });
} }
// --- KİK kurul kararları ---
if (url.pathname === '/api/kik/search') {
try {
const q = url.searchParams.get('q') || '';
const out = await searchKik(
{
metin: url.searchParams.get('metin') || (url.searchParams.get('alan') ? null : q),
idare: url.searchParams.get('idare'),
basvuran: url.searchParams.get('basvuran'),
kararNo: url.searchParams.get('kararNo'),
yil: url.searchParams.get('yil'),
},
url.searchParams.get('tur') || 'uyusmazlik',
Number(url.searchParams.get('limit')) || 25
);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if (url.pathname === '/api/kik/karar') {
try {
const id = url.searchParams.get('id');
if (!id) return json(res, 400, { success: false, error: 'id gerekli' });
const metin = await kararMetni(id);
return json(res, 200, { success: true, id, metin });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Geçmiş sonuç ilanları (kazanan firma arşivi) ---
if (url.pathname === '/api/history') {
const out = readHistory(Number(url.searchParams.get('limit')) || 300, {
firma: url.searchParams.get('firma'),
idare: url.searchParams.get('idare'),
il: url.searchParams.get('il'),
});
return json(res, 200, { success: true, ...out });
}
if (url.pathname === '/api/history/stats') {
return json(res, 200, { success: true, ...historyStats() });
}
// --- TED (AB ihaleleri) ---
if (url.pathname === '/api/ted') {
try {
const out = await searchTed({
mod: url.searchParams.get('mod') || 'turkiye',
since: url.searchParams.get('since') || '20240101',
page: Number(url.searchParams.get('page')) || 1,
limit: Number(url.searchParams.get('limit')) || 25,
});
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Abonelik / ödeme ---
if (url.pathname === '/api/billing/plans') {
return json(res, 200, { configured: billingConfigured(), plans: planCatalog() });
}
if (url.pathname === '/api/billing/checkout' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
try {
const body = await readBody(req);
const out = await createCheckoutSession({ planId: body.planId, email: user.email });
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, billingConfigured() ? 400 : 503, { success: false, error: e.message });
}
}
// --- Gerçek AI şartname analizi ---
if (url.pathname === '/api/ai/config') {
return json(res, 200, { configured: aiConfigured(), providers: availableProviders() });
}
if (url.pathname === '/api/ai/analyze' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!aiConfigured()) {
return json(res, 503, {
success: false,
error: 'AI yapılandırılmamış — sunucuya en az bir sağlayıcı anahtarı ekleyin (ZAI_API_KEY / DEEPSEEK_API_KEY / GEMINI_API_KEY …)',
});
}
try {
const body = await readBody(req, 2 * 1024 * 1024);
if (!body.text || String(body.text).trim().length < 40) {
return json(res, 400, { success: false, error: 'Analiz için yeterli metin yok (en az 40 karakter).' });
}
const out = await analyzeSpec({
text: body.text,
fileName: body.fileName,
tender: body.tender,
chain: body.chain,
});
return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() });
} catch (e) {
return json(res, 502, { success: false, error: e.message, attempts: e.attempts || null });
}
}
// --- E-posta bildirimi (Resend) --- // --- E-posta bildirimi (Resend) ---
if (url.pathname === '/api/notify/email/config') { if (url.pathname === '/api/notify/email/config') {
return json(res, 200, { configured: emailConfigured() }); return json(res, 200, { configured: emailConfigured() });
@@ -370,8 +481,16 @@ const server = http.createServer(async (req, res) => {
} catch (e) { } catch (e) {
console.warn('[ihaleist-api] DT okuma hatası:', e.message); console.warn('[ihaleist-api] DT okuma hatası:', e.message);
} }
// Geçmiş sonuç ilanları (kazanan firma + bedel + teklif sayısı)
let hist = { tenders: [], totalCount: 0, available: false };
try {
hist = readHistory(600);
} catch (e) {
console.warn('[ihaleist-api] geçmiş okuma hatası:', e.message);
}
const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları']; const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları'];
if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker'); if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker');
if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)');
return json(res, 200, { return json(res, 200, {
success: true, success: true,
source: sources.join(' + '), source: sources.join(' + '),
@@ -379,7 +498,8 @@ const server = http.createServer(async (req, res) => {
totalCount: c.totalCount + dt.totalCount, totalCount: c.totalCount + dt.totalCount,
todayCount: c.todayCount, todayCount: c.todayCount,
dtCount: dt.totalCount, dtCount: dt.totalCount,
tenders: [...dt.tenders, ...c.tenders], historyCount: hist.totalCount,
tenders: [...dt.tenders, ...c.tenders, ...hist.tenders],
}); });
} }
@@ -428,4 +548,5 @@ const server = http.createServer(async (req, res) => {
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`); console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message)); getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message));
startScheduler();
}); });
+206
View File
@@ -0,0 +1,206 @@
/**
* KİK (Kamu İhale Kurumu) kurul kararları — EKAP v2 JSON API.
* Zero-dependency: node:crypto + built-in fetch.
*
* Endpoint'ler (anonim, login yok):
* POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlari (uyuşmazlık)
* POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk (düzenleyici)
* POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk (mahkeme)
*
* Güvenlik: X-Ekap-Sec-1..6 (AES-256-CBC). Sec-5 = HTTP metodu, Sec-6 = URL path.
* Başlıklar HER istekte yeniden üretilmeli (GUID nonce + timestamp penceresi).
*
* Karar tam metni: gundemMaddesiId AYRI bir AES anahtarıyla şifrelenip
* KararGoster.aspx'e query param olarak verilir (HTML döner).
*
* ⚠ Her iki anahtar da KİK frontend'inde sabittir; değişirse 401/HataKodu 1200
* alınır. Env ile geçilebilir: EKAP_R8FACT_KEY, EKAP_RAW_KEY (hex).
*/
import crypto from 'node:crypto';
const ORIGIN = 'https://ekapv2.kik.gov.tr';
const SIGN_KEY_DEFAULT = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g';
const ID_KEY_DEFAULT = Buffer.from([
236, 193, 164, 43, 12, 135, 121, 170, 4, 244, 123, 219, 82, 158, 124, 174,
174, 228, 219, 174, 208, 104, 174, 120, 32, 76, 250, 4, 143, 159, 211, 176,
]);
const signKey = () => Buffer.from(process.env.EKAP_R8FACT_KEY || SIGN_KEY_DEFAULT, 'utf8');
const idKey = () => (process.env.EKAP_RAW_KEY ? Buffer.from(process.env.EKAP_RAW_KEY, 'hex') : ID_KEY_DEFAULT);
function secHeaders(method, pathname) {
const iv = crypto.randomBytes(16);
const key = signKey();
const e = (t) => {
const c = crypto.createCipheriv('aes-256-cbc', key, iv);
return Buffer.concat([c.update(String(t), 'utf8'), c.final()]).toString('base64');
};
const g = crypto.randomUUID();
return {
'X-Ekap-Sec-1': e(g),
'X-Ekap-Sec-2': iv.toString('base64'),
'X-Ekap-Sec-3': g,
'X-Ekap-Sec-4': e(Date.now()),
'X-Ekap-Sec-5': e(method.toUpperCase()),
'X-Ekap-Sec-6': e(pathname),
};
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function kikPost(pathname, body, { retries = 2 } = {}) {
let last;
for (let i = 0; i <= retries; i++) {
try {
const r = await fetch(ORIGIN + pathname, {
method: 'POST',
headers: {
'api-version': 'v1',
'Content-Type': 'application/json',
Accept: 'application/json',
'Accept-Language': 'tr',
Origin: ORIGIN,
Referer: `${ORIGIN}/sorgulamalar/kurul-kararlari`,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
...secHeaders('POST', pathname),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(40000),
});
const raw = await r.text();
if (!r.ok) {
if (i < retries && (r.status === 401 || r.status >= 500)) { await sleep(600 * (i + 1)); continue; }
throw new Error(`KİK sunucusu isteği reddetti (HTTP ${r.status}).`);
}
return JSON.parse(raw);
} catch (e) {
last = e;
if (i >= retries) break;
await sleep(600 * (i + 1));
}
}
throw last;
}
// Yanıt anahtarları uyuşmazlıkta camelCase, DK/MK'de PascalCase gelebilir
const pick = (o, ...names) => {
for (const n of names) if (o && o[n] != null) return o[n];
return undefined;
};
const TURLER = {
uyusmazlik: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlari', etiket: 'Uyuşmazlık' },
duzenleyici: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk', etiket: 'Düzenleyici' },
mahkeme: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk', etiket: 'Mahkeme' },
};
/** Kararın tam metnini gösteren kalıcı URL (gundemMaddesiId şifrelenir). */
export function kararUrl(gundemMaddesiId) {
const iv = crypto.randomBytes(16);
const c = crypto.createCipheriv('aes-256-cbc', idKey(), iv);
const id = iv.toString('hex') + Buffer.concat([c.update(String(gundemMaddesiId), 'utf8'), c.final()]).toString('hex');
return `https://ekap.kik.gov.tr/EKAP/Vatandas/KurulKararGoster.aspx?KararId=${id}`;
}
/** Kararın tam metnini düz metin olarak getirir. */
export async function kararMetni(gundemMaddesiId) {
const res = await fetch(kararUrl(gundemMaddesiId), {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'tr' },
signal: AbortSignal.timeout(40000),
});
if (!res.ok) throw new Error(`Karar metni alınamadı (HTTP ${res.status})`);
const html = await res.text();
return html
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ').replace(/&quot;/g, '"').replace(/&amp;/g, '&')
.replace(/&times;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&[a-z]+;/gi, ' ')
.replace(/[ \t]+/g, ' ')
.replace(/\s*\n\s*/g, '\n')
.trim();
}
const cache = new Map();
const TTL = 20 * 60 * 1000;
/**
* Kurul kararı araması.
* @param {object} f { metin, kararNo, idare, basvuran, konu, yil, tarih1, tarih2, sonKararlar }
* @param {'uyusmazlik'|'duzenleyici'|'mahkeme'} tur
*/
export async function searchKik(f = {}, tur = 'uyusmazlik', limit = 25) {
const cfg = TURLER[tur] || TURLER.uyusmazlik;
const filters = {};
if (f.metin) filters.KararMetni = f.metin;
if (f.kararNo) filters.KararNo = f.kararNo;
if (f.idare) filters.IhaleyiYapanIdare = f.idare;
if (f.basvuran) filters.Sikayetci = f.basvuran;
if (f.konu) filters.BasvuruKonusuIhale = f.konu;
if (f.yil) filters.Yil = f.yil;
if (f.tarih1) filters.KararTarihi1 = f.tarih1; // "dd.MM.yyyy HH:mm:ss"
if (f.tarih2) filters.KararTarihi2 = f.tarih2;
if (!Object.keys(filters).length) filters.SonKararlar = '';
const key = `${cfg.path}|${JSON.stringify(filters)}|${limit}`;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < TTL) return hit.data;
const j = await kikPost(cfg.path, {
sorgulaKurulKararlari: {
keyValuePairs: {
keyValueOfstringanyType: Object.entries(filters).map(([k, v]) => ({ key: k, value: String(v) })),
},
},
});
const wrap = pick(j, 'SorgulaKurulKararlariResponse', 'sorgulaKurulKararlariResponse') || {};
const res = pick(wrap, 'SorgulaKurulKararlariResult', 'sorgulaKurulKararlariResult') || {};
const hataKodu = String(pick(res, 'hataKodu', 'HataKodu') ?? '0');
// 4401 = kayıt bulunamadı (hata değil)
if (hataKodu === '4401') {
const bos = { results: [], totalCount: 0, tur: cfg.etiket, kaynak: 'KİK — EKAP Kurul Kararları', fetchedAt: new Date().toISOString() };
cache.set(key, { at: Date.now(), data: bos });
return bos;
}
if (hataKodu !== '0') {
throw new Error(`KİK: ${pick(res, 'hataMesaji', 'HataMesaji') || `hata kodu ${hataKodu}`}`);
}
const liste = pick(res, 'KurulKararTutanakDetayListesi', 'kurulKararTutanakDetayListesi') || [];
const kayitlar = pick(liste[0] || {}, 'kurulKararTutanakDetayi', 'KurulKararTutanakDetayi') || [];
const results = (Array.isArray(kayitlar) ? kayitlar : [kayitlar]).slice(0, limit).map((k, i) => {
const gid = pick(k, 'gundemMaddesiId', 'GundemMaddesiId');
const kararNo = pick(k, 'kararNo', 'KararNo');
const tarih = pick(k, 'kararTarihi', 'KararTarihi');
return {
id: String(gid ?? kararNo ?? i),
gundemMaddesiId: gid ?? null,
kararNo: kararNo ?? null,
kararTarihi: tarih ? String(tarih).slice(0, 10).split('-').reverse().join('.') : null,
idare: pick(k, 'idareAdi', 'IdareAdi') ?? null,
basvuran: pick(k, 'basvuran', 'Basvuran') ?? null,
baslik: pick(k, 'basvuruKonusu', 'BasvuruKonusu') ?? null,
konu: pick(k, 'basvuruKonusu', 'BasvuruKonusu') ?? null,
ozet: pick(k, 'kararNitelik', 'KararNitelik') ?? null,
resmiGazete: pick(k, 'resmiGazeteSayisi', 'ResmiGazeteSayisi') ?? null,
tur: cfg.etiket,
link: gid != null ? kararUrl(gid) : null,
};
});
const data = {
results,
totalCount: results.length,
tur: cfg.etiket,
kaynak: 'KİK — EKAP Kurul Kararları',
fetchedAt: new Date().toISOString(),
};
cache.set(key, { at: Date.now(), data });
return data;
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Sunucu içi zamanlayıcı — geçmiş sonuç ilanlarını periyodik olarak biriktirir.
* Container'da ayrı cron kurmaya gerek kalmaz; server/index.js başlatır.
*
* Env:
* SCAN_ENABLED=0 → kapat (varsayılan açık)
* SCAN_INTERVAL_SAAT=6 → tarama sıklığı (saat)
* SCAN_GUN=3 → her turda geriye kaç günü tara
* SCAN_LIMIT=300 → tur başına maksimum ihale
*/
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let calisiyor = false;
function tara() {
if (calisiyor) {
console.log('[scheduler] önceki tarama sürüyor, bu tur atlandı');
return;
}
calisiyor = true;
const gun = process.env.SCAN_GUN || '3';
const limit = process.env.SCAN_LIMIT || '300';
console.log(`[scheduler] EKAP sonuç taraması başlıyor (son ${gun} gün, limit ${limit})`);
const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--gun', gun, '--limit', limit], {
stdio: ['ignore', 'pipe', 'pipe'],
});
p.stdout.on('data', (d) => process.stdout.write(String(d)));
p.stderr.on('data', (d) => process.stderr.write(String(d)));
p.on('close', (code) => {
calisiyor = false;
console.log(`[scheduler] tarama bitti (çıkış kodu ${code})`);
});
p.on('error', (e) => {
calisiyor = false;
console.warn('[scheduler] tarama başlatılamadı:', e.message);
});
}
export function startScheduler() {
if (process.env.SCAN_ENABLED === '0') {
console.log('[scheduler] devre dışı (SCAN_ENABLED=0)');
return;
}
const saat = Number(process.env.SCAN_INTERVAL_SAAT || 6);
const ms = Math.max(1, saat) * 3600 * 1000;
console.log(`[scheduler] geçmiş sonuç taraması her ${saat} saatte bir çalışacak`);
// Açılıştan 2 dk sonra ilk tur (sunucu ısınsın), sonra periyodik
setTimeout(tara, 2 * 60 * 1000).unref?.();
setInterval(tara, ms).unref?.();
}
+154
View File
@@ -0,0 +1,154 @@
/**
* TED (Tenders Electronic Daily) — AB kamu ihale ilanları, Türkiye odaklı.
*
* Endpoint: POST https://api.ted.europa.eu/v3/notices/search (anonim, API key YOK)
* Doğrulanmış davranış:
* - `fields` boş olamaz (400 verir)
* - limit max 250; page*limit max 15000; alan/sayfa max 10000 ("links" gizli alan da sayılır)
* - Rate limit agresif ve belgelenmemiş: ~1 istek/sn güvenli. 429 gövdesi JSON DEĞİL (nginx HTML)
* - Ülke kodları ISO alpha-3 (TUR); tarih formatı YYYYMMDD
* - Metin alanları çok dilli obje: { eng: [...], tur: [...] }
*
* Bağımlılık yok (Node 18+ fetch).
*/
const TED_URL = 'https://api.ted.europa.eu/v3/notices/search';
const FIELDS = [
'publication-number', 'notice-title', 'notice-type', 'publication-date',
'deadline-receipt-tender-date-lot', 'organisation-name-buyer', 'organisation-city-buyer',
'buyer-country', 'winner-name', 'winner-country',
'estimated-value-lot', 'estimated-value-cur-lot',
'result-value-notice', 'result-value-cur-notice',
'classification-cpv', 'contract-nature', 'description-lot',
];
// Türkiye evreni: TR idarelerin AB ihaleleri + AB'de kazanan TR firmaları + teklif veren TR firmaları
const Q_TURKIYE = '(buyer-country IN (TUR) OR winner-country IN (TUR) OR organisation-country-tenderer IN (TUR))';
// TR firmalarının AB'de kazandıkları (rakip radar için)
const Q_KAZANAN = 'winner-country IN (TUR)';
const cache = new Map(); // key -> {at, data}
const TTL = 30 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Çok dilli alan → tek string
function txt(v) {
if (v == null) return null;
if (typeof v === 'string') return v;
if (Array.isArray(v)) return txt(v[0]);
if (typeof v === 'object') return txt(v.eng ?? v.tur ?? Object.values(v)[0]);
return String(v);
}
const first = (v) => (Array.isArray(v) ? v[0] ?? null : v ?? null);
const uniq = (v) => (Array.isArray(v) ? [...new Set(v)] : v == null ? [] : [v]);
const day = (v) => { const s = first(v); return s ? String(s).slice(0, 10) : null; };
const NATURE_TR = { works: 'Yapım İşi', supplies: 'Mal Alımı', services: 'Hizmet Alımı' };
function mapNotice(n) {
const pn = n['publication-number'];
const bedel = n['result-value-notice'] ?? first(n['estimated-value-lot']);
const cur = n['result-value-cur-notice'] ?? first(n['estimated-value-cur-lot']);
const nature = first(n['contract-nature']);
return {
id: `ted-${pn}`,
kaynak: 'TED',
ikn: String(pn || ''),
baslik: txt(n['notice-title']) || 'Başlıksız ilan',
kurum: txt(n['organisation-name-buyer']) || '—',
sehir: first(n['organisation-city-buyer'])?.trim() || null,
ulke: first(n['buyer-country']) || null,
tarih: day(n['deadline-receipt-tender-date-lot']) || day(n['publication-date']),
yayimTarihi: day(n['publication-date']),
sonTeklifTarihi: day(n['deadline-receipt-tender-date-lot']),
yaklasikMaliyet: bedel != null && bedel !== '' ? Number(bedel) : null,
paraBirimi: cur || null,
kazananFirma: txt(n['winner-name']),
kazananUlke: first(n['winner-country']),
alimTuru: NATURE_TR[nature] || nature || 'AB İhalesi',
ihaleTuru: n['notice-type'],
sonucIlani: String(n['notice-type'] || '').startsWith('can'),
cpv: uniq(n['classification-cpv']),
aciklama: txt(n['description-lot']),
link: n.links?.html?.ENG || (pn ? `https://ted.europa.eu/en/notice/-/detail/${pn}` : null),
};
}
async function tedSearch(body, { retries = 3 } = {}) {
for (let i = 0; ; i++) {
let res;
try {
res = await fetch(TED_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(45000),
});
} catch (e) {
if (i >= retries) throw new Error(`TED bağlantı hatası: ${e.message}`);
await sleep(1000 * 2 ** i);
continue;
}
// 429 gövdesi HTML gelir; her durumda önce text al
const raw = await res.text();
if (res.status === 429 || res.status >= 500) {
if (i >= retries) throw new Error(`TED geçici olarak yanıt vermiyor (HTTP ${res.status}).`);
await sleep(1200 * 2 ** i);
continue;
}
if (!res.ok) {
let msg = raw.slice(0, 300);
try { const j = JSON.parse(raw); msg = j.message || msg; } catch { /* HTML yanıt */ }
throw new Error(`TED API: ${msg}`);
}
return JSON.parse(raw);
}
}
/**
* @param {object} opts
* @param {'turkiye'|'kazanan'|'acik'} [opts.mod]
* @param {string} [opts.query] Ham expert query (verilirse mod yok sayılır)
* @param {string} [opts.since] YYYYMMDD
*/
export async function searchTed({ mod = 'turkiye', query, since = '20240101', page = 1, limit = 25 } = {}) {
let q;
let scope = 'ALL';
if (query && query.trim()) {
q = query.trim();
} else if (mod === 'kazanan') {
q = `${Q_KAZANAN} AND publication-date >= ${since} SORT BY publication-date DESC`;
} else if (mod === 'acik') {
q = `buyer-country IN (TUR) SORT BY publication-date DESC`;
scope = 'ACTIVE';
} else {
q = `${Q_TURKIYE} AND publication-date >= ${since} SORT BY publication-date DESC`;
}
const key = `${q}|${scope}|${limit}|${page}`;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < TTL) return hit.data;
const data = await tedSearch({
query: q,
fields: FIELDS,
page,
limit: Math.min(limit, 250),
scope,
onlyLatestVersions: true,
paginationMode: 'PAGE_NUMBER',
});
const out = {
tenders: (data.notices || []).map(mapNotice),
totalCount: data.totalNoticeCount ?? (data.notices || []).length,
mod,
query: q,
timedOut: Boolean(data.timedOut),
fetchedAt: new Date().toISOString(),
};
cache.set(key, { at: Date.now(), data: out });
return out;
}
+19
View File
@@ -23,10 +23,15 @@ import { PriceAnalysis } from './components/tools/PriceAnalysis';
import { CompetitorRadar } from './components/tools/CompetitorRadar'; import { CompetitorRadar } from './components/tools/CompetitorRadar';
import { DocumentVault } from './components/tools/DocumentVault'; import { DocumentVault } from './components/tools/DocumentVault';
import { AnalyticsDashboard } from './components/tools/AnalyticsDashboard'; import { AnalyticsDashboard } from './components/tools/AnalyticsDashboard';
import { RedFlags } from './components/tools/RedFlags';
import { TedTenders } from './components/tools/TedTenders';
import { Pricing } from './components/tools/Pricing';
import { KikDecisions } from './components/tools/KikDecisions';
import { ApiWebhooks } from './components/tools/ApiWebhooks'; import { ApiWebhooks } from './components/tools/ApiWebhooks';
import { PwaSettings } from './components/tools/PwaSettings'; import { PwaSettings } from './components/tools/PwaSettings';
import { AiModelSettings } from './components/tools/AiModelSettings'; import { AiModelSettings } from './components/tools/AiModelSettings';
import { LoginPage } from './components/LoginPage'; import { LoginPage } from './components/LoginPage';
import { LandingPage } from './components/LandingPage';
const MainContent = () => { const MainContent = () => {
const { activeTab, isAdmin } = useApp(); const { activeTab, isAdmin } = useApp();
@@ -57,6 +62,10 @@ const MainContent = () => {
{activeTab === 'rakip_radar' && <CompetitorRadar />} {activeTab === 'rakip_radar' && <CompetitorRadar />}
{activeTab === 'dokuman_deposu' && <DocumentVault />} {activeTab === 'dokuman_deposu' && <DocumentVault />}
{activeTab === 'analitik_dashboard' && <AnalyticsDashboard />} {activeTab === 'analitik_dashboard' && <AnalyticsDashboard />}
{activeTab === 'kirmizi_bayrak' && <RedFlags />}
{activeTab === 'ted_eu' && <TedTenders />}
{activeTab === 'kik_kararlari' && <KikDecisions />}
{activeTab === 'planlar' && <Pricing />}
{activeTab === 'api_webhooks' && <ApiWebhooks />} {activeTab === 'api_webhooks' && <ApiWebhooks />}
{activeTab === 'pwa_settings' && <PwaSettings />} {activeTab === 'pwa_settings' && <PwaSettings />}
{activeTab === 'ai_ayarlari' && isAdmin && <AiModelSettings />} {activeTab === 'ai_ayarlari' && isAdmin && <AiModelSettings />}
@@ -72,6 +81,13 @@ const MainContent = () => {
const Gate = () => { const Gate = () => {
const { authReady, isAuthenticated, handleLogin } = useApp(); const { authReady, isAuthenticated, handleLogin } = useApp();
const [showLogin, setShowLogin] = React.useState(() => window.location.hash === '#giris');
React.useEffect(() => {
const onHash = () => setShowLogin(window.location.hash === '#giris');
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
if (!authReady) { if (!authReady) {
return ( return (
@@ -81,6 +97,9 @@ const Gate = () => {
); );
} }
if (!isAuthenticated) { if (!isAuthenticated) {
if (!showLogin) {
return <LandingPage onGiris={() => { window.location.hash = '#giris'; setShowLogin(true); }} />;
}
return <LoginPage onLogin={handleLogin} />; return <LoginPage onLogin={handleLogin} />;
} }
return ( return (
+181 -131
View File
@@ -1,67 +1,88 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, ShieldCheck, DollarSign, X, Loader2, ArrowRight, TrendingUp, Sliders } from 'lucide-react'; import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react';
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { getAiConfig } from '../services/ai';
const tl = (n) => (typeof n === 'number' && isFinite(n) ? '₺' + n.toLocaleString('tr-TR') : '—');
export const AiSpecAnalyzerModal = () => { export const AiSpecAnalyzerModal = () => {
const { isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen, analyzeSpecPdfWithGemini, aiAnalysisResults } = useApp(); const {
isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
analyzeSpecPdfWithGemini, aiAnalysisResults, activeAiModel, activeTenderDetail,
} = useApp();
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
const [customBidSlider, setCustomBidSlider] = useState(148500); const [error, setError] = useState(null);
const [progress, setProgress] = useState('');
const [customBid, setCustomBid] = useState(null);
const [aiCfg, setAiCfg] = useState(null);
useEffect(() => {
if (isAiSpecAnalyzerOpen && aiCfg === null) getAiConfig().then(setAiCfg);
}, [isAiSpecAnalyzerOpen, aiCfg]);
if (!isAiSpecAnalyzerOpen) return null; if (!isAiSpecAnalyzerOpen) return null;
const handleUploadAndAnalyze = async (fileName = 'Teknik_Sartname_Kayseri_Cezaevi.pdf') => { const results = aiAnalysisResults;
const bid = results?.bidAnalysis || {};
const runAnalysis = async (text, fileName) => {
setError(null);
setIsAnalyzing(true); setIsAnalyzing(true);
setSelectedFile(fileName); setSelectedFile(fileName);
await analyzeSpecPdfWithGemini(fileName); try {
setIsAnalyzing(false); setProgress('Model analiz ediyor…');
const r = await analyzeSpecPdfWithGemini({ text, fileName, tender: activeTenderDetail });
if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid);
confetti({ particleCount: 80, spread: 70 }); confetti({ particleCount: 80, spread: 70 });
}; } catch (e) {
setError(e.message);
const results = aiAnalysisResults || { } finally {
fileName: 'Teknik_Sartname_Örnek.pdf', setIsAnalyzing(false);
model: 'Gemini 3.6 Flash', setProgress('');
analyzedAt: new Date().toLocaleString('tr-TR'),
summary: 'İşbu teknik şartname, idarenin ihtiyacı olan medikal sarf malzemeleri ve cihaz parçalarının teknik özellikleri, garanti şartları, teslimat vadeleri ve muayene kabul kriterlerini düzenlemektedir.',
qualificationCriteria: [
'TSE HYB Belgesi ve Yetkili Satıcı Satış Yetki Belgesi',
'ISO 13485 Medikal Cihaz Kalite Yönetim Sertifikası',
'ÜTS (Ürün Takip Sistemi) Barkod ve TİUBB Kaydı',
'En az %50 Oranında Benzer İş Deneyim Belgesi (Kamu/Özel)',
'Son 3 yıla ait Bilanço ve Gelir Tablosu oranları'
],
criticalRequirements: [
'Ürünler biyo-uyumluluk (ISO 10993) standartlarına uygun olmalıdır.',
'Teslim edilen paketlerin sterilizasyon son kullanma tarihi en az 18 ay olmalıdır.',
'Arıza durumunda 24 saat içinde yerinde teknik servis desteği sağlanmalıdır.',
'Teslimat aşamasında her parti için numune test raporu (CoA) ibraz edilmelidir.'
],
risksAndPenalties: [
{ level: 'Yüksek', clause: 'Madde 14.2', text: 'Gecikilen her takvim günü için ihale bedelinin %0.5 oranında cezai şart uygulanacaktır.' },
{ level: 'Orta', clause: 'Madde 18.1', text: 'Numune kontrolünde standart dışı bulunan partiler 3 iş günü içinde muadili ile ücretsiz değiştirilmelidir.' },
{ level: 'Düşük', clause: 'Madde 22.4', text: 'Ambalaj üzerindeki Türkçe etiket eksikliğinde kabul işlemi durdurulacaktır.' }
],
bidAnalysis: {
estimatedCost: 185000,
recommendedBid: 148500,
minBidLimit: 132000,
maxBidLimit: 165000,
winProbability: 89,
competitionLevel: 'Orta Yoğunlukta (3-4 Firma Katılımı Bekleniyor)',
historicalDiscountAvg: '%19.7 EKAP İndirim Oranı'
} }
}; };
// Win probability calculation based on custom bid slider const handleFile = async (file) => {
const calcProbability = (bid) => { setError(null);
const rec = results.bidAnalysis.recommendedBid; setIsAnalyzing(true);
const diff = Math.abs(bid - rec); setSelectedFile(file.name);
const prob = Math.max(35, Math.min(96, Math.round(92 - (diff / 3000) * 3))); try {
return prob; setProgress('Belge metni çıkarılıyor…');
const { extractText } = await import('../services/pdfText');
const { text, usedPages, pageCount } = await extractText(file);
if (!text || text.length < 40) {
throw new Error('Belgeden metin çıkarılamadı. Taranmış (görüntü) PDF olabilir — metin tabanlı bir dosya deneyin.');
}
setProgress(`${usedPages}/${pageCount} sayfa okundu, model analiz ediyor…`);
const r = await analyzeSpecPdfWithGemini({ text, fileName: file.name, tender: activeTenderDetail });
if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid);
confetti({ particleCount: 80, spread: 70 });
} catch (e) {
setError(e.message);
} finally {
setIsAnalyzing(false);
setProgress('');
}
}; };
// Açık ihalenin ilan metnini analiz et
const analyzeCurrentTender = () => {
const t = activeTenderDetail;
const text = [t?.baslik, t?.kurum, t?.aciklama, ...(t?.yeterlikKriterleri || [])].filter(Boolean).join('\n');
if (!text || text.length < 40) {
setError('Açık ihalenin ilan metni analiz için yeterli değil. Bir şartname dosyası yükleyin.');
return;
}
runAnalysis(text, `${t.ikn || 'ihale'} — ilan metni`);
};
const modelEtiketi = aiCfg?.providers?.length
? aiCfg.providers.map((p) => p.label).join(' → ')
: `${activeAiModel.label} (${activeAiModel.provider})`;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/75 backdrop-blur-xs animate-fade-in"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/75 backdrop-blur-xs animate-fade-in">
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-4xl overflow-hidden my-auto flex flex-col max-h-[92vh]"> <div className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-4xl overflow-hidden my-auto flex flex-col max-h-[92vh]">
@@ -73,191 +94,219 @@ export const AiSpecAnalyzerModal = () => {
<Sparkles className="w-6 h-6" /> <Sparkles className="w-6 h-6" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<h2 className="font-extrabold text-base">Teknik Şartname & Teklif Analiz Asistanı</h2> <h2 className="font-extrabold text-base">Teknik Şartname & Teklif Analiz Asistanı</h2>
<span className="bg-amber-400 text-slate-900 text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider"> <span className="bg-amber-400 text-slate-900 text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider">
Gemini 3.6 Flash Engine {modelEtiketi}
</span> </span>
</div> </div>
<p className="text-xs text-blue-200">Teknik şartname PDF'inizi yükleyin; yeterlik, riskler ve önerilen teklif fiyat aralığını saniyeler içinde çıkaralım.</p> <p className="text-xs text-blue-200">Şartname dosyanızı yükleyin; yeterlik, riskler ve teklif aralığı gerçek AI ile analiz edilsin.</p>
</div> </div>
</div> </div>
<button <button onClick={() => setIsAiSpecAnalyzerOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors">
onClick={() => setIsAiSpecAnalyzerOpen(false)}
className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors"
>
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
{/* Modal Content */}
<div className="p-6 space-y-6 overflow-y-auto"> <div className="p-6 space-y-6 overflow-y-auto">
{/* PDF Uploader Area */} {/* AI yapılandırılmamış uyarısı */}
{aiCfg && !aiCfg.configured && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
<Info className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
<p className="text-xs text-amber-900 leading-relaxed">
<strong>AI henüz yapılandırılmamış.</strong> Analizin çalışması için sunucuya en az bir sağlayıcı anahtarı ekleyin
(<code className="bg-amber-100 px-1 rounded">ZAI_API_KEY</code>, <code className="bg-amber-100 px-1 rounded">DEEPSEEK_API_KEY</code>,
{' '}<code className="bg-amber-100 px-1 rounded">GEMINI_API_KEY</code> …) ve yeniden dağıtın.
Model sırası, Admin → AI Model Ayarları'ndan belirlenir.
</p>
</div>
)}
{/* Uploader */}
<div className="bg-slate-50 border-2 border-dashed border-blue-300 hover:border-blue-500 rounded-2xl p-6 text-center space-y-3 transition-colors"> <div className="bg-slate-50 border-2 border-dashed border-blue-300 hover:border-blue-500 rounded-2xl p-6 text-center space-y-3 transition-colors">
<div className="w-12 h-12 rounded-2xl bg-blue-100 text-blue-600 flex items-center justify-center mx-auto shadow-xs"> <div className="w-12 h-12 rounded-2xl bg-blue-100 text-blue-600 flex items-center justify-center mx-auto shadow-xs">
<Upload className="w-6 h-6" /> <Upload className="w-6 h-6" />
</div> </div>
<div> <div>
<h3 className="font-bold text-sm text-slate-800">Teknik Şartname PDF Dosyası Yükleyin</h3> <h3 className="font-bold text-sm text-slate-800">Teknik Şartname Dosyası Yükleyin</h3>
<p className="text-xs text-slate-500">Bilgisayarınızdan `.pdf` dosyanızı buraya sürükleyin veya örnek ihale şartnamesi seçin.</p> <p className="text-xs text-slate-500">Metin tabanlı <code>.pdf</code> veya <code>.txt</code> dosyası — belge tarayıcınızda okunur, yalnızca metni analiz için gönderilir.</p>
</div> </div>
<div className="flex justify-center gap-3 pt-2"> <div className="flex justify-center gap-3 pt-2 flex-wrap">
<label className="bg-blue-600 hover:bg-blue-500 text-white font-extrabold text-xs px-5 py-2.5 rounded-xl cursor-pointer shadow-md transition-colors flex items-center gap-2"> <label className={`font-extrabold text-xs px-5 py-2.5 rounded-xl shadow-md transition-colors flex items-center gap-2 ${
isAnalyzing ? 'bg-slate-300 text-slate-500 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-500 text-white cursor-pointer'
}`}>
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
<span>PDF Dosyası Seç</span> <span>Dosya Seç (PDF / TXT)</span>
<input <input
type="file" type="file"
accept=".pdf" accept=".pdf,.txt,.md"
className="hidden" className="hidden"
onChange={(e) => { disabled={isAnalyzing}
if (e.target.files && e.target.files[0]) { onChange={(e) => { if (e.target.files?.[0]) handleFile(e.target.files[0]); }}
handleUploadAndAnalyze(e.target.files[0].name);
}
}}
/> />
</label> </label>
{activeTenderDetail && (
<button <button
type="button" type="button"
onClick={() => handleUploadAndAnalyze('Örnek_EKAP_Kayseri_Cezaevi_Teknik_Sartname.pdf')} disabled={isAnalyzing}
className="bg-white hover:bg-slate-100 text-slate-700 font-bold text-xs px-4 py-2.5 rounded-xl border border-slate-300 transition-colors flex items-center gap-2" onClick={analyzeCurrentTender}
className="bg-white hover:bg-slate-100 disabled:opacity-50 text-slate-700 font-bold text-xs px-4 py-2.5 rounded-xl border border-slate-300 transition-colors flex items-center gap-2"
> >
<FileText className="w-4 h-4 text-purple-600" /> <FileText className="w-4 h-4 text-purple-600" />
<span>Örnek EKAP Şartnamesi İle Dene</span> <span>Açık İhalenin İlan Metnini Analiz Et</span>
</button> </button>
)}
</div> </div>
</div> </div>
{/* Analyzing Loading Progress */} {/* Hata */}
{isAnalyzing && ( {error && (
<div className="bg-linear-to-r from-blue-50 to-indigo-50 border border-blue-200 p-6 rounded-2xl space-y-3 animate-fade-in shadow-xs"> <div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
<div className="flex items-center justify-between font-extrabold text-xs text-blue-900"> <AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
<span className="flex items-center gap-2"> <div>
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" /> <p className="text-xs font-bold text-red-900">Analiz tamamlanamadı</p>
⚡ Gemini 3.6 Flash PDF Multimodal Akışı İşleniyor... <p className="text-xs text-red-800 mt-0.5 leading-relaxed">{error}</p>
</span>
<span className="bg-amber-400 text-slate-900 px-2 py-0.5 rounded font-black text-[10px]">Gemini 3.6 Flash</span>
</div>
<div className="space-y-1.5 text-xs text-slate-700 font-medium pt-2">
<p className="flex items-center gap-2">✓ 📄 {selectedFile} belgesindeki maddeler ayrıştırıldı.</p>
<p className="flex items-center gap-2">✓ 🛡️ Yeterlik sertifikaları ve cezai hükümler tespit ediliyor...</p>
<p className="flex items-center gap-2">✓ 📊 EKAP geçmiş ihale veri tabanından optimal teklif marjı hesaplanıyor...</p>
</div> </div>
</div> </div>
)} )}
{/* AI Specification Report Output */} {/* Yükleniyor */}
{isAnalyzing && (
<div className="bg-linear-to-r from-blue-50 to-indigo-50 border border-blue-200 p-6 rounded-2xl space-y-2 animate-fade-in shadow-xs">
<div className="flex items-center gap-2 font-extrabold text-xs text-blue-900">
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
<span>{progress || 'İşleniyor…'}</span>
</div>
<p className="text-xs text-slate-600">{selectedFile}</p>
</div>
)}
{/* Sonuç */}
{!isAnalyzing && results && ( {!isAnalyzing && results && (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Top Summary Banner */}
<div className="bg-slate-900 text-white p-5 rounded-2xl space-y-3 shadow-md"> <div className="bg-slate-900 text-white p-5 rounded-2xl space-y-3 shadow-md">
<div className="flex items-center justify-between border-b border-slate-800 pb-2"> <div className="flex items-center justify-between border-b border-slate-800 pb-2 gap-2 flex-wrap">
<span className="text-xs font-extrabold text-amber-400 uppercase tracking-wider flex items-center gap-1.5"> <span className="text-xs font-extrabold text-amber-400 uppercase tracking-wider flex items-center gap-1.5">
<Sparkles className="w-4 h-4" /> Gemini 3.6 Flash Şartname Özeti <Sparkles className="w-4 h-4" /> Şartname Özeti
</span>
<span className="text-[10px] text-slate-400">
{results.model} · {results.analyzedAt}
</span> </span>
<span className="text-[10px] text-slate-400">Analiz: {results.analyzedAt}</span>
</div> </div>
<p className="text-xs text-slate-300 leading-relaxed font-medium">{results.summary}</p> <p className="text-xs text-slate-300 leading-relaxed font-medium">{results.summary || 'Özet üretilemedi.'}</p>
{results.fileName && <p className="text-[10px] text-slate-500">Kaynak: {results.fileName}</p>}
</div> </div>
{/* Bidding Recommendation Box (Kritik Fark Yaratacak Özellik) */} {/* Teklif analizi — yalnızca model sayı ürettiyse */}
{(bid.estimatedCost != null || bid.recommendedBid != null) && (
<div className="bg-linear-to-br from-emerald-950 via-slate-900 to-blue-950 text-white p-6 rounded-2xl space-y-4 shadow-xl border border-emerald-800/40"> <div className="bg-linear-to-br from-emerald-950 via-slate-900 to-blue-950 text-white p-6 rounded-2xl space-y-4 shadow-xl border border-emerald-800/40">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-400" /> <TrendingUp className="w-5 h-5 text-emerald-400" />
<h3 className="font-black text-base">Gemini 3.6 Flash Önerilen İhale Teklif Aralığı</h3> <h3 className="font-black text-base">Önerilen Teklif Aralığı</h3>
</div> </div>
{bid.winProbability != null && (
<span className="bg-emerald-500 text-slate-950 font-black text-xs px-3 py-1 rounded-full shadow-xs"> <span className="bg-emerald-500 text-slate-950 font-black text-xs px-3 py-1 rounded-full shadow-xs">
%{calcProbability(customBidSlider)} Kazanma Şansı %{bid.winProbability} Kazanma Tahmini
</span> </span>
)}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
<div className="bg-white/10 p-3.5 rounded-xl border border-white/10"> <div className="bg-white/10 p-3.5 rounded-xl border border-white/10">
<span className="text-[11px] text-slate-300 font-medium block">İdare Yaklaşık Maliyeti</span> <span className="text-[11px] text-slate-300 font-medium block">İdare Yaklaşık Maliyeti</span>
<span className="text-base font-black text-white mt-1 block"> <span className="text-base font-black text-white mt-1 block">{tl(bid.estimatedCost)}</span>
₺{results.bidAnalysis.estimatedCost.toLocaleString('tr-TR')}
</span>
</div> </div>
<div className="bg-emerald-500/20 p-3.5 rounded-xl border border-emerald-400/40"> <div className="bg-emerald-500/20 p-3.5 rounded-xl border border-emerald-400/40">
<span className="text-[11px] text-emerald-300 font-bold block">Önerilen Optimal Teklif</span> <span className="text-[11px] text-emerald-300 font-bold block">Önerilen Optimal Teklif</span>
<span className="text-xl font-black text-amber-300 mt-0.5 block"> <span className="text-xl font-black text-amber-300 mt-0.5 block">{tl(bid.recommendedBid)}</span>
₺{results.bidAnalysis.recommendedBid.toLocaleString('tr-TR')}
</span>
</div> </div>
<div className="bg-white/10 p-3.5 rounded-xl border border-white/10"> <div className="bg-white/10 p-3.5 rounded-xl border border-white/10">
<span className="text-[11px] text-slate-300 font-medium block">Geçmiş EKAP İndirim Ort.</span> <span className="text-[11px] text-slate-300 font-medium block">Rekabet Değerlendirmesi</span>
<span className="text-base font-black text-blue-300 mt-1 block"> <span className="text-xs font-bold text-blue-300 mt-1 block leading-snug">{bid.competitionLevel || '—'}</span>
{results.bidAnalysis.historicalDiscountAvg}
</span>
</div> </div>
</div> </div>
{/* Custom Bid Price Interactive Slider */} {bid.minBidLimit != null && bid.maxBidLimit != null && bid.maxBidLimit > bid.minBidLimit && (
<div className="bg-black/30 p-4 rounded-xl space-y-2 border border-white/10"> <div className="bg-black/30 p-4 rounded-xl space-y-2 border border-white/10">
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="font-bold text-slate-300 flex items-center gap-1.5"> <span className="font-bold text-slate-300 flex items-center gap-1.5">
<Sliders className="w-4 h-4 text-amber-400" /> Teklif Fiyatınızı Simüle Edin: <Sliders className="w-4 h-4 text-amber-400" /> Teklif Fiyatınızı Simüle Edin:
</span> </span>
<span className="font-black text-sm text-emerald-400">₺{customBidSlider.toLocaleString('tr-TR')}</span> <span className="font-black text-sm text-emerald-400">{tl(customBid ?? bid.recommendedBid)}</span>
</div> </div>
<input <input
type="range" type="range"
min={results.bidAnalysis.minBidLimit} min={bid.minBidLimit}
max={results.bidAnalysis.maxBidLimit} max={bid.maxBidLimit}
step={500} step={Math.max(1, Math.round((bid.maxBidLimit - bid.minBidLimit) / 200))}
value={customBidSlider} value={customBid ?? bid.recommendedBid ?? bid.minBidLimit}
onChange={(e) => setCustomBidSlider(Number(e.target.value))} onChange={(e) => setCustomBid(Number(e.target.value))}
className="w-full accent-emerald-500 cursor-pointer h-2 bg-slate-800 rounded-lg" className="w-full accent-emerald-500 cursor-pointer h-2 bg-slate-800 rounded-lg"
/> />
<div className="flex justify-between text-[10px] text-slate-400 font-medium"> <div className="flex justify-between text-[10px] text-slate-400 font-medium">
<span>Min Limit: ₺{results.bidAnalysis.minBidLimit.toLocaleString('tr-TR')}</span> <span>Min: {tl(bid.minBidLimit)}</span>
<span className="text-amber-300 font-bold">Önerilen: ₺{results.bidAnalysis.recommendedBid.toLocaleString('tr-TR')}</span> <span className="text-amber-300 font-bold">Önerilen: {tl(bid.recommendedBid)}</span>
<span>Maks Limit: ₺{results.bidAnalysis.maxBidLimit.toLocaleString('tr-TR')}</span> <span>Maks: {tl(bid.maxBidLimit)}</span>
</div> </div>
</div> </div>
)}
{bid.notes && <p className="text-[11px] text-slate-400 leading-relaxed">{bid.notes}</p>}
</div>
)}
</div>
{/* 2-Column Details: Qualifications & Risks */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Qualification Criteria */}
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3"> <div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3">
<h4 className="font-extrabold text-sm text-slate-900 flex items-center gap-2 border-b border-slate-100 pb-2"> <h4 className="font-extrabold text-sm text-slate-900 flex items-center gap-2 border-b border-slate-100 pb-2">
<CheckCircle2 className="w-4 h-4 text-emerald-600" /> Yeterlik Kriterleri & Zorunlu Belgeler <CheckCircle2 className="w-4 h-4 text-emerald-600" /> Yeterlik Kriterleri & Zorunlu Belgeler
</h4> </h4>
<ul className="space-y-2 text-xs text-slate-700"> <ul className="space-y-2 text-xs text-slate-700">
{results.qualificationCriteria.map((item, idx) => ( {(results.qualificationCriteria || []).length === 0 && (
<li className="text-slate-400">Belgede yeterlik kriteri tespit edilemedi.</li>
)}
{(results.qualificationCriteria || []).map((item, idx) => (
<li key={idx} className="flex items-start gap-2 bg-slate-50 p-2.5 rounded-lg border border-slate-200 font-medium"> <li key={idx} className="flex items-start gap-2 bg-slate-50 p-2.5 rounded-lg border border-slate-200 font-medium">
<span className="w-2 h-2 rounded-full bg-emerald-600 mt-1.5 shrink-0"></span> <span className="w-2 h-2 rounded-full bg-emerald-600 mt-1.5 shrink-0"></span>
<span>{item}</span> <span>{item}</span>
</li> </li>
))} ))}
</ul> </ul>
{(results.criticalRequirements || []).length > 0 && (
<>
<h4 className="font-extrabold text-xs text-slate-900 pt-2 border-t border-slate-100">Kritik Şartlar</h4>
<ul className="space-y-1.5 text-xs text-slate-700">
{results.criticalRequirements.map((item, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-600 mt-1.5 shrink-0"></span>
<span>{item}</span>
</li>
))}
</ul>
</>
)}
</div> </div>
{/* Risks & Penalty Clauses */}
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3"> <div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3">
<h4 className="font-extrabold text-sm text-slate-900 flex items-center gap-2 border-b border-slate-100 pb-2"> <h4 className="font-extrabold text-sm text-slate-900 flex items-center gap-2 border-b border-slate-100 pb-2">
<AlertTriangle className="w-4 h-4 text-rose-600" /> Riskler & Şartname Cezai Hükümleri <AlertTriangle className="w-4 h-4 text-rose-600" /> Riskler & Cezai Hükümler
</h4> </h4>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
{results.risksAndPenalties.map((risk, idx) => ( {(results.risksAndPenalties || []).length === 0 && (
<p className="text-slate-400">Belgede cezai hüküm/risk tespit edilemedi.</p>
)}
{(results.risksAndPenalties || []).map((risk, idx) => (
<div key={idx} className="p-3 bg-slate-50 rounded-lg border border-slate-200 space-y-1"> <div key={idx} className="p-3 bg-slate-50 rounded-lg border border-slate-200 space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<span className="font-bold text-slate-900">{risk.clause}</span> <span className="font-bold text-slate-900">{risk.clause || '—'}</span>
<span className={`text-[10px] font-extrabold px-2 py-0.5 rounded ${ <span className={`text-[10px] font-extrabold px-2 py-0.5 rounded shrink-0 ${
risk.level === 'Yüksek' ? 'bg-red-100 text-red-800' : risk.level === 'Yüksek' ? 'bg-red-100 text-red-800' :
risk.level === 'Orta' ? 'bg-amber-100 text-amber-800' : 'bg-slate-200 text-slate-800' risk.level === 'Orta' ? 'bg-amber-100 text-amber-800' : 'bg-slate-200 text-slate-800'
}`}> }`}>
{risk.level} Risk {risk.level || 'Bilgi'} Risk
</span> </span>
</div> </div>
<p className="text-slate-600 leading-snug">{risk.text}</p> <p className="text-slate-600 leading-snug">{risk.text}</p>
@@ -265,15 +314,16 @@ export const AiSpecAnalyzerModal = () => {
))} ))}
</div> </div>
</div> </div>
</div> </div>
<p className="text-[10px] text-slate-400 text-center leading-relaxed">
Analiz yapay zeka tarafından üretilmiştir; teklif kararı öncesi şartnamenin aslını doğrulayın.
</p>
</div> </div>
)} )}
</div> </div>
{/* Footer */}
<div className="bg-slate-50 p-4 border-t border-slate-200 flex justify-end"> <div className="bg-slate-50 p-4 border-t border-slate-200 flex justify-end">
<button <button
onClick={() => setIsAiSpecAnalyzerOpen(false)} onClick={() => setIsAiSpecAnalyzerOpen(false)}
+312
View File
@@ -0,0 +1,312 @@
import React, { useEffect, useRef, useState } from 'react';
import {
ArrowRight, Bell, Building2, CalendarDays, ChartNoAxesCombined, CheckCircle2,
FileSearch, Gavel, Kanban, MapPin, Radar, Sparkles, Timer, TrendingUp, Zap,
} from 'lucide-react';
/* ---------- yardımcılar ---------- */
const useReveal = () => {
useEffect(() => {
const els = document.querySelectorAll('.lp-reveal');
const io = new IntersectionObserver(
(entries) => entries.forEach((e) => e.isIntersecting && e.target.classList.add('lp-in')),
{ threshold: 0.14 }
);
els.forEach((el) => io.observe(el));
return () => io.disconnect();
}, []);
};
const CountUp = ({ to, suffix = '', duration = 1600 }) => {
const ref = useRef(null);
const [val, setVal] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
let raf, started = false;
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting || started) return;
started = true;
const t0 = performance.now();
const tick = (t) => {
const p = Math.min(1, (t - t0) / duration);
setVal(Math.round(to * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
}, { threshold: 0.5 });
io.observe(el);
return () => { io.disconnect(); cancelAnimationFrame(raf); };
}, [to, duration]);
return <span ref={ref}>{val.toLocaleString('tr-TR')}{suffix}</span>;
};
/* ---------- örnek akış verisi (görsel amaçlı) ---------- */
const TICKER = [
{ k: 'Yapım İşi', t: 'Okul binası güçlendirme', c: 'Ankara', renk: '#fbbf24' },
{ k: 'Mal Alımı', t: 'Tıbbi sarf malzemesi', c: 'İzmir', renk: '#22d3ee' },
{ k: 'Hizmet', t: 'Personel taşıma 12 ay', c: 'Bursa', renk: '#a78bfa' },
{ k: '22/d', t: 'Bilgisayar ve çevre birimleri', c: 'İstanbul', renk: '#34d399' },
{ k: 'Yapım İşi', t: 'İçme suyu hattı yenileme', c: 'Konya', renk: '#fbbf24' },
{ k: 'Mal Alımı', t: 'Araç lastiği alımı', c: 'Antalya', renk: '#22d3ee' },
{ k: 'Hizmet', t: 'Yemek hazırlama ve dağıtım', c: 'Gaziantep', renk: '#a78bfa' },
{ k: '22/d', t: 'Kırtasiye malzemesi', c: 'Trabzon', renk: '#34d399' },
];
const OZELLIKLER = [
{ icon: Zap, baslik: 'Canlı ihale akışı', metin: 'ilan.gov.tr ilanları dakikalar içinde ekranınızda. Şehir, sektör ve bütçeye göre süzün; hiçbir fırsatı kaçırmayın.', renk: 'from-cyan-400/20 to-cyan-400/0', ikon: 'text-cyan-300' },
{ icon: FileSearch, baslik: 'AI şartname analizi', metin: 'Yüzlerce sayfalık şartnameyi yükleyin; yeterlilik şartları, riskler ve kritik tarihler saniyeler içinde özetlensin.', renk: 'from-amber-400/20 to-amber-400/0', ikon: 'text-amber-300' },
{ icon: Kanban, baslik: 'Kanban süreç takibi', metin: 'İlgilendiğiniz ihaleleri panoya taşıyın: İnceleme → Teklif Hazırlığı → Verildi → Sonuç. Ekipçe aynı ekranda.', renk: 'from-violet-400/20 to-violet-400/0', ikon: 'text-violet-300' },
{ icon: Radar, baslik: 'Rakip radarı', metin: 'Hangi firma hangi ihaleyi, hangi bedelle kazandı? Rakiplerinizin izini sürün, stratejinizi veriyle kurun.', renk: 'from-rose-400/20 to-rose-400/0', ikon: 'text-rose-300' },
{ icon: TrendingUp, baslik: 'Fiyat & piyasa analizi', metin: 'Geçmiş sonuçlardan yaklaşık maliyet ve kırım oranı analizi. Teklifinizi tahminle değil, veriyle verin.', renk: 'from-emerald-400/20 to-emerald-400/0', ikon: 'text-emerald-300' },
{ icon: Bell, baslik: 'Günlük bülten & uyarılar', metin: 'Kriterlerinize uyan yeni ilanlar her sabah e-postanızda. Son teklif tarihi yaklaşınca otomatik hatırlatma.', renk: 'from-sky-400/20 to-sky-400/0', ikon: 'text-sky-300' },
];
/* ---------- bileşenler ---------- */
const TickerRow = ({ reverse = false }) => (
<div className="lp-ticker-mask overflow-hidden">
<div className={`flex w-max gap-3 ${reverse ? 'lp-marquee-rev' : 'lp-marquee'}`}>
{[...TICKER, ...TICKER].map((x, i) => (
<div key={i} className="flex shrink-0 items-center gap-2.5 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm backdrop-blur">
<span className="h-2 w-2 rounded-full" style={{ background: x.renk }} />
<span className="font-semibold text-white/90">{x.t}</span>
<span className="text-white/40">·</span>
<span className="flex items-center gap-1 text-white/50"><MapPin size={12} />{x.c}</span>
<span className="rounded-full px-2 py-0.5 text-[11px] font-bold" style={{ background: `${x.renk}22`, color: x.renk }}>{x.k}</span>
</div>
))}
</div>
</div>
);
const MockDashboard = () => (
<div className="relative mx-auto w-full max-w-xl lp-float">
<div className="absolute -inset-8 rounded-[2.5rem] bg-gradient-to-tr from-cyan-500/25 via-violet-500/15 to-amber-400/25 blur-3xl" aria-hidden="true" />
<div className="relative overflow-hidden rounded-2xl border border-white/12 bg-[#101a2e] shadow-[0_40px_120px_-30px_rgba(34,211,238,0.35)]">
<div className="flex h-11 items-center gap-2 border-b border-white/8 bg-white/[0.04] px-4">
<span className="h-2.5 w-2.5 rounded-full bg-rose-400/80" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-400/80" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400/80" />
<span className="ml-3 flex items-center gap-1.5 text-xs font-semibold text-white/50">
<span className="lp-blink h-1.5 w-1.5 rounded-full bg-emerald-400" /> ihaleist · canlı akış
</span>
</div>
<div className="space-y-2.5 p-4">
{[
{ t: 'Hastane ek bina inşaatı', b: '₺48,2M', s: 'Teklif Hazırlığı', sr: 'bg-amber-400/15 text-amber-300', g: 8, d: 0 },
{ t: 'Ağ altyapısı yenileme', b: '₺6,7M', s: 'Yeni İlan', sr: 'bg-cyan-400/15 text-cyan-300', g: 3, d: 1 },
{ t: 'Araç kiralama 24 ay', b: '₺12,4M', s: 'İnceleme', sr: 'bg-violet-400/15 text-violet-300', g: 15, d: 2 },
].map((r, i) => (
<div key={i} className="lp-card-in rounded-xl border border-white/8 bg-white/[0.04] p-3.5 transition-colors hover:bg-white/[0.07]" style={{ animationDelay: `${0.35 + r.d * 0.22}s` }}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Gavel size={14} className="shrink-0 text-cyan-300" />
<span className="truncate text-sm font-bold text-white">{r.t}</span>
</div>
<div className="mt-1.5 flex items-center gap-3 text-xs text-white/45">
<span className="flex items-center gap-1"><Building2 size={11} /> Kamu kurumu</span>
<span className="flex items-center gap-1"><Timer size={11} /> {r.g} gün kaldı</span>
</div>
</div>
<div className="text-right">
<div className="font-mono text-sm font-black text-emerald-300">{r.b}</div>
<span className={`mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-bold ${r.sr}`}>{r.s}</span>
</div>
</div>
</div>
))}
<div className="grid grid-cols-4 items-end gap-2 rounded-xl border border-white/8 bg-white/[0.04] p-3.5">
<div className="col-span-4 mb-1 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs font-semibold text-white/60"><ChartNoAxesCombined size={13} className="text-amber-300" /> Kırım oranı analizi</span>
<span className="text-[10px] font-bold text-white/35">SON 6 AY</span>
</div>
{[42, 68, 55, 80, 62, 90, 74, 96].map((h, i) => (
<div key={i} className="lp-bar rounded-t-md bg-gradient-to-t from-cyan-500/60 to-cyan-300" style={{ '--h': `${h * 0.5}px`, animationDelay: `${0.9 + i * 0.09}s` }} />
))}
</div>
</div>
</div>
<div className="lp-sticker absolute -right-4 -top-5 rotate-6 rounded-xl border border-amber-300/30 bg-amber-400 px-3.5 py-2 text-xs font-black text-amber-950 shadow-lg shadow-amber-500/30">
<Sparkles size={13} className="mr-1 inline" /> AI özetledi: 3 kritik şart
</div>
<div className="lp-sticker absolute -bottom-4 -left-3 -rotate-3 rounded-xl border border-white/15 bg-[#16233c] px-3.5 py-2 text-xs font-bold text-white shadow-xl" style={{ animationDelay: '1.4s' }}>
🔔 Bugün size uyan <span className="text-cyan-300">14 yeni ilan</span>
</div>
</div>
);
const Adim = ({ no, baslik, metin, gecikme }) => (
<div className="lp-reveal group relative rounded-2xl border border-white/10 bg-white/[0.04] p-7 transition duration-300 hover:-translate-y-1.5 hover:border-cyan-300/30 hover:bg-white/[0.06]" style={{ transitionDelay: gecikme }}>
<div className="font-mono text-5xl font-black text-white/8 transition group-hover:text-cyan-300/20">{no}</div>
<h3 className="mt-4 text-xl font-black text-white">{baslik}</h3>
<p className="mt-2.5 text-sm leading-7 text-white/55">{metin}</p>
</div>
);
/* ---------- sayfa ---------- */
export const LandingPage = ({ onGiris }) => {
useReveal();
const yil = new Date().getFullYear();
return (
<div className="lp-root min-h-screen overflow-x-hidden bg-[#0a1122] text-white antialiased">
{/* arkaplan */}
<div className="pointer-events-none fixed inset-0" aria-hidden="true">
<div className="lp-blob absolute -top-40 left-1/4 h-[520px] w-[520px] rounded-full bg-cyan-500/14 blur-[110px]" />
<div className="lp-blob absolute right-[-10%] top-1/3 h-[460px] w-[460px] rounded-full bg-violet-500/12 blur-[110px]" style={{ animationDelay: '-6s' }} />
<div className="lp-blob absolute bottom-[-15%] left-[-5%] h-[420px] w-[420px] rounded-full bg-amber-400/10 blur-[110px]" style={{ animationDelay: '-12s' }} />
<div className="lp-grid absolute inset-0 opacity-[0.35]" />
</div>
{/* nav */}
<header className="relative z-40 mx-auto flex max-w-7xl items-center justify-between px-5 py-5 sm:px-8">
<div className="flex items-center gap-2.5">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-cyan-400 to-blue-600 shadow-lg shadow-cyan-500/30">
<Gavel size={20} className="text-white" />
</span>
<span className="text-xl font-black tracking-tight">ihale<span className="text-cyan-300">ist</span></span>
</div>
<nav className="hidden items-center gap-8 text-sm font-semibold text-white/60 md:flex">
<a href="#ozellikler" className="transition hover:text-white">Özellikler</a>
<a href="#nasil" className="transition hover:text-white">Nasıl çalışır?</a>
<a href="#rakamlar" className="transition hover:text-white">Rakamlarla</a>
</nav>
<button onClick={onGiris} className="group flex items-center gap-2 rounded-xl bg-white px-5 py-2.5 text-sm font-black text-slate-900 transition hover:bg-cyan-300">
Giriş Yap <ArrowRight size={15} className="transition group-hover:translate-x-0.5" />
</button>
</header>
{/* hero */}
<section className="relative z-10 mx-auto max-w-7xl px-5 pb-16 pt-10 sm:px-8 sm:pt-16">
<div className="grid items-center gap-14 lg:grid-cols-[1.02fr_0.98fr]">
<div>
<div className="lp-hero-in inline-flex items-center gap-2 rounded-full border border-cyan-300/25 bg-cyan-400/10 px-4 py-1.5 text-xs font-bold text-cyan-200">
<span className="lp-blink h-1.5 w-1.5 rounded-full bg-emerald-400" />
ilan.gov.tr akışı şu anda canlı
</div>
<h1 className="lp-hero-in mt-6 text-5xl font-black leading-[1.04] tracking-tight sm:text-6xl xl:text-7xl" style={{ animationDelay: '0.08s' }}>
Kamu ihalelerini
<br />
<span className="lp-gradient-text">kaçırmayan</span> firma,
<br />
kazanan firmadır.
</h1>
<p className="lp-hero-in mt-6 max-w-xl text-lg leading-8 text-white/60" style={{ animationDelay: '0.16s' }}>
İhaleist; canlı ilan akışını, yapay zekâ destekli şartname analizini, rakip takibini ve
fiyat analizini tek panelde toplar. Siz teklife odaklanın, takibi biz yapalım.
</p>
<div className="lp-hero-in mt-9 flex flex-col gap-3 sm:flex-row" style={{ animationDelay: '0.24s' }}>
<button onClick={onGiris} className="group flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-cyan-400 to-blue-500 px-7 py-3.5 text-base font-black text-white shadow-xl shadow-cyan-500/25 transition hover:shadow-cyan-400/40 hover:brightness-110">
Hemen Başla — Ücretsiz <ArrowRight size={17} className="transition group-hover:translate-x-1" />
</button>
<a href="#ozellikler" className="flex items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/[0.05] px-7 py-3.5 text-base font-bold text-white/85 backdrop-blur transition hover:border-white/30 hover:bg-white/10">
Neler yapabilir?
</a>
</div>
<div className="lp-hero-in mt-7 flex flex-wrap gap-x-6 gap-y-2 text-sm font-medium text-white/45" style={{ animationDelay: '0.32s' }}>
<span className="flex items-center gap-1.5"><CheckCircle2 size={15} className="text-emerald-400" /> Google ile 10 saniyede giriş</span>
<span className="flex items-center gap-1.5"><CheckCircle2 size={15} className="text-emerald-400" /> Kurulum gerekmez</span>
<span className="flex items-center gap-1.5"><CheckCircle2 size={15} className="text-emerald-400" /> KVKK uyumlu</span>
</div>
</div>
<MockDashboard />
</div>
</section>
{/* akış şeridi */}
<section className="relative z-10 space-y-3 py-6">
<TickerRow />
<TickerRow reverse />
</section>
{/* rakamlar */}
<section id="rakamlar" className="relative z-10 mx-auto max-w-7xl scroll-mt-24 px-5 py-16 sm:px-8">
<div className="lp-reveal grid gap-4 rounded-3xl border border-white/10 bg-gradient-to-r from-cyan-500/[0.08] via-white/[0.04] to-violet-500/[0.08] p-8 sm:grid-cols-2 sm:p-10 lg:grid-cols-4">
{[
{ v: 2400, s: '+', l: 'Günlük yeni ilan taranıyor', i: Zap, r: 'text-cyan-300' },
{ v: 81, s: '', l: 'İl genelinde takip', i: MapPin, r: 'text-amber-300' },
{ v: 15000, s: '+', l: 'Kayıtlı kamu kurumu', i: Building2, r: 'text-violet-300' },
{ v: 30, s: ' sn', l: 'Ortalama şartname özeti', i: Sparkles, r: 'text-emerald-300' },
].map((x) => (
<div key={x.l} className="flex flex-col items-center gap-1 text-center">
<x.i size={22} className={x.r} />
<div className="mt-2 text-4xl font-black tracking-tight"><CountUp to={x.v} suffix={x.s} /></div>
<div className="text-sm font-medium text-white/50">{x.l}</div>
</div>
))}
</div>
</section>
{/* özellikler */}
<section id="ozellikler" className="relative z-10 mx-auto max-w-7xl scroll-mt-24 px-5 py-16 sm:px-8">
<div className="lp-reveal max-w-2xl">
<span className="text-xs font-black uppercase tracking-[0.22em] text-cyan-300">Tek panel, tam kontrol</span>
<h2 className="mt-4 text-4xl font-black tracking-tight sm:text-5xl">İhale masanızın <span className="lp-underline">tamamı</span> burada.</h2>
<p className="mt-4 text-lg leading-8 text-white/55">EKAP'ta, gazete ilanlarında, kurum sitelerinde dağınık duran her şey — tek akışta, süzülmüş ve önceliklendirilmiş.</p>
</div>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{OZELLIKLER.map((o, i) => (
<article key={o.baslik} className="lp-reveal group relative overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04] p-7 transition duration-300 hover:-translate-y-1.5 hover:border-white/25" style={{ transitionDelay: `${(i % 3) * 90}ms` }}>
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-br opacity-0 transition duration-300 group-hover:opacity-100 ${o.renk}`} aria-hidden="true" />
<div className="relative">
<span className={`flex h-12 w-12 items-center justify-center rounded-xl border border-white/10 bg-white/[0.06] ${o.ikon}`}><o.icon size={22} /></span>
<h3 className="mt-6 text-xl font-black">{o.baslik}</h3>
<p className="mt-2.5 text-sm leading-7 text-white/55">{o.metin}</p>
</div>
</article>
))}
</div>
</section>
{/* nasıl çalışır */}
<section id="nasil" className="relative z-10 mx-auto max-w-7xl scroll-mt-24 px-5 py-16 sm:px-8">
<div className="lp-reveal text-center">
<span className="text-xs font-black uppercase tracking-[0.22em] text-amber-300">Nasıl çalışır?</span>
<h2 className="mt-4 text-4xl font-black tracking-tight sm:text-5xl">Üç adımda ihale radarınız hazır.</h2>
</div>
<div className="mt-12 grid gap-5 lg:grid-cols-3">
<Adim no="01" baslik="Giriş yapın, kriterinizi kurun" metin="Google hesabınızla girin; sektör, şehir ve bütçe aralığınızı seçin. Akıllı filtre motoru gerisini halleder." gecikme="0ms" />
<Adim no="02" baslik="Akışı ve bülteni takip edin" metin="Size uyan ilanlar panelde ve her sabah e-posta bülteninizde. Şartnameyi AI'a özetletin, riskleri görün." gecikme="120ms" />
<Adim no="03" baslik="Teklif verin, süreci yönetin" metin="Kanban'da süreci ekiple yürütün; sonuç ilanlarından rakip ve fiyat analizine geçip bir sonraki teklifinizi güçlendirin." gecikme="240ms" />
</div>
</section>
{/* son çağrı */}
<section className="relative z-10 mx-auto max-w-7xl px-5 py-20 sm:px-8">
<div className="lp-reveal relative overflow-hidden rounded-[2rem] border border-cyan-300/20 bg-gradient-to-br from-[#0e2036] via-[#101a2e] to-[#1a1035] px-7 py-16 text-center shadow-2xl sm:px-14">
<div className="lp-blob absolute -right-24 -top-24 h-72 w-72 rounded-full bg-cyan-400/15 blur-[90px]" aria-hidden="true" />
<div className="lp-blob absolute -bottom-24 -left-24 h-72 w-72 rounded-full bg-amber-400/12 blur-[90px]" style={{ animationDelay: '-8s' }} aria-hidden="true" />
<div className="relative">
<CalendarDays size={34} className="mx-auto text-cyan-300" />
<h2 className="mx-auto mt-5 max-w-2xl text-4xl font-black tracking-tight sm:text-5xl">Yarınki ilanlar sizi beklemeyecek.</h2>
<p className="mx-auto mt-4 max-w-xl text-lg leading-8 text-white/55">Bugün girin, kriterlerinizi kurun; yarın sabah ilk bülteniniz hazır olsun.</p>
<button onClick={onGiris} className="group mx-auto mt-9 flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-400 to-blue-500 px-8 py-4 text-lg font-black text-white shadow-xl shadow-cyan-500/30 transition hover:shadow-cyan-400/50 hover:brightness-110">
Ücretsiz Giriş Yap <ArrowRight size={19} className="transition group-hover:translate-x-1" />
</button>
</div>
</div>
</section>
{/* footer */}
<footer className="relative z-10 border-t border-white/8 px-5 py-9 sm:px-8">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-5 text-sm text-white/40 sm:flex-row">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-400 to-blue-600"><Gavel size={14} className="text-white" /></span>
<span className="font-black text-white/80">ihale<span className="text-cyan-300">ist</span></span>
<span className="ml-2">© {yil}</span>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 font-semibold">
<a href="/gizlilik.html" className="transition hover:text-white">Gizlilik Politikası</a>
<a href="/kullanim-kosullari.html" className="transition hover:text-white">Kullanım Şartları</a>
<button onClick={onGiris} className="transition hover:text-white">Giriş</button>
</div>
</div>
</footer>
</div>
);
};
+5 -1
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu } from 'lucide-react'; import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu, ShieldAlert, Globe2, Gavel, CreditCard } from 'lucide-react';
export const TopNavigation = () => { export const TopNavigation = () => {
const { activeTab, setActiveTab, isAdmin } = useApp(); const { activeTab, setActiveTab, isAdmin } = useApp();
@@ -17,6 +17,10 @@ export const TopNavigation = () => {
{ id: 'rakip_radar', label: 'Rakip Radar', icon: Radar, badge: 'Canlı Uyar' }, { id: 'rakip_radar', label: 'Rakip Radar', icon: Radar, badge: 'Canlı Uyar' },
{ id: 'dokuman_deposu', label: 'Doküman Deposu', icon: FolderKanban }, { id: 'dokuman_deposu', label: 'Doküman Deposu', icon: FolderKanban },
{ id: 'analitik_dashboard', label: 'Analitik Dashboard', icon: BarChart3 }, { id: 'analitik_dashboard', label: 'Analitik Dashboard', icon: BarChart3 },
{ id: 'kirmizi_bayrak', label: 'Kırmızı Bayrak', icon: ShieldAlert, badge: 'Şeffaflık' },
{ id: 'kik_kararlari', label: 'KİK Kurul Kararları', icon: Gavel },
{ id: 'ted_eu', label: 'AB İhaleleri (TED)', icon: Globe2 },
{ id: 'planlar', label: 'Planlar', icon: CreditCard },
{ id: 'api_webhooks', label: 'API & Webhooks', icon: Webhook }, { id: 'api_webhooks', label: 'API & Webhooks', icon: Webhook },
// Sadece admin: AI Model Ayarları // Sadece admin: AI Model Ayarları
...(isAdmin ? [{ id: 'ai_ayarlari', label: 'AI Model Ayarları', icon: Cpu, badge: 'Admin' }] : []), ...(isAdmin ? [{ id: 'ai_ayarlari', label: 'AI Model Ayarları', icon: Cpu, badge: 'Admin' }] : []),
+165
View File
@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { useApp } from '../../context/AppContext';
import { Gavel, Search, Loader2, ExternalLink, AlertTriangle, Calendar, Building2, Info } from 'lucide-react';
export const KikDecisions = () => {
const { tenders } = useApp();
const [q, setQ] = useState('');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const ara = async (terim) => {
const t = (terim ?? q).trim();
if (!t) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/kik/search?q=${encodeURIComponent(t)}&limit=20`);
const d = await res.json();
if (d.success) setData(d);
else { setError(d.error || 'Arama başarısız'); setData(null); }
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
// Takipteki ihalelerin idarelerinden hızlı arama önerileri
const oneriler = [...new Set(
tenders.filter((t) => t.takipte || t.kanbanStatus).map((t) => t.kurum).filter(Boolean)
)].slice(0, 4);
return (
<div className="space-y-6 animate-fade-in">
<div className="bg-linear-to-r from-slate-900 via-amber-950 to-orange-900 text-white p-6 rounded-2xl shadow-lg border border-amber-800/40">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-amber-600 flex items-center justify-center shadow-md">
<Gavel className="w-6 h-6" />
</div>
<div>
<h2 className="font-extrabold text-xl">KİK Kurul Kararları</h2>
<p className="text-xs text-amber-200 mt-0.5">
Kamu İhale Kurulu itirazen şikâyet kararlarında arama yapın; emsal kararları teklif öncesi inceleyin.
</p>
</div>
</div>
</div>
{/* Arama */}
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && ara()}
placeholder="Karar metni, idare adı veya başvuran firma…"
className="w-full bg-slate-50 border border-slate-300 rounded-lg pl-9 pr-4 py-2.5 text-xs font-semibold"
/>
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
</div>
<button
onClick={() => ara()}
disabled={loading || !q.trim()}
className="bg-amber-600 hover:bg-amber-500 disabled:opacity-40 text-white font-bold text-xs px-5 rounded-lg flex items-center gap-2"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />} Ara
</button>
</div>
{oneriler.length > 0 && (
<div className="flex gap-2 flex-wrap items-center">
<span className="text-[11px] text-slate-400 font-semibold">Takibinizdeki idareler:</span>
{oneriler.map((k) => (
<button key={k} onClick={() => { setQ(k); ara(k); }}
className="text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 px-2 py-1 rounded-md font-medium truncate max-w-[220px]">
{k}
</button>
))}
</div>
)}
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-bold text-red-900">KİK kararlarına ulaşılamadı</p>
<p className="text-xs text-red-800 mt-0.5 leading-relaxed">{error}</p>
<p className="text-[11px] text-red-700 mt-1">
KİK sunucusu zaman zaman otomatik isteklere kapalı olabilir; birkaç dakika sonra tekrar deneyin.
</p>
</div>
</div>
)}
{data && (
<>
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-xs text-slate-500">
<strong className="text-slate-900">{data.totalCount?.toLocaleString('tr-TR') ?? data.results.length}</strong> karar bulundu
</p>
{data.kaynak && <span className="text-[11px] text-slate-400">Kaynak: {data.kaynak}</span>}
</div>
<div className="space-y-3">
{data.results.length === 0 && (
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">
<p className="text-sm font-bold text-slate-500">Bu aramada karar bulunamadı</p>
</div>
)}
{data.results.map((k, i) => (
<div key={k.id || i} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md transition-shadow space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{k.kararNo && (
<span className="text-[10px] font-extrabold bg-amber-100 text-amber-900 px-2 py-0.5 rounded">
{k.kararNo}
</span>
)}
{k.kararTarihi && (
<span className="text-[11px] text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" /> {k.kararTarihi}
</span>
)}
{k.tur && <span className="text-[10px] bg-slate-100 text-slate-700 px-2 py-0.5 rounded font-bold">{k.tur}</span>}
</div>
<h3 className="font-bold text-sm text-slate-900 mt-1.5 leading-snug">{k.baslik || k.konu || 'Kurul Kararı'}</h3>
</div>
{k.link && (
<a href={k.link} target="_blank" rel="noopener noreferrer" className="text-amber-700 hover:text-amber-900 shrink-0" title="Kararı aç">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
{(k.idare || k.basvuran) && (
<div className="flex items-center gap-3 flex-wrap text-[11px] text-slate-600">
{k.idare && <span className="flex items-center gap-1"><Building2 className="w-3 h-3 text-slate-400" /> {k.idare}</span>}
{k.basvuran && <span>Başvuran: <strong>{k.basvuran}</strong></span>}
</div>
)}
{k.ozet && <p className="text-xs text-slate-600 leading-relaxed line-clamp-3">{k.ozet}</p>}
</div>
))}
</div>
</>
)}
{!data && !loading && !error && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex gap-3">
<Info className="w-4 h-4 text-blue-600 shrink-0 mt-0.5" />
<p className="text-xs text-blue-900 leading-relaxed">
Bir idare adı, firma veya konu yazıp arayın. KİK kurul kararları, benzer ihalelerde
hangi itirazların kabul/ret edildiğini gösterir — teklif ve şikâyet stratejinizi buna göre kurabilirsiniz.
</p>
</div>
)}
</div>
);
};
+148
View File
@@ -0,0 +1,148 @@
import React, { useState, useEffect } from 'react';
import { useApp } from '../../context/AppContext';
import { getToken } from '../../services/auth';
import { Check, Sparkles, CreditCard, Loader2, Info } from 'lucide-react';
export const Pricing = () => {
const { authUser } = useApp();
const [data, setData] = useState(null);
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const [yillik, setYillik] = useState(false);
useEffect(() => {
fetch('/api/billing/plans')
.then((r) => r.json())
.then(setData)
.catch(() => setData({ configured: false, plans: [] }));
}, []);
const upgrade = async (planId) => {
setBusy(planId);
setError(null);
try {
const res = await fetch('/api/billing/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ planId }),
});
const d = await res.json();
if (d.success && d.url) window.location.href = d.url;
else setError(d.error || 'Ödeme başlatılamadı');
} catch (e) {
setError(e.message);
} finally {
setBusy(null);
}
};
const plans = data?.plans || [];
const fiyatla = (p) => (yillik ? Math.round(p.fiyat * 12 * 0.83) : p.fiyat);
return (
<div className="space-y-6 animate-fade-in">
<div className="bg-linear-to-r from-slate-900 via-blue-950 to-indigo-900 text-white p-6 rounded-2xl shadow-lg border border-blue-800/40">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center shadow-md">
<CreditCard className="w-6 h-6" />
</div>
<div>
<h2 className="font-extrabold text-xl">Planlar & Fiyatlandırma</h2>
<p className="text-xs text-blue-200 mt-0.5">İhtiyacınıza uygun planı seçin. İstediğiniz zaman iptal edebilirsiniz.</p>
</div>
</div>
</div>
{/* Aylık / Yıllık */}
<div className="flex justify-center">
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex text-xs font-bold shadow-xs">
<button onClick={() => setYillik(false)} className={`px-4 py-2 rounded-lg transition-colors ${!yillik ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-slate-50'}`}>
Aylık
</button>
<button onClick={() => setYillik(true)} className={`px-4 py-2 rounded-lg transition-colors flex items-center gap-1.5 ${yillik ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-slate-50'}`}>
Yıllık <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${yillik ? 'bg-amber-400 text-slate-900' : 'bg-emerald-100 text-emerald-800'}`}>%17 indirim</span>
</button>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3 text-xs text-red-800 text-center">{error}</div>
)}
{data && !data.configured && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
<Info className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
<p className="text-xs text-amber-900 leading-relaxed">
<strong>Ödeme altyapısı henüz bağlanmadı.</strong> Satın alma için sunucuya <code className="bg-amber-100 px-1 rounded">STRIPE_SECRET_KEY</code> ve
plan fiyat kimlikleri (<code className="bg-amber-100 px-1 rounded">STRIPE_PRICE_PRO</code>, <code className="bg-amber-100 px-1 rounded">STRIPE_PRICE_KURUMSAL</code>) eklenmeli.
Planlar aşağıda görüntülenebilir.
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{plans.map((p) => (
<div
key={p.id}
className={`rounded-2xl border p-6 flex flex-col gap-4 shadow-xs ${
p.vurgu ? 'border-blue-500 border-2 bg-linear-to-b from-blue-50 to-white shadow-lg' : 'border-slate-200 bg-white'
}`}
>
<div>
<div className="flex items-center justify-between gap-2">
<h3 className="font-black text-lg text-slate-900">{p.ad}</h3>
{p.rozet && (
<span className="bg-blue-600 text-white text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wide flex items-center gap-1">
<Sparkles className="w-3 h-3" /> {p.rozet}
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-1">{p.ozet}</p>
</div>
<div>
<span className="text-3xl font-black text-slate-900">
{p.fiyat === 0 ? 'Ücretsiz' : `₺${fiyatla(p).toLocaleString('tr-TR')}`}
</span>
{p.fiyat > 0 && <span className="text-xs text-slate-500 font-medium"> /{yillik ? 'yıl' : 'ay'}</span>}
{p.fiyat > 0 && <p className="text-[11px] text-slate-400 mt-0.5">KDV hariç</p>}
</div>
<ul className="space-y-2 text-xs text-slate-700 flex-1">
{p.ozellikler.map((o, i) => (
<li key={i} className="flex items-start gap-2">
<Check className="w-3.5 h-3.5 text-emerald-600 shrink-0 mt-0.5" />
<span>{o}</span>
</li>
))}
</ul>
{p.fiyat === 0 ? (
<div className="text-center text-xs font-bold text-slate-500 bg-slate-50 border border-slate-200 rounded-xl py-2.5">
Mevcut planınız
</div>
) : (
<button
onClick={() => upgrade(p.id)}
disabled={!p.satinAlinabilir || busy === p.id || !authUser}
title={!authUser ? 'Önce giriş yapın' : !p.satinAlinabilir ? 'Ödeme altyapısı bağlanmadı' : ''}
className={`w-full font-extrabold text-xs py-3 rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
p.vurgu ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-slate-900 hover:bg-slate-800 text-white'
}`}
>
{busy === p.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <CreditCard className="w-4 h-4" />}
{busy === p.id ? 'Yönlendiriliyor…' : `${p.ad}'a Geç`}
</button>
)}
</div>
))}
</div>
<p className="text-[11px] text-slate-400 text-center leading-relaxed max-w-2xl mx-auto">
Ödemeler Stripe üzerinden güvenli şekilde alınır; kart bilgileriniz İhaleist sunucularında saklanmaz.
Aboneliğinizi istediğiniz zaman iptal edebilirsiniz.
</p>
</div>
);
};
+119
View File
@@ -0,0 +1,119 @@
import React from 'react';
import { useApp } from '../../context/AppContext';
import { redFlags, marketConcentration, formatTL } from '../../services/analytics';
import { ShieldAlert, AlertTriangle, TrendingDown, Building2, Users, Info } from 'lucide-react';
const SEVIYE = {
yuksek: { etiket: 'Yüksek', kutu: 'bg-red-50 border-red-200', rozet: 'bg-red-100 text-red-800', cizgi: 'bg-red-500' },
orta: { etiket: 'Orta', kutu: 'bg-amber-50 border-amber-200', rozet: 'bg-amber-100 text-amber-800', cizgi: 'bg-amber-500' },
dusuk: { etiket: 'Düşük', kutu: 'bg-slate-50 border-slate-200', rozet: 'bg-slate-100 text-slate-700', cizgi: 'bg-slate-400' },
};
const TUR_IKON = {
'Tek teklif': Users,
'Düşük kırım': TrendingDown,
'Tahmin üstü bedel': AlertTriangle,
'Aşırı düşük teklif': TrendingDown,
'Tedarikçi yoğunlaşması': Building2,
};
export const RedFlags = () => {
const { tenders, setActiveTenderDetail } = useApp();
const { flags, yuksek, orta, analiz } = redFlags(tenders);
const { hhi, seviye } = marketConcentration(tenders);
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="bg-linear-to-r from-slate-900 via-rose-950 to-red-900 text-white p-6 rounded-2xl shadow-lg border border-rose-800/40">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-rose-600 text-white flex items-center justify-center shadow-md">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<h2 className="font-extrabold text-xl">Kırmızı Bayrak & Şeffaflık Analizi</h2>
<p className="text-xs text-rose-200 mt-0.5">Tek teklifli ihaleler, olağandışı kırım oranları ve tedarikçi yoğunlaşması otomatik tespit edilir.</p>
</div>
</div>
</div>
{/* Özet kartlar */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Yüksek Öncelik</span>
<span className="text-2xl font-black text-red-600 mt-1 block">{yuksek}</span>
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Orta Öncelik</span>
<span className="text-2xl font-black text-amber-600 mt-1 block">{orta}</span>
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Analiz Edilen</span>
<span className="text-2xl font-black text-slate-900 mt-1 block">{analiz}</span>
<span className="text-[11px] text-slate-400">sonuç verisi olan ihale</span>
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Pazar Yoğunlaşması</span>
<span className="text-2xl font-black text-indigo-900 mt-1 block">{hhi != null ? hhi : '—'}</span>
<span className="text-[11px] text-slate-400">{seviye || 'HHI için sonuç verisi gerekli'}</span>
</div>
</div>
{/* Yöntem notu */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex gap-3">
<Info className="w-4 h-4 text-blue-600 shrink-0 mt-0.5" />
<p className="text-xs text-blue-900 leading-relaxed">
Bu sinyaller kamuya açık ihale verisinden otomatik hesaplanır ve <strong>incelenmeye değer durumları</strong> işaret eder —
tek başlarına usulsüzlük kanıtı değildir. Tek teklif ve kırım analizleri, ilgili alanların (teklif sayısı, yaklaşık maliyet,
sözleşme bedeli) veride bulunduğu ihaleler için üretilir.
</p>
</div>
{/* Bayrak listesi */}
{flags.length === 0 ? (
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">
<ShieldAlert className="w-8 h-8 text-slate-300 mx-auto mb-3" />
<p className="text-sm font-bold text-slate-500">Henüz kırmızı bayrak tespit edilmedi</p>
<p className="text-xs text-slate-400 mt-1 max-w-md mx-auto leading-relaxed">
Analiz için sonuç verisi (kazanan firma, sözleşme bedeli, teklif sayısı) gerekir.
Doğrudan temin sonuçları ve EKAP sonuç ilanları biriktikçe bu liste otomatik dolar.
</p>
</div>
) : (
<div className="space-y-3">
{flags.map((f) => {
const s = SEVIYE[f.seviye];
const Icon = TUR_IKON[f.tur] || AlertTriangle;
return (
<div key={f.id} className={`rounded-xl border ${s.kutu} p-4 flex gap-3 items-start`}>
<span className={`w-1 self-stretch rounded-full ${s.cizgi} shrink-0`} />
<Icon className="w-4 h-4 text-slate-600 shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-[10px] font-extrabold px-2 py-0.5 rounded-full ${s.rozet}`}>{s.etiket}</span>
<span className="text-xs font-extrabold text-slate-900">{f.tur}</span>
{f.deger && <span className="text-[11px] font-bold text-slate-600 bg-white/70 border border-slate-200 px-2 py-0.5 rounded">{f.deger}</span>}
</div>
<p className="text-xs text-slate-700 mt-1.5 leading-relaxed">{f.aciklama}</p>
{f.tender ? (
<button
onClick={() => setActiveTenderDetail(f.tender)}
className="text-[11px] font-bold text-blue-700 hover:underline mt-1.5 text-left"
>
{f.tender.baslik}
{f.tender.kurum ? ` — ${f.tender.kurum}` : ''}
{f.tender.sozlesmeBedeli != null ? ` (${formatTL(Number(f.tender.sozlesmeBedeli))})` : ''}
</button>
) : (
<p className="text-[11px] font-bold text-slate-600 mt-1.5">{f.kurum}</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
+153
View File
@@ -0,0 +1,153 @@
import React, { useState, useEffect } from 'react';
import { Globe2, Loader2, ExternalLink, Building2, Calendar, Trophy, AlertTriangle, RefreshCw } from 'lucide-react';
const MODLAR = [
{ id: 'acik', label: 'Açık AB İhaleleri', aciklama: 'Türkiye’deki idarelerin AB’de yayımladığı, hâlen teklif verilebilir ihaleler.' },
{ id: 'turkiye', label: 'Tüm Türkiye Kayıtları', aciklama: 'TR idareler + TR kazananlar + TR teklif verenler.' },
{ id: 'kazanan', label: 'AB’de Kazanan TR Firmaları', aciklama: 'Türk firmalarının AB ihalelerinde kazandığı sözleşmeler.' },
];
export const TedTenders = () => {
const [mod, setMod] = useState('acik');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const yukle = (m) => {
setLoading(true);
setError(null);
fetch(`/api/ted?mod=${m}&limit=30`)
.then((r) => r.json())
.then((d) => { if (d.success) setData(d); else setError(d.error); })
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
};
useEffect(() => { yukle(mod); }, [mod]);
const aktifMod = MODLAR.find((m) => m.id === mod);
return (
<div className="space-y-6 animate-fade-in">
<div className="bg-linear-to-r from-slate-900 via-blue-950 to-sky-900 text-white p-6 rounded-2xl shadow-lg border border-sky-800/40">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-sky-600 flex items-center justify-center shadow-md">
<Globe2 className="w-6 h-6" />
</div>
<div>
<h2 className="font-extrabold text-xl">AB İhaleleri — TED</h2>
<p className="text-xs text-sky-200 mt-0.5">
Tenders Electronic Daily (AB resmi ihale yayını) üzerinden Türkiye ile ilgili ihaleler.
</p>
</div>
</div>
</div>
{/* Mod seçici */}
<div className="flex gap-2 flex-wrap">
{MODLAR.map((m) => (
<button
key={m.id}
onClick={() => setMod(m.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold border transition-colors ${
mod === m.id ? 'bg-sky-600 text-white border-sky-600 shadow-xs' : 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50'
}`}
>
{m.label}
</button>
))}
<button
onClick={() => yukle(mod)}
disabled={loading}
className="px-3 py-2 rounded-xl text-xs font-bold border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 flex items-center gap-1.5 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} /> Yenile
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 flex items-center justify-between gap-3 flex-wrap shadow-xs">
<p className="text-xs text-slate-600">{aktifMod?.aciklama}</p>
{data && (
<span className="text-xs font-bold text-sky-800 bg-sky-50 border border-sky-200 px-3 py-1 rounded-full shrink-0">
{data.totalCount.toLocaleString('tr-TR')} kayıt
</span>
)}
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-bold text-red-900">TED verisi alınamadı</p>
<p className="text-xs text-red-800 mt-0.5">{error}</p>
</div>
</div>
)}
{loading && !data && (
<div className="bg-white rounded-2xl border border-slate-200 p-10 text-center">
<Loader2 className="w-6 h-6 text-sky-600 animate-spin mx-auto" />
<p className="text-xs text-slate-500 mt-3">TED'den çekiliyor…</p>
</div>
)}
{data && (
<div className="space-y-3">
{data.tenders.length === 0 && (
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">
<p className="text-sm font-bold text-slate-500">Bu filtrede kayıt bulunamadı</p>
</div>
)}
{data.tenders.map((t) => (
<div key={t.id} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md transition-shadow space-y-2">
<div className="flex items-start justify-between gap-3">
<h3 className="font-bold text-sm text-slate-900 leading-snug">{t.baslik}</h3>
{t.link && (
<a href={t.link} target="_blank" rel="noopener noreferrer"
className="text-sky-600 hover:text-sky-800 shrink-0" title="TED'de aç">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
<div className="flex items-center gap-3 flex-wrap text-[11px] text-slate-600">
<span className="flex items-center gap-1 font-semibold">
<Building2 className="w-3 h-3 text-slate-400" /> {t.kurum}
</span>
{t.sehir && <span>{t.sehir}</span>}
{t.ulke && <span className="bg-slate-100 px-1.5 py-0.5 rounded font-bold">{t.ulke}</span>}
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3 text-blue-500" />
{t.sonTeklifTarihi ? `Son teklif: ${t.sonTeklifTarihi}` : t.yayimTarihi}
</span>
<span className="bg-sky-50 text-sky-800 border border-sky-200 px-1.5 py-0.5 rounded font-bold">{t.alimTuru}</span>
{t.sonucIlani && <span className="bg-emerald-50 text-emerald-800 border border-emerald-200 px-1.5 py-0.5 rounded font-bold">Sonuç İlanı</span>}
</div>
{(t.kazananFirma || t.yaklasikMaliyet != null) && (
<div className="flex items-center gap-4 flex-wrap pt-2 border-t border-slate-100 text-[11px]">
{t.kazananFirma && (
<span className="flex items-center gap-1 font-bold text-emerald-800">
<Trophy className="w-3 h-3" /> {t.kazananFirma}
{t.kazananUlke && <span className="text-slate-400 font-normal">({t.kazananUlke})</span>}
</span>
)}
{t.yaklasikMaliyet != null && (
<span className="font-black text-indigo-900">
{t.yaklasikMaliyet.toLocaleString('tr-TR')} {t.paraBirimi || ''}
</span>
)}
</div>
)}
</div>
))}
</div>
)}
<p className="text-[11px] text-slate-400 text-center">
Kaynak: Tenders Electronic Daily (ted.europa.eu) — Avrupa Birliği resmi ihale yayını.
</p>
</div>
);
};
+12 -39
View File
@@ -215,49 +215,22 @@ export const AppProvider = ({ children }) => {
const getModelById = (id) => AI_MODEL_CATALOG.find(m => m.id === id); const getModelById = (id) => AI_MODEL_CATALOG.find(m => m.id === id);
const activeAiModel = getModelById(aiModelConfig.chain[0]) || AI_MODEL_CATALOG[0]; const activeAiModel = getModelById(aiModelConfig.chain[0]) || AI_MODEL_CATALOG[0];
const analyzeSpecPdfWithGemini = (fileName = 'Teknik_Sartname.pdf') => { // Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde
return new Promise((resolve) => { // seçilen model zinciri (fallback) ile.
setTimeout(() => { const analyzeSpecPdfWithGemini = async ({ text, fileName, tender } = {}) => {
const { analyzeSpecText } = await import('../services/ai');
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain });
const result = { const result = {
...data.result,
fileName, fileName,
model: `${activeAiModel.label} (${activeAiModel.provider})`, model: `${data.model.label} (${data.model.provider})`,
modelId: activeAiModel.model, modelId: data.model.model,
fallbackChain: aiModelConfig.chain.map(id => getModelById(id)?.model).filter(Boolean), fallbackChain: aiModelConfig.chain.map((id) => getModelById(id)?.model).filter(Boolean),
analyzedAt: new Date().toLocaleString('tr-TR'), attempts: data.attempts,
summary: 'İşbu teknik şartname, idarenin ihtiyacı olan medikal sarf malzemeleri ve cihaz parçalarının teknik özellikleri, garanti şartları, teslimat vadeleri ve muayene kabul kriterlerini düzenlemektedir.', analyzedAt: new Date(data.analyzedAt).toLocaleString('tr-TR'),
qualificationCriteria: [
'TSE HYB Belgesi ve Yetkili Satıcı Satış Yetki Belgesi',
'ISO 13485 Medikal Cihaz Kalite Yönetim Sertifikası',
'ÜTS (Ürün Takip Sistemi) Barkod ve TİUBB Kaydı',
'En az %50 Oranında Benzer İş Deneyim Belgesi (Kamu/Özel)',
'Son 3 yıla ait Bilanço ve Gelir Tablosu oranları'
],
criticalRequirements: [
'Ürünler biyo-uyumluluk (ISO 10993) standartlarına uygun olmalıdır.',
'Teslim edilen paketlerin sterilizasyon son kullanma tarihi en az 18 ay olmalıdır.',
'Arıza durumunda 24 saat içinde yerinde teknik servis desteği sağlanmalıdır.',
'Teslimat aşamasında her parti için numune test raporu (CoA) ibraz edilmelidir.'
],
risksAndPenalties: [
{ level: 'Yüksek', clause: 'Madde 14.2', text: 'Gecikilen her takvim günü için ihale bedelinin %0.5 oranında cezai şart uygulanacaktır.' },
{ level: 'Orta', clause: 'Madde 18.1', text: 'Numune kontrolünde standart dışı bulunan partiler 3 iş günü içinde muadili ile ücretsiz değiştirilmelidir.' },
{ level: 'Düşük', clause: 'Madde 22 border', text: 'Ambalaj üzerindeki Türkçe etiket eksikliğinde kabul işlemi durdurulacaktır.' }
],
bidAnalysis: {
estimatedCost: 185000,
recommendedBid: 148500,
minBidLimit: 132000,
maxBidLimit: 165000,
winProbability: 89,
competitionLevel: 'Orta Yoğunlukta (3-4 Firma Katılımı Bekleniyor)',
historicalDiscountAvg: '%19.7 EKAP İndirim Oranı'
}
}; };
setAiAnalysisResults(result); setAiAnalysisResults(result);
resolve(result); return result;
}, 2200);
});
}; };
const [userProfile, setUserProfile] = useState({ const [userProfile, setUserProfile] = useState({
+110
View File
@@ -43,3 +43,113 @@
.pulse-glow { .pulse-glow {
animation: pulseGlow 2s infinite ease-in-out; animation: pulseGlow 2s infinite ease-in-out;
} }
/* ============ Landing page (lp-) ============ */
.lp-root { color-scheme: dark; }
.lp-grid {
background-image:
linear-gradient(rgba(148, 163, 184, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.05) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 90% 60% at 50% 0%, #000 55%, transparent 100%);
}
@keyframes lpBlob {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(50px, -40px) scale(1.12); }
66% { transform: translate(-35px, 30px) scale(0.94); }
}
.lp-blob { animation: lpBlob 18s ease-in-out infinite; }
@keyframes lpHeroIn {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: translateY(0); }
}
.lp-hero-in { opacity: 0; animation: lpHeroIn 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) forwards; }
@keyframes lpGradientMove {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.lp-gradient-text {
background: linear-gradient(90deg, #22d3ee, #a78bfa, #fbbf24, #22d3ee);
background-size: 280% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: lpGradientMove 7s ease-in-out infinite;
}
.lp-underline {
position: relative;
white-space: nowrap;
}
.lp-underline::after {
content: '';
position: absolute;
left: -2px; right: -2px; bottom: 4px;
height: 10px;
background: linear-gradient(90deg, rgba(34, 211, 238, 0.45), rgba(251, 191, 36, 0.45));
border-radius: 4px;
z-index: -1;
transform: skewX(-8deg);
}
@keyframes lpFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.lp-float { animation: lpFloat 7s ease-in-out infinite; }
@keyframes lpCardIn {
from { opacity: 0; transform: translateX(26px); }
to { opacity: 1; transform: translateX(0); }
}
.lp-card-in { opacity: 0; animation: lpCardIn 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) forwards; }
@keyframes lpBar {
from { height: 0; }
to { height: var(--h); }
}
.lp-bar { height: 0; animation: lpBar 0.8s cubic-bezier(0.2, 0.7, 0.2, 1) forwards; }
@keyframes lpSticker {
from { opacity: 0; transform: scale(0.5) rotate(14deg); }
60% { transform: scale(1.08) rotate(5deg); }
to { opacity: 1; transform: scale(1) rotate(6deg); }
}
.lp-sticker { opacity: 0; animation: lpSticker 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) 1.1s forwards; }
@keyframes lpBlink {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.55); }
50% { opacity: 0.6; box-shadow: 0 0 0 5px rgba(52, 211, 153, 0); }
}
.lp-blink { animation: lpBlink 1.6s ease-in-out infinite; }
@keyframes lpMarquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.lp-marquee { animation: lpMarquee 42s linear infinite; }
.lp-marquee-rev { animation: lpMarquee 48s linear infinite reverse; }
.lp-marquee:hover, .lp-marquee-rev:hover { animation-play-state: paused; }
.lp-ticker-mask {
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.lp-reveal {
opacity: 0;
transform: translateY(28px);
transition: opacity 0.7s cubic-bezier(0.2, 0.7, 0.2, 1), transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.lp-reveal.lp-in { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
.lp-blob, .lp-float, .lp-marquee, .lp-marquee-rev, .lp-gradient-text, .lp-blink { animation: none; }
.lp-hero-in, .lp-card-in, .lp-sticker { animation: none; opacity: 1; }
.lp-bar { animation: none; height: var(--h); }
.lp-reveal { opacity: 1; transform: none; transition: none; }
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Gerçek AI şartname analizi istemcisi (backend /api/ai üzerinden).
*/
import { getToken } from './auth';
export async function getAiConfig() {
try {
const res = await fetch('/api/ai/config');
if (!res.ok) return { configured: false, providers: [] };
return await res.json();
} catch {
return { configured: false, providers: [] };
}
}
export async function analyzeSpecText({ text, fileName, tender, chain }) {
const res = await fetch('/api/ai/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ text, fileName, tender, chain }),
});
const data = await res.json().catch(() => ({}));
if (!data.success) throw new Error(data.error || `Analiz başarısız (HTTP ${res.status})`);
return data;
}
+92
View File
@@ -164,6 +164,98 @@ export function monthlyStats(tenders) {
.map((x) => ({ month: AY_KISA[x.m], kazanim: x.kazanim, katilim: x.katilim })); .map((x) => ({ month: AY_KISA[x.m], kazanim: x.kazanim, katilim: x.katilim }));
} }
// --- Kırmızı bayrak / şeffaflık analizi ---
// Kaynak veride bulunan alanlardan hesaplanır; alan yoksa o bayrak üretilmez.
// (Uyarı niteliğindedir — usulsüzlük iddiası değil, incelenmeye değer sinyaldir.)
export function redFlags(tenders) {
const flags = [];
// Firma-kurum yoğunlaşması için sayaçlar
const firmKurum = new Map(); // "firma||kurum" -> sayı
const kurumToplam = new Map(); // kurum -> sonuçlanan ihale sayısı
for (const t of tenders) {
const bedel = parseTL(t.sozlesmeBedeli);
const yaklasik = parseTL(t.yaklasikMaliyet);
const teklif = t.teklifSayisi != null ? Number(t.teklifSayisi) : null;
// 1) Tek teklifli ihale
if (teklif === 1) {
flags.push({
id: `tek-${t.id}`, seviye: 'yuksek', tur: 'Tek teklif',
tender: t,
aciklama: 'İhaleye yalnızca bir geçerli teklif verilmiş — rekabet oluşmamış.',
});
}
// 2) Yaklaşık maliyete çok yakın sözleşme (düşük kırım)
if (bedel != null && yaklasik != null && yaklasik > 0) {
const kirim = 1 - bedel / yaklasik;
if (kirim >= 0 && kirim < 0.03) {
flags.push({
id: `kirim-${t.id}`, seviye: 'orta', tur: 'Düşük kırım',
tender: t, deger: `%${(kirim * 100).toFixed(1)} kırım`,
aciklama: 'Sözleşme bedeli yaklaşık maliyete neredeyse eşit — fiyat rekabeti sınırlı.',
});
}
// 3) Yaklaşık maliyetin üzerinde sözleşme
if (bedel > yaklasik) {
flags.push({
id: `ustu-${t.id}`, seviye: 'yuksek', tur: 'Tahmin üstü bedel',
tender: t, deger: `%${(((bedel - yaklasik) / yaklasik) * 100).toFixed(1)} üzerinde`,
aciklama: 'Sözleşme bedeli idarenin yaklaşık maliyetini aşıyor.',
});
}
// 4) Aşırı düşük teklif riski
if (kirim > 0.4) {
flags.push({
id: `asiri-${t.id}`, seviye: 'orta', tur: 'Aşırı düşük teklif',
tender: t, deger: `%${(kirim * 100).toFixed(1)} kırım`,
aciklama: 'Yaklaşık maliyete göre çok yüksek kırım — sürdürülebilirlik/ifa riski.',
});
}
}
if (t.kazananFirma && t.kurum) {
const key = `${t.kazananFirma.trim()}||${t.kurum.trim()}`;
firmKurum.set(key, (firmKurum.get(key) || 0) + 1);
kurumToplam.set(t.kurum.trim(), (kurumToplam.get(t.kurum.trim()) || 0) + 1);
}
}
// 5) Kurum-firma yoğunlaşması: aynı firma bir kurumun ihalelerinin çoğunu almış
for (const [key, adet] of firmKurum) {
const [firma, kurum] = key.split('||');
const toplam = kurumToplam.get(kurum) || 0;
if (toplam >= 3 && adet / toplam >= 0.6) {
flags.push({
id: `yogun-${key}`, seviye: 'yuksek', tur: 'Tedarikçi yoğunlaşması',
firma, kurum, deger: `${adet}/${toplam} ihale`,
aciklama: `${firma}, bu idarenin sonuçlanan ihalelerinin %${Math.round((adet / toplam) * 100)}'ini almış.`,
});
}
}
const siraDeger = { yuksek: 0, orta: 1, dusuk: 2 };
flags.sort((a, b) => siraDeger[a.seviye] - siraDeger[b.seviye]);
return {
flags,
yuksek: flags.filter((f) => f.seviye === 'yuksek').length,
orta: flags.filter((f) => f.seviye === 'orta').length,
// Analiz edilebilen (sonuç verisi olan) ihale sayısı — şeffaflık için
analiz: tenders.filter((t) => t.kazananFirma || t.sozlesmeBedeli != null || t.teklifSayisi != null).length,
};
}
// --- Pazar yoğunlaşması (HHI) ---
export function marketConcentration(tenders) {
const { firms, totalMarket } = firmStats(tenders);
if (!firms.length || !totalMarket) return { hhi: null, seviye: null, firms: [] };
const hhi = firms.reduce((s, f) => s + Math.pow((f.total / totalMarket) * 100, 2), 0);
const seviye = hhi < 1500 ? 'Rekabetçi' : hhi < 2500 ? 'Orta yoğunlaşma' : 'Yüksek yoğunlaşma';
return { hhi: Math.round(hhi), seviye, firms };
}
// --- Alım türü dağılımı --- // --- Alım türü dağılımı ---
export function alimTuruStats(tenders) { export function alimTuruStats(tenders) {
const map = new Map(); const map = new Map();
+35
View File
@@ -0,0 +1,35 @@
/**
* PDF'ten metin çıkarma (tarayıcı içinde, pdfjs-dist ile).
* Dinamik import edilir — ana bundle'a girmez.
*/
export async function extractPdfText(file, { maxPages = 40 } = {}) {
const pdfjs = await import('pdfjs-dist');
// Worker'ı Vite'ın bundle ettiği dosyadan al (CDN'e çıkmaz)
const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
const buf = await file.arrayBuffer();
const doc = await pdfjs.getDocument({ data: buf }).promise;
const pages = Math.min(doc.numPages, maxPages);
const parts = [];
for (let i = 1; i <= pages; i++) {
const page = await doc.getPage(i);
const content = await page.getTextContent();
parts.push(content.items.map((it) => it.str).join(' '));
}
const text = parts.join('\n\n').replace(/[ \t]+/g, ' ').trim();
return { text, pageCount: doc.numPages, usedPages: pages };
}
// Metin dosyaları için basit okuyucu
export async function extractPlainText(file) {
const text = await file.text();
return { text: text.trim(), pageCount: 1, usedPages: 1 };
}
export async function extractText(file) {
const name = (file.name || '').toLowerCase();
if (name.endsWith('.pdf') || file.type === 'application/pdf') return extractPdfText(file);
return extractPlainText(file);
}