feat: changes to the index page

This commit is contained in:
brian-inspiren 2026-08-06 10:07:31 +08:00
parent ccf4daa92b
commit 983097ea7c
5 changed files with 234 additions and 3 deletions

View File

@ -47,7 +47,13 @@ public function accounts()
// $customerMap = $accounts->keyBy('customer_id'); // $customerMap = $accounts->keyBy('customer_id');
$localClients = $this->hierarchyService $localClients = $this->hierarchyService
->scopeClientsVisibleTo(Client::query(), Auth::user()) ->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(); ->get();
$customerMap = $localClients->map(function ($data) { $customerMap = $localClients->map(function ($data) {
$assignedPerson = $data->assignations->firstWhere('role', ClientUserAssignation::ROLE_ASSIGNED_PERSON); $assignedPerson = $data->assignations->firstWhere('role', ClientUserAssignation::ROLE_ASSIGNED_PERSON);

View File

@ -3,18 +3,26 @@
namespace App\Models; namespace App\Models;
use App\Services\ClientInvoicePaymentSyncService; use App\Services\ClientInvoicePaymentSyncService;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class Client extends Model class Client extends Model
{ {
use HasFactory; use HasFactory;
private ?array $latestPaymentDateRange = null;
protected $fillable = ['name', 'customer_id', 'status', 'time_zone', 'industry', 'sql_acc_code']; protected $fillable = ['name', 'customer_id', 'status', 'time_zone', 'industry', 'sql_acc_code'];
protected $appends = [ protected $appends = [
'latest_remaining_amount', 'latest_remaining_amount',
'latest_start_date',
'latest_end_date',
'latest_remaining_days',
]; ];
public function campaigns() public function campaigns()
@ -42,13 +50,28 @@ public function getLatestRemainingAmountAttribute(): string
return $this->latestRemainingAmount(); 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 public function latestRemainingAmount(): string
{ {
$invoices = $this->relationLoaded('invoices') $invoices = $this->relationLoaded('invoices')
? $this->invoices ? $this->invoices
: $this->invoices()->with('payments.items.billingItemType')->get(); : $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') $adjustments = $this->relationLoaded('invoiceAdjustments')
? $this->invoiceAdjustments ? $this->invoiceAdjustments
@ -77,7 +100,66 @@ public function latestRemainingAmount(): string
: -$amount; : -$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 public function activitiesList(): HasMany
@ -96,6 +178,31 @@ private function isCreditCardSpend(ClientInvoice $invoice, ClientInvoicePaymentI
&& (float) ($item->spending ?? 0) > 0; && (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() public function customers()
{ {
return $this->hasMany(ClientCustomer::class); return $this->hasMany(ClientCustomer::class);

View File

@ -1,4 +1,5 @@
import { Client } from '@/types'; import { Client } from '@/types';
import { dateDisplay } from '@/utils/datetime';
import { Link, router } from '@inertiajs/react'; import { Link, router } from '@inertiajs/react';
import { import {
ActionIcon, ActionIcon,
@ -39,6 +40,9 @@ const parseAmount = (value?: number | string | null): number => {
return Number.isNaN(normalized) ? 0 : normalized; return Number.isNaN(normalized) ? 0 : normalized;
}; };
const formatDate = (value?: string | null): string =>
value ? dateDisplay(value) : '—';
const statusOrder = ['ENABLED', 'PAUSED', 'REMOVED', 'ENDED', 'CANCELED']; const statusOrder = ['ENABLED', 'PAUSED', 'REMOVED', 'ENDED', 'CANCELED'];
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@ -128,6 +132,38 @@ export default function TicketDetails({
return <Badge color={getStatusColor(value)}>{value}</Badge>; return <Badge color={getStatusColor(value)}>{value}</Badge>;
}, },
}, },
{
accessorKey: 'latest_remaining_amount',
header: 'Remaining Amount',
size: 180,
Cell: ({ cell }: any) => {
const amount = parseAmount(cell.getValue());
return (
<Text color={amount < 0 ? 'red' : 'green'} weight={700}>
{currencyFormatter.format(amount)}
</Text>
);
},
},
{
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', accessorKey: 'industry',
header: 'Industry', header: 'Industry',

View File

@ -59,6 +59,9 @@ export interface Client {
updated_at: string; updated_at: string;
invoices?: ClientInvoice[]; invoices?: ClientInvoice[];
latest_remaining_amount?: string; latest_remaining_amount?: string;
latest_start_date?: string | null;
latest_end_date?: string | null;
latest_remaining_days?: number | null;
assigned_person?: string; assigned_person?: string;
sales_person?: string; sales_person?: string;
} }

View File

@ -6,6 +6,7 @@
use App\Models\ClientInvoicePayment; use App\Models\ClientInvoicePayment;
use App\Models\ClientInvoicePaymentItem; use App\Models\ClientInvoicePaymentItem;
use App\Services\ClientInvoicePaymentSyncService; use App\Services\ClientInvoicePaymentSyncService;
use Illuminate\Support\Carbon;
function createRemainingAmountPaymentItem( function createRemainingAmountPaymentItem(
ClientInvoice $invoice, 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 () { test('it treats google search management spending on credit card invoices as credit card media spend', function () {
$client = Client::factory()->create(); $client = Client::factory()->create();
@ -87,3 +92,77 @@ function createRemainingAmountPaymentItem(
expect($client->latestRemainingAmount())->toBe('75.00'); 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);
});