Review company scan suggestions before saving profile
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"date": "2026-09-06",
|
||||
"environment": "localhost:5182 synthetic local user; external AI disabled",
|
||||
"checks": {
|
||||
"manualWithoutScan": true,
|
||||
"discardUnsaved": true,
|
||||
"saveAcknowledged": true,
|
||||
"reloadPreserved": true,
|
||||
"scanFailureVisible": true,
|
||||
"failurePreservesProduct": true
|
||||
},
|
||||
"limits": [
|
||||
"Manual product add, discard, save, reload and failed scan tested",
|
||||
"Successful scan proposal mapping tested with stub; no real website AI scan",
|
||||
"Structured brand/model/UTS/service-capacity portfolio remains open",
|
||||
"Concurrent profile edits from multiple devices not tested"
|
||||
]
|
||||
}
|
||||
@@ -889,3 +889,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
|
||||
- Kullanım kılavuzuna grup açıklaması eklendi. Kanban yardımı yeni durum seçimi, kaydetme, hazırlık özeti ve desteklenmeyen Slack/Teams gönderimine göre güncellendi.
|
||||
- Yerel normal hesapta dört grup, hazırlık panosunun açılması, normal kullanıcıda AI ayarının gizli olması ve yan panel kısayoluyla grubun değişmesi dahil 6 kontrol geçti (qa-navigation-groups.json). Derleme geçti. Önceki 200 otomatik test tekrar çalıştırılmadı; sunucu/hizmet hesabı değişmedi.
|
||||
- Yeni menünün tam mobil/yalnız-klavye kabulü ve yardım metinlerinin kalan ekranlardaki iddiaları ayrıca incelenmeli. Bu değişiklik gizlenen yönetici araçlarının sunucu yetkilendirmesi yerine geçmez.
|
||||
|
||||
### D07 — Firma taramasını düzenlenebilir öneriye dönüştürme — 6 Eylül 2026
|
||||
- İncelemede gerçek tarama hatasının alan adından ürün tahminiyle gizlendiği, zamanlayıcının sahte yüzde/başarı ürettiği ve profilin inceleme öncesinde değiştirildiği görüldü. Bu fallback ve otomatik uygulama kaldırıldı.
|
||||
- Site taraması ortak zaman aşımı/oturum kontrollü istekle yalnız öneri döndürür; bozuk listeler hata olur. Düzenleme ekranında sektör, ürün/hizmet, sertifika beyanı ve ihale konuları elle eklenebilir/kaldırılabilir. Tarama olmadan boş profilden başlanabilir.
|
||||
- Profil ve uyum eşiği yalnız Profili kaydet ile sunucu yanıtı beklendikten sonra uygulama durumuna aktarılır. Eski arka plan profil autosave kaldırıldı. Tarama/yerel düzenleme/kapama kendiliğinden profil yazmaz. Hata mevcut ürünleri korur; modal kapanınca tarama istemcisi iptal edilir, hesap değişiminde yerel taslak ayrılır.
|
||||
- Sahte ilerleme yüzdeleri, konfeti, doğrulanmış sektör/ÜTS iddiaları kaldırıldı. Modal başlığı/alanlar/kapama etiketlendi; odak geri dönüşü, Escape ve Tab sınırı eklendi.
|
||||
- 202 otomatik test ve derleme geçti. Yerel normal hesapta taramasız ekleme, kaydetmeden kapatma, kayıt onayı, yenileme sonrası kalıcılık, yapılandırılmamış tarama hatası ve mevcut ürünü koruma dahil 6 tarayıcı kontrolü geçti (qa-company-profile.json). Başarılı tarama öneri dönüşümü stub testte doğrulandı; gerçek AI/site taraması yapılmadı.
|
||||
- Marka/model, yapılandırılmış ÜTS durumu ve servis kapasitesi portföyü henüz eklenmedi. Çok cihazlı eşzamanlı profil düzenleme ve başarılı gerçek tarama kabulü açık. Profil kaydı için mevcut hesap veri deposu kullanılır; bu tur yeni sunucu şeması yoktur.
|
||||
|
||||
@@ -1,313 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { Globe, Bot, Sparkles, CheckCircle2, ShieldCheck, Tag, X, Loader2, Database, ArrowRight } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export const CompanyScannerModal = () => {
|
||||
const { isCompanyScannerOpen, setIsCompanyScannerOpen, userProfile, scanCompanyWebsite, updateCompanyCapabilities, uyumEsigi, setUyumEsigi, gommeDurum } = useApp();
|
||||
|
||||
const [urlInput, setUrlInput] = useState(userProfile.website || '');
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanStep, setScanStep] = useState(0);
|
||||
const [scanSuccess, setScanSuccess] = useState(false);
|
||||
|
||||
const [newProductInput, setNewProductInput] = useState('');
|
||||
|
||||
if (!isCompanyScannerOpen) return null;
|
||||
|
||||
const handleStartScan = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!urlInput.trim()) return;
|
||||
|
||||
setIsScanning(true);
|
||||
setScanSuccess(false);
|
||||
setScanStep(1);
|
||||
|
||||
setTimeout(() => setScanStep(2), 600);
|
||||
setTimeout(() => setScanStep(3), 1200);
|
||||
|
||||
const capabilities = await scanCompanyWebsite(urlInput);
|
||||
setIsScanning(false);
|
||||
setScanSuccess(true);
|
||||
confetti({ particleCount: 70, spread: 60 });
|
||||
import React,{useState,useEffect,useRef,useId} from 'react';
|
||||
import {useApp} from '../context/AppContext';
|
||||
import {getToken} from '../services/auth';
|
||||
import {X,Loader2} from 'lucide-react';
|
||||
const fields=[['extractedSectors','Sektörler'],['extractedProducts','Ürün ve hizmetler'],['extractedCertifications','Sertifika ve kayıt beyanları'],['ihaleKonulari','İhale konuları']];
|
||||
export function CompanyScannerModal(){
|
||||
const {isCompanyScannerOpen,authUser}=useApp();
|
||||
return isCompanyScannerOpen?<ProfileEditor key={`${authUser?.email}:${getToken()}`}/>:null;
|
||||
}
|
||||
function ProfileEditor(){
|
||||
const {setIsCompanyScannerOpen,userProfile,scanCompanyWebsite,updateCompanyCapabilities,uyumEsigi}=useApp();
|
||||
const [url,setUrl]=useState(userProfile.website || ''),[caps,setCaps]=useState(userProfile.scrapedCapabilities || {});
|
||||
const [threshold,setThreshold]=useState(uyumEsigi),[busy,setBusy]=useState(''),[error,setError]=useState(null),[message,setMessage]=useState('');
|
||||
const [entries,setEntries]=useState({});
|
||||
const alive=useRef(true),controller=useRef(null),locked=useRef(false),root=useRef(null),titleId=useId();
|
||||
useEffect(()=>{
|
||||
alive.current=true;const opener=document.activeElement;root.current?.querySelector('button')?.focus();
|
||||
return()=>{alive.current=false;controller.current?.abort();if(opener?.isConnected)opener.focus();};
|
||||
},[]);
|
||||
const run=async(kind,operation)=>{
|
||||
if(locked.current)return;locked.current=true;setBusy(kind);setError(null);setMessage('');
|
||||
try{await operation();}catch(e){if(alive.current)setError(e.message);}finally{locked.current=false;if(alive.current)setBusy('');}
|
||||
};
|
||||
|
||||
const caps = userProfile.scrapedCapabilities;
|
||||
|
||||
const handleRemoveSector = (indexToRemove) => {
|
||||
if (!caps?.extractedSectors) return;
|
||||
const updated = caps.extractedSectors.filter((_, idx) => idx !== indexToRemove);
|
||||
updateCompanyCapabilities({ extractedSectors: updated });
|
||||
const scan=e=>{e.preventDefault();void run('scan',async()=>{
|
||||
controller.current=new AbortController();
|
||||
const proposal=await scanCompanyWebsite(url,{signal:controller.current.signal});
|
||||
if(alive.current){setCaps(proposal);setMessage('Tarama önerileri alındı. Alanları kontrol edip düzenleyin; henüz profilinize kaydedilmedi.');}
|
||||
});};
|
||||
const save=()=>run('save',async()=>{
|
||||
await updateCompanyCapabilities(caps,{website:url,threshold});
|
||||
if(alive.current)setMessage('Firma profili sunucuya kaydedildi. Eşleşmeler kayıtlı profilinizle güncellenir.');
|
||||
});
|
||||
const edit=(key,values)=>{setCaps(previous=>({...previous,[key]:values}));setMessage('Kaydedilmemiş değişiklikler var.');};
|
||||
const keyDown=e=>{
|
||||
if(e.key==='Escape'){e.preventDefault();e.stopPropagation();setIsCompanyScannerOpen(false);}
|
||||
if(e.key!=='Tab')return;
|
||||
const controls=[...root.current.querySelectorAll('button,input,select,textarea,a[href]')].filter(el=>!el.disabled&&el.getClientRects().length);
|
||||
const first=controls[0],last=controls.at(-1);
|
||||
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}
|
||||
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}
|
||||
};
|
||||
|
||||
const handleRemoveProduct = (indexToRemove) => {
|
||||
if (!caps?.extractedProducts) return;
|
||||
const updated = caps.extractedProducts.filter((_, idx) => idx !== indexToRemove);
|
||||
updateCompanyCapabilities({ extractedProducts: updated });
|
||||
};
|
||||
|
||||
const handleRemoveCert = (indexToRemove) => {
|
||||
if (!caps?.extractedCertifications) return;
|
||||
const updated = caps.extractedCertifications.filter((_, idx) => idx !== indexToRemove);
|
||||
updateCompanyCapabilities({ extractedCertifications: updated });
|
||||
};
|
||||
|
||||
const handleAddCustomProduct = (e) => {
|
||||
e.preventDefault();
|
||||
if (!newProductInput.trim() || !caps) return;
|
||||
const updated = [...(caps.extractedProducts || []), newProductInput.trim()];
|
||||
updateCompanyCapabilities({ extractedProducts: updated });
|
||||
setNewProductInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/70 backdrop-blur-xs animate-fade-in">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-3xl overflow-hidden my-auto flex flex-col max-h-[92vh]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-linear-to-r from-blue-900 via-slate-900 to-indigo-900 text-white px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white shadow-md">
|
||||
<Globe className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-extrabold text-base flex items-center gap-2">
|
||||
Firma Web Sitesi Tarama & Detaylı Ürün Analiz Motoru
|
||||
</h2>
|
||||
<p className="text-xs text-blue-200">Ürün ve faaliyet alanı önerilerini sitenizden çıkarır; profilinize eklemeden önce doğrulayın.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCompanyScannerOpen(false)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto">
|
||||
|
||||
{/* Strict Sector Protection Banner */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 flex items-center justify-between text-xs text-blue-900">
|
||||
<div className="flex items-center gap-2 font-bold">
|
||||
<ShieldCheck className="w-4 h-4 text-blue-600" />
|
||||
<span>Sektör Filtresi Aktif: Sadece web sitenizdeki kesinleşmiş faaliyet alanları ve detaylı ürün kataloğu işlenir.</span>
|
||||
</div>
|
||||
<span className="bg-blue-600 text-white text-[10px] font-extrabold px-2 py-0.5 rounded-full">
|
||||
Açıklanabilir ürün ve sektör eşleşmesi
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* URL Form */}
|
||||
<form onSubmit={handleStartScan} className="bg-slate-50 p-4 rounded-xl border border-slate-200 space-y-3">
|
||||
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">
|
||||
FİRMANIZA AİT WEB SİTESİ ADRESİ
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Globe className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
placeholder="https://www.firmaniz.com.tr"
|
||||
className="w-full bg-white border border-slate-300 rounded-lg pl-9 pr-4 py-2.5 text-xs text-slate-900 font-semibold focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isScanning}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white font-extrabold px-5 py-2.5 rounded-lg text-xs flex items-center gap-2 transition-colors shadow-md disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{isScanning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4 text-amber-300" />}
|
||||
<span>{isScanning ? 'Detaylı Ürünler Taranıyor...' : 'Web Sitemi Tara & Profili Güncelle'}</span>
|
||||
</button>
|
||||
</div>
|
||||
return <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/70">
|
||||
<section ref={root} role="dialog" aria-modal="true" aria-labelledby={titleId} onKeyDown={keyDown} className="bg-white rounded-2xl w-full max-w-3xl max-h-[92vh] overflow-y-auto p-5 space-y-4">
|
||||
<div className="flex justify-between gap-3"><h2 id={titleId} className="font-bold text-lg">Firma ürün ve faaliyet profili</h2><button aria-label="Firma profilini kapat" onClick={()=>setIsCompanyScannerOpen(false)}><X/></button></div>
|
||||
<p className="text-sm text-slate-600">Ürünlerinizi elle ekleyebilir veya web sitenizden öneri alabilirsiniz. Tarama, ürün uygunluğunu ya da ÜTS/sertifika geçerliliğini doğrulamaz. Kaydetmeden kapatırsanız bu ekrandaki değişiklikler uygulanmaz.</p>
|
||||
<form onSubmit={scan} className="flex gap-2 items-end flex-wrap">
|
||||
<label className="text-sm flex-1 min-w-0">Firma web sitesi<input aria-label="Firma web sitesi" type="url" required disabled={!!busy} value={url} onChange={e=>{setUrl(e.target.value);setMessage('Kaydedilmemiş değişiklikler var.');}} placeholder="https://www.firmaniz.com.tr" className="block border rounded p-2 w-full"/></label>
|
||||
<button disabled={!!busy} className="bg-blue-700 text-white rounded p-2 disabled:opacity-50">Web sitesinden öneri al</button>
|
||||
</form>
|
||||
|
||||
{/* Scanning Live Indicator */}
|
||||
{isScanning && (
|
||||
<div className="bg-blue-50 border border-blue-200 p-5 rounded-xl space-y-3 animate-fade-in">
|
||||
<div className="flex items-center justify-between text-xs font-extrabold text-blue-900">
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 text-blue-600 animate-spin" />
|
||||
Derin Sayfa Metin Analizi ve Ürün Çıkarımı Sürüyor...
|
||||
</span>
|
||||
<span>%{(scanStep * 33).toFixed(0)}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-blue-200 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${scanStep * 33}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-slate-600 font-medium">
|
||||
<p className={scanStep >= 1 ? 'text-blue-900 font-bold' : 'text-slate-400'}>
|
||||
1. 🌐 {urlInput} alan adına bağlanıldı, harici ve alakasız sektör tanımları elendi.
|
||||
</p>
|
||||
<p className={scanStep >= 2 ? 'text-blue-900 font-bold' : 'text-slate-400'}>
|
||||
2. 📄 Web sitesindeki "Hakkımızda", "Ürünler" ve "Katalog" alt sayfalarındaki ürün isimleri ayıklandı.
|
||||
</p>
|
||||
<p className={scanStep >= 3 ? 'text-blue-900 font-bold' : 'text-slate-400'}>
|
||||
3. 💾 Firmanın gerçek ürün yelpazesi ve ISO/ÜTS kalite belgeleri İhaleist veritabanına işlendi.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanSuccess && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 text-emerald-900 p-4 rounded-xl text-xs font-bold flex items-center gap-2 animate-fade-in shadow-xs">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-600 shrink-0" />
|
||||
<span>Web sitenizdeki ürünler ve doğrudan doğrulanmış sektörler veritabanına işlendi! Alakasız sektörler elendi.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Extracted Capabilities Database View */}
|
||||
{caps && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
||||
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-blue-600" /> Veritabanındaki Doğrulanmış Firma Ürün & Sektör Profili
|
||||
</h3>
|
||||
<span className="text-[10px] text-slate-400 font-semibold">Son Tarama: {caps.scrapedAt}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
{/* Sectors */}
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 space-y-2">
|
||||
<span className="font-bold text-xs text-slate-800 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Tag className="w-3.5 h-3.5 text-blue-600" /> Doğrulanmış Sektörler ({caps.extractedSectors?.length || 0})
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
{caps.extractedSectors?.map((s, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs font-bold text-blue-900 bg-blue-100/70 border border-blue-200 px-2.5 py-1.5 rounded-lg group">
|
||||
<span>{s}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveSector(i)}
|
||||
className="text-blue-400 hover:text-red-600 transition-colors p-0.5"
|
||||
title="Sektörü Kaldır"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 space-y-3 md:col-span-2">
|
||||
<span className="font-bold text-xs text-slate-800 uppercase tracking-wider flex items-center gap-1.5 justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Bot className="w-3.5 h-3.5 text-emerald-600" /> Detaylı Ürün & Hizmet Kataloğu ({caps.extractedProducts?.length || 0})
|
||||
</span>
|
||||
<span className="text-[10px] text-emerald-700 font-semibold">Sayfadan Ayıklanan Detaylı Kalemler</span>
|
||||
</span>
|
||||
|
||||
{/* Add Custom Item Form */}
|
||||
<form onSubmit={handleAddCustomProduct} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newProductInput}
|
||||
onChange={(e) => setNewProductInput(e.target.value)}
|
||||
placeholder="+ Yeni Ürün / İhale Kalemi Ekle..."
|
||||
className="flex-1 bg-white border border-slate-300 rounded-lg px-3 py-1.5 text-xs text-slate-900 font-semibold focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold px-3 py-1.5 rounded-lg text-xs transition-colors shrink-0"
|
||||
>
|
||||
Ekle
|
||||
</button>
|
||||
{busy&&<p role="status" className="flex gap-2 text-sm"><Loader2 className="w-4 h-4 animate-spin"/>{busy==='scan'?'Site taranıyor; sonuç bekleniyor…':'Profil kaydediliyor…'}</p>}
|
||||
{error&&<p role="alert" className="text-red-700 text-sm">{error}</p>}
|
||||
{message&&<p role="status" className="text-sm text-blue-800">{message}</p>}
|
||||
{caps.ozet&&<p className="text-sm">Tarama özeti: {caps.ozet}</p>}
|
||||
<fieldset disabled={!!busy} className="grid gap-4 sm:grid-cols-2">
|
||||
{fields.map(([key,label])=><section key={key} className="border rounded p-3 space-y-2">
|
||||
<h3 className="font-bold text-sm">{label}</h3>
|
||||
<form onSubmit={e=>{e.preventDefault();const value=(entries[key] || '').trim();if(!value)return;edit(key,[...new Set([...(caps[key] || []),value])]);setEntries(previous=>({...previous,[key]:''}));}} className="flex gap-2">
|
||||
<input aria-label={`${label} yeni kayıt`} value={entries[key] || ''} onChange={e=>setEntries(previous=>({...previous,[key]:e.target.value}))} maxLength={300} className="min-w-0 w-full border rounded p-2 text-sm"/>
|
||||
<button className="border rounded p-2 text-sm" disabled={!(entries[key] || '').trim()} aria-label={`${label} ekle`}>Ekle</button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
||||
{caps.extractedProducts?.map((p, i) => (
|
||||
<div key={i} className="text-xs font-semibold text-slate-700 bg-white border border-slate-200 p-2 rounded-lg flex items-start justify-between gap-1.5 group">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="shrink-0 text-emerald-600 font-bold">📦</span>
|
||||
<span className="line-clamp-2">{p}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveProduct(i)}
|
||||
className="text-slate-300 hover:text-red-600 transition-colors p-0.5 shrink-0"
|
||||
title="Ürünü Kaldır"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Certifications */}
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 space-y-2">
|
||||
<span className="font-bold text-xs text-slate-800 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-purple-600" /> Web Sitesinde Tespit Edilen Sertifika ve Kayıtlar
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{caps.extractedCertifications?.map((c, i) => (
|
||||
<span key={i} className="text-xs font-semibold text-purple-900 bg-purple-100/70 border border-purple-200 px-3 py-1 rounded-lg flex items-center gap-1.5">
|
||||
📜 {c}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCert(i)}
|
||||
className="text-purple-400 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren.
|
||||
(Önceki "%98+ doğruluk" cümlesi ölçülmemiş bir iddiaydı, kaldırıldı.) */}
|
||||
<div className="bg-white px-6 py-4 border-t border-slate-200 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<span className="text-xs font-bold text-slate-800">Uyum eşiği — "Uyumlu" filtresi bu skordan itibaren gösterir</span>
|
||||
<span className="text-sm font-black text-blue-700">%{uyumEsigi}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={30} max={95} step={5} value={uyumEsigi}
|
||||
onChange={(e) => setUyumEsigi(e.target.value)}
|
||||
className="w-full accent-blue-600 cursor-pointer"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed">
|
||||
Skor = profil terimlerinin ihalede geçmesi (başlık, teklif cetveli, OKAS) + anlamsal yakınlık bonusu.
|
||||
{gommeDurum === 'hazir' && <> Anlamsal bonus <strong className="text-emerald-700">etkin</strong>.</>}
|
||||
{gommeDurum === 'bekliyor' && <> Anlamsal bonus hesaplanıyor…</>}
|
||||
{(gommeDurum === 'kapali' || gommeDurum === 'hata') && <> Anlamsal bonus şu an kapalı; yalnızca terim eşleşmesi.</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-slate-50 p-4 border-t border-slate-200 flex justify-between items-center">
|
||||
<p className="text-[11px] text-slate-500 font-medium">
|
||||
Terim ekledikçe skor keskinleşir; kalem havuzu doldukça teklif cetveli eşleşmeleri devreye girer.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsCompanyScannerOpen(false)}
|
||||
className="bg-slate-800 hover:bg-slate-700 text-white font-bold text-xs px-6 py-2.5 rounded-lg transition-colors shadow-xs shrink-0"
|
||||
>
|
||||
Tamam & Profili Kaydet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<ul className="space-y-2">{(caps[key] || []).map((value,index)=><li key={`${value}:${index}`} className="flex justify-between gap-2 text-sm break-words"><span>{value}</span><button type="button" aria-label={`${label}: ${value} kaldır`} onClick={()=>edit(key,caps[key].filter((_,i)=>i!==index))} className="text-red-700 shrink-0">Kaldır</button></li>)}</ul>
|
||||
</section>)}
|
||||
</fieldset>
|
||||
<label className="block text-sm">Uyum eşiği: %{threshold}<input aria-label="Uyum eşiği" type="range" min={30} max={95} step={5} disabled={!!busy} value={threshold} onChange={e=>{setThreshold(Number(e.target.value));setMessage('Kaydedilmemiş değişiklikler var.');}} className="block w-full"/></label>
|
||||
<p className="text-xs text-slate-500">Uyum skoru terim eşleşmesi ve varsa anlamsal yakınlık girdisidir; başvuru yeterliliği değildir.</p>
|
||||
<button type="button" onClick={save} disabled={!!busy} className="bg-blue-700 text-white px-4 py-2 rounded disabled:opacity-50">Profili kaydet</button>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
+14
-50
@@ -2,7 +2,7 @@ import {mergeServerNotifications,acknowledgeNotifications} from '../services/not
|
||||
import { searchTender } from '../services/searchTender';
|
||||
import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch';
|
||||
import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||||
import { scanCompanyProposal } from '../services/companyProfile';
|
||||
import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
|
||||
import { getToken } from '../services/auth';
|
||||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||
@@ -479,14 +479,6 @@ export const AppProvider = ({ children }) => {
|
||||
};
|
||||
|
||||
const [userProfile, setUserProfile] = useState(() => defaultProfile(getStoredUser()));
|
||||
useEffect(() => {
|
||||
const veri = {
|
||||
company: userProfile.company, website: userProfile.website,
|
||||
scrapedCapabilities: userProfile.scrapedCapabilities, uyumEsigi: userProfile.uyumEsigi,
|
||||
};
|
||||
if (veriSenkron) ilkYazmaDegil('firma_profil', veri);
|
||||
}, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, userProfile.uyumEsigi, veriSenkron]);
|
||||
const setUyumEsigi = (v) => setUserProfile((p) => ({ ...p, uyumEsigi: Math.max(0, Math.min(100, Number(v) || 0)) }));
|
||||
|
||||
// Anlamsal bonus: profil metni sunucuda gömülür, havuzla kosinüs alınır,
|
||||
// z-skor → 0..20. Profil terimi yoksa ya da oturum yoksa istenmez. Havuz
|
||||
@@ -522,47 +514,20 @@ export const AppProvider = ({ children }) => {
|
||||
[tenders, userProfile.scrapedCapabilities, gommeBonus],
|
||||
);
|
||||
|
||||
// Önce gerçek tarama (/api/company/scan: site metni + model), başarısızsa
|
||||
// alan adından tahmin eden eski yol. Sonuç uyum skorunun girdisi olur.
|
||||
const scanCompanyWebsite = async (url) => {
|
||||
const session = getToken();
|
||||
let yeni = null;
|
||||
const scanCompanyWebsite = scanCompanyProposal;
|
||||
const profileSaveLock=useRef(false);
|
||||
const updateCompanyCapabilities = async (capabilities,{website=userProfile.website,threshold=userProfile.uyumEsigi}={}) => {
|
||||
if(!authUser||!veriSenkron)throw new Error('Hesap verileri yüklenmeden profil kaydedilemez.');
|
||||
if(profileSaveLock.current)throw new Error('Önceki profil kaydı sürüyor.');
|
||||
const session=getToken();
|
||||
profileSaveLock.current=true;
|
||||
try {
|
||||
const r = await fetch('/api/company/scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success && d.sonuc) {
|
||||
const so = d.sonuc;
|
||||
yeni = {
|
||||
scrapedAt: new Date().toLocaleString('tr-TR'),
|
||||
extractedSectors: so.sektorler || [],
|
||||
extractedProducts: so.urunler || [],
|
||||
extractedCertifications: so.sertifikalar || [],
|
||||
ihaleKonulari: so.ihaleKonulari || [],
|
||||
ozet: so.ozet || null,
|
||||
tarananSayfa: d.tarananSayfa,
|
||||
matchScoreBoost: 0,
|
||||
gercekTarama: true,
|
||||
};
|
||||
}
|
||||
} catch { /* aşağıda yedek yol */ }
|
||||
if (!yeni) yeni = { ...parseWebsiteCapabilitiesDynamically(url), ihaleKonulari: [], gercekTarama: false };
|
||||
if(getToken() !== session) throw new Error('Oturum değişti; firma taraması bu hesaba aktarılmadı.');
|
||||
setUserProfile((prev) => ({ ...prev, website: url, scrapedCapabilities: yeni }));
|
||||
return yeni;
|
||||
};
|
||||
|
||||
const updateCompanyCapabilities = (updatedCapabilities) => {
|
||||
setUserProfile(prev => ({
|
||||
...prev,
|
||||
scrapedCapabilities: {
|
||||
...prev.scrapedCapabilities,
|
||||
...updatedCapabilities
|
||||
}
|
||||
}));
|
||||
const profile={company:userProfile.company,website,scrapedCapabilities:capabilities,uyumEsigi:Math.max(0,Math.min(100,Number(threshold)||0))};
|
||||
await setUserData('firma_profil',profile);
|
||||
if(getToken()!==session)throw new Error('Oturum değişti; profil bu hesaba aktarılmadı.');
|
||||
setUserProfile(previous=>({...previous,...profile}));
|
||||
return profile;
|
||||
}finally{profileSaveLock.current=false;}
|
||||
};
|
||||
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
@@ -741,7 +706,6 @@ export const AppProvider = ({ children }) => {
|
||||
setActiveTab,
|
||||
tenders: tendersSkorlu,
|
||||
uyumEsigi: userProfile.uyumEsigi,
|
||||
setUyumEsigi,
|
||||
gommeDurum,
|
||||
setTenders,
|
||||
isLiveMode,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {accountRequest,requireObject} from './accountRequest.js';
|
||||
export function companyScanProposal(data) {
|
||||
const result=requireObject(data,'sonuc');
|
||||
const array=key=>{
|
||||
if(!Array.isArray(result[key])||result[key].some(value=>typeof value!=='string'||!value.trim()))throw new Error('Tarama önerileri eksik veya geçersiz. Profil değiştirilmedi.');
|
||||
return [...new Set(result[key].map(value=>value.trim()))];
|
||||
};
|
||||
return {scrapedAt:new Date().toLocaleString('tr-TR'),extractedSectors:array('sektorler'),extractedProducts:array('urunler'),
|
||||
extractedCertifications:array('sertifikalar'),ihaleKonulari:array('ihaleKonulari'),ozet:typeof result.ozet==='string'?result.ozet:null,
|
||||
tarananSayfa:data.tarananSayfa,gercekTarama:true,matchScoreBoost:0};
|
||||
}
|
||||
export async function scanCompanyProposal(url,{signal,request=accountRequest}={}) {
|
||||
const data=await request('/api/company/scan',{method:'POST',timeoutMs:120000,signal,body:JSON.stringify({url})});
|
||||
return companyScanProposal(data);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {scanCompanyProposal,companyScanProposal} from '../src/services/companyProfile.js';
|
||||
test('failed company scan throws instead of fabricating domain-derived products',async()=>{
|
||||
await assert.rejects(scanCompanyProposal('https://lab-medical.example',{request:async()=>{throw new Error('Provider unavailable');}}),/Provider unavailable/);
|
||||
for(const sonuc of [{},{urunler:'kit'},{sektorler:[],urunler:[{}],sertifikalar:[],ihaleKonulari:[]}])assert.throws(()=>companyScanProposal({sonuc}));
|
||||
});
|
||||
test('successful scan is returned as editable source suggestions with no profile write request',async()=>{
|
||||
const calls=[];const result=await scanCompanyProposal('https://example.test',{request:async(url,options)=>{calls.push({url,options});return {sonuc:{sektorler:[' Sağlık ','Sağlık'],urunler:['Kit'],sertifikalar:[],ihaleKonulari:['Laboratuvar']},tarananSayfa:2};}});
|
||||
assert.equal(calls.length,1);assert.equal(calls[0].url,'/api/company/scan');
|
||||
assert.deepEqual(result.extractedSectors,['Sağlık']);assert.deepEqual(result.extractedProducts,['Kit']);
|
||||
assert.equal(result.gercekTarama,true);
|
||||
});
|
||||
Reference in New Issue
Block a user