1278 lines
52 KiB
TypeScript
1278 lines
52 KiB
TypeScript
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<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 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<PendingInvoiceNotification>[]
|
|
>(
|
|
() => [
|
|
{
|
|
accessorKey: 'invoice_no',
|
|
header: 'Invoice',
|
|
Cell: ({ row }) => (
|
|
<Stack spacing={1}>
|
|
<Anchor
|
|
component={Link}
|
|
href={`/client-invoices/${row.original.id}/edit`}
|
|
size="sm"
|
|
weight={700}
|
|
>
|
|
{row.original.invoice_no}
|
|
</Anchor>
|
|
<Text size="xs" color="dimmed">
|
|
{row.original.requires_client
|
|
? 'Link client before approval'
|
|
: '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>
|
|
// ),
|
|
// },
|
|
{
|
|
id: 'invoice_amount',
|
|
header: 'Invoice Amount',
|
|
accessorFn: (invoice) => getInvoiceAmount(invoice),
|
|
Cell: ({ row }) => (
|
|
<Text size="sm" weight={600} align="right">
|
|
{formatAmount(getInvoiceAmount(row.original))}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: 'tax_amount',
|
|
header: 'Tax Amount',
|
|
accessorFn: (invoice) => getInvoiceTaxAmount(invoice),
|
|
Cell: ({ row }) => (
|
|
<Text size="sm" weight={600} align="right">
|
|
{formatAmount(getInvoiceTaxAmount(row.original))}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: 'net_amount',
|
|
header: 'Net Amount',
|
|
accessorFn: (invoice) => getInvoiceNetAmount(invoice),
|
|
Cell: ({ row }) => (
|
|
<Text size="sm" weight={600} align="right">
|
|
{formatAmount(getInvoiceNetAmount(row.original))}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: 'paid_amount',
|
|
header: 'Paid Amount',
|
|
accessorFn: (invoice) => getPaidAmount(invoice),
|
|
Cell: ({ row }) => (
|
|
<Text size="sm" weight={600} align="right">
|
|
{formatAmount(getPaidAmount(row.original))}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: 'outstanding',
|
|
header: 'Outstanding',
|
|
accessorFn: (invoice) => getOutstandingAmount(invoice),
|
|
Cell: ({ row }) => (
|
|
<Text
|
|
size="sm"
|
|
weight={700}
|
|
align="right"
|
|
color={
|
|
getOutstandingAmount(row.original) > 0
|
|
? 'red'
|
|
: 'green'
|
|
}
|
|
>
|
|
{formatAmount(getOutstandingAmount(row.original))}
|
|
</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 renderPendingInvoiceDetailPanel = ({
|
|
row,
|
|
}: {
|
|
row: MRT_Row<PendingInvoiceNotification>;
|
|
}) => {
|
|
const records = row.original.previous_payments ?? [];
|
|
const paymentItems = getPaymentItemRows(row.original);
|
|
|
|
return (
|
|
<Box px="md" py="sm">
|
|
<Stack spacing="lg">
|
|
<Stack spacing="xs">
|
|
<Group position="apart">
|
|
<Text size="sm" weight={700}>
|
|
Payment Items
|
|
</Text>
|
|
<Badge variant="light">{paymentItems.length}</Badge>
|
|
</Group>
|
|
{paymentItems.length === 0 ? (
|
|
<Text size="sm" color="dimmed">
|
|
No payment items found for this invoice.
|
|
</Text>
|
|
) : (
|
|
<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',
|
|
}}
|
|
>
|
|
Item
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: 'left',
|
|
padding: '8px',
|
|
}}
|
|
>
|
|
SQL Code
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: 'left',
|
|
padding: '8px',
|
|
}}
|
|
>
|
|
Fee Type
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: 'right',
|
|
padding: '8px',
|
|
}}
|
|
>
|
|
Amount
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: 'right',
|
|
padding: '8px',
|
|
}}
|
|
>
|
|
Tax %
|
|
</th>
|
|
<th
|
|
style={{
|
|
textAlign: 'right',
|
|
padding: '8px',
|
|
}}
|
|
>
|
|
Net
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paymentItems.map((item, index) => (
|
|
<tr
|
|
key={`${item.payment_no ?? 'payment'}-${item.billing_item_types_id}-${index}`}
|
|
>
|
|
<td style={{ padding: '8px' }}>
|
|
{item.payment_no ?? '-'}
|
|
</td>
|
|
<td style={{ padding: '8px' }}>
|
|
{item.billing_item_type
|
|
?.name ?? '-'}
|
|
</td>
|
|
<td style={{ padding: '8px' }}>
|
|
{item.billing_item_type
|
|
?.sql_acc_code ?? '-'}
|
|
</td>
|
|
<td style={{ padding: '8px' }}>
|
|
<Badge
|
|
variant="light"
|
|
color={
|
|
item
|
|
.billing_item_type
|
|
?.fee_type ===
|
|
'Media'
|
|
? 'blue'
|
|
: 'grape'
|
|
}
|
|
>
|
|
{item.billing_item_type
|
|
?.fee_type ?? '-'}
|
|
</Badge>
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: '8px',
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
{formatAmount(
|
|
item.payment_item_amount,
|
|
)}
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: '8px',
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
{formatAmount(
|
|
item.tax_percentage,
|
|
)}
|
|
</td>
|
|
<td
|
|
style={{
|
|
padding: '8px',
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
{formatAmount(
|
|
item.net_amount,
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
|
|
<Stack spacing="xs">
|
|
<Group position="apart">
|
|
<Text size="sm" weight={700}>
|
|
Previous Payment Records
|
|
</Text>
|
|
<Badge variant="light">{records.length}</Badge>
|
|
</Group>
|
|
{records.length === 0 ? (
|
|
<Text size="sm" color="dimmed">
|
|
No previous payment records found for this
|
|
invoice.
|
|
</Text>
|
|
) : (
|
|
<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>
|
|
</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}/edit`}
|
|
color="blue"
|
|
variant="light"
|
|
>
|
|
<Tooltip
|
|
label="Edit invoice and 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 ?? []);
|
|
} 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={renderPendingInvoiceDetailPanel}
|
|
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>
|
|
);
|
|
}
|