inspiren-sem-tool/resources/js/pages/client-invoices/edit.tsx

301 lines
12 KiB
TypeScript

import InvoicePdfButton from '@/components/invoice-pdf-button';
import InvoiceForm, {
BillingItemTypeOption,
InvoiceFormValues,
InvoicePaymentFormValues,
createPayment,
createPaymentItem,
} from '@/forms/account/InvoiceForm';
import AppLayout from '@/layouts/app-layout';
import { ClientInvoice } from '@/types';
import { Link, router, useForm } from '@inertiajs/react';
import { Badge, Button, Container, Group, Title } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import React from 'react';
type InvoiceOptionSource = Pick<ClientInvoice, 'id' | 'invoice_no'> & {
linked_invoice_id?: number | null;
};
type InvoicePaymentItemSource = {
billing_item_types_id: number;
start_date: string | null;
end_date: string | null;
payment_item_amount: string | number;
tax_percentage: string | number;
net_amount: string | number;
withholding_tax: string | number;
final_net_amount: string | number;
spending: string | number;
is_creditcard: boolean;
};
type InvoicePaymentSource = {
payment_no: string | null;
payment_total_amount: string | number;
payment_nett_amount: string | number;
items: InvoicePaymentItemSource[];
};
interface Props {
invoice: ClientInvoice & {
pending_sql_acc_code?: string | null;
pending_client_name?: string | null;
client?: {
id: number;
name: string;
customer_id: string;
};
payments?: InvoicePaymentSource[];
total_sem_amount?: number | string | null;
total_net_amount?: number | string | null;
};
existingClient?: { id: number; name: string; customer_id: string } | null;
unlinkedClients: { value: string; label: string }[];
availableInvoices: InvoiceOptionSource[];
billingItemTypes: BillingItemTypeOption[];
}
const valueString = (value: string | number | null | undefined) =>
value === null || value === undefined ? '' : String(value);
const parseAmount = (value?: string | number | null) => {
const parsed = Number.parseFloat(String(value ?? ''));
return Number.isFinite(parsed) ? parsed : 0;
};
const finalNetAmount = (netAmount: number, withholdingTax: number) =>
netAmount / (1 + withholdingTax / 100);
const paymentItemsFromInvoice = (
invoice: Props['invoice'],
billingItemTypes: BillingItemTypeOption[],
): InvoicePaymentFormValues[] => {
if (invoice.payments?.length) {
return invoice.payments.map((payment) => ({
payment_no: payment.payment_no ?? '',
payment_total_amount: valueString(payment.payment_total_amount),
payment_nett_amount: valueString(payment.payment_nett_amount),
items: payment.items.map((item) => ({
billing_item_types_id: String(item.billing_item_types_id),
start_date: item.start_date ?? '',
end_date: item.end_date ?? '',
payment_item_amount: valueString(item.payment_item_amount),
tax_percentage: valueString(item.tax_percentage),
net_amount: valueString(item.net_amount),
withholding_tax: valueString(item.withholding_tax),
final_net_amount: valueString(item.final_net_amount),
spending: valueString(item.spending),
is_creditcard: !!item.is_creditcard,
})),
}));
}
const mediaSearch = billingItemTypes.find(
(itemType) => itemType.name === 'Google Ads Search (Media Fee)',
);
const managementSearch = billingItemTypes.find(
(itemType) => itemType.name === 'Management Fee (Google Search Ads)',
);
const fallbackPayment = createPayment(billingItemTypes, {
payment_no: invoice.payment_no ?? '',
payment_total_amount: valueString(
(Number(invoice.media_fee ?? 0) || 0) +
(Number(invoice.management_fee ?? 0) || 0),
),
payment_nett_amount: valueString(
invoice.total_net_amount ?? invoice.nett_amount ?? 0,
),
});
return [
{
...fallbackPayment,
items: [
createPaymentItem(
String(mediaSearch?.id ?? billingItemTypes[0]?.id ?? ''),
{
start_date: invoice.start_date ?? '',
end_date: invoice.end_date ?? '',
payment_item_amount: valueString(
invoice.media_fee ?? 0,
),
tax_percentage: valueString(invoice.tax_percent ?? 0),
net_amount: valueString(
invoice.media_fee_amount ??
invoice.nett_amount ??
0,
),
withholding_tax: '0',
final_net_amount: valueString(
invoice.nett_amount ??
invoice.media_fee_amount ??
0,
),
spending: valueString(invoice.total_spending ?? 0),
is_creditcard: !!invoice.is_credit_card,
},
),
createPaymentItem(
String(
managementSearch?.id ?? billingItemTypes[0]?.id ?? '',
),
{
payment_item_amount: valueString(
invoice.management_fee ?? 0,
),
tax_percentage: valueString(invoice.tax_percent ?? 0),
net_amount: valueString(
invoice.management_fee_amount ??
invoice.management_fee ??
0,
),
withholding_tax: '0',
final_net_amount: valueString(
invoice.management_fee_amount ??
invoice.management_fee ??
0,
),
spending: '0',
},
),
],
},
];
};
export default function Page({
invoice,
existingClient,
unlinkedClients,
availableInvoices,
billingItemTypes,
}: Props) {
const resolvedClient = invoice.client ?? existingClient ?? null;
const form = useForm<InvoiceFormValues>({
invoice_no: invoice.invoice_no ?? '',
linked_invoice_id: invoice.linked_invoice_id
? String(invoice.linked_invoice_id)
: '',
is_paid: !!invoice.is_paid,
client_id: resolvedClient ? String(resolvedClient.id) : '',
customer_id: resolvedClient?.customer_id ?? '',
total_sem_amount: valueString(invoice.total_sem_amount ?? '0.00'),
total_net_amount: valueString(
invoice.total_net_amount ?? invoice.nett_amount ?? '0.00',
),
payments: paymentItemsFromInvoice(invoice, billingItemTypes),
});
const isApproved = !!invoice.approved_at;
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
form.transform((data) => ({
...data,
linked_invoice_id: data.linked_invoice_id || null,
is_paid: !!data.is_paid,
total_sem_amount: parseFloat(data.total_sem_amount) || 0,
total_net_amount: parseFloat(data.total_net_amount) || 0,
payments: data.payments.map((payment) => ({
...payment,
payment_no: payment.payment_no || null,
payment_total_amount:
parseFloat(payment.payment_total_amount) || 0,
payment_nett_amount:
parseFloat(payment.payment_nett_amount) || 0,
items: payment.items.map((item) => ({
...item,
start_date: item.start_date || null,
end_date: item.end_date || null,
billing_item_types_id: parseInt(
item.billing_item_types_id,
10,
),
payment_item_amount: parseAmount(
item.payment_item_amount,
),
tax_percentage: parseAmount(item.tax_percentage),
net_amount: parseAmount(item.net_amount),
withholding_tax: parseAmount(item.withholding_tax),
final_net_amount: finalNetAmount(
parseAmount(item.net_amount),
parseAmount(item.withholding_tax),
),
spending: parseFloat(item.spending) || 0,
is_creditcard: !!item.is_creditcard,
})),
})),
}));
form.put(route('client-invoices.update', { invoice: invoice.id }));
};
const handleApprove = () => {
router.patch(route('client-invoices.approve', { invoice: invoice.id }));
};
const invoiceOptions = availableInvoices.map((item) => ({
value: String(item.id),
label: item.invoice_no,
}));
const requiresClient = !invoice.client_id;
return (
<AppLayout>
<Container size="lg" px="xs">
<Group position="apart" mb="md">
<Group spacing="sm">
<Title order={2}>Edit invoice</Title>
<Badge
color={isApproved ? 'green' : 'yellow'}
variant="light"
>
{isApproved ? 'Approved' : 'Pending approval'}
</Badge>
</Group>
<Group spacing="sm">
{/* {!isApproved && (
<Button color="green" onClick={handleApprove}>
Approve invoice
</Button>
)} */}
<InvoicePdfButton
invoiceId={invoice.id}
invoiceNo={invoice.invoice_no}
display="button"
label="View invoice"
/>
<Button
component={Link}
href={
resolvedClient?.customer_id
? route('google-ads.accounts.show', {
id: resolvedClient.customer_id,
})
: route('dashboard')
}
leftIcon={<IconArrowLeft size={16} />}
variant="outline"
>
{resolvedClient ? 'Back to account' : 'Back'}
</Button>
</Group>
</Group>
<InvoiceForm
form={form}
onSubmit={handleSubmit}
invoiceOptions={invoiceOptions}
billingItemTypes={billingItemTypes}
showClientLink
requiresClient={requiresClient}
clientOptions={unlinkedClients}
pendingClientName={invoice.pending_client_name ?? null}
/>
</Container>
</AppLayout>
);
}