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');
|
||||
$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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 <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',
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user