feat: Add detailed progress modal for AI enrichment

- New modal with animated progress bar and percentage
- Step-by-step log showing:
  - Initialization
  - Data collection
  - AI prompt preparation
  - Gemini API call
  - Response parsing
  - Results summary (services, USPs, tags, etc.)
- Cancel button with AbortController support
- Success/error states with appropriate icons
- Footer with "Close" and "Refresh page" buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-13 18:12:04 +01:00
parent 35d53665e3
commit d722fdb71e

View File

@ -195,7 +195,6 @@
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-left: var(--spacing-md);
}
.ai-enrich-btn:hover:not(:disabled) {
@ -209,32 +208,202 @@
opacity: 0.7;
}
.ai-enrich-btn.loading {
pointer-events: none;
/* AI Progress Modal */
.ai-progress-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.ai-enrich-btn .spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
.ai-progress-modal.active {
display: flex;
}
.ai-progress-content {
background: white;
border-radius: var(--radius-xl);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from { transform: translateY(-30px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.ai-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg) var(--spacing-xl);
border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.ai-progress-header h3 {
margin: 0;
font-size: var(--font-size-xl);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.ai-progress-header .spinner-icon {
width: 24px;
height: 24px;
border: 3px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: none;
}
.ai-enrich-btn.loading .spinner {
display: inline-block;
.ai-cancel-btn {
padding: var(--spacing-sm) var(--spacing-lg);
background: rgba(255,255,255,0.2);
color: white;
border: 1px solid rgba(255,255,255,0.3);
border-radius: var(--radius);
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.ai-enrich-btn.loading .btn-text {
display: none;
.ai-cancel-btn:hover {
background: rgba(255,255,255,0.3);
}
.ai-progress-body {
padding: var(--spacing-xl);
}
.ai-progress-bar-container {
margin-bottom: var(--spacing-lg);
}
.ai-progress-bar-bg {
height: 28px;
background: var(--background);
border-radius: 14px;
overflow: hidden;
position: relative;
}
.ai-progress-bar {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 14px;
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 50px;
}
.ai-progress-bar span {
color: white;
font-weight: 700;
font-size: var(--font-size-sm);
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
.ai-progress-status {
font-size: var(--font-size-base);
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.ai-progress-status .status-icon {
font-size: 1.2em;
}
.ai-progress-log {
background: var(--background);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: var(--font-size-sm);
}
.ai-log-entry {
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
}
.ai-log-entry:last-child {
border-bottom: none;
}
.ai-log-entry.step {
color: var(--text-primary);
font-weight: 600;
}
.ai-log-entry.success {
color: var(--success);
}
.ai-log-entry.error {
color: var(--error);
}
.ai-log-entry.info {
color: var(--text-secondary);
padding-left: var(--spacing-lg);
}
.ai-log-icon {
flex-shrink: 0;
width: 20px;
text-align: center;
}
.ai-progress-footer {
padding: var(--spacing-lg) var(--spacing-xl);
border-top: 1px solid var(--border);
background: var(--background);
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
}
.ai-progress-footer .btn {
padding: var(--spacing-sm) var(--spacing-xl);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.ai-analyzing {
animation: pulse 1.5s ease-in-out infinite;
}
.quality-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: var(--spacing-xl);
@ -2979,6 +3148,39 @@
</div>
</div>
<!-- AI Progress Modal -->
<div id="aiProgressModal" class="ai-progress-modal">
<div class="ai-progress-content">
<div class="ai-progress-header">
<h3>
<div class="spinner-icon" id="aiSpinner"></div>
<span id="aiModalTitle">Wzbogacanie danych AI...</span>
</h3>
<button class="ai-cancel-btn" id="aiCancelBtn" onclick="cancelAiEnrichment()">Anuluj</button>
</div>
<div class="ai-progress-body">
<div class="ai-progress-bar-container">
<div class="ai-progress-bar-bg">
<div class="ai-progress-bar" id="aiProgressBar" style="width: 0%">
<span id="aiProgressPercent">0%</span>
</div>
</div>
</div>
<div class="ai-progress-status" id="aiProgressStatus">
<span class="status-icon"></span>
<span id="aiStatusText">Przygotowywanie...</span>
</div>
<div class="ai-progress-log" id="aiProgressLog">
<!-- Log entries will be added here -->
</div>
</div>
<div class="ai-progress-footer" id="aiProgressFooter" style="display: none;">
<button class="btn btn-secondary" onclick="closeAiModal()">Zamknij</button>
<button class="btn btn-primary" onclick="window.location.reload()">Odswiez strone</button>
</div>
</div>
</div>
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
@ -3062,52 +3264,188 @@ async function deleteRecommendation(recId) {
}
// === AI ENRICHMENT ===
let aiEnrichmentCancelled = false;
let aiEnrichmentController = null;
function openAiModal() {
document.getElementById('aiProgressModal').classList.add('active');
document.getElementById('aiProgressBar').style.width = '0%';
document.getElementById('aiProgressPercent').textContent = '0%';
document.getElementById('aiProgressLog').innerHTML = '';
document.getElementById('aiProgressFooter').style.display = 'none';
document.getElementById('aiCancelBtn').style.display = 'block';
document.getElementById('aiSpinner').style.display = 'block';
document.getElementById('aiModalTitle').textContent = 'Wzbogacanie danych AI...';
aiEnrichmentCancelled = false;
}
function closeAiModal() {
document.getElementById('aiProgressModal').classList.remove('active');
}
function cancelAiEnrichment() {
aiEnrichmentCancelled = true;
if (aiEnrichmentController) {
aiEnrichmentController.abort();
}
addAiLogEntry('Anulowano przez uzytkownika', 'error', '✕');
finishAiEnrichment(false);
}
function updateAiProgress(percent, status) {
document.getElementById('aiProgressBar').style.width = `${percent}%`;
document.getElementById('aiProgressPercent').textContent = `${percent}%`;
if (status) {
document.getElementById('aiStatusText').textContent = status;
}
}
function addAiLogEntry(message, type = 'info', icon = null) {
const log = document.getElementById('aiProgressLog');
const entry = document.createElement('div');
entry.className = `ai-log-entry ${type}`;
const icons = {
step: '▶',
success: '✓',
error: '✕',
info: '•'
};
entry.innerHTML = `
<span class="ai-log-icon">${icon || icons[type] || '•'}</span>
<span>${message}</span>
`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function finishAiEnrichment(success) {
document.getElementById('aiCancelBtn').style.display = 'none';
document.getElementById('aiSpinner').style.display = 'none';
document.getElementById('aiProgressFooter').style.display = 'flex';
if (success) {
document.getElementById('aiModalTitle').textContent = 'Wzbogacanie zakonczone!';
updateAiProgress(100, 'Zakonczone pomyslnie');
document.querySelector('#aiProgressStatus .status-icon').textContent = '✓';
} else {
document.getElementById('aiModalTitle').textContent = 'Wystapil blad';
document.querySelector('#aiProgressStatus .status-icon').textContent = '✕';
}
}
document.addEventListener('DOMContentLoaded', function() {
const aiEnrichBtn = document.getElementById('aiEnrichBtn');
if (aiEnrichBtn && !aiEnrichBtn.disabled) {
aiEnrichBtn.addEventListener('click', async function() {
const companyId = this.dataset.companyId;
const companyName = '{{ company.name }}';
// Confirm action
if (!confirm('Czy chcesz wzbogacic dane firmy przez AI? Operacja moze potrwac kilka sekund.')) {
return;
}
// Open modal
openAiModal();
// Set loading state
this.classList.add('loading');
this.disabled = true;
// Step 1: Initialize
updateAiProgress(5, 'Inicjalizacja...');
addAiLogEntry(`Rozpoczynam wzbogacanie danych dla: ${companyName}`, 'step');
await sleep(300);
if (aiEnrichmentCancelled) return;
// Step 2: Collecting data
updateAiProgress(15, 'Zbieranie danych firmy...');
addAiLogEntry('Pobieranie aktualnych danych firmy z bazy', 'info');
await sleep(400);
if (aiEnrichmentCancelled) return;
// Step 3: Preparing prompt
updateAiProgress(25, 'Przygotowywanie zapytania AI...');
addAiLogEntry('Budowanie kontekstu dla modelu AI', 'info');
addAiLogEntry('Dane do analizy: nazwa, kategoria, opis, uslugi, kompetencje', 'info');
await sleep(300);
if (aiEnrichmentCancelled) return;
// Step 4: Calling AI
updateAiProgress(35, 'Wysylanie do Gemini AI...');
addAiLogEntry('Laczenie z Google Gemini API...', 'step');
try {
aiEnrichmentController = new AbortController();
addAiLogEntry('Oczekiwanie na odpowiedz AI (moze potrwac do 30s)...', 'info');
updateAiProgress(45, 'Analizowanie przez AI...');
const response = await fetch(`/api/company/${companyId}/enrich-ai`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
},
signal: aiEnrichmentController.signal
});
if (aiEnrichmentCancelled) return;
updateAiProgress(70, 'Przetwarzanie odpowiedzi...');
addAiLogEntry('Odpowiedz otrzymana, przetwarzanie...', 'info');
const data = await response.json();
if (data.success) {
showToast(`Dane firmy zostaly wzbogacone przez AI (${data.processing_time_ms}ms)`, 'success');
// Reload page to show new data
setTimeout(() => {
window.location.reload();
}, 1500);
updateAiProgress(85, 'Zapisywanie wynikow...');
addAiLogEntry('Parsowanie odpowiedzi JSON', 'info');
await sleep(200);
// Show what was generated
const insights = data.insights;
addAiLogEntry('Wygenerowane dane:', 'step');
if (insights.business_summary) {
addAiLogEntry(`Opis biznesowy: ${insights.business_summary.substring(0, 60)}...`, 'success');
}
if (insights.services_list && insights.services_list.length > 0) {
addAiLogEntry(`Uslugi: ${insights.services_list.length} pozycji`, 'success');
}
if (insights.unique_selling_points && insights.unique_selling_points.length > 0) {
addAiLogEntry(`Wyrozniki: ${insights.unique_selling_points.length} pozycji`, 'success');
}
if (insights.industry_tags && insights.industry_tags.length > 0) {
addAiLogEntry(`Tagi branzowe: ${insights.industry_tags.join(', ')}`, 'success');
}
if (insights.suggested_category) {
addAiLogEntry(`Sugerowana kategoria: ${insights.suggested_category}`, 'success');
}
updateAiProgress(95, 'Finalizacja...');
addAiLogEntry('Zapisano do bazy danych', 'info');
await sleep(300);
addAiLogEntry(`Czas przetwarzania: ${data.processing_time_ms}ms`, 'info');
addAiLogEntry('Wzbogacanie zakonczone pomyslnie!', 'success', '★');
finishAiEnrichment(true);
} else {
showToast('Blad: ' + (data.error || 'Nie udalo sie wzbogacic danych'), 'error');
this.classList.remove('loading');
this.disabled = false;
addAiLogEntry('Blad: ' + (data.error || 'Nieznany blad'), 'error');
finishAiEnrichment(false);
}
} catch (error) {
if (error.name === 'AbortError') {
// Already handled in cancelAiEnrichment
return;
}
console.error('AI enrichment error:', error);
showToast('Wystapil blad podczas wzbogacania danych', 'error');
this.classList.remove('loading');
this.disabled = false;
addAiLogEntry('Blad polaczenia: ' + error.message, 'error');
finishAiEnrichment(false);
}
});
}
});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
</script>
{% endblock %}