@extends('layouts.app') @section('content')
@php $formatIsk = fn($amount) => number_format((float) $amount, 0, ',', '.') . ' ISK'; // Closed dkPlus customer periods (company moves) as JSON for the settings dialog. $periodsJson = fn($pid) => json_encode( ($customerPeriodsMap[trim((string) $pid)] ?? collect()) ->map(fn($p) => ['id' => $p->id, 'customer_id' => (string) $p->dkplus_customer_id, 'customer_name' => (string) ($p->dkplus_customer_name ?? ''), 'from_month' => $p->from_month, 'to_month' => $p->to_month]) ->values()->all() ); $totalPendingAcrossMonths = collect($billing['month_groups'])->reduce(function ($carry, $mg) use ($invoiceMap, $propertyEmailMap) { return $carry + collect($mg['api_groups'])->flatMap(fn($ag) => collect($ag['rows']))->filter(function ($r) use ($invoiceMap, $propertyEmailMap) { $hasCustomer = !empty($propertyEmailMap[(string) $r['property_id']]?->dkplus_customer_id); $hasInvoice = !empty($invoiceMap[(string) $r['property_id'] . '|' . $r['month']]?->dkplus_invoice_id); return $hasCustomer && !$hasInvoice; })->count(); }, 0); // Out of billing: never active, superseded, or deactivated (inactive-from reached). $outOfBilling = function ($pid) use ($propertySettingsMap, $currentMonth) { $s = $propertySettingsMap[trim((string) $pid)] ?? null; return $s && (!empty($s->billing_disabled) || !empty($s->superseded_by) || (!empty($s->inactive_from) && $s->inactive_from <= $currentMonth)); }; $billedPropertyIdsAll = collect($billing['rows'] ?? [])->pluck('property_id')->map(fn($id) => trim((string) $id))->unique(); $unlinkedProperties = collect($billing['rows'] ?? [])->unique('property_id')->filter(function ($row) use ($propertyEmailMap, $outOfBilling) { return empty($propertyEmailMap[(string) $row['property_id']]?->dkplus_customer_id) && !$outOfBilling($row['property_id']); })->values(); @endphp {{-- Flash messages rendered as toasts (see container at bottom of page) --}} {{-- ══════════════════ COMPACT HEADER ════════════════════════════════════ --}}
Lock billing
Invoicing happens on By property. By month is a read-only overview. info
bar_chartAnalytics & exports
@include('superadmin.lock-statistics.partials.filters', ['pageRoute' => 'locks-by-month']) {{-- ══════════════════ MAIN TABS ══════════════════════════════════════════ --}}
{{-- ─────────────────────── BY MONTH TAB (read-only overview) ────────── --}}
{{-- Unlinked properties callout --}} @if($unlinkedProperties->count() > 0) @endif {{-- Monthly Billing card --}}
@php $monthOptions = collect($billing['month_groups'])->map(fn($mg) => [ 'value' => 'billing_month_' . str_replace('-', '_', $mg['month']), 'label' => $mg['month'], ]); @endphp
Billing by month

Read-only overview of charges and invoice status per month. Create, sync and email invoices on the By property tab.

@if($monthOptions->count() > 1) @endif @if($totalPendingAcrossMonths > 0) {{ $totalPendingAcrossMonths }} pending @endif {{-- Defaults to ON (invoiced + skipped rows hidden); remembered in localStorage --}}
@forelse($billing['month_groups'] as $monthGroup) @php $collapseId = 'billing_month_' . str_replace('-', '_', $monthGroup['month']); $headingId = $collapseId . '_heading'; $allMonthRows = collect($monthGroup['api_groups'])->flatMap(fn($ag) => collect($ag['rows'])); $totalRows = $allMonthRows->count(); $invoicedRows = $allMonthRows->filter(function ($r) use ($invoiceMap) { return !empty($invoiceMap[(string) $r['property_id'] . '|' . $r['month']]?->dkplus_invoice_id); })->count(); $pendingInvoiceCount = $allMonthRows->filter(function ($r) use ($invoiceMap, $propertyEmailMap) { $hasCustomer = !empty($propertyEmailMap[(string) $r['property_id']]?->dkplus_customer_id); $hasInvoice = !empty($invoiceMap[(string) $r['property_id'] . '|' . $r['month']]?->dkplus_invoice_id); return $hasCustomer && !$hasInvoice; })->count(); $progressClass = ($totalRows > 0 && $invoicedRows === $totalRows) ? 'bg-gradient-success' : ($invoicedRows > 0 ? 'bg-gradient-warning' : 'bg-gradient-secondary'); @endphp
{{ $formatIsk($monthGroup['month_total']) }} @if($totalRows > 0) {{ $invoicedRows }}/{{ $totalRows }} invoiced @endif
@if($pendingInvoiceCount > 0) {{ $pendingInvoiceCount }} not invoiced @endif
@foreach($monthGroup['api_groups'] as $apiGroup) @foreach($apiGroup['rows'] as $row) @php $propertyEmail = $propertyEmailMap[(string) $row['property_id']] ?? null; $invoiceRecord = $invoiceMap[(string) $row['property_id'] . '|' . $row['month']] ?? null; $invoiceStatus = strtolower((string) ($invoiceRecord?->status ?? '')); $statusBadge = match($invoiceStatus) { 'finalized', 'sent' => 'bg-gradient-success', 'ready' => 'bg-gradient-info', 'submitted' => 'bg-gradient-warning', default => 'bg-gradient-secondary', }; @endphp {{-- Property + API --}} {{-- dkPlus customer (read-only here — edit on the Customers tab) --}} {{-- Locks --}} {{-- Pricing --}} {{-- Subtotal --}} {{-- Invoice actions --}} @endforeach @endforeach
Property dkPlus Customer Locks Pricing Subtotal Invoice help_outline
{{ $row['property_name'] }} @if(!empty($row['property_notes'])) sticky_note_2 @endif
{{ $row['api'] }} @if(!empty($row['property_inactive_from'])) Inactive from {{ $row['property_inactive_from'] }} @endif @if(!empty($row['is_current_month'])) current @endif
@if($propertyEmail?->dkplus_customer_id)
{{ $propertyEmail->dkplus_customer_name ?: '—' }}
{{ $propertyEmail->dkplus_customer_id }}
@else No customer linked @endif
{{ $row['billable_unique_lock_count'] }}
{{ $row['pricing_rule_label'] }}
{{ $row['pricing_badge'] }} {{ $formatIsk($row['unit_price']) }}/u
Formula
{{ $row['calculation_formula'] }}
Qty: {{ $row['input_quantity'] }} {{ $row['input_quantity_label'] }}
{{ $formatIsk($row['subtotal']) }}
@if($invoiceRecord?->dkplus_invoice_id) ID: {{ $invoiceRecord->dkplus_invoice_id }} @endif @if($invoiceStatus) {{ strtoupper($invoiceStatus) }} @endif @if($invoiceRecord?->synced_at)
access_time {{ $invoiceRecord->synced_at->diffForHumans() }}
@endif @if($invoiceRecord?->dkplus_invoice_id) picture_as_pdf PDF @elseif(!empty($row['is_skipped'])) Skipped @else Not invoiced @endif
API subtotal — {{ $apiGroup['api'] }} {{ $formatIsk($apiGroup['api_total']) }}
Month grand total {{ $formatIsk($monthGroup['month_total']) }}
@if(auth()->id() === 4) {{-- Per-month commission breakdown — owner-only (user_id 4) --}}

account_balance_walletCommission breakdown

Gross revenue
{{ $formatIsk($monthGroup['month_total']) }}
−
Godo (100 ISK/lock) @if($monthGroup['godo_lock_count'] > 0) — {{ $monthGroup['godo_lock_count'] }} Godo lock(s) @endif
{{ $formatIsk($monthGroup['godo_commission']) }}
−
Sturla (10%)
{{ $formatIsk($monthGroup['sturla_bonus']) }}
−
Brynjar (10%)
{{ $formatIsk($monthGroup['brynjar_bonus']) }}
=
Net to company
{{ $formatIsk($monthGroup['net_revenue']) }}
@endif
@empty

No billing data for the selected filters.

@endforelse
{{-- /tab-billing --}} {{-- ─────────────────────── BY PROPERTY TAB ──────────────────────────── --}}
@php $bulkCandidateCount = $byProperty->where('unbilled_count', '>', 0)->count(); $bulkCandidateAmount = (int) $byProperty->where('unbilled_count', '>', 0)->sum('unbilled_total'); $inactiveCount = $byProperty->where('is_inactive', true)->count(); $noCustomerToInvoice = $byProperty->filter(fn($p) => $p['unbilled_count'] > 0 && empty($propertyEmailMap[$p['property_id']]?->dkplus_customer_id))->count(); @endphp {{-- Sticky toolbar — row 1: view + toggles + summary + bulk; row 2: search / group / sort --}}
{{-- View switch: "To invoice" (default) shows only properties with closed, unbilled months and hides settled rows; "All" shows everything. --}}
Show
{{-- Live count + summary --}}
{{ $bulkCandidateCount }} shown @if($bulkCandidateCount > 0) ·{{ $formatIsk($bulkCandidateAmount) }} to invoice @endif @if($noCustomerToInvoice > 0) ·{{ $noCustomerToInvoice }} without dkPlus customer @endif @if($inactiveCount > 0) ·{{ $inactiveCount }} inactive @endif info
{{-- Bulk close --}} @if($bulkCandidateCount > 0)
@csrf
@endif
{{-- Search ("/" focuses, Esc clears) --}}
{{-- Floating bulk-action bar — visible while ≥1 card is ticked --}}
0 selected
@csrf
{{-- Property cards (grouped client-side into .by-property-group sections) --}}
@forelse($byProperty as $prop) @php $propertyEmail = $propertyEmailMap[$prop['property_id']] ?? null; $hasCustomer = !empty($propertyEmail?->dkplus_customer_id); // Pending = not invoiced/skipped (incl. the running month) — drives the action bar. $pendingList = collect($prop['months'])->filter(fn($m) => empty($m['is_invoiced']) && empty($m['is_skipped']))->values(); $propNotes = (string) ($prop['notes'] ?? ''); $inactiveFrom = $prop['inactive_from'] ?? null; @endphp
{{-- /.by-property-toggle --}} {{-- Body is fetched on first expand (see propertyCard route) --}} {{-- /.by-property-collapse --}}
@empty
No billing data for the current filters.
@endforelse
{{-- /#byPropCards --}} {{-- Empty state for the client-side view/search — rendered ONCE and toggled by JS. --}}
search_off
No properties match your search.
check_circle Nothing left to invoice. Switch to All to see settled and inactive properties.
{{-- /tab-by-property --}} {{-- ─────────────────────── CUSTOMERS TAB ────────────────────────────── --}} {{-- Quick-fill dashboard for the dkPlus customer linkage on every property. Default sort: missing first (so the user lands on what needs work). Default filter: "Missing only" ON. --}}
@php // Sort: missing customer first, then by property name. $billedPropertyIds = collect($billing['rows'] ?? [])->pluck('property_id')->map(fn($id) => trim((string) $id))->unique(); $customersList = $properties ->map(function ($p) use ($propertyEmailMap, $propertySettingsMap, $currentMonth, $billedPropertyIds) { $setting = $propertySettingsMap[trim((string) $p->PropertyID)] ?? null; $inactiveFrom = $setting?->inactive_from ?: null; return [ // No billable lock in any month → never active; hidden unless "Show inactive". 'never_billed' => !$billedPropertyIds->contains(trim((string) $p->PropertyID)), 'property' => $p, 'email' => $propertyEmailMap[(string) $p->PropertyID] ?? null, 'has_customer' => !empty($propertyEmailMap[(string) $p->PropertyID]?->dkplus_customer_id), 'notes' => (string) ($setting?->notes ?? ''), 'inactive_from' => $inactiveFrom, // Superseded (older duplicate) is as non-actionable as inactive here. 'billing_disabled' => (bool) ($setting?->billing_disabled ?? false), 'is_inactive' => ($inactiveFrom !== null && $inactiveFrom <= $currentMonth) || !empty($setting?->superseded_by) || !empty($setting?->billing_disabled), ]; }) ->sortBy(fn($r) => sprintf('%d|%s', $r['has_customer'] ? 1 : 0, mb_strtolower((string) $r['property']->PropertyName) )) ->values(); $linkedCount = $customersList->where('has_customer', true)->count(); // Inactive / never-billed properties without a customer are not actionable — leave them out of "missing". $missingCount = $customersList->where('has_customer', false)->where('is_inactive', false)->where('never_billed', false)->count(); $inactiveCustomerCount = $customersList->filter(fn($r) => $r['is_inactive'] || $r['never_billed'])->count(); // Linked by id only — the name can be fetched from dkPlus in one click. $noNameCount = $customersList->filter(fn($r) => $r['has_customer'] && trim((string) ($r['email']?->dkplus_customer_name ?? '')) === '')->count(); @endphp {{-- Sticky toolbar --}}
Show
{{ $missingCount }} shown ·{{ $missingCount }} missing ·{{ $linkedCount }} linked
@if($noNameCount > 0) @endif
{{-- Customers table --}}
@foreach($customersList as $entry) @php $property = $entry['property']; $email = $entry['email']; $hasCustomer = $entry['has_customer']; @endphp @endforeach
Property API Status Internal notes dkPlus customer
{{ $property->PropertyName }}
{{ $property->PropertyID }}
{{ strtoupper($property->API ?: 'unknown') }} @if($hasCustomer) Linked @else Missing @endif @if($entry['billing_disabled']) Inactive @elseif($entry['inactive_from']) Inactive from {{ $entry['inactive_from'] }} @elseif($entry['never_billed']) Never active @endif
{{ $entry['notes'] !== '' ? $entry['notes'] : '—' }}
@csrf
search_off
No properties match the current filters.
{{-- /tab-customers --}} {{-- ─────────────────────── RECONCILE TAB ────────────────────────────── --}} {{-- Same place under two PropertyIDs (API move, re-onboarding, Frímann encoding accidents). Detection is computed by LockReconciliationService; the verdict (superseded_by / reconciled_at) is stored on lock_billing_property_settings. --}}
@php $resolvedClusterCount = $reconciliation->count() - $openClusterCount; $reasonLabel = fn(string $r, $detail) => match ($r) { 'shared_locks' => ['bg-gradient-danger', 'Shared locks · ' . (int) $detail, 'The same lock id is assigned under both property IDs'], 'same_customer' => ['bg-gradient-warning text-dark', 'Same dkPlus customer', 'Customer ' . $detail], 'similar_name' => ['bg-gradient-info', 'Similar name · ' . (int) round($detail * 100) . '%', 'Normalised names match'], default => ['bg-gradient-secondary', $r, ''], }; @endphp
{{ $openClusterCount }} open · {{ $resolvedClusterCount }} resolved Properties that look like the same place twice. Signals: shared lock ids, same dkPlus customer, similar names. Superseded by marks the older one and (optionally) stops its billing from the handover month — unbilled months before that stay for you to invoice or skip. Keep all quiets a cluster that is legitimately separate.
@forelse($reconciliation as $cluster) @php $memberIds = implode(',', array_column($cluster['members'], 'property_id')); @endphp
{{-- Title row: name · status · why it matched · Keep all / Reopen --}}
{{ $cluster['title'] }}
{{ ucfirst($cluster['status']) }} @foreach($cluster['reasons'] as $reason => $detail) @php [$cls, $label, $hint] = $reasonLabel($reason, $detail); @endphp {{ $label }} @endforeach
{{-- Double-billing: one line per matched pair --}} @if(count($cluster['overlap_pairs'] ?? []) > 0)
warning Both billed the same months
    @foreach(array_slice($cluster['overlap_pairs'], 0, 6) as $pair)
  • {{ $pair['a_name'] }} ↔ {{ $pair['b_name'] }} · {{ $pair['months'][0] }} – {{ end($pair['months']) }} · {{ count($pair['months']) }} {{ count($pair['months']) === 1 ? 'month' : 'months' }} · {{ $formatIsk($pair['total']) }}
  • @endforeach @if(count($cluster['overlap_pairs']) > 6)
  • +{{ count($cluster['overlap_pairs']) - 6 }} more pairs
  • @endif
@endif
@foreach($cluster['members'] as $m) @endforeach
Property Billed months Invoiced / unbilled dkPlus customer Status Actions
{{ $m['property_name'] }}
{{ strtoupper($m['api']) }} ID {{ $m['property_id'] }} · created {{ $m['created_at'] ?? '—' }}
@if($m['notes'] !== '')
sticky_note_2 {{ \Illuminate\Support\Str::limit($m['notes'], 80) }}
@endif @if(!empty($m['suggested_successor']))
Suggested superseded by {{ $propertyNameMap[$m['suggested_successor']] ?? $m['suggested_successor'] }} from {{ $m['suggested_from'] ?? '—' }} — {{ $m['suggestion_reason'] }}
@endif
@if($m['billed_months'] > 0) {{ $m['first_month'] }} – {{ $m['last_month'] }} ({{ $m['billed_months'] }}) @else no billing rows @endif {{ $m['invoiced_count'] }} invoiced · {{ $m['unbilled_count'] }} unbilled @if($m['unbilled_total'] > 0)
{{ $formatIsk($m['unbilled_total']) }}
@endif
@if($m['customer_id'] !== '')
{{ $m['customer_name'] !== '' ? $m['customer_name'] : '—' }}
#{{ $m['customer_id'] }}
@else No customer @endif
@if($m['billing_disabled']) Inactive @elseif($m['superseded_by']) Superseded by {{ $propertyNameMap[$m['superseded_by']] ?? $m['superseded_by'] }} @elseif($m['reconciled_at']) Kept @else Active @endif @if($m['inactive_from'])
inactive from {{ $m['inactive_from'] }}
@endif @if($m['automation_active'] && ($m['billing_disabled'] || $m['superseded_by'] || $m['inactive_from']))
bolt automation still active
@endif
@if(!empty($m['invoices_after_handover'])) {{-- Invoices to the OLD property covering months on/after the handover: the successor billed those months too → credit note in dkPlus. --}}
error_outline Invoiced after handover — credit note needed: @foreach($m['invoices_after_handover'] as $inv)
#{{ $inv['invoice_id'] }} · {{ $inv['months'][0] }}{{ count($inv['months']) > 1 ? ' – ' . end($inv['months']) : '' }} ({{ count($inv['months']) }} mo)
@endforeach
@endif
{{-- Row 1: Inactive switch (never active) + notes/settings --}}
{{-- Row 2: supersede (older duplicate → successor, handover month) --}}
@csrf @php $sugg = $m['suggested_successor'] ?? null; @endphp
@empty
check_circle
No duplicate candidates found.
@endforelse
check_circle
Every cluster has been reviewed. Tick Show resolved to see them.
{{-- /tab-reconcile --}} @if(auth()->id() === 4) {{-- ─────────────────────── COMMISSIONS TAB ──────────────────────────── --}} {{-- Owner-only (Sturla, user_id 4). Other users never see this pane. --}}
{{-- Grand-total summary cards --}}

Gross Revenue

{{ $formatIsk($billing['grand_total']) }}

All subscription billing

Godo Commission

{{ $formatIsk($billing['total_godo_commission']) }}

100 ISK × {{ $billing['total_godo_lock_count'] }} Godo lock(s) × month

Sturla Bonus

{{ $formatIsk($billing['total_sturla_bonus']) }}

10% of gross revenue

Brynjar Bonus

{{ $formatIsk($billing['total_brynjar_bonus']) }}

10% of gross revenue

Net to Company

{{ $formatIsk($billing['total_net_revenue']) }}

After commissions & bonuses

{{-- Month-by-month breakdown --}}
account_balance_wallet Monthly Commission Breakdown
Godo: 100 ISK/lock  |  Sturla & Brynjar: 10% each
@foreach($billing['month_groups'] as $mg) @endforeach
Month Gross Revenue Godo Commission Sturla (10%) Brynjar (10%) Net to Company
{{ $mg['month'] }} {{ $formatIsk($mg['month_total']) }} {{ $formatIsk($mg['godo_commission']) }} @if($mg['godo_lock_count'] > 0) {{ $mg['godo_lock_count'] }} Godo lock(s) @elseif($mg['godo_commission'] === 0) no Godo properties @endif {{ $formatIsk($mg['sturla_bonus']) }} {{ $formatIsk($mg['brynjar_bonus']) }} {{ $formatIsk($mg['net_revenue']) }}
Grand Total {{ $formatIsk($billing['grand_total']) }} {{ $formatIsk($billing['total_godo_commission']) }} @if($billing['total_godo_lock_count'] > 0) {{ $billing['total_godo_lock_count'] }} Godo lock(s) @endif {{ $formatIsk($billing['total_sturla_bonus']) }} {{ $formatIsk($billing['total_brynjar_bonus']) }} {{ $formatIsk($billing['total_net_revenue']) }}
{{-- /tab-commissions --}} @endif
{{-- /tab-content --}} {{-- Flash data passed to SweetAlert (no Bootstrap toast HTML needed) --}} {{-- ══════════════════ PROPERTY SETTINGS DIALOG (shared) ═════════════════ --}} {{-- Opened from the gear button on every property card and every Customers-tab row. Internal notes + "inactive from". A change to inactive_from reloads the page so the month rows are regenerated; notes-only saves update inline. --}} @if(session('status')) @elseif($errors->has('dkplus')) @endif
@endsection @push('css') @endpush @push('js') @endpush