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; onSubmit: (event: React.FormEvent) => 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 { 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 { 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)[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, 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 (
({ border: `1px solid ${borderColor(theme)}`, borderRadius: theme.radius.sm, background: sectionSurface(theme), padding: theme.spacing.lg, })} > Invoice details {form.data.payments.length} payment {form.data.payments.length === 1 ? '' : 's'} form.setData( 'invoice_no', event.target.value, ) } error={form.errors.invoice_no} required /> form.setData((data) => ({ ...data, client_id: value ?? '', linked_invoice_id: '', })) } error={form.errors.client_id} searchable nothingFound="No clients found" required={requiresClient} /> ) : null} {form.data.payments.map((payment, paymentIndex) => ( ({ border: `1px solid ${borderColor(theme)}`, borderRadius: theme.radius.sm, background: sectionSurface(theme), overflow: 'hidden', })} > ({ background: subtleSurface(theme), borderBottom: `1px solid ${borderColor(theme)}`, padding: `${theme.spacing.md} ${theme.spacing.lg}`, })} > Payment {paymentIndex + 1} {payment.items.length} item {payment.items.length === 1 ? '' : 's'} RM{' '} {formatAmount( parseAmount( payment.payment_total_amount, ), )} {form.data.payments.length > 1 && ( removePayment(paymentIndex) } aria-label="Remove payment" > )} updatePayment( paymentIndex, 'payment_no', event.target.value, ) } error={fieldError( `payments.${paymentIndex}.payment_no`, )} /> updatePayment( paymentIndex, 'payment_total_amount', numberInputString(value), ) } error={fieldError( `payments.${paymentIndex}.payment_total_amount`, )} /> updatePayment( paymentIndex, 'payment_nett_amount', numberInputString(value), ) } error={fieldError( `payments.${paymentIndex}.payment_nett_amount`, )} /> Line items {payment.items.map((item, itemIndex) => ( ({ border: `1px solid ${borderColor(theme, 2)}`, borderRadius: theme.radius.sm, background: nestedSurface(theme), padding: theme.spacing.md, })} > Item {itemIndex + 1} {itemTypesById.get( item.billing_item_types_id, )?.fee_type && ( { itemTypesById.get( item.billing_item_types_id, )?.fee_type } )} removeItem( paymentIndex, itemIndex, ) } aria-label="Remove item" >