@extends('layouts.dashboard') @section('content') @php /** * Vendor CRM → Customers: everyone who has ordered from this restaurant, as a * searchable, segmented, sortable, paginated list. Each name links to that customer's * per-restaurant detail page. Built from the shared list components * (.qm-kpi-grid / .qm-order-filters / .qm-table / .qm-tbl-id / pagination) — no bespoke markup. * * Column order carries a judgement, so it is deliberate: how often someone orders and * what they spend decide who matters to the business, and how long they have been quiet * decides who needs attention. Ratings are the last column because a five-star review * from a one-time visitor should never outrank a regular who has never written one. * * @var array $customers the current page of customer rows * @var string $q active search term * @var string $sort active sort key (whitelisted in the controller) * @var string $segment active segment filter (blank = everyone) * @var array $counts per-segment customer counts + total spend * @var float|null $repeatRate share of customers who have ordered more than once * @var int $total total customers matching the search + segment * @var array $pagination paginate() metadata * @var array|null $restaurant */ $loyLabel = (string) ($restaurant['loyalty_points_label'] ?? 'points'); $sortOpts = [ 'spend' => t_raw('vendor.customers.sort_spend'), 'orders' => t_raw('vendor.customers.sort_orders'), 'recent' => t_raw('vendor.customers.sort_recent'), 'lapsed' => t_raw('vendor.customers.sort_lapsed'), 'name' => t_raw('vendor.customers.sort_name'), ]; $counts = $counts ?? ['all' => 0, 'new' => 0, 'active' => 0, 'at_risk' => 0, 'repeat' => 0, 'spend' => 0.0]; $segment = $segment ?? ''; $segOpts = [ '' => t_raw('vendor.customers.seg_all', [':n' => number_format((int) $counts['all'])]), 'active' => t_raw('vendor.customers.seg_active', [':n' => number_format((int) $counts['active'])]), 'at_risk' => t_raw('vendor.customers.seg_at_risk', [':n' => number_format((int) $counts['at_risk'])]), 'new' => t_raw('vendor.customers.seg_new', [':n' => number_format((int) $counts['new'])]), ]; $repeatRate = $repeatRate ?? null; $hasFilters = $q !== '' || $segment !== ''; // A manager runs the relationships, not the takings — same boundary as Kiosks and Orders. $showMoney = $showMoney ?? true; // Carry the active search / segment / sort onto the export link. $qs = http_build_query(array_filter([ 'segment' => $segment, 'sort' => $sort, 'q' => $q, ], static fn ($v) => (string) $v !== '')); @endphp @php /* .qm-d2: the dashboard density standard (app.css §35) */ @endphp
@php /* The four numbers that decide where the marketing effort goes: how many people this restaurant has, how many come back, how many have gone quiet, and what the relationship is worth. */ @endphp
@partial('kpi-card', ['variant' => 'panel', 'accent' => 'blue', 'icon' => 'fa-users', 'label' => t_raw('nav.customers'), 'value' => number_format((int) $counts['all']), 'meta' => '' . t('vendor.customers.kpi_meta_all') . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'green', 'icon' => 'fa-redo', 'label' => t_raw('vendor.customers.kpi_repeat_rate'), 'value' => $repeatRate !== null ? number_format($repeatRate, 1) . '%' : "—", 'meta' => '' . t('vendor.customers.kpi_meta_repeat', [':n' => number_format((int) $counts['repeat'])]) . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'amber', 'icon' => 'fa-user-clock', 'label' => t_raw('vendor.customers.kpi_at_risk'), 'value' => number_format((int) $counts['at_risk']), 'href' => route('vendor.customers', ['segment' => 'at_risk']), 'linkLabel' => t_raw('vendor.customers.kpi_link_review'), 'meta' => '' . t('vendor.customers.kpi_meta_at_risk') . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'teal', 'icon' => 'fa-user-plus', 'label' => t_raw('vendor.customers.kpi_new'), 'value' => number_format((int) $counts['new']), 'href' => route('vendor.customers', ['segment' => 'new']), 'linkLabel' => t_raw('vendor.customers.kpi_link_review'), 'meta' => '' . t('vendor.customers.kpi_meta_new') . '']) @if ($showMoney) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'violet', 'icon' => 'fa-coins', 'label' => t_raw('vendor.customers.kpi_lifetime_spend'), 'value' => money($counts['spend']), 'meta' => '' . t('vendor.customers.kpi_meta_spend') . '']) @endif

{{ t_raw('vendor.customers.list_heading') }}

@if ($total > 0){{ t_raw('vendor.customers.total_pill', [':count' => number_format($total)]) }}@endif
{{ t_raw('common.export_csv') }}
@partial('filter-collapse')
@if ($showMoney)@endif @if ($showMoney)@endif @if ($customers) @foreach ($customers as $c) @php $cid = (int) $c['id']; $rc = (int) $c['reviews_c']; $oc = (int) $c['orders_c']; $days = $c['days_since'] !== null ? (int) $c['days_since'] : null; $span = (int) ($c['span_days'] ?? 0); // Mirrors the controller's SQL definition exactly — one rule, applied in // the place that filters and in the place that labels. $isNew = $oc === 1; $isAtRisk = !$isNew && $days !== null && $days > max(21, (2 * $span) / max(1, $oc - 1)); @endphp @if ($showMoney)@endif @if ($showMoney) @php $wn = $c['wallet_net'] !== null ? (float) $c['wallet_net'] : 0.0; @endphp @endif @php /* Recency is the number an operator acts on, so it leads the cell and the calendar date follows it as the supporting detail. */ @endphp @endforeach @else @partial('table-empty', ['colspan' => $showMoney ? 7 : 5, 'icon' => 'fa-users', 'msg' => $hasFilters ? t_raw('vendor.customers.empty_search') : t_raw('vendor.customers.empty')]) @endif
{{ t_raw('vendor.customers.caption') }}
{{ t_raw('common.customer') }} {{ t_raw('common.orders') }}{{ t_raw('admin.analytics_customers.col_spend') }}{{ t_raw('vendor.customers.col_balance') }}{{ t_raw('vendor.customers.col_recency') }} {{ t_raw('vendor.analytics.col_loyalty_balance') }} {{ t_raw('vendor.customers.col_reviews') }}
{!! brandmark_html($c['avatar'] ?? null, (string) $c['name'], 'md', 'qm-brandmark--round') !!}
{{ $c['name'] }} @if (!empty($c['email'])){{ $c['email'] }}@endif @if (!empty($c['phone'])){{ $c['phone'] }}@endif @if (empty($c['email']) && empty($c['phone'])){!! or_dash(null) !!}@endif @if ($isAtRisk) {{ t_raw('vendor.customers.pill_at_risk') }} @elseif ($isNew) {{ t_raw('vendor.customers.pill_new') }} @endif
{{ number_format($oc) }}{{ money($c['spend']) }} @if ($wn > 0.005){{ money($wn) }} @elseif ($wn < -0.005){{ money(abs($wn)) }} @else{!! dash() !!}@endif @if ($days === null){!! dash() !!} @else {!! $days === 0 ? t('vendor.customers.recency_today') : t('vendor.customers.recency_days_ago', [':n' => number_format($days)]) !!}
{!! fmt_day($c['last_order']) !!}
@endif
{!! $c['points'] !== null ? number_format((int) $c['points']) . ' ' . e($loyLabel) : dash() !!} @if ($rc > 0) {!! rating_stars_html((float) $c['review_avg']) !!} {{ t_raw('vendor.customers.review_count', [':count' => $rc]) }} @else {{ t_raw('vendor.customers.no_review') }} @endif
@partial('table-toolbar', ['pager' => $pagination, 'base' => '/vendor/customers'])
@php /* /.qm-d2 */ @endphp @endsection