Groundwork for reusing this codebase per-client (own VPS/DB/bot per
cafe): the operational config (bot token, DB URL, secrets, Checkbox PRRO
creds) was already .env/Setting-driven, but ~13 spots still hardcoded the
literal string "Bober BBQ" instead of reading the existing cafe_name
Setting, and the webapp's static shell (index.html title, PWA manifest)
had no templating at all since Settings only load after JS boots.
- Route the remaining hardcoded strings through Setting.get("cafe_name",
"Bober BBQ") — same pattern already used correctly in bot/handlers/
start.py. Every fallback stays "Bober BBQ", so production is byte-
identical with no cafe_name override.
- Template webapp/index.html via Vite's native %VITE_CAFE_NAME% HTML
replacement, backed by a committed webapp/.env (default "Bober BBQ",
no secrets) with a per-client override via gitignored .env.local.
- Add webapp/scripts/gen-manifest.mjs to generate manifest.json from a
new manifest.template.json the same way, since Vite doesn't process
public/ assets — wired into the build script.
- Parameterize deploy.sh's systemd unit names and the admin log-viewer's
log paths via optional SERVICE_WEB/SERVICE_BOT/LOG_FILE_WEB/
LOG_FILE_BOT env vars, defaulting to today's literal values.
- Rewrite docs/DEPLOYMENT.md into a repeatable new-client runbook (fixed
an existing /opt/bober-bbq vs /opt/bober-bbq-bot inconsistency, added
a rebranding checklist).
Verified via a smoke test that every changed string still renders
"Bober BBQ" with no overrides present (matching prod's actual .env/DB
state), and that webapp builds both with and without a VITE_CAFE_NAME
override produce the expected output — including a no-override rebuild
confirming byte-identical output to before this change.
242 lines
11 KiB
HTML
242 lines
11 KiB
HTML
{% extends "admin/base.html" %}
|
||
{% import "admin/_macros.html" as macros %}
|
||
{% block page_title %}Панель адміністратора{% endblock %}
|
||
{% block content %}
|
||
|
||
{% macro trend(v) %}
|
||
{%- if v is none -%}
|
||
<span class="stat-sub">—</span>
|
||
{%- elif v >= 0 -%}
|
||
<span class="stat-trend up">↑ {{ v }}%</span>
|
||
{%- else -%}
|
||
<span class="stat-trend down">↓ {{ -v }}%</span>
|
||
{%- endif -%}
|
||
{% endmacro %}
|
||
|
||
<div class="stat-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-top">
|
||
<div class="stat-label">Сьогоднішні замовлення</div>
|
||
<div class="stat-icon orange">{{ pickup_icon(20) }}</div>
|
||
</div>
|
||
<div class="stat-value">{{ stats.today.count }}</div>
|
||
{{ trend(stats.trend.count) }}
|
||
<div class="stat-sub">Вчора: {{ stats.yesterday.count }}</div>
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
<div class="stat-top">
|
||
<div class="stat-label">Доставка</div>
|
||
<div class="stat-icon orange">🛵</div>
|
||
</div>
|
||
<div class="stat-value">{{ stats.today.delivery }}</div>
|
||
{{ trend(stats.trend.delivery) }}
|
||
<div class="stat-sub">Вчора: {{ stats.yesterday.delivery }}</div>
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
<div class="stat-top">
|
||
<div class="stat-label">Самовивіз</div>
|
||
<div class="stat-icon orange">{{ pickup_icon(20) }}</div>
|
||
</div>
|
||
<div class="stat-value">{{ stats.today.pickup }}</div>
|
||
{{ trend(stats.trend.pickup) }}
|
||
<div class="stat-sub">Вчора: {{ stats.yesterday.pickup }}</div>
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
<div class="stat-top">
|
||
<div class="stat-label">У закладі</div>
|
||
<div class="stat-icon violet">🍽️</div>
|
||
</div>
|
||
<div class="stat-value">{{ stats.today.dine_in }}</div>
|
||
{{ trend(stats.trend.dine_in) }}
|
||
<div class="stat-sub">Вчора: {{ stats.yesterday.dine_in }}</div>
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
<div class="stat-top">
|
||
<div class="stat-label">Виручка сьогодні</div>
|
||
<div class="stat-icon green">💰</div>
|
||
</div>
|
||
<div class="stat-value">{{ stats.today.revenue }} грн</div>
|
||
{{ trend(stats.trend.revenue) }}
|
||
<div class="stat-sub">Вчора: {{ stats.yesterday.revenue }} грн</div>
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
<div class="stat-top">
|
||
<div class="stat-label">Середній чек</div>
|
||
<div class="stat-icon violet">💳</div>
|
||
</div>
|
||
<div class="stat-value">{{ stats.today.avg_check }} грн</div>
|
||
{{ trend(stats.trend.avg_check) }}
|
||
<div class="stat-sub">Вчора: {{ stats.yesterday.avg_check }} грн</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h3>Останні замовлення</h3>
|
||
<a class="btn secondary small" href="{{ url_for('admin.orders_list') }}">Перейти до всіх</a>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table class="responsive-table">
|
||
<tr><th>№</th><th>Тип</th><th>Клієнт</th><th>Сума</th><th>Оплата</th><th>Статус</th><th>Час</th></tr>
|
||
{% for o in orders %}
|
||
<tr onclick="location.href='{{ url_for('admin.orders_detail', order_id=o.id) }}'" style="cursor:pointer;">
|
||
<td data-label="№">{{ o.display_number() }}</td>
|
||
<td data-label="Тип">{{ macros.order_type_badge(o.order_type) }}</td>
|
||
<td data-label="Клієнт">{{ o.customer_name }}<br><span class="muted">{{ o.customer_phone }}</span></td>
|
||
<td data-label="Сума">{{ o.total }} грн</td>
|
||
<td data-label="Оплата"><span class="cell-flex">{{ '💳' if o.payment_method == 'card' else '💵' }} <span class="badge {{ o.payment_status }}">{{ '✓' if o.payment_status == 'paid' else '✕' }}</span></span></td>
|
||
<td data-label="Статус"><span class="badge {{ o.status }}">{{ o.status_label() }}</span></td>
|
||
<td data-label="Час">{{ o.created_at.strftime('%H:%M') if o.created_at else '' }}</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="7" class="muted">Замовлень ще немає</td></tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h3>Статуси замовлень (сьогодні)</h3></div>
|
||
<div class="donut-wrap">
|
||
<div class="donut" style="background: {{ donut.gradient }};">
|
||
<div class="donut-inner">
|
||
<div class="n">{{ donut.total }}</div>
|
||
<div class="l">всього</div>
|
||
</div>
|
||
</div>
|
||
<div class="legend">
|
||
{% for item in donut.legend %}
|
||
<div class="legend-item">
|
||
<span class="legend-dot" style="background: {{ {'new':'#ff7a3d','confirmed':'#eab308','cooking':'#fb923c','ready':'#34d399','courier':'#a78bfa','completed':'#22c55e','cancelled':'#f87171'}[item.status] }};"></span>
|
||
{{ item.label }}
|
||
<span>{{ item.count }} {% if donut.total %}({{ item.pct }}%){% endif %}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2-even">
|
||
<div class="card">
|
||
<div class="card-head"><h3>Топ страв (сьогодні)</h3></div>
|
||
{% for name, qty in top_products_today %}
|
||
<div style="margin-bottom:10px;">
|
||
<div style="display:flex;justify-content:space-between;font-size:13px;">
|
||
<span>{{ loop.index }}. {{ name }}</span>
|
||
<span class="muted">{{ qty }} шт.</span>
|
||
</div>
|
||
<div class="progress-bar"><span style="width:{{ (qty / top_products_max * 100)|round }}%;"></span></div>
|
||
</div>
|
||
{% else %}
|
||
<div class="muted">Сьогодні ще немає продажів</div>
|
||
{% endfor %}
|
||
<a class="btn secondary small" href="{{ url_for('admin.products_list') }}" style="margin-top:6px;">Переглянути все меню</a>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h3>Фінанси (сьогодні)</h3></div>
|
||
<table>
|
||
<tr><td class="muted">Виручка</td><td style="text-align:right;font-weight:800;color:var(--success);">{{ finance_today.revenue }} грн</td></tr>
|
||
<tr><td class="muted">Оплачено карткою</td><td style="text-align:right;">{{ finance_today.card }} грн</td></tr>
|
||
<tr><td class="muted">Оплачено готівкою</td><td style="text-align:right;">{{ finance_today.cash }} грн</td></tr>
|
||
<tr><td class="muted">Середній чек</td><td style="text-align:right;">{{ finance_today.avg_check }} грн</td></tr>
|
||
<tr><td class="muted">Кількість замовлень</td><td style="text-align:right;">{{ finance_today.count }} шт.</td></tr>
|
||
</table>
|
||
<a class="btn secondary small" href="{{ url_for('admin.statistics') }}" style="margin-top:12px;">Детальна статистика</a>
|
||
</div>
|
||
</div>
|
||
|
||
{% if ratings.count %}
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h3>⭐ Оцінки клієнтів</h3>
|
||
<a class="btn secondary small" href="{{ url_for('admin.reviews_list') }}">Усі відгуки</a>
|
||
</div>
|
||
<div class="rating-summary">
|
||
<div class="rating-score">
|
||
<div class="rating-score-value">{{ ratings.average }}</div>
|
||
<div class="rating-score-stars">{{ '⭐' * ratings.average_rounded }}{{ '☆' * (5 - ratings.average_rounded) }}</div>
|
||
<div class="rating-score-count muted">{{ ratings.count }} {{ ratings.count_label }}</div>
|
||
</div>
|
||
<div class="rating-bars">
|
||
{% for n in [5, 4, 3, 2, 1] %}
|
||
<div class="rating-bar-row">
|
||
<span class="rating-bar-label">{{ n }} ⭐</span>
|
||
<div class="progress-bar"><span style="width:{{ ratings.distribution_pct[n] }}%;"></span></div>
|
||
<span class="rating-bar-count">{{ ratings.distribution[n] }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% if recent_reviews %}
|
||
<div class="review-mini-list">
|
||
{% for o in recent_reviews %}
|
||
<div class="review-mini-row" onclick="location.href='{{ url_for('admin.orders_detail', order_id=o.id) }}'">
|
||
<span class="review-mini-stars">{{ '⭐' * o.rating }}{{ '☆' * (5 - o.rating) }}</span>
|
||
<span class="muted">{{ o.customer_name or o.display_number() }}</span>
|
||
<span class="muted">{{ o.rated_at.strftime('%d.%m %H:%M') if o.rated_at else '' }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="grid-2-even">
|
||
<div class="card">
|
||
<div class="card-head"><h3>Активність в Telegram</h3></div>
|
||
<div class="tg-item">
|
||
<div class="tg-icon">📨</div>
|
||
<div>
|
||
<div class="tg-name">{{ cafe_name }} — Доставка</div>
|
||
<div class="tg-sub">{{ stats.today.delivery }} нових замовлень сьогодні{% if not delivery_chat_configured %} · чат не підключено{% endif %}</div>
|
||
</div>
|
||
<a class="btn secondary small" href="{{ url_for('admin.telegram_chats') }}">Відкрити</a>
|
||
</div>
|
||
<div class="tg-item">
|
||
<div class="tg-icon">📦</div>
|
||
<div>
|
||
<div class="tg-name">{{ cafe_name }} — Самовивіз</div>
|
||
<div class="tg-sub">{{ stats.today.pickup }} нових замовлень сьогодні{% if not pickup_chat_configured %} · чат не підключено{% endif %}</div>
|
||
</div>
|
||
<a class="btn secondary small" href="{{ url_for('admin.telegram_chats') }}">Відкрити</a>
|
||
</div>
|
||
<div class="tg-item">
|
||
<div class="tg-icon">🍽️</div>
|
||
<div>
|
||
<div class="tg-name">{{ cafe_name }} — У закладі</div>
|
||
<div class="tg-sub">{{ stats.today.dine_in }} нових замовлень сьогодні{% if not dine_in_chat_configured %} · чат не підключено{% endif %}</div>
|
||
</div>
|
||
<a class="btn secondary small" href="{{ url_for('admin.telegram_chats') }}">Відкрити</a>
|
||
</div>
|
||
<a class="muted" style="font-size:12.5px;" href="{{ url_for('admin.telegram_chats') }}">Налаштування чатів →</a>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h3>Швидкі дії</h3></div>
|
||
<div class="quick-actions">
|
||
<a class="quick-action" href="{{ url_for('admin.orders_new') }}"><span class="qa-icon">🧾</span> Нове замовлення</a>
|
||
{% if current_user.role == 'admin' %}
|
||
<a class="quick-action" href="{{ url_for('admin.products_new') }}"><span class="qa-icon">➕</span> Додати товар</a>
|
||
<a class="quick-action" href="{{ url_for('admin.categories_list') }}"><span class="qa-icon">📁</span> Додати категорію</a>
|
||
<a class="quick-action" href="{{ url_for('admin.settings_view') }}"><span class="qa-icon">🛵</span> Налаштування доставки</a>
|
||
<a class="quick-action" href="{{ url_for('admin.settings_view') }}"><span class="qa-icon">🏷️</span> Знижка самовивозу</a>
|
||
<a class="quick-action" href="{{ url_for('admin.settings_view') }}"><span class="qa-icon">🕐</span> Графік роботи</a>
|
||
<a class="quick-action" href="{{ url_for('admin.backups_list') }}"><span class="qa-icon">☁️</span> Резервне копіювання</a>
|
||
{% else %}
|
||
<a class="quick-action" href="{{ url_for('admin.orders_list', status='new') }}"><span class="qa-icon">🆕</span> Нові замовлення</a>
|
||
<a class="quick-action" href="{{ url_for('admin.customers_list') }}"><span class="qa-icon">👥</span> Клієнти</a>
|
||
<a class="quick-action" href="{{ url_for('admin.telegram_chats') }}"><span class="qa-icon">📱</span> Telegram-чати</a>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|