{{-- Unlinked properties callout --}}
@if($unlinkedProperties->count() > 0)
{{-- /tab-billing --}}
{{-- ─────────────────────── BY PROPERTY TAB ──────────────────────────── --}}
link_off
@endif
{{-- Monthly Billing card --}}
{{ $unlinkedProperties->count() }} {{ $unlinkedProperties->count() === 1 ? 'property has' : 'properties have' }} no dkPlus customer linked
— invoices cannot be created until linked.
@foreach($unlinkedProperties as $up)
{{ $up['property_name'] }}
@endforeach
@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
@if(auth()->id() === 4)
{{-- Per-month commission breakdown — owner-only (user_id 4) --}}
@endif
@empty
{{ $formatIsk($monthGroup['month_total']) }}
@if($totalRows > 0)
{{ $invoicedRows }}/{{ $totalRows }} invoiced
@endif
@if($pendingInvoiceCount > 0)
{{ $pendingInvoiceCount }} not invoiced
@endif
| 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
|
{{-- dkPlus customer (read-only here — edit on the Customers tab) --}}
@if($propertyEmail?->dkplus_customer_id)
{{ $propertyEmail->dkplus_customer_name ?: '—' }}
{{ $propertyEmail->dkplus_customer_id }}
@else
No customer linked
@endif
|
{{-- Locks --}}
{{ $row['billable_unique_lock_count'] }} | {{-- Pricing --}}
{{ $row['pricing_rule_label'] }}
{{ $row['pricing_badge'] }}
{{ $formatIsk($row['unit_price']) }}/u
Formula
{{ $row['calculation_formula'] }}
Qty: {{ $row['input_quantity'] }} {{ $row['input_quantity_label'] }} |
{{-- Subtotal --}}
{{ $formatIsk($row['subtotal']) }} | {{-- Invoice actions --}}
@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']) }} | ||||
account_balance_walletCommission breakdown
Gross revenue
{{ $formatIsk($monthGroup['month_total']) }}
{{ $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']) }}
{{ $formatIsk($monthGroup['godo_commission']) }}
−
Sturla (10%)
{{ $formatIsk($monthGroup['sturla_bonus']) }}
{{ $formatIsk($monthGroup['sturla_bonus']) }}
−
Brynjar (10%)
{{ $formatIsk($monthGroup['brynjar_bonus']) }}
{{ $formatIsk($monthGroup['brynjar_bonus']) }}
=
Net to company
{{ $formatIsk($monthGroup['net_revenue']) }}
{{ $formatIsk($monthGroup['net_revenue']) }}
No billing data for the selected filters.
@endforelse
@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 --}}
{{-- Floating bulk-action bar — visible while ≥1 card is ticked --}}
{{-- Property cards (grouped client-side into .by-property-group sections) --}}
{{-- /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. --}}
@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-collapse --}}
@empty
@endforelse
{{-- /#byPropCards --}}
{{-- Empty state for the client-side view/search — rendered ONCE and toggled by JS. --}}
{{-- Bulk selection (floating action bar appears when ≥1 card is ticked) --}}
chevron_right
{{-- /.by-property-toggle --}}
{{-- Body is fetched on first expand (see propertyCard route) --}}
{{ $prop['property_name'] }} {{ strtoupper($prop['api'] ?: 'unknown') }} @if(!$hasCustomer) link_off No customer @endif {{-- Inactive badge — text/visibility updated inline by the settings form --}} @if(!empty($prop['superseded_by'])) call_merge Superseded by {{ $propertyNameMap[$prop['superseded_by']] ?? $prop['superseded_by'] }} @endif power_settings_new Inactive from {{ $inactiveFrom }} {{-- Oldest closed unbilled month — 1 = normal cycle, 2+ = slipped through --}} @php $age = (int) ($prop['unbilled_age_months'] ?? 0); @endphp history unbilled since {{ $prop['oldest_unbilled_month'] ?? '' }} ({{ $age }} mo) {{-- Selection-summary chip — visible when ≥1 month is checked --}} 0 selected · 0 ISK
{{-- Internal note preview — visible while the card is collapsed --}}
sticky_note_2
{{ $propNotes }}
{{-- Mini month strip — one chip per month, status-coloured. Visible even when card is collapsed. --}}
@foreach($prop['months'] as $m)
@php $st = !empty($m['is_invoiced']) ? 'invoiced' : (!empty($m['is_skipped']) ? 'skipped' : 'pending'); @endphp
@endforeach
{{-- One-click invoice of every closed, unbilled month (the common case) --}}
{{-- Notes & status (opens the shared settings dialog) --}}
Unbilled · {{ $prop['unbilled_count'] }}
{{ $prop['unbilled_count'] === 1 ? 'month' : 'months' }}
{{ $formatIsk($prop['unbilled_total']) }}
Lifetime · {{ $formatIsk($prop['all_time_total']) }}
·
{{ $prop['skipped_count'] }} skipped
Loading months…
No billing data for the current filters.
search_off
No properties match your search.
check_circle
Nothing left to invoice. Switch to All to see settled and inactive properties.
@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 --}}
{{-- Customers table --}}
{{-- /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. --}}
| 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'] : '—' }}
|
search_off
No properties match the current filters.
@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
@forelse($reconciliation as $cluster)
@php $memberIds = implode(',', array_column($cluster['members'], 'property_id')); @endphp
@empty
@endforelse
{{-- /tab-reconcile --}}
@if(auth()->id() === 4)
{{-- ─────────────────────── COMMISSIONS TAB ──────────────────────────── --}}
{{-- Owner-only (Sturla, user_id 4). Other users never see this pane. --}}
{{-- Title row: name · status · why it matched · Keep all / Reopen --}}
{{-- Double-billing: one line per matched pair --}}
@if(count($cluster['overlap_pairs'] ?? []) > 0)
@endif
{{ $cluster['title'] }}
{{ ucfirst($cluster['status']) }} @foreach($cluster['reasons'] as $reason => $detail) @php [$cls, $label, $hint] = $reasonLabel($reason, $detail); @endphp {{ $label }} @endforeach
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
| 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)
@endif
#{{ $inv['invoice_id'] }} · {{ $inv['months'][0] }}{{ count($inv['months']) > 1 ? ' – ' . end($inv['months']) : '' }} ({{ count($inv['months']) }} mo)
@endforeach
|
{{-- Row 1: Inactive switch (never active) + notes/settings --}}
|
check_circle
No duplicate candidates found.
check_circle
Every cluster has been reviewed. Tick Show resolved to see them.
{{-- Grand-total summary cards --}}
{{-- Month-by-month breakdown --}}
{{-- /tab-commissions --}}
@endif
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
account_balance_wallet Monthly Commission Breakdown
Godo: 100 ISK/lock | Sturla & Brynjar: 10% each| 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']) }} |