nordabiz/templates/admin/recommendations.html
Maciej Pienczyn 110d971dca
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat: migrate prod docs to OVH VPS + UTC→Warsaw timezone in all templates
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS
(57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash
commands, memory files, architecture docs, and deploy procedures.

Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted
155 .strftime() calls across 71 templates so timestamps display
in Polish timezone regardless of server timezone.

Also includes: created_by_id tracking, abort import fix, ICS
calendar fix for missing end times, Pros Poland data cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:41:53 +02:00

678 lines
22 KiB
HTML
Executable File
Raw Permalink 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 %}Moderacja Rekomendacji - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
text-align: center;
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.filter-tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.filter-tab {
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-size-base);
font-weight: 500;
transition: var(--transition);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.filter-tab:hover {
color: var(--text-primary);
}
.filter-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.recommendations-table {
width: 100%;
border-collapse: collapse;
}
.recommendations-table th,
.recommendations-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.recommendations-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.recommendations-table tr:hover {
background: var(--background);
}
.recommendation-text {
max-width: 300px;
max-height: 60px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
font-size: var(--font-size-sm);
color: var(--text-primary);
line-height: 1.4;
}
.recommendation-meta {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.recommendation-company {
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
}
.recommendation-company:hover {
color: var(--primary);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
}
.badge-pending {
background: #FEF3C7;
color: #92400E;
}
.badge-approved {
background: #D1FAE5;
color: #065F46;
}
.badge-rejected {
background: #FEE2E2;
color: #991B1B;
}
.action-buttons {
display: flex;
gap: var(--spacing-xs);
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
transition: var(--transition);
}
.btn-icon:hover {
background: var(--background);
}
.btn-icon.approve {
background: #D1FAE5;
border-color: #10B981;
color: #065F46;
}
.btn-icon.approve:hover {
background: #10B981;
color: white;
}
.btn-icon.reject {
background: #FEE2E2;
border-color: #EF4444;
color: #991B1B;
}
.btn-icon.reject:hover {
background: #EF4444;
color: white;
}
.btn-icon.danger:hover {
background: var(--error);
border-color: var(--error);
color: white;
}
.btn-icon svg {
width: 16px;
height: 16px;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
/* Modal for rejection reason */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 500px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-header {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-md);
color: var(--text-primary);
}
.modal-body {
margin-bottom: var(--spacing-lg);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-label {
display: block;
margin-bottom: var(--spacing-xs);
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: 500;
}
.form-control {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
font-family: inherit;
resize: vertical;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
}
.btn {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background: var(--background);
color: var(--text-secondary);
}
.btn-secondary:hover {
background: var(--border);
}
@media (max-width: 768px) {
.recommendations-table {
font-size: var(--font-size-sm);
}
.recommendations-table th:nth-child(3),
.recommendations-table td:nth-child(3) {
display: none;
}
.recommendation-text {
max-width: 200px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<h1>⭐ Moderacja Rekomendacji</h1>
<p class="text-muted">Zarządzaj rekomendacjami firm członkowskich</p>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ total_recommendations }}</div>
<div class="stat-label">Wszystkich</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #F59E0B;">{{ pending_count }}</div>
<div class="stat-label">Oczekujących</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #10B981;">{{ approved_count }}</div>
<div class="stat-label">Zatwierdzonych</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #EF4444;">{{ rejected_count }}</div>
<div class="stat-label">Odrzuconych</div>
</div>
</div>
<!-- Recommendations Section -->
<div class="section">
<h2>Rekomendacje</h2>
<!-- Filter Tabs -->
<div class="filter-tabs">
<button class="filter-tab active" data-filter="all">Wszystkie</button>
<button class="filter-tab" data-filter="pending">Oczekujące ({{ pending_count }})</button>
<button class="filter-tab" data-filter="approved">Zatwierdzone ({{ approved_count }})</button>
<button class="filter-tab" data-filter="rejected">Odrzucone ({{ rejected_count }})</button>
</div>
{% if recommendations %}
<table class="recommendations-table">
<thead>
<tr>
<th>Firma</th>
<th>Rekomendujący</th>
<th>Treść</th>
<th>Data</th>
<th>Status</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for rec in recommendations %}
<tr data-recommendation-id="{{ rec.id }}" data-status="{{ rec.status }}">
<td>
<a href="{{ url_for('company_detail_by_slug', slug=rec.company.slug) }}"
class="recommendation-company"
target="_blank">
{{ rec.company.name }}
</a>
{% if rec.service_category %}
<div class="recommendation-meta">{{ rec.service_category }}</div>
{% endif %}
</td>
<td>
<div class="recommendation-meta">
{{ rec.user.name or rec.user.email.split('@')[0] }}
{% if rec.user.company %}
<br>{{ rec.user.company.name }}
{% endif %}
</div>
</td>
<td>
<div class="recommendation-text" title="{{ rec.recommendation_text }}">
{{ rec.recommendation_text }}
</div>
</td>
<td class="recommendation-meta">{{ rec.created_at|local_time('%d.%m.%Y') }}</td>
<td>
{% if rec.status == 'pending' %}
<span class="badge badge-pending">Oczekuje</span>
{% elif rec.status == 'approved' %}
<span class="badge badge-approved">Zatwierdzona</span>
{% elif rec.status == 'rejected' %}
<span class="badge badge-rejected">Odrzucona</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
{% if rec.status == 'pending' %}
<button class="btn-icon approve"
onclick="approveRecommendation({{ rec.id }})"
title="Zatwierdź">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</button>
<button class="btn-icon reject"
onclick="openRejectModal({{ rec.id }}, '{{ rec.company.name|e }}')"
title="Odrzuć">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
{% elif rec.status == 'rejected' %}
<button class="btn-icon approve"
onclick="approveRecommendation({{ rec.id }})"
title="Zatwierdź">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</button>
{% endif %}
<button class="btn-icon danger"
onclick="deleteRecommendation({{ rec.id }}, '{{ rec.company.name|e }}')"
title="Usuń">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak rekomendacji</p>
</div>
{% endif %}
</div>
<!-- Rejection Modal -->
<div id="rejectModal" class="modal">
<div class="modal-content">
<div class="modal-header">Odrzuć rekomendację</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Powód odrzucenia (opcjonalnie)</label>
<textarea id="rejectionReason"
class="form-control"
rows="4"
placeholder="Wprowadź powód odrzucenia..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeRejectModal()">Anuluj</button>
<button class="btn btn-primary" onclick="confirmReject()">Odrzuć</button>
</div>
</div>
</div>
<!-- Universal Confirm Modal -->
<div id="confirmModal" class="modal">
<div class="modal-content" style="max-width: 420px;">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon" style="font-size: 3em; margin-bottom: var(--spacing-md);"></div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p class="modal-description" id="confirmModalMessage" style="color: var(--text-secondary);"></p>
</div>
<div class="modal-footer" style="justify-content: center;">
<button type="button" class="btn btn-secondary" id="confirmModalCancel">Anuluj</button>
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</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>
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: toastIn 0.3s ease; }
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); }
.toast.warning { border-left-color: var(--warning); }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
</style>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
let currentRecommendationId = null;
let confirmResolve = null;
function showConfirm(message, options = {}) {
return new Promise(resolve => {
confirmResolve = resolve;
document.getElementById('confirmModalIcon').textContent = options.icon || '❓';
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
document.getElementById('confirmModalMessage').innerHTML = message;
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
document.getElementById('confirmModal').classList.add('active');
});
}
function closeConfirm(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmResolve) { confirmResolve(result); confirmResolve = null; }
}
document.getElementById('confirmModalOk').addEventListener('click', () => closeConfirm(true));
document.getElementById('confirmModalCancel').addEventListener('click', () => closeConfirm(false));
document.getElementById('confirmModal').addEventListener('click', e => { if (e.target.id === 'confirmModal') closeConfirm(false); });
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `<span style="font-size:1.2em">${icons[type]||''}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
}
function showMessage(message, type) {
showToast(message, type === 'error' ? 'error' : 'success');
}
// Filter tabs functionality
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.addEventListener('click', function() {
// Update active tab
document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active'));
this.classList.add('active');
// Filter rows
const filter = this.dataset.filter;
document.querySelectorAll('[data-recommendation-id]').forEach(row => {
if (filter === 'all' || row.dataset.status === filter) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
});
});
async function approveRecommendation(recommendationId) {
try {
const response = await fetch(`/admin/recommendations/${recommendationId}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
function openRejectModal(recommendationId, companyName) {
currentRecommendationId = recommendationId;
document.getElementById('rejectionReason').value = '';
document.getElementById('rejectModal').classList.add('active');
}
function closeRejectModal() {
currentRecommendationId = null;
document.getElementById('rejectModal').classList.remove('active');
}
async function confirmReject() {
if (!currentRecommendationId) return;
const reason = document.getElementById('rejectionReason').value.trim();
try {
const response = await fetch(`/admin/recommendations/${currentRecommendationId}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ reason: reason })
});
const data = await response.json();
if (data.success) {
closeRejectModal();
location.reload();
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
async function deleteRecommendation(recommendationId, companyName) {
const confirmed = await showConfirm(`Czy na pewno chcesz usunąć rekomendację dla firmy "<strong>${companyName}</strong>"?<br><br><small>Ta operacja jest nieodwracalna.</small>`, {
icon: '🗑️',
title: 'Usuwanie rekomendacji',
okText: 'Usuń',
okClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch(`/api/recommendations/${recommendationId}/delete`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
document.querySelector(`tr[data-recommendation-id="${recommendationId}"]`).remove();
showToast('Rekomendacja została usunięta', 'success');
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
// Close modal on background click
document.getElementById('rejectModal').addEventListener('click', function(e) {
if (e.target === this) {
closeRejectModal();
}
});
{% endblock %}