inspiren-sem-tool/resources/js/layouts/app-layout.tsx

734 lines
29 KiB
TypeScript

import React from 'react';
import {
AppShell,
Navbar,
Header,
Group,
Text,
MediaQuery,
Burger,
useMantineTheme,
Box,
Container,
Menu,
Avatar,
Button,
Stack,
ActionIcon,
Indicator,
Loader,
Anchor,
Badge,
Modal,
Tooltip,
} from '@mantine/core';
import Sidebar from '../components/sidebar';
import ThemeToggle from '../components/theme-toggle';
import { SidebarProvider } from '@/components/ui/sidebar';
import { usePage, Link, router } from '@inertiajs/react';
import { Alert } from '@mantine/core';
import { notifications } from "@mantine/notifications";
import { useEffect } from "react";
import { IconInfoCircle, IconCircleX, IconAlertCircle, IconSettings, IconLogout, IconBell, IconFileDollar, IconUserPlus } from "@tabler/icons-react";
import { logout } from '@/routes';
import { edit } from '@/routes/profile';
import { SharedData } from '@/types';
import { MantineReactTable, type MRT_ColumnDef, type MRT_Row } from 'mantine-react-table';
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;
payment_no: string | null;
management_fee: string | number | null;
media_fee: string | number | null;
created_at: string | null;
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 AppNotification = {
type: 'pending_invoice' | string;
title: string;
description: string;
count: number;
};
function AppNotifications() {
const [notifications, setNotifications] = React.useState<AppNotification[]>([]);
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 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<PendingInvoiceNotification>[]>(
() => [
{
accessorKey: 'invoice_no',
header: 'Invoice',
Cell: ({ row }) => (
<Stack spacing={1}>
<Anchor
component={Link}
href={
row.original.requires_client
? `/client-invoices/${row.original.id}/client/create`
: `/client-invoices/${row.original.id}/edit`
}
size="sm"
weight={700}
>
{row.original.invoice_no}
</Anchor>
<Text size="xs" color="dimmed">
{row.original.requires_client ? 'Client setup required' : 'Ready for review'}
</Text>
</Stack>
),
},
{
id: 'client',
header: 'Client',
accessorFn: (invoice) => invoice.client?.name ?? invoice.pending_client_name ?? '-',
Cell: ({ cell }) => (
<Text size="sm" lineClamp={1}>
{cell.getValue<string>()}
</Text>
),
},
{
id: 'sql_acc_code',
header: 'SQL Acc Code',
accessorFn: (invoice) => invoice.pending_sql_acc_code ?? '-',
Cell: ({ cell }) => <Text size="sm">{cell.getValue<string>()}</Text>,
},
// {
// id: 'invoice_management_fee',
// header: 'Invoice Management Fee',
// accessorFn: (invoice) => invoice.invoice_billing_totals?.management_fee ?? 0,
// Cell: ({ row }) => (
// <Text size="sm" weight={600} align="right">
// {formatAmount(row.original.invoice_billing_totals?.management_fee ?? 0)}
// </Text>
// ),
// },
// {
// id: 'invoice_media_fee',
// header: 'Invoice Media Fee',
// accessorFn: (invoice) => invoice.invoice_billing_totals?.media_fee ?? 0,
// Cell: ({ row }) => (
// <Text size="sm" weight={600} align="right">
// {formatAmount(row.original.invoice_billing_totals?.media_fee ?? 0)}
// </Text>
// ),
// },
{
accessorKey: 'management_fee',
header: 'Payment Management Fee (incl. tax)',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.management_fee_amount)}
</Text>
),
},
{
accessorKey: 'management_fee_tax',
header: 'Payment Management Tax',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.management_fee_tax)}
</Text>
),
},
{
accessorKey: 'management_fee_nett',
header: 'Payment Management Nett',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.management_fee)}
</Text>
),
},
{
accessorKey: 'media_fee',
header: 'Payment Media Fee (incl. tax)',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.media_fee_amount)}
</Text>
),
},
{
accessorKey: 'media_fee_tax',
header: 'Payment Media Tax',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.media_fee_tax)}
</Text>
),
},
{
accessorKey: 'media_fee_nett',
header: 'Payment Media Nett',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.media_fee)}
</Text>
),
},
{
id: 'payment_date',
header: 'Payment Date',
accessorFn: (invoice) => invoice.created_at,
Cell: ({ row }) => (
<Text size="sm">
{formatDate(row.original.created_at)}
</Text>
),
},
{
accessorKey: 'created_at',
header: 'Created',
Cell: ({ row }) => (
<Text size="sm">
{formatDate(row.original.created_at)}
</Text>
),
},
],
[],
);
const renderPreviousPaymentsPanel = ({ row }: { row: MRT_Row<PendingInvoiceNotification> }) => {
const records = row.original.previous_payments ?? [];
if (records.length === 0) {
return (
<Box px="md" py="sm">
<Text size="sm" color="dimmed">
No previous payment records found for this invoice.
</Text>
</Box>
);
}
return (
<Box px="md" py="sm">
<Stack spacing="xs">
<Group position="apart">
<Text size="sm" weight={700}>
Previous Payment Records
</Text>
<Badge variant="light">{records.length}</Badge>
</Group>
<Box sx={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px' }}>Payment No</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Payment Date</th>
<th style={{ textAlign: 'right', padding: '8px' }}>Media Fee</th>
<th style={{ textAlign: 'right', padding: '8px' }}>Management Fee</th>
<th style={{ textAlign: 'right', padding: '8px' }}>Invoice Media</th>
<th style={{ textAlign: 'right', padding: '8px' }}>Invoice Management</th>
<th style={{ textAlign: 'right', padding: '8px' }}>Total</th>
</tr>
</thead>
<tbody>
{records.map((record, index) => (
<tr key={`${record.payment_number ?? 'payment'}-${index}`}>
<td style={{ padding: '8px' }}>{record.payment_number ?? '-'}</td>
<td style={{ padding: '8px' }}>
<Badge color={record.status === 'Processed' ? 'green' : 'gray'} variant="light">
{record.status ?? '-'}
</Badge>
</td>
<td style={{ padding: '8px' }}>{formatDate(record.sql_created_at)}</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
{formatAmount(record.media_fee)}
</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
{formatAmount(record.management_fee)}
</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
{formatAmount(record.invoice_media_fee)}
</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
{formatAmount(record.invoice_management_fee)}
</td>
<td style={{ padding: '8px', textAlign: 'right', fontWeight: 700 }}>
{formatAmount(record.amount)}
</td>
</tr>
))}
</tbody>
</table>
</Box>
</Stack>
</Box>
);
};
const renderPendingInvoiceActions = ({ row }: { row: MRT_Row<PendingInvoiceNotification> }) => (
<Group spacing="xs" noWrap>
{row.original.requires_client ? (
<ActionIcon
component={Link}
href={`/client-invoices/${row.original.id}/client/create`}
color="blue"
variant="light"
>
<Tooltip label="Link client" withArrow withinPortal>
<IconUserPlus size={18} />
</Tooltip>
</ActionIcon>
) : null}
<ActionIcon
component="a"
href={`/client-invoices/pdf/invoice/${row.original.id}`}
target="_blank"
>
<Tooltip label="View Invoice" withArrow withinPortal>
<IconFileDollar color="green" />
</Tooltip>
</ActionIcon>
</Group>
);
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 ?? []);
console.log(data.invoices);
} catch {
setInvoices([]);
} finally {
setIsLoadingInvoices(false);
}
}, []);
const openNotification = (notification: AppNotification) => {
if (notification.type === 'pending_invoice') {
setPendingInvoicesOpened(true);
loadPendingInvoices();
}
};
return (
<>
<Menu shadow="xl" width={360} position="bottom-end" withinPortal>
<Menu.Target>
<Indicator
label={count > 99 ? '99+' : count}
size={18}
disabled={count === 0}
color="red"
>
<ActionIcon
variant="light"
color="blue"
size="lg"
radius="xl"
aria-label="Notifications"
>
<IconBell size={20} />
</ActionIcon>
</Indicator>
</Menu.Target>
<Menu.Dropdown p={0}>
<Box
px="md"
py="sm"
sx={(theme) => ({
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],
})}
>
<Group position="apart" align="center">
<Stack spacing={2}>
<Text size="sm" weight={700}>
Notifications
</Text>
<Text size="xs" color="dimmed">
Updates grouped by notification type
</Text>
</Stack>
<Badge color={count > 0 ? 'red' : 'gray'} variant="filled">
{count} pending
</Badge>
</Group>
</Box>
{isLoadingNotifications ? (
<Group position="center" py="xl">
<Stack spacing="xs" align="center">
<Loader size="sm" />
<Text color="dimmed" size="sm">
Loading notifications...
</Text>
</Stack>
</Group>
) : notifications.length === 0 ? (
<Stack spacing={4} align="center" py="xl">
<Text weight={600} size="sm">
All caught up
</Text>
<Text color="dimmed" size="sm">
No notifications need your attention.
</Text>
</Stack>
) : (
<Stack spacing={0}>
{notifications.map((notification) => (
<Menu.Item
key={notification.type}
onClick={() => openNotification(notification)}
icon={<IconAlertCircle size={18} />}
rightSection={
<Badge color="red" variant="light">
{notification.count}
</Badge>
}
>
<Stack spacing={2}>
<Text size="sm" weight={700}>
{notification.title}
</Text>
<Text size="xs" color="dimmed">
{notification.description}
</Text>
</Stack>
</Menu.Item>
))}
</Stack>
)}
</Menu.Dropdown>
</Menu>
<Modal
opened={pendingInvoicesOpened}
onClose={() => setPendingInvoicesOpened(false)}
title="Pending invoice approvals"
size="xxl"
>
{isLoadingInvoices ? (
<Group position="center" py="xl">
<Stack spacing="xs" align="center">
<Loader size="sm" />
<Text color="dimmed" size="sm">
Loading pending invoices...
</Text>
</Stack>
</Group>
) : invoices.length === 0 ? (
<Stack spacing={4} align="center" py="xl">
<Text weight={600} size="sm">
All caught up
</Text>
<Text color="dimmed" size="sm">
No invoices are waiting for approval.
</Text>
</Stack>
) : (
<MantineReactTable
columns={pendingInvoiceColumns}
data={invoices}
renderRowActions={renderPendingInvoiceActions}
renderDetailPanel={renderPreviousPaymentsPanel}
enableExpanding
getRowCanExpand={() => true}
positionExpandColumn="first"
enableRowActions
positionActionsColumn="last"
enablePagination={false}
enableGlobalFilter={false}
enableColumnFilters={false}
enableTopToolbar={false}
enableBottomToolbar={false}
initialState={{ density: 'xs' }}
/>
)}
</Modal>
</>
);
}
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<SharedData>();
const { flash, auth } = page.props as any;
useEffect(() => {
if (flash['message-info']) {
notifications.show({
title: 'Info',
color: 'blue',
icon: <IconInfoCircle />,
message: flash['message-info'],
});
}
if (flash['message-warning']) {
notifications.show({
title: 'Warning',
color: 'yellow',
icon: <IconAlertCircle />,
message: flash['message-warning'],
});
}
if (flash['message-error']) {
notifications.show({
title: 'Error',
color: 'red',
icon: <IconCircleX />,
message: flash['message-error'],
});
}
}, [flash]);
return (
<SidebarProvider open={opened} onOpenChange={setOpened}>
<AppShell
padding="lg"
navbarOffsetBreakpoint="sm"
navbar={
<Navbar
width={{ base: 260 }}
hiddenBreakpoint="sm"
hidden={!opened}
p="md"
style={{
borderRight: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[3]
}`,
}}
>
<Sidebar />
</Navbar>
}
header={
<Header
height={60}
px="lg"
style={{
backgroundColor: isDark ? theme.colors.dark[6] : theme.white,
borderBottom: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[3]
}`,
}}
>
<Group position="apart" align="center" style={{ height: '100%' }}>
<Group spacing="sm">
<MediaQuery largerThan="sm" styles={{ display: 'none' }}>
<Burger
opened={opened}
onClick={() => setOpened((o) => !o)}
size="sm"
/>
</MediaQuery>
<Text weight={700} size="lg">
Inspiren SEM Tool
</Text>
</Group>
<Group spacing="sm">
<AppNotifications />
<ThemeToggle />
<Menu shadow="md" width={220}>
<Menu.Target>
<Button variant="subtle">
<Avatar color="gray" radius="xl" size="sm">
{auth.user.name
.split(' ')
.map((part: string) => part[0])
.join('')
.toUpperCase()}
</Avatar>
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
<Stack spacing={0}>
<Text size="sm">{auth.user.name}</Text>
<Text size="xs" color="dimmed">
{auth.user.email}
</Text>
</Stack>
</Menu.Label>
<Menu.Item
icon={<IconSettings size={16} />}
component={Link}
href={edit()}
as="button"
prefetch
>
Settings
</Menu.Item>
<Menu.Item
icon={<IconLogout size={16} />}
onClick={() =>
router.post(logout(), {
preserveState: true,
})
}
>
Log out
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</Header>
}
styles={{
main: {
backgroundColor: isDark
? theme.colors.dark[7]
: theme.colors.gray[0],
minHeight: '100vh',
maxWidth: '100vw',
overflowX: 'hidden',
},
}}
>
<Container fluid px={{ base: 'xs', sm: 'md', lg: 'lg' }} style={{ maxWidth: '100%', overflowX: 'hidden' }}>
<Box
style={{
backgroundColor: isDark ? theme.colors.dark[6] : theme.white,
borderRadius: theme.radius.md,
padding: theme.spacing.lg,
boxShadow: theme.shadows.sm,
maxWidth: '100%',
minWidth: 0,
overflowX: 'auto',
}}
>
{children}
</Box>
</Container>
</AppShell>
</SidebarProvider>
);
}