nordabiz/templates/admin/zopk_knowledge_duplicates.html
Maciej Pienczyn 094379d95e
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
fix(templates): Add blueprint prefix to url_for calls across admin templates
After refactoring to blueprints, templates still used bare endpoint names
(e.g., url_for('admin_zopk')) instead of prefixed names (e.g.,
url_for('admin.admin_zopk')). While most worked via backward-compat aliases,
api_zopk_search_news was missing from the alias list causing 500 on /admin/zopk.

Fixed 19 template files and added missing alias for api_zopk_search_news.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:44:50 +01:00

702 lines
21 KiB
HTML
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 %}Duplikaty Encji - ZOPK Baza Wiedzy{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.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;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.filters-bar {
display: flex;
gap: var(--spacing-md);
align-items: center;
margin-bottom: var(--spacing-xl);
padding: var(--spacing-md);
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.filter-group label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.filter-group select,
.filter-group input {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.duplicates-list {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.duplicate-card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.duplicate-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md) var(--spacing-lg);
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-bottom: 1px solid #fbbf24;
}
.duplicate-type {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-weight: 600;
color: #92400e;
}
.similarity-badge {
padding: 4px 10px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 600;
}
.similarity-high {
background: #dcfce7;
color: #166534;
}
.similarity-medium {
background: #fef3c7;
color: #92400e;
}
.similarity-low {
background: #fee2e2;
color: #991b1b;
}
.duplicate-body {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
}
.entity-card {
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
border: 2px solid transparent;
cursor: pointer;
transition: var(--transition);
}
.entity-card:hover {
border-color: var(--primary);
}
.entity-card.selected {
border-color: var(--primary);
background: #f0fdf4;
}
.entity-card.selected-duplicate {
border-color: #ef4444;
background: #fee2e2;
}
.entity-name {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.entity-meta {
display: flex;
gap: var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.entity-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.merge-arrow {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
}
.merge-arrow svg {
width: 32px;
height: 32px;
color: var(--primary);
}
.merge-arrow span {
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.duplicate-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
border-top: 1px solid var(--border);
background: var(--background);
}
.btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 8px 16px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
text-decoration: none;
border: none;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-secondary {
background: var(--background);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--surface);
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
.empty-state svg {
width: 64px;
height: 64px;
color: var(--text-muted);
margin-bottom: var(--spacing-md);
}
/* 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;
justify-content: center;
align-items: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--surface);
border-radius: var(--radius-lg);
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: var(--font-size-xl);
}
.modal-body {
padding: var(--spacing-lg);
}
.modal-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
}
.preview-section {
margin-bottom: var(--spacing-lg);
}
.preview-section h4 {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.preview-entities {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: var(--spacing-md);
align-items: center;
margin-bottom: var(--spacing-lg);
}
.preview-entity {
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
}
.preview-entity.keep {
border: 2px solid var(--primary);
}
.preview-entity.delete {
border: 2px solid #ef4444;
opacity: 0.7;
}
.preview-stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--spacing-sm);
}
.preview-stat {
padding: var(--spacing-sm);
background: var(--background);
border-radius: var(--radius-sm);
text-align: center;
}
.preview-stat-value {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--primary);
}
.preview-stat-label {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.name-input {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
margin-top: var(--spacing-xs);
}
@media (max-width: 768px) {
.duplicate-body {
grid-template-columns: 1fr;
}
.merge-arrow {
transform: rotate(90deg);
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="breadcrumb">
<a href="{{ url_for('admin.admin_zopk') }}">Panel ZOPK</a>
<span></span>
<a href="{{ url_for('admin.admin_zopk_knowledge_dashboard') }}">Baza Wiedzy</a>
<span></span>
<span>Duplikaty Encji</span>
</div>
<div class="page-header">
<h1>🔀 Duplikaty Encji</h1>
</div>
<div class="filters-bar">
<form method="get" style="display: contents;">
<div class="filter-group">
<label for="entity_type">Typ encji:</label>
<select name="entity_type" id="entity_type" onchange="this.form.submit()">
<option value="">Wszystkie</option>
{% for etype in entity_types %}
<option value="{{ etype }}" {% if etype == selected_type %}selected{% endif %}>{{ etype }}</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="min_similarity">Min. podobieństwo:</label>
<input type="range" name="min_similarity" id="min_similarity"
min="0.3" max="0.9" step="0.1"
value="{{ min_similarity }}"
onchange="document.getElementById('sim_value').textContent = this.value; this.form.submit()">
<span id="sim_value">{{ min_similarity }}</span>
</div>
</form>
<div style="margin-left: auto;">
Znaleziono: <strong>{{ duplicates|length }}</strong> par
</div>
</div>
{% if duplicates %}
<div class="duplicates-list">
{% for dup in duplicates %}
<div class="duplicate-card" data-pair-id="{{ loop.index }}">
<div class="duplicate-header">
<div class="duplicate-type">
<span>{{ dup.entity1.entity_type }}</span>
</div>
<span class="similarity-badge {% if dup.similarity > 0.8 %}similarity-high{% elif dup.similarity > 0.6 %}similarity-medium{% else %}similarity-low{% endif %}">
{{ (dup.similarity * 100)|round|int }}% podobieństwo
{% if dup.match_type == 'substring' %}(substring){% endif %}
</span>
</div>
<div class="duplicate-body">
<div class="entity-card"
onclick="selectEntity(this, {{ dup.entity1.id }}, 'primary')"
data-id="{{ dup.entity1.id }}"
data-name="{{ dup.entity1.name }}">
<div class="entity-name">{{ dup.entity1.name }}</div>
<div class="entity-meta">
<span>📊 {{ dup.entity1.mentions_count }} wzmianek</span>
{% if dup.entity1.is_verified %}
<span>✅ Zweryfikowano</span>
{% endif %}
</div>
</div>
<div class="merge-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
<span>połącz</span>
</div>
<div class="entity-card"
onclick="selectEntity(this, {{ dup.entity2.id }}, 'duplicate')"
data-id="{{ dup.entity2.id }}"
data-name="{{ dup.entity2.name }}">
<div class="entity-name">{{ dup.entity2.name }}</div>
<div class="entity-meta">
<span>📊 {{ dup.entity2.mentions_count }} wzmianek</span>
{% if dup.entity2.is_verified %}
<span>✅ Zweryfikowano</span>
{% endif %}
</div>
</div>
</div>
<div class="duplicate-actions">
<button class="btn btn-secondary" onclick="skipPair({{ loop.index }})">
⏭️ Pomiń
</button>
<button class="btn btn-primary" onclick="openMergeModal({{ loop.index }}, {{ dup.entity1.id }}, {{ dup.entity2.id }})">
🔀 Połącz encje
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M16 16s-1.5-2-4-2-4 2-4 2"></path>
<line x1="9" y1="9" x2="9.01" y2="9"></line>
<line x1="15" y1="9" x2="15.01" y2="9"></line>
</svg>
<h3>Brak duplikatów do wyświetlenia</h3>
<p>Spróbuj zmniejszyć próg podobieństwa lub wybierz inny typ encji.</p>
</div>
{% endif %}
</div>
<!-- Merge Preview Modal -->
<div class="modal-overlay" id="mergeModal">
<div class="modal">
<div class="modal-header">
<h2>🔀 Podgląd połączenia encji</h2>
</div>
<div class="modal-body" id="mergePreviewContent">
<p>Ładowanie...</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeMergeModal()">Anuluj</button>
<button class="btn btn-danger" id="confirmMergeBtn" onclick="confirmMerge()">
🔀 Połącz encje
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
let currentPrimaryId = null;
let currentDuplicateId = null;
let currentNewName = null;
function selectEntity(element, id, role) {
const card = element.closest('.duplicate-card');
const entities = card.querySelectorAll('.entity-card');
// Reset selection
entities.forEach(e => {
e.classList.remove('selected', 'selected-duplicate');
});
// If primary clicked, mark it and mark other as duplicate
if (role === 'primary') {
element.classList.add('selected');
entities.forEach(e => {
if (e !== element) e.classList.add('selected-duplicate');
});
}
}
function skipPair(pairId) {
const card = document.querySelector(`[data-pair-id="${pairId}"]`);
card.style.opacity = '0.3';
card.style.pointerEvents = 'none';
}
function openMergeModal(pairId, id1, id2) {
const card = document.querySelector(`[data-pair-id="${pairId}"]`);
const entities = card.querySelectorAll('.entity-card');
// Get selected primary
let primaryId = id1;
let duplicateId = id2;
entities.forEach(e => {
if (e.classList.contains('selected')) {
primaryId = parseInt(e.dataset.id);
}
if (e.classList.contains('selected-duplicate')) {
duplicateId = parseInt(e.dataset.id);
}
});
// If nothing selected, use the one with more mentions
if (!card.querySelector('.selected')) {
const e1 = entities[0];
const e2 = entities[1];
e1.classList.add('selected');
e2.classList.add('selected-duplicate');
}
currentPrimaryId = primaryId;
currentDuplicateId = duplicateId;
// Show modal and fetch preview
document.getElementById('mergeModal').classList.add('active');
fetchMergePreview(primaryId, duplicateId);
}
function closeMergeModal() {
document.getElementById('mergeModal').classList.remove('active');
currentPrimaryId = null;
currentDuplicateId = null;
}
async function fetchMergePreview(primaryId, duplicateId) {
const content = document.getElementById('mergePreviewContent');
content.innerHTML = '<p>Ładowanie podglądu...</p>';
try {
const response = await fetch('/api/zopk/knowledge/duplicates/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
primary_id: primaryId,
duplicate_id: duplicateId
})
});
const data = await response.json();
if (data.success) {
const p = data.preview;
currentNewName = p.primary.name;
content.innerHTML = `
<div class="preview-section">
<h4>Encje do połączenia</h4>
<div class="preview-entities">
<div class="preview-entity keep">
<strong>✅ Zachowaj</strong>
<div class="entity-name">${p.primary.name}</div>
<div class="entity-meta">
<span>📊 ${p.primary.mentions_count} wzmianek</span>
</div>
</div>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
<div class="preview-entity delete">
<strong>🗑️ Usuń</strong>
<div class="entity-name">${p.duplicate.name}</div>
<div class="entity-meta">
<span>📊 ${p.duplicate.mentions_count} wzmianek</span>
</div>
</div>
</div>
</div>
<div class="preview-section">
<h4>Co zostanie przeniesione</h4>
<div class="preview-stats">
<div class="preview-stat">
<div class="preview-stat-value">${p.transfers.mentions}</div>
<div class="preview-stat-label">Wzmianki</div>
</div>
<div class="preview-stat">
<div class="preview-stat-value">${p.transfers.facts || 0}</div>
<div class="preview-stat-label">Fakty</div>
</div>
<div class="preview-stat">
<div class="preview-stat-value">${p.transfers.relations_source + p.transfers.relations_target}</div>
<div class="preview-stat-label">Relacje</div>
</div>
<div class="preview-stat">
<div class="preview-stat-value">${p.result.new_mentions_count}</div>
<div class="preview-stat-label">Wynik wzmianek</div>
</div>
</div>
</div>
<div class="preview-section">
<h4>Nowa nazwa encji (opcjonalnie)</h4>
<input type="text" class="name-input" id="newNameInput"
value="${p.primary.name}"
placeholder="Pozostaw pustą aby zachować obecną nazwę">
</div>
`;
} else {
content.innerHTML = `<p style="color: #ef4444;">Błąd: ${data.error}</p>`;
}
} catch (error) {
content.innerHTML = `<p style="color: #ef4444;">Błąd połączenia: ${error.message}</p>`;
}
}
async function confirmMerge() {
const btn = document.getElementById('confirmMergeBtn');
btn.disabled = true;
btn.textContent = 'Łączenie...';
const newName = document.getElementById('newNameInput')?.value || null;
try {
const response = await fetch('/api/zopk/knowledge/duplicates/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
primary_id: currentPrimaryId,
duplicate_id: currentDuplicateId,
new_name: newName !== currentNewName ? newName : null
})
});
const data = await response.json();
if (data.success) {
alert(`✅ Encje połączone!\n\nPrzeniesiono:\n- ${data.transfers.mentions} wzmianek\n- ${data.transfers.facts || 0} faktów\n- ${data.transfers.relations_source + data.transfers.relations_target} relacji`);
closeMergeModal();
window.location.reload();
} else {
alert(`❌ Błąd: ${data.error}`);
btn.disabled = false;
btn.textContent = '🔀 Połącz encje';
}
} catch (error) {
alert(`❌ Błąd połączenia: ${error.message}`);
btn.disabled = false;
btn.textContent = '🔀 Połącz encje';
}
}
{% endblock %}