feat: AI usage user details + styled modals across app

- Add /admin/ai-usage/user/<id> route for detailed AI usage per user
- Add ai_usage_user.html template with stats, usage breakdown, logs
- Make user names clickable in AI usage dashboard ranking
- Replace all native browser dialogs (alert, confirm) with styled modals/toasts:
  - admin/fees.html, forum.html, recommendations.html, announcements.html, debug.html
  - calendar/admin.html, event.html
  - company_detail.html, company/recommend.html
  - forum/new_topic.html, topic.html
  - classifieds/view.html
  - auth/reset_password.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-11 10:30:35 +01:00
parent 421d7c8e89
commit 6e00291a88
16 changed files with 1421 additions and 136 deletions

115
app.py
View File

@ -5953,6 +5953,121 @@ def admin_ai_usage():
db.close()
@app.route('/admin/ai-usage/user/<int:user_id>')
@login_required
def admin_ai_usage_user(user_id):
"""Detailed AI usage for a specific user"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
from database import AIUsageLog, User, Company
from sqlalchemy import func, desc
db = SessionLocal()
try:
# Get user info
user = db.query(User).filter_by(id=user_id).first()
if not user:
flash('Użytkownik nie istnieje.', 'error')
return redirect(url_for('admin_ai_usage'))
company = None
if user.company_id:
company = db.query(Company).filter_by(id=user.company_id).first()
# Get overall stats for this user
stats = db.query(
func.count(AIUsageLog.id).label('total_requests'),
func.coalesce(func.sum(AIUsageLog.tokens_input), 0).label('tokens_input'),
func.coalesce(func.sum(AIUsageLog.tokens_output), 0).label('tokens_output'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents'),
func.count(func.nullif(AIUsageLog.success, True)).label('errors')
).filter(AIUsageLog.user_id == user_id).first()
# Usage by type
type_labels = {
'ai_chat': 'Chat AI',
'zopk_news_evaluation': 'Ocena newsów ZOPK',
'ai_user_parse': 'Tworzenie user',
'gbp_audit_ai': 'Audyt GBP',
'general': 'Ogólne'
}
type_stats = db.query(
AIUsageLog.request_type,
func.count(AIUsageLog.id).label('count'),
func.coalesce(func.sum(AIUsageLog.tokens_input + AIUsageLog.tokens_output), 0).label('tokens'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).filter(
AIUsageLog.user_id == user_id
).group_by(AIUsageLog.request_type).order_by(desc('count')).all()
# Calculate total for percentages
total_type_count = sum(t.count for t in type_stats) if type_stats else 1
type_classes = {
'ai_chat': 'chat',
'zopk_news_evaluation': 'news_evaluation',
'ai_user_parse': 'user_creation',
'gbp_audit_ai': 'image_analysis',
'general': 'other'
}
usage_by_type = []
for t in type_stats:
usage_by_type.append({
'type': t.request_type,
'type_label': type_labels.get(t.request_type, t.request_type),
'type_class': type_classes.get(t.request_type, 'other'),
'count': t.count,
'tokens': int(t.tokens),
'cost_usd': float(t.cost_cents) / 100,
'percentage': round(t.count / total_type_count * 100, 1) if total_type_count > 0 else 0
})
# Get all requests for this user (paginated)
page = request.args.get('page', 1, type=int)
per_page = 50
requests_query = db.query(AIUsageLog).filter(
AIUsageLog.user_id == user_id
).order_by(desc(AIUsageLog.created_at))
total_requests = requests_query.count()
total_pages = (total_requests + per_page - 1) // per_page
logs = requests_query.offset((page - 1) * per_page).limit(per_page).all()
# Enrich logs with type labels and cost
for log in logs:
log.type_label = type_labels.get(log.request_type, log.request_type)
log.cost_usd = float(log.cost_cents or 0) / 100
user_stats = {
'total_requests': stats.total_requests or 0,
'tokens_total': int(stats.tokens_input or 0) + int(stats.tokens_output or 0),
'tokens_input': int(stats.tokens_input or 0),
'tokens_output': int(stats.tokens_output or 0),
'cost_usd': float(stats.cost_cents or 0) / 100,
'errors': stats.errors or 0
}
return render_template(
'admin/ai_usage_user.html',
user=user,
company=company,
stats=user_stats,
usage_by_type=usage_by_type,
logs=logs,
page=page,
total_pages=total_pages,
total_requests=total_requests
)
finally:
db.close()
@app.route('/api/admin/chat-stats')
@login_required
def api_chat_stats():

View File

@ -240,6 +240,23 @@
color: var(--text-secondary);
}
.user-info-link {
text-decoration: none;
display: block;
padding: var(--spacing-xs);
margin: calc(-1 * var(--spacing-xs));
border-radius: var(--radius-sm);
transition: var(--transition);
}
.user-info-link:hover {
background: var(--primary-light, #eff6ff);
}
.user-info-link:hover .user-name {
color: var(--primary);
}
.cost-badge {
display: inline-block;
padding: 2px 8px;
@ -507,10 +524,12 @@
{{ loop.index }}
</td>
<td>
<a href="{{ url_for('admin_ai_usage_user', user_id=user.id) }}" class="user-info-link">
<div class="user-info">
<span class="user-name">{{ user.name }}</span>
<span class="user-company">{{ user.company }}</span>
</div>
</a>
</td>
<td>{{ user.requests }}</td>
<td>{{ "{:,}".format(user.tokens) }}</td>

View File

@ -0,0 +1,549 @@
{% extends "base.html" %}
{% block title %}AI - {{ user.name or user.email }} - Panel Admina{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-xl);
}
.page-header h1 {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-xs);
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.breadcrumb a {
color: var(--primary);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.user-card {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
display: flex;
align-items: center;
gap: var(--spacing-xl);
}
.user-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-2xl);
font-weight: 700;
flex-shrink: 0;
}
.user-details {
flex: 1;
}
.user-name-large {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.user-email {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-xs);
}
.user-company-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: #f0f9ff;
border: 1px solid #bae6fd;
color: #0369a1;
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 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-card.highlight { border-left: 4px solid var(--primary); }
.stat-card.success { border-left: 4px solid var(--success); }
.stat-card.warning { border-left: 4px solid var(--warning); }
.stat-card.error { border-left: 4px solid var(--error); }
.stat-card.info { border-left: 4px solid #6366f1; }
.stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-primary);
}
.stat-value.primary { color: var(--primary); }
.stat-value.success { color: var(--success); }
.stat-value.warning { color: var(--warning); }
.stat-value.error { color: var(--error); }
.stat-value.info { color: #6366f1; }
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.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);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.section h2 .icon {
font-size: var(--font-size-2xl);
}
/* Usage breakdown */
.usage-breakdown {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.usage-row {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.usage-label {
width: 140px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.usage-bar-container {
flex: 1;
height: 24px;
background: var(--background);
border-radius: var(--radius);
overflow: hidden;
}
.usage-bar {
height: 100%;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: var(--spacing-sm);
font-size: var(--font-size-xs);
font-weight: 600;
color: white;
min-width: 40px;
transition: width 0.3s ease;
}
.usage-bar.chat { background: #3b82f6; }
.usage-bar.news_evaluation { background: #10b981; }
.usage-bar.user_creation { background: #f59e0b; }
.usage-bar.image_analysis { background: #8b5cf6; }
.usage-bar.other { background: #6b7280; }
.usage-count {
width: 80px;
text-align: right;
font-weight: 600;
font-size: var(--font-size-sm);
}
.usage-cost {
width: 80px;
text-align: right;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* Logs table */
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table th,
.logs-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.logs-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.logs-table tr:hover {
background: var(--background);
}
.log-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.log-type-badge.chat { background: #dbeafe; color: #1d4ed8; }
.log-type-badge.news_evaluation { background: #d1fae5; color: #065f46; }
.log-type-badge.user_creation { background: #fef3c7; color: #92400e; }
.log-type-badge.image_analysis { background: #ede9fe; color: #5b21b6; }
.log-type-badge.other { background: #f3f4f6; color: #374151; }
.log-tokens {
font-family: monospace;
font-size: var(--font-size-sm);
}
.log-cost {
font-weight: 500;
}
.log-time {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.log-status-success {
color: var(--success);
}
.log-status-error {
color: var(--error);
}
.log-error-msg {
font-size: var(--font-size-xs);
color: var(--error);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-xl);
}
.pagination a,
.pagination span {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-size: var(--font-size-sm);
}
.pagination a {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-secondary);
}
.pagination a:hover {
background: var(--background);
color: var(--text-primary);
}
.pagination .current {
background: var(--primary);
color: white;
border: 1px solid var(--primary);
}
.pagination .disabled {
color: var(--text-muted);
cursor: not-allowed;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
.empty-state .icon {
font-size: 48px;
margin-bottom: var(--spacing-md);
}
/* Sections grid */
.sections-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
}
@media (max-width: 768px) {
.user-card {
flex-direction: column;
text-align: center;
}
.sections-grid {
grid-template-columns: 1fr;
}
.usage-label {
width: 100px;
}
.logs-table {
font-size: var(--font-size-sm);
}
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div>
<div class="breadcrumb">
<a href="{{ url_for('admin_ai_usage') }}">Monitoring AI</a>
<span>/</span>
<span>Szczegoly uzytkownika</span>
</div>
<h1>Uzycie AI - {{ user.name or user.email.split('@')[0] }}</h1>
</div>
<a href="{{ url_for('admin_ai_usage') }}" class="btn btn-secondary">Powrot</a>
</div>
<!-- User Card -->
<div class="user-card">
<div class="user-avatar">
{{ (user.name or user.email)[0].upper() }}
</div>
<div class="user-details">
<div class="user-name-large">{{ user.name or 'Brak nazwy' }}</div>
<div class="user-email">{{ user.email }}</div>
{% if company %}
<span class="user-company-badge">
<span>{{ company.name }}</span>
</span>
{% endif %}
</div>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card highlight">
<div class="stat-value primary">{{ stats.total_requests }}</div>
<div class="stat-label">Zapytan</div>
</div>
<div class="stat-card info">
<div class="stat-value info">{{ "{:,}".format(stats.tokens_input) }}</div>
<div class="stat-label">Tokenow input</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ "{:,}".format(stats.tokens_output) }}</div>
<div class="stat-label">Tokenow output</div>
</div>
<div class="stat-card success">
<div class="stat-value success">${{ "%.4f"|format(stats.cost_usd) }}</div>
<div class="stat-label">Calkowity koszt</div>
</div>
<div class="stat-card {% if stats.errors > 0 %}error{% else %}success{% endif %}">
<div class="stat-value {% if stats.errors > 0 %}error{% else %}success{% endif %}">{{ stats.errors }}</div>
<div class="stat-label">Bledow</div>
</div>
</div>
<!-- Usage by Type -->
<div class="sections-grid">
<div class="section">
<h2><span class="icon">📊</span> Wykorzystanie wg typu</h2>
{% if usage_by_type %}
<div class="usage-breakdown">
{% for item in usage_by_type %}
<div class="usage-row">
<div class="usage-label">{{ item.type_label }}</div>
<div class="usage-bar-container">
<div class="usage-bar {{ item.type_class }}" style="width: {{ item.percentage }}%">
{{ item.percentage }}%
</div>
</div>
<div class="usage-count">{{ item.count }}</div>
<div class="usage-cost">${{ "%.4f"|format(item.cost_usd) }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="icon">📭</div>
<p>Brak danych</p>
</div>
{% endif %}
</div>
<div class="section">
<h2><span class="icon">💡</span> Podsumowanie</h2>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">
<p style="margin-bottom: var(--spacing-md);">
<strong>{{ user.name or user.email.split('@')[0] }}</strong> wykonal/a <strong>{{ stats.total_requests }}</strong> zapytan do AI,
wykorzystujac lacznie <strong>{{ "{:,}".format(stats.tokens_input + stats.tokens_output) }}</strong> tokenow.
</p>
{% if usage_by_type %}
<p style="margin-bottom: var(--spacing-md);">
Najczesciej uzywana funkcja: <strong>{{ usage_by_type[0].type_label }}</strong> ({{ usage_by_type[0].count }} zapytan).
</p>
{% endif %}
<p>
Calkowity koszt uzycia AI: <strong>${{ "%.4f"|format(stats.cost_usd) }}</strong>
</p>
</div>
</div>
</div>
<!-- Logs Table -->
<div class="section">
<h2>
<span class="icon">📜</span> Historia zapytan
<span style="font-size: var(--font-size-sm); font-weight: 400; margin-left: auto; color: var(--text-secondary);">
{{ total_requests }} zapytan lacznie
</span>
</h2>
{% if logs %}
<table class="logs-table">
<thead>
<tr>
<th>Typ</th>
<th>Model</th>
<th>Tokeny (in/out)</th>
<th>Koszt</th>
<th>Status</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>
<span class="log-type-badge {{ log.request_type }}">{{ log.type_label }}</span>
</td>
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);">
{{ log.model or '-' }}
</td>
<td class="log-tokens">
{{ log.tokens_input or 0 }} / {{ log.tokens_output or 0 }}
</td>
<td class="log-cost">
${{ "%.5f"|format(log.cost_usd) }}
</td>
<td>
{% if log.success %}
<span class="log-status-success">OK</span>
{% else %}
<span class="log-status-error">Blad</span>
{% if log.error_message %}
<div class="log-error-msg" title="{{ log.error_message }}">{{ log.error_message[:50] }}...</div>
{% endif %}
{% endif %}
</td>
<td class="log-time">
{{ log.created_at.strftime('%d.%m.%Y %H:%M') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('admin_ai_usage_user', user_id=user.id, page=page-1) }}">Poprzednia</a>
{% else %}
<span class="disabled">Poprzednia</span>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="{{ url_for('admin_ai_usage_user', user_id=user.id, page=p) }}">{{ p }}</a>
{% elif p == page - 3 or p == page + 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('admin_ai_usage_user', user_id=user.id, page=page+1) }}">Nastepna</a>
{% else %}
<span class="disabled">Nastepna</span>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty-state">
<div class="icon">📭</div>
<p>Brak zapytan AI dla tego uzytkownika</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -165,30 +165,95 @@
{% endif %}
</div>
</div>
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<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-actions" style="display: flex; gap: var(--spacing-sm); 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>
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active { display: flex; }
.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); }
@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 now = new Date();
let confirmResolve = null;
function deleteAnnouncement(id) {
if (!confirm('Czy na pewno chcesz usunac to ogloszenie?')) {
return;
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');
});
}
fetch('/admin/announcements/' + id + '/delete', {
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);
}
async function deleteAnnouncement(id) {
const confirmed = await showConfirm('Czy na pewno chcesz usunąć to ogłoszenie?', {
icon: '🗑️',
title: 'Usuwanie ogłoszenia',
okText: 'Usuń',
okClass: 'btn-error'
});
if (!confirmed) return;
try {
const response = await fetch('/admin/announcements/' + id + '/delete', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
});
const data = await response.json();
if (data.success) {
location.reload();
showToast('Ogłoszenie zostało usunięte', 'success');
setTimeout(() => location.reload(), 1500);
} else {
alert('Blad: ' + data.error);
showToast('Błąd: ' + data.error, 'error');
}
} catch (err) {
showToast('Błąd: ' + err, 'error');
}
})
.catch(err => alert('Blad: ' + err));
}
{% endblock %}

View File

@ -210,9 +210,51 @@
Oczekiwanie na logi... Wykonaj jakąś akcję na stronie.
</div>
</div>
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<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-actions" style="display: flex; gap: var(--spacing-sm); 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>
<style>
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active { display: flex; }
</style>
{% endblock %}
{% block extra_js %}
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); });
const logContainer = document.getElementById('logContainer');
const emptyMessage = document.getElementById('emptyMessage');
const statusIndicator = document.getElementById('statusIndicator');
@ -354,7 +396,13 @@
// Clear logs
async function clearLogs() {
if (!confirm('Wyczyścić wszystkie logi?')) return;
const confirmed = await showConfirm('Czy na pewno chcesz wyczyścić wszystkie logi?', {
icon: '🗑️',
title: 'Czyszczenie logów',
okText: 'Wyczyść',
okClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch('/api/admin/logs/clear', {

View File

@ -443,35 +443,101 @@
</form>
</div>
</div>
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px;">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon"></div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p class="modal-description" id="confirmModalMessage"></p>
</div>
<div class="modal-actions" 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>
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active { display: flex; }
#confirmModal .modal { background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl); }
#confirmModal .modal-icon { font-size: 3em; margin-bottom: var(--spacing-md); }
#confirmModal .modal-actions { display: flex; gap: var(--spacing-sm); margin-top: var(--spacing-lg); }
.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); }
@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 %}
function generateFees() {
if (!confirm('Czy na pewno chcesz wygenerowac rekordy skladek dla wszystkich firm na wybrany miesiac?')) {
return;
}
// Modal system
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);
}
async function generateFees() {
const confirmed = await showConfirm('Czy na pewno chcesz wygenerować rekordy składek dla wszystkich firm na wybrany miesiąc?', {
icon: '📋',
title: 'Generowanie składek',
okText: 'Generuj',
okClass: 'btn-success'
});
if (!confirmed) return;
const formData = new FormData();
formData.append('year', {{ year }});
formData.append('month', {{ month or 'null' }});
fetch('{{ url_for("admin_fees_generate") }}', {
try {
const response = await fetch('{{ url_for("admin_fees_generate") }}', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
});
const data = await response.json();
if (data.success) {
alert(data.message);
location.reload();
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1500);
} else {
alert('Blad: ' + data.error);
showToast('Błąd: ' + data.error, 'error');
}
} catch (err) {
showToast('Błąd: ' + err, 'error');
}
})
.catch(err => alert('Blad: ' + err));
}
function openPaymentModal(feeId, companyName, amount) {
@ -486,29 +552,31 @@
document.getElementById('paymentModal').classList.remove('active');
}
document.getElementById('paymentForm').addEventListener('submit', function(e) {
document.getElementById('paymentForm').addEventListener('submit', async function(e) {
e.preventDefault();
const feeId = document.getElementById('modalFeeId').value;
const formData = new FormData(this);
fetch('/admin/fees/' + feeId + '/mark-paid', {
try {
const response = await fetch('/admin/fees/' + feeId + '/mark-paid', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
});
const data = await response.json();
if (data.success) {
alert(data.message);
location.reload();
closePaymentModal();
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1500);
} else {
alert('Blad: ' + data.error);
showToast('Błąd: ' + data.error, 'error');
}
} catch (err) {
showToast('Błąd: ' + err, 'error');
}
})
.catch(err => alert('Blad: ' + err));
});
function toggleSelectAll() {
@ -517,37 +585,42 @@
checkboxes.forEach(cb => cb.checked = selectAll.checked);
}
function bulkMarkPaid() {
async function bulkMarkPaid() {
const checkboxes = document.querySelectorAll('.fee-checkbox:checked');
if (checkboxes.length === 0) {
alert('Zaznacz przynajmniej jedna skladke');
showToast('Zaznacz przynajmniej jedną składkę', 'warning');
return;
}
if (!confirm('Czy na pewno chcesz oznaczyc ' + checkboxes.length + ' skladek jako oplacone?')) {
return;
}
const confirmed = await showConfirm(`Czy na pewno chcesz oznaczyć <strong>${checkboxes.length}</strong> składek jako opłacone?`, {
icon: '💰',
title: 'Oznaczanie płatności',
okText: 'Oznacz',
okClass: 'btn-success'
});
if (!confirmed) return;
const formData = new FormData();
checkboxes.forEach(cb => formData.append('fee_ids[]', cb.value));
fetch('{{ url_for("admin_fees_bulk_mark_paid") }}', {
try {
const response = await fetch('{{ url_for("admin_fees_bulk_mark_paid") }}', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
});
const data = await response.json();
if (data.success) {
alert(data.message);
location.reload();
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1500);
} else {
alert('Blad: ' + data.error);
showToast('Błąd: ' + data.error, 'error');
}
} catch (err) {
showToast('Błąd: ' + err, 'error');
}
})
.catch(err => alert('Blad: ' + err));
}
// Close modal on outside click

View File

@ -551,14 +551,71 @@
</div>
</div>
</div>
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<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-outline" 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 currentTopicId = 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) {
alert(message);
showToast(message, type === 'error' ? 'error' : 'success');
}
// Status modal functions
@ -664,9 +721,13 @@
}
async function deleteTopic(topicId, title) {
if (!confirm(`Czy na pewno chcesz usunac temat "${title}"?\n\nTa operacja usunie rowniez wszystkie odpowiedzi i jest nieodwracalna.`)) {
return;
}
const confirmed = await showConfirm(`Czy na pewno chcesz usunąć temat "<strong>${title}</strong>"?<br><br><small>Ta operacja usunie również wszystkie odpowiedzi i jest nieodwracalna.</small>`, {
icon: '🗑️',
title: 'Usuwanie tematu',
okText: 'Usuń',
okClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch(`/admin/forum/topic/${topicId}/delete`, {
@ -680,18 +741,23 @@
const data = await response.json();
if (data.success) {
document.querySelector(`tr[data-topic-id="${topicId}"]`).remove();
showToast('Temat został usunięty', 'success');
} else {
showMessage(data.error || 'Wystapil blad', 'error');
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Blad polaczenia', 'error');
showMessage('Błąd połączenia', 'error');
}
}
async function deleteReply(replyId) {
if (!confirm('Czy na pewno chcesz usunac te odpowiedz?')) {
return;
}
const confirmed = await showConfirm('Czy na pewno chcesz usunąć tę odpowiedź?', {
icon: '🗑️',
title: 'Usuwanie odpowiedzi',
okText: 'Usuń',
okClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch(`/admin/forum/reply/${replyId}/delete`, {
@ -705,11 +771,12 @@
const data = await response.json();
if (data.success) {
document.querySelector(`div[data-reply-id="${replyId}"]`).remove();
showToast('Odpowiedź została usunięta', 'success');
} else {
showMessage(data.error || 'Wystapil blad', 'error');
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Blad polaczenia', 'error');
showMessage('Błąd połączenia', 'error');
}
}
{% endblock %}

View File

@ -492,15 +492,72 @@
</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) {
// Simple alert for now - could be improved with toast notifications
alert(message);
showToast(message, type === 'error' ? 'error' : 'success');
}
// Filter tabs functionality
@ -582,9 +639,13 @@
}
async function deleteRecommendation(recommendationId, companyName) {
if (!confirm(`Czy na pewno chcesz usunąć rekomendację dla firmy "${companyName}"?\n\nTa operacja jest nieodwracalna.`)) {
return;
}
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`, {
@ -598,7 +659,7 @@
const data = await response.json();
if (data.success) {
document.querySelector(`tr[data-recommendation-id="${recommendationId}"]`).remove();
showMessage('Rekomendacja została usunięta', 'success');
showToast('Rekomendacja została usunięta', 'success');
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}

View File

@ -284,9 +284,27 @@
</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.error { border-left-color: var(--error); }
@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 %}
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { error: '✕', 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);
}
const passwordInput = document.getElementById('password');
const passwordConfirm = document.getElementById('password_confirm');
const strengthBar = document.getElementById('strengthBar');
@ -355,7 +373,7 @@
if (password !== confirm) {
passwordConfirm.classList.add('error');
e.preventDefault();
alert('Hasla nie sa identyczne');
showToast('Hasła nie są identyczne', 'error');
}
});
{% endblock %}

View File

@ -171,15 +171,78 @@
<a href="{{ url_for('admin_calendar_new') }}" class="btn btn-primary mt-2">Dodaj wydarzenie</a>
</div>
{% endif %}
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<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-actions" style="display: flex; gap: var(--spacing-sm); 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>
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active { display: flex; }
.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); }
@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 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);
}
async function deleteEvent(eventId, title) {
if (!confirm(`Czy na pewno chcesz usunac wydarzenie "${title}"?`)) {
return;
}
const confirmed = await showConfirm(`Czy na pewno chcesz usunąć wydarzenie "<strong>${title}</strong>"?`, {
icon: '🗑️',
title: 'Usuwanie wydarzenia',
okText: 'Usuń',
okClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch(`/admin/kalendarz/${eventId}/delete`, {
@ -193,11 +256,12 @@ async function deleteEvent(eventId, title) {
const data = await response.json();
if (data.success) {
document.querySelector(`tr[data-event-id="${eventId}"]`).remove();
showToast('Wydarzenie zostało usunięte', 'success');
} else {
alert(data.error || 'Wystapil blad');
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
alert('Blad polaczenia');
showToast('Błąd połączenia', 'error');
}
}
{% endblock %}

View File

@ -223,11 +223,31 @@
</div>
</div>
{% endif %}
<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); }
@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() }}';
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);
}
async function toggleRSVP() {
const btn = document.getElementById('rsvp-btn');
btn.disabled = true;
@ -247,18 +267,20 @@ async function toggleRSVP() {
btn.textContent = 'Wypisz sie';
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary', 'attending');
showToast('Zapisano na wydarzenie!', 'success');
} else {
btn.textContent = 'Wezme udzial';
btn.classList.remove('btn-secondary', 'attending');
btn.classList.add('btn-primary');
showToast('Wypisano z wydarzenia', 'info');
}
// Refresh page to update attendees list
setTimeout(() => location.reload(), 500);
setTimeout(() => location.reload(), 1000);
} else {
alert(data.error || 'Wystapil blad');
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
alert('Blad polaczenia');
showToast('Błąd połączenia', 'error');
}
btn.disabled = false;

View File

@ -282,15 +282,78 @@
</div>
</div>
</div>
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<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-actions" style="display: flex; gap: var(--spacing-sm); 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>
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active { display: flex; }
.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); }
@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 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);
}
async function closeClassified() {
if (!confirm('Czy na pewno chcesz zamknac to ogloszenie?')) {
return;
}
const confirmed = await showConfirm('Czy na pewno chcesz zamknąć to ogłoszenie?', {
icon: '🔒',
title: 'Zamykanie ogłoszenia',
okText: 'Zamknij',
okClass: 'btn-warning'
});
if (!confirmed) return;
try {
const response = await fetch('{{ url_for("classifieds_close", classified_id=classified.id) }}', {
@ -303,12 +366,13 @@ async function closeClassified() {
const data = await response.json();
if (data.success) {
window.location.href = '{{ url_for("classifieds_index") }}';
showToast('Ogłoszenie zostało zamknięte', 'success');
setTimeout(() => window.location.href = '{{ url_for("classifieds_index") }}', 1500);
} else {
alert(data.error || 'Wystapil blad');
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
alert('Blad polaczenia');
showToast('Błąd połączenia', 'error');
}
}
{% endblock %}

View File

@ -280,9 +280,29 @@
</form>
</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 %}
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);
}
// Character counter
const textarea = document.getElementById('recommendation_text');
const counter = document.getElementById('charCounter');
@ -308,11 +328,11 @@
if (text.length < 50) {
textarea.style.borderColor = 'var(--error)';
alert('Rekomendacja musi mieć co najmniej 50 znaków.');
showToast('Rekomendacja musi mieć co najmniej 50 znaków.', 'error');
valid = false;
} else if (text.length > 2000) {
textarea.style.borderColor = 'var(--error)';
alert('Rekomendacja może mieć maksymalnie 2000 znaków.');
showToast('Rekomendacja może mieć maksymalnie 2000 znaków.', 'error');
valid = false;
} else {
textarea.style.borderColor = '';

View File

@ -2248,39 +2248,101 @@
</div>
{% endif %}
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<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-actions" style="display: flex; gap: var(--spacing-sm); 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>
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active { display: flex; }
.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); }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
</style>
<script>
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);
}
// Edit recommendation
function editRecommendation(recId) {
// Redirect to edit form (will be implemented in phase 4)
window.location.href = `/api/recommendations/${recId}/edit`;
}
// Delete recommendation
function deleteRecommendation(recId) {
if (!confirm('Czy na pewno chcesz usunąć tę rekomendację?')) {
return;
}
async function deleteRecommendation(recId) {
const confirmed = await showConfirm('Czy na pewno chcesz usunąć tę rekomendację?', {
icon: '🗑️',
title: 'Usuwanie rekomendacji',
okText: 'Usuń',
okClass: 'btn-danger'
});
if (!confirmed) return;
fetch(`/api/recommendations/${recId}/delete`, {
try {
const response = await fetch(`/api/recommendations/${recId}/delete`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Rekomendacja została usunięta.');
window.location.reload();
} else {
alert('Błąd: ' + (data.error || 'Nie udało się usunąć rekomendacji.'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Wystąpił błąd podczas usuwania rekomendacji.');
});
const data = await response.json();
if (data.success) {
showToast('Rekomendacja została usunięta', 'success');
setTimeout(() => window.location.reload(), 1500);
} else {
showToast('Błąd: ' + (data.error || 'Nie udało się usunąć rekomendacji.'), 'error');
}
} catch (error) {
console.error('Error:', error);
showToast('Wystąpił błąd podczas usuwania rekomendacji.', 'error');
}
}
</script>

View File

@ -339,9 +339,28 @@
</form>
</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 %}
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);
}
// Client-side validation
document.querySelector('form').addEventListener('submit', function(e) {
const title = document.getElementById('title');
@ -433,13 +452,13 @@
function handleFile(file) {
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
alert('Plik jest za duzy (max 5MB)');
showToast('Plik jest za duży (max 5MB)', 'error');
return;
}
// Validate file type
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
alert('Dozwolone formaty: JPG, PNG, GIF');
showToast('Dozwolone formaty: JPG, PNG, GIF', 'warning');
return;
}

View File

@ -689,9 +689,28 @@
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightboxImage" src="" alt="Enlarged image">
</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 %}
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);
}
// Lightbox functions
function openLightbox(src) {
document.getElementById('lightboxImage').src = src;
@ -783,7 +802,7 @@
const availableSlots = MAX_FILES - currentCount;
if (availableSlots <= 0) {
alert('Osiagnieto limit ' + MAX_FILES + ' plikow');
showToast('Osiągnięto limit ' + MAX_FILES + ' plików', 'warning');
return;
}
@ -809,7 +828,7 @@
});
if (errors.length > 0) {
alert('Bledy:\n' + errors.join('\n'));
showToast('Błędy: ' + errors.join(', '), 'error');
}
updateCounter();