feat: changes to the index page
This commit is contained in:
parent
ccf4daa92b
commit
983097ea7c
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
3
resources/js/types/index.d.ts
vendored
3
resources/js/types/index.d.ts
vendored
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user