2368 lines
82 KiB
TypeScript
2368 lines
82 KiB
TypeScript
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, { useCallback, 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,
|
|
IconFileDollar,
|
|
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 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 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 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 (item.billing_item_types_id !== 1 || !item.is_creditcard) {
|
|
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(getInvoiceItems)
|
|
.filter((item) => item.billing_item_types_id === 1);
|
|
|
|
return items.length > 0 && items.every((item) => item.is_creditcard);
|
|
};
|
|
|
|
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 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 [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 [adjustmentEntryType, setAdjustmentEntryType] = useState<
|
|
'debit' | 'credit'
|
|
>('debit');
|
|
const [adjustmentAmount, setAdjustmentAmount] = useState<number | ''>('');
|
|
const [adjustmentRemark, setAdjustmentRemark] = useState('');
|
|
|
|
const openAdjustmentModal = () => {
|
|
setAdjustmentEntryType('debit');
|
|
setAdjustmentAmount('');
|
|
setAdjustmentRemark('');
|
|
setAdjustmentModalOpened(true);
|
|
};
|
|
|
|
const closeAdjustmentModal = () => setAdjustmentModalOpened(false);
|
|
|
|
const refreshAccount = () => {
|
|
router.visit(route('google-ads.accounts.show', { id }), {
|
|
preserveScroll: true,
|
|
preserveState: false,
|
|
replace: true,
|
|
});
|
|
};
|
|
|
|
const submitAdjustment = () => {
|
|
router.post(
|
|
route('clients.adjustments.store', { client: localClientId }),
|
|
{
|
|
entry_type: adjustmentEntryType,
|
|
amount:
|
|
typeof adjustmentAmount === 'number' ? adjustmentAmount : 0,
|
|
remark: adjustmentRemark.trim()
|
|
? adjustmentRemark.trim()
|
|
: null,
|
|
},
|
|
{
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
closeAdjustmentModal();
|
|
refreshAccount();
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
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: true,
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleDeleteAdjustment = (adjustmentId: number) => {
|
|
if (
|
|
!window.confirm(
|
|
'This will permanently delete the adjustment. Continue?',
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
Inertia.delete(
|
|
route('clients.adjustments.destroy', { adjustment: adjustmentId }),
|
|
{
|
|
preserveState: true,
|
|
onSuccess: () => {
|
|
refreshAccount();
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const renderLinkedInvoiceActions = ({
|
|
row,
|
|
}: {
|
|
row: MRT_Row<InvoiceRow>;
|
|
}) => {
|
|
const item = row.original;
|
|
return (
|
|
<Group spacing="xs">
|
|
{item.invoice_no !== null && (
|
|
<ActionIcon
|
|
component="a"
|
|
href={route('client-invoices.getPdfInvoice', {
|
|
id: item.id,
|
|
})}
|
|
target="_blank"
|
|
>
|
|
<Tooltip label={'View Invoice'} withArrow withinPortal>
|
|
<IconFileDollar color="green" />
|
|
</Tooltip>
|
|
</ActionIcon>
|
|
)}
|
|
<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="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 billableInvoiceSpending = Math.max(
|
|
0,
|
|
parseNumber(lifeTimeSpending) - creditCardMediaSpending,
|
|
);
|
|
|
|
const nettAmountBase = invoicesData.reduce(
|
|
(sum, invoice) => sum + getBillableNettAmount(invoice),
|
|
0,
|
|
);
|
|
|
|
const nettAmount = nettAmountBase + adjustmentNet;
|
|
const remainingAmount = getInvoicesMediaItemsAreAllCreditCard(
|
|
invoicesData,
|
|
)
|
|
? 0
|
|
: Math.max(0, nettAmount - billableInvoiceSpending);
|
|
|
|
return {
|
|
managementFee: invoicesData.reduce(
|
|
(sum, invoice) => sum + getInvoiceManagementFee(invoice),
|
|
0,
|
|
),
|
|
mediaFee: invoicesData.reduce(
|
|
(sum, invoice) => sum + getInvoiceMediaFee(invoice),
|
|
0,
|
|
),
|
|
invoiceSpending,
|
|
billableInvoiceSpending,
|
|
adjustmentNet,
|
|
nettAmount,
|
|
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 (Google Live)',
|
|
value: invoiceTotals.billableInvoiceSpending,
|
|
icon: IconCurrencyDollar,
|
|
color: 'indigo',
|
|
},
|
|
{
|
|
label: 'Remaining Amount',
|
|
value: invoiceTotals.remainingAmount,
|
|
icon: IconChartBar,
|
|
color: 'teal',
|
|
},
|
|
{
|
|
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 },
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
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',
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
// 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}
|
|
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}>
|
|
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
|
|
/>
|
|
|
|
<Modal
|
|
opened={adjustmentModalOpened}
|
|
onClose={closeAdjustmentModal}
|
|
title="Add adjustment"
|
|
centered
|
|
>
|
|
<Stack spacing="sm">
|
|
<Select
|
|
label="Entry type"
|
|
data={[
|
|
{ value: 'debit', label: 'Debit' },
|
|
{ value: 'credit', label: 'Credit' },
|
|
]}
|
|
value={adjustmentEntryType}
|
|
onChange={(value) =>
|
|
setAdjustmentEntryType(
|
|
(value as 'debit' | 'credit') ??
|
|
'debit',
|
|
)
|
|
}
|
|
required
|
|
/>
|
|
|
|
<NumberInput
|
|
label="Amount"
|
|
precision={2}
|
|
step={0.01}
|
|
value={adjustmentAmount}
|
|
onChange={(value) =>
|
|
setAdjustmentAmount(value ?? '')
|
|
}
|
|
required
|
|
min={0}
|
|
/>
|
|
|
|
<Textarea
|
|
label="Remark"
|
|
value={adjustmentRemark}
|
|
onChange={(event) =>
|
|
setAdjustmentRemark(
|
|
event.currentTarget.value,
|
|
)
|
|
}
|
|
minRows={2}
|
|
/>
|
|
|
|
<Group position="right" spacing="xs" mt="sm">
|
|
<Button
|
|
variant="default"
|
|
onClick={closeAdjustmentModal}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={submitAdjustment}>
|
|
Save
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|