307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
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, IconFileDollar } 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>
|
|
)} */}
|
|
<Button
|
|
component="a"
|
|
href={route('client-invoices.getPdfInvoice', {
|
|
id: invoice.id,
|
|
})}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
leftIcon={<IconFileDollar size={16} />}
|
|
variant="light"
|
|
color="green"
|
|
>
|
|
View invoice
|
|
</Button>
|
|
<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>
|
|
);
|
|
}
|