bober-bbq-bot/bober_bbq/templates/admin/dashboard.html
byrsapty 922d9c5172 Make brand identity reusable for redeploying to other clients
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.
2026-08-25 17:54:29 +03:00

242 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "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 %}