feat: implement payable invoice functionality and enhance invoice processing
- Introduced `isPayableInvoice` utility to determine the payability of invoices based on status and balance. - Updated sales invoice detail page to conditionally create payment actions for payable invoices. - Refactored form allocations to load only payable sales invoices, improving data integrity in payment forms. - Enhanced sales payment form to validate invoice payability before processing. - Added unit tests for `isPayableInvoice` to ensure accurate functionality. - Updated sales status transitions to allow draft invoices to be processed or cancelled, improving workflow flexibility. These changes enhance the sales invoicing process by ensuring that only valid invoices are processed for payments, improving overall user experience and data management.
This commit is contained in:
+1
@@ -23,6 +23,7 @@
|
||||
"import_success": "CSV imported.",
|
||||
"status_updated": "Status updated.",
|
||||
"create_sales_payment": "Create Sales Payment",
|
||||
"action_process": "Process",
|
||||
"action_cancel": "Cancel",
|
||||
"status_draft": "Draft",
|
||||
"status_processed": "Processed",
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"import_success": "CSV berhasil diimpor.",
|
||||
"status_updated": "Status diperbarui.",
|
||||
"create_sales_payment": "Buat Pembayaran Penjualan",
|
||||
"action_process": "Proses",
|
||||
"action_cancel": "Batalkan",
|
||||
"status_draft": "Draft",
|
||||
"status_processed": "Diproses",
|
||||
|
||||
+8
-4
@@ -6,6 +6,7 @@ import { DetailGeneral } from '../../../shared/detail-general';
|
||||
import { DetailLocation } from '../../../shared/detail-location';
|
||||
import { DetailProducts } from '../../../shared/detail-products';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { isPayableInvoice } from '../../../shared/payable-invoice';
|
||||
import { salesOrdersModuleConfig } from '../../../orders/domain/constants';
|
||||
import { salesPaymentsModuleConfig } from '../../../payments/domain/constants';
|
||||
import type { SalesInvoiceEntity } from '../../domain/entities';
|
||||
@@ -27,10 +28,13 @@ export default function SalesInvoicePageDetail() {
|
||||
],
|
||||
}}
|
||||
customPageActions={(data, pageActions) => {
|
||||
const createPayment = actions.createPaymentAction(data as SalesInvoiceEntity, () => {
|
||||
navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`);
|
||||
});
|
||||
const withStatus = actions.detailStatusActions(data as SalesInvoiceEntity, pageActions ?? []);
|
||||
const invoice = data as SalesInvoiceEntity;
|
||||
const createPayment = isPayableInvoice(invoice)
|
||||
? actions.createPaymentAction(invoice, () => {
|
||||
navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`);
|
||||
})
|
||||
: null;
|
||||
const withStatus = actions.detailStatusActions(invoice, pageActions ?? []);
|
||||
return createPayment ? [createPayment, ...withStatus] : withStatus;
|
||||
}}
|
||||
>
|
||||
|
||||
+6
-4
@@ -12,9 +12,10 @@ import {
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { useFieldArray, useWatch } from '@repo/ui/form';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { loadSalesInvoiceOptions } from '../../../../../field/shared/lookup.factories';
|
||||
import { loadPayableSalesInvoiceOptions } from '../../../../shared/load-payable-sales-invoice-options';
|
||||
import { isPayableInvoice } from '../../../../shared/payable-invoice';
|
||||
import { relationLabel } from '../../../../../field/shared/relation-label';
|
||||
import type { LookupEntity } from '../../../../../field/shared/lookup.entity';
|
||||
import type { SalesInvoiceEntity } from '../../../../invoices/domain/entities';
|
||||
|
||||
export function FormAllocations() {
|
||||
const { formControl } = useFormPageContext();
|
||||
@@ -45,13 +46,14 @@ export function FormAllocations() {
|
||||
return (
|
||||
<Table.Tr key={field.id}>
|
||||
<Table.Td miw={240}>
|
||||
<FieldAsyncSelect<LookupEntity>
|
||||
<FieldAsyncSelect<SalesInvoiceEntity>
|
||||
control={formControl.control}
|
||||
name={`invoices.${index}.invoice`}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
searchable
|
||||
loadOptions={loadSalesInvoiceOptions}
|
||||
loadOptions={loadPayableSalesInvoiceOptions}
|
||||
filterOption={(item) => isPayableInvoice(item)}
|
||||
defaultOptions={line?.invoice ? [line.invoice] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import { FormAllocations } from '../components/form-component/form-allocations';
|
||||
import { FormImages } from '../../../shared/form-images';
|
||||
import { FormNotes } from '../../../shared/form-notes';
|
||||
import { salesInvoicesDataService } from '../../../invoices/domain/factories';
|
||||
import { isPayableInvoice } from '../../../shared/payable-invoice';
|
||||
import type { SalesInvoiceEntity } from '../../../invoices/domain/entities';
|
||||
|
||||
export default function SalesPaymentPageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||
@@ -46,7 +47,7 @@ export default function SalesPaymentPageForm({ formPageType }: { formPageType: F
|
||||
prefilled.current = true;
|
||||
void salesInvoicesDataService.getOne(invoiceId).then((result) => {
|
||||
const entity = (result.data as { data?: SalesInvoiceEntity })?.data;
|
||||
if (!entity) return;
|
||||
if (!entity || !isPayableInvoice(entity)) return;
|
||||
formControl.reset({
|
||||
invoices: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createOptionLoader } from '../../field/shared/create-option-loader';
|
||||
import { salesInvoicesDataService } from '../invoices/domain/factories';
|
||||
import type { SalesInvoiceEntity } from '../invoices/domain/entities';
|
||||
|
||||
export const loadPayableSalesInvoiceOptions = createOptionLoader<SalesInvoiceEntity>(
|
||||
(config) => salesInvoicesDataService.getMany(config),
|
||||
{ payable: true },
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isPayableInvoice } from './payable-invoice';
|
||||
|
||||
describe('isPayableInvoice', () => {
|
||||
it('accepts processed and partial invoices with remaining balance', () => {
|
||||
expect(isPayableInvoice({ status: 'processed', balance: '25000.0000' })).toBe(true);
|
||||
expect(isPayableInvoice({ status: 'partial', balance: '1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects draft invoices and invoices with no remaining balance', () => {
|
||||
expect(isPayableInvoice({ status: 'draft', balance: '25000.0000' })).toBe(false);
|
||||
expect(isPayableInvoice({ status: 'processed', balance: '0.0000' })).toBe(false);
|
||||
expect(isPayableInvoice({ status: 'completed', balance: '0' })).toBe(false);
|
||||
expect(isPayableInvoice({ status: 'cancelled', balance: '10000' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const;
|
||||
|
||||
export function isPayableInvoice(invoice: {
|
||||
status?: string | null;
|
||||
balance?: string | number | null;
|
||||
}): boolean {
|
||||
const payable: readonly string[] = PAYABLE_INVOICE_STATUSES;
|
||||
if (!invoice.status || !payable.includes(invoice.status)) {
|
||||
return false;
|
||||
}
|
||||
const balance = Number(invoice.balance);
|
||||
return Number.isFinite(balance) && balance > 0;
|
||||
}
|
||||
@@ -26,9 +26,11 @@ describe('sales status transitions', () => {
|
||||
expect(namedActionsFor('order', 'processed').map((action) => action.key)).toEqual(['cancel']);
|
||||
});
|
||||
|
||||
it('limits invoice user transitions to cancelled', () => {
|
||||
it('allows invoice draft to processed or cancelled', () => {
|
||||
expect(allowedTransitions('invoice', 'draft')).toEqual(['processed', 'cancelled']);
|
||||
expect(allowedTransitions('invoice', 'processed')).toEqual(['cancelled']);
|
||||
expect(allowedTransitions('invoice', 'partial')).toEqual(['cancelled']);
|
||||
expect(namedActionsFor('invoice', 'draft').map((action) => action.key)).toEqual(['process', 'cancel']);
|
||||
expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['cancel']);
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const PACKING_TRANSITIONS: Record<PackingSlipStatus, PackingSlipStatus[]> = {
|
||||
};
|
||||
|
||||
const INVOICE_TRANSITIONS: Record<SalesInvoiceStatus, SalesInvoiceStatus[]> = {
|
||||
draft: ['cancelled'],
|
||||
draft: ['processed', 'cancelled'],
|
||||
processed: ['cancelled'],
|
||||
partial: ['cancelled'],
|
||||
completed: ['cancelled'],
|
||||
@@ -61,6 +61,7 @@ export const PACKING_SLIP_NAMED_ACTIONS = [
|
||||
] as const;
|
||||
|
||||
export const SALES_INVOICE_NAMED_ACTIONS = [
|
||||
{ key: 'process', target: 'processed' as const, from: ['draft'] as const },
|
||||
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed', 'partial'] as const },
|
||||
] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user