- Dodano skrypt cron do automatycznej ekstrakcji wiedzy (scripts/cron_extract_knowledge.py) - Dodano panel deduplikacji faktów (/admin/zopk/knowledge/fact-duplicates) - Dodano API i funkcje auto-weryfikacji encji i faktów - Dodano panel Timeline ZOPK (/admin/zopk/timeline) z CRUD - Rozszerzono dashboard bazy wiedzy o statystyki weryfikacji i przyciski auto-weryfikacji - Dodano migrację 016_zopk_milestones.sql dla tabeli kamieni milowych - Naprawiono duplikat modelu ZOPKMilestone w database.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
303 lines
13 KiB
HTML
303 lines
13 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Timeline ZOPK - Roadmapa{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-lg); }
|
||
.page-header h1 { font-size: var(--font-size-2xl); color: var(--text-primary); }
|
||
.breadcrumb { display: flex; gap: var(--spacing-xs); color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-lg); }
|
||
.breadcrumb a { color: var(--primary); text-decoration: none; }
|
||
|
||
.timeline-container { position: relative; padding: var(--spacing-lg) 0; }
|
||
.timeline-line { position: absolute; left: 50%; top: 0; bottom: 0; width: 4px; background: var(--border); transform: translateX(-50%); }
|
||
|
||
.timeline-item { display: flex; margin-bottom: var(--spacing-xl); position: relative; }
|
||
.timeline-item:nth-child(odd) { flex-direction: row-reverse; }
|
||
.timeline-item:nth-child(odd) .timeline-content { text-align: right; padding-right: var(--spacing-xl); }
|
||
.timeline-item:nth-child(even) .timeline-content { padding-left: var(--spacing-xl); }
|
||
|
||
.timeline-content { width: 45%; }
|
||
.timeline-dot { position: absolute; left: 50%; transform: translateX(-50%); width: 20px; height: 20px; border-radius: 50%; border: 4px solid var(--surface); z-index: 1; }
|
||
|
||
.timeline-card { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: var(--spacing-md); }
|
||
.timeline-date { font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-xs); }
|
||
.timeline-title { font-size: var(--font-size-lg); font-weight: 600; margin-bottom: var(--spacing-xs); }
|
||
.timeline-desc { font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-sm); }
|
||
.timeline-meta { display: flex; gap: var(--spacing-sm); flex-wrap: wrap; }
|
||
.timeline-badge { padding: 2px 8px; border-radius: 12px; font-size: var(--font-size-xs); }
|
||
|
||
.status-planned { background: #e5e7eb; color: #374151; }
|
||
.status-in_progress { background: #dbeafe; color: #1d4ed8; }
|
||
.status-completed { background: #d1fae5; color: #047857; }
|
||
.status-delayed { background: #fef3c7; color: #b45309; }
|
||
|
||
.category-nuclear { --cat-color: #ef4444; }
|
||
.category-offshore { --cat-color: #3b82f6; }
|
||
.category-infrastructure { --cat-color: #8b5cf6; }
|
||
.category-defense { --cat-color: #059669; }
|
||
.category-other { --cat-color: #6b7280; }
|
||
|
||
.timeline-dot.category-nuclear { background: #ef4444; }
|
||
.timeline-dot.category-offshore { background: #3b82f6; }
|
||
.timeline-dot.category-infrastructure { background: #8b5cf6; }
|
||
.timeline-dot.category-defense { background: #059669; }
|
||
.timeline-dot.category-other { background: #6b7280; }
|
||
|
||
.btn { padding: 8px 16px; border-radius: var(--radius); font-weight: 500; cursor: pointer; border: none; }
|
||
.btn-primary { background: var(--primary); color: white; }
|
||
.btn-sm { padding: 4px 8px; font-size: var(--font-size-xs); }
|
||
|
||
.legend { display: flex; gap: var(--spacing-lg); margin-bottom: var(--spacing-lg); flex-wrap: wrap; }
|
||
.legend-item { display: flex; align-items: center; gap: var(--spacing-xs); font-size: var(--font-size-sm); }
|
||
.legend-dot { width: 12px; height: 12px; border-radius: 50%; }
|
||
|
||
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center; }
|
||
.modal.active { display: flex; }
|
||
.modal-content { background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-lg); width: 500px; max-width: 90%; }
|
||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-md); }
|
||
.form-group { margin-bottom: var(--spacing-md); }
|
||
.form-group label { display: block; font-size: var(--font-size-sm); margin-bottom: 4px; }
|
||
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius); }
|
||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); }
|
||
.empty-state { text-align: center; padding: var(--spacing-xl); color: var(--text-secondary); }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container">
|
||
<div class="breadcrumb">
|
||
<a href="{{ url_for('admin_zopk') }}">Panel ZOPK</a>
|
||
<span>›</span>
|
||
<span>Timeline / Roadmapa</span>
|
||
</div>
|
||
|
||
<div class="page-header">
|
||
<h1>🗺️ Timeline ZOPK</h1>
|
||
<button class="btn btn-primary" onclick="openAddModal()">➕ Dodaj kamień milowy</button>
|
||
</div>
|
||
|
||
<div class="legend">
|
||
<div class="legend-item"><div class="legend-dot" style="background: #ef4444;"></div> Energia jądrowa</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background: #3b82f6;"></div> Offshore wind</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background: #8b5cf6;"></div> Infrastruktura</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background: #059669;"></div> Obronność</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background: #6b7280;"></div> Inne</div>
|
||
</div>
|
||
|
||
<div class="timeline-container">
|
||
<div class="timeline-line"></div>
|
||
<div id="timelineItems">
|
||
<div class="empty-state">Ładowanie...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal dodawania -->
|
||
<div class="modal" id="addModal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3 id="modalTitle">Dodaj kamień milowy</h3>
|
||
<button onclick="closeModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
||
</div>
|
||
<form id="milestoneForm" onsubmit="saveMilestone(event)">
|
||
<input type="hidden" id="milestoneId">
|
||
<div class="form-group">
|
||
<label>Tytuł *</label>
|
||
<input type="text" id="title" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Opis</label>
|
||
<textarea id="description" rows="3"></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Kategoria</label>
|
||
<select id="category">
|
||
<option value="nuclear">Energia jądrowa</option>
|
||
<option value="offshore">Offshore wind</option>
|
||
<option value="infrastructure">Infrastruktura</option>
|
||
<option value="defense">Obronność</option>
|
||
<option value="other">Inne</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Status</label>
|
||
<select id="status">
|
||
<option value="planned">Planowane</option>
|
||
<option value="in_progress">W trakcie</option>
|
||
<option value="completed">Zakończone</option>
|
||
<option value="delayed">Opóźnione</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Data planowana</label>
|
||
<input type="date" id="targetDate">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Data rzeczywista</label>
|
||
<input type="date" id="actualDate">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Źródło (URL)</label>
|
||
<input type="url" id="sourceUrl">
|
||
</div>
|
||
<div style="display: flex; justify-content: flex-end; gap: var(--spacing-sm);">
|
||
<button type="button" class="btn" onclick="closeModal()">Anuluj</button>
|
||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
let milestones = [];
|
||
let editingId = null;
|
||
|
||
async function loadMilestones() {
|
||
try {
|
||
const response = await fetch('/api/zopk/milestones');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
milestones = data.milestones;
|
||
renderTimeline();
|
||
}
|
||
} catch (error) {
|
||
document.getElementById('timelineItems').innerHTML = '<div class="empty-state">Błąd ładowania: ' + error + '</div>';
|
||
}
|
||
}
|
||
|
||
function renderTimeline() {
|
||
if (milestones.length === 0) {
|
||
document.getElementById('timelineItems').innerHTML = '<div class="empty-state">Brak kamieni milowych. Dodaj pierwszy!</div>';
|
||
return;
|
||
}
|
||
|
||
const statusLabels = {planned: 'Planowane', in_progress: 'W trakcie', completed: 'Zakończone', delayed: 'Opóźnione'};
|
||
const categoryLabels = {nuclear: 'Energia jądrowa', offshore: 'Offshore', infrastructure: 'Infrastruktura', defense: 'Obronność', other: 'Inne'};
|
||
|
||
const html = milestones.map(m => `
|
||
<div class="timeline-item">
|
||
<div class="timeline-content">
|
||
<div class="timeline-card">
|
||
<div class="timeline-date">${formatDate(m.target_date)}</div>
|
||
<div class="timeline-title">${escapeHtml(m.title)}</div>
|
||
<div class="timeline-desc">${escapeHtml(m.description || '')}</div>
|
||
<div class="timeline-meta">
|
||
<span class="timeline-badge status-${m.status}">${statusLabels[m.status] || m.status}</span>
|
||
<span class="timeline-badge" style="background: var(--cat-color);">${categoryLabels[m.category] || m.category}</span>
|
||
<button class="btn btn-sm" onclick="editMilestone(${m.id})">✏️</button>
|
||
<button class="btn btn-sm" onclick="deleteMilestone(${m.id})">🗑️</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="timeline-dot category-${m.category}"></div>
|
||
</div>
|
||
`).join('');
|
||
|
||
document.getElementById('timelineItems').innerHTML = html;
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return 'Brak daty';
|
||
const d = new Date(dateStr);
|
||
return d.toLocaleDateString('pl-PL', {year: 'numeric', month: 'long'});
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text || '';
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function openAddModal() {
|
||
editingId = null;
|
||
document.getElementById('modalTitle').textContent = 'Dodaj kamień milowy';
|
||
document.getElementById('milestoneForm').reset();
|
||
document.getElementById('addModal').classList.add('active');
|
||
}
|
||
|
||
function editMilestone(id) {
|
||
const m = milestones.find(x => x.id === id);
|
||
if (!m) return;
|
||
|
||
editingId = id;
|
||
document.getElementById('modalTitle').textContent = 'Edytuj kamień milowy';
|
||
document.getElementById('title').value = m.title;
|
||
document.getElementById('description').value = m.description || '';
|
||
document.getElementById('category').value = m.category;
|
||
document.getElementById('status').value = m.status;
|
||
document.getElementById('targetDate').value = m.target_date || '';
|
||
document.getElementById('actualDate').value = m.actual_date || '';
|
||
document.getElementById('sourceUrl').value = m.source_url || '';
|
||
document.getElementById('addModal').classList.add('active');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('addModal').classList.remove('active');
|
||
}
|
||
|
||
async function saveMilestone(e) {
|
||
e.preventDefault();
|
||
|
||
const data = {
|
||
title: document.getElementById('title').value,
|
||
description: document.getElementById('description').value,
|
||
category: document.getElementById('category').value,
|
||
status: document.getElementById('status').value,
|
||
target_date: document.getElementById('targetDate').value || null,
|
||
actual_date: document.getElementById('actualDate').value || null,
|
||
source_url: document.getElementById('sourceUrl').value || null
|
||
};
|
||
|
||
try {
|
||
const url = editingId ? `/api/zopk/milestones/${editingId}` : '/api/zopk/milestones';
|
||
const method = editingId ? 'PUT' : 'POST';
|
||
|
||
const response = await fetch(url, {
|
||
method: method,
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
|
||
body: JSON.stringify(data)
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
closeModal();
|
||
loadMilestones();
|
||
} else {
|
||
alert('Błąd: ' + result.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Błąd: ' + error);
|
||
}
|
||
}
|
||
|
||
async function deleteMilestone(id) {
|
||
if (!confirm('Usunąć ten kamień milowy?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/zopk/milestones/${id}`, {
|
||
method: 'DELETE',
|
||
headers: {'X-CSRFToken': '{{ csrf_token() }}'}
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
loadMilestones();
|
||
} else {
|
||
alert('Błąd: ' + result.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Błąd: ' + error);
|
||
}
|
||
}
|
||
|
||
// Init
|
||
loadMilestones();
|
||
{% endblock %}
|