feat: changes to the detail pages

This commit is contained in:
brian-inspiren 2026-08-06 09:45:05 +08:00
parent 7b5247ab50
commit ccf4daa92b
4 changed files with 208 additions and 45 deletions

View File

@ -2,16 +2,16 @@
namespace App\Models; namespace App\Models;
use App\Services\ClientInvoicePaymentSyncService;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Models\ClientProjectActivities;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class Client extends Model class Client extends Model
{ {
use HasFactory; use HasFactory;
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',
@ -54,16 +54,20 @@ public function latestRemainingAmount(): string
? $this->invoiceAdjustments ? $this->invoiceAdjustments
: $this->invoiceAdjustments()->get(['client_id', 'entry_type', 'amount']); : $this->invoiceAdjustments()->get(['client_id', 'entry_type', 'amount']);
$items = $invoices->flatMap(fn (ClientInvoice $invoice) => $invoice->payments) $invoiceItems = $invoices->flatMap(fn (ClientInvoice $invoice) => $invoice->payments
->flatMap(fn ($payment) => $payment->items); ->flatMap(fn ($payment) => $payment->items
->map(fn (ClientInvoicePaymentItem $item) => [
'invoice' => $invoice,
'item' => $item,
])));
$nettAmount = $items $nettAmount = $invoiceItems
->filter(fn ($item) => ! $item->is_creditcard && ($item->billingItemType?->nett_contribution ?? false)) ->filter(fn (array $row) => ! $row['item']->is_creditcard && ($row['item']->billingItemType?->nett_contribution ?? false))
->sum(fn ($item) => (float) ($item->final_net_amount ?? $item->net_amount ?? 0)); ->sum(fn (array $row) => (float) ($row['item']->final_net_amount ?? $row['item']->net_amount ?? 0));
$billableSpending = $items $billableSpending = $invoiceItems
->reject(fn ($item) => $item->is_creditcard) ->reject(fn (array $row) => $this->isCreditCardSpend($row['invoice'], $row['item']))
->sum(fn ($item) => (float) ($item->spending ?? 0)); ->sum(fn (array $row) => (float) ($row['item']->spending ?? 0));
$adjustmentNet = $adjustments->sum(function (ClientInvoiceAdjustment $adjustment) { $adjustmentNet = $adjustments->sum(function (ClientInvoiceAdjustment $adjustment) {
$amount = (float) ($adjustment->amount ?? 0); $amount = (float) ($adjustment->amount ?? 0);
@ -81,6 +85,17 @@ public function activitiesList(): HasMany
return $this->hasMany(ClientProjectActivities::class); return $this->hasMany(ClientProjectActivities::class);
} }
private function isCreditCardSpend(ClientInvoice $invoice, ClientInvoicePaymentItem $item): bool
{
if ($item->is_creditcard) {
return true;
}
return $invoice->is_credit_card
&& $item->billingItemType?->name === ClientInvoicePaymentSyncService::MANAGEMENT_SEARCH_NAME
&& (float) ($item->spending ?? 0) > 0;
}
public function customers() public function customers()
{ {
return $this->hasMany(ClientCustomer::class); return $this->hasMany(ClientCustomer::class);

View File

@ -1,21 +1,21 @@
import React, { useMemo } from 'react'; import { Client } from '@/types';
import AppLayout from '../../layouts/app-layout'; import { Link, router } from '@inertiajs/react';
import { import {
Container,
Title,
Group,
Button,
Badge,
ActionIcon, ActionIcon,
Badge,
Button,
Container,
Group,
Stack, Stack,
Text,
Tabs, Tabs,
Text,
Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconEye, IconRefresh } from '@tabler/icons-react'; import { IconEye, IconRefresh } from '@tabler/icons-react';
import { MantineReactTable } from 'mantine-react-table';
import type { MRT_Row } from 'mantine-react-table'; import type { MRT_Row } from 'mantine-react-table';
import { Link, router } from '@inertiajs/react'; import { MantineReactTable } from 'mantine-react-table';
import { Client } from "@/types"; import React, { useMemo } from 'react';
import AppLayout from '../../layouts/app-layout';
interface Props { interface Props {
clients: Client[]; clients: Client[];
@ -32,7 +32,10 @@ const parseAmount = (value?: number | string | null): number => {
return 0; return 0;
} }
const normalized = typeof value === 'string' ? Number(value.replace(/,/g, '')) : Number(value); const normalized =
typeof value === 'string'
? Number(value.replace(/,/g, ''))
: Number(value);
return Number.isNaN(normalized) ? 0 : normalized; return Number.isNaN(normalized) ? 0 : normalized;
}; };
@ -59,23 +62,30 @@ export default function TicketDetails({
googleCompanySyncRunning, googleCompanySyncRunning,
}: Props) { }: Props) {
const [syncing, setSyncing] = React.useState(false); const [syncing, setSyncing] = React.useState(false);
const [activeStatus, setActiveStatus] = React.useState<string | null>('all'); const [activeStatus, setActiveStatus] = React.useState<string | null>(
'all',
);
const campaignsData = clients ?? []; const campaignsData = clients ?? [];
const statusTabs = useMemo(() => { const statusTabs = useMemo(() => {
const counts = campaignsData.reduce<Record<string, number>>((acc, client) => { const counts = campaignsData.reduce<Record<string, number>>(
const status = client.status || 'UNKNOWN'; (acc, client) => {
acc[status] = (acc[status] ?? 0) + 1; const status = client.status || 'UNKNOWN';
return acc; acc[status] = (acc[status] ?? 0) + 1;
}, {}); return acc;
},
{},
);
return Object.entries(counts).sort(([a], [b]) => { return Object.entries(counts).sort(([a], [b]) => {
const aIndex = statusOrder.indexOf(a); const aIndex = statusOrder.indexOf(a);
const bIndex = statusOrder.indexOf(b); const bIndex = statusOrder.indexOf(b);
if (aIndex !== -1 || bIndex !== -1) { if (aIndex !== -1 || bIndex !== -1) {
return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) return (
- (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex); (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) -
(bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex)
);
} }
return a.localeCompare(b); return a.localeCompare(b);
@ -87,7 +97,9 @@ export default function TicketDetails({
return campaignsData; return campaignsData;
} }
return campaignsData.filter((client) => (client.status || 'UNKNOWN') === activeStatus); return campaignsData.filter(
(client) => (client.status || 'UNKNOWN') === activeStatus,
);
}, [activeStatus, campaignsData]); }, [activeStatus, campaignsData]);
const columns = useMemo( const columns = useMemo(
@ -95,18 +107,22 @@ export default function TicketDetails({
{ {
accessorKey: 'customer_id', accessorKey: 'customer_id',
header: 'Google ID', header: 'Google ID',
size: 150,
}, },
{ {
accessorKey: 'sql_acc_code', accessorKey: 'sql_acc_code',
header: 'Customer Code', header: 'Customer Code',
size: 170,
}, },
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Google Account Name', header: 'Google Account Name',
size: 320,
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
header: 'Status', header: 'Status',
size: 130,
Cell: ({ cell }: any) => { Cell: ({ cell }: any) => {
const value = cell.getValue() as string; const value = cell.getValue() as string;
return <Badge color={getStatusColor(value)}>{value}</Badge>; return <Badge color={getStatusColor(value)}>{value}</Badge>;
@ -115,19 +131,22 @@ export default function TicketDetails({
{ {
accessorKey: 'industry', accessorKey: 'industry',
header: 'Industry', header: 'Industry',
size: 180,
}, },
{ {
accessorKey: 'assigned_person', accessorKey: 'assigned_person',
header: 'Assigned Person', header: 'Assigned Person',
size: 180,
Cell: ({ cell }: any) => cell.getValue() ?? '—', Cell: ({ cell }: any) => cell.getValue() ?? '—',
}, },
{ {
accessorKey: 'sales_person', accessorKey: 'sales_person',
header: 'Sales Person', header: 'Sales Person',
size: 180,
Cell: ({ cell }: any) => cell.getValue() ?? '—', Cell: ({ cell }: any) => cell.getValue() ?? '—',
}, },
], ],
[] [],
); );
const renderRowActions = ({ row }: { row: MRT_Row<Client> }) => { const renderRowActions = ({ row }: { row: MRT_Row<Client> }) => {
@ -152,7 +171,7 @@ export default function TicketDetails({
setSyncing(true); setSyncing(true);
router.post( router.post(
route("google-ads.accounts.sync-google-company-details"), route('google-ads.accounts.sync-google-company-details'),
{}, {},
{ {
preserveScroll: true, preserveScroll: true,
@ -163,12 +182,13 @@ export default function TicketDetails({
return ( return (
<AppLayout> <AppLayout>
<Container size="xl" px="xs"> <Container fluid px="xs">
<Group position="apart" mb="md"> <Group position="apart" mb="md">
<Stack spacing={2}> <Stack spacing={2}>
<Title order={2}>Clients</Title> <Title order={2}>Clients</Title>
<Text size="sm" color="dimmed"> <Text size="sm" color="dimmed">
Sync Google account records before linking pending invoices to new clients. Sync Google account records before linking pending
invoices to new clients.
</Text> </Text>
</Stack> </Stack>
<Button <Button
@ -177,23 +197,39 @@ export default function TicketDetails({
loading={syncing} loading={syncing}
disabled={googleCompanySyncRunning} disabled={googleCompanySyncRunning}
> >
{googleCompanySyncRunning ? "Sync running" : "Sync Google records"} {googleCompanySyncRunning
? 'Sync running'
: 'Sync Google records'}
</Button> </Button>
</Group> </Group>
<MantineReactTable <MantineReactTable
columns={columns} columns={columns}
data={filteredClients} data={filteredClients}
enableRowActions // ✅ REQUIRED enableRowActions // ✅ REQUIRED
positionActionsColumn="first" // optional but recommended positionActionsColumn="first" // optional but recommended
renderRowActions={renderRowActions} renderRowActions={renderRowActions}
mantinePaperProps={{
style: { width: '100%' },
}}
mantineTableContainerProps={{
style: { width: '100%' },
}}
renderTopToolbarCustomActions={() => ( renderTopToolbarCustomActions={() => (
<Tabs value={activeStatus} onTabChange={setActiveStatus}> <Tabs
value={activeStatus}
onTabChange={setActiveStatus}
>
<Tabs.List> <Tabs.List>
<Tabs.Tab value="all">All ({campaignsData.length})</Tabs.Tab> <Tabs.Tab value="all">
All ({campaignsData.length})
</Tabs.Tab>
{statusTabs.map(([status, count]) => ( {statusTabs.map(([status, count]) => (
<Tabs.Tab key={status} value={status}> <Tabs.Tab key={status} value={status}>
<Group spacing={6}> <Group spacing={6}>
<Badge color={getStatusColor(status)} variant="dot"> <Badge
color={getStatusColor(status)}
variant="dot"
>
{status} {status}
</Badge> </Badge>
<Text size="sm" color="dimmed"> <Text size="sm" color="dimmed">

View File

@ -213,12 +213,26 @@ const getInvoicePayments = (invoice: ClientInvoice): ClientInvoicePayment[] =>
const getInvoiceItems = (invoice: ClientInvoice): ClientInvoicePaymentItem[] => const getInvoiceItems = (invoice: ClientInvoice): ClientInvoicePaymentItem[] =>
getInvoicePayments(invoice).flatMap((payment) => payment.items ?? []); getInvoicePayments(invoice).flatMap((payment) => payment.items ?? []);
const GOOGLE_SEARCH_MANAGEMENT_FEE_NAME = 'Management Fee (Google Search Ads)';
const isMediaItem = (item: ClientInvoicePaymentItem): boolean => const isMediaItem = (item: ClientInvoicePaymentItem): boolean =>
item.billing_item_type?.fee_type?.toLowerCase() === 'media'; item.billing_item_type?.fee_type?.toLowerCase() === 'media';
const isManagementItem = (item: ClientInvoicePaymentItem): boolean => const isManagementItem = (item: ClientInvoicePaymentItem): boolean =>
item.billing_item_type?.fee_type?.toLowerCase() === 'management'; item.billing_item_type?.fee_type?.toLowerCase() === 'management';
const carriesSearchMediaSpending = (item: ClientInvoicePaymentItem): boolean =>
item.billing_item_type?.name === GOOGLE_SEARCH_MANAGEMENT_FEE_NAME &&
parseNumber(item.spending) > 0;
const isCreditCardMediaSpendingItem = (
invoice: ClientInvoice,
item: ClientInvoicePaymentItem,
): boolean =>
(isMediaItem(item) && item.is_creditcard) ||
(carriesSearchMediaSpending(item) &&
(item.is_creditcard || Boolean(invoice.is_credit_card)));
const contributesToNet = (item: ClientInvoicePaymentItem): boolean => const contributesToNet = (item: ClientInvoicePaymentItem): boolean =>
item.billing_item_type?.nett_contribution ?? false; item.billing_item_type?.nett_contribution ?? false;
@ -232,7 +246,7 @@ const getInvoiceSpending = (invoice: ClientInvoice): number =>
const getCreditCardMediaSpending = (invoice: ClientInvoice): number => const getCreditCardMediaSpending = (invoice: ClientInvoice): number =>
getInvoiceItems(invoice).reduce((sum, item) => { getInvoiceItems(invoice).reduce((sum, item) => {
if (item.billing_item_types_id !== 1 || !item.is_creditcard) { if (!isCreditCardMediaSpendingItem(invoice, item)) {
return sum; return sum;
} }
@ -270,11 +284,20 @@ const getInvoiceIsCreditCard = (invoice: ClientInvoice): boolean => {
const getInvoicesMediaItemsAreAllCreditCard = ( const getInvoicesMediaItemsAreAllCreditCard = (
invoices: ClientInvoice[], invoices: ClientInvoice[],
): boolean => { ): boolean => {
const items = invoices const items = invoices.flatMap((invoice) =>
.flatMap(getInvoiceItems) getInvoiceItems(invoice)
.filter((item) => item.billing_item_types_id === 1); .filter(
(item) => isMediaItem(item) || carriesSearchMediaSpending(item),
)
.map((item) => ({ invoice, item })),
);
return items.length > 0 && items.every((item) => item.is_creditcard); return (
items.length > 0 &&
items.every(({ invoice, item }) =>
isCreditCardMediaSpendingItem(invoice, item),
)
);
}; };
const buildInvoiceTree = (invoices: ClientInvoice[]): InvoiceRow[] => { const buildInvoiceTree = (invoices: ClientInvoice[]): InvoiceRow[] => {

View File

@ -0,0 +1,89 @@
<?php
use App\Models\BillingItemType;
use App\Models\Client;
use App\Models\ClientInvoice;
use App\Models\ClientInvoicePayment;
use App\Models\ClientInvoicePaymentItem;
use App\Services\ClientInvoicePaymentSyncService;
function createRemainingAmountPaymentItem(
ClientInvoice $invoice,
int $billingItemTypeId,
array $attributes = [],
): ClientInvoicePaymentItem {
$payment = ClientInvoicePayment::query()->create([
'client_invoice_id' => $invoice->id,
]);
return ClientInvoicePaymentItem::query()->create(array_merge([
'client_invoice_payment_id' => $payment->id,
'billing_item_types_id' => $billingItemTypeId,
], $attributes));
}
beforeEach(function () {
BillingItemType::query()->insert([
[
'id' => 1,
'name' => ClientInvoicePaymentSyncService::MEDIA_SEARCH_NAME,
'nett_contribution' => true,
'fee_type' => 'Media',
],
[
'id' => 2,
'name' => ClientInvoicePaymentSyncService::MANAGEMENT_SEARCH_NAME,
'nett_contribution' => false,
'fee_type' => 'Management',
],
]);
});
test('it treats google search management spending on credit card invoices as credit card media spend', function () {
$client = Client::factory()->create();
$payToUsInvoice = ClientInvoice::query()->forceCreate([
'client_id' => $client->id,
'invoice_no' => 'INV-PAY-TO-US',
'is_credit_card' => false,
]);
createRemainingAmountPaymentItem($payToUsInvoice, 1, [
'final_net_amount' => 200,
'spending' => 0,
'is_creditcard' => false,
]);
$creditCardInvoice = ClientInvoice::query()->forceCreate([
'client_id' => $client->id,
'invoice_no' => 'INV-CREDIT-CARD',
'is_credit_card' => true,
]);
createRemainingAmountPaymentItem($creditCardInvoice, 2, [
'spending' => 125,
'is_creditcard' => false,
]);
expect($client->latestRemainingAmount())->toBe('200.00');
});
test('it keeps google search management spending billable on non credit card invoices', function () {
$client = Client::factory()->create();
$invoice = ClientInvoice::query()->forceCreate([
'client_id' => $client->id,
'invoice_no' => 'INV-PAY-TO-US',
'is_credit_card' => false,
]);
createRemainingAmountPaymentItem($invoice, 1, [
'final_net_amount' => 200,
'spending' => 0,
'is_creditcard' => false,
]);
createRemainingAmountPaymentItem($invoice, 2, [
'spending' => 125,
'is_creditcard' => false,
]);
expect($client->latestRemainingAmount())->toBe('75.00');
});