diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json index 91c1c67..7265b7f 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json @@ -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", diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json index 65a84b4..3464ad6 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json @@ -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", diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx index 76b5ee8..b43cad9 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx @@ -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; }} > diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx index a273138..79c0b22 100644 --- a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx @@ -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 ( - + 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} /> diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx index 34f867d..0255f8c 100644 --- a/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx @@ -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: [ { diff --git a/apps/web/src/apps/main/modules/sales/shared/load-payable-sales-invoice-options.ts b/apps/web/src/apps/main/modules/sales/shared/load-payable-sales-invoice-options.ts new file mode 100644 index 0000000..001d15d --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/load-payable-sales-invoice-options.ts @@ -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( + (config) => salesInvoicesDataService.getMany(config), + { payable: true }, +); diff --git a/apps/web/src/apps/main/modules/sales/shared/payable-invoice.test.ts b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.test.ts new file mode 100644 index 0000000..edfc4c1 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts new file mode 100644 index 0000000..10a7469 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts @@ -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; +} diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts index cb99720..ff7dc31 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts @@ -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']); }); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-status.ts b/apps/web/src/apps/main/modules/sales/shared/sales-status.ts index ada63e5..e7f57c8 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-status.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-status.ts @@ -37,7 +37,7 @@ const PACKING_TRANSITIONS: Record = { }; const INVOICE_TRANSITIONS: Record = { - 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;