inspiren-sem-tool/resources/js/pages/campaigns/show.tsx

2485 lines
85 KiB
TypeScript

import InvoicePdfButton from '@/components/invoice-pdf-button';
import Table from '@/components/table';
import ActivityForm from '@/forms/activities/activityForm';
import { FormStatus } from '@/types';
import { Inertia } from '@inertiajs/inertia';
import { Link, router } from '@inertiajs/react';
import { useDisclosure } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import axios from 'axios';
import dayjs from 'dayjs';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import AppLayout from '../../layouts/app-layout';
import {
Client,
ClientInvoice,
ClientInvoiceAdjustment,
ClientInvoicePayment,
ClientInvoicePaymentItem,
} from '../../types';
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Divider,
Flex,
Group,
LoadingOverlay,
Modal,
NumberInput,
Paper,
Portal,
Select,
SimpleGrid,
Stack,
Tabs,
Text,
Textarea,
ThemeIcon,
Title,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import {
IconBriefcase,
IconBuilding,
IconCash,
IconChartBar,
IconChecks,
IconClock,
IconCreditCard,
IconCurrencyDollar,
IconDeviceTv,
IconEdit,
IconEye,
IconLink,
IconNotebook,
IconPlus,
IconReportAnalytics,
IconTrash,
IconUsers,
IconWallet,
} from '@tabler/icons-react';
import type { MRT_Cell, MRT_ColumnDef, MRT_Row } from 'mantine-react-table';
import { MantineReactTable } from 'mantine-react-table';
import {
CartesianGrid,
Legend,
Line,
LineChart,
Tooltip as RechartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
interface Props {
id: string;
client: Client & {
activities_list?: ProjectActivity[];
can_modify?: boolean;
};
clientAssignmentRoles: AssignmentRole[];
clientAssignments: Record<number, number | null>;
assignmentUsers: SelectOption[];
clientInvoices: ClientInvoice[];
clientAdjustments: ClientInvoiceAdjustment[];
localClientId: number;
lifeTimeSpending: number;
}
interface AssignmentRole {
id: number;
label: string;
field: string;
}
interface SelectOption {
value: string;
label: string;
}
interface CampaignMetric {
date: string;
impressions: number;
clicks: number;
actual_spend: number;
conversions: number;
cost_per_conversion: number;
interactions: number;
interaction_rate: number;
}
interface CampaignDetail {
id: number;
name: string;
status: string;
total_impressions: number;
total_clicks: number;
total_actual_spend: string;
cpc?: number;
metrics: CampaignMetric[];
}
interface CampaignSummary {
total_actual_spend: string;
total_impressions: number;
total_clicks: number;
conversions: number;
cpc: number;
}
interface CampaignDetailResponse {
id: number;
name: string;
status: string;
total_impressions?: number | string;
total_clicks?: number | string;
total_actual_spend?: number | string;
metrics?: Array<Record<string, unknown>>;
}
interface ProjectActivity {
id: number;
activity_no?: string | null;
activity_type?: string | null;
task_description?: string | null;
estimated_completed_at?: string | null;
completed_at?: string | null;
notification_sent_at?: string | null;
notification_status?: number | null;
assignation?: {
ticket?: {
ticket_no?: string | null;
} | null;
} | null;
note_to_customer?: boolean;
}
type AdjustmentRow = {
row_kind: 'adjustment';
id: number;
client_id: number | null;
entry_type: 'debit' | 'credit';
amount: number;
remark: string | null;
created_at?: string | null;
};
type AdjustmentFormValues = {
entry_type: 'debit' | 'credit';
amount: number | '';
remark: string;
};
type InvoiceRow = ClientInvoice & {
row_kind: 'invoice';
linked_invoices?: InvoiceRow[];
};
const summaryDefaults: CampaignSummary = {
total_actual_spend: '0.00',
total_impressions: 0,
total_clicks: 0,
conversions: 0,
cpc: 0,
};
const currencyFormatter = new Intl.NumberFormat('en-MY', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const parseNumber = (value?: number | string | null): number => {
if (value === undefined || value === null) {
return 0;
}
const normalized =
typeof value === 'string'
? Number(value.replace(/,/g, ''))
: Number(value);
return Number.isNaN(normalized) ? 0 : normalized;
};
const getInvoicePayments = (invoice: ClientInvoice): ClientInvoicePayment[] =>
invoice.payments ?? [];
const getInvoiceItems = (invoice: ClientInvoice): ClientInvoicePaymentItem[] =>
getInvoicePayments(invoice).flatMap((payment) => payment.items ?? []);
const GOOGLE_SEARCH_MANAGEMENT_FEE_NAME = 'Management Fee (Google Search Ads)';
const isMediaItem = (item: ClientInvoicePaymentItem): boolean =>
item.billing_item_type?.fee_type?.toLowerCase() === 'media';
const isManagementItem = (item: ClientInvoicePaymentItem): boolean =>
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 =>
item.billing_item_type?.nett_contribution ?? false;
const getInvoiceSpending = (invoice: ClientInvoice): number =>
getInvoiceItems(invoice).reduce(
(sum, item) => sum + parseNumber(item.spending),
0,
) ||
parseNumber(invoice.total_sem_amount) ||
parseNumber(invoice.total_spend);
const getCreditCardMediaSpending = (invoice: ClientInvoice): number =>
getInvoiceItems(invoice).reduce((sum, item) => {
if (!isCreditCardMediaSpendingItem(invoice, item)) {
return sum;
}
return sum + parseNumber(item.spending);
}, 0);
const getBillableNettAmount = (invoice: ClientInvoice): number =>
getInvoiceItems(invoice).reduce((sum, item) => {
if (item.is_creditcard || !contributesToNet(item)) {
return sum;
}
return (
sum +
(parseNumber(item.final_net_amount) || parseNumber(item.net_amount))
);
}, 0);
const getInvoiceMediaFee = (invoice: ClientInvoice): number =>
getInvoiceItems(invoice)
.filter(isMediaItem)
.reduce((sum, item) => sum + parseNumber(item.net_amount), 0);
const getInvoiceManagementFee = (invoice: ClientInvoice): number =>
getInvoiceItems(invoice)
.filter(isManagementItem)
.reduce((sum, item) => sum + parseNumber(item.net_amount), 0);
const getInvoiceIsCreditCard = (invoice: ClientInvoice): boolean => {
const items = getInvoiceItems(invoice);
return items.length > 0 && items.every((item) => item.is_creditcard);
};
const getInvoicesMediaItemsAreAllCreditCard = (
invoices: ClientInvoice[],
): boolean => {
const items = invoices.flatMap((invoice) =>
getInvoiceItems(invoice)
.filter(
(item) => isMediaItem(item) || carriesSearchMediaSpending(item),
)
.map((item) => ({ invoice, item })),
);
return (
items.length > 0 &&
items.every(({ invoice, item }) =>
isCreditCardMediaSpendingItem(invoice, item),
)
);
};
const buildInvoiceTree = (invoices: ClientInvoice[]): InvoiceRow[] => {
const nodes: InvoiceRow[] = invoices.map((invoice) => ({
...invoice,
row_kind: 'invoice',
linked_invoices: [] as InvoiceRow[],
}));
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const childrenByParent = new Map<number, InvoiceRow[]>();
nodes.forEach((node) => {
const parentId = node.linked_invoice_id;
if (!parentId || !nodeById.has(parentId)) {
return;
}
const children = childrenByParent.get(parentId) ?? [];
children.push(node);
childrenByParent.set(parentId, children);
});
const visited = new Set<number>();
const attachChildren = (node: InvoiceRow): InvoiceRow => {
if (visited.has(node.id)) {
return node;
}
visited.add(node.id);
node.linked_invoices = (childrenByParent.get(node.id) ?? []).map(
(child) => attachChildren(child),
);
visited.delete(node.id);
return node;
};
const roots = nodes.filter(
(node) =>
!node.linked_invoice_id || !nodeById.has(node.linked_invoice_id),
);
if (roots.length === 0 && nodes.length > 0) {
return nodes;
}
return roots.map((node) => attachChildren(node));
};
const flattenInvoiceRows = (invoice: InvoiceRow): InvoiceRow[] => [
invoice,
...(invoice.linked_invoices ?? []).flatMap(flattenInvoiceRows),
];
const getPaymentTaxAmount = (payment: ClientInvoicePayment): number => {
const itemTaxAmount = (payment.items ?? []).reduce(
(sum, item) =>
sum +
Math.max(
0,
parseNumber(item.payment_item_amount) -
parseNumber(item.net_amount),
),
0,
);
if (itemTaxAmount > 0) {
return itemTaxAmount;
}
return Math.max(
0,
parseNumber(payment.payment_total_amount) -
parseNumber(payment.payment_nett_amount),
);
};
const getInvoiceAmount = (invoice: ClientInvoice): number => {
return parseNumber(invoice.total_sem_amount);
};
const getInvoiceNetAmount = (invoice: ClientInvoice): number => {
return parseNumber(invoice.total_net_amount);
};
const getInvoicePaidAmount = (invoice: ClientInvoice): number => {
return getInvoicePayments(invoice).reduce(
(sum, payment) => sum + parseNumber(payment.payment_nett_amount),
0,
);
};
const getInvoiceOutstandingAmount = (invoice: ClientInvoice): number =>
Math.max(0, getInvoiceNetAmount(invoice) - getInvoicePaidAmount(invoice));
const getPaymentStartDate = (payment: ClientInvoicePayment): string | null =>
minDateString((payment.items ?? []).map((item) => item.start_date));
const getPaymentEndDate = (payment: ClientInvoicePayment): string | null =>
maxDateString((payment.items ?? []).map((item) => item.end_date));
const getPaymentSpending = (payment: ClientInvoicePayment): number =>
(payment.items ?? []).reduce(
(sum, item) => sum + parseNumber(item.spending),
0,
);
const getPaymentAmount = (payment: ClientInvoicePayment): number =>
(payment.items ?? []).reduce(
(sum, item) => sum + parseNumber(item.payment_item_amount),
0,
) || parseNumber(payment.payment_total_amount);
const getPaymentNetAmount = (payment: ClientInvoicePayment): number =>
(payment.items ?? []).reduce(
(sum, item) =>
sum +
(parseNumber(item.final_net_amount) ||
parseNumber(item.net_amount)),
0,
) || parseNumber(payment.payment_nett_amount);
const formatCurrency = (value?: number | string | null): string =>
currencyFormatter.format(parseNumber(value));
const normalizeCurrencyValue = (value: number): number =>
Math.abs(value) < 0.005 ? 0 : value;
const formatPercent = (value?: number | string | null): string =>
`${currencyFormatter.format(parseNumber(value))}%`;
const formatShortDate = (value?: string | null): string =>
value ? dayjs(value).format('DD MMM YYYY') : '-';
const minDateString = (
values: Array<string | null | undefined>,
): string | null => {
const filtered = values.filter((value): value is string => !!value);
if (filtered.length === 0) return null;
return filtered.reduce(
(min, value) => (value < min ? value : min),
filtered[0],
);
};
const maxDateString = (
values: Array<string | null | undefined>,
): string | null => {
const filtered = values.filter((value): value is string => !!value);
if (filtered.length === 0) return null;
return filtered.reduce(
(max, value) => (value > max ? value : max),
filtered[0],
);
};
const normalizeCampaignMetrics = (
metrics: Array<Record<string, unknown>> = [],
): CampaignMetric[] =>
metrics.map((metric) => ({
date: (metric['date'] as string) ?? '',
impressions: parseNumber(metric['impressions'] as number | string),
clicks: parseNumber(metric['clicks'] as number | string),
actual_spend: parseNumber(metric['actual_spend'] as number | string),
conversions: parseNumber(metric['conversions'] as number | string),
cost_per_conversion: parseNumber(
metric['cost_per_conversion'] as number | string,
),
interactions: parseNumber(metric['interactions'] as number | string),
interaction_rate: parseNumber(
metric['interaction_rate'] as number | string,
),
}));
const buildSummary = (summary?: Record<string, unknown>): CampaignSummary => {
const totalClicks = parseNumber(
summary?.['total_clicks'] as number | string,
);
const totalImpressions = parseNumber(
summary?.['total_impressions'] as number | string,
);
const totalConversions = parseNumber(
summary?.['conversions'] as number | string | undefined,
);
const totalSpendValue = parseNumber(
summary?.['total_actual_spend'] as number | string | undefined,
);
const totalSpendString =
summary?.['total_actual_spend'] ?? summaryDefaults.total_actual_spend;
return {
total_actual_spend:
typeof totalSpendString === 'string'
? totalSpendString
: totalSpendValue.toFixed(2),
total_impressions: totalImpressions,
total_clicks: totalClicks,
conversions: totalConversions,
cpc: totalClicks > 0 ? totalSpendValue / totalClicks : 0,
};
};
const dateDisplay = (date: Date) => dayjs(date).format('DD MMM YYYY');
const isHtml = (value: string) => /<[^>]+>/i.test(value);
export default function TicketDetails({
id,
client,
clientAssignmentRoles,
clientAssignments,
assignmentUsers,
clientInvoices,
clientAdjustments,
localClientId,
lifeTimeSpending,
}: Props) {
const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([
dayjs().startOf('month').toDate(),
dayjs().endOf('month').toDate(),
]);
const [loading, setLoading] = useState(false);
const [pageActionLoading, setPageActionLoading] = useState(false);
const [campaigns, setCampaigns] = useState<CampaignDetail[]>([]);
const [metrics, setMetrics] = useState<CampaignSummary>(summaryDefaults);
const [description, setDescription] = useState('');
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
const handleOpenDescriptionModal = useCallback((value: string) => {
setDescription(value);
setIsDescriptionModalOpen(true);
}, []);
const handleCloseDescriptionModal = useCallback(() => {
setIsDescriptionModalOpen(false);
}, []);
const [
activityFormOpened,
{ open: openActivityForm, close: closeActivityForm },
] = useDisclosure(false);
const [activityFormMode, setActivityFormMode] =
useState<FormStatus>('create');
const [selectedActivity, setSelectedActivity] =
useState<ProjectActivity | null>(null);
const handleOpenActivityForm = useCallback(
(activity?: ProjectActivity) => {
setSelectedActivity(activity ?? null);
setActivityFormMode(activity ? 'update' : 'create');
openActivityForm();
},
[openActivityForm],
);
const handleCloseActivityForm = useCallback(() => {
closeActivityForm();
setSelectedActivity(null);
setActivityFormMode('create');
}, [closeActivityForm]);
const modals = useModals();
const [adjustmentModalOpened, setAdjustmentModalOpened] = useState(false);
const [selectedAdjustment, setSelectedAdjustment] =
useState<AdjustmentRow | null>(null);
const openAdjustmentModal = (adjustment?: AdjustmentRow) => {
setSelectedAdjustment(adjustment ?? null);
setAdjustmentModalOpened(true);
};
const closeAdjustmentModal = () => {
setAdjustmentModalOpened(false);
setSelectedAdjustment(null);
};
const activities = useMemo(
() => client.activities_list ?? [],
[client.activities_list],
);
const canModify = client.can_modify ?? true;
const pendingActivities = useMemo(() => {
return activities
.filter(
(activity) =>
!activity.completed_at && activity.estimated_completed_at,
)
.sort((a, b) => {
const aDate = a.estimated_completed_at
? new Date(a.estimated_completed_at).getTime()
: -Infinity;
const bDate = b.estimated_completed_at
? new Date(b.estimated_completed_at).getTime()
: -Infinity;
return bDate - aDate;
});
}, [activities]);
const completedActivities = useMemo(() => {
return activities
.filter((activity) => !!activity.completed_at)
.sort((a, b) => {
const aDate = a.completed_at
? new Date(a.completed_at).getTime()
: -Infinity;
const bDate = b.completed_at
? new Date(b.completed_at).getTime()
: -Infinity;
return bDate - aDate;
});
}, [activities]);
const notesActivities = useMemo(() => {
return activities.filter((activity) => activity.note_to_customer);
}, [activities]);
const lookupAssignmentLabel = (roleId: number) => {
const userId = clientAssignments?.[roleId];
if (!userId) {
return '—';
}
return (
assignmentUsers.find((option) => option.value === String(userId))
?.label ?? '—'
);
};
const accountSummary = [
{
label: 'Industry',
value: client.industry,
icon: <IconBuilding size={16} />,
},
{
label: 'Customer Code',
value: client.sql_acc_code,
icon: <IconUsers size={16} />,
},
{
label: 'Timezone',
value: client.time_zone,
icon: <IconClock size={16} />,
},
];
const fetchCampaigns = useCallback(
async (rangeParam?: [Date | null, Date | null]) => {
const rangeToUse = rangeParam ?? dateRange;
if (!rangeToUse[0] || !rangeToUse[1]) {
return;
}
try {
setLoading(true);
const startDate = dayjs(rangeToUse[0]).format('YYYY-MM-DD');
const endDate = dayjs(rangeToUse[1]).format('YYYY-MM-DD');
const res = await axios.post(
route('google.getCampaignsDetails'),
{
clientCustomerId: id,
startDate,
endDate,
},
);
const rawCampaigns: CampaignDetailResponse[] =
res.data.campaigns ?? [];
const normalizedCampaigns: CampaignDetail[] = rawCampaigns.map(
(campaign) => ({
...campaign,
total_clicks: parseNumber(campaign.total_clicks),
total_impressions: parseNumber(
campaign.total_impressions,
),
total_actual_spend: String(
campaign.total_actual_spend ??
summaryDefaults.total_actual_spend,
),
metrics: normalizeCampaignMetrics(campaign.metrics),
}),
);
const summaryData = buildSummary(res.data.summary);
setCampaigns(normalizedCampaigns);
setMetrics(summaryData);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
},
[dateRange, id],
);
const handleDeleteInvoice = (invoiceId: number) => {
if (
!window.confirm(
'This will permanently delete the invoice. Continue?',
)
) {
return;
}
Inertia.delete(
route('client-invoices.destroy', { invoice: invoiceId }),
{
preserveState: false,
onStart: () => setPageActionLoading(true),
onFinish: () => setPageActionLoading(false),
},
);
};
const handleDeleteAdjustment = (adjustmentId: number) => {
if (
!window.confirm(
'This will permanently delete the adjustment. Continue?',
)
) {
return;
}
Inertia.delete(
route('clients.adjustments.destroy', { adjustment: adjustmentId }),
{
preserveState: false,
onStart: () => setPageActionLoading(true),
onFinish: () => setPageActionLoading(false),
},
);
};
const renderLinkedInvoiceActions = ({
row,
}: {
row: MRT_Row<InvoiceRow>;
}) => {
const item = row.original;
return (
<Group spacing="xs">
{item.invoice_no !== null && (
<InvoicePdfButton
invoiceId={item.id}
invoiceNo={item.invoice_no}
/>
)}
<ActionIcon
component={Link}
href={route('client-invoices.edit', { invoice: item.id })}
color="blue"
variant="light"
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
color="red"
variant="light"
onClick={() => handleDeleteInvoice(item.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
);
};
const renderAdjustmentActions = ({
row,
}: {
row: MRT_Row<AdjustmentRow>;
}) => (
<Group spacing="xs">
<ActionIcon
color="blue"
variant="light"
onClick={() => openAdjustmentModal(row.original)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
color="red"
variant="light"
onClick={() => handleDeleteAdjustment(row.original.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
);
const columns = useMemo<MRT_ColumnDef<CampaignDetail>[]>(
() => [
{ accessorKey: 'name', header: 'Campaign' },
{
accessorKey: 'status',
header: 'Status',
Cell: ({ cell }: { cell: MRT_Cell<CampaignDetail> }) => {
const value = cell.getValue() as string;
const color =
value === 'ENABLED'
? 'green'
: value === 'PAUSED'
? 'yellow'
: value === 'ENDED'
? 'red'
: 'gray';
return <Badge color={color}>{value}</Badge>;
},
},
{ accessorKey: 'total_impressions', header: 'Impressions' },
{ accessorKey: 'total_clicks', header: 'Clicks' },
{ accessorKey: 'total_actual_spend', header: 'Spend (RM)' },
],
[],
);
const invoiceTotals = useMemo(() => {
const invoicesData = clientInvoices ?? [];
const adjustmentsData = clientAdjustments ?? [];
const adjustmentNet = adjustmentsData.reduce((sum, adj) => {
const value = parseNumber(adj.amount as any);
return sum + (adj.entry_type === 'credit' ? value : -value);
}, 0);
const invoiceSpending = invoicesData.reduce((sum, invoice) => {
return sum + getInvoiceSpending(invoice);
}, 0);
const creditCardMediaSpending = invoicesData.reduce(
(sum, invoice) => sum + getCreditCardMediaSpending(invoice),
0,
);
const nettAmountBase = invoicesData.reduce(
(sum, invoice) => sum + getBillableNettAmount(invoice),
0,
);
const shouldZeroSpendingAndRemaining =
getInvoicesMediaItemsAreAllCreditCard(invoicesData) ||
normalizeCurrencyValue(nettAmountBase) === 0;
const billableInvoiceSpending = shouldZeroSpendingAndRemaining
? 0
: Math.max(
0,
parseNumber(lifeTimeSpending) - creditCardMediaSpending,
);
const adjustedNettAmount = nettAmountBase + adjustmentNet;
const remainingAmount = shouldZeroSpendingAndRemaining
? 0
: normalizeCurrencyValue(
adjustedNettAmount - billableInvoiceSpending,
);
return {
managementFee: invoicesData.reduce(
(sum, invoice) => sum + getInvoiceManagementFee(invoice),
0,
),
mediaFee: invoicesData.reduce(
(sum, invoice) => sum + getInvoiceMediaFee(invoice),
0,
),
invoiceSpending,
billableInvoiceSpending,
adjustmentNet,
nettAmount: nettAmountBase,
remainingAmount,
};
}, [clientInvoices, clientAdjustments, lifeTimeSpending]);
const groupedInvoices = useMemo(
() => buildInvoiceTree(clientInvoices ?? []),
[clientInvoices],
);
const invoiceRows = useMemo<InvoiceRow[]>(
() => groupedInvoices.flatMap((invoice) => flattenInvoiceRows(invoice)),
[groupedInvoices],
);
const adjustmentRows = useMemo<AdjustmentRow[]>(
() =>
(clientAdjustments ?? []).map((adj) => ({
row_kind: 'adjustment',
id: adj.id,
client_id: adj.client_id ?? null,
entry_type: adj.entry_type,
amount: parseNumber(adj.amount as any),
remark: adj.remark ?? null,
created_at: adj.created_at ?? null,
})),
[clientAdjustments],
);
const summaryItems = [
{
label: 'Total Media Fees (Invoice)',
value: invoiceTotals.mediaFee,
icon: IconDeviceTv,
color: 'grape',
},
{
label: 'Net Amount (Invoice)',
value: invoiceTotals.nettAmount,
icon: IconWallet,
color: 'green',
},
{
label: 'Spending Media Fee (Live)',
value: invoiceTotals.billableInvoiceSpending,
icon: IconCurrencyDollar,
color: 'indigo',
},
{
label: 'Remaining Amount',
value: invoiceTotals.remainingAmount,
icon: IconChartBar,
color: 'teal',
valueColor:
invoiceTotals.remainingAmount < 0 ? 'red' : 'green',
},
{
label: 'Adjustments',
value: invoiceTotals.adjustmentNet,
icon: IconChartBar,
color: 'cyan',
},
{
label: 'Lifetime Spend (All)',
value: lifeTimeSpending,
icon: IconCash,
color: 'orange',
},
{
label: 'Total Management Fees (Invoice)',
value: invoiceTotals.managementFee,
icon: IconBriefcase,
color: 'blue',
},
];
const metricColumns = useMemo<MRT_ColumnDef<CampaignMetric>[]>(
() => [
{
accessorFn: (row) =>
row.date ? dayjs(row.date).startOf('day').toDate() : null,
id: 'date',
header: 'Date',
sortingFn: 'datetime',
Cell: ({ cell }) =>
cell.getValue<Date>()
? dateDisplay(cell.getValue<Date>())
: '-',
},
{ accessorKey: 'impressions', header: 'Impressions' },
{ accessorKey: 'clicks', header: 'Clicks' },
{ accessorKey: 'actual_spend', header: 'Spend (RM)' },
{ accessorKey: 'conversions', header: 'Conversions' },
{ accessorKey: 'cost_per_conversion', header: 'Cost/Conversion %' },
{ accessorKey: 'interactions', header: 'Interactions' },
{ accessorKey: 'interaction_rate', header: 'Interaction %' },
],
[],
);
const invoiceColumns = useMemo<MRT_ColumnDef<InvoiceRow>[]>(
() => [
{
accessorKey: 'invoice_no',
header: 'Invoice No',
size: 120,
Cell: ({ cell, row }) => {
const isCreditCard = getInvoiceIsCreditCard(row.original);
return (
<Group
spacing={4}
pl={row.original.linked_invoice_id ? 24 : 0}
noWrap
>
<Text>{cell.getValue<string>()}</Text>
{row.original.linked_invoice_id && (
<Tooltip
label="Linked invoice"
withArrow
withinPortal
>
<IconLink size={16} color="blue" />
</Tooltip>
)}
{isCreditCard && (
<Tooltip
label="Credit card"
withArrow
withinPortal
>
<IconCreditCard size={16} color="teal" />
</Tooltip>
)}
</Group>
);
},
},
{
id: 'invoice_amount',
header: 'Invoice Amount',
accessorFn: (row) => getInvoiceAmount(row),
Cell: ({ cell }) =>
`${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: 'net_amount',
header: 'Net Amount',
accessorFn: (row) => getInvoiceNetAmount(row),
Cell: ({ cell }) =>
`${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: 'paid_amount',
header: 'Paid Amount',
accessorFn: (row) => getInvoicePaidAmount(row),
Cell: ({ cell }) =>
`${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: 'outstanding',
header: 'Outstanding',
accessorFn: (row) => getInvoiceOutstandingAmount(row),
Cell: ({ cell }) =>
`${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
// {
// id: 'spending',
// header: 'Spending',
// accessorFn: (row) => getInvoiceSpending(row),
// Cell: ({ cell }) =>
// `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
// },
// {
// id: 'remark',
// header: 'Remark',
// accessorFn: () => '—',
// },
{
id: 'actions',
header: 'Actions',
Cell: ({ row }) =>
renderLinkedInvoiceActions({
row: { original: row.original } as any,
}),
},
],
[],
);
const adjustmentColumns = useMemo<MRT_ColumnDef<AdjustmentRow>[]>(
() => [
{
accessorKey: 'entry_type',
header: 'Entry Type',
Cell: ({ row }) =>
row.original.entry_type === 'credit' ? 'Credit' : 'Debit',
},
{
accessorKey: 'amount',
header: 'Amount',
Cell: ({ row }) => {
const amount = row.original.amount ?? 0;
const isCredit = row.original.entry_type === 'credit';
const signed = isCredit ? amount : -amount;
const isNegative = signed < 0;
const color = isNegative ? 'red' : 'green';
const sign = isNegative ? '-' : '+';
return (
<span style={{ color, fontWeight: '500' }}>
{sign} {currencyFormatter.format(Math.abs(signed))}
</span>
);
},
},
{
accessorKey: 'remark',
header: 'Remark',
Cell: ({ cell }) => cell.getValue<string | null>() ?? '—',
},
{
accessorKey: 'created_at',
header: 'Created At',
Cell: ({ cell }) => cell.getValue<string | null>() ?? '—',
},
],
[],
);
const pendingColumns = useMemo<MRT_ColumnDef<ProjectActivity>[]>(
() => [
{ header: 'Activity No', accessorKey: 'activity_no' },
{ header: 'Category', accessorKey: 'activity_type' },
{
header: 'Task Description',
accessorKey: 'task_description',
muiTableBodyCellProps: {
align: 'center',
},
muiTableHeadCellProps: {
align: 'center',
},
Cell: ({ cell }) => {
const value = cell.getValue<string>();
if (!value) {
return (
<Text size="sm" c="dimmed">
-
</Text>
);
}
return (
<ActionIcon
size="sm"
variant="subtle"
onClick={() => handleOpenDescriptionModal(value)}
>
<IconEye size={16} />
</ActionIcon>
);
},
},
{
header: 'Estimated Completion',
accessorKey: 'estimated_completed_at',
size: 180,
accessorFn: (activity) =>
activity.estimated_completed_at
? dayjs(activity.estimated_completed_at)
.startOf('day')
.toDate()
: null,
Cell: ({ cell }) => {
const date = cell.getValue<Date>();
if (!date)
return (
<Text size="sm" c="dimmed">
-
</Text>
);
return (
<Group spacing="xs">
<Text size="sm">{dateDisplay(date)}</Text>
</Group>
);
},
filterVariant: 'date-range',
filterFn: (row, columnId, filterValue) => {
const [start, end] = filterValue ?? [];
const value = row.getValue(columnId) as Date | null;
if (!start && !end) return true;
if (!value) return false;
return (
(!start ||
value >= dayjs(start).startOf('day').toDate()) &&
(!end || value <= dayjs(end).endOf('day').toDate())
);
},
sortingFn: 'datetime',
},
],
[handleOpenDescriptionModal],
);
const completedColumns = useMemo<MRT_ColumnDef<ProjectActivity>[]>(
() => [
{
header: 'Activity No',
accessorKey: 'activity_no',
},
{ header: 'Category', accessorKey: 'activity_type' },
{
header: 'Task Description',
accessorKey: 'task_description',
muiTableBodyCellProps: {
align: 'center',
},
muiTableHeadCellProps: {
align: 'center',
},
Cell: ({ cell }) => {
const value = cell.getValue<string>();
if (!value) {
return (
<Text size="sm" c="dimmed">
-
</Text>
);
}
return (
<ActionIcon
size="sm"
variant="subtle"
onClick={() => handleOpenDescriptionModal(value)}
>
<IconEye size={16} />
</ActionIcon>
);
},
},
{
header: 'Estimated Completion',
accessorKey: 'estimated_completed_at',
size: 180,
accessorFn: (activity) =>
activity.estimated_completed_at
? dayjs(activity.estimated_completed_at)
.startOf('day')
.toDate()
: null,
Cell: ({ cell }) => {
const date = cell.getValue<Date>();
if (!date)
return (
<Text size="sm" c="dimmed">
-
</Text>
);
return (
<Group spacing="xs">
<Text size="sm">{dateDisplay(date)}</Text>
</Group>
);
},
filterVariant: 'date-range',
filterFn: (row, columnId, filterValue) => {
const [start, end] = filterValue ?? [];
const value = row.getValue(columnId) as Date | null;
if (!start && !end) return true;
if (!value) return false;
return (
(!start ||
value >= dayjs(start).startOf('day').toDate()) &&
(!end || value <= dayjs(end).endOf('day').toDate())
);
},
sortingFn: 'datetime',
},
{
header: 'Completed Date',
accessorKey: 'completed_at',
size: 180,
Cell: ({ cell }) => {
const date = cell.getValue<Date>();
if (!date)
return (
<Text size="sm" c="dimmed">
-
</Text>
);
return (
<Group spacing="xs">
<Text size="sm">{dateDisplay(date)}</Text>
</Group>
);
},
filterVariant: 'date-range',
filterFn: (row, columnId, filterValue) => {
const [start, end] = filterValue ?? [];
const value = row.getValue(columnId) as Date | null;
if (!start && !end) return true;
if (!value) return false;
return (
(!start ||
value >= dayjs(start).startOf('day').toDate()) &&
(!end || value <= dayjs(end).endOf('day').toDate())
);
},
sortingFn: 'datetime',
},
],
[handleOpenDescriptionModal],
);
const notesColumns = useMemo<MRT_ColumnDef<ProjectActivity>[]>(
() => [
{ header: 'Activity No', accessorKey: 'activity_no' },
{
header: 'Task Description',
accessorKey: 'task_description',
Cell: ({ cell }) => {
const value = cell.getValue<string>();
if (!value) {
return (
<Center>
<Text size="sm" c="dimmed">
-
</Text>
</Center>
);
}
return (
<Center>
<ActionIcon
size="sm"
variant="subtle"
onClick={() =>
handleOpenDescriptionModal(value)
}
>
<IconEye size={16} />
</ActionIcon>
</Center>
);
},
},
{
header: 'Ticket No',
accessorFn: (activity) =>
activity.assignation?.ticket?.ticket_no ?? '-',
},
{
header: 'Status',
accessorFn: (activity) => ({
sent_at: activity.notification_sent_at,
status: activity.notification_status,
}),
Cell: ({ cell }) => {
const value = cell.getValue<{
sent_at?: string | null;
status?: number;
}>();
if (value?.sent_at) {
return <Badge color="green">Notified</Badge>;
}
if (!value?.sent_at && value?.status === 1) {
return <Badge color="yellow">Schedule to notify</Badge>;
}
return <Badge color="red">Not schedule</Badge>;
},
},
{
header: 'Sent Date',
accessorFn: (activity) =>
activity.notification_sent_at
? dayjs(activity.notification_sent_at).toDate()
: null,
Cell: ({ cell }) =>
cell.getValue<Date>()
? dateDisplay(cell.getValue<Date>())
: '-',
maxSize: 50,
},
],
[handleOpenDescriptionModal],
);
const renderActivityActions = (
activity: ProjectActivity,
allowComplete: boolean,
) => (
<Flex gap="xs">
{allowComplete &&
!activity.completed_at &&
activity.estimated_completed_at ? (
<ActionIcon
color="green"
onClick={() => completedButtonClicked(activity)}
>
<Tooltip
label="Complete Task"
color="green"
withArrow
withinPortal
>
<IconChecks />
</Tooltip>
</ActionIcon>
) : null}
<ActionIcon onClick={() => handleOpenActivityForm(activity)}>
<Tooltip label="Edit Task" withArrow withinPortal>
<IconEdit />
</Tooltip>
</ActionIcon>
<ActionIcon
color="red"
onClick={() => discardButtonClicked(activity)}
>
<Tooltip label="Discard" color="red" withArrow withinPortal>
<IconTrash color="red" />
</Tooltip>
</ActionIcon>
</Flex>
);
const rowOptions = (
row: MRT_Row<ProjectActivity>,
allowComplete: boolean,
) => renderActivityActions(row.original, allowComplete);
function completedButtonClicked(activity: ProjectActivity) {
let status = !!activity.notification_status;
const ModalContent = () => {
const [checked, setChecked] = useState(status);
status = checked;
return (
<>
<Text mb="sm">
Are you sure you want to complete this activity?
</Text>
{/* <Switch
label="Email Customer on Project Updates"
checked={checked}
onChange={(event) => setChecked(event.currentTarget.checked)}
/> */}
</>
);
};
modals.openConfirmModal({
title: 'Activity Completion Confirmation',
centered: true,
children: <ModalContent />,
labels: { confirm: 'Confirm', cancel: 'Cancel' },
confirmProps: { color: 'green' },
onConfirm: () => {
router.visit(
route('google-ads.accounts.activity.completeActivity', {
id: activity.id,
}),
{
method: 'patch',
data: { status },
onStart: () => setPageActionLoading(true),
onFinish: () => setPageActionLoading(false),
},
);
},
});
}
function discardButtonClicked(activity: ProjectActivity) {
modals.openConfirmModal({
title: 'Activity Discard Confirmation',
centered: true,
children: <Text>Are you sure want to discard this activity?</Text>,
labels: { confirm: 'Confirm', cancel: 'Cancel' },
confirmProps: { color: 'red' },
onConfirm: () => {
router.visit(
route('google-ads.accounts.activity.deleteActivity', {
id: activity.id,
}),
{
method: 'delete',
onStart: () => setPageActionLoading(true),
onFinish: () => setPageActionLoading(false),
},
);
},
});
}
// useEffect(() => {
// if (dateRange[0] && dateRange[1]) {
// fetchCampaigns(dateRange);
// }
// }, [dateRange, fetchCampaigns]);
const Chart = ({ data }: { data: CampaignMetric[] }) => (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<RechartTooltip />
<Legend />
<Line
type="monotone"
dataKey="clicks"
stroke="#228be6"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="actual_spend"
stroke="#fa5252"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="conversions"
stroke="#40c057"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
);
return (
<AppLayout>
<Portal>
<LoadingOverlay
visible={loading || pageActionLoading}
zIndex={2000}
overlayOpacity={0.75}
overlayColor="#000"
loaderProps={{ size: 'xl', color: 'white' }}
sx={{ position: 'fixed', inset: 0 }} // Use sx or styles to ensure fixed
/>
</Portal>
<Title order={2} mb="lg">
Client Dashboard
</Title>
{/* CLIENT INFO */}
<Paper withBorder p="lg" radius="md" mb="xl">
<Group position="apart">
<Group>
<ThemeIcon size="lg" variant="light">
<IconBuilding size={18} />
</ThemeIcon>
<Stack spacing={0}>
<Group spacing="xs">
<Title order={3}>{client.name}</Title>
<Badge
color={
client.status === 'ENABLED'
? 'green'
: 'red'
}
variant="light"
>
{client.status}
</Badge>
</Group>
<Text c="dimmed" size="sm">
Customer ID: {client.id}
</Text>
</Stack>
</Group>
</Group>
</Paper>
<Paper withBorder p="lg" radius="md" mb="xl">
<Group position="apart" mb="md">
<Title order={4}>Account Details</Title>
<Button
component={Link}
href={route('google-ads.accounts.edit', { id })}
variant="outline"
>
Edit account
</Button>
</Group>
<SimpleGrid
cols={3}
spacing="xs"
breakpoints={[
{ maxWidth: 'sm', cols: 1 },
{ maxWidth: 'md', cols: 2 },
]}
mb="md"
>
{accountSummary.map((item) => (
<AccountStatCard key={item.label} {...item} />
))}
</SimpleGrid>
<Divider />
<Stack spacing="sm" mt="md">
<Group position="apart" spacing="xs">
<Text size="sm" c="dimmed">
Assignments
</Text>
<Badge size="xs" color="gray" variant="light">
{clientAssignmentRoles.length} roles
</Badge>
</Group>
<SimpleGrid
cols={3}
spacing="sm"
breakpoints={[
{ maxWidth: 'md', cols: 2 },
{ maxWidth: 'sm', cols: 1 },
]}
>
{clientAssignmentRoles.map((role) => {
const assignee = lookupAssignmentLabel(role.id);
return (
<Card
key={role.id}
withBorder
radius="md"
p="sm"
>
<Group position="apart">
<Text size="xs" c="dimmed">
{role.label}
</Text>
<Badge
color={
assignee !== '—'
? 'green'
: 'gray'
}
variant="light"
>
{assignee !== '—'
? 'Assigned'
: 'Unassigned'}
</Badge>
</Group>
<Text fw={600}>{assignee}</Text>
</Card>
);
})}
</SimpleGrid>
</Stack>
</Paper>
<Tabs defaultValue="invoice">
<Tabs.List mb="lg">
{/* <Tabs.Tab icon={<IconChartLine size={16} />} value="campaigns">
Campaigns
</Tabs.Tab> */}
<Tabs.Tab
icon={<IconReportAnalytics size={16} />}
value="invoice"
>
Invoices
</Tabs.Tab>
<Tabs.Tab icon={<IconNotebook size={16} />} value="tasks">
Tasks
</Tabs.Tab>
</Tabs.List>
{/* <Tabs.Panel value="campaigns">
<Paper withBorder p="md" mb="lg">
<Group>
<DatePickerInput type="range" placeholder="Select date range" value={dateRange} onChange={setDateRange} />
<Button leftIcon={<IconCalendarStats size={16} />} onClick={() => fetchCampaigns()} loading={loading}>
Load Data
</Button>
</Group>
</Paper>
<SimpleGrid cols={5} mb="xl">
<KpiCard title="Clicks" value={metrics.total_clicks} icon={<IconChartLine size={18} />} />
<KpiCard title="Impressions" value={metrics.total_impressions} icon={<IconReportAnalytics size={18} />} />
<KpiCard title="Spend (RM)" value={metrics.total_actual_spend} icon={<IconCurrencyDollar size={18} />} />
<KpiCard title="CPC (RM)" value={metrics.cpc.toFixed(2)} icon={<IconChartLine size={18} />} />
<KpiCard title="Conversions" value={metrics.conversions} icon={<IconChartLine size={18} />} />
</SimpleGrid>
<Paper withBorder p="lg" mb="xl">
<Title order={4} mb="md">
Campaign Overview
</Title>
<MantineReactTable columns={columns} data={campaigns} enablePagination />
</Paper>
{campaigns.length > 0 && (
<Paper withBorder p="lg">
<Title order={4} mb="lg">
Campaign Analytics
</Title>
<Tabs defaultValue={campaigns[0].id.toString()}>
<Tabs.List>
{campaigns.map((c) => (
<Tabs.Tab key={c.id} value={c.id.toString()}>
{c.name}
</Tabs.Tab>
))}
</Tabs.List>
{campaigns.map((c) => (
<Tabs.Panel key={c.id} value={c.id.toString()} pt="md">
<Paper withBorder p="md" mb="lg">
<Title order={5} mb="md">
Performance Trend
</Title>
<Chart data={c.metrics ?? []} />
</Paper>
<MantineReactTable initialState={{
sorting: [
{
id: "date",
desc: true,
},
],
}} columns={metricColumns} data={c.metrics ?? []} />
</Tabs.Panel>
))}
</Tabs>
</Paper>
)}
</Tabs.Panel> */}
<Tabs.Panel value="invoice">
<Paper withBorder p="lg">
<Group position="apart" mb="md">
<Title order={4}>Invoices</Title>
<Group spacing="xs">
<Button
variant="outline"
onClick={() => openAdjustmentModal()}
leftIcon={<IconPlus size={16} />}
>
Add adjustment
</Button>
<Button
component={Link}
href={route('client-invoices.create', {
client: localClientId,
customer_id: id,
})}
leftIcon={<IconPlus size={16} />}
variant="outline"
>
Add invoice
</Button>
</Group>
</Group>
<SimpleGrid
cols={4}
spacing="sm"
mb="md"
breakpoints={[
{ maxWidth: 'md', cols: 2 },
{ maxWidth: 'xs', cols: 1 },
]}
>
{summaryItems.map((item) => {
const Icon = item.icon;
return (
<Card
key={item.label}
withBorder
radius="md"
p="sm"
>
<Group position="apart" mb="xs">
<Text size="xs" c="dimmed">
{item.label}
</Text>
<ThemeIcon
color={item.color}
variant="light"
radius="xl"
size="md"
>
<Icon size={16} />
</ThemeIcon>
</Group>
<Text
size="lg"
fw={600}
color={item.valueColor}
>
RM{' '}
{item.value.toLocaleString(
'en-MY',
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
)}
</Text>
</Card>
);
})}
</SimpleGrid>
<MantineReactTable
columns={invoiceColumns}
data={invoiceRows}
enablePagination
enableExpanding
renderDetailPanel={({ row }) => (
<InvoicePaymentDetails invoice={row.original} />
)}
initialState={{
density: 'xs',
}}
/>
<Title order={5} mt="lg" mb="sm">
Adjustments
</Title>
<Table
columns={adjustmentColumns}
data={adjustmentRows}
enablePagination
// initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}
renderRowActions={renderAdjustmentActions}
// enableRowActions
/>
<AdjustmentModal
opened={adjustmentModalOpened}
onClose={closeAdjustmentModal}
adjustment={selectedAdjustment}
clientId={localClientId}
onProcessingChange={setPageActionLoading}
/>
</Paper>
</Tabs.Panel>
<Tabs.Panel value="tasks">
<Paper withBorder p="lg">
<Group position="apart" mb="md">
<Title order={4}>Tasks</Title>
<Button
leftIcon={<IconPlus size={16} />}
variant="outline"
onClick={() => handleOpenActivityForm()}
>
New Task
</Button>
</Group>
<Tabs defaultValue="pending">
<Tabs.List>
<Tabs.Tab value="pending">
Pending Activities (
{pendingActivities.length})
</Tabs.Tab>
<Tabs.Tab value="completed">
Completed Activities (
{completedActivities.length})
</Tabs.Tab>
{/* <Tabs.Tab value="notes">Notes To Customer ({notesActivities.length})</Tabs.Tab> */}
</Tabs.List>
<Tabs.Panel value="pending" pt="md">
<MantineReactTable
columns={pendingColumns}
data={pendingActivities}
renderRowActions={({ row }) =>
canModify ? rowOptions(row, true) : null
}
enableRowActions
/>
</Tabs.Panel>
<Tabs.Panel value="completed" pt="md">
<MantineReactTable
columns={completedColumns}
data={completedActivities}
renderRowActions={({ row }) =>
canModify
? rowOptions(row, false)
: null
}
enableRowActions
/>
</Tabs.Panel>
<Tabs.Panel value="notes" pt="md">
<MantineReactTable
columns={notesColumns}
data={notesActivities}
enablePagination={false}
enableTopToolbar={false}
enableBottomToolbar={false}
renderRowActions={({ row }) =>
renderActivityActions(
row.original,
true,
)
}
enableColumnActions={false}
enableRowActions
// columnPinning={{ right: ['mrt-row-actions'] }}
/>
</Tabs.Panel>
</Tabs>
<Modal
opened={isDescriptionModalOpen}
onClose={handleCloseDescriptionModal}
title="Task Description"
size="lg"
>
{isHtml(description) ? (
<div
dangerouslySetInnerHTML={{
__html: description,
}}
style={{ wordBreak: 'break-word' }}
/>
) : (
<Text style={{ whiteSpace: 'pre-wrap' }}>
{description}
</Text>
)}
</Modal>
<Modal
opened={activityFormOpened}
onClose={handleCloseActivityForm}
title={
activityFormMode === 'create'
? 'New Activity'
: 'Edit Activity'
}
size="xl"
>
<ActivityForm
status={activityFormMode}
projectId={localClientId}
activity={
(selectedActivity as any) ?? undefined
}
activityCategories={[]}
unassignedTickets={[]}
formAction={
selectedActivity
? route(
'google-ads.accounts.activity.updateActivity',
{ id: selectedActivity.id },
)
: undefined
}
formMethod={selectedActivity ? 'patch' : 'post'}
onSuccess={handleCloseActivityForm}
/>
</Modal>
</Paper>
</Tabs.Panel>
</Tabs>
</AppLayout>
);
}
function KpiCard({
title,
value,
icon,
}: {
title: string;
value?: string | number | null;
icon: React.ReactNode;
}) {
return (
<Card withBorder radius="md" p="md">
<Group position="apart">
<Text size="xs" c="dimmed">
{title}
</Text>
<ThemeIcon variant="light">{icon}</ThemeIcon>
</Group>
<Text size="xl" fw={700} mt={6}>
{value ?? '-'}
</Text>
</Card>
);
}
interface AccountStatCardProps {
label: string;
value?: string | null;
icon: React.ReactNode;
}
function AccountStatCard({ label, value, icon }: AccountStatCardProps) {
return (
<Card withBorder radius="sm" p="sm">
<Group position="apart">
<Text size="xs" c="dimmed">
{label}
</Text>
<ThemeIcon variant="light" color="blue">
{icon}
</ThemeIcon>
</Group>
<Text fw={600}>{value || '—'}</Text>
</Card>
);
}
const adjustmentDefaults: AdjustmentFormValues = {
entry_type: 'debit',
amount: '',
remark: '',
};
const AdjustmentModal = memo(function AdjustmentModal({
opened,
onClose,
adjustment,
clientId,
onProcessingChange,
}: {
opened: boolean;
onClose: () => void;
adjustment: AdjustmentRow | null;
clientId: number;
onProcessingChange: (processing: boolean) => void;
}) {
const [values, setValues] =
useState<AdjustmentFormValues>(adjustmentDefaults);
const [processing, setProcessing] = useState(false);
useEffect(() => {
if (!opened) {
return;
}
setValues(
adjustment
? {
entry_type: adjustment.entry_type,
amount: adjustment.amount,
remark: adjustment.remark ?? '',
}
: adjustmentDefaults,
);
}, [adjustment, opened]);
const submit = () => {
onProcessingChange(true);
const payload = {
entry_type: values.entry_type,
amount: typeof values.amount === 'number' ? values.amount : 0,
remark: values.remark.trim() ? values.remark.trim() : null,
};
const options = {
preserveScroll: true,
preserveState: false,
onStart: () => setProcessing(true),
onFinish: () => {
setProcessing(false);
onProcessingChange(false);
},
onSuccess: () => {
onClose();
},
};
if (adjustment) {
router.patch(
route('clients.adjustments.update', {
adjustment: adjustment.id,
}),
payload,
options,
);
return;
}
router.post(
route('clients.adjustments.store', { client: clientId }),
payload,
options,
);
};
return (
<Modal
opened={opened}
onClose={onClose}
title={adjustment ? 'Edit adjustment' : 'Add adjustment'}
centered
>
<Stack spacing="sm">
<Select
label="Entry type"
data={[
{ value: 'debit', label: 'Debit' },
{ value: 'credit', label: 'Credit' },
]}
value={values.entry_type}
onChange={(value) =>
setValues((current) => ({
...current,
entry_type:
(value as 'debit' | 'credit') ?? 'debit',
}))
}
required
/>
<NumberInput
label="Amount"
precision={2}
step={0.01}
value={values.amount}
onChange={(value) =>
setValues((current) => ({
...current,
amount: value ?? '',
}))
}
required
min={0}
/>
<Textarea
label="Remark"
value={values.remark}
onChange={(event) => {
const remark = event.currentTarget.value;
setValues((current) => ({
...current,
remark,
}));
}}
minRows={2}
/>
<Group position="right" spacing="xs" mt="sm">
<Button
variant="default"
onClick={onClose}
disabled={processing}
>
Cancel
</Button>
<Button onClick={submit} loading={processing}>
Save
</Button>
</Group>
</Stack>
</Modal>
);
});
function InvoicePaymentDetails({ invoice }: { invoice: InvoiceRow }) {
const payments = getInvoicePayments(invoice);
if (payments.length === 0) {
return (
<Paper
withBorder
radius="sm"
p="md"
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
})}
>
<Text size="sm" c="dimmed">
No payments recorded for this invoice.
</Text>
</Paper>
);
}
return (
<Stack spacing="sm">
<SimpleGrid
cols={4}
spacing="sm"
breakpoints={[
{ maxWidth: 'md', cols: 2 },
{ maxWidth: 'xs', cols: 1 },
]}
>
<InvoiceDetailMetric
label="Payments"
value={payments.length.toLocaleString('en-MY')}
/>
<InvoiceDetailMetric
label="Payment amount"
value={`RM ${formatCurrency(getInvoicePaidAmount(invoice))}`}
/>
<InvoiceDetailMetric
label="Outstanding"
value={`RM ${formatCurrency(getInvoiceOutstandingAmount(invoice))}`}
tone={
getInvoiceOutstandingAmount(invoice) > 0
? 'red'
: 'green'
}
/>
<InvoiceDetailMetric
label="Tracked spending"
value={`RM ${formatCurrency(getInvoiceSpending(invoice))}`}
/>
</SimpleGrid>
{payments.map((payment, index) => (
<PaymentPanel
key={payment.id ?? index}
payment={payment}
index={index}
/>
))}
</Stack>
);
}
function InvoiceDetailMetric({
label,
value,
tone = 'gray',
}: {
label: string;
value: string;
tone?: 'gray' | 'green' | 'red';
}) {
const color =
tone === 'green' ? 'green.7' : tone === 'red' ? 'red.7' : undefined;
return (
<Paper
withBorder
radius="sm"
p="sm"
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[6]
: theme.white,
})}
>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fw={700} c={color}>
{value}
</Text>
</Paper>
);
}
function PaymentPanel({
payment,
index,
}: {
payment: ClientInvoicePayment;
index: number;
}) {
const items = payment.items ?? [];
const startDate = getPaymentStartDate(payment);
const endDate = getPaymentEndDate(payment);
return (
<Paper
withBorder
radius="sm"
p="md"
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[6]
: theme.white,
})}
>
<Group position="apart" align="flex-start" mb="sm">
<Stack spacing={2}>
<Group spacing="xs">
<ThemeIcon variant="light" color="blue" size="sm">
<IconWallet size={14} />
</ThemeIcon>
<Text fw={700}>
{payment.payment_no || `Payment ${index + 1}`}
</Text>
</Group>
<Text size="xs" c="dimmed">
{formatShortDate(startDate)} to{' '}
{formatShortDate(endDate)}
</Text>
</Stack>
<Group spacing="lg">
<PaymentAmount
label="Amount"
value={getPaymentAmount(payment)}
/>
<PaymentAmount
label="Tax"
value={getPaymentTaxAmount(payment)}
/>
<PaymentAmount
label="Net"
value={getPaymentNetAmount(payment)}
/>
<PaymentAmount
label="Spend"
value={getPaymentSpending(payment)}
/>
</Group>
</Group>
{items.length === 0 ? (
<Text size="sm" c="dimmed">
No payment items recorded.
</Text>
) : (
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
minWidth: 920,
borderCollapse: 'collapse',
tableLayout: 'fixed',
}}
>
<thead>
<tr>
<PaymentItemHeader width="20%">
Item
</PaymentItemHeader>
<PaymentItemHeader width="12%">
Billing
</PaymentItemHeader>
<PaymentItemHeader width="8%">
Dates
</PaymentItemHeader>
<PaymentItemHeader width="10%">
Spend
</PaymentItemHeader>
<PaymentItemHeader width="10%">
Amount
</PaymentItemHeader>
<PaymentItemHeader width="8%">
Tax
</PaymentItemHeader>
<PaymentItemHeader width="10%">
Net
</PaymentItemHeader>
<PaymentItemHeader width="10%">
WHT
</PaymentItemHeader>
<PaymentItemHeader width="12%">
Final Net
</PaymentItemHeader>
</tr>
</thead>
<tbody>
{items.map((item, itemIndex) => (
<PaymentItemRow
key={item.id ?? itemIndex}
item={item}
/>
))}
</tbody>
</table>
</div>
)}
</Paper>
);
}
function PaymentAmount({ label, value }: { label: string; value: number }) {
return (
<Stack spacing={0} align="flex-end">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={700}>
RM {formatCurrency(value)}
</Text>
</Stack>
);
}
function PaymentItemHeader({
children,
width,
}: {
children: React.ReactNode;
width: string;
}) {
const theme = useMantineTheme();
const isDark = theme.colorScheme === 'dark';
return (
<th
style={{
width,
padding: '8px 10px',
textAlign: 'left',
borderBottom: `1px solid ${
isDark ? theme.colors.dark[4] : theme.colors.gray[3]
}`,
backgroundColor: isDark
? theme.colors.dark[7]
: theme.colors.gray[0],
color: isDark ? theme.colors.dark[1] : theme.colors.gray[6],
fontSize: 12,
fontWeight: 600,
}}
>
{children}
</th>
);
}
function PaymentItemCell({
children,
align = 'left',
}: {
children: React.ReactNode;
align?: 'left' | 'right';
}) {
const theme = useMantineTheme();
const isDark = theme.colorScheme === 'dark';
return (
<td
style={{
padding: '10px',
textAlign: align,
borderBottom: `1px solid ${
isDark ? theme.colors.dark[5] : theme.colors.gray[1]
}`,
verticalAlign: 'top',
}}
>
{children}
</td>
);
}
function PaymentItemRow({ item }: { item: ClientInvoicePaymentItem }) {
const billingMode = item.is_creditcard ? 'Credit Card' : 'Pay to Us';
return (
<tr>
<PaymentItemCell>
<Stack spacing={2}>
<Text size="sm" fw={600}>
{item.billing_item_type?.name ?? 'Billing item'}
</Text>
<Text size="xs" c="dimmed">
{item.billing_item_type?.fee_type ?? '-'}
</Text>
</Stack>
</PaymentItemCell>
<PaymentItemCell>
<Badge
color={item.is_creditcard ? 'teal' : 'green'}
variant="light"
size="sm"
>
{billingMode}
</Badge>
</PaymentItemCell>
<PaymentItemCell>
<Text size="xs">
{formatShortDate(item.start_date)}
<br />
{formatShortDate(item.end_date)}
</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm" fw={600}>
RM {formatCurrency(item.spending)}
</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm">
RM {formatCurrency(item.payment_item_amount)}
</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm">{formatPercent(item.tax_percentage)}</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm">RM {formatCurrency(item.net_amount)}</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm">{formatPercent(item.withholding_tax)}</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm" fw={600}>
RM {formatCurrency(item.final_net_amount)}
</Text>
</PaymentItemCell>
</tr>
);
}