inspiren-sem-tool/resources/js/forms/account/InvoiceForm.tsx

1003 lines
43 KiB
TypeScript

import { InertiaFormProps } from '@inertiajs/react';
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
NumberInput,
Select,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
type MantineTheme,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import {
IconDeviceFloppy,
IconPlus,
IconTrash,
IconUserPlus,
} from '@tabler/icons-react';
import axios from 'axios';
import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react';
import { route } from 'ziggy-js';
export interface BillingItemTypeOption {
id: number;
name: string;
sql_acc_code: string | null;
nett_contribution: boolean;
fee_type: string | null;
type: string | null;
campaign_type: string | null;
}
export interface InvoicePaymentItemFormValues {
billing_item_types_id: string;
start_date: string;
end_date: string;
payment_item_amount: string;
tax_percentage: string;
net_amount: string;
withholding_tax: string;
final_net_amount: string;
spending: string;
is_creditcard: boolean;
}
export interface InvoicePaymentFormValues {
payment_no: string;
payment_total_amount: string;
payment_nett_amount: string;
items: InvoicePaymentItemFormValues[];
}
export interface InvoiceFormValues {
invoice_no: string;
linked_invoice_id: string;
is_paid: boolean;
total_sem_amount: string;
total_net_amount: string;
payments: InvoicePaymentFormValues[];
client_id?: string;
customer_id?: string;
}
interface InvoiceOption {
value: string;
label: string;
}
interface Props {
form: InertiaFormProps<InvoiceFormValues>;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
billingItemTypes: BillingItemTypeOption[];
submitLabel?: string;
invoiceOptions?: InvoiceOption[];
showClientLink?: boolean;
requiresClient?: boolean;
clientOptions?: InvoiceOption[];
pendingClientName?: string | null;
}
const parseAmount = (value?: string) => {
const parsed = Number.parseFloat(value ?? '');
return Number.isFinite(parsed) ? parsed : 0;
};
const formatAmount = (value: number) => value.toFixed(2);
const finalNetAmount = (netAmount: number, withholdingTax: number) =>
netAmount / (1 + withholdingTax / 100);
const numberInputValue = (value?: string) =>
value !== '' && value !== undefined ? Number(value) : undefined;
const numberInputString = (value: number | string | null | undefined) =>
value === '' || value === null || value === undefined ? '' : String(value);
const sectionSurface = (theme: MantineTheme) =>
theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.white;
const subtleSurface = (theme: MantineTheme) =>
theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0];
const nestedSurface = (theme: MantineTheme) =>
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0];
const borderColor = (theme: MantineTheme, shade = 3) =>
theme.colorScheme === 'dark'
? theme.colors.dark[Math.max(3, 7 - shade)]
: theme.colors.gray[shade];
export function createPaymentItem(
billingItemTypeId: string,
overrides: Partial<InvoicePaymentItemFormValues> = {},
): InvoicePaymentItemFormValues {
return {
billing_item_types_id: billingItemTypeId,
start_date: '',
end_date: '',
payment_item_amount: '',
tax_percentage: '8',
net_amount: '',
withholding_tax: '0',
final_net_amount: '',
spending: '0',
is_creditcard: false,
...overrides,
};
}
export function createPayment(
_billingItemTypes: BillingItemTypeOption[],
overrides: Partial<InvoicePaymentFormValues> = {},
): InvoicePaymentFormValues {
return {
payment_no: '',
payment_total_amount: '',
payment_nett_amount: '',
items: [],
...overrides,
};
}
export default function InvoiceForm({
form,
onSubmit,
billingItemTypes,
submitLabel = 'Save invoice',
invoiceOptions = [],
showClientLink = false,
requiresClient = false,
clientOptions = [],
pendingClientName = null,
}: Props) {
const formatDate = (value: string) =>
value ? dayjs(value).toDate() : null;
const [fetchingSpend, setFetchingSpend] = useState(false);
const [manualTotals, setManualTotals] = useState({
total_sem_amount: false,
total_net_amount: false,
});
const itemTypeOptions = billingItemTypes.map((itemType) => ({
value: String(itemType.id),
label: itemType.name,
}));
const itemTypesById = useMemo(
() =>
new Map(
billingItemTypes.map((itemType) => [
String(itemType.id),
itemType,
]),
),
[billingItemTypes],
);
const fieldError = (path: string) =>
(form.errors as Record<string, string | undefined>)[path];
const calculateItem = (
item: InvoicePaymentItemFormValues,
): InvoicePaymentItemFormValues => {
const grossAmount = parseAmount(item.payment_item_amount);
const netAmount =
grossAmount / (1 + parseAmount(item.tax_percentage) / 100);
return {
...item,
net_amount: formatAmount(netAmount),
final_net_amount: formatAmount(
finalNetAmount(netAmount, parseAmount(item.withholding_tax)),
),
};
};
const calculateFinalNetItem = (
item: InvoicePaymentItemFormValues,
): InvoicePaymentItemFormValues => ({
...item,
final_net_amount: formatAmount(
finalNetAmount(
parseAmount(item.net_amount),
parseAmount(item.withholding_tax),
),
),
});
const calculatePayment = (
payment: InvoicePaymentFormValues,
): InvoicePaymentFormValues => {
const paymentTotal = payment.items.reduce(
(sum, item) => sum + parseAmount(item.payment_item_amount),
0,
);
const paymentNett = payment.items.reduce(
(sum, item) => sum + parseAmount(item.net_amount),
0,
);
return {
...payment,
payment_total_amount: formatAmount(paymentTotal),
payment_nett_amount: formatAmount(paymentNett),
};
};
const calculateTotals = (payments: InvoicePaymentFormValues[]) => {
const totalSemAmount = payments.reduce(
(paymentSum, payment) =>
paymentSum +
payment.items.reduce(
(itemSum, item) =>
itemSum + parseAmount(item.payment_item_amount),
0,
),
0,
);
const totalNetAmount = payments.reduce(
(paymentSum, payment) =>
paymentSum +
payment.items.reduce(
(itemSum, item) => itemSum + parseAmount(item.net_amount),
0,
),
0,
);
form.setData((data) => ({
...data,
payments,
total_sem_amount: manualTotals.total_sem_amount
? data.total_sem_amount
: formatAmount(totalSemAmount),
total_net_amount: manualTotals.total_net_amount
? data.total_net_amount
: formatAmount(totalNetAmount),
}));
};
const setPayments = (payments: InvoicePaymentFormValues[]) => {
calculateTotals(payments.map(calculatePayment));
};
const updatePayment = (
paymentIndex: number,
field: keyof Omit<InvoicePaymentFormValues, 'items'>,
value: string,
) => {
const payments = [...form.data.payments];
payments[paymentIndex] = calculatePayment({
...payments[paymentIndex],
[field]: value,
});
calculateTotals(payments);
};
const updateItem = (
paymentIndex: number,
itemIndex: number,
field: keyof InvoicePaymentItemFormValues,
value: string | boolean,
) => {
const payments = [...form.data.payments];
const payment = { ...payments[paymentIndex] };
const items = [...payment.items];
const nextItem = {
...items[itemIndex],
[field]: value,
};
items[itemIndex] =
field === 'payment_item_amount' || field === 'tax_percentage'
? calculateItem(nextItem)
: field === 'net_amount' || field === 'withholding_tax'
? calculateFinalNetItem(nextItem)
: nextItem;
payments[paymentIndex] = calculatePayment({
...payment,
items,
});
calculateTotals(payments);
};
const addPayment = () => {
setPayments([...form.data.payments, createPayment(billingItemTypes)]);
};
const removePayment = (paymentIndex: number) => {
setPayments(
form.data.payments.filter((_, index) => index !== paymentIndex),
);
};
const addItem = (paymentIndex: number) => {
const payments = [...form.data.payments];
const fallbackType = billingItemTypes[0];
const payment = payments[paymentIndex];
payments[paymentIndex] = {
...payment,
items: [
...payment.items,
createPaymentItem(String(fallbackType?.id ?? '')),
],
};
setPayments(payments);
};
const removeItem = (paymentIndex: number, itemIndex: number) => {
const payments = [...form.data.payments];
const payment = payments[paymentIndex];
payments[paymentIndex] = {
...payment,
items: payment.items.filter((_, index) => index !== itemIndex),
};
setPayments(payments);
};
const firstItem = form.data.payments[0]?.items[0];
useEffect(() => {
const startDate = firstItem?.start_date;
const endDate = firstItem?.end_date;
const customerId = form.data.customer_id;
if (!startDate || !endDate || !customerId) {
setFetchingSpend(false);
return;
}
let canceled = false;
setFetchingSpend(true);
axios
.post(
route('google.getCampaignsDetails'),
{
clientCustomerId: customerId,
startDate,
endDate,
},
{
withCredentials: true,
},
)
.then((response) => {
if (canceled) return;
const value =
parseFloat(
response.data?.summary?.total_actual_spend ?? 0,
) || 0;
updateItem(0, 0, 'spending', value.toFixed(2));
})
.catch(() => undefined)
.finally(() => {
if (!canceled) {
setFetchingSpend(false);
}
});
return () => {
canceled = true;
};
}, [firstItem?.start_date, firstItem?.end_date, form.data.customer_id]);
return (
<form onSubmit={onSubmit}>
<Stack spacing="lg">
<Box
sx={(theme) => ({
border: `1px solid ${borderColor(theme)}`,
borderRadius: theme.radius.sm,
background: sectionSurface(theme),
padding: theme.spacing.lg,
})}
>
<Stack spacing="md">
<Group position="apart" align="center">
<Box>
<Text weight={700}>Invoice details</Text>
</Box>
<Badge variant="light" color="blue">
{form.data.payments.length} payment
{form.data.payments.length === 1 ? '' : 's'}
</Badge>
</Group>
<SimpleGrid
cols={2}
breakpoints={[{ maxWidth: 'sm', cols: 1 }]}
>
<TextInput
label="Invoice number"
value={form.data.invoice_no}
onChange={(event) =>
form.setData(
'invoice_no',
event.target.value,
)
}
error={form.errors.invoice_no}
required
/>
<Select
label="Linked invoice"
data={invoiceOptions}
value={form.data.linked_invoice_id || null}
onChange={(value) =>
form.setData(
'linked_invoice_id',
value ?? '',
)
}
error={form.errors.linked_invoice_id}
clearable
searchable
nothingFound="No invoices available"
/>
</SimpleGrid>
</Stack>
</Box>
{showClientLink ? (
<Box
sx={(theme) => ({
border: `1px solid ${borderColor(theme)}`,
borderRadius: theme.radius.sm,
background: sectionSurface(theme),
padding: theme.spacing.lg,
})}
>
<Stack spacing="md">
<Group spacing="sm">
<IconUserPlus size={18} />
<Text weight={700}>Link client</Text>
{pendingClientName ? (
<Badge variant="light" color="gray">
{pendingClientName}
</Badge>
) : null}
</Group>
<SimpleGrid
cols={1}
breakpoints={[{ maxWidth: 'sm', cols: 1 }]}
>
<Select
label="Google client"
placeholder="Select a synced Google client"
data={clientOptions}
value={form.data.client_id || null}
onChange={(value) =>
form.setData((data) => ({
...data,
client_id: value ?? '',
linked_invoice_id: '',
}))
}
error={form.errors.client_id}
searchable
nothingFound="No clients found"
required={requiresClient}
/>
</SimpleGrid>
</Stack>
</Box>
) : null}
{form.data.payments.map((payment, paymentIndex) => (
<Box
key={paymentIndex}
sx={(theme) => ({
border: `1px solid ${borderColor(theme)}`,
borderRadius: theme.radius.sm,
background: sectionSurface(theme),
overflow: 'hidden',
})}
>
<Box
sx={(theme) => ({
background: subtleSurface(theme),
borderBottom: `1px solid ${borderColor(theme)}`,
padding: `${theme.spacing.md} ${theme.spacing.lg}`,
})}
>
<Group position="apart" align="center">
<Group spacing="sm">
<Text weight={700}>
Payment {paymentIndex + 1}
</Text>
<Badge color="gray" variant="outline">
{payment.items.length} item
{payment.items.length === 1 ? '' : 's'}
</Badge>
</Group>
<Group spacing="xs">
<Badge variant="light" color="teal">
RM{' '}
{formatAmount(
parseAmount(
payment.payment_total_amount,
),
)}
</Badge>
{form.data.payments.length > 1 && (
<ActionIcon
color="red"
variant="light"
onClick={() =>
removePayment(paymentIndex)
}
aria-label="Remove payment"
>
<IconTrash size={16} />
</ActionIcon>
)}
</Group>
</Group>
</Box>
<Stack spacing="lg" p="lg">
<SimpleGrid
cols={4}
breakpoints={[{ maxWidth: 'sm', cols: 1 }]}
>
<TextInput
label="Payment No"
value={payment.payment_no}
onChange={(event) =>
updatePayment(
paymentIndex,
'payment_no',
event.target.value,
)
}
error={fieldError(
`payments.${paymentIndex}.payment_no`,
)}
/>
<NumberInput
precision={2}
step={0.01}
label="Payment total amount"
value={numberInputValue(
payment.payment_total_amount,
)}
onChange={(value) =>
updatePayment(
paymentIndex,
'payment_total_amount',
numberInputString(value),
)
}
error={fieldError(
`payments.${paymentIndex}.payment_total_amount`,
)}
/>
<NumberInput
precision={2}
step={0.01}
label="Payment nett amount"
value={numberInputValue(
payment.payment_nett_amount,
)}
onChange={(value) =>
updatePayment(
paymentIndex,
'payment_nett_amount',
numberInputString(value),
)
}
error={fieldError(
`payments.${paymentIndex}.payment_nett_amount`,
)}
/>
</SimpleGrid>
<Group position="apart" align="center">
<Text weight={700}>Line items</Text>
<Button
type="button"
variant="light"
leftIcon={<IconPlus size={16} />}
onClick={() => addItem(paymentIndex)}
>
Add item
</Button>
</Group>
{payment.items.map((item, itemIndex) => (
<Box
key={itemIndex}
sx={(theme) => ({
border: `1px solid ${borderColor(theme, 2)}`,
borderRadius: theme.radius.sm,
background: nestedSurface(theme),
padding: theme.spacing.md,
})}
>
<Stack spacing="md">
<Group position="apart">
<Group spacing="xs">
<Text size="sm" weight={700}>
Item {itemIndex + 1}
</Text>
{itemTypesById.get(
item.billing_item_types_id,
)?.fee_type && (
<Badge
size="sm"
variant="light"
color={
itemTypesById.get(
item.billing_item_types_id,
)?.fee_type ===
'Media'
? 'blue'
: 'grape'
}
>
{
itemTypesById.get(
item.billing_item_types_id,
)?.fee_type
}
</Badge>
)}
</Group>
<ActionIcon
color="red"
variant="subtle"
onClick={() =>
removeItem(
paymentIndex,
itemIndex,
)
}
aria-label="Remove item"
>
<IconTrash size={16} />
</ActionIcon>
</Group>
<SimpleGrid
cols={3}
breakpoints={[
{ maxWidth: 'sm', cols: 1 },
]}
>
<Select
label="Billing item type"
data={itemTypeOptions}
value={
item.billing_item_types_id ||
null
}
onChange={(value) =>
updateItem(
paymentIndex,
itemIndex,
'billing_item_types_id',
value ?? '',
)
}
error={fieldError(
`payments.${paymentIndex}.items.${itemIndex}.billing_item_types_id`,
)}
searchable
required
/>
<DateInput
label="Start date"
value={formatDate(
item.start_date,
)}
onChange={(value) =>
updateItem(
paymentIndex,
itemIndex,
'start_date',
value
? dayjs(
value,
).format(
'YYYY-MM-DD',
)
: '',
)
}
error={fieldError(
`payments.${paymentIndex}.items.${itemIndex}.start_date`,
)}
clearable
/>
<DateInput
label="End date"
value={formatDate(
item.end_date,
)}
onChange={(value) =>
updateItem(
paymentIndex,
itemIndex,
'end_date',
value
? dayjs(
value,
).format(
'YYYY-MM-DD',
)
: '',
)
}
error={fieldError(
`payments.${paymentIndex}.items.${itemIndex}.end_date`,
)}
clearable
/>
<NumberInput
precision={2}
step={0.01}
label="Item amount"
value={numberInputValue(
item.payment_item_amount,
)}
onChange={(value) =>
updateItem(
paymentIndex,
itemIndex,
'payment_item_amount',
numberInputString(
value,
),
)
}
error={fieldError(
`payments.${paymentIndex}.items.${itemIndex}.payment_item_amount`,
)}
/>
<NumberInput
precision={2}
step={0.01}
label="Tax (%)"
value={numberInputValue(
item.tax_percentage,
)}
onChange={(value) =>
updateItem(
paymentIndex,
itemIndex,
'tax_percentage',
numberInputString(
value,
),
)
}
error={fieldError(
`payments.${paymentIndex}.items.${itemIndex}.tax_percentage`,
)}
/>
<NumberInput
precision={2}
step={0.01}
label="Net amount"
value={numberInputValue(
item.net_amount,
)}
onChange={(value) =>
updateItem(
paymentIndex,
itemIndex,
'net_amount',
numberInputString(
value,
),
)
}
error={fieldError(
`payments.${paymentIndex}.items.${itemIndex}.net_amount`,
)}
/>
<NumberInput
precision={2}
step={0.01}
label="Withholding Tax (%)"
value={numberInputValue(
item.withholding_tax,
)}
onChange={(value) =>
updateItem(
paymentIndex,
itemIndex,
'withholding_tax',
numberInputString(
value,
),
)
}
error={fieldError(
`payments.${paymentIndex}.items.${itemIndex}.withholding_tax`,
)}
/>
<NumberInput
precision={2}
step={0.01}
label="Final net amount"
value={numberInputValue(
item.final_net_amount,
)}
onChange={(value) =>
updateItem(
paymentIndex,
itemIndex,
'final_net_amount',
numberInputString(
value,
),
)
}
error={fieldError(
`payments.${paymentIndex}.items.${itemIndex}.final_net_amount`,
)}
/>
<NumberInput
precision={2}
step={0.01}
label="Spending"
value={numberInputValue(
item.spending,
)}
onChange={(value) =>
updateItem(
paymentIndex,
itemIndex,
'spending',
numberInputString(
value,
),
)
}
rightSection={
paymentIndex === 0 &&
itemIndex === 0 &&
fetchingSpend ? (
<Loader size="xs" />
) : null
}
error={fieldError(
`payments.${paymentIndex}.items.${itemIndex}.spending`,
)}
/>
</SimpleGrid>
<Switch
label="Credit card"
checked={item.is_creditcard}
onChange={(event) =>
updateItem(
paymentIndex,
itemIndex,
'is_creditcard',
event.currentTarget.checked,
)
}
/>
</Stack>
</Box>
))}
</Stack>
</Box>
))}
<Button
type="button"
variant="light"
leftIcon={<IconPlus size={16} />}
onClick={addPayment}
style={{ width: 'max-content' }}
>
Add payment
</Button>
<Box
sx={(theme) => ({
border: `1px solid ${borderColor(theme)}`,
borderRadius: theme.radius.sm,
background: sectionSurface(theme),
padding: theme.spacing.lg,
})}
>
<Stack spacing="md">
<Group position="apart">
<Box>
<Text weight={700}>Totals</Text>
</Box>
<Badge variant="light" color="teal">
RM{' '}
{formatAmount(
parseAmount(form.data.total_net_amount),
)}
</Badge>
</Group>
<SimpleGrid
cols={2}
breakpoints={[{ maxWidth: 'sm', cols: 1 }]}
>
<NumberInput
precision={2}
label="Total SEM amount"
value={numberInputValue(
form.data.total_sem_amount,
)}
onChange={(value) => {
setManualTotals((current) => ({
...current,
total_sem_amount: true,
}));
form.setData(
'total_sem_amount',
numberInputString(value),
);
}}
/>
<NumberInput
precision={2}
label="Total net amount"
value={numberInputValue(
form.data.total_net_amount,
)}
onChange={(value) => {
setManualTotals((current) => ({
...current,
total_net_amount: true,
}));
form.setData(
'total_net_amount',
numberInputString(value),
);
}}
/>
</SimpleGrid>
</Stack>
</Box>
<Group position="right">
<Button
type="submit"
loading={form.processing}
leftIcon={<IconDeviceFloppy size={16} />}
>
{submitLabel}
</Button>
</Group>
</Stack>
</form>
);
}