nordabiz/templates/admin/zopk_dashboard.html
Maciej Pienczyn d51637a226 feat: Add ZOPK (Zielony Okręg Przemysłowy Kaszubia) knowledge base
- Add database models for ZOPK projects, stakeholders, news, resources
- Add migration with initial data (5 projects, 7 stakeholders)
- Implement admin dashboard with news moderation workflow
- Add Brave Search API integration for automated news discovery
- Create public knowledge base pages (index, project detail, news list)
- Add navigation links in main menu and admin bar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 06:32:27 +01:00

648 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}ZOPK - Panel Admina - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
}
.admin-actions {
display: flex;
gap: var(--spacing-sm);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.stat-card.warning .stat-value {
color: var(--warning);
}
.stat-card.success .stat-value {
color: var(--success);
}
.panel-section {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.panel-section h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.pending-news-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.pending-news-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
gap: var(--spacing-md);
}
.news-info {
flex: 1;
min-width: 0;
}
.news-info h4 {
font-size: var(--font-size-base);
margin-bottom: var(--spacing-xs);
word-break: break-word;
}
.news-info h4 a {
color: inherit;
text-decoration: none;
}
.news-info h4 a:hover {
color: var(--primary);
}
.news-meta {
font-size: var(--font-size-xs);
color: var(--text-secondary);
display: flex;
gap: var(--spacing-md);
}
.news-actions {
display: flex;
gap: var(--spacing-xs);
flex-shrink: 0;
}
.btn-approve {
background: var(--success);
color: white;
}
.btn-approve:hover {
background: #059669;
}
.btn-reject {
background: var(--danger);
color: white;
}
.btn-reject:hover {
background: #dc2626;
}
.projects-table {
width: 100%;
border-collapse: collapse;
}
.projects-table th,
.projects-table td {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.projects-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-planned { background: #fef3c7; color: #92400e; }
.status-in_progress { background: #dbeafe; color: #1e40af; }
.status-completed { background: #dcfce7; color: #166534; }
.search-section {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
color: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-xl);
}
.search-section h3 {
margin-bottom: var(--spacing-md);
}
.search-form {
display: flex;
gap: var(--spacing-sm);
}
.search-form input {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.search-form button {
padding: var(--spacing-sm) var(--spacing-lg);
background: white;
color: var(--primary);
border: none;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
transition: var(--transition);
}
.search-form button:hover {
background: #f0fdf4;
}
.search-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.fetch-jobs-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.fetch-job {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.fetch-job-status {
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.fetch-job-status.completed { background: #dcfce7; color: #166534; }
.fetch-job-status.failed { background: #fee2e2; color: #991b1b; }
.fetch-job-status.running { background: #dbeafe; color: #1e40af; }
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin-bottom: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.pending-news-item {
flex-direction: column;
}
.news-actions {
width: 100%;
justify-content: flex-end;
}
.search-form {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div>
<h1>Zielony Okręg Przemysłowy Kaszubia</h1>
<p class="text-muted">Panel zarządzania bazą wiedzy</p>
</div>
<div class="admin-actions">
<a href="{{ url_for('zopk_index') }}" class="btn btn-secondary" target="_blank">Zobacz stronę publiczną</a>
<a href="{{ url_for('admin_zopk_news') }}" class="btn btn-secondary">Zarządzaj newsami</a>
<button class="btn btn-primary" onclick="openAddNewsModal()">+ Dodaj news</button>
</div>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_projects }}</div>
<div class="stat-label">Projektów</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.total_stakeholders }}</div>
<div class="stat-label">Interesariuszy</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ stats.pending_news }}</div>
<div class="stat-label">Oczekujących newsów</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ stats.approved_news }}</div>
<div class="stat-label">Zatwierdzonych newsów</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.total_resources }}</div>
<div class="stat-label">Materiałów</div>
</div>
</div>
<!-- Search Section -->
<div class="search-section">
<h3>Wyszukaj nowe artykuły</h3>
<p style="opacity: 0.9; margin-bottom: var(--spacing-md); font-size: var(--font-size-sm);">Użyj Brave Search API do automatycznego wyszukania nowych artykułów o ZOPK</p>
<div class="search-form">
<input type="text" id="searchQuery" value="Zielony Okręg Przemysłowy Kaszubia" placeholder="Wpisz zapytanie...">
<button type="button" onclick="searchNews()" id="searchBtn">Szukaj artykułów</button>
</div>
<div id="searchResult" style="margin-top: var(--spacing-md); display: none;"></div>
</div>
<!-- Pending News -->
<div class="panel-section">
<h2>Newsy oczekujące na moderację ({{ stats.pending_news }})</h2>
{% if pending_news %}
<div class="pending-news-list">
{% for news in pending_news %}
<div class="pending-news-item" id="news-{{ news.id }}">
<div class="news-info">
<h4><a href="{{ news.url }}" target="_blank" rel="noopener">{{ news.title }}</a></h4>
<div class="news-meta">
<span>{{ news.source_name or news.source_domain }}</span>
<span>{{ news.created_at.strftime('%d.%m.%Y %H:%M') if news.created_at else '-' }}</span>
<span>{{ news.source_type }}</span>
</div>
</div>
<div class="news-actions">
<button class="btn btn-sm btn-approve" onclick="approveNews({{ news.id }})">Zatwierdź</button>
<button class="btn btn-sm btn-reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Brak newsów oczekujących na moderację.</p>
</div>
{% endif %}
</div>
<!-- Projects -->
<div class="panel-section">
<h2>Projekty strategiczne</h2>
{% if projects %}
<table class="projects-table">
<thead>
<tr>
<th>Nazwa</th>
<th>Typ</th>
<th>Status</th>
<th>Region</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<strong>{{ project.name }}</strong>
<br><small class="text-muted">{{ project.slug }}</small>
</td>
<td>{{ project.project_type or '-' }}</td>
<td>
<span class="status-badge status-{{ project.status }}">
{% if project.status == 'planned' %}Planowany{% elif project.status == 'in_progress' %}W realizacji{% else %}{{ project.status }}{% endif %}
</span>
</td>
<td>{{ project.region or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak projektów.</p>
</div>
{% endif %}
</div>
<!-- Recent Fetch Jobs -->
{% if fetch_jobs %}
<div class="panel-section">
<h2>Ostatnie wyszukiwania</h2>
<div class="fetch-jobs-list">
{% for job in fetch_jobs %}
<div class="fetch-job">
<div>
<strong>{{ job.search_query }}</strong>
<br><small class="text-muted">{{ job.created_at.strftime('%d.%m.%Y %H:%M') if job.created_at else '-' }}</small>
</div>
<div>
Znaleziono: {{ job.results_found or 0 }} | Nowych: {{ job.results_new or 0 }}
</div>
<span class="fetch-job-status {{ job.status }}">{{ job.status }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Add News Modal -->
<div class="modal-overlay" id="addNewsModal">
<div class="modal">
<h3>Dodaj news ręcznie</h3>
<form id="addNewsForm">
<div class="form-group">
<label for="newsTitle">Tytuł *</label>
<input type="text" id="newsTitle" name="title" required>
</div>
<div class="form-group">
<label for="newsUrl">URL artykułu *</label>
<input type="url" id="newsUrl" name="url" required placeholder="https://...">
</div>
<div class="form-group">
<label for="newsDescription">Opis</label>
<textarea id="newsDescription" name="description" placeholder="Krótki opis artykułu..."></textarea>
</div>
<div class="form-group">
<label for="newsSource">Źródło</label>
<input type="text" id="newsSource" name="source_name" placeholder="np. trojmiasto.pl">
</div>
<div class="form-group">
<label for="newsProject">Projekt</label>
<select id="newsProject" name="project_id">
<option value="">-- Brak --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeAddNewsModal()">Anuluj</button>
<button type="submit" class="btn btn-primary">Dodaj</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
function openAddNewsModal() {
document.getElementById('addNewsModal').classList.add('active');
}
function closeAddNewsModal() {
document.getElementById('addNewsModal').classList.remove('active');
document.getElementById('addNewsForm').reset();
}
document.getElementById('addNewsForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
title: document.getElementById('newsTitle').value,
url: document.getElementById('newsUrl').value,
description: document.getElementById('newsDescription').value,
source_name: document.getElementById('newsSource').value,
project_id: document.getElementById('newsProject').value || null
};
try {
const response = await fetch('{{ url_for("admin_zopk_news_add") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.success) {
alert('News został dodany.');
closeAddNewsModal();
location.reload();
} else {
alert(data.error || 'Wystąpił błąd');
}
} catch (error) {
alert('Błąd połączenia: ' + error.message);
}
});
async function approveNews(newsId) {
if (!confirm('Czy na pewno chcesz zatwierdzić ten news?')) return;
try {
const response = await fetch(`/admin/zopk/news/${newsId}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
document.getElementById(`news-${newsId}`).remove();
} else {
alert(data.error || 'Wystąpił błąd');
}
} catch (error) {
alert('Błąd połączenia: ' + error.message);
}
}
async function rejectNews(newsId) {
const reason = prompt('Powód odrzucenia (opcjonalnie):');
if (reason === null) return; // User cancelled
try {
const response = await fetch(`/admin/zopk/news/${newsId}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ reason: reason })
});
const data = await response.json();
if (data.success) {
document.getElementById(`news-${newsId}`).remove();
} else {
alert(data.error || 'Wystąpił błąd');
}
} catch (error) {
alert('Błąd połączenia: ' + error.message);
}
}
async function searchNews() {
const btn = document.getElementById('searchBtn');
const resultDiv = document.getElementById('searchResult');
const query = document.getElementById('searchQuery').value;
btn.disabled = true;
btn.textContent = 'Szukam...';
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<p>Trwa wyszukiwanie artykułów...</p>';
try {
const response = await fetch('{{ url_for("api_zopk_search_news") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ query: query })
});
const data = await response.json();
if (data.success) {
resultDiv.innerHTML = `<p style="color: #dcfce7;">${data.message}</p>`;
if (data.new > 0) {
setTimeout(() => location.reload(), 2000);
}
} else {
resultDiv.innerHTML = `<p style="color: #fca5a5;">Błąd: ${data.error}</p>`;
}
} catch (error) {
resultDiv.innerHTML = `<p style="color: #fca5a5;">Błąd połączenia: ${error.message}</p>`;
} finally {
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
}
// Close modal on click outside
document.getElementById('addNewsModal').addEventListener('click', function(e) {
if (e.target === this) {
closeAddNewsModal();
}
});
{% endblock %}