From 983097ea7cff2c2eed70af55af4251e063c57bc6 Mon Sep 17 00:00:00 2001 From: brian-inspiren Date: Thu, 6 Aug 2026 10:07:31 +0800 Subject: [PATCH] feat: changes to the index page --- app/Http/Controllers/GoogleAdsController.php | 8 +- app/Models/Client.php | 111 +++++++++++++++++- resources/js/pages/campaigns/index.tsx | 36 ++++++ resources/js/types/index.d.ts | 3 + .../ClientLatestRemainingAmountTest.php | 79 +++++++++++++ 5 files changed, 234 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/GoogleAdsController.php b/app/Http/Controllers/GoogleAdsController.php index a587bfb..dbc24b1 100644 --- a/app/Http/Controllers/GoogleAdsController.php +++ b/app/Http/Controllers/GoogleAdsController.php @@ -47,7 +47,13 @@ public function accounts() // $customerMap = $accounts->keyBy('customer_id'); $localClients = $this->hierarchyService ->scopeClientsVisibleTo(Client::query(), Auth::user()) - ->with('assignations.user', 'customers', 'invoices.payments.items.billingItemType', 'invoiceAdjustments') + ->with( + 'assignations.user', + 'customers', + 'invoices.payments.items.billingItemType', + 'invoices.linkedInvoice.payments.items.billingItemType', + 'invoiceAdjustments', + ) ->get(); $customerMap = $localClients->map(function ($data) { $assignedPerson = $data->assignations->firstWhere('role', ClientUserAssignation::ROLE_ASSIGNED_PERSON); diff --git a/app/Models/Client.php b/app/Models/Client.php index 8c79383..85a51de 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -3,18 +3,26 @@ namespace App\Models; use App\Services\ClientInvoicePaymentSyncService; +use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; class Client extends Model { use HasFactory; + private ?array $latestPaymentDateRange = null; + protected $fillable = ['name', 'customer_id', 'status', 'time_zone', 'industry', 'sql_acc_code']; protected $appends = [ 'latest_remaining_amount', + 'latest_start_date', + 'latest_end_date', + 'latest_remaining_days', ]; public function campaigns() @@ -42,13 +50,28 @@ public function getLatestRemainingAmountAttribute(): string return $this->latestRemainingAmount(); } + public function getLatestStartDateAttribute(): ?string + { + return $this->latestPaymentDateRange()['start_date']; + } + + public function getLatestEndDateAttribute(): ?string + { + return $this->latestPaymentDateRange()['end_date']; + } + + public function getLatestRemainingDaysAttribute(): ?int + { + return $this->latestRemainingDays(); + } + public function latestRemainingAmount(): string { $invoices = $this->relationLoaded('invoices') ? $this->invoices : $this->invoices()->with('payments.items.billingItemType')->get(); - $invoices->loadMissing('payments.items.billingItemType'); + $invoices->loadMissing('payments.items.billingItemType', 'linkedInvoice.payments.items.billingItemType'); $adjustments = $this->relationLoaded('invoiceAdjustments') ? $this->invoiceAdjustments @@ -77,7 +100,66 @@ public function latestRemainingAmount(): string : -$amount; }); - return number_format(max(0, $nettAmount + $adjustmentNet - $billableSpending), 2, '.', ''); + return number_format($nettAmount + $adjustmentNet - $billableSpending, 2, '.', ''); + } + + public function latestPaymentDateRange(): array + { + if ($this->latestPaymentDateRange !== null) { + return $this->latestPaymentDateRange; + } + + $row = $this->effectiveInvoiceItems() + ->filter(fn (array $row) => $row['item']->start_date !== null || $row['item']->end_date !== null) + ->sortByDesc(function (array $row) { + $item = $row['item']; + + return sprintf( + '%s|%s|%010d', + $this->dateSortValue($item->start_date), + $this->dateSortValue($item->end_date), + $item->id ?? 0, + ); + }) + ->first(); + + if ($row === null) { + return $this->latestPaymentDateRange = [ + 'start_date' => null, + 'end_date' => null, + ]; + } + + return $this->latestPaymentDateRange = [ + 'start_date' => $row['item']->start_date?->toDateString(), + 'end_date' => $row['item']->end_date?->toDateString(), + ]; + } + + public function latestRemainingDays(): ?int + { + $range = $this->latestPaymentDateRange(); + + if ($range['start_date'] === null || $range['end_date'] === null) { + return null; + } + + $startDate = Carbon::parse($range['start_date'])->startOfDay(); + $endDate = Carbon::parse($range['end_date'])->startOfDay(); + + if ($endDate->lessThan($startDate)) { + return null; + } + + $today = today()->startOfDay(); + + if ($today->greaterThan($endDate)) { + return 0; + } + + $fromDate = $today->lessThan($startDate) ? $startDate : $today; + + return (int) $fromDate->diffInDays($endDate); } public function activitiesList(): HasMany @@ -96,6 +178,31 @@ private function isCreditCardSpend(ClientInvoice $invoice, ClientInvoicePaymentI && (float) ($item->spending ?? 0) > 0; } + private function effectiveInvoiceItems(): Collection + { + $invoices = $this->relationLoaded('invoices') + ? $this->invoices + : $this->invoices()->with('payments.items.billingItemType', 'linkedInvoice.payments.items.billingItemType')->get(); + + $invoices->loadMissing('payments.items.billingItemType', 'linkedInvoice.payments.items.billingItemType'); + + return $invoices->flatMap(function (ClientInvoice $invoice) { + return collect([$invoice, $invoice->linkedInvoice]) + ->filter() + ->flatMap(fn (ClientInvoice $effectiveInvoice) => $effectiveInvoice->payments + ->flatMap(fn ($payment) => $payment->items + ->map(fn (ClientInvoicePaymentItem $item) => [ + 'invoice' => $effectiveInvoice, + 'item' => $item, + ]))); + }); + } + + private function dateSortValue(?CarbonInterface $date): string + { + return $date?->toDateString() ?? '0000-00-00'; + } + public function customers() { return $this->hasMany(ClientCustomer::class); diff --git a/resources/js/pages/campaigns/index.tsx b/resources/js/pages/campaigns/index.tsx index 1d68ecc..ad30b6b 100644 --- a/resources/js/pages/campaigns/index.tsx +++ b/resources/js/pages/campaigns/index.tsx @@ -1,4 +1,5 @@ import { Client } from '@/types'; +import { dateDisplay } from '@/utils/datetime'; import { Link, router } from '@inertiajs/react'; import { ActionIcon, @@ -39,6 +40,9 @@ const parseAmount = (value?: number | string | null): number => { return Number.isNaN(normalized) ? 0 : normalized; }; +const formatDate = (value?: string | null): string => + value ? dateDisplay(value) : '—'; + const statusOrder = ['ENABLED', 'PAUSED', 'REMOVED', 'ENDED', 'CANCELED']; const getStatusColor = (status: string) => { @@ -128,6 +132,38 @@ export default function TicketDetails({ return {value}; }, }, + { + accessorKey: 'latest_remaining_amount', + header: 'Remaining Amount', + size: 180, + Cell: ({ cell }: any) => { + const amount = parseAmount(cell.getValue()); + + return ( + + {currencyFormatter.format(amount)} + + ); + }, + }, + { + accessorKey: 'latest_start_date', + header: 'Latest Start Date', + size: 180, + Cell: ({ cell }: any) => formatDate(cell.getValue()), + }, + { + accessorKey: 'latest_end_date', + header: 'Latest End Date', + size: 180, + Cell: ({ cell }: any) => formatDate(cell.getValue()), + }, + { + accessorKey: 'latest_remaining_days', + header: 'Remaining Days', + size: 160, + Cell: ({ cell }: any) => cell.getValue() ?? '—', + }, { accessorKey: 'industry', header: 'Industry', diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 6515a3f..932a54c 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -59,6 +59,9 @@ export interface Client { updated_at: string; invoices?: ClientInvoice[]; latest_remaining_amount?: string; + latest_start_date?: string | null; + latest_end_date?: string | null; + latest_remaining_days?: number | null; assigned_person?: string; sales_person?: string; } diff --git a/tests/Feature/ClientLatestRemainingAmountTest.php b/tests/Feature/ClientLatestRemainingAmountTest.php index 2d2a68f..c8e3dd7 100644 --- a/tests/Feature/ClientLatestRemainingAmountTest.php +++ b/tests/Feature/ClientLatestRemainingAmountTest.php @@ -6,6 +6,7 @@ use App\Models\ClientInvoicePayment; use App\Models\ClientInvoicePaymentItem; use App\Services\ClientInvoicePaymentSyncService; +use Illuminate\Support\Carbon; function createRemainingAmountPaymentItem( ClientInvoice $invoice, @@ -39,6 +40,10 @@ function createRemainingAmountPaymentItem( ]); }); +afterEach(function () { + Carbon::setTestNow(); +}); + test('it treats google search management spending on credit card invoices as credit card media spend', function () { $client = Client::factory()->create(); @@ -87,3 +92,77 @@ function createRemainingAmountPaymentItem( expect($client->latestRemainingAmount())->toBe('75.00'); }); + +test('it allows negative remaining amount when billable spending exceeds invoice net amount', function () { + $client = Client::factory()->create(); + + $invoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-OVERSPEND', + 'is_credit_card' => false, + ]); + + createRemainingAmountPaymentItem($invoice, 1, [ + 'final_net_amount' => 100, + 'spending' => 175, + 'is_creditcard' => false, + ]); + + expect($client->latestRemainingAmount())->toBe('-75.00'); +}); + +test('it uses linked invoice payment dates when they are the latest range', function () { + Carbon::setTestNow('2026-06-25 10:00:00'); + + $client = Client::factory()->create(); + + $olderInvoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-OLDER', + ]); + createRemainingAmountPaymentItem($olderInvoice, 1, [ + 'start_date' => '2026-06-01', + 'end_date' => '2026-06-30', + ]); + + $linkedInvoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-LINKED-SOURCE', + ]); + createRemainingAmountPaymentItem($linkedInvoice, 2, [ + 'start_date' => '2026-08-01', + 'end_date' => '2026-08-31', + ]); + + $linkedPaymentInvoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-LINKED-PAYMENT', + 'linked_invoice_id' => $linkedInvoice->id, + ]); + createRemainingAmountPaymentItem($linkedPaymentInvoice, 2, [ + 'start_date' => '2026-09-01', + 'end_date' => '2026-09-30', + ]); + + expect($client->latestPaymentDateRange())->toBe([ + 'start_date' => '2026-09-01', + 'end_date' => '2026-09-30', + ])->and($client->latestRemainingDays())->toBe(29); +}); + +test('it counts remaining days from today within the latest range', function () { + Carbon::setTestNow('2026-06-25 10:00:00'); + + $client = Client::factory()->create(); + $invoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-CURRENT', + ]); + + createRemainingAmountPaymentItem($invoice, 1, [ + 'start_date' => '2026-06-01', + 'end_date' => '2026-06-30', + ]); + + expect($client->latestRemainingDays())->toBe(5); +});