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:
+2
-2
@@ -4,8 +4,8 @@
|
||||
{
|
||||
"name": "ihaleist-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "3005", "--strictPort"],
|
||||
"port": 3005
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ COPY --from=build /app/nginx.conf /etc/nginx/http.d/default.conf
|
||||
WORKDIR /app
|
||||
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.
|
||||
# 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).
|
||||
|
||||
Generated
+278
@@ -11,6 +11,7 @@
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.0"
|
||||
@@ -808,6 +809,271 @@
|
||||
"@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": {
|
||||
"version": "1.5.1",
|
||||
"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)"
|
||||
},
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.0"
|
||||
|
||||
+206
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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); });
|
||||
@@ -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 = { ' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" };
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -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
@@ -18,6 +18,12 @@ import { fileURLToPath } from 'node:url';
|
||||
import { readDtTenders } from './dt-store.mjs';
|
||||
import { loginWithGoogle, userFromAuthHeader } from './auth.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;
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// --- 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) ---
|
||||
if (url.pathname === '/api/notify/email/config') {
|
||||
return json(res, 200, { configured: emailConfigured() });
|
||||
@@ -370,8 +481,16 @@ const server = http.createServer(async (req, res) => {
|
||||
} catch (e) {
|
||||
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ı'];
|
||||
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, {
|
||||
success: true,
|
||||
source: sources.join(' + '),
|
||||
@@ -379,7 +498,8 @@ const server = http.createServer(async (req, res) => {
|
||||
totalCount: c.totalCount + dt.totalCount,
|
||||
todayCount: c.todayCount,
|
||||
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, () => {
|
||||
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));
|
||||
startScheduler();
|
||||
});
|
||||
|
||||
+206
@@ -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(/ /g, ' ').replace(/"/g, '"').replace(/&/g, '&')
|
||||
.replace(/×/g, ' ').replace(/</g, '<').replace(/>/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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -23,10 +23,15 @@ import { PriceAnalysis } from './components/tools/PriceAnalysis';
|
||||
import { CompetitorRadar } from './components/tools/CompetitorRadar';
|
||||
import { DocumentVault } from './components/tools/DocumentVault';
|
||||
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 { PwaSettings } from './components/tools/PwaSettings';
|
||||
import { AiModelSettings } from './components/tools/AiModelSettings';
|
||||
import { LoginPage } from './components/LoginPage';
|
||||
import { LandingPage } from './components/LandingPage';
|
||||
|
||||
const MainContent = () => {
|
||||
const { activeTab, isAdmin } = useApp();
|
||||
@@ -57,6 +62,10 @@ const MainContent = () => {
|
||||
{activeTab === 'rakip_radar' && <CompetitorRadar />}
|
||||
{activeTab === 'dokuman_deposu' && <DocumentVault />}
|
||||
{activeTab === 'analitik_dashboard' && <AnalyticsDashboard />}
|
||||
{activeTab === 'kirmizi_bayrak' && <RedFlags />}
|
||||
{activeTab === 'ted_eu' && <TedTenders />}
|
||||
{activeTab === 'kik_kararlari' && <KikDecisions />}
|
||||
{activeTab === 'planlar' && <Pricing />}
|
||||
{activeTab === 'api_webhooks' && <ApiWebhooks />}
|
||||
{activeTab === 'pwa_settings' && <PwaSettings />}
|
||||
{activeTab === 'ai_ayarlari' && isAdmin && <AiModelSettings />}
|
||||
@@ -72,6 +81,13 @@ const MainContent = () => {
|
||||
|
||||
const Gate = () => {
|
||||
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) {
|
||||
return (
|
||||
@@ -81,6 +97,9 @@ const Gate = () => {
|
||||
);
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
if (!showLogin) {
|
||||
return <LandingPage onGiris={() => { window.location.hash = '#giris'; setShowLogin(true); }} />;
|
||||
}
|
||||
return <LoginPage onLogin={handleLogin} />;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -1,67 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 { getAiConfig } from '../services/ai';
|
||||
|
||||
const tl = (n) => (typeof n === 'number' && isFinite(n) ? '₺' + n.toLocaleString('tr-TR') : '—');
|
||||
|
||||
export const AiSpecAnalyzerModal = () => {
|
||||
const { isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen, analyzeSpecPdfWithGemini, aiAnalysisResults } = useApp();
|
||||
const {
|
||||
isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
|
||||
analyzeSpecPdfWithGemini, aiAnalysisResults, activeAiModel, activeTenderDetail,
|
||||
} = useApp();
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
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;
|
||||
|
||||
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);
|
||||
setSelectedFile(fileName);
|
||||
await analyzeSpecPdfWithGemini(fileName);
|
||||
setIsAnalyzing(false);
|
||||
try {
|
||||
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 });
|
||||
};
|
||||
|
||||
const results = aiAnalysisResults || {
|
||||
fileName: 'Teknik_Sartname_Örnek.pdf',
|
||||
model: 'Gemini 3.6 Flash',
|
||||
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ı'
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
setProgress('');
|
||||
}
|
||||
};
|
||||
|
||||
// Win probability calculation based on custom bid slider
|
||||
const calcProbability = (bid) => {
|
||||
const rec = results.bidAnalysis.recommendedBid;
|
||||
const diff = Math.abs(bid - rec);
|
||||
const prob = Math.max(35, Math.min(96, Math.round(92 - (diff / 3000) * 3)));
|
||||
return prob;
|
||||
const handleFile = async (file) => {
|
||||
setError(null);
|
||||
setIsAnalyzing(true);
|
||||
setSelectedFile(file.name);
|
||||
try {
|
||||
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 (
|
||||
<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]">
|
||||
@@ -73,191 +94,219 @@ export const AiSpecAnalyzerModal = () => {
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
<button
|
||||
onClick={() => setIsAiSpecAnalyzerOpen(false)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors"
|
||||
>
|
||||
<button onClick={() => setIsAiSpecAnalyzerOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<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="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" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-sm text-slate-800">Teknik Şartname PDF 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>
|
||||
<h3 className="font-bold text-sm text-slate-800">Teknik Şartname Dosyası Yükleyin</h3>
|
||||
<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 className="flex justify-center gap-3 pt-2">
|
||||
<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">
|
||||
<div className="flex justify-center gap-3 pt-2 flex-wrap">
|
||||
<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" />
|
||||
<span>PDF Dosyası Seç</span>
|
||||
<span>Dosya Seç (PDF / TXT)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
accept=".pdf,.txt,.md"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleUploadAndAnalyze(e.target.files[0].name);
|
||||
}
|
||||
}}
|
||||
disabled={isAnalyzing}
|
||||
onChange={(e) => { if (e.target.files?.[0]) handleFile(e.target.files[0]); }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{activeTenderDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUploadAndAnalyze('Örnek_EKAP_Kayseri_Cezaevi_Teknik_Sartname.pdf')}
|
||||
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"
|
||||
disabled={isAnalyzing}
|
||||
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" />
|
||||
<span>Örnek EKAP Şartnamesi İle Dene</span>
|
||||
<span>Açık İhalenin İlan Metnini Analiz Et</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analyzing Loading Progress */}
|
||||
{isAnalyzing && (
|
||||
<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="flex items-center justify-between font-extrabold text-xs text-blue-900">
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
|
||||
⚡ Gemini 3.6 Flash PDF Multimodal Akışı İşleniyor...
|
||||
</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>
|
||||
{/* Hata */}
|
||||
{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">Analiz tamamlanamadı</p>
|
||||
<p className="text-xs text-red-800 mt-0.5 leading-relaxed">{error}</p>
|
||||
</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 && (
|
||||
<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="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">
|
||||
<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 className="text-[10px] text-slate-400">Analiz: {results.analyzedAt}</span>
|
||||
</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>
|
||||
|
||||
{/* 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="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{bid.winProbability != null && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
₺{results.bidAnalysis.estimatedCost.toLocaleString('tr-TR')}
|
||||
</span>
|
||||
<span className="text-base font-black text-white mt-1 block">{tl(bid.estimatedCost)}</span>
|
||||
</div>
|
||||
|
||||
<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-xl font-black text-amber-300 mt-0.5 block">
|
||||
₺{results.bidAnalysis.recommendedBid.toLocaleString('tr-TR')}
|
||||
</span>
|
||||
<span className="text-xl font-black text-amber-300 mt-0.5 block">{tl(bid.recommendedBid)}</span>
|
||||
</div>
|
||||
|
||||
<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-base font-black text-blue-300 mt-1 block">
|
||||
{results.bidAnalysis.historicalDiscountAvg}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-300 font-medium block">Rekabet Değerlendirmesi</span>
|
||||
<span className="text-xs font-bold text-blue-300 mt-1 block leading-snug">{bid.competitionLevel || '—'}</span>
|
||||
</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="flex justify-between items-center text-xs">
|
||||
<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:
|
||||
</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>
|
||||
<input
|
||||
type="range"
|
||||
min={results.bidAnalysis.minBidLimit}
|
||||
max={results.bidAnalysis.maxBidLimit}
|
||||
step={500}
|
||||
value={customBidSlider}
|
||||
onChange={(e) => setCustomBidSlider(Number(e.target.value))}
|
||||
min={bid.minBidLimit}
|
||||
max={bid.maxBidLimit}
|
||||
step={Math.max(1, Math.round((bid.maxBidLimit - bid.minBidLimit) / 200))}
|
||||
value={customBid ?? bid.recommendedBid ?? bid.minBidLimit}
|
||||
onChange={(e) => setCustomBid(Number(e.target.value))}
|
||||
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">
|
||||
<span>Min Limit: ₺{results.bidAnalysis.minBidLimit.toLocaleString('tr-TR')}</span>
|
||||
<span className="text-amber-300 font-bold">Önerilen: ₺{results.bidAnalysis.recommendedBid.toLocaleString('tr-TR')}</span>
|
||||
<span>Maks Limit: ₺{results.bidAnalysis.maxBidLimit.toLocaleString('tr-TR')}</span>
|
||||
<span>Min: {tl(bid.minBidLimit)}</span>
|
||||
<span className="text-amber-300 font-bold">Önerilen: {tl(bid.recommendedBid)}</span>
|
||||
<span>Maks: {tl(bid.maxBidLimit)}</span>
|
||||
</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">
|
||||
|
||||
{/* Qualification Criteria */}
|
||||
<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">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" /> Yeterlik Kriterleri & Zorunlu Belgeler
|
||||
</h4>
|
||||
<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">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-600 mt-1.5 shrink-0"></span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Risks & Penalty Clauses */}
|
||||
<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">
|
||||
<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>
|
||||
<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 className="flex items-center justify-between">
|
||||
<span className="font-bold text-slate-900">{risk.clause}</span>
|
||||
<span className={`text-[10px] font-extrabold px-2 py-0.5 rounded ${
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-bold text-slate-900">{risk.clause || '—'}</span>
|
||||
<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 === 'Orta' ? 'bg-amber-100 text-amber-800' : 'bg-slate-200 text-slate-800'
|
||||
}`}>
|
||||
{risk.level} Risk
|
||||
{risk.level || 'Bilgi'} Risk
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-600 leading-snug">{risk.text}</p>
|
||||
@@ -265,15 +314,16 @@ export const AiSpecAnalyzerModal = () => {
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-slate-50 p-4 border-t border-slate-200 flex justify-end">
|
||||
<button
|
||||
onClick={() => setIsAiSpecAnalyzerOpen(false)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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 = () => {
|
||||
const { activeTab, setActiveTab, isAdmin } = useApp();
|
||||
@@ -17,6 +17,10 @@ export const TopNavigation = () => {
|
||||
{ id: 'rakip_radar', label: 'Rakip Radar', icon: Radar, badge: 'Canlı Uyar' },
|
||||
{ id: 'dokuman_deposu', label: 'Doküman Deposu', icon: FolderKanban },
|
||||
{ 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 },
|
||||
// Sadece admin: AI Model Ayarları
|
||||
...(isAdmin ? [{ id: 'ai_ayarlari', label: 'AI Model Ayarları', icon: Cpu, badge: 'Admin' }] : []),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -215,49 +215,22 @@ export const AppProvider = ({ children }) => {
|
||||
const getModelById = (id) => AI_MODEL_CATALOG.find(m => m.id === id);
|
||||
const activeAiModel = getModelById(aiModelConfig.chain[0]) || AI_MODEL_CATALOG[0];
|
||||
|
||||
const analyzeSpecPdfWithGemini = (fileName = 'Teknik_Sartname.pdf') => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
// Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde
|
||||
// seçilen model zinciri (fallback) ile.
|
||||
const analyzeSpecPdfWithGemini = async ({ text, fileName, tender } = {}) => {
|
||||
const { analyzeSpecText } = await import('../services/ai');
|
||||
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain });
|
||||
const result = {
|
||||
...data.result,
|
||||
fileName,
|
||||
model: `${activeAiModel.label} (${activeAiModel.provider})`,
|
||||
modelId: activeAiModel.model,
|
||||
fallbackChain: aiModelConfig.chain.map(id => getModelById(id)?.model).filter(Boolean),
|
||||
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 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ı'
|
||||
}
|
||||
model: `${data.model.label} (${data.model.provider})`,
|
||||
modelId: data.model.model,
|
||||
fallbackChain: aiModelConfig.chain.map((id) => getModelById(id)?.model).filter(Boolean),
|
||||
attempts: data.attempts,
|
||||
analyzedAt: new Date(data.analyzedAt).toLocaleString('tr-TR'),
|
||||
};
|
||||
|
||||
setAiAnalysisResults(result);
|
||||
resolve(result);
|
||||
}, 2200);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const [userProfile, setUserProfile] = useState({
|
||||
|
||||
+110
@@ -43,3 +43,113 @@
|
||||
.pulse-glow {
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -164,6 +164,98 @@ export function monthlyStats(tenders) {
|
||||
.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ı ---
|
||||
export function alimTuruStats(tenders) {
|
||||
const map = new Map();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user