import { ActionIcon, Anchor, AppShell, Avatar, Badge, Box, Burger, Button, Container, Group, Header, Indicator, Loader, MediaQuery, Menu, Modal, Navbar, Stack, Text, Tooltip, useMantineTheme, } from '@mantine/core'; import React from 'react'; import { SidebarProvider } from '@/components/ui/sidebar'; import { logout } from '@/routes'; import { edit } from '@/routes/profile'; import { ClientInvoicePayment, ClientInvoicePaymentItem, SharedData, } from '@/types'; import { Link, router, usePage } from '@inertiajs/react'; import { notifications } from '@mantine/notifications'; import { IconAlertCircle, IconBell, IconCircleX, IconFileDollar, IconInfoCircle, IconLogout, IconSettings, IconUserPlus, } from '@tabler/icons-react'; import { MantineReactTable, type MRT_ColumnDef, type MRT_Row, } from 'mantine-react-table'; import { useEffect } from 'react'; import Sidebar from '../components/sidebar'; import ThemeToggle from '../components/theme-toggle'; type PendingInvoiceNotification = { id: number; client_id: number | null; invoice_no: string; pending_sql_acc_code?: string | null; pending_client_name?: string | null; requires_client?: boolean; is_credit_card?: boolean; is_paid?: boolean; payment_no: string | null; amount: string | number | null; management_fee: string | number | null; management_fee_amount?: string | number | null; management_fee_tax?: string | number | null; media_fee: string | number | null; media_fee_amount?: string | number | null; media_fee_tax?: string | number | null; tax_percent?: string | number | null; nett_amount?: string | number | null; total_net_amount?: string | number | null; created_at: string | null; payments?: ClientInvoicePayment[]; previous_payments?: PreviousPaymentRecord[]; invoice_billing_totals?: { media_fee: string | number | null; management_fee: string | number | null; }; client?: { name: string | null; } | null; }; type PreviousPaymentRecord = { payment_number: string | null; status: string | null; sql_created_at: string | null; amount: string | number | null; media_fee: string | number | null; management_fee: string | number | null; invoice_media_fee: string | number | null; invoice_management_fee: string | number | null; invoice_number: string | null; }; type CurrentPaymentItemRecord = ClientInvoicePaymentItem & { payment_no: string | null; }; type AppNotification = { type: 'pending_invoice' | string; title: string; description: string; count: number; }; function AppNotifications() { const [notifications, setNotifications] = React.useState( [], ); const [invoices, setInvoices] = React.useState< PendingInvoiceNotification[] >([]); const [count, setCount] = React.useState(0); const [isLoadingNotifications, setIsLoadingNotifications] = React.useState(true); const [isLoadingInvoices, setIsLoadingInvoices] = React.useState(false); const [pendingInvoicesOpened, setPendingInvoicesOpened] = React.useState(false); const dateFormatter = React.useMemo( () => new Intl.DateTimeFormat('en-MY', { day: '2-digit', month: 'short', year: 'numeric', }), [], ); const formatAmount = (amount: string | number | null) => { const value = Number(amount ?? 0); return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(Number.isFinite(value) ? value : 0); }; const parseAmount = (amount: string | number | null | undefined) => { const value = Number(amount ?? 0); return Number.isFinite(value) ? value : 0; }; const getPaymentRows = ( invoice: PendingInvoiceNotification, ): ClientInvoicePayment[] => { if (invoice.payments?.length) { return invoice.payments; } const invoiceAmount = parseAmount(invoice.amount) || parseAmount(invoice.management_fee) + parseAmount(invoice.media_fee); const taxAmount = Math.max( 0, parseAmount(invoice.management_fee_tax) + parseAmount(invoice.media_fee_tax), ); const netAmount = parseAmount(invoice.total_net_amount) || parseAmount(invoice.management_fee_amount) + parseAmount(invoice.media_fee_amount) || Math.max(0, invoiceAmount - taxAmount); return [ { payment_no: invoice.payment_no, payment_total_amount: invoiceAmount, payment_tax_percentage: parseAmount(invoice.tax_percent), payment_nett_amount: netAmount, items: [], }, ]; }; const getPaymentTaxAmount = (payment: ClientInvoicePayment) => { const totalAmount = parseAmount(payment.payment_total_amount); const nettAmount = parseAmount(payment.payment_nett_amount); if (totalAmount > 0 || nettAmount > 0) { return Math.max(0, totalAmount - nettAmount); } return (payment.items ?? []).reduce( (sum, item) => sum + Math.max( 0, parseAmount(item.payment_item_amount) - parseAmount(item.net_amount), ), 0, ); }; const getInvoiceAmount = (invoice: PendingInvoiceNotification) => { const directAmount = parseAmount(invoice.amount); if (directAmount > 0) { return directAmount; } const paymentTotal = getPaymentRows(invoice).reduce( (sum, payment) => sum + parseAmount(payment.payment_total_amount), 0, ); return ( paymentTotal || parseAmount(invoice.management_fee) + parseAmount(invoice.media_fee) ); }; const getInvoiceTaxAmount = (invoice: PendingInvoiceNotification) => { const paymentTaxTotal = getPaymentRows(invoice).reduce( (sum, payment) => sum + getPaymentTaxAmount(payment), 0, ); return ( paymentTaxTotal || Math.max( 0, parseAmount(invoice.management_fee_tax) + parseAmount(invoice.media_fee_tax), ) ); }; const getInvoiceNetAmount = (invoice: PendingInvoiceNotification) => { const directNetAmount = parseAmount(invoice.total_net_amount) || parseAmount(invoice.nett_amount); if (directNetAmount > 0) { return directNetAmount; } const paymentNetTotal = getPaymentRows(invoice).reduce( (sum, payment) => sum + parseAmount(payment.payment_nett_amount), 0, ); return ( paymentNetTotal || parseAmount(invoice.management_fee_amount) + parseAmount(invoice.media_fee_amount) ); }; const getPaidAmount = (invoice: PendingInvoiceNotification) => getPaymentRows(invoice).reduce( (sum, payment) => sum + parseAmount(payment.payment_total_amount), 0, ); const getOutstandingAmount = (invoice: PendingInvoiceNotification) => Math.max(0, getInvoiceAmount(invoice) - getPaidAmount(invoice)); const getPaymentItemRows = ( invoice: PendingInvoiceNotification, ): CurrentPaymentItemRecord[] => { const paymentItems = getPaymentRows(invoice).flatMap((payment) => (payment.items ?? []).map((item) => ({ ...item, payment_no: payment.payment_no, })), ); if (paymentItems.length > 0) { return paymentItems; } return [ { payment_no: invoice.payment_no, billing_item_types_id: 0, billing_item_type: { id: 0, name: 'Google Ads Search (Media Fee)', sql_acc_code: 'G03', nett_contribution: true, fee_type: 'Media', type: 'Google', campaign_type: 'Search', }, start_date: null, end_date: null, payment_item_amount: invoice.media_fee ?? 0, tax_percentage: invoice.tax_percent ?? 0, net_amount: invoice.media_fee_amount ?? invoice.nett_amount ?? 0, withholding_tax: 0, final_net_amount: invoice.total_net_amount ?? invoice.nett_amount ?? 0, spending: 0, is_creditcard: !!invoice.is_credit_card, }, { payment_no: invoice.payment_no, billing_item_types_id: 0, billing_item_type: { id: 0, name: 'Management Fee (Google Search Ads)', sql_acc_code: 'GOOGLE', nett_contribution: false, fee_type: 'Management', type: 'Google', campaign_type: 'Search', }, start_date: null, end_date: null, payment_item_amount: invoice.management_fee ?? 0, tax_percentage: invoice.tax_percent ?? 0, net_amount: invoice.management_fee_amount ?? invoice.management_fee ?? 0, withholding_tax: 0, final_net_amount: invoice.management_fee_amount ?? invoice.management_fee ?? 0, spending: 0, is_creditcard: false, }, ].filter((item) => parseAmount(item.payment_item_amount) > 0); }; const formatDate = (date: string | null) => { if (!date) { return '-'; } const value = new Date(date); return Number.isNaN(value.getTime()) ? '-' : dateFormatter.format(value); }; const pendingInvoiceColumns = React.useMemo< MRT_ColumnDef[] >( () => [ { accessorKey: 'invoice_no', header: 'Invoice', Cell: ({ row }) => ( {row.original.invoice_no} {row.original.requires_client ? 'Link client before approval' : 'Ready for review'} ), }, { id: 'client', header: 'Client', accessorFn: (invoice) => invoice.client?.name ?? invoice.pending_client_name ?? '-', Cell: ({ cell }) => ( {cell.getValue()} ), }, { id: 'sql_acc_code', header: 'SQL Acc Code', accessorFn: (invoice) => invoice.pending_sql_acc_code ?? '-', Cell: ({ cell }) => ( {cell.getValue()} ), }, // { // id: 'invoice_management_fee', // header: 'Invoice Management Fee', // accessorFn: (invoice) => invoice.invoice_billing_totals?.management_fee ?? 0, // Cell: ({ row }) => ( // // {formatAmount(row.original.invoice_billing_totals?.management_fee ?? 0)} // // ), // }, // { // id: 'invoice_media_fee', // header: 'Invoice Media Fee', // accessorFn: (invoice) => invoice.invoice_billing_totals?.media_fee ?? 0, // Cell: ({ row }) => ( // // {formatAmount(row.original.invoice_billing_totals?.media_fee ?? 0)} // // ), // }, { id: 'invoice_amount', header: 'Invoice Amount', accessorFn: (invoice) => getInvoiceAmount(invoice), Cell: ({ row }) => ( {formatAmount(getInvoiceAmount(row.original))} ), }, { id: 'tax_amount', header: 'Tax Amount', accessorFn: (invoice) => getInvoiceTaxAmount(invoice), Cell: ({ row }) => ( {formatAmount(getInvoiceTaxAmount(row.original))} ), }, { id: 'net_amount', header: 'Net Amount', accessorFn: (invoice) => getInvoiceNetAmount(invoice), Cell: ({ row }) => ( {formatAmount(getInvoiceNetAmount(row.original))} ), }, { id: 'paid_amount', header: 'Paid Amount', accessorFn: (invoice) => getPaidAmount(invoice), Cell: ({ row }) => ( {formatAmount(getPaidAmount(row.original))} ), }, { id: 'outstanding', header: 'Outstanding', accessorFn: (invoice) => getOutstandingAmount(invoice), Cell: ({ row }) => ( 0 ? 'red' : 'green' } > {formatAmount(getOutstandingAmount(row.original))} ), }, { id: 'payment_date', header: 'Payment Date', accessorFn: (invoice) => invoice.created_at, Cell: ({ row }) => ( {formatDate(row.original.created_at)} ), }, { accessorKey: 'created_at', header: 'Created', Cell: ({ row }) => ( {formatDate(row.original.created_at)} ), }, ], [], ); const renderPendingInvoiceDetailPanel = ({ row, }: { row: MRT_Row; }) => { const records = row.original.previous_payments ?? []; const paymentItems = getPaymentItemRows(row.original); return ( Payment Items {paymentItems.length} {paymentItems.length === 0 ? ( No payment items found for this invoice. ) : ( {paymentItems.map((item, index) => ( ))}
Payment No Item SQL Code Fee Type Amount Tax % Net
{item.payment_no ?? '-'} {item.billing_item_type ?.name ?? '-'} {item.billing_item_type ?.sql_acc_code ?? '-'} {item.billing_item_type ?.fee_type ?? '-'} {formatAmount( item.payment_item_amount, )} {formatAmount( item.tax_percentage, )} {formatAmount( item.net_amount, )}
)}
Previous Payment Records {records.length} {records.length === 0 ? ( No previous payment records found for this invoice. ) : ( {records.map((record, index) => ( ))}
Payment No Status Payment Date Media Fee Management Fee Invoice Media Invoice Management Total
{record.payment_number ?? '-'} {record.status ?? '-'} {formatDate( record.sql_created_at, )} {formatAmount( record.media_fee, )} {formatAmount( record.management_fee, )} {formatAmount( record.invoice_media_fee, )} {formatAmount( record.invoice_management_fee, )} {formatAmount( record.amount, )}
)}
); }; const renderPendingInvoiceActions = ({ row, }: { row: MRT_Row; }) => ( {row.original.requires_client ? ( ) : null} ); useEffect(() => { let isMounted = true; const loadNotifications = async () => { try { const response = await fetch('/api/notifications', { headers: { Accept: 'application/json', }, credentials: 'same-origin', }); if (!response.ok) { throw new Error('Unable to load pending invoices.'); } const data = await response.json(); if (isMounted) { setNotifications(data.notifications ?? []); setCount(data.count ?? 0); } } catch { if (isMounted) { setNotifications([]); setCount(0); } } finally { if (isMounted) { setIsLoadingNotifications(false); } } }; loadNotifications(); return () => { isMounted = false; }; }, []); const loadPendingInvoices = React.useCallback(async () => { setIsLoadingInvoices(true); try { const response = await fetch('/api/customer-invoices/pending', { headers: { Accept: 'application/json', }, credentials: 'same-origin', }); if (!response.ok) { throw new Error('Unable to load pending invoices.'); } const data = await response.json(); setInvoices(data.invoices ?? []); } catch { setInvoices([]); } finally { setIsLoadingInvoices(false); } }, []); const openNotification = (notification: AppNotification) => { if (notification.type === 'pending_invoice') { setPendingInvoicesOpened(true); loadPendingInvoices(); } }; return ( <> 99 ? '99+' : count} size={18} disabled={count === 0} color="red" > ({ borderBottom: `1px solid ${ theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2] }`, backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0], })} > Notifications Updates grouped by notification type 0 ? 'red' : 'gray'} variant="filled" > {count} pending {isLoadingNotifications ? ( Loading notifications... ) : notifications.length === 0 ? ( All caught up No notifications need your attention. ) : ( {notifications.map((notification) => ( openNotification(notification) } icon={} rightSection={ {notification.count} } > {notification.title} {notification.description} ))} )} setPendingInvoicesOpened(false)} title="Pending invoice approvals" size="xxl" > {isLoadingInvoices ? ( Loading pending invoices... ) : invoices.length === 0 ? ( All caught up No invoices are waiting for approval. ) : ( true} positionExpandColumn="first" enableRowActions positionActionsColumn="last" enablePagination={false} enableGlobalFilter={false} enableColumnFilters={false} enableTopToolbar={false} enableBottomToolbar={false} initialState={{ density: 'xs' }} /> )} ); } type Props = { children: React.ReactNode; breadcrumbs?: unknown; }; export default function AppLayout({ children }: Props) { const theme = useMantineTheme(); const [opened, setOpened] = React.useState(false); const isDark = theme.colorScheme === 'dark'; const page = usePage(); const { flash, auth } = page.props as any; useEffect(() => { if (flash['message-info']) { notifications.show({ title: 'Info', color: 'blue', icon: , message: flash['message-info'], }); } if (flash['message-warning']) { notifications.show({ title: 'Warning', color: 'yellow', icon: , message: flash['message-warning'], }); } if (flash['message-error']) { notifications.show({ title: 'Error', color: 'red', icon: , message: flash['message-error'], }); } }, [flash]); return ( ); }