nordabiz/templates/admin/krs_audit_dashboard.html
Maciej Pienczyn de52e6991c feat: Add KRS Audit panel with full PDF parsing
- New admin panel /admin/krs-audit for KRS data extraction
- Full PDF parser extracting: company data, capital, shares, PKD codes,
  management board, shareholders, procurators, financial reports
- API endpoints for single/batch audits and PDF download
- Company profile shows "Odpis PDF" button and last audit date
- Database migration for krs_audits, company_pkd, company_financial_reports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:46:54 +01:00

870 lines
28 KiB
HTML

{% extends "base.html" %}
{% block title %}Panel Audyt KRS - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.data-source-info {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
margin-top: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--info-light, #e0f2fe);
border-radius: var(--radius);
font-size: var(--font-size-sm);
color: var(--info, #0284c7);
}
.data-source-info svg {
flex-shrink: 0;
}
.data-source-info a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
/* Summary Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
text-align: center;
}
.stat-number {
font-size: var(--font-size-2xl);
font-weight: 700;
display: block;
margin-bottom: var(--spacing-xs);
}
.stat-number.green { color: var(--success); }
.stat-number.yellow { color: var(--warning); }
.stat-number.red { color: var(--error); }
.stat-number.gray { color: var(--secondary); }
.stat-number.blue { color: var(--primary); }
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Progress Section */
.progress-section {
background: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-xl);
display: none;
}
.progress-section.active {
display: block;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.progress-title {
font-size: var(--font-size-lg);
font-weight: 600;
}
.progress-bar-container {
height: 24px;
background: var(--border);
border-radius: 12px;
overflow: hidden;
margin-bottom: var(--spacing-sm);
}
.progress-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 12px;
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: var(--font-size-sm);
}
.progress-message {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.progress-log {
max-height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: var(--font-size-sm);
background: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-top: var(--spacing-md);
}
.progress-log-entry {
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--border);
}
.progress-log-entry.success { color: var(--success); }
.progress-log-entry.error { color: var(--error); }
.progress-log-entry.skip { color: var(--warning); }
/* Filters */
.filters-bar {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
background: white;
padding: var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.filter-group label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-weight: 500;
}
.filter-group select,
.filter-group input {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-sm);
min-width: 150px;
}
/* Table Container */
.table-container {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.krs-table {
width: 100%;
border-collapse: collapse;
}
.krs-table th,
.krs-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.krs-table th {
background: var(--background);
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
white-space: nowrap;
}
.krs-table tbody tr:hover {
background: var(--background);
}
.company-name-cell {
font-weight: 500;
max-width: 250px;
}
.company-name-cell a {
color: var(--text-primary);
text-decoration: none;
}
.company-name-cell a:hover {
color: var(--primary);
}
.krs-number {
font-family: monospace;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: 500;
}
.status-badge.audited {
background: #dcfce7;
color: #166534;
}
.status-badge.pending {
background: #fef3c7;
color: #92400e;
}
.status-badge.error {
background: #fee2e2;
color: #991b1b;
}
/* Data cell */
.data-cell {
text-align: center;
font-size: var(--font-size-sm);
}
.data-value {
font-weight: 600;
color: var(--text-primary);
}
.data-label {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
}
.capital-value {
font-family: monospace;
white-space: nowrap;
}
/* Date cell */
.date-cell {
font-size: var(--font-size-sm);
color: var(--text-secondary);
white-space: nowrap;
}
.date-never {
color: var(--error);
font-style: italic;
}
/* Action buttons */
.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);
text-decoration: none;
color: var(--text-primary);
}
.btn-icon:hover {
background: var(--background);
border-color: var(--primary);
color: var(--primary);
}
.btn-icon.audit {
color: var(--success);
}
.btn-icon.audit:hover {
background: #dcfce7;
border-color: var(--success);
}
.btn-icon.pdf {
color: var(--error);
}
.btn-icon.pdf:hover {
background: #fee2e2;
border-color: var(--error);
}
.btn-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
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);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 500px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.modal-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.modal-icon.warning {
background: #fef3c7;
color: #d97706;
}
.modal-icon.success {
background: #dcfce7;
color: #16a34a;
}
.modal-title {
font-size: var(--font-size-xl);
font-weight: 600;
}
.modal-body {
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
/* Responsive */
@media (max-width: 1200px) {
.krs-table {
font-size: var(--font-size-sm);
}
.hide-mobile {
display: none;
}
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div>
<h1>Panel Audyt KRS</h1>
<p class="text-muted">Ekstrakcja danych z odpisow KRS (Krajowy Rejestr Sadowy)</p>
<div class="data-source-info">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Dane z <a href="https://ekrs.ms.gov.pl/" target="_blank" rel="noopener">eKRS (ekrs.ms.gov.pl)</a></span>
</div>
</div>
<div class="header-actions">
{% if krs_audit_available %}
<button class="btn btn-primary btn-sm" onclick="runBatchAudit()" id="batchAuditBtn">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Uruchom audyt wszystkich
</button>
{% else %}
<span class="text-muted">Usluga audytu niedostepna</span>
{% endif %}
</div>
</div>
<!-- Summary Stats -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number blue">{{ stats.total_with_krs }}</span>
<span class="stat-label">Firm z KRS</span>
</div>
<div class="stat-card">
<span class="stat-number green">{{ stats.audited_count }}</span>
<span class="stat-label">Przeaudytowane</span>
</div>
<div class="stat-card">
<span class="stat-number yellow">{{ stats.not_audited_count }}</span>
<span class="stat-label">Oczekujace</span>
</div>
<div class="stat-card">
<span class="stat-number gray">{{ stats.no_krs_count }}</span>
<span class="stat-label">Bez KRS (JDG)</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.with_capital }}</span>
<span class="stat-label">Z kapitalem</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.with_people }}</span>
<span class="stat-label">Z zarzadem</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.with_pkd }}</span>
<span class="stat-label">Z PKD</span>
</div>
</div>
<!-- Progress Section (hidden by default) -->
<div class="progress-section" id="progressSection">
<div class="progress-header">
<span class="progress-title">Audyt w toku...</span>
<button class="btn btn-sm btn-outline" onclick="cancelAudit()">Anuluj</button>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progressBar" style="width: 0%">0%</div>
</div>
<div class="progress-message" id="progressMessage">Przygotowywanie...</div>
<div class="progress-log" id="progressLog"></div>
</div>
<!-- Filters -->
<div class="filters-bar">
<div class="filter-group">
<label for="filterStatus">Status:</label>
<select id="filterStatus" onchange="applyFilters()">
<option value="">Wszystkie</option>
<option value="audited">Przeaudytowane</option>
<option value="pending">Oczekujace</option>
</select>
</div>
<div class="filter-group">
<label for="filterSearch">Szukaj:</label>
<input type="text" id="filterSearch" placeholder="Nazwa lub KRS..." oninput="applyFilters()">
</div>
<div class="filter-group" style="margin-left: auto;">
<button class="btn btn-sm btn-outline" onclick="resetFilters()">Resetuj filtry</button>
</div>
</div>
<!-- Table -->
{% if companies %}
<div class="table-container">
<table class="krs-table" id="krsTable">
<thead>
<tr>
<th>Firma</th>
<th>KRS</th>
<th class="hide-mobile">Kapital</th>
<th class="hide-mobile">Zarzad</th>
<th class="hide-mobile">PKD</th>
<th>Status</th>
<th>Ostatni audyt</th>
<th>Akcje</th>
</tr>
</thead>
<tbody id="krsTableBody">
{% for company in companies %}
<tr data-name="{{ company.name|lower }}"
data-krs="{{ company.krs }}"
data-status="{{ 'audited' if company.krs_last_audit_at else 'pending' }}">
<td class="company-name-cell">
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a>
</td>
<td>
<span class="krs-number">{{ company.krs }}</span>
</td>
<td class="data-cell hide-mobile">
{% if company.capital_amount %}
<span class="capital-value">{{ "{:,.0f}".format(company.capital_amount|float).replace(",", " ") }} PLN</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="data-cell hide-mobile">
{% if company.people_count > 0 %}
<span class="data-value">{{ company.people_count }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="data-cell hide-mobile">
{% if company.pkd_count > 0 %}
<span class="data-value">{{ company.pkd_count }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if company.krs_last_audit_at %}
<span class="status-badge audited">
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
OK
</span>
{% else %}
<span class="status-badge pending">
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
Oczekuje
</span>
{% endif %}
</td>
<td class="date-cell">
{% if company.krs_last_audit_at %}
<span title="{{ company.krs_last_audit_at.strftime('%Y-%m-%d %H:%M') }}">
{{ company.krs_last_audit_at.strftime('%d.%m.%Y') }}
</span>
{% else %}
<span class="date-never">Nigdy</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
<a href="{{ url_for('company_detail', company_id=company.id) }}" class="btn-icon" title="Zobacz profil">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</a>
{% if company.krs_pdf_path %}
<a href="{{ url_for('api_krs_pdf_download', company_id=company.id) }}" class="btn-icon pdf" title="Pobierz PDF" target="_blank">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
</a>
{% endif %}
{% if krs_audit_available %}
<button class="btn-icon audit" onclick="runSingleAudit({{ company.id }}, '{{ company.name }}')" title="Uruchom audyt">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<h3>Brak firm z KRS</h3>
<p>Nie znaleziono firm z numerem KRS do audytu.</p>
</div>
{% endif %}
<!-- Confirmation Modal -->
<div class="modal" id="confirmModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-icon warning">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="modal-title" id="modalTitle">Potwierdz operacje</div>
</div>
<div class="modal-body" id="modalBody">
Czy na pewno chcesz wykonac te operacje?
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal()">Anuluj</button>
<button class="btn btn-primary" onclick="confirmModalAction()">Potwierdz</button>
</div>
</div>
</div>
<!-- Result Modal -->
<div class="modal" id="resultModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-icon success" id="resultIcon">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div class="modal-title" id="resultTitle">Sukces</div>
</div>
<div class="modal-body" id="resultBody">
Operacja zakonczona pomyslnie.
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="closeResultModal()">OK</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
let pendingModalAction = null;
let auditInProgress = false;
// Modal functions
function showModal(title, body, onConfirm) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalBody').textContent = body;
pendingModalAction = onConfirm;
document.getElementById('confirmModal').classList.add('active');
}
function closeModal() {
document.getElementById('confirmModal').classList.remove('active');
pendingModalAction = null;
}
function confirmModalAction() {
if (pendingModalAction) {
pendingModalAction();
}
closeModal();
}
function showResultModal(title, body, success = true) {
document.getElementById('resultTitle').textContent = title;
document.getElementById('resultBody').textContent = body;
const icon = document.getElementById('resultIcon');
icon.className = 'modal-icon ' + (success ? 'success' : 'warning');
document.getElementById('resultModal').classList.add('active');
}
function closeResultModal() {
document.getElementById('resultModal').classList.remove('active');
location.reload();
}
// Close modal on backdrop click
document.getElementById('confirmModal')?.addEventListener('click', (e) => {
if (e.target.id === 'confirmModal') closeModal();
});
document.getElementById('resultModal')?.addEventListener('click', (e) => {
if (e.target.id === 'resultModal') closeResultModal();
});
// Filter functions
function applyFilters() {
const status = document.getElementById('filterStatus').value;
const search = document.getElementById('filterSearch').value.toLowerCase();
const rows = document.querySelectorAll('#krsTableBody tr');
rows.forEach(row => {
let show = true;
// Status filter
if (status && row.dataset.status !== status) {
show = false;
}
// Search filter
if (search && show) {
const name = row.dataset.name || '';
const krs = row.dataset.krs || '';
if (!name.includes(search) && !krs.includes(search)) {
show = false;
}
}
row.style.display = show ? '' : 'none';
});
}
function resetFilters() {
document.getElementById('filterStatus').value = '';
document.getElementById('filterSearch').value = '';
applyFilters();
}
// Audit functions
async function runSingleAudit(companyId, companyName) {
showModal(
'Uruchom audyt KRS',
`Czy chcesz uruchomic audyt KRS dla firmy "${companyName}"? Plik PDF musi byc dostepny w katalogu data/krs_pdfs/.`,
async () => {
try {
const response = await fetch('/api/krs/audit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ company_id: companyId })
});
const data = await response.json();
if (response.ok && data.success) {
showResultModal(
'Audyt zakonczon',
`Pomyslnie wyciagnieto dane dla ${companyName}. ` +
`Kapital: ${data.data?.kapital?.toLocaleString() || '-'} PLN, ` +
`Zarzad: ${data.data?.zarzad_count || 0} osob, ` +
`PKD: ${data.data?.pkd_count || 0}`,
true
);
} else {
showResultModal('Blad', data.error || 'Wystapil nieznany blad', false);
}
} catch (error) {
showResultModal('Blad polaczenia', 'Nie udalo sie polaczyc z serwerem: ' + error.message, false);
}
}
);
}
async function runBatchAudit() {
showModal(
'Uruchom audyt wszystkich firm',
'Czy chcesz uruchomic audyt KRS dla wszystkich firm? To moze potrwac kilka minut.',
async () => {
auditInProgress = true;
const btn = document.getElementById('batchAuditBtn');
btn.disabled = true;
btn.innerHTML = '<span>Audyt w toku...</span>';
const progressSection = document.getElementById('progressSection');
progressSection.classList.add('active');
const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage');
const progressLog = document.getElementById('progressLog');
progressBar.style.width = '5%';
progressBar.textContent = '5%';
progressMessage.textContent = 'Rozpoczynanie audytu...';
progressLog.innerHTML = '';
try {
const response = await fetch('/api/krs/audit/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (response.ok && data.success) {
progressBar.style.width = '100%';
progressBar.textContent = '100%';
progressMessage.textContent = data.message;
// Show details in log
if (data.results && data.results.details) {
data.results.details.forEach(item => {
const entry = document.createElement('div');
entry.className = 'progress-log-entry ' + item.status;
entry.textContent = `${item.company} (${item.krs}): ${item.status}${item.reason ? ' - ' + item.reason : ''}`;
progressLog.appendChild(entry);
});
}
showResultModal(
'Audyt zakonczony',
`Sukces: ${data.results?.success || 0}, Bledy: ${data.results?.failed || 0}, Pominiete: ${data.results?.skipped || 0}`,
true
);
} else {
showResultModal('Blad', data.error || 'Wystapil nieznany blad', false);
}
} catch (error) {
showResultModal('Blad polaczenia', 'Nie udalo sie polaczyc z serwerem: ' + error.message, false);
} finally {
auditInProgress = false;
btn.disabled = false;
btn.innerHTML = `
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Uruchom audyt wszystkich
`;
}
}
);
}
function cancelAudit() {
// Note: Currently can't cancel - just hide progress section
document.getElementById('progressSection').classList.remove('active');
}
{% endblock %}