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:
parent
421d7c8e89
commit
6e00291a88
115
app.py
115
app.py
@ -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():
|
||||
|
||||
@ -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>
|
||||
|
||||
549
templates/admin/ai_usage_user.html
Normal file
549
templates/admin/ai_usage_user.html
Normal 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 %}
|
||||
@ -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 %}
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 = '';
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user