inspiren-sem-tool/resources/js/pages/campaigns/show.tsx
brian-inspiren 221d3f8173
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
feat: sem codebase
2026-05-21 11:28:03 +08:00

1767 lines
67 KiB
TypeScript

import React, { useMemo, useState, useEffect, useCallback } from "react";
import AppLayout from "../../layouts/app-layout";
import { Client, ClientInvoice, ClientInvoiceAdjustment } from "../../types";
import { FormStatus } from "@/types";
import axios from "axios";
import dayjs from "dayjs";
import { Link, router } from "@inertiajs/react";
import { Inertia } from "@inertiajs/inertia";
import { useDisclosure } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import ActivityForm from "@/forms/activities/activityForm";
import Table, { RowActionsProps } from "@/components/table";
import {
Title,
Group,
Button,
SimpleGrid,
Card,
Text,
Paper,
Badge,
Divider,
Stack,
ThemeIcon,
Tabs,
LoadingOverlay,
ActionIcon,
Center,
Flex,
Tooltip,
Switch,
Modal,
Portal,
NumberInput,
Select,
Textarea,
} from "@mantine/core";
import {
IconBuilding,
IconCurrencyDollar,
IconClock,
IconChartLine,
IconCalendarStats,
IconReportAnalytics,
IconEdit,
IconPlus,
IconTrash,
IconUsers,
IconNotebook,
IconEye,
IconChecks,
IconBriefcase,
IconDeviceTv,
IconWallet,
IconCash,
IconChartBar,
IconFileDollar,
IconCreditCard,
} from "@tabler/icons-react";
import { DatePickerInput } from "@mantine/dates";
import { MantineReactTable } from "mantine-react-table";
import type {
MRT_Cell,
MRT_ColumnDef,
MRT_Row,
} from "mantine-react-table";
import {
ResponsiveContainer,
LineChart,
Line,
CartesianGrid,
Tooltip as RechartTooltip,
XAxis,
YAxis,
Legend,
} from "recharts";
import { Tab } from "@headlessui/react";
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[];
};
type InvoiceNoSummaryRow = {
invoice_no: string;
payments: InvoiceRow[];
depth: number;
parentInvoiceNo?: string;
};
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 getInvoiceSpending = (invoice: ClientInvoice): number =>
invoice.total_spend !== undefined && invoice.total_spend !== null
? parseNumber(invoice.total_spend)
: parseNumber(invoice.total_spending);
const getBillableInvoiceSpending = (invoice: ClientInvoice): number =>
invoice.is_credit_card ? 0 : getInvoiceSpending(invoice);
const getBillableNettAmount = (invoice: ClientInvoice): number =>
invoice.is_credit_card ? 0 : parseNumber(invoice.nett_amount);
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 getPaymentAmount = (invoice: InvoiceRow): number =>
parseNumber(invoice.management_fee) + parseNumber(invoice.media_fee);
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 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(),
}
);
};
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)
: -Infinity;
const bDate = b.estimated_completed_at
? new Date(b.estimated_completed_at)
: -Infinity;
return bDate.getTime() - aDate.getTime();
});
}, [activities]);
const completedActivities = useMemo(() => {
return activities
.filter((activity) => !!activity.completed_at)
.sort((a, b) => {
const aDate = a.completed_at ? new Date(a.completed_at) : -Infinity;
const bDate = b.completed_at ? new Date(b.completed_at) : -Infinity;
return bDate.getTime() - aDate.getTime();
});
}, [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:
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,
});
};
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) => {
// Prefer computed total_spend (from metrics) when present; fall back to stored total_spending.
const value =
invoice.total_spend !== undefined && invoice.total_spend !== null
? parseNumber(invoice.total_spend as any)
: parseNumber(invoice.total_spending as any);
return sum + value;
}, 0);
const billableInvoiceSpending = invoicesData.reduce(
(sum, invoice) => sum + getBillableInvoiceSpending(invoice),
0
);
const nettAmountBase = invoicesData.reduce(
(sum, invoice) => sum + parseNumber(invoice.nett_amount ?? 0),
0
);
const nettAmount = nettAmountBase + adjustmentNet;
const remainingAmount = Math.max(0, nettAmount - billableInvoiceSpending);
return {
managementFee: invoicesData.reduce(
(sum, invoice) => sum + parseNumber(invoice.management_fee ?? 0),
0
),
mediaFee: invoicesData.reduce(
(sum, invoice) => sum + parseNumber(invoice.media_fee ?? 0),
0
),
invoiceSpending,
billableInvoiceSpending,
adjustmentNet,
nettAmount,
remainingAmount,
};
}, [clientInvoices, clientAdjustments]);
const groupedInvoices = useMemo(() => buildInvoiceTree(clientInvoices ?? []), [clientInvoices]);
const invoiceNoSummaries = useMemo<InvoiceNoSummaryRow[]>(() => {
const allInvoices: InvoiceRow[] = groupedInvoices.flatMap((invoice) =>
flattenInvoiceRows(invoice)
);
const invoiceById = new Map<number, InvoiceRow>();
allInvoices.forEach((invoice) => invoiceById.set(invoice.id, invoice));
// Group by invoice_no
const grouped = new Map<string, InvoiceRow[]>();
allInvoices.forEach((invoice) => {
const key = invoice.invoice_no ?? "—";
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key)!.push(invoice);
});
// Create summary rows from grouped invoices
const result: InvoiceNoSummaryRow[] = Array.from(grouped.entries()).map(
([invoice_no, payments]) => {
// Check if any payment in this group is linked (child)
const linkedPayment = payments.find(
(payment) => payment.linked_invoice_id && invoiceById.has(payment.linked_invoice_id)
);
return {
invoice_no,
payments: payments.sort((a, b) =>
(a.invoice_no ?? "").localeCompare(b.invoice_no ?? "", undefined, { numeric: true })
),
depth: linkedPayment ? 1 : 0,
parentInvoiceNo: linkedPayment
? invoiceById.get(linkedPayment.linked_invoice_id!)?.invoice_no
: undefined,
};
}
);
// Sort by parent, then by invoice_no
return result.sort((a, b) => {
const aParent = a.parentInvoiceNo ?? a.invoice_no;
const bParent = b.parentInvoiceNo ?? b.invoice_no;
const parentCompare = aParent.localeCompare(bParent, undefined, { numeric: true });
if (parentCompare !== 0) return parentCompare;
return a.invoice_no.localeCompare(b.invoice_no, undefined, { numeric: true });
});
}, [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 (RM)",
value: invoiceTotals.mediaFee,
icon: IconDeviceTv,
color: "grape",
},
{
label: "Net Amount (RM)",
value: invoiceTotals.nettAmount,
icon: IconWallet,
color: "green",
},
{
label: "Billable Spend (Invoice)",
value: invoiceTotals.billableInvoiceSpending,
icon: IconCurrencyDollar,
color: "indigo",
},
{
label: "Remaining Amount (RM)",
value: invoiceTotals.remainingAmount,
icon: IconChartBar,
color: "teal",
},
{
label: "Adjustments (RM)",
value: invoiceTotals.adjustmentNet,
icon: IconChartBar,
color: "cyan",
},
{
label: "Lifetime Spend (RM)",
value: lifeTimeSpending,
icon: IconCash,
color: "orange",
},
{
label: "Total Management Fees (RM)",
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<InvoiceNoSummaryRow>[]>(
() => [
{
accessorKey: "invoice_no",
header: "Invoice No",
size: 120,
Cell: ({ cell, row }) => {
const isCreditCard = row.original.payments.some(
(payment) => payment.is_credit_card
);
return (
<Group
spacing={4}
position={row.original.depth > 0 ? "right" : "left"}
noWrap
>
<Text>{cell.getValue<string>()}</Text>
{isCreditCard && (
<Tooltip label="Credit card" withArrow withinPortal>
<IconCreditCard size={16} color="teal" />
</Tooltip>
)}
</Group>
);
},
},
{
id: "billing_mode",
header: "Billing",
accessorFn: (row) =>
row.payments.every((payment) => payment.is_credit_card)
? "Credit Card"
: row.payments.some((payment) => payment.is_credit_card)
? "Mixed"
: "Pay to Us",
Cell: ({ cell }) => {
const value = cell.getValue<string>();
return (
<Badge
color={
value === "Credit Card"
? "teal"
: value === "Mixed"
? "yellow"
: "green"
}
variant="light"
>
{value}
</Badge>
);
},
},
{
id: "budget_total",
header: "Budget",
accessorFn: (row) => row.payments.reduce((sum, payment) => sum + parseNumber(payment.media_fee), 0),
Cell: ({ cell }) => `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: "budget_after_tax",
header: "Budget After Tax",
accessorFn: (row) => row.payments.reduce((sum, payment) => sum + parseNumber(payment.nett_amount), 0),
Cell: ({ cell }) => `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: "management_total",
header: "Management Fee",
accessorFn: (row) => row.payments.reduce((sum, payment) => sum + parseNumber(payment.management_fee), 0),
Cell: ({ cell }) => `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: "grand_total_invoice",
header: "Grand Total Invoice",
accessorFn: (row) => row.payments.reduce((sum, payment) => getPaymentAmount(payment), 0),
Cell: ({ cell }) => `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: "paid_amount",
header: "Paid Amount",
accessorFn: (row) => row.payments.reduce((sum, payment) => payment.is_paid ? getPaymentAmount(payment) : 0, 0),
Cell: ({ cell }) => `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: "outstanding",
header: "Outstanding",
accessorFn: (row) => {
const total = row.payments.reduce((sum, payment) => sum + getPaymentAmount(payment), 0);
const paid = row.payments.reduce((sum, payment) => sum + (payment.is_paid ? getPaymentAmount(payment) : 0), 0);
return Math.max(0, total - paid);
},
Cell: ({ cell }) => `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: "start_date",
header: "Start Date",
accessorFn: (row) => minDateString(row.payments.map(p => p.start_date)),
Cell: ({ cell }) => cell.getValue<string | null>() ?? "—",
},
{
id: "end_date",
header: "End Date",
accessorFn: (row) => maxDateString(row.payments.map(p => p.end_date)),
Cell: ({ cell }) => cell.getValue<string | null>() ?? "—",
},
{
id: "spending",
header: "Spending",
accessorFn: (row) => row.payments.reduce((sum, payment) => sum + getInvoiceSpending(payment), 0),
Cell: ({ cell }) => `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
{
id: "remaining_media",
header: "Remaining",
accessorFn: (row) => {
const nettAmount = row.payments.reduce(
(sum, payment) => sum + getBillableNettAmount(payment),
0
);
const spending = row.payments.reduce(
(sum, payment) => sum + getBillableInvoiceSpending(payment),
0
);
return Math.max(0, nettAmount - spending);
},
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.payments[0] } as any }),
},
],
[]
);
const paymentDetailColumns = useMemo<MRT_ColumnDef<InvoiceRow>[]>(
() => [
{
accessorKey: "invoice_no",
header: "Invoice No",
},
{
accessorKey: "payment_no",
header: "Payment No",
Cell: ({ cell }) => cell.getValue<string | null>() ?? "—",
},
{
id: "paid_amount",
header: "Paid Amount",
accessorFn: (row) => getPaymentAmount(row),
Cell: ({ cell }) => `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
// {
// id: "payment_date",
// header: "Payment Date",
// accessorFn: (row) => row.start_date,
// Cell: ({ cell }) => cell.getValue<string | null>() ?? "—",
// },
],
[]
);
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 gap="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 gap="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 gap="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="campaigns">
<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 justify="space-between" 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={invoiceNoSummaries}
enablePagination
enableExpanding
renderDetailPanel={({ row }) => (
<MantineReactTable
columns={paymentDetailColumns}
data={row.original.payments}
enablePagination={false}
enableTopToolbar={false}
enableBottomToolbar={false}
/>
)}
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
columnPinning={{ right: ['mrt-row-actions'] }}
/>
</Tabs.Panel>
<Tabs.Panel value="completed" pt="md">
<MantineReactTable
columns={completedColumns}
data={completedActivities}
renderRowActions={({ row }) =>
canModify ? rowOptions(row, false) : null
}
enableRowActions
columnPinning={{ right: ['mrt-row-actions'] }}
/>
</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 ?? 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>
);
}