nordabiz/templates/admin/zopk_timeline.html
Maciej Pienczyn 96fa0058c2 feat(zopk): Rozbudowa bazy wiedzy ZOPK
- 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>
2026-01-17 10:57:11 +01:00

303 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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;">&times;</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 %}