1003 lines
43 KiB
TypeScript
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>
|
|
);
|
|
}
|