feat: derin analiz & yol haritasi + ihale dosyasi hazirlama; docx/xlsx/zip okunuyor
Doküman panelindeki "Tum Evraki Derin Analiz Et" dugmesi EKAP ilan metnini ve
ihaleye yuklenmis butun belgeleri TEK metinde birlestirip gonderiyor; model
sartnameyi ilanla ve eklerle capraz okuyabiliyor.
Derin analiz (mode='derin', ayri sistem istemi + 8000 token):
- Teklifi gecersiz kilabilecek noktalar (kirmizi uyari, en ustte)
- Gozden kacan ince detaylar (Kritik/Yuksek/Orta + madde no + neden onemli)
- Alinacak kalemler: aranan evsaflar, miktar, sartnamede geciyorsa fiyat
(modele "TAHMIN URETME" talimati verildi, yoksa null)
- Belge kontrol listesi, kritik tarihler, maliyet kalemleri
- Teklife kadar yol haritasi (faz / kim / ne zaman / cikti)
- Idareye sorulacak belirsizlikler
Standart hizli ozet modu aynen duruyor.
Ihale Dosyasi Hazirla (rapor sonunda, acilir bolum):
- Eksik firma bilgileri kullaniciya sorulur, profilden gelenler sorulmaz,
girilenler tarayicida saklanir
- Kalem fiyatlandirma tablosu, canli toplam + yaziyla karsiligi
- Teklif Mektubu, Birim Fiyat Teklif Cetveli, Evrak Kontrol Listesi
- Belgeler yazdirilabilir HTML: jsPDF'in gomulu fontlari WinAnsi, s/g/i/I
tasimiyor (bkz. services/pdf.js ASCII basliklari) — resmi teklif mektubunda
bozuk Turkce kabul edilemez
docx/xlsx/zip metin cikarimi (server/office-text.mjs):
- ihaleab projesindeki officeText.ts'ten tasindi, harici bagimlilik yok
- Sunucuda: tarayicida cozucu yok, baytlar zaten BELGE_DIR'de
- Tablolar " | " ile korunuyor, ic ice zip aciliyor, ~$ gecici dosyalari atlanir
- Yeni uc: GET /api/me/documents/text
- Yukleme kabul listesi .doc/.rar cikarildi (karsiligi yok), .xlsx eklendi
fix: yuklenen belgelerdeki "AI analiz" dugmesi bozuktu — extractText nesne
donduruyor ({text,pageCount,usedPages}), kod onu duz metin sanip gonderiyordu;
sunucuya "[object Object]" gidiyor ve analiz reddediliyordu. Uzunluk kontrolu
de nesne uzerinde undefined verdigi icin hatayi yutuyordu.
fix: analiz dokuman panelinden baslatildiginda modal kendi isAnalyzing'ini
bilmiyordu, kullanici 1-2 dakika bos modal goruyordu — ortak aiAnalyzing sinyali
This commit is contained in:
+92
-18
@@ -100,7 +100,75 @@ YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema:
|
||||
}
|
||||
Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.`;
|
||||
|
||||
function buildUserPrompt({ text, fileName, tender }) {
|
||||
/**
|
||||
* Derin analiz — teknik şartname + idari şartname + sözleşme tasarısı için.
|
||||
* Standart analizden farkı: gözden kaçan "ince detayları", teklifi geçersiz
|
||||
* kılan sebepleri, belge kontrol listesini ve teklife kadarki yol haritasını
|
||||
* ayrı ayrı üretir.
|
||||
*/
|
||||
export const DERIN_SYSTEM_PROMPT = `Sen Türkiye kamu ihale mevzuatı (4734 sayılı Kamu İhale Kanunu, 4735 sayılı Kamu İhale Sözleşmeleri Kanunu ve ikincil mevzuat) konusunda çok deneyimli bir ihale danışmanısın.
|
||||
Sana verilen teknik şartname / idari şartname / sözleşme tasarısı / ihale ilanı metnini madde madde incele.
|
||||
İki şey üret: (1) derinlemesine analiz, (2) teklif anına kadar izlenecek somut yol haritası.
|
||||
|
||||
Özellikle şunları ara — ihaleyi kaybettiren ama gözden kaçan detaylar bunlardır:
|
||||
- Teklif zarfında eksikse doğrudan değerlendirme dışı bırakılma sebepleri
|
||||
- Belgelerin tarih/süre koşulları (son X ay içinde alınmış, ihale tarihinden önceki Y gün, geçerlilik süresi)
|
||||
- Geçici/kesin teminat oranı, süresi, kabul edilen teminat türleri
|
||||
- Numune / demo / katalog / teknik doküman sunma zorunluluğu, teslim yeri ve anı
|
||||
- Yerli malı belgesi ve fiyat avantajı oranı
|
||||
- İş deneyim belgesi oranı, benzer iş tanımı, iş ortaklığında pilot ortak payı
|
||||
- Birim fiyat mı anahtar teslim götürü bedel mi; aritmetik hatanın sonucu
|
||||
- Kısmi teklif verilebilir mi, yeterlik kısım bazında mı aranıyor
|
||||
- Teslim süresi, montaj / eğitim / garanti / yedek parça yükümlülükleri
|
||||
- Gecikme cezası oranı, fesih ve yasaklama sonuçları
|
||||
- Fiyat farkı verilip verilmeyeceği
|
||||
- Zeyilname / açıklama talebi ve şikâyet başvurusu süreleri
|
||||
- Alınacak ürün/hizmetin belirleyici teknik evsafları, miktarları ve varsa birim fiyatları
|
||||
|
||||
YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema:
|
||||
{
|
||||
"summary": "3-5 cümlelik yönetici özeti",
|
||||
"scopeOfWork": "işin kapsamı: ne alınıyor/yaptırılıyor, miktar, süre, yer",
|
||||
"disqualificationRisks": ["teklifin doğrudan değerlendirme dışı kalmasına yol açacak durumlar (en fazla 8)"],
|
||||
"criticalDetails": [
|
||||
{"level":"Kritik|Yüksek|Orta","title":"kısa başlık","where":"madde no veya bölüm","detail":"ince detayın kendisi","whyItMatters":"gözden kaçarsa ne olur"}
|
||||
],
|
||||
"itemsAndSpecs": [
|
||||
{"name":"kalem/ürün adı","quantity":"miktar ve birim (ör. 3 adet)","keySpecs":["teklifi belirleyen zorunlu teknik evsaf"],"estimatedUnitPrice":sayı_veya_null,"estimatedTotal":sayı_veya_null,"note":"marka/eşdeğer, garanti, montaj gibi kaleme özel not"}
|
||||
],
|
||||
"qualificationCriteria": ["istenen yeterlik belgeleri, madde madde (en fazla 15)"],
|
||||
"criticalRequirements": ["dikkat edilmesi gereken kritik teknik/idari şartlar (en fazla 12)"],
|
||||
"documentChecklist": [
|
||||
{"document":"belge adı","mandatory":true,"note":"nereden/nasıl temin edilir, tarih veya geçerlilik koşulu"}
|
||||
],
|
||||
"deadlines": [{"label":"neyin süresi","value":"tarih veya süre","note":"kaçırılırsa sonucu"}],
|
||||
"costDrivers": ["maliyeti asıl belirleyen kalemler (en fazla 8)"],
|
||||
"risksAndPenalties": [{"level":"Yüksek|Orta|Düşük","clause":"madde no veya bölüm","text":"risk açıklaması"}],
|
||||
"roadmap": [
|
||||
{"step":1,"phase":"Hazırlık|Belge Toplama|Teknik|Fiyatlandırma|Teklif|Sözleşme","action":"yapılacak somut iş","owner":"kim yapmalı","timing":"ne zaman / hangi tarihe kadar","output":"ortaya çıkacak çıktı"}
|
||||
],
|
||||
"questionsForAdministration": ["idareye sorulması / zeyilname istenmesi gereken belirsizlikler (en fazla 8)"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
Kurallar:
|
||||
- criticalDetails en fazla 12, documentChecklist en fazla 18, deadlines en fazla 10, roadmap en fazla 10, itemsAndSpecs en fazla 20 madde olsun.
|
||||
- itemsAndSpecs'i teknik şartnamedeki kalem/miktar tablolarından çıkar; keySpecs'e yalnızca teklifi bağlayan zorunlu evsafları yaz (ör. "en az 17x17 inç dedektör", "IP54"), tanıtım cümlelerini yazma.
|
||||
- estimatedUnitPrice / estimatedTotal yalnızca metinde geçen bir yaklaşık maliyet, tahmini bedel veya birim fiyat varsa doldurulur; TAHMİN ÜRETME, yoksa null bırak. Sayıları yalnızca rakam olarak yaz (para birimi simgesi, nokta/virgül ayracı koyma).
|
||||
- roadmap adımlarını mantıklı sırayla ve step alanını 1'den başlayarak numaralandır.
|
||||
- Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.
|
||||
- Madde/bölüm numarası metinde geçiyorsa "where" ve "clause" alanlarında mutlaka yaz.
|
||||
- Tüm metinleri Türkçe yaz.`;
|
||||
|
||||
function buildUserPrompt({ text, fileName, tender, limit = 60000 }) {
|
||||
const parts = [];
|
||||
if (fileName) parts.push(`Dosya: ${fileName}`);
|
||||
if (tender) {
|
||||
@@ -113,12 +181,12 @@ function buildUserPrompt({ text, fileName, tender }) {
|
||||
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));
|
||||
parts.push('\n--- ŞARTNAME / İLAN METNİ ---\n' + String(text || '').slice(0, limit));
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// --- OpenRouter (OpenAI uyumlu) ---
|
||||
async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PROMPT) {
|
||||
async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PROMPT, maxTokens = 4000) {
|
||||
const res = await fetch(OPENROUTER_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -135,7 +203,7 @@ async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PR
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature: 0.2,
|
||||
max_tokens: 4000,
|
||||
max_tokens: maxTokens,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
signal,
|
||||
@@ -146,7 +214,7 @@ async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PR
|
||||
return data?.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
async function callOpenAiStyle(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT) {
|
||||
async function callOpenAiStyle(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT, maxTokens = 4000) {
|
||||
const res = await fetch(p.url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
||||
@@ -157,7 +225,7 @@ async function callOpenAiStyle(p, key, model, userPrompt, signal, sistem = SYSTE
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature: 0.2,
|
||||
max_tokens: 4000,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
@@ -166,7 +234,7 @@ async function callOpenAiStyle(p, key, model, userPrompt, signal, sistem = SYSTE
|
||||
return data?.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
async function callGemini(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT) {
|
||||
async function callGemini(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT, maxTokens = 4000) {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -174,7 +242,7 @@ async function callGemini(p, key, model, userPrompt, signal, sistem = SYSTEM_PRO
|
||||
body: JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: sistem }] },
|
||||
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
|
||||
generationConfig: { temperature: 0.2, maxOutputTokens: 4000, responseMimeType: 'application/json' },
|
||||
generationConfig: { temperature: 0.2, maxOutputTokens: maxTokens, responseMimeType: 'application/json' },
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
@@ -183,13 +251,13 @@ async function callGemini(p, key, model, userPrompt, signal, sistem = SYSTEM_PRO
|
||||
return data?.candidates?.[0]?.content?.parts?.map((x) => x.text).join('') || '';
|
||||
}
|
||||
|
||||
async function callAnthropic(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT) {
|
||||
async function callAnthropic(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT, maxTokens = 4000) {
|
||||
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,
|
||||
max_tokens: 4000,
|
||||
max_tokens: maxTokens,
|
||||
system: sistem,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
}),
|
||||
@@ -212,11 +280,16 @@ function extractJson(raw) {
|
||||
}
|
||||
|
||||
/** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */
|
||||
export async function analyzeSpec({ text, fileName, tender, chain, systemOverride }) {
|
||||
export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode }) {
|
||||
const derin = mode === 'derin';
|
||||
const order = (Array.isArray(chain) && chain.length ? chain : ['glmflash', 'gemini', 'deepseek', 'grok', 'gpt', 'claude'])
|
||||
.filter((id) => PROVIDERS[id]);
|
||||
const userPrompt = buildUserPrompt({ text, fileName, tender });
|
||||
const sistem = systemOverride || SYSTEM_PROMPT;
|
||||
// Derin analiz hem daha çok metin okur hem daha uzun JSON üretir; çıktı
|
||||
// kesilirse JSON parse edilemez ve zincir boşuna tüketilir.
|
||||
const userPrompt = buildUserPrompt({ text, fileName, tender, limit: derin ? 90000 : 60000 });
|
||||
const sistem = systemOverride || (derin ? DERIN_SYSTEM_PROMPT : SYSTEM_PROMPT);
|
||||
const maxTokens = derin ? 8000 : 4000;
|
||||
const timeoutMs = derin ? 150000 : 90000;
|
||||
const attempts = [];
|
||||
|
||||
for (const id of order) {
|
||||
@@ -227,22 +300,23 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
|
||||
continue;
|
||||
}
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), 90000);
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
let raw;
|
||||
if (route.yol === 'openrouter') {
|
||||
raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal, sistem);
|
||||
raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal, sistem, maxTokens);
|
||||
} else if (p.style === 'gemini') {
|
||||
raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal, sistem);
|
||||
raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens);
|
||||
} else if (p.style === 'anthropic') {
|
||||
raw = await callAnthropic(p, route.key, route.model, userPrompt, ac.signal, sistem);
|
||||
raw = await callAnthropic(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens);
|
||||
} else {
|
||||
raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem);
|
||||
raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens);
|
||||
}
|
||||
const result = extractJson(raw);
|
||||
attempts.push({ id, ok: true, yol: route.yol });
|
||||
return {
|
||||
result,
|
||||
mode: derin ? 'derin' : 'standart',
|
||||
model: { id, label: p.label, provider: p.provider, model: route.model, yol: route.yol },
|
||||
attempts,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SECTOR_RULES, INSTITUTION_RULES, classify, sektorBul, SEKTOR_ETIKETLERI
|
||||
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
|
||||
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
|
||||
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
|
||||
import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs';
|
||||
import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.mjs';
|
||||
import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs';
|
||||
import * as store from './user-store.mjs';
|
||||
@@ -786,6 +787,24 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 500, { success: false, error: 'dosya okunamadı' });
|
||||
}
|
||||
}
|
||||
// Belge metni — docx/xlsx/zip için. PDF tarayıcıda (pdfjs) okunuyor;
|
||||
// bu türler için tarayıcıda çözücü yok, baytlar zaten burada duruyor.
|
||||
if (url.pathname === '/api/me/documents/text') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
const rec = store.getDocument(user.email, url.searchParams.get('id'));
|
||||
if (!rec) return json(res, 404, { success: false, error: 'belge bulunamadı' });
|
||||
if (!sunucuOkuyabilir(rec.ad)) {
|
||||
return json(res, 400, { success: false, error: 'Bu dosya türünden sunucuda metin çıkarılamıyor (docx, xlsx, zip destekleniyor).' });
|
||||
}
|
||||
try {
|
||||
const buf = fs.readFileSync(path.join(store.BELGE_DIR, rec.dosya));
|
||||
const metin = arsivMetni(rec.ad, buf);
|
||||
return json(res, 200, { success: true, ad: rec.ad, metin, karakter: metin.length });
|
||||
} catch (e) {
|
||||
return json(res, 422, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kullanıcı verisi (localStorage yerine sunucu tarafı) ---
|
||||
if (url.pathname.startsWith('/api/me/data')) {
|
||||
@@ -973,6 +992,7 @@ const server = http.createServer(async (req, res) => {
|
||||
fileName: body.fileName,
|
||||
tender: body.tender,
|
||||
chain: body.chain,
|
||||
mode: body.mode === 'derin' ? 'derin' : undefined,
|
||||
});
|
||||
return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() });
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* DOCX / XLSX / ZIP metin çıkarımı — harici bağımlılık YOK.
|
||||
* ihaleab projesindeki backend/src/lib/officeText.ts dosyasından taşındı.
|
||||
*
|
||||
* NEDEN SUNUCUDA: ihaleist'te PDF metni tarayıcıda (pdfjs) çıkarılıyor, ama
|
||||
* docx/xlsx/zip için tarayıcıda çözücü yok. Belgelerin baytları zaten
|
||||
* sunucuda duruyor (BELGE_DIR), Node'un yerleşik zlib'i de burada.
|
||||
*/
|
||||
// ZIP / DOCX / XLSX metin çıkarımı — harici bağımlılık YOK.
|
||||
//
|
||||
// NEDEN: ihale şartnameleri pratikte .docx ve .xlsx olarak yayımlanıyor ve
|
||||
// çoğu zaman tek bir .zip arşivinin içinde geliyor (ör. Letonya EIS'te
|
||||
// "Nolikums ar pielikumiem.zip" — 17 dosya, içinde 12 kısmın teknik
|
||||
// şartnamesi ve mali teklif formu). pdf-parse yalnızca PDF okuyabildiği için
|
||||
// bu dosyalardan hiç metin çıkarılamıyordu; aranan birim miktarlar
|
||||
// (kaç adet polo gömlek, kaç çift bot) tam olarak bu dosyaların içinde.
|
||||
//
|
||||
// TASARIM: OOXML dosyalarının tamamı ZIP arşividir ve içerikleri düz XML'dir.
|
||||
// Node'un yerleşik zlib'i ile küçük bir ZIP okuyucu yazmak, yeni bir paket
|
||||
// eklemekten daha az risk taşıyor (projede zaten HTML/XML için regex tabanlı
|
||||
// yaklaşım benimsenmiş, bkz. tenderDocuments.ts stripHtml).
|
||||
//
|
||||
// SINIRLAR: ZIP64 arşivleri ve şifreli arşivler desteklenmez — bu durumlarda
|
||||
// anlamlı bir hata döner, sessizce bozuk veri üretilmez.
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
|
||||
const EOCD_SIG = 0x06054b50;
|
||||
const CENTRAL_SIG = 0x02014b50;
|
||||
const LOCAL_SIG = 0x04034b50;
|
||||
|
||||
const MAX_ENTRIES = 400;
|
||||
const MAX_ENTRY_BYTES = 80 * 1024 * 1024;
|
||||
|
||||
|
||||
/**
|
||||
* ZIP arşivinin merkezî dizinini okuyup girdileri döndürür. İçerik tembel
|
||||
* açılır (read()) — 17 dosyalık bir arşivde yalnızca ihtiyaç duyulanlar
|
||||
* açılsın diye.
|
||||
*/
|
||||
export function readZip(buf) {
|
||||
const eocd = findEocd(buf);
|
||||
if (eocd < 0) throw new Error("ZIP dizini bulunamadı (geçersiz veya ZIP64 arşiv).");
|
||||
|
||||
const entryCount = buf.readUInt16LE(eocd + 10);
|
||||
const centralOffset = buf.readUInt32LE(eocd + 16);
|
||||
if (centralOffset >= buf.length) throw new Error("ZIP merkezî dizin konumu geçersiz.");
|
||||
|
||||
const entries = [];
|
||||
let p = centralOffset;
|
||||
|
||||
for (let i = 0; i < Math.min(entryCount, MAX_ENTRIES); i++) {
|
||||
if (p + 46 > buf.length || buf.readUInt32LE(p) !== CENTRAL_SIG) break;
|
||||
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const flags = buf.readUInt16LE(p + 8);
|
||||
const compSize = buf.readUInt32LE(p + 20);
|
||||
const uncompSize = buf.readUInt32LE(p + 24);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = decodeName(buf.subarray(p + 46, p + 46 + nameLen), flags);
|
||||
|
||||
const isDirectory = name.endsWith("/") || uncompSize === 0;
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
size: uncompSize,
|
||||
isDirectory,
|
||||
read: () => {
|
||||
if (flags & 0x1) throw new Error(`Şifreli arşiv girdisi okunamaz: ${name}`);
|
||||
if (compSize > MAX_ENTRY_BYTES) throw new Error(`Arşiv girdisi çok büyük: ${name}`);
|
||||
if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== LOCAL_SIG) {
|
||||
throw new Error(`Arşiv girdisi konumu geçersiz: ${name}`);
|
||||
}
|
||||
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
||||
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + lNameLen + lExtraLen;
|
||||
const raw = buf.subarray(start, start + compSize);
|
||||
if (method === 0) return Buffer.from(raw);
|
||||
if (method === 8) return inflateRawSync(raw);
|
||||
throw new Error(`Desteklenmeyen sıkıştırma yöntemi (${method}): ${name}`);
|
||||
},
|
||||
});
|
||||
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** EOCD imzasını sondan geriye doğru arar (yorum alanı en fazla 64KB olabilir). */
|
||||
function findEocd(buf) {
|
||||
const min = Math.max(0, buf.length - 65_557);
|
||||
for (let i = buf.length - 22; i >= min; i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD_SIG) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** ZIP adları UTF-8 bayrağı yoksa CP437'dir; pratikte UTF-8 denemesi yeterli. */
|
||||
function decodeName(raw, flags) {
|
||||
if (flags & 0x800) return raw.toString("utf8");
|
||||
const asUtf8 = raw.toString("utf8");
|
||||
return asUtf8.includes("�") ? raw.toString("latin1") : asUtf8;
|
||||
}
|
||||
|
||||
/* ---------------- XML yardımcıları ---------------- */
|
||||
|
||||
function decodeXmlEntities(s) {
|
||||
return s
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function stripTags(xml) {
|
||||
return decodeXmlEntities(xml.replace(/<[^>]+>/g, "")).replace(/[ \t]+/g, " ").trim();
|
||||
}
|
||||
|
||||
function findEntry(entries, path) {
|
||||
return entries.find((e) => e.name === path || e.name === path.replace(/^\//, "")) ?? null;
|
||||
}
|
||||
|
||||
/* ---------------- DOCX ---------------- */
|
||||
|
||||
/**
|
||||
* DOCX'ten metin çıkarır. Tablolar korunur: hücreler " | ", satırlar yeni
|
||||
* satır. Miktar tabloları düz metne ezilirse hangi sayının hangi kaleme ait
|
||||
* olduğu kaybolduğu için bu şart.
|
||||
*/
|
||||
export function docxToText(buf) {
|
||||
const entries = readZip(buf);
|
||||
const doc = findEntry(entries, "word/document.xml");
|
||||
if (!doc) throw new Error("DOCX içinde word/document.xml bulunamadı.");
|
||||
|
||||
let xml = doc.read().toString("utf8");
|
||||
|
||||
// Hücre/satır sınırları ÖNCE işaretleyiciye çevrilir. Doğrudan " | " ve "\n"
|
||||
// yazılamaz: bir tablo hücresi kendi içinde <w:p> barındırdığı için paragraf
|
||||
// sonu ayracı kendi satırına düşürür ve satır temizliğinde silinirdi.
|
||||
const CELL = "";
|
||||
const ROW = "";
|
||||
|
||||
xml = xml
|
||||
.replace(/<w:br\b[^>]*\/?>/g, "\n")
|
||||
.replace(/<w:tab\b[^>]*\/?>/g, "\t")
|
||||
.replace(/<\/w:tc>/g, CELL)
|
||||
.replace(/<\/w:tr>/g, ROW)
|
||||
.replace(/<\/w:p>/g, "\n");
|
||||
|
||||
const duz = stripTagsPreservingBreaks(xml)
|
||||
// İşaretleyicinin etrafındaki boşluk/satır sonlarını yut.
|
||||
.replace(new RegExp(`\\s*${CELL}\\s*`, "g"), " | ")
|
||||
.replace(new RegExp(`\\s*${ROW}\\s*`, "g"), "\n");
|
||||
|
||||
return tidyLines(duz);
|
||||
}
|
||||
|
||||
/** stripTags'in satır sonlarını koruyan hali. */
|
||||
function stripTagsPreservingBreaks(xml) {
|
||||
return decodeXmlEntities(xml.replace(/<[^>]+>/g, ""));
|
||||
}
|
||||
|
||||
function tidyLines(text) {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/[ \t ]+/g, " ").replace(/\s*\|\s*/g, " | ").trim())
|
||||
.map((l) => l.replace(/^\|\s*/, "").replace(/\s*\|$/, "").trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/* ---------------- XLSX ---------------- */
|
||||
|
||||
/** A1 -> 0, B1 -> 1 ... (sütun harflerini indekse çevirir) */
|
||||
function colIndex(ref) {
|
||||
const m = /^([A-Z]+)/.exec(ref);
|
||||
if (!m) return 0;
|
||||
let n = 0;
|
||||
for (const ch of m[1]) n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
function parseSharedStrings(entries) {
|
||||
const e = findEntry(entries, "xl/sharedStrings.xml");
|
||||
if (!e) return [];
|
||||
const xml = e.read().toString("utf8");
|
||||
const out = [];
|
||||
// Her <si> bir dize; içinde birden çok <t> parçası olabilir (biçimli metin).
|
||||
for (const si of xml.split(/<si\b[^>]*>/).slice(1)) {
|
||||
const govde = si.split("</si>")[0] ?? "";
|
||||
const parcalar = [...govde.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((m) => m[1]);
|
||||
out.push(decodeXmlEntities(parcalar.join("")).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sheetNames(entries) {
|
||||
const wb = findEntry(entries, "xl/workbook.xml");
|
||||
if (!wb) return [];
|
||||
const xml = wb.read().toString("utf8");
|
||||
return [...xml.matchAll(/<sheet\b[^>]*\bname="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* XLSX'ten metin çıkarır. Her sayfa "### <sayfa adı>" başlığıyla, satırlar
|
||||
* " | " ayraçlı olarak döner — mali teklif formundaki kalem/miktar tabloları
|
||||
* bu biçimde okunabilir kalıyor.
|
||||
*/
|
||||
export function xlsxToText(buf) {
|
||||
const entries = readZip(buf);
|
||||
const shared = parseSharedStrings(entries);
|
||||
const adlar = sheetNames(entries);
|
||||
|
||||
const sayfalar = entries
|
||||
.filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e.name))
|
||||
.sort((a, b) => {
|
||||
const na = Number(/sheet(\d+)\.xml/.exec(a.name)?.[1] ?? 0);
|
||||
const nb = Number(/sheet(\d+)\.xml/.exec(b.name)?.[1] ?? 0);
|
||||
return na - nb;
|
||||
});
|
||||
if (sayfalar.length === 0) throw new Error("XLSX içinde çalışma sayfası bulunamadı.");
|
||||
|
||||
const bloklar = [];
|
||||
|
||||
sayfalar.forEach((sheet, idx) => {
|
||||
const xml = sheet.read().toString("utf8");
|
||||
const satirlar = [];
|
||||
|
||||
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||
const hucreler = [];
|
||||
for (const c of rowMatch[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||
const attrs = c[1];
|
||||
const govde = c[2];
|
||||
const ref = /\br="([A-Z]+\d+)"/.exec(attrs)?.[1] ?? "";
|
||||
const tip = /\bt="([^"]*)"/.exec(attrs)?.[1] ?? "";
|
||||
const vRaw = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(govde)?.[1];
|
||||
|
||||
let deger = "";
|
||||
if (tip === "s") {
|
||||
const i = Number(vRaw);
|
||||
deger = Number.isInteger(i) ? shared[i] ?? "" : "";
|
||||
} else if (tip === "inlineStr") {
|
||||
deger = stripTags(govde);
|
||||
} else if (vRaw != null) {
|
||||
deger = decodeXmlEntities(vRaw).trim();
|
||||
}
|
||||
|
||||
if (!deger) continue;
|
||||
const i = ref ? colIndex(ref) : hucreler.length;
|
||||
while (hucreler.length < i) hucreler.push("");
|
||||
hucreler[i] = deger;
|
||||
}
|
||||
|
||||
// Kendi kendine boş satırları atla.
|
||||
const satir = hucreler.join(" | ").replace(/(\s*\|\s*)+$/, "").trim();
|
||||
if (satir.replace(/[|\s]/g, "")) satirlar.push(satir);
|
||||
}
|
||||
|
||||
if (satirlar.length > 0) {
|
||||
bloklar.push(`### ${adlar[idx] ?? `Sayfa ${idx + 1}`}\n${satirlar.join("\n")}`);
|
||||
}
|
||||
});
|
||||
|
||||
return bloklar.join("\n\n").trim();
|
||||
}
|
||||
|
||||
/* ---------------- Dağıtıcı ---------------- */
|
||||
|
||||
export function isDocx(name, buf) {
|
||||
return /\.docx$/i.test(name) || (/\.doc$/i.test(name) === false && hasEntry(buf, "word/document.xml"));
|
||||
}
|
||||
|
||||
export function isXlsx(name) {
|
||||
return /\.xlsx$/i.test(name);
|
||||
}
|
||||
|
||||
export function isZip(name, buf) {
|
||||
return /\.zip$/i.test(name) || (buf.length > 4 && buf.readUInt32LE(0) === LOCAL_SIG);
|
||||
}
|
||||
|
||||
function hasEntry(buf, path) {
|
||||
try {
|
||||
return readZip(buf).some((e) => e.name === path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dosya adına ve içeriğine bakarak uygun çıkarıcıyı seçer. Tanınmayan tür
|
||||
* için null döner (çağıran taraf PDF/HTML yollarına düşebilsin diye).
|
||||
*/
|
||||
export function officeToText(name, buf) {
|
||||
try {
|
||||
if (isXlsx(name)) return xlsxToText(buf);
|
||||
if (/\.docx$/i.test(name)) return docxToText(buf);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------------- Arşiv (zip) ---------------- */
|
||||
|
||||
// Derin analiz zaten 90.000 karakter okuyor; tek bir arşivin bunun tamamını
|
||||
// yemesini istemiyoruz — birden çok belge birleştiriliyor.
|
||||
const MAX_ARSIV_KARAKTER = 60000;
|
||||
|
||||
/**
|
||||
* ZIP/DOCX/XLSX tamponundan metin çıkarır. Arşivse içindeki Office dosyaları
|
||||
* tek tek çözülüp "## <dosya adı>" başlıklarıyla birleştirilir. İhale
|
||||
* paketleri iç içe zip olabildiği için bir seviye iç arşiv de açılır.
|
||||
*/
|
||||
export function arsivMetni(name, buf, depth = 0) {
|
||||
const direct = officeToText(name, buf);
|
||||
if (direct) return direct;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readZip(buf);
|
||||
} catch (e) {
|
||||
throw new Error(`Arşiv okunamadı: ${String(e.message || e).slice(0, 160)}`);
|
||||
}
|
||||
|
||||
const bloklar = [];
|
||||
let toplam = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (toplam >= MAX_ARSIV_KARAKTER) break;
|
||||
if (entry.isDirectory) continue;
|
||||
// Word'ün geçici kilit dosyaları (~$...) içerik taşımaz.
|
||||
if (/(^|\/)~\$/.test(entry.name)) continue;
|
||||
|
||||
const kisaAd = entry.name.split('/').pop() ?? entry.name;
|
||||
let metin = null;
|
||||
try {
|
||||
const icerik = entry.read();
|
||||
if (depth < 1 && /\.zip$/i.test(entry.name)) {
|
||||
metin = arsivMetni(kisaAd, icerik, depth + 1);
|
||||
} else {
|
||||
metin = officeToText(entry.name, icerik);
|
||||
}
|
||||
} catch {
|
||||
continue; // tek bozuk dosya tüm paketi düşürmesin
|
||||
}
|
||||
|
||||
if (!metin) continue;
|
||||
bloklar.push(`## ${kisaAd}\n${metin}`);
|
||||
toplam += metin.length;
|
||||
}
|
||||
|
||||
if (bloklar.length === 0) throw new Error('Arşiv içinde okunabilir doküman bulunamadı.');
|
||||
return bloklar.join('\n\n').slice(0, MAX_ARSIV_KARAKTER);
|
||||
}
|
||||
|
||||
/** Sunucunun metin çıkarabildiği uzantılar (PDF tarayıcıda okunuyor). */
|
||||
export function sunucuOkuyabilir(ad) {
|
||||
return /\.(docx|xlsx|zip)$/i.test(String(ad || ''));
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { useApp } from '../context/AppContext';
|
||||
import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { getAiConfig } from '../services/ai';
|
||||
import { DeepAnalysisReport } from './DeepAnalysisReport';
|
||||
|
||||
const tl = (n) => (typeof n === 'number' && isFinite(n) ? '₺' + n.toLocaleString('tr-TR') : '—');
|
||||
|
||||
export const AiSpecAnalyzerModal = () => {
|
||||
const {
|
||||
isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
|
||||
analyzeSpecPdfWithGemini, aiAnalysisResults, activeAiModel, activeTenderDetail,
|
||||
analyzeSpecPdfWithGemini, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail,
|
||||
} = useApp();
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
@@ -18,6 +19,9 @@ export const AiSpecAnalyzerModal = () => {
|
||||
const [progress, setProgress] = useState('');
|
||||
const [customBid, setCustomBid] = useState(null);
|
||||
const [aiCfg, setAiCfg] = useState(null);
|
||||
// Derin mod: ince detaylar, belge kontrol listesi ve yol haritası da üretilir.
|
||||
// Çıktısı standart şemanın üst kümesi olduğu için varsayılan açık.
|
||||
const [derin, setDerin] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAiSpecAnalyzerOpen && aiCfg === null) getAiConfig().then(setAiCfg);
|
||||
@@ -25,6 +29,9 @@ export const AiSpecAnalyzerModal = () => {
|
||||
|
||||
if (!isAiSpecAnalyzerOpen) return null;
|
||||
|
||||
// Analiz doküman panelinden de başlatılabiliyor — o durumda isAnalyzing false
|
||||
// kalır, bu yüzden context'teki ortak sinyalle birleştiriyoruz.
|
||||
const mesgul = isAnalyzing || aiAnalyzing;
|
||||
const results = aiAnalysisResults;
|
||||
const bid = results?.bidAnalysis || {};
|
||||
|
||||
@@ -33,8 +40,8 @@ export const AiSpecAnalyzerModal = () => {
|
||||
setIsAnalyzing(true);
|
||||
setSelectedFile(fileName);
|
||||
try {
|
||||
setProgress('Model analiz ediyor…');
|
||||
const r = await analyzeSpecPdfWithGemini({ text, fileName, tender: activeTenderDetail });
|
||||
setProgress(derin ? 'Derin analiz üretiliyor (1-2 dk sürebilir)…' : 'Model analiz ediyor…');
|
||||
const r = await analyzeSpecPdfWithGemini({ text, fileName, tender: activeTenderDetail, mode: derin ? 'derin' : undefined });
|
||||
if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid);
|
||||
confetti({ particleCount: 80, spread: 70 });
|
||||
} catch (e) {
|
||||
@@ -56,8 +63,8 @@ export const AiSpecAnalyzerModal = () => {
|
||||
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 });
|
||||
setProgress(`${usedPages}/${pageCount} sayfa okundu, ${derin ? 'derin analiz üretiliyor (1-2 dk sürebilir)…' : 'model analiz ediyor…'}`);
|
||||
const r = await analyzeSpecPdfWithGemini({ text, fileName: file.name, tender: activeTenderDetail, mode: derin ? 'derin' : undefined });
|
||||
if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid);
|
||||
confetti({ particleCount: 80, spread: 70 });
|
||||
} catch (e) {
|
||||
@@ -133,9 +140,37 @@ export const AiSpecAnalyzerModal = () => {
|
||||
<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="inline-flex items-center gap-1 bg-white border border-slate-300 rounded-xl p-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={mesgul}
|
||||
onClick={() => setDerin(true)}
|
||||
className={`text-[11px] font-extrabold px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50 ${
|
||||
derin ? 'bg-amber-400 text-slate-950' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
Derin Analiz + Yol Haritası
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={mesgul}
|
||||
onClick={() => setDerin(false)}
|
||||
className={`text-[11px] font-extrabold px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50 ${
|
||||
!derin ? 'bg-slate-800 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
Hızlı Özet
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed max-w-lg mx-auto">
|
||||
{derin
|
||||
? 'Elenme sebepleri, gözden kaçan ince detaylar, belge kontrol listesi, kritik tarihler ve teklife kadar adım adım yol haritası üretilir. Daha uzun sürer.'
|
||||
: 'Yalnızca özet, yeterlik kriterleri, riskler ve teklif aralığı üretilir. Daha hızlıdır.'}
|
||||
</p>
|
||||
|
||||
<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'
|
||||
mesgul ? '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>Dosya Seç (PDF / TXT)</span>
|
||||
@@ -143,7 +178,7 @@ export const AiSpecAnalyzerModal = () => {
|
||||
type="file"
|
||||
accept=".pdf,.txt,.md"
|
||||
className="hidden"
|
||||
disabled={isAnalyzing}
|
||||
disabled={mesgul}
|
||||
onChange={(e) => { if (e.target.files?.[0]) handleFile(e.target.files[0]); }}
|
||||
/>
|
||||
</label>
|
||||
@@ -151,7 +186,7 @@ export const AiSpecAnalyzerModal = () => {
|
||||
{activeTenderDetail && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAnalyzing}
|
||||
disabled={mesgul}
|
||||
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"
|
||||
>
|
||||
@@ -174,18 +209,18 @@ export const AiSpecAnalyzerModal = () => {
|
||||
)}
|
||||
|
||||
{/* Yükleniyor */}
|
||||
{isAnalyzing && (
|
||||
{mesgul && (
|
||||
<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>
|
||||
<span>{progress || 'Evraklar analiz ediliyor…'}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">{selectedFile}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sonuç */}
|
||||
{!isAnalyzing && results && (
|
||||
{!mesgul && results && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
<div className="bg-slate-900 text-white p-5 rounded-2xl space-y-3 shadow-md">
|
||||
@@ -193,6 +228,11 @@ export const AiSpecAnalyzerModal = () => {
|
||||
<span className="text-xs font-extrabold text-amber-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4" /> Şartname Özeti
|
||||
</span>
|
||||
{results.mode === 'derin' && (
|
||||
<span className="bg-amber-400 text-slate-950 text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider">
|
||||
Derin Analiz
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{results.model} · {results.analyzedAt}
|
||||
</span>
|
||||
@@ -201,6 +241,8 @@ export const AiSpecAnalyzerModal = () => {
|
||||
{results.fileName && <p className="text-[10px] text-slate-500">Kaynak: {results.fileName}</p>}
|
||||
</div>
|
||||
|
||||
<DeepAnalysisReport results={results} tender={activeTenderDetail} />
|
||||
|
||||
{/* 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">
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import {
|
||||
FolderCheck, ChevronDown, ChevronUp, FileSignature, Table2, ListChecks,
|
||||
AlertTriangle, Printer, Info,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
yaziylaTL, paraTR, miktarAyristir, belgeYazdir,
|
||||
teklifMektubuHtml, teklifCetveliHtml, evrakListesiHtml,
|
||||
} from '../services/bidDocs';
|
||||
|
||||
/**
|
||||
* İhale Dosyası Hazırla — derin analiz bittikten sonra teklif zarfına girecek
|
||||
* belgeleri üretir. Analizden gelen kalemler fiyatlandırılır, eksik firma
|
||||
* bilgileri kullanıcıya sorulur; belgeler tam Türkçe olarak yazdırılır.
|
||||
*
|
||||
* Firma bilgileri localStorage'da tutulur — her ihalede yeniden sorulmasın.
|
||||
*/
|
||||
|
||||
const ALANLAR = [
|
||||
{ k: 'firmaUnvan', l: 'Firma ticaret unvanı', zorunlu: true, genis: true },
|
||||
{ k: 'adres', l: 'Tebligata esas adres', zorunlu: true, genis: true },
|
||||
{ k: 'vergiDairesi', l: 'Vergi dairesi', zorunlu: true },
|
||||
{ k: 'vergiNo', l: 'Vergi numarası', zorunlu: true },
|
||||
{ k: 'sicilNo', l: 'Ticaret sicil / MERSİS no', zorunlu: false },
|
||||
{ k: 'yetkiliAd', l: 'Yetkili ad soyad', zorunlu: true },
|
||||
{ k: 'yetkiliGorev', l: 'Yetkilinin görevi', zorunlu: true, ph: 'Örn. Genel Müdür' },
|
||||
{ k: 'telefon', l: 'Telefon', zorunlu: true },
|
||||
{ k: 'eposta', l: 'E-posta', zorunlu: true },
|
||||
];
|
||||
|
||||
const FIRMA_ANAHTAR = 'ihaleist_firma_bilgi';
|
||||
|
||||
const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
|
||||
|
||||
export const BidPackageBuilder = ({ results, tender }) => {
|
||||
const { userProfile } = useApp();
|
||||
const [acik, setAcik] = useState(false);
|
||||
const [hata, setHata] = useState(null);
|
||||
|
||||
const [firma, setFirma] = useState(() => {
|
||||
let kayitli = {};
|
||||
try { kayitli = JSON.parse(localStorage.getItem(FIRMA_ANAHTAR) || '{}'); } catch { /* yoksay */ }
|
||||
return {
|
||||
...bosFirma(),
|
||||
firmaUnvan: userProfile?.company || '',
|
||||
...kayitli,
|
||||
};
|
||||
});
|
||||
|
||||
const [teklifTuru, setTeklifTuru] = useState('birim');
|
||||
const [gecerlilikGun, setGecerlilikGun] = useState('60');
|
||||
const [kisim, setKisim] = useState('');
|
||||
const [goturuBedel, setGoturuBedel] = useState('');
|
||||
|
||||
const kalemler = Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : [];
|
||||
const [fiyatlar, setFiyatlar] = useState({});
|
||||
|
||||
const alanYaz = (k, v) => {
|
||||
const yeni = { ...firma, [k]: v };
|
||||
setFirma(yeni);
|
||||
try { localStorage.setItem(FIRMA_ANAHTAR, JSON.stringify(yeni)); } catch { /* kota */ }
|
||||
};
|
||||
|
||||
const satirlar = useMemo(
|
||||
() => kalemler.map((k, i) => ({ ...k, birimFiyat: fiyatlar[i] })),
|
||||
[kalemler, fiyatlar],
|
||||
);
|
||||
|
||||
const kalemToplami = useMemo(
|
||||
() => satirlar.reduce((t, r) => {
|
||||
const { miktar } = miktarAyristir(r.quantity);
|
||||
return t + (miktar ?? 0) * (Number(r.birimFiyat) || 0);
|
||||
}, 0),
|
||||
[satirlar],
|
||||
);
|
||||
|
||||
const toplam = teklifTuru === 'goturu' ? (Number(goturuBedel) || 0) : kalemToplami;
|
||||
|
||||
const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
|
||||
const hazir = eksikler.length === 0 && toplam > 0;
|
||||
|
||||
const formVerisi = () => ({
|
||||
...firma,
|
||||
teklifTuru,
|
||||
gecerlilikGun,
|
||||
kisim,
|
||||
idare: tender?.kurum || '',
|
||||
isinAdi: tender?.baslik || '',
|
||||
ikn: tender?.ikn || '',
|
||||
ihaleTarihi: tender?.ihaleTarihi || tender?.tarih || '',
|
||||
});
|
||||
|
||||
const uret = (tip) => {
|
||||
setHata(null);
|
||||
try {
|
||||
const f = formVerisi();
|
||||
if (tip === 'mektup') belgeYazdir('Teklif Mektubu', teklifMektubuHtml(f, toplam));
|
||||
if (tip === 'cetvel') belgeYazdir('Birim Fiyat Teklif Cetveli', teklifCetveliHtml(f, satirlar, toplam));
|
||||
if (tip === 'liste') belgeYazdir('Evrak Kontrol Listesi', evrakListesiHtml(f, results?.documentChecklist || []));
|
||||
} catch (e) {
|
||||
setHata(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border-2 border-emerald-300 shadow-xs overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAcik((o) => !o)}
|
||||
className="w-full flex items-center gap-3 p-5 text-left hover:bg-emerald-50/50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shrink-0">
|
||||
<FolderCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-black text-sm text-slate-900">İhale Dosyası Hazırla</h4>
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed">
|
||||
Analizdeki kalemleri fiyatlandırın; teklif mektubu, birim fiyat teklif cetveli ve
|
||||
evrak kontrol listesi hazır olsun.
|
||||
</p>
|
||||
</div>
|
||||
{acik ? <ChevronUp className="w-5 h-5 text-slate-400 shrink-0" />
|
||||
: <ChevronDown className="w-5 h-5 text-slate-400 shrink-0" />}
|
||||
</button>
|
||||
|
||||
{acik && (
|
||||
<div className="border-t border-emerald-200 p-5 space-y-5">
|
||||
|
||||
{/* Sizden beklenen bilgiler */}
|
||||
{eksikler.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-300 rounded-xl p-3.5 flex gap-2.5">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-amber-900">Sizden beklenen bilgiler</p>
|
||||
<p className="text-[11px] text-amber-800 leading-relaxed mt-0.5">
|
||||
Belgeleri hazırlayabilmek için şunlar gerekli:{' '}
|
||||
<strong>{eksikler.map((a) => a.l).join(', ')}</strong>.
|
||||
Bir kez girdiğinizde bu tarayıcıda saklanır, sonraki ihalelerde tekrar sorulmaz.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Firma bilgileri */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="font-extrabold text-xs text-slate-900">İstekli (firma) bilgileri</h5>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{ALANLAR.map((a) => {
|
||||
const eksik = a.zorunlu && !String(firma[a.k] || '').trim();
|
||||
return (
|
||||
<label key={a.k} className={a.genis ? 'sm:col-span-2' : ''}>
|
||||
<span className="text-[11px] font-semibold text-slate-600 block mb-1">
|
||||
{a.l}{a.zorunlu && <span className="text-red-600"> *</span>}
|
||||
</span>
|
||||
<input
|
||||
value={firma[a.k] || ''}
|
||||
onChange={(e) => alanYaz(a.k, e.target.value)}
|
||||
placeholder={a.ph || ''}
|
||||
className={`w-full text-xs px-3 py-2 rounded-lg border bg-white focus:ring-2 focus:ring-emerald-500 outline-none ${
|
||||
eksik ? 'border-amber-400 bg-amber-50/40' : 'border-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teklif ayarları */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||
<label>
|
||||
<span className="text-[11px] font-semibold text-slate-600 block mb-1">Teklif türü</span>
|
||||
<select
|
||||
value={teklifTuru}
|
||||
onChange={(e) => setTeklifTuru(e.target.value)}
|
||||
className="w-full text-xs px-3 py-2 rounded-lg border border-slate-300 bg-white"
|
||||
>
|
||||
<option value="birim">Birim fiyat</option>
|
||||
<option value="goturu">Anahtar teslim götürü bedel</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="text-[11px] font-semibold text-slate-600 block mb-1">Teklif geçerlilik (gün)</span>
|
||||
<input
|
||||
value={gecerlilikGun}
|
||||
onChange={(e) => setGecerlilikGun(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full text-xs px-3 py-2 rounded-lg border border-slate-300"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="text-[11px] font-semibold text-slate-600 block mb-1">Kısım (varsa)</span>
|
||||
<input
|
||||
value={kisim}
|
||||
onChange={(e) => setKisim(e.target.value)}
|
||||
placeholder="Örn. 2. kısım"
|
||||
className="w-full text-xs px-3 py-2 rounded-lg border border-slate-300"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Fiyatlandırma */}
|
||||
{teklifTuru === 'goturu' ? (
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold text-slate-600 block mb-1">
|
||||
Teklif edilen toplam bedel (KDV hariç, TL)
|
||||
</span>
|
||||
<input
|
||||
value={goturuBedel}
|
||||
onChange={(e) => setGoturuBedel(e.target.value.replace(/[^\d.]/g, ''))}
|
||||
placeholder="0"
|
||||
className="w-full sm:w-64 text-sm font-bold px-3 py-2 rounded-lg border border-slate-300"
|
||||
/>
|
||||
</label>
|
||||
) : kalemler.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h5 className="font-extrabold text-xs text-slate-900">Kalem fiyatlandırma</h5>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs min-w-[520px]">
|
||||
<thead>
|
||||
<tr className="text-[11px] text-slate-500 border-b border-slate-200">
|
||||
<th className="text-left py-1.5 font-semibold">Kalem</th>
|
||||
<th className="text-right py-1.5 font-semibold w-24">Miktar</th>
|
||||
<th className="text-right py-1.5 font-semibold w-32">Birim fiyat</th>
|
||||
<th className="text-right py-1.5 font-semibold w-28">Tutar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{satirlar.map((r, i) => {
|
||||
const { miktar, birim } = miktarAyristir(r.quantity);
|
||||
const tutar = (miktar ?? 0) * (Number(r.birimFiyat) || 0);
|
||||
return (
|
||||
<tr key={i} className="border-b border-slate-100">
|
||||
<td className="py-1.5 pr-2 text-slate-800">{r.name || '—'}</td>
|
||||
<td className="py-1.5 text-right text-slate-600 whitespace-nowrap">
|
||||
{miktar ?? '—'} {birim}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
<input
|
||||
value={fiyatlar[i] ?? ''}
|
||||
onChange={(e) =>
|
||||
setFiyatlar((o) => ({ ...o, [i]: e.target.value.replace(/[^\d.]/g, '') }))}
|
||||
placeholder={r.estimatedUnitPrice != null ? String(r.estimatedUnitPrice) : '0'}
|
||||
className="w-28 text-xs text-right px-2 py-1 rounded border border-slate-300"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-semibold text-slate-900 whitespace-nowrap">
|
||||
{tutar ? paraTR(tutar) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{kalemler.some((k) => k.estimatedUnitPrice != null) && (
|
||||
<p className="text-[10px] text-slate-400 flex items-start gap-1.5">
|
||||
<Info className="w-3 h-3 shrink-0 mt-0.5" />
|
||||
Gri yazılı değerler şartnamede geçen tahmini birim fiyatlardır — teklifiniz değildir.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 bg-slate-50 border border-slate-200 rounded-lg p-3">
|
||||
Analizde kalem dökümü çıkmadı. Götürü bedel seçip toplam teklifinizi doğrudan girebilirsiniz.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Toplam */}
|
||||
<div className="bg-slate-900 text-white rounded-xl p-4 space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
||||
<span className="text-xs font-semibold text-slate-300">Teklif toplamı (KDV hariç)</span>
|
||||
<span className="text-lg font-black text-amber-300">{paraTR(toplam)} TL</span>
|
||||
</div>
|
||||
{toplam > 0 && (
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">Yazı ile: {yaziylaTL(toplam)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hata && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-red-800 leading-relaxed">{hata}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Belgeler */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="font-extrabold text-xs text-slate-900">Hazırlanacak belgeler</h5>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||
{[
|
||||
{ t: 'mektup', ad: 'Teklif Mektubu', ikon: FileSignature, aciklama: 'Rakam ve yazı ile bedel' },
|
||||
{ t: 'cetvel', ad: 'Birim Fiyat Teklif Cetveli', ikon: Table2, aciklama: `${kalemler.length} kalem` },
|
||||
{ t: 'liste', ad: 'Evrak Kontrol Listesi', ikon: ListChecks, aciklama: `${(results?.documentChecklist || []).length} belge` },
|
||||
].map((b) => (
|
||||
<button
|
||||
key={b.t}
|
||||
type="button"
|
||||
disabled={!hazir}
|
||||
onClick={() => uret(b.t)}
|
||||
className="text-left bg-white border border-slate-300 hover:border-emerald-500 hover:bg-emerald-50/50 disabled:opacity-40 disabled:hover:border-slate-300 disabled:hover:bg-white rounded-xl p-3 transition-colors"
|
||||
>
|
||||
<b.ikon className="w-4 h-4 text-emerald-600 mb-1.5" />
|
||||
<span className="block text-xs font-bold text-slate-900 leading-snug">{b.ad}</span>
|
||||
<span className="block text-[10px] text-slate-500 mt-0.5">{b.aciklama}</span>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-700 mt-1.5">
|
||||
<Printer className="w-3 h-3" /> Yazdır / PDF
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!hazir && (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{eksikler.length > 0
|
||||
? 'Yukarıdaki zorunlu alanları doldurun.'
|
||||
: 'Belgeleri hazırlamak için teklif tutarını girin.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-400 leading-relaxed">
|
||||
Belgeler taslaktır ve idarenin standart formlarının yerine geçmez. İmzalamadan önce
|
||||
ihale dokümanındaki form metinleriyle karşılaştırın.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
AlertOctagon, AlertTriangle, Eye, CalendarClock, ClipboardList, Route,
|
||||
HelpCircle, Coins, Target, CheckSquare, Square, Package,
|
||||
} from 'lucide-react';
|
||||
import { BidPackageBuilder } from './BidPackageBuilder';
|
||||
|
||||
/**
|
||||
* Derin analiz raporu — /api/ai/analyze mode='derin' çıktısını gösterir.
|
||||
* Standart analizden farkı: elenme sebepleri, gözden kaçan ince detaylar,
|
||||
* belge kontrol listesi, süreler ve teklife kadarki yol haritası.
|
||||
*
|
||||
* Model alanları kısmi doldurabilir; her bölüm kendi verisi varsa çizilir.
|
||||
*/
|
||||
|
||||
const SEVIYE_RENK = {
|
||||
Kritik: 'bg-red-100 text-red-800 border-red-200',
|
||||
Yüksek: 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
Orta: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
Düşük: 'bg-slate-200 text-slate-700 border-slate-300',
|
||||
};
|
||||
|
||||
const FAZ_RENK = {
|
||||
Hazırlık: 'bg-slate-700',
|
||||
'Belge Toplama': 'bg-blue-600',
|
||||
Teknik: 'bg-indigo-600',
|
||||
Fiyatlandırma: 'bg-emerald-600',
|
||||
Teklif: 'bg-amber-600',
|
||||
Sözleşme: 'bg-purple-600',
|
||||
};
|
||||
|
||||
const dolu = (x) => Array.isArray(x) && x.length > 0;
|
||||
|
||||
const tl = (n) =>
|
||||
typeof n === 'number' && isFinite(n)
|
||||
? n.toLocaleString('tr-TR', { maximumFractionDigits: 2 }) + ' ₺'
|
||||
: null;
|
||||
|
||||
const Baslik = ({ icon: Icon, renk, children, not }) => (
|
||||
<h4 className="font-extrabold text-sm text-slate-900 flex items-center gap-2 border-b border-slate-100 pb-2">
|
||||
<Icon className={`w-4 h-4 ${renk}`} />
|
||||
<span>{children}</span>
|
||||
{not && <span className="ml-auto text-[10px] font-semibold text-slate-400">{not}</span>}
|
||||
</h4>
|
||||
);
|
||||
|
||||
export const DeepAnalysisReport = ({ results, tender }) => {
|
||||
const [isaretli, setIsaretli] = useState({});
|
||||
if (!results) return null;
|
||||
|
||||
const {
|
||||
scopeOfWork, disqualificationRisks, criticalDetails, documentChecklist,
|
||||
deadlines, costDrivers, roadmap, questionsForAdministration, itemsAndSpecs,
|
||||
} = results;
|
||||
|
||||
const varMi = scopeOfWork || dolu(disqualificationRisks) || dolu(criticalDetails) ||
|
||||
dolu(documentChecklist) || dolu(deadlines) || dolu(costDrivers) ||
|
||||
dolu(roadmap) || dolu(questionsForAdministration) || dolu(itemsAndSpecs);
|
||||
if (!varMi) return null;
|
||||
|
||||
const degistir = (k) => setIsaretli((o) => ({ ...o, [k]: !o[k] }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* İşin kapsamı */}
|
||||
{scopeOfWork && (
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-2">
|
||||
<Baslik icon={Target} renk="text-indigo-600">İşin Kapsamı</Baslik>
|
||||
<p className="text-xs text-slate-700 leading-relaxed">{scopeOfWork}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UYARI — teklifi geçersiz kılan noktalar */}
|
||||
{dolu(disqualificationRisks) && (
|
||||
<div className="bg-red-50 border-2 border-red-300 rounded-2xl p-5 space-y-3 shadow-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-red-600 text-white flex items-center justify-center shrink-0">
|
||||
<AlertOctagon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black text-sm text-red-900">Teklifinizi Geçersiz Kılabilecek Noktalar</h4>
|
||||
<p className="text-[11px] text-red-700">Bu maddelerden biri eksikse teklif değerlendirme dışı bırakılabilir.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{disqualificationRisks.map((r, i) => (
|
||||
<li key={i} className="flex items-start gap-2 bg-white border border-red-200 rounded-lg p-2.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-red-900 font-medium leading-relaxed">{r}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gözden kaçan ince detaylar */}
|
||||
{dolu(criticalDetails) && (
|
||||
<div className="bg-white p-5 rounded-2xl border border-amber-300 shadow-xs space-y-3">
|
||||
<Baslik icon={Eye} renk="text-amber-600" not={`${criticalDetails.length} madde`}>
|
||||
Gözden Kaçmaması Gereken İnce Detaylar
|
||||
</Baslik>
|
||||
<div className="space-y-2.5">
|
||||
{criticalDetails.map((d, i) => (
|
||||
<div key={i} className="bg-amber-50/60 border border-amber-200 rounded-xl p-3 space-y-1.5">
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<span className="font-bold text-xs text-slate-900">{d.title || 'Detay'}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{d.where && (
|
||||
<span className="text-[10px] font-semibold text-slate-500 bg-white border border-slate-200 px-1.5 py-0.5 rounded">
|
||||
{d.where}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[10px] font-extrabold px-2 py-0.5 rounded border ${SEVIYE_RENK[d.level] || SEVIYE_RENK.Orta}`}>
|
||||
{d.level || 'Orta'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{d.detail && <p className="text-xs text-slate-700 leading-relaxed">{d.detail}</p>}
|
||||
{d.whyItMatters && (
|
||||
<p className="text-[11px] text-amber-900 bg-amber-100/70 rounded-lg px-2.5 py-1.5 leading-relaxed">
|
||||
<strong>Neden önemli:</strong> {d.whyItMatters}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alınacak ürün/hizmet: evsaf, miktar, tahmini fiyat */}
|
||||
{dolu(itemsAndSpecs) && (
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3">
|
||||
<Baslik icon={Package} renk="text-emerald-600" not={`${itemsAndSpecs.length} kalem`}>
|
||||
Alınacak Kalemler & Aranan Evsaflar
|
||||
</Baslik>
|
||||
<div className="space-y-2.5">
|
||||
{itemsAndSpecs.map((k, i) => (
|
||||
<div key={i} className="bg-slate-50 border border-slate-200 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<span className="font-bold text-xs text-slate-900 flex-1 min-w-0">{k.name || '—'}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{k.quantity && (
|
||||
<span className="text-[11px] font-black text-slate-900 bg-white border border-slate-300 px-2 py-0.5 rounded">
|
||||
{k.quantity}
|
||||
</span>
|
||||
)}
|
||||
{tl(k.estimatedUnitPrice) && (
|
||||
<span className="text-[11px] font-bold text-emerald-800 bg-emerald-100 border border-emerald-200 px-2 py-0.5 rounded"
|
||||
title="Şartnamede geçen tahmini birim fiyat">
|
||||
birim {tl(k.estimatedUnitPrice)}
|
||||
</span>
|
||||
)}
|
||||
{tl(k.estimatedTotal) && (
|
||||
<span className="text-[11px] font-black text-emerald-900 bg-emerald-200 px-2 py-0.5 rounded"
|
||||
title="Şartnamede geçen tahmini toplam">
|
||||
toplam {tl(k.estimatedTotal)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{dolu(k.keySpecs) && (
|
||||
<ul className="space-y-1">
|
||||
{k.keySpecs.map((e, j) => (
|
||||
<li key={j} className="flex items-start gap-2 text-[11px] text-slate-700 leading-relaxed">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-600 mt-1.5 shrink-0" />
|
||||
<span>{e}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{k.note && <p className="text-[11px] text-slate-500 leading-snug italic">{k.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-relaxed">
|
||||
Fiyatlar yalnızca şartnamede/ilanda geçiyorsa gösterilir; model tahmin üretmez.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Belge kontrol listesi */}
|
||||
{dolu(documentChecklist) && (
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3">
|
||||
<Baslik icon={ClipboardList} renk="text-blue-600" not="işaretleyerek ilerleyin">
|
||||
Belge Kontrol Listesi
|
||||
</Baslik>
|
||||
<ul className="space-y-1.5">
|
||||
{documentChecklist.map((b, i) => {
|
||||
const k = `b${i}`;
|
||||
const secili = !!isaretli[k];
|
||||
return (
|
||||
<li key={i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => degistir(k)}
|
||||
className="w-full text-left flex items-start gap-2 bg-slate-50 hover:bg-slate-100 border border-slate-200 rounded-lg p-2.5 transition-colors"
|
||||
>
|
||||
{secili
|
||||
? <CheckSquare className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||
: <Square className="w-4 h-4 text-slate-300 shrink-0 mt-0.5" />}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className={`text-xs font-semibold block ${secili ? 'line-through text-slate-400' : 'text-slate-800'}`}>
|
||||
{b.document || '—'}
|
||||
</span>
|
||||
{b.note && <span className="text-[11px] text-slate-500 leading-snug block mt-0.5">{b.note}</span>}
|
||||
</span>
|
||||
{b.mandatory === false
|
||||
? <span className="text-[10px] font-bold text-slate-500 bg-slate-200 px-1.5 py-0.5 rounded shrink-0">İsteğe bağlı</span>
|
||||
: <span className="text-[10px] font-bold text-red-700 bg-red-100 px-1.5 py-0.5 rounded shrink-0">Zorunlu</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Süreler + maliyet sürücüleri */}
|
||||
<div className="space-y-6">
|
||||
{dolu(deadlines) && (
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3">
|
||||
<Baslik icon={CalendarClock} renk="text-rose-600">Kritik Tarihler & Süreler</Baslik>
|
||||
<div className="space-y-2">
|
||||
{deadlines.map((d, i) => (
|
||||
<div key={i} className="bg-slate-50 border border-slate-200 rounded-lg p-2.5">
|
||||
<div className="flex items-baseline justify-between gap-2 flex-wrap">
|
||||
<span className="text-xs font-semibold text-slate-800">{d.label || '—'}</span>
|
||||
<span className="text-xs font-black text-rose-700">{d.value || '—'}</span>
|
||||
</div>
|
||||
{d.note && <p className="text-[11px] text-slate-500 leading-snug mt-0.5">{d.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dolu(costDrivers) && (
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3">
|
||||
<Baslik icon={Coins} renk="text-emerald-600">Maliyeti Belirleyen Kalemler</Baslik>
|
||||
<ul className="space-y-1.5 text-xs text-slate-700">
|
||||
{costDrivers.map((c, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-600 mt-1.5 shrink-0" />
|
||||
<span className="leading-relaxed">{c}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yol haritası */}
|
||||
{dolu(roadmap) && (
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-3">
|
||||
<Baslik icon={Route} renk="text-purple-600" not={`${roadmap.length} adım`}>
|
||||
Teklife Kadar Yol Haritası
|
||||
</Baslik>
|
||||
<ol className="space-y-2">
|
||||
{roadmap.map((a, i) => {
|
||||
const k = `y${i}`;
|
||||
const secili = !!isaretli[k];
|
||||
return (
|
||||
<li key={i} className="flex gap-3">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => degistir(k)}
|
||||
aria-label={`${i + 1}. adımı tamamlandı işaretle`}
|
||||
className={`w-7 h-7 rounded-full text-white text-[11px] font-black flex items-center justify-center transition-colors ${
|
||||
secili ? 'bg-emerald-600' : (FAZ_RENK[a.phase] || 'bg-slate-700')
|
||||
}`}
|
||||
>
|
||||
{secili ? '✓' : (a.step ?? i + 1)}
|
||||
</button>
|
||||
{i < roadmap.length - 1 && <div className="w-px flex-1 bg-slate-200 my-1" />}
|
||||
</div>
|
||||
<div className={`flex-1 min-w-0 bg-slate-50 border border-slate-200 rounded-xl p-3 space-y-1 mb-1 ${secili ? 'opacity-60' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<span className={`text-xs font-bold ${secili ? 'line-through text-slate-400' : 'text-slate-900'}`}>
|
||||
{a.action || '—'}
|
||||
</span>
|
||||
{a.phase && (
|
||||
<span className="text-[10px] font-extrabold text-white px-2 py-0.5 rounded shrink-0 bg-slate-700">
|
||||
{a.phase}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-[11px] text-slate-500">
|
||||
{a.owner && <span><strong className="text-slate-600">Kim:</strong> {a.owner}</span>}
|
||||
{a.timing && <span><strong className="text-slate-600">Ne zaman:</strong> {a.timing}</span>}
|
||||
{a.output && <span><strong className="text-slate-600">Çıktı:</strong> {a.output}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* İdareye sorulacaklar */}
|
||||
{dolu(questionsForAdministration) && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-5 space-y-3">
|
||||
<Baslik icon={HelpCircle} renk="text-blue-700">İdareye Sorulması Gereken Belirsizlikler</Baslik>
|
||||
<ul className="space-y-1.5">
|
||||
{questionsForAdministration.map((q, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-xs text-blue-900 leading-relaxed">
|
||||
<span className="font-black text-blue-500 shrink-0">{i + 1}.</span>
|
||||
<span>{q}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[10px] text-blue-700 leading-relaxed">
|
||||
Bu sorular için şartnamede belirtilen süre içinde idareye açıklama talebi / zeyilname
|
||||
başvurusu yapabilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analiz ve yol haritası okunduktan sonraki adım: teklif zarfını hazırla */}
|
||||
<BidPackageBuilder results={results} tender={tender} />
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { getToken } from '../services/auth';
|
||||
import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download, Upload, Trash2, Paperclip } from 'lucide-react';
|
||||
import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download, Upload, Trash2, Paperclip, Route } from 'lucide-react';
|
||||
import { uploadDocument, listTenderDocuments, deleteDocument } from '../services/account';
|
||||
import { extractText } from '../services/pdfText';
|
||||
|
||||
@@ -15,6 +15,7 @@ export const TenderDocuments = ({ tender }) => {
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [bilgi, setBilgi] = useState(null);
|
||||
const [ilerleme, setIlerleme] = useState('');
|
||||
|
||||
const ikn = tender?.ikn;
|
||||
const [belgeler, setBelgeler] = useState([]);
|
||||
@@ -47,19 +48,41 @@ export const TenderDocuments = ({ tender }) => {
|
||||
try { await deleteDocument(id); belgeleriYukle(); } catch (err) { setError(err.message); }
|
||||
};
|
||||
|
||||
// Yüklenen PDF'i AI'ya ver
|
||||
const belgeyiAnalizEt = async (b) => {
|
||||
setBusy('belge'); setError(null);
|
||||
try {
|
||||
// PDF/metin tarayıcıda (pdfjs) okunur; docx/xlsx/zip için tarayıcıda çözücü
|
||||
// yok, sunucudaki çıkarıcıya sorulur. .doc ve .rar'ın karşılığı yok.
|
||||
const tarayicidaOkunur = (ad) => /\.(pdf|txt|md)$/i.test(String(ad || ''));
|
||||
const sunucudaOkunur = (ad) => /\.(docx|xlsx|zip)$/i.test(String(ad || ''));
|
||||
const okunabilirMi = (ad) => tarayicidaOkunur(ad) || sunucudaOkunur(ad);
|
||||
|
||||
const belgeMetni = async (b) => {
|
||||
if (sunucudaOkunur(b.ad)) {
|
||||
const r = await fetch(`/api/me/documents/text?id=${encodeURIComponent(b.id)}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!d.success) throw new Error(d.error || 'metin çıkarılamadı');
|
||||
return d.metin;
|
||||
}
|
||||
const r = await fetch(`/api/me/documents/download?id=${encodeURIComponent(b.id)}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (!r.ok) throw new Error('Belge indirilemedi');
|
||||
if (!r.ok) throw new Error('belge indirilemedi');
|
||||
const blob = await r.blob();
|
||||
const metin = await extractText(new File([blob], b.ad, { type: b.mime || 'application/pdf' }));
|
||||
// extractText { text, pageCount, usedPages } döndürür — nesneyi metin sanıp
|
||||
// göndermek analizi sessizce bozuyordu.
|
||||
const { text } = await extractText(new File([blob], b.ad, { type: b.mime || 'application/pdf' }));
|
||||
return text;
|
||||
};
|
||||
|
||||
// Yüklenen PDF'i AI'ya ver
|
||||
const belgeyiAnalizEt = async (b, mod) => {
|
||||
setBusy('belge'); setError(null);
|
||||
try {
|
||||
if (!okunabilirMi(b.ad)) throw new Error('Bu dosya türünden metin çıkarılamıyor (PDF, DOCX, XLSX, ZIP ve TXT destekleniyor).');
|
||||
const metin = await belgeMetni(b);
|
||||
if (!metin || metin.length < 100) throw new Error('Belgeden analiz için yeterli metin çıkarılamadı (taranmış PDF olabilir).');
|
||||
setIsAiSpecAnalyzerOpen(true);
|
||||
await analyzeSpecPdfWithGemini({ text: metin, fileName: b.ad, tender });
|
||||
await analyzeSpecPdfWithGemini({ text: metin, fileName: b.ad, tender, mode: mod });
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally { setBusy(null); }
|
||||
@@ -79,14 +102,19 @@ export const TenderDocuments = ({ tender }) => {
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const ilaniAnalizEt = async () => {
|
||||
setBusy('analiz'); setError(null);
|
||||
try {
|
||||
const ilanMetniGetir = async () => {
|
||||
const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
return d;
|
||||
};
|
||||
|
||||
const ilaniAnalizEt = async () => {
|
||||
setBusy('analiz'); setError(null);
|
||||
try {
|
||||
const d = await ilanMetniGetir();
|
||||
if (!d.metin || d.metin.length < 100) throw new Error('İlan metni analiz için yeterli değil.');
|
||||
setBilgi({ karakter: d.metin.length, ilanSayisi: d.ilanSayisi, okasSayisi: d.okasSayisi });
|
||||
setIsAiSpecAnalyzerOpen(true);
|
||||
@@ -96,6 +124,63 @@ export const TenderDocuments = ({ tender }) => {
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
/**
|
||||
* Derin analiz — teknik şartname ve diğer evrakın tamamını TEK metinde
|
||||
* birleştirip gönderir: EKAP ilan metni + bu ihaleye yüklenmiş her belge.
|
||||
* Böylece model şartnameyi ilanla ve eklerle çapraz okuyabiliyor.
|
||||
*/
|
||||
const tumEvrakiTopla = async () => {
|
||||
const parcalar = [];
|
||||
const atlanan = [];
|
||||
|
||||
if (ekapIhalesi) {
|
||||
setIlerleme('EKAP ilan metni alınıyor…');
|
||||
try {
|
||||
const d = await ilanMetniGetir();
|
||||
if (d.metin && d.metin.length >= 100) {
|
||||
parcalar.push(`===== EKAP İLAN METNİ (${d.ikn}) =====\n${d.metin}`);
|
||||
}
|
||||
} catch {
|
||||
atlanan.push('EKAP ilan metni (alınamadı)');
|
||||
}
|
||||
}
|
||||
|
||||
for (const b of belgeler) {
|
||||
if (!okunabilirMi(b.ad)) { atlanan.push(`${b.ad} (metin çıkarılamıyor)`); continue; }
|
||||
setIlerleme(`${b.ad} okunuyor…`);
|
||||
try {
|
||||
const t = await belgeMetni(b);
|
||||
if (t && t.length >= 200) parcalar.push(`===== BELGE: ${b.ad} =====\n${t}`);
|
||||
else atlanan.push(`${b.ad} (yeterli metin yok — taranmış olabilir)`);
|
||||
} catch (e) {
|
||||
atlanan.push(`${b.ad} (${e.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
return { metin: parcalar.join('\n\n'), parcaSayisi: parcalar.length, atlanan };
|
||||
};
|
||||
|
||||
const derinAnalizEt = async () => {
|
||||
setBusy('derin'); setError(null); setBilgi(null);
|
||||
try {
|
||||
const { metin, parcaSayisi, atlanan } = await tumEvrakiTopla();
|
||||
if (!metin || metin.length < 200) {
|
||||
throw new Error('Derin analiz için yeterli metin toplanamadı. EKAP dokümanını indirip PDF olarak yükleyin.');
|
||||
}
|
||||
setBilgi({ derin: { parcaSayisi, karakter: metin.length, atlanan } });
|
||||
setIlerleme('');
|
||||
setIsAiSpecAnalyzerOpen(true);
|
||||
await analyzeSpecPdfWithGemini({
|
||||
text: metin,
|
||||
fileName: `${ikn || 'ihale'} — ${parcaSayisi} evrak birlikte`,
|
||||
tender,
|
||||
mode: 'derin',
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally { setBusy(null); setIlerleme(''); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-3">
|
||||
<h4 className="font-bold text-sm text-slate-900 flex items-center gap-2">
|
||||
@@ -140,7 +225,7 @@ export const TenderDocuments = ({ tender }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bilgi && (
|
||||
{bilgi && !bilgi.derin && (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{bilgi.dokumanSayisi != null && <>EKAP'ta {bilgi.dokumanSayisi} doküman kayıtlı. </>}
|
||||
{bilgi.karakter != null && <>{bilgi.ilanSayisi} ilan · {bilgi.okasSayisi} kalem · {bilgi.karakter.toLocaleString('tr-TR')} karakter metin analize gönderildi. </>}
|
||||
@@ -173,7 +258,7 @@ export const TenderDocuments = ({ tender }) => {
|
||||
ref={dosyaRef}
|
||||
type="file"
|
||||
onChange={dosyaSec}
|
||||
accept=".pdf,.doc,.docx,.zip,.rar,.txt"
|
||||
accept=".pdf,.docx,.xlsx,.zip,.txt"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
@@ -184,7 +269,7 @@ export const TenderDocuments = ({ tender }) => {
|
||||
{yukleniyor ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
{yukleniyor ? 'Yükleniyor…' : 'İndirdiğim dosyayı yükle'}
|
||||
</button>
|
||||
<span className="text-[10px] text-slate-400">PDF, Word, ZIP · en fazla 10 MB</span>
|
||||
<span className="text-[10px] text-slate-400">PDF, DOCX, XLSX, ZIP · en fazla 10 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -207,6 +292,14 @@ export const TenderDocuments = ({ tender }) => {
|
||||
>
|
||||
{busy === 'belge' ? '…' : 'AI analiz'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => belgeyiAnalizEt(b, 'derin')}
|
||||
disabled={busy !== null}
|
||||
className="text-[10px] font-bold text-indigo-700 hover:text-indigo-800 disabled:opacity-40 whitespace-nowrap"
|
||||
title="Yalnızca bu belge için ince detaylar + yol haritası"
|
||||
>
|
||||
Derin
|
||||
</button>
|
||||
<button onClick={() => belgeSil(b.id)} className="text-slate-300 hover:text-red-600" aria-label="Sil">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -215,6 +308,54 @@ export const TenderDocuments = ({ tender }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(ekapIhalesi || belgeler.length > 0) && (
|
||||
<div className="bg-linear-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-xl p-4 space-y-2.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-9 h-9 rounded-xl bg-amber-400 text-slate-950 flex items-center justify-center shrink-0">
|
||||
<Route className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h5 className="font-black text-sm text-white">Derin Analiz & Yol Haritası</h5>
|
||||
<p className="text-[11px] text-slate-300 leading-relaxed">
|
||||
EKAP ilan metni ve bu ihaleye yüklediğiniz tüm evrak <strong className="text-white">birlikte</strong> okunur;
|
||||
elenme sebepleri, gözden kaçan ince detaylar, belge kontrol listesi, kritik tarihler ve
|
||||
teklife kadar adım adım yol haritası çıkarılır.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={derinAnalizEt}
|
||||
disabled={busy !== null}
|
||||
className="w-full bg-amber-400 hover:bg-amber-300 disabled:opacity-40 text-slate-950 font-black text-xs px-4 py-2.5 rounded-xl flex items-center justify-center gap-2"
|
||||
>
|
||||
{busy === 'derin'
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Sparkles className="w-3.5 h-3.5" />}
|
||||
{busy === 'derin' ? (ilerleme || 'Analiz ediliyor…') : 'Tüm Evrakı Derin Analiz Et'}
|
||||
</button>
|
||||
|
||||
{bilgi?.derin ? (
|
||||
<div className="bg-black/30 rounded-lg p-2.5 space-y-1">
|
||||
<p className="text-[10px] text-slate-300">
|
||||
{bilgi.derin.parcaSayisi} kaynak · {bilgi.derin.karakter.toLocaleString('tr-TR')} karakter analize gönderildi.
|
||||
</p>
|
||||
{bilgi.derin.atlanan.length > 0 && (
|
||||
<p className="text-[10px] text-amber-300 leading-relaxed">
|
||||
Atlanan: {bilgi.derin.atlanan.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-slate-400 text-center">
|
||||
{belgeler.length > 0
|
||||
? `${ekapIhalesi ? 'İlan metni + ' : ''}${belgeler.length} yüklü belge okunacak`
|
||||
: 'Henüz belge yüklemediniz — yalnızca ilan metni okunacak'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -196,6 +196,9 @@ export const AppProvider = ({ children }) => {
|
||||
const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false);
|
||||
|
||||
const [aiAnalysisResults, setAiAnalysisResults] = useState(null);
|
||||
// Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da
|
||||
// tetiklenebiliyor; modalın beklerken boş görünmemesi için ortak sinyal.
|
||||
const [aiAnalyzing, setAiAnalyzing] = useState(false);
|
||||
|
||||
// Enterprise Module States
|
||||
// Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (localStorage)
|
||||
@@ -335,11 +338,16 @@ export const AppProvider = ({ children }) => {
|
||||
|
||||
// Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde
|
||||
// seçilen model zinciri (fallback) ile.
|
||||
const analyzeSpecPdfWithGemini = async ({ text, fileName, tender } = {}) => {
|
||||
// mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir
|
||||
const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode } = {}) => {
|
||||
setAiAnalyzing(true);
|
||||
setAiAnalysisResults(null);
|
||||
try {
|
||||
const { analyzeSpecText } = await import('../services/ai');
|
||||
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain });
|
||||
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode });
|
||||
const result = {
|
||||
...data.result,
|
||||
mode: data.mode || (mode === 'derin' ? 'derin' : 'standart'),
|
||||
fileName,
|
||||
model: `${data.model.label} (${data.model.provider})`,
|
||||
modelId: data.model.model,
|
||||
@@ -349,6 +357,9 @@ export const AppProvider = ({ children }) => {
|
||||
};
|
||||
setAiAnalysisResults(result);
|
||||
return result;
|
||||
} finally {
|
||||
setAiAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [userProfile, setUserProfile] = useState({
|
||||
@@ -537,6 +548,7 @@ export const AppProvider = ({ children }) => {
|
||||
setIsAiSpecAnalyzerOpen,
|
||||
analyzeSpecPdfWithGemini,
|
||||
aiAnalysisResults,
|
||||
aiAnalyzing,
|
||||
competitorWatchlist,
|
||||
setCompetitorWatchlist,
|
||||
vaultDocuments,
|
||||
|
||||
+2
-2
@@ -13,11 +13,11 @@ export async function getAiConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeSpecText({ text, fileName, tender, chain }) {
|
||||
export async function analyzeSpecText({ text, fileName, tender, chain, mode }) {
|
||||
const res = await fetch('/api/ai/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ text, fileName, tender, chain }),
|
||||
body: JSON.stringify({ text, fileName, tender, chain, mode }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data.success) throw new Error(data.error || `Analiz başarısız (HTTP ${res.status})`);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* İhale dosyası belgeleri — teklif mektubu, birim fiyat teklif cetveli ve
|
||||
* evrak kontrol listesi.
|
||||
*
|
||||
* NEDEN HTML/YAZDIR: jsPDF'in gömülü fontları WinAnsi; ş/ğ/ı/İ taşımıyor
|
||||
* (bkz. services/pdf.js, başlıkları ASCII'ye düşürüyor). Resmî teklif
|
||||
* mektubunda bozuk Türkçe kabul edilemez, o yüzden belgeler tam Türkçe HTML
|
||||
* olarak üretilip tarayıcının yazdır → "PDF olarak kaydet" akışına veriliyor.
|
||||
*/
|
||||
|
||||
const BIRLER = ['', 'bir', 'iki', 'üç', 'dört', 'beş', 'altı', 'yedi', 'sekiz', 'dokuz'];
|
||||
const ONLAR = ['', 'on', 'yirmi', 'otuz', 'kırk', 'elli', 'altmış', 'yetmiş', 'seksen', 'doksan'];
|
||||
const BASAMAK = ['', 'bin', 'milyon', 'milyar', 'trilyon'];
|
||||
|
||||
function ucBasamak(n) {
|
||||
const y = Math.floor(n / 100), o = Math.floor((n % 100) / 10), b = n % 10;
|
||||
let s = '';
|
||||
if (y) s += y === 1 ? 'yüz' : BIRLER[y] + 'yüz';
|
||||
if (o) s += ONLAR[o];
|
||||
if (b) s += BIRLER[b];
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 1850000.5 → "birmilyonsekizyüzellibin Türk Lirası elli Kuruş" */
|
||||
export function yaziylaTL(n) {
|
||||
const sayi = Number(n);
|
||||
if (!isFinite(sayi)) return '';
|
||||
const negatif = sayi < 0;
|
||||
const mutlak = Math.abs(sayi);
|
||||
const tam = Math.floor(mutlak);
|
||||
const kurus = Math.round((mutlak - tam) * 100);
|
||||
|
||||
const gruplar = [];
|
||||
let kalan = tam;
|
||||
while (kalan > 0) { gruplar.push(kalan % 1000); kalan = Math.floor(kalan / 1000); }
|
||||
|
||||
let lira = '';
|
||||
for (let i = gruplar.length - 1; i >= 0; i--) {
|
||||
const g = gruplar[i];
|
||||
if (!g) continue;
|
||||
// "birbin" değil "bin"
|
||||
lira += (i === 1 && g === 1) ? 'bin' : ucBasamak(g) + BASAMAK[i];
|
||||
}
|
||||
if (!lira) lira = 'sıfır';
|
||||
|
||||
let s = `${lira} Türk Lirası`;
|
||||
if (kurus > 0) s += ` ${ucBasamak(kurus)} Kuruş`;
|
||||
return (negatif ? 'eksi ' : '') + s;
|
||||
}
|
||||
|
||||
export const paraTR = (n) =>
|
||||
(Number(n) || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
/** "3 adet" → { miktar: 3, birim: "adet" } ; okunamazsa miktar null */
|
||||
export function miktarAyristir(q) {
|
||||
const m = /(-?[\d.,]+)\s*(.*)$/.exec(String(q || '').trim());
|
||||
if (!m) return { miktar: null, birim: String(q || '').trim() };
|
||||
const sayi = parseFloat(m[1].replace(/\./g, '').replace(',', '.'));
|
||||
return { miktar: isFinite(sayi) ? sayi : null, birim: (m[2] || '').trim() };
|
||||
}
|
||||
|
||||
const kacir = (s) =>
|
||||
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
const STIL = `
|
||||
@page { size: A4; margin: 20mm 18mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: "Times New Roman", Georgia, serif; font-size: 11.5pt; line-height: 1.55; color: #000; }
|
||||
h1 { font-size: 13pt; text-align: center; margin: 0 0 4mm; letter-spacing: .3px; }
|
||||
h2 { font-size: 11pt; margin: 6mm 0 2mm; }
|
||||
.ust { font-size: 10pt; margin-bottom: 6mm; }
|
||||
.ust div { margin: .6mm 0; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 3mm 0; font-size: 9.5pt; }
|
||||
th, td { border: 1px solid #444; padding: 1.6mm 2mm; text-align: left; vertical-align: top; }
|
||||
th { background: #eee; font-weight: bold; }
|
||||
td.s, th.s { text-align: right; white-space: nowrap; }
|
||||
.toplam td { font-weight: bold; background: #f4f4f4; }
|
||||
.imza { margin-top: 12mm; width: 70mm; margin-left: auto; text-align: center; font-size: 10pt; }
|
||||
.imza .kutu { border: 1px solid #666; height: 26mm; margin-bottom: 2mm; }
|
||||
.not { font-size: 8.5pt; color: #555; margin-top: 8mm; }
|
||||
ul { margin: 2mm 0; padding-left: 6mm; }
|
||||
li { margin: 1.2mm 0; }
|
||||
.kutucuk { display: inline-block; width: 3.6mm; height: 3.6mm; border: 1px solid #333; margin-right: 2mm; vertical-align: -0.4mm; }
|
||||
@media print { .yazdirma { display: none; } }
|
||||
`;
|
||||
|
||||
/** Belgeyi kendi penceresinde açıp yazdırma diyaloğunu tetikler. */
|
||||
export function belgeYazdir(baslik, govde) {
|
||||
const w = window.open('', '_blank', 'width=900,height=1000');
|
||||
if (!w) throw new Error('Tarayıcı yeni pencereyi engelledi. Açılır pencere iznini verip tekrar deneyin.');
|
||||
w.document.write(`<!doctype html><html lang="tr"><head><meta charset="utf-8">
|
||||
<title>${kacir(baslik)}</title><style>${STIL}</style></head><body>${govde}
|
||||
<div class="yazdirma" style="text-align:center;margin-top:10mm">
|
||||
<button onclick="window.print()" style="font:600 14px system-ui;padding:10px 22px;cursor:pointer">
|
||||
Yazdır / PDF olarak kaydet
|
||||
</button>
|
||||
</div></body></html>`);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
return w;
|
||||
}
|
||||
|
||||
function ustBilgi(f) {
|
||||
return `<div class="ust">
|
||||
<div><strong>İdarenin adı:</strong> ${kacir(f.idare)}</div>
|
||||
<div><strong>İşin adı:</strong> ${kacir(f.isinAdi)}</div>
|
||||
<div><strong>İhale kayıt numarası:</strong> ${kacir(f.ikn)}</div>
|
||||
<div><strong>İhale tarihi:</strong> ${kacir(f.ihaleTarihi || '—')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** KİK birim fiyat / götürü bedel teklif mektubu metni. */
|
||||
export function teklifMektubuHtml(f, toplam) {
|
||||
const goturu = f.teklifTuru === 'goturu';
|
||||
const tur = goturu ? 'GÖTÜRÜ BEDEL TEKLİF MEKTUBU' : 'BİRİM FİYAT TEKLİF MEKTUBU';
|
||||
const bedelCumle = goturu
|
||||
? 'işin tamamını teklif ettiğimiz toplam bedel karşılığında yapmayı'
|
||||
: 'işi, teklif birim fiyat cetvelinde belirttiğimiz birim fiyatlar üzerinden yapmayı';
|
||||
|
||||
return `<h1>${tur}</h1>
|
||||
${ustBilgi(f)}
|
||||
<p>Yukarıda ihale kayıt numarası ve adı yer alan ihaleye ilişkin ihale dokümanını
|
||||
oluşturan bütün belgeler tarafımızdan incelenmiş, okunmuş ve herhangi bir ayrım ve
|
||||
sınırlama yapılmadan bütün koşullarıyla kabul edilmiştir.</p>
|
||||
|
||||
<p>İhale dokümanında yer alan bütün şartları kabul ederek, söz konusu ${bedelCumle}
|
||||
kabul ve taahhüt ediyoruz.</p>
|
||||
|
||||
<p>Teklifimizin, ihale tarihinden itibaren <strong>${kacir(f.gecerlilikGun || '60')}</strong>
|
||||
takvim günü süreyle geçerli olduğunu beyan ediyoruz.</p>
|
||||
|
||||
${f.kisim ? `<p>Teklifimiz <strong>${kacir(f.kisim)}</strong> kısmına/kısımlarına ilişkindir.</p>` : ''}
|
||||
|
||||
<p>Teklif ettiğimiz toplam bedel:</p>
|
||||
<table>
|
||||
<tr><th style="width:32%">Rakam ile</th><td class="s"><strong>${paraTR(toplam)} TL</strong></td></tr>
|
||||
<tr><th>Yazı ile</th><td>${kacir(yaziylaTL(toplam))}</td></tr>
|
||||
</table>
|
||||
<p style="font-size:9.5pt;color:#444">(Teklif edilen bedele KDV dâhil değildir.)</p>
|
||||
|
||||
<h2>İstekliye ait bilgiler</h2>
|
||||
<table>
|
||||
<tr><th style="width:32%">Adı / Ticaret unvanı</th><td>${kacir(f.firmaUnvan)}</td></tr>
|
||||
<tr><th>Tebligata esas adres</th><td>${kacir(f.adres)}</td></tr>
|
||||
<tr><th>Vergi dairesi / numarası</th><td>${kacir(f.vergiDairesi)} / ${kacir(f.vergiNo)}</td></tr>
|
||||
<tr><th>Ticaret sicil / MERSİS no</th><td>${kacir(f.sicilNo)}</td></tr>
|
||||
<tr><th>Telefon / E-posta</th><td>${kacir(f.telefon)} · ${kacir(f.eposta)}</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="imza">
|
||||
<div class="kutu"></div>
|
||||
<div><strong>${kacir(f.yetkiliAd)}</strong></div>
|
||||
<div>${kacir(f.yetkiliGorev)}</div>
|
||||
<div style="margin-top:2mm">Kaşe ve İmza</div>
|
||||
</div>
|
||||
<p class="not">Bu taslak Ihaleist tarafından hazırlanmıştır. İmzalamadan önce idarenin
|
||||
ihale dokümanındaki standart form metniyle karşılaştırınız.</p>`;
|
||||
}
|
||||
|
||||
/** Birim fiyat teklif cetveli. */
|
||||
export function teklifCetveliHtml(f, satirlar, toplam) {
|
||||
const govde = satirlar.map((r, i) => {
|
||||
const { miktar, birim } = miktarAyristir(r.quantity);
|
||||
const bf = Number(r.birimFiyat) || 0;
|
||||
const tutar = (miktar ?? 0) * bf;
|
||||
return `<tr>
|
||||
<td class="s">${i + 1}</td>
|
||||
<td>${kacir(r.name)}</td>
|
||||
<td class="s">${miktar ?? kacir(r.quantity || '—')}</td>
|
||||
<td>${kacir(birim)}</td>
|
||||
<td class="s">${bf ? paraTR(bf) : '—'}</td>
|
||||
<td class="s">${tutar ? paraTR(tutar) : '—'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<h1>BİRİM FİYAT TEKLİF CETVELİ</h1>
|
||||
${ustBilgi(f)}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th class="s" style="width:8%">Sıra</th><th>Mal / hizmet adı</th>
|
||||
<th class="s" style="width:12%">Miktar</th><th style="width:12%">Birim</th>
|
||||
<th class="s" style="width:16%">Birim fiyat (TL)</th><th class="s" style="width:18%">Tutar (TL)</th>
|
||||
</tr></thead>
|
||||
<tbody>${govde}</tbody>
|
||||
<tfoot><tr class="toplam">
|
||||
<td colspan="5" style="text-align:right">Toplam tutar (KDV hariç)</td>
|
||||
<td class="s">${paraTR(toplam)}</td>
|
||||
</tr></tfoot>
|
||||
</table>
|
||||
<p>Yazı ile: ${kacir(yaziylaTL(toplam))}</p>
|
||||
<div class="imza">
|
||||
<div class="kutu"></div>
|
||||
<div><strong>${kacir(f.firmaUnvan)}</strong></div>
|
||||
<div>${kacir(f.yetkiliAd)} · ${kacir(f.yetkiliGorev)}</div>
|
||||
<div style="margin-top:2mm">Kaşe ve İmza</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Teklif zarfına girecek evrakın kontrol listesi / dosya kapağı. */
|
||||
export function evrakListesiHtml(f, belgeler) {
|
||||
const satirlar = belgeler.map((b) => `<li>
|
||||
<span class="kutucuk"></span><strong>${kacir(b.document)}</strong>
|
||||
${b.mandatory === false ? ' <em>(isteğe bağlı)</em>' : ''}
|
||||
${b.note ? `<div style="font-size:9pt;color:#444;margin-left:5.6mm">${kacir(b.note)}</div>` : ''}
|
||||
</li>`).join('');
|
||||
|
||||
return `<h1>TEKLİF DOSYASI EVRAK KONTROL LİSTESİ</h1>
|
||||
${ustBilgi(f)}
|
||||
<div class="ust"><div><strong>İstekli:</strong> ${kacir(f.firmaUnvan)}</div></div>
|
||||
<h2>Zarfa konulacak belgeler</h2>
|
||||
<ul>${satirlar || '<li>Analizden belge listesi çıkarılamadı.</li>'}</ul>
|
||||
<div class="imza">
|
||||
<div class="kutu"></div>
|
||||
<div>Hazırlayan: <strong>${kacir(f.yetkiliAd)}</strong></div>
|
||||
<div style="margin-top:2mm">Tarih / İmza</div>
|
||||
</div>
|
||||
<p class="not">Liste, yüklediğiniz ihale dokümanının yapay zekâ analizinden üretilmiştir;
|
||||
zarfı kapatmadan önce idari şartnamenin yeterlik maddeleriyle karşılaştırınız.</p>`;
|
||||
}
|
||||
Reference in New Issue
Block a user