1767 lines
67 KiB
TypeScript
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>
|
|
);
|
|
}
|