@extends('admin.layouts.app') @php $locale = app()->getLocale(); $nameField = $locale === 'ar' ? 'name_ar' : 'name_en'; $dash = __('admin.investors_admin.unknown') ?: '—'; /** Localised label-option name with em-dash fallback. */ $optName = function ($option) use ($nameField, $dash) { if (! $option) return $dash; return $option->{$nameField} ?: ($option->name_en ?? $dash); }; /** Long-form SAR ("1,500,000.00 SAR") for detail rows. */ $sar = function ($v) use ($dash) { if ($v === null || $v === '' || (is_numeric($v) && (float) $v === 0.0)) return $dash; return number_format((float) $v, 2) . ' SAR'; }; /** Compact SAR ("1.5M") for stat headlines. */ $sarCompact = function ($v) use ($dash) { $n = (float) ($v ?? 0); if ($n <= 0) return $dash; if ($n >= 1_000_000_000) return rtrim(rtrim(number_format($n / 1_000_000_000, 1), '0'), '.') . 'B'; if ($n >= 1_000_000) return rtrim(rtrim(number_format($n / 1_000_000, 1), '0'), '.') . 'M'; if ($n >= 1_000) return rtrim(rtrim(number_format($n / 1_000, 1), '0'), '.') . 'K'; return number_format($n, 0); }; $int = fn ($v) => ($v === null || $v === '') ? $dash : number_format((int) $v); $text = fn ($v) => ($v === null || $v === '') ? $dash : $v; $has = fn ($v) => $v !== null && $v !== '' && (! is_numeric($v) || (float) $v !== 0.0); // ── Locale-aware display name ──────────────────────────────── // The `display_name` accessor handles every fallback internally // (full_name_ → full_name_ → composed first+last // → email → "#id") and is guaranteed non-empty. The legacy // `name` fallback was removed in Phase 2 of the 2026-06-09 // refactor; the column is no longer consulted by any read path. $displayName = (string) $investor->display_name; /** Two-letter initials for the avatar tile. */ $initials = collect(explode(' ', trim((string) $displayName))) ->filter()->take(2) ->map(fn ($p) => mb_strtoupper(mb_substr($p, 0, 1))) ->implode(''); if ($initials === '') $initials = '—'; $logoFile = $investor->files?->firstWhere('file_type', \App\Models\File::TYPE_IMAGE); $logoUrl = $logoFile ? route('admin.files.show', $logoFile) : null; /* * Hero avatar resolution. Preference order: * 1. `avatar_path` — the profile photo the investor uploaded on the * public signup form. Stored as a public-disk relative path * under `storage/app/public/investors/avatars/…`; served via * the `public/storage` symlink. * 2. `$logoUrl` — legacy admin-uploaded FileRecord image. Still * respected so existing rows keep their picture. * 3. fall through to the two-letter initials tile rendered in the * hero block (no var needed). * * We deliberately emit a ROOT-RELATIVE URL (e.g. `/storage/…`) * instead of an absolute one. `Storage::disk('public')->url()` * builds an absolute URL from `filesystems.disks.public.url`, which * defaults to `APP_URL/storage` — and APP_URL on developer machines * is often `http://localhost` while `php artisan serve` actually * hosts the dashboard on `http://127.0.0.1:8000`. A root-relative * src lets the browser resolve against whichever host the user is * actually on, so the image loads in any environment. */ $avatarUrl = $investor->avatar_path ? '/storage/' . ltrim((string) $investor->avatar_path, '/') : null; $heroImageUrl = $avatarUrl ?: $logoUrl; $sectorChips = $investor->sectors->map(fn ($s) => $optName($s->option))->filter(fn ($n) => $n !== $dash); $stageChips = $investor->stages ->map(fn ($s) => $optName($s->option))->filter(fn ($n) => $n !== $dash); $methodChips = $investor->paymentMethods->map(fn ($s) => $optName($s->option))->filter(fn ($n) => $n !== $dash); $countryName = $investor->country?->{$nameField} ?: $investor->country?->name_en ?: $dash; $cityName = $investor->city?->{$nameField} ?: $investor->city?->name_en ?: $dash; @endphp @section('title', $displayName . ' — ' . __('admin.investors_admin.show_title')) @section('page_title', __('admin.investors_admin.show_title')) @push('styles') @endpush @section('content') {{-- AI activity strip — renders nothing if Claude hasn't touched this investor yet, so it stays out of the way on records the AI hasn't enriched. --}} @include('admin.ai._entity-badge', ['subject' => $investor]) {{-- {{ __('admin.investors_admin.back_to_list') }} --}} @include('admin.partials.flash') {{-- Inline icon library — every SVG used on this page is declared once here and referenced via . --}}
{{-- ───────────────── HERO ───────────────── --}}
(via the shared `displayName` binding) so every spot stays in sync without a full page reload. --}} x-on:section:updated.window="onSectionUpdate($event)"> {{-- Avatar with edit-mode camera overlay. Click triggers the hidden file input → AJAX upload → confirm → image swap in place + cache-bust query string so the browser pulls the new bytes. --}}
{{-- Camera button — visible only in Edit Mode. --}}
@if ($investor->category || $countryName !== $dash) {{ $countryName }} @if ($cityName !== $dash) · {{ $cityName }} @endif @endif

{{ $displayName }}

@if ($investor->group) @php $gc = $investor->group->resolveColor(); @endphp {{ __('admin.investors_admin.fields.group') }}: {{ $investor->group->name }} @endif {{-- Investor STATUS — beside the group, distinct indigo pill (mirrors the startup status hero badge). --}} @if ($investor->statusOption) @php $iso = $investor->statusOption; $isoName = app()->getLocale() === 'ar' ? ($iso->name_ar ?: $iso->name_en) : $iso->name_en; @endphp {{ __('admin.investors_admin.fields.status_option_id') }}: {{ $isoName }} @endif {{-- WhatsApp group membership — small status chip next to Group so ops sees at-a-glance whether this investor is already added to the WA community group. The chip is read-only here; full edit lives inside the Profile & Classification section. --}} @if ($investor->whatsapp_group_status) @php // Three semantic states (post-2026-06-09 // consolidation): `yes` = in the group, // `no` = not in the group, `left` = left // the group. Only `yes` gets the green // pill; `no` and `left` share the muted // styling. Older synonyms were collapsed // by a data migration. $waJoined = $investor->whatsapp_group_status === 'yes'; // Humanize fallback for any future write // that bypasses the dropdown (e.g. an API // call landing a value outside the catalog). // Without this the raw key would leak. $waKey = 'admin.investors_admin.whatsapp_group_status_values.' . $investor->whatsapp_group_status; $waLabel = __($waKey); if ($waLabel === $waKey) { $waLabel = \Illuminate\Support\Str::headline($investor->whatsapp_group_status); } @endphp {{-- Single pill: WhatsApp brand glyph + status label, no external icon. The glyph identifies the channel; aria-label / title carry the field name for screen readers and hover. --}} {{ $waLabel }} @endif {{-- Action badge intentionally removed from the hero badges row: the change-action button in the hero-actions strip below replaces it. The button shows the current action AND lets admins change it inline. --}} @if ($investor->category) {{ __('admin.investors_admin.fields.category_option_id') }}: {{ $optName($investor->category) }} @endif
{{-- Active/Inactive badge removed per UX spec. Duplicate gets the dedicated rd-dup-banner below. --}}
{{-- ───── Change-action button (first/innermost) ───── Sits closest to the name in the RTL hero strip — admins reach for it before the mailto/tel icons. PATCHes /admin/investors/{id}/label so the side-effect listeners (cancel-meeting, notification fan-out) take effect immediately. --}} @php $actionOpts = $labelOptions['action_option_id'] ?? []; $currentAct = $investor->action; $currentName = $currentAct ? ($optName($currentAct)) : __('admin.investors_admin.row_action_clear'); @endphp
{{-- ───── Favorite star toggle (SECOND icon) ───── Per-admin favorite. POSTs /admin/favorites/toggle and inverts the visual state locally. Sits AFTER the action picker per UX spec — the action is the primary affordance, the star is secondary. `data-favorited` drives the filled/outlined star. --}} {{-- Contact icons render ALWAYS so the hero shows a consistent action strip per UX spec. When a field is empty the icon is dimmed + non-interactive; the tooltip explains why. --}} @if ($investor->email) @else @endif @if ($investor->phone_number) {{-- WhatsApp shortcut. The phone string is normalized to E.164-without-plus via `Phone::forWhatsApp` (digits-only, leading zero stripped). The button opens wa.me in a new tab and sits next to the existing tel: + copy buttons so existing clipboard flow stays untouched. --}} @php $waUrl = \App\Support\Phone::whatsAppUrl($investor->phone_number); @endphp @if ($waUrl) @endif @else @endif @if ($investor->linkedin_profile) @else @endif
{{-- Bio on its own row spanning the full hero width (grid-column: 1 / -1 via the .rd-hero-bio rule). --}} @if ($investor->investor_bio)

{{ \Illuminate\Support\Str::limit($investor->investor_bio, 240) }}

@endif
{{-- ───────────────── DUPLICATE BANNER ───────────────── Surfaces business-critical metadata: this record was flagged as a duplicate by the dedup pipeline. If a canonical id was wired, link to it so admins can jump straight to the merged- into row. Otherwise just warn that it's a duplicate. --}} @if ($investor->is_duplicate) @endif {{-- ───────────────── KPI STRIP ───────────────── --}}
{{-- Investment Cap (SAR) hero tile removed 2026-06-15. Operator request: the value was rendered twice (hero tile + Investment Profile field row), and the duplicate read as visual clutter. The column itself stays on the investors table; only the read-side display is dropped from the show page. The edit form's Investment Profile section no longer exposes it either (see EntitySectionSchema). --}} {{-- Stat #2: "Expected Annual Investments" — relabeled 2026-06-11 from "Expected Investment Per Year (SAR)". The value now resolves the label-driven range option (e.g. "2-4 investments / year") rather than the dropped SAR amount. `data-edit-section` jumps into the Investment Profile card's edit mode when clicked in Edit Mode. --}}
{{ __('admin.investors_admin.fields.expected_annual_investments_option_id') }} {{ $optName($investor->expectedAnnualInvestments) }}
{{ __('admin.investors_admin.fields.investments_count') }} {{ $int($investor->investments_count) }}
{{-- Average Ticket — `@if` guard removed 2026-06-15 so the tile always renders. Without it, an investor with no average_ticket_option_id would collapse the top row to 2 tiles, breaking the locked 3 + 3 layout (the Background tile would slide up into the empty cell). The shared `$optName(...)` helper falls back to the em-dash placeholder when the relation is null. --}}
{{ __('admin.investors_admin.fields.average_ticket_option_id') }} {{ $optName($investor->averageTicket) }}
{{-- ── New classification tiles (2026-06-11) ─────────────── Three additional stat tiles surfaced at the end of the row per UX request ("الاخير وكلهم ف صف واحد"). The grid uses repeat(auto-fit, minmax(150px, 1fr)) so 4–7 tiles lay out responsively. Clicking a tile in Edit Mode scrolls to + opens the Classifications section card. --}} {{-- 3 classification chips are DISPLAY-ONLY since 2026-06-14 per ops spec. `data-edit-section` was removed so the global click handler ignores them; admins still edit via the dedicated "Edit" button on the Classifications section card below. --}}
{{ __('admin.investors_admin.fields.background_option_id') }} {{ $optName($investor->background) }}
{{ __('admin.investors_admin.fields.investment_experience_option_id') }} {{ $optName($investor->investmentExperience) }}
{{ __('admin.investors_admin.fields.activity_in_numu_option_id') }} {{ $optName($investor->activityInNumu) }}
{{-- ───────────────── SECTION NAV ───────────────── Sticky pill bar — smooth-scrolls to each card and highlights the visible one (IntersectionObserver in the script block). --}} {{-- Rejection-reason card (only shows when the investor has a stored reason — appears right under the hero so it's the first thing an admin reading the page sees). --}} @include('admin.partials.rejection-reason-display', ['subject' => $investor]) {{-- ───────────────── BIO — inline editable ───────────────── Always rendered (even when empty) so the Edit button is always reachable. LinkedIn intentionally NOT shown here — the hero icon strip is its sole display surface. --}} @if ($investor->investor_bio)

{{ $investor->investor_bio }}

@else

{{ __('admin.section_edit.empty_hint') }}

@endif
{{-- ───────────────── IDENTITY — inline section editing ───────────────── --}} {{-- The card now uses : view-mode markup stays as-is (icon rows + copy buttons); clicking Edit swaps to an auto-generated form built from App\Services\Admin\EntitySectionSchema::investorSections()['identity']. First-name pair (ar/en) lives inside the form — the old separate first-names-editor partial was removed for that reason. --}}
{{ $text($displayName) }}
@if ($investor->email) @endif @if ($investor->phone_number) @php $waUrl = \App\Support\Phone::whatsAppUrl($investor->phone_number); @endphp
{{ $investor->phone_number }} @if ($waUrl) @endif
@endif @if ($investor->linkedin_profile) {{-- LinkedIn row added to Identity 2026-06-14 per ops spec. Hero strip still shows the brand icon at the top; this row mirrors the email/phone treatment (icon-link + hover-revealed copy button) so admins can grab the URL without leaving the section. --}}
{{-- Inline LinkedIn glyph removed 2026-06-14: the row's gutter icon (rd-info-row__icon) already identifies the link as LinkedIn, so the duplicate inline svg read as two adjacent icons. --}} LinkedIn
@endif
{{ $countryName }}@if ($cityName !== $dash) · {{ $cityName }}@endif
@if ($investor->joining_date)
{{ $investor->joining_date->format('Y-m-d') }}
@endif @if ($investor->first_name_ar || $investor->first_name_en)
@if ($investor->first_name_ar) {{ $investor->first_name_ar }} @endif @if ($investor->first_name_ar && $investor->first_name_en) · @endif @if ($investor->first_name_en) {{ $investor->first_name_en }} @endif
@endif
{{-- ───────────────── CLASSIFICATIONS — inline editable ───────────────── Three new dropdowns (background / investment_experience / activity_in_numu) added 2026-06-11. Same three values are surfaced as hero stat tiles above; clicking a tile in Edit Mode scrolls here and opens this card's edit form. --}} {{-- Field-row order mirrors the schema: group → category → background → investment_experience → activity_in_numu → whatsapp_group_status. Category + WhatsApp moved in here on 2026-06-15 from the Profile & Classification section so all classification-like dimensions live in one place. --}}
{{ __('admin.investors_admin.fields.group_id') }} @if ($investor->group) @php $gc = $investor->group->resolveColor(); @endphp {{ $investor->group->name }} @else {{ $dash }} @endif
{{ __('admin.investors_admin.fields.category_option_id') }} @if ($investor->category) {{ $optName($investor->category) }} @else {{ $dash }} @endif
{{ __('admin.investors_admin.fields.background_option_id') }} @if ($investor->background) {{ $optName($investor->background) }} @else {{ $dash }} @endif
{{ __('admin.investors_admin.fields.investment_experience_option_id') }} @if ($investor->investmentExperience) {{ $optName($investor->investmentExperience) }} @else {{ $dash }} @endif
{{ __('admin.investors_admin.fields.activity_in_numu_option_id') }} @if ($investor->activityInNumu) {{ $optName($investor->activityInNumu) }} @else {{ $dash }} @endif
{{ __('admin.investors_admin.fields.whatsapp_group_status') }} @if ($investor->whatsapp_group_status) @php $waJoined = $investor->whatsapp_group_status === 'yes'; $waKey = 'admin.investors_admin.whatsapp_group_status_values.' . $investor->whatsapp_group_status; $waLabel = __($waKey); if ($waLabel === $waKey) { $waLabel = \Illuminate\Support\Str::headline($investor->whatsapp_group_status); } @endphp {{ $waLabel }} @else {{ $dash }} @endif
{{-- ───────────────── INVESTMENT PROFILE — inline editable ───────────────── --}}
{{ __('admin.investors_admin.fields.average_ticket_option_id') }} {{ $optName($investor->averageTicket) }}
{{-- `investment_cap_sar` field row removed 2026-06-15. The same value is already surfaced as the first stat tile in the hero strip above; rendering it twice (hero + row) was a visual duplicate. The column itself stays on the table + on the Investment Profile section's EDIT form (via EntitySectionSchema), only the view-mode row here is the duplicate that's removed. --}}
{{ __('admin.investors_admin.fields.expected_annual_investments_option_id') }} {{ $optName($investor->expectedAnnualInvestments) }}
{{-- `expected_investment_per_year_sar` row removed 2026-06-11 — column dropped. --}}
{{ __('admin.investors_admin.fields.investments_count') }} {{ $int($investor->investments_count) }}
{{ __('admin.investors_admin.fields.investment_experience_level_option_id') }} {{ $optName($investor->investmentExperienceLevel) }}
{{-- `investment_method_option_id` row removed 2026-06-11 — replaced visually by the Methods multiselect chip block rendered below. --}}
{{ __('admin.investors_admin.fields.has_angel_invested_option_id') }} {{ $optName($investor->hasAngelInvested) }}
{{ __('admin.investors_admin.fields.exit_experience_option_id') }} {{ $optName($investor->exitExperience) }}
{{-- Methods (multiselect) — moved here from the Sectors & Stages section on 2026-06-11. Visually occupies the slot where the single-FK `investment_method` used to live. --}}

{{ __('admin.investors_admin.sections.methods') }} {{ $methodChips->count() }}

@if ($methodChips->isEmpty())

—

@else
@foreach ($methodChips as $name) {{ $name }} @endforeach
@endif
{{-- `previous_investments_details` block removed 2026-06-11 — column was dropped. The curated companies list below is now the only structured surface for these names. --}} {{-- Curated companies — JSON list captured by the signup repeater. Each chip is now a CLICKABLE LINK to the investor index pre-filtered to other investors who listed the same company name (?company=). --}} @php $companies = is_array($investor->previous_investments_companies) ? collect($investor->previous_investments_companies) ->map(fn ($v) => trim((string) $v)) ->filter(fn ($v) => $v !== '') ->values() : collect(); @endphp @if ($companies->isNotEmpty())

{{ __('admin.investors_admin.fields.previous_investments_companies') }}

@foreach ($companies as $company) {{ $company }} @endforeach
@endif
{{-- ───────────────── CLASSIFICATION — inline editable ───────────────── --}} {{-- 2026-06-15: `category_option_id` + `whatsapp_group_status` rows REMOVED from this section — they now live in the Classifications card above. The Profile section keeps workflow / referral fields only. --}}
{{ __('admin.investors_admin.fields.action_option_id') }} @if ($investor->action) {{ $optName($investor->action) }} @else {{ $dash }} @endif
{{ __('admin.investors_admin.fields.referral_source_option_id') }} {{ $optName($investor->referralSource) }}
{{ __('admin.investors_admin.fields.is_in_other_angel_group_option_id') }} {{ $optName($investor->isInOtherAngelGroup) }}
@if ($investor->rejection_reason)
{{ __('admin.investors_admin.fields.rejection_reason') }} {{ $investor->rejection_reason }}
@endif
{{-- ───────────────── PREFERENCES — inline editable (multiselect) ───────────────── --}}

{{ __('admin.investors_admin.sections.sectors') }} {{ $sectorChips->count() }}

@if ($sectorChips->isEmpty())

—

@else
@foreach ($sectorChips as $name) {{ $name }} @endforeach
@endif

{{ __('admin.investors_admin.sections.stages') }} {{ $stageChips->count() }}

@if ($stageChips->isEmpty())

—

@else
@foreach ($stageChips as $name) {{ $name }} @endforeach
@endif
{{-- Methods block moved to Investment Profile on 2026-06-11. Pivot table (`investor_payment_methods`) is unchanged; only the section it renders in moved. --}}
{{-- ───────────────── TAGS (editable) ───────────────── Always-present card. The chip strip shows the currently attached tags; the "Edit" button opens an inline popover listing the full Tag catalog as checkable chips. Saving PATCHes the FULL desired set to investors.update-tags; see InvestorController::updateTags. --}} @php $tagsEndpoint = route('admin.investors.update-tags', $investor); // Initial state for the Alpine component. We pass the // current pivot set (as a list of ids) plus the available // catalog so the popover can render its checkbox list // without an extra fetch. $tagsState = [ 'selectedIds' => $investor->tags->pluck('id')->map(fn ($v) => (int) $v)->all(), 'available' => $availableTags->map(fn ($t) => [ 'id' => (int) $t->id, 'name' => (string) $t->name, 'color' => (string) ($t->color ?: '#6366f1'), ])->all(), 'attached' => $investor->tags->map(fn ($t) => [ 'id' => (int) $t->id, 'name' => (string) $t->name, 'color' => (string) ($t->color ?: '#6366f1'), ])->all(), 'endpoint' => $tagsEndpoint, 'createEndpoint' => route('admin.tags.store'), // The rename URL template uses `__ID__` as a placeholder // the Alpine factory substitutes per-tag at click time — // saves a per-chip route call without baking N urls into // the page payload. 'updateEndpoint' => str_replace( '__ID__', '__ID__', route('admin.tags.update', ['tag' => '__ID__']), ), ]; @endphp
{{-- Live chip strip — driven by Alpine `attached` so it refreshes on save without a page reload. --}}

{{ __('admin.investors_admin.tags_editor.empty') }}

{{-- Edit toggle — opens the popover that lists the catalog. Hidden while the popover is open. --}}
{{-- ── Editor ── Renders inline below the chip strip when `editing` is true. The catalog is shown as toggleable chips — click to add / remove from the working set. Save PATCHes the new id list; cancel restores the pre-edit selection without a network hit. --}}
{{ __('admin.investors_admin.tags_editor.title') }} {{ __('admin.investors_admin.tags_editor.hint') }}
{{-- "+ Add new tag" affordance — kept as the FIRST item in the catalog so it's always the first chip the admin's eye lands on, regardless of how many tags already exist. Collapsed to a chip-sized button by default; click expands an inline input + confirm/cancel so the admin can mint a brand-new tag and have it pre- attached to this investor in one motion. --}}

{{ __('admin.investors_admin.tags_editor.catalog_empty') }}

{{-- ───────────────── MEETINGS (read-only) ───────────────── Every Microsoft Bookings meeting this investor has had, newest first. Shows wall-clock date + time (via the tz-aware localStart accessor so it matches what the customer's MS confirmation email said), attendance, and admin notes when present. ALWAYS rendered — admins land here expecting a meetings timeline; an empty state inside makes "no meetings yet" explicit rather than silently hiding the section. The attendance + notes columns are read-only on the investor show page: they're owned by /admin/meetings/{id} (and the attendance modal on the meetings index). --}} @php $attendancePillStyle = [ 'attended' => ['fg' => '#065f46', 'bg' => '#d1fae5'], 'not_attended' => ['fg' => '#991b1b', 'bg' => '#fee2e2'], 'coming' => ['fg' => '#1d4ed8', 'bg' => '#dbeafe'], 'cancelled' => ['fg' => '#475569', 'bg' => '#e2e8f0'], 'unknown' => ['fg' => '#475569', 'bg' => '#f1f5f9'], ]; $statusPillStyle = [ 'cancelled' => ['fg' => '#991b1b', 'bg' => '#fee2e2'], ]; @endphp
@if ($meetings->isEmpty())

{{ __('admin.investors_admin.meetings_empty') }}

@else
    @foreach ($meetings as $m) @php $att = $attendancePillStyle[$m->attendance_status] ?? $attendancePillStyle['unknown']; $isCxl = $m->status === 'cancelled'; @endphp
  • {{ optional($m->meeting_date)->format('Y-m-d') ?: $dash }}
    @if ($m->start_time)
    {{ optional($m->localStart)->format('H:i') }}@if ($m->end_time) – {{ optional($m->localEnd)->format('H:i') }}@endif
    @endif @if ($m->created_at)
    {{ $m->created_at->format('Y-m-d') }}
    @endif
    {{ __('admin.meetings_admin.attendances.' . $m->attendance_status) }} @if ($isCxl) {{ __('admin.meetings_admin.statuses.cancelled') }} @endif @if ($m->staff_name) {{ $m->staff_name }} @endif
    @if ($m->meeting_notes)

    {{ $m->meeting_notes }}

    @endif
  • @endforeach
@endif
{{-- ───────────────── ENTITY NOTES ───────────────── Free-form admin notes attached to this investor. Reusable component shared with the startup show page. Pushes its own styles + Alpine component via entity-notes-assets. --}} @include('admin.partials.entity-notes', [ 'subjectType' => 'investor', 'subjectId' => $investor->id, 'notes' => $notes, 'sectionTitle' => __('admin.notes.section_title'), ]) @include('admin.partials.entity-notes-assets') {{-- ───────────────── ACTIVITY LOG (Item N, 2026-06-14) ───────────── Aggregated timeline at the bottom of the show page. Pulls from three log surfaces (see InvestorTimelineService): · activity_logs — admin + public actions · notification_logs — email / SMS / WhatsApp / push attempts · signup_audit_logs — forensic signup-flow capture Rendered as one newest-first list with a colored left-rail per `kind`; each card has a header (icon + title + actor + timestamp), a status pill, and an expandable meta block. No JS dependencies — pure Blade +
. --}} @php // Group the flat timeline into buckets — Today / Yesterday / // This week / This month / Older — so the list reads more like // a journal than a wall of cards. `$timeline` is already sorted // newest-first, so each bucket inherits that order naturally. $now = \Carbon\Carbon::now(); $today = $now->copy()->startOfDay(); $yest = $today->copy()->subDay(); $weekAgo = $today->copy()->subDays(7); $monthAgo = $today->copy()->subDays(30); $bucketKey = function (?\Carbon\Carbon $t) use ($today, $yest, $weekAgo, $monthAgo): string { if (! $t) return 'older'; if ($t->gte($today)) return 'today'; if ($t->gte($yest)) return 'yesterday'; if ($t->gte($weekAgo)) return 'this_week'; if ($t->gte($monthAgo)) return 'this_month'; return 'older'; }; $buckets = collect([ 'today' => __('admin.investors_admin.activity.buckets.today'), 'yesterday' => __('admin.investors_admin.activity.buckets.yesterday'), 'this_week' => __('admin.investors_admin.activity.buckets.this_week'), 'this_month' => __('admin.investors_admin.activity.buckets.this_month'), 'older' => __('admin.investors_admin.activity.buckets.older'), ]); $grouped = $timeline->groupBy(fn ($r) => $bucketKey($r['happened_at'])); @endphp

{{ __('admin.investors_admin.activity.card_subtitle') }}

@if ($timeline->isEmpty())

{{ __('admin.investors_admin.activity.empty') }}

@else {{-- ─── Filter tabs ────────────────────────────────── Pure-CSS filter via hidden radio inputs + :has() selector — no JS. Click a tab → its radio gets checked → the wrapper's :has(input:checked) styles hide non-matching items. Counts come straight from the summary array so they stay in sync. --}}
@if ($timelineSummary['audits'] > 0) @endif @if ($timelineSummary['failures'] > 0) @endif
{{-- ─── Date-grouped timeline ───────────────────── Each bucket renders its own connected timeline: an absolutely-positioned rail on the leading edge ties the cards together, and each card's node icon sits on top of that rail to look like a stop on a trip. Per-row data attrs drive the filter; pure CSS, no JS. --}}
@foreach ($buckets as $bucketKey => $bucketLabel) @php $rows = $grouped->get($bucketKey, collect()); @endphp @if ($rows->isEmpty()) @continue @endif
{{ $bucketLabel }} {{ $rows->count() }}
    @foreach ($rows as $row) @php // Per-kind colors / icons for the node circle. // For notifications we pick the channel icon // (mail/sms/whatsapp) instead of the generic // envelope so the user clocks the channel at // a glance. $kind = $row['kind']; $channel = $kind === 'notification' ? (string) ($row['raw']->channel ?? '') : ''; $iconRef = match (true) { $kind === 'notification' && $channel === 'whatsapp' => '#icon-phone', $kind === 'notification' && $channel === 'sms' => '#icon-phone', $kind === 'notification' && $channel === 'push' => '#icon-alert', $kind === 'notification' => '#icon-mail', $kind === 'audit' => '#icon-shield', default => '#icon-edit', }; $kindPalette = match ($kind) { 'notification' => ['fg' => '#0369a1', 'bg' => '#e0f2fe', 'rail' => '#0ea5e9'], 'audit' => ['fg' => '#6d28d9', 'bg' => '#ede9fe', 'rail' => '#a855f7'], default => ['fg' => '#047857', 'bg' => '#d1fae5', 'rail' => '#10b981'], }; $tone = $row['badge_tone'] ?? 'muted'; $tonePalette = match ($tone) { 'ok' => ['fg' => '#15803d', 'bg' => '#dcfce7', 'border' => '#86efac', 'dot' => '#22c55e'], 'warn' => ['fg' => '#a16207', 'bg' => '#fef9c3', 'border' => '#fde047', 'dot' => '#eab308'], 'err' => ['fg' => '#b91c1c', 'bg' => '#fee2e2', 'border' => '#fca5a5', 'dot' => '#ef4444'], default => ['fg' => '#475569', 'bg' => '#f1f5f9', 'border' => '#e2e8f0', 'dot' => '#94a3b8'], }; $isFailed = $tone === 'err'; // Actor initials for the avatar bubble. $actorText = (string) ($row['actor'] ?? ''); $actorInitials = '?'; if ($actorText !== '') { $parts = preg_split('/\s+/u', trim($actorText)); $actorInitials = mb_strtoupper(mb_substr($parts[0] ?? '?', 0, 1) . (isset($parts[1]) ? mb_substr($parts[1], 0, 1) : '')); } @endphp @php $logId = (int) ($row['raw']->id ?? 0); $detailHref = $logId > 0 ? route('admin.investors.activity-show', [$investor, $kind, $logId]) : null; @endphp
  1. {{-- The whole row is an : clicking anywhere opens the dedicated detail page. We dropped the inline
    / pattern so admins read the FULL log on a focused page instead of squinting at a meta grid. --}} {{ $row['title'] }} @if (filled($row['subtitle'])) {{ $row['subtitle'] }} · @endif {{ $actorText !== '' ? $actorText : __('admin.investors_admin.activity.actor_system') }} @if ($row['happened_at']) · {{ $row['happened_at']->diffForHumans() }} @endif @if (filled($row['badge'])) {{ $row['badge'] }} @endif
  2. @endforeach
@endforeach
@endif
@push('styles') @endpush {{-- Shared CSS + Alpine factory for the inline section-edit feature (the new wrapper). Include ONCE. --}} @include('admin.partials.editable-section-assets') {{-- Rejection confirmation modal (shared with the index table). --}} @include('admin.partials.rejection-modal')
@endsection {{-- Toast element used by the copy-to-clipboard feedback. Lives outside the .rd-shell grid so its `position: fixed` doesn't get clipped. --}}
{{ __('admin.actions.copied') ?: 'Copied' }}
@push('scripts') @endpush