diff --git a/apps/web/src/apps/main/layouts/data/menu.data.ts b/apps/web/src/apps/main/layouts/data/menu.data.ts index f3686a3..c0e7dd0 100644 --- a/apps/web/src/apps/main/layouts/data/menu.data.ts +++ b/apps/web/src/apps/main/layouts/data/menu.data.ts @@ -85,14 +85,14 @@ export const MENU_ITEMS: MenuItemType[] = [ key: 'sales-invoices', label: 'nav:sales-invoices', icon: Receipt, - path: '/app/sales/invoices', + path: '/app/sales/invoices/index', moduleKey: 'SALES.INVOICE', }, { key: 'sales-payments', label: 'nav:sales-payments', icon: CreditCard, - path: '/app/sales/payments', + path: '/app/sales/payments/index', moduleKey: 'SALES.PAYMENT', }, { diff --git a/apps/web/src/apps/main/modules/sales/index.tsx b/apps/web/src/apps/main/modules/sales/index.tsx index dc75f77..7da2c0a 100644 --- a/apps/web/src/apps/main/modules/sales/index.tsx +++ b/apps/web/src/apps/main/modules/sales/index.tsx @@ -7,6 +7,8 @@ const OrdersModule = lazy(() => import('./orders/presentation/factory')); const EmployeesModule = lazy(() => import('../configuration/employees/presentation/factory')); const CyclesModule = lazy(() => import('../field/cycles/presentation/factory')); const PlansModule = lazy(() => import('../field/plans/presentation/factory')); +const InvoicesModule = lazy(() => import('./invoices/presentation/factory')); +const PaymentsModule = lazy(() => import('./payments/presentation/factory')); export default function SalesModule() { return ( @@ -16,8 +18,8 @@ export default function SalesModule() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.test.ts b/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.test.ts new file mode 100644 index 0000000..03cb9b1 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AxiosInstance } from '@repo/core-api/http-client'; +import { SalesInvoicesRemoteDataServices } from './sales-invoice.remote.service'; +import { SalesInvoicesRemoteDataTransformer } from '../domain/transformers/sales-invoice.remote.transformer'; + +function createMockHttpClient(): AxiosInstance { + return { + request: vi.fn().mockResolvedValue({ data: {}, status: 200 }), + defaults: {} as AxiosInstance['defaults'], + interceptors: { + request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, + response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, + }, + getUri: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + head: vi.fn(), + options: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + postForm: vi.fn(), + putForm: vi.fn(), + patchForm: vi.fn(), + } as unknown as AxiosInstance; +} + +describe('SalesInvoicesRemoteDataServices', () => { + let httpClient: AxiosInstance; + let service: SalesInvoicesRemoteDataServices; + + beforeEach(() => { + httpClient = createMockHttpClient(); + service = new SalesInvoicesRemoteDataServices(httpClient, { + apiUrl: '/sales-invoices', + moduleKey: 'SALES.INVOICE', + transformer: new SalesInvoicesRemoteDataTransformer(), + }); + }); + + it('uses PATCH when editing', async () => { + await service.edit('so-1', { address: 'Jl Sudirman 1' } as any); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/sales-invoices/so-1', method: 'PATCH' }), + ); + }); + + it('bulk-changes status via POST /bulk-status', async () => { + await service.bulkChangeStatus(['so-1'], 'processed'); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/sales-invoices/bulk-status', + method: 'POST', + data: { ids: ['so-1'], status: 'processed' }, + }), + ); + }); +}); diff --git a/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.ts b/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.ts new file mode 100644 index 0000000..82b8da8 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/data/sales-invoice.remote.service.ts @@ -0,0 +1,13 @@ +import type { AxiosInstance } from '@repo/core-api/http-client'; +import type { DataServicesConfig } from '@repo/core-api/data-services'; +import { SalesDocumentRemoteDataServices } from '../../shared/sales-document.remote.service'; +import type { SalesInvoiceEntity } from '../domain/entities'; + +export class SalesInvoicesRemoteDataServices extends SalesDocumentRemoteDataServices { + constructor(httpClient: AxiosInstance, config: DataServicesConfig) { + super(httpClient, { + ...config, + apiUrl: config.apiUrl ?? '/sales-invoices', + }); + } +} diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/constants/index.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/constants/index.ts new file mode 100644 index 0000000..1b9fbcd --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/domain/constants/index.ts @@ -0,0 +1 @@ +export * from './sales-invoice.constants'; diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/constants/sales-invoice.constants.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/constants/sales-invoice.constants.ts new file mode 100644 index 0000000..2810467 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/domain/constants/sales-invoice.constants.ts @@ -0,0 +1,11 @@ +import { ModuleConfigEntity } from '@repo/ui/foundations'; +import type { SalesInvoiceEntity } from '../entities'; + +export const salesInvoicesModuleConfig: ModuleConfigEntity = { + moduleKey: 'SALES.INVOICE', + translationNamespace: 'SALES_INVOICES', + apiUrl: '/sales-invoices', + webUrl: '/app/sales/invoices', + moduleCategory: 'FULL_PAGE', + moduleType: 'TRANSACTION', +} as const; diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/entities/index.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/entities/index.ts new file mode 100644 index 0000000..70fdce9 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './sales-invoice.entity'; diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/entities/sales-invoice.entity.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/entities/sales-invoice.entity.ts new file mode 100644 index 0000000..95054c1 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/domain/entities/sales-invoice.entity.ts @@ -0,0 +1,21 @@ +import type { SalesDocumentDto, SalesDocumentEntity } from '../../../shared/sales-document.entity'; + +export interface SalesInvoiceEntity extends SalesDocumentEntity { + salesOrderId?: string | null; + salesOrder?: { id: string; code?: string; name?: string } | null; + salesOrderCode?: string | null; + packingSlipId?: string | null; + packingSlip?: { id: string; code?: string; name?: string } | null; + packingSlipCode?: string | null; + balance?: string | null; +} + +export interface SalesInvoiceDto extends SalesDocumentDto { + salesOrderId?: string | null; + salesOrder?: { id: string; code?: string; name?: string } | null; + salesOrderCode?: string | null; + packingSlipId?: string | null; + packingSlip?: { id: string; code?: string; name?: string } | null; + packingSlipCode?: string | null; + balance?: string | null; +} diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/factories/index.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/factories/index.ts new file mode 100644 index 0000000..7c76c68 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/domain/factories/index.ts @@ -0,0 +1,12 @@ +import { apiClient } from '../../../../../../../core/lib/api-client'; +import { SalesInvoicesRemoteDataServices } from '../../data/sales-invoice.remote.service'; +import { salesInvoicesModuleConfig } from '../constants/sales-invoice.constants'; +import { SalesInvoicesRemoteDataTransformer } from '../transformers/sales-invoice.remote.transformer'; + +export const salesInvoicesDataTransformer = new SalesInvoicesRemoteDataTransformer(); + +export const salesInvoicesDataService = new SalesInvoicesRemoteDataServices(apiClient, { + apiUrl: salesInvoicesModuleConfig.apiUrl, + moduleKey: salesInvoicesModuleConfig.moduleKey, + transformer: salesInvoicesDataTransformer, +}); diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.test.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.test.ts new file mode 100644 index 0000000..75e8e38 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { SalesInvoicesRemoteDataTransformer } from './sales-invoice.remote.transformer'; + +const transformer = new SalesInvoicesRemoteDataTransformer(); + +describe('SalesInvoicesRemoteDataTransformer', () => { + it('keeps nested sales order, packing slip, and balance from the API response', () => { + const entity = transformer.transformToEntity({ + id: 'inv-1', + date: Date.UTC(2026, 7, 26), + address: 'depok', + salesOrder: { id: 'so-1', code: 'SO-20260801-0001' }, + packingSlip: { id: 'ps-1', code: 'PS-1' }, + salesOrderCode: 'SO-20260801-0001', + packingSlipCode: 'PS-1', + balance: '10000.0000', + products: [], + } as any); + + expect(entity.salesOrder).toEqual({ id: 'so-1', code: 'SO-20260801-0001' }); + expect(entity.salesOrderId).toBe('so-1'); + expect(entity.packingSlipId).toBe('ps-1'); + expect(entity.balance).toBe('10000.0000'); + }); + + it('includes parent ids on create, omits them on edit, and never writes images', () => { + const entity = { + date: '2026-08-26', + salesPerson: { id: 'emp-1' } as any, + branch: { id: 'br-1' } as any, + division: { id: 'div-1' } as any, + customer: { id: 'cus-1' } as any, + address: 'Jl Sudirman 1', + products: [{ product: { id: 'prd-1' } as any, quantity: '1', price: '10.0000' }], + images: [{ url: 'https://cdn.example/a.png' }], + salesOrder: { id: 'so-1' } as any, + packingSlip: { id: 'ps-1' } as any, + }; + + const createPayload = transformer.transformCreatePayload(entity); + const editPayload = transformer.transformEditPayload(entity); + + expect(createPayload.salesOrderId).toBe('so-1'); + expect(createPayload.packingSlipId).toBe('ps-1'); + expect(createPayload).not.toHaveProperty('images'); + expect(editPayload).not.toHaveProperty('salesOrderId'); + expect(editPayload).not.toHaveProperty('images'); + }); +}); diff --git a/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.ts b/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.ts new file mode 100644 index 0000000..6f1ec8e --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/domain/transformers/sales-invoice.remote.transformer.ts @@ -0,0 +1,51 @@ +import { BaseDataTransformer } from '@repo/core-api/data-services'; +import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; +import { + mapSalesDocumentFromDto, + relationId, + toLookup, + toSalesFilterPayload, + toSalesWritePayload, +} from '../../../shared/sales-document.mapper'; +import type { SalesInvoiceDto, SalesInvoiceEntity } from '../entities'; + +export class SalesInvoicesRemoteDataTransformer extends BaseDataTransformer { + transformToEntity(dto: SalesInvoiceDto | SalesInvoiceEntity): SalesInvoiceEntity { + const invoiceDto = dto as SalesInvoiceDto; + const base = mapSalesDocumentFromDto(invoiceDto); + const salesOrder = toLookup(invoiceDto.salesOrder, invoiceDto.salesOrderId); + const packingSlip = toLookup(invoiceDto.packingSlip, invoiceDto.packingSlipId); + return { + ...base, + salesOrderId: relationId(invoiceDto.salesOrder) ?? invoiceDto.salesOrderId ?? null, + salesOrder, + salesOrderCode: invoiceDto.salesOrderCode ?? salesOrder?.code ?? null, + packingSlipId: relationId(invoiceDto.packingSlip) ?? invoiceDto.packingSlipId ?? null, + packingSlip, + packingSlipCode: invoiceDto.packingSlipCode ?? packingSlip?.code ?? null, + balance: invoiceDto.balance ?? null, + }; + } + + transformToDTO(entity: SalesInvoiceEntity): SalesInvoiceEntity { + return { ...entity }; + } + + transformCreatePayload(entity: Partial): Partial { + return omitEmptyFields( + toSalesWritePayload(entity, { + includeSalesOrderId: true, + includePackingSlipId: true, + omitImages: true, + }), + ) as Partial; + } + + transformEditPayload(entity: Partial): Partial { + return toSalesWritePayload(entity, { omitImages: true }) as Partial; + } + + transformPayloadFilter(filter: Record): Record { + return toSalesFilterPayload(filter); + } +} diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/components/form-component/form-invoice-source.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/components/form-component/form-invoice-source.tsx new file mode 100644 index 0000000..2244d00 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/components/form-component/form-invoice-source.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef } from 'react'; +import { Box, FieldAsyncSelect, Paper, SimpleGrid, Text } from '@repo/ui/components'; +import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; +import { loadSalesOrderOptions } from '../../../../shared/load-sales-order-options'; +import { loadPackingSlipOptions, packingSlipsDataService } from '../../../../../field/shared/lookup.factories'; +import { relationLabel } from '../../../../../field/shared/relation-label'; +import { salesRequestToFormValues } from '../../../../shared/sales-document.mapper'; +import { salesOrdersDataService } from '../../../../orders/domain/factories'; +import type { SalesOrderEntity } from '../../../../orders/domain/entities'; +import type { LookupEntity } from '../../../../../field/shared/lookup.entity'; + +export function FormInvoiceSource() { + const { formControl, isCreate } = useFormPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const salesOrder = formControl.watch('salesOrder'); + const packingSlip = formControl.watch('packingSlip'); + const appliedOrderId = useRef(null); + const appliedPackingSlipId = useRef(null); + + useEffect(() => { + const id = salesOrder?.id; + if (!isCreate || !id || appliedOrderId.current === id) return; + appliedOrderId.current = id; + void salesOrdersDataService.getOne(id).then((result: { data?: { data?: SalesOrderEntity } }) => { + const entity = (result.data as { data?: SalesOrderEntity })?.data; + if (!entity) return; + const values = salesRequestToFormValues(entity); + formControl.reset({ + ...formControl.getValues(), + salesOrder, + ...values, + images: [], + }); + }); + }, [formControl, isCreate, salesOrder]); + + useEffect(() => { + const id = packingSlip?.id; + if (!isCreate || !id || appliedPackingSlipId.current === id) return; + appliedPackingSlipId.current = id; + void packingSlipsDataService.getOne(id).then((result: { data?: { data?: LookupEntity } }) => { + const entity = (result.data as { data?: LookupEntity })?.data; + if (!entity) return; + formControl.reset({ + ...formControl.getValues(), + packingSlip: entity, + }); + }); + }, [formControl, isCreate, packingSlip]); + + if (!isCreate) return null; + + return ( + + + {t('section_source')} + + + + + control={formControl.control} + name="salesOrder" + label={t('common:fields.salesOrder')} + valueKey="id" + labelKey="code" + searchable + clearable + loadOptions={loadSalesOrderOptions} + defaultOptions={salesOrder ? [salesOrder] : []} + renderLabel={relationLabel} + /> + + control={formControl.control} + name="packingSlip" + label={t('common:fields.packingSlip')} + valueKey="id" + labelKey="code" + searchable + clearable + loadOptions={loadPackingSlipOptions} + defaultOptions={packingSlip ? [packingSlip] : []} + renderLabel={relationLabel} + /> + + + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/factory/index.tsx new file mode 100644 index 0000000..e08872b --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/factory/index.tsx @@ -0,0 +1,40 @@ +import { lazy } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { EnterpriseModuleProvider } from '@repo/ui/foundations'; +import { registerModuleNamespace } from '@repo/core-i18n'; +import { salesInvoicesModuleConfig } from '../../domain/constants'; +import { salesInvoicesDataService } from '../../domain/factories'; +import { SalesInvoiceEntity } from '../../domain/entities'; +import { salesInvoicesStore } from '../store'; + +import salesInvoicesId from '../languages/id/sales-invoices.json'; +import salesInvoicesEn from '../languages/en/sales-invoices.json'; + +const IndexPage = lazy(() => import('../pages/sales-invoice.page.index')); +const FormPage = lazy(() => import('../pages/sales-invoice.page.form')); +const DetailPage = lazy(() => import('../pages/sales-invoice.page.detail')); + +registerModuleNamespace(salesInvoicesModuleConfig.translationNamespace, { + id: salesInvoicesId, + en: salesInvoicesEn, +}); + +export default function SalesInvoicesModule() { + return ( + + config={salesInvoicesModuleConfig} + dataServices={salesInvoicesDataService} + store={salesInvoicesStore} + > + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} 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 new file mode 100644 index 0000000..a1ffafd --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json @@ -0,0 +1,34 @@ +{ + "title": "Sales Invoices", + "detail_page_title": "Sales Invoice Detail", + "create_page_title": "New Sales Invoice", + "edit_page_title": "Edit Sales Invoice", + "duplicate_page_title": "Duplicate Sales Invoice", + "description": "Create <1>sales invoices from a sales order or packing slip, or as a standalone document.", + "detail_page_description": "Review invoice header, location, products, and remaining balance.", + "create_page_description": "Create a sales invoice, optionally sourced from a sales order or packing slip.", + "edit_page_description": "Update invoice header, location, products, and notes.", + "duplicate_page_description": "Copy an existing sales invoice to create a new one.", + "section_general": "General", + "section_source": "Source", + "section_location": "Location", + "section_products": "Products", + "section_notes": "Notes", + "add_line": "Add line", + "remove_line": "Remove line", + "empty_products": "No product lines.", + "change_status": "Change status", + "import_csv": "Import CSV", + "csv_file": "CSV file", + "import_success": "CSV imported.", + "status_updated": "Status updated.", + "create_sales_payment": "Create Sales Payment", + "action_process": "Process", + "action_complete": "Complete", + "action_cancel": "Cancel", + "status_draft": "Draft", + "status_processed": "Processed", + "status_partial": "Partial", + "status_completed": "Completed", + "status_cancelled": "Cancelled" +} 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 new file mode 100644 index 0000000..7a835bc --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json @@ -0,0 +1,34 @@ +{ + "title": "Faktur Penjualan", + "detail_page_title": "Detail Faktur Penjualan", + "create_page_title": "Faktur Penjualan Baru", + "edit_page_title": "Ubah Faktur Penjualan", + "duplicate_page_title": "Duplikat Faktur Penjualan", + "description": "Buat <1>faktur penjualan dari pesanan atau surat jalan, atau sebagai dokumen mandiri.", + "detail_page_description": "Tinjau header, lokasi, produk, dan sisa saldo faktur.", + "create_page_description": "Buat faktur penjualan, opsional dari pesanan atau surat jalan.", + "edit_page_description": "Perbarui header, lokasi, produk, dan catatan faktur.", + "duplicate_page_description": "Salin faktur penjualan yang ada untuk membuat data baru.", + "section_general": "Umum", + "section_source": "Sumber", + "section_location": "Lokasi", + "section_products": "Produk", + "section_notes": "Catatan", + "add_line": "Tambah baris", + "remove_line": "Hapus baris", + "empty_products": "Tidak ada baris produk.", + "change_status": "Ubah status", + "import_csv": "Impor CSV", + "csv_file": "File CSV", + "import_success": "CSV berhasil diimpor.", + "status_updated": "Status diperbarui.", + "create_sales_payment": "Buat Pembayaran Penjualan", + "action_process": "Proses", + "action_complete": "Selesaikan", + "action_cancel": "Batalkan", + "status_draft": "Draft", + "status_processed": "Diproses", + "status_partial": "Sebagian", + "status_completed": "Selesai", + "status_cancelled": "Dibatalkan" +} 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 new file mode 100644 index 0000000..e3258ed --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from 'react-router-dom'; +import { Stack } from '@repo/ui/components'; +import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import { salesInvoicesModuleConfig } from '../../domain/constants'; +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 { salesOrdersModuleConfig } from '../../../orders/domain/constants'; +import { salesPaymentsModuleConfig } from '../../../payments/domain/constants'; +import type { SalesInvoiceEntity } from '../../domain/entities'; + +export default function SalesInvoicePageDetail() { + const { t } = useEnterpriseModuleTranslationContext(); + const navigate = useNavigate(); + const actions = useSalesDocumentActions('invoice'); + + return ( + { + const createPayment = actions.createPaymentAction(data as SalesInvoiceEntity, () => { + navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`); + }); + const withStatus = actions.detailStatusActions(data as SalesInvoiceEntity, pageActions ?? []); + return createPayment ? [createPayment, ...withStatus] : withStatus; + }} + > + + `${salesOrdersModuleConfig.webUrl}/detail/${id}`} + showBalance + /> + + + + {actions.modals} + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.form.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.form.tsx new file mode 100644 index 0000000..5374523 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.form.tsx @@ -0,0 +1,118 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations'; +import { Stack } from '@repo/ui/components'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { salesInvoicesModuleConfig } from '../../domain/constants'; +import { createSalesInvoiceSchema } from '../../../shared/sales-document.validator'; +import { FormInvoiceSource } from '../components/form-component/form-invoice-source'; +import { FormGeneral } from '../../../shared/form-general'; +import { FormLocation } from '../../../shared/form-location'; +import { FormProducts } from '../../../shared/form-products'; +import { FormNotes } from '../../../shared/form-notes'; +import { salesOrdersDataService } from '../../../orders/domain/factories'; +import { packingSlipsDataService } from '../../../../field/shared/lookup.factories'; +import { salesRequestToFormValues } from '../../../shared/sales-document.mapper'; +import type { SalesOrderEntity } from '../../../orders/domain/entities'; +import type { LookupEntity } from '../../../../field/shared/lookup.entity'; + +export default function SalesInvoicePageForm({ formPageType }: { formPageType: FormPageType }) { + const { t } = useEnterpriseModuleTranslationContext(); + const [searchParams] = useSearchParams(); + const prefilled = useRef(false); + + const title = useMemo(() => { + if (formPageType === 'CREATE') { + return { title: t('create_page_title'), description: t('create_page_description') }; + } + if (formPageType === 'EDIT') { + return { title: t('edit_page_title'), description: t('edit_page_description') }; + } + if (formPageType === 'DUPLICATE') { + return { title: t('duplicate_page_title'), description: t('duplicate_page_description') }; + } + return { title: '', description: '' }; + }, [formPageType, t]); + + const validator = useMemo(() => createSalesInvoiceSchema(t), [t]); + const formControl = useForm({ + resolver: zodResolver(validator), + defaultValues: { + products: [{ quantity: '1', price: '' }], + }, + }); + + useEffect(() => { + const salesOrderId = searchParams.get('salesOrderId'); + const packingSlipId = searchParams.get('packingSlipId'); + if (formPageType !== 'CREATE' || prefilled.current || (!salesOrderId && !packingSlipId)) return; + prefilled.current = true; + + const load = async () => { + let next: Record = { ...formControl.getValues() }; + if (salesOrderId) { + const result = await salesOrdersDataService.getOne(salesOrderId); + const entity = (result.data as { data?: SalesOrderEntity })?.data; + if (entity) { + next = { + ...next, + salesOrder: { id: entity.id as string, code: entity.code ?? undefined }, + ...salesRequestToFormValues(entity), + images: [], + }; + } + } + if (packingSlipId) { + const result = await packingSlipsDataService.getOne(packingSlipId); + const entity = (result.data as { data?: LookupEntity })?.data; + if (entity) { + next = { ...next, packingSlip: entity }; + } + } + formControl.reset(next as any); + }; + + void load(); + }, [formControl, formPageType, searchParams]); + + return ( + + + + + + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx new file mode 100644 index 0000000..9f04fab --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx @@ -0,0 +1,79 @@ +import { useMemo } from 'react'; +import { + EnterpriseIndexPageProvider, + useEnterpriseModuleTranslationContext, + EnterpriseDataTable, +} from '@repo/ui/foundations'; +import { ColDef, Text } from '@repo/ui/components'; +import { Trans } from '@repo/core-i18n'; +import { Receipt } from 'lucide-react'; +import { SalesFilterFormContent } from '../../../shared/filter-content'; +import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions'; +import { relationLabel } from '../../../../field/shared/relation-label'; +import type { SalesInvoiceEntity } from '../../domain/entities'; + +export default function SalesInvoicePageIndex() { + const { t } = useEnterpriseModuleTranslationContext(); + const actions = useSalesDocumentActions('invoice'); + + const columnDefs: ColDef[] = useMemo( + () => [ + { field: 'code', headerName: t('common:fields.code'), minWidth: 160 }, + { field: 'date', headerName: t('common:fields.date'), minWidth: 140 }, + { + field: 'customerId', + headerName: t('common:fields.customer'), + minWidth: 180, + valueGetter: ({ data }) => relationLabel(data?.customer) || data?.customerId, + }, + { + field: 'salesPersonId', + headerName: t('common:fields.salesPerson'), + minWidth: 180, + valueGetter: ({ data }) => relationLabel(data?.salesPerson) || data?.salesPersonId, + }, + { + field: 'branchId', + headerName: t('common:fields.branch'), + minWidth: 160, + valueGetter: ({ data }) => relationLabel(data?.branch) || data?.branchId, + }, + { field: 'balance', headerName: t('common:fields.balance'), minWidth: 140 }, + ], + [t], + ); + + const filterConfig = useMemo( + () => ({ + renderBody: (form: any) => (form ? : null), + }), + [t], + ); + + return ( + }} /> + ), + icon: Receipt, + breadcrumbs: [ + { label: t('nav:sales'), type: 'text' }, + { label: t('nav:sales-invoices'), type: 'text' }, + ], + }} + customPageActions={(pageActions) => + actions.importPageAction ? [actions.importPageAction, ...(pageActions ?? [])] : pageActions + } + > + actions.namedRowActions(data, defaultActions)} + customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)} + /> + {actions.modals} + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/store/index.ts b/apps/web/src/apps/main/modules/sales/invoices/presentation/store/index.ts new file mode 100644 index 0000000..32bc78e --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/store/index.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; +import { EnterpriseModuleState } from '@repo/ui/foundations'; +import { SalesInvoiceEntity } from '../../domain/entities'; + +export interface SalesInvoicesStoreState extends EnterpriseModuleState {} + +export const salesInvoicesStore = create((set) => ({ + metaData: { limit: 15 }, + setMetaData: (data) => set({ metaData: data }), + filterData: {}, + setFilterData: (data) => set({ filterData: data }), + selectedRows: [], + setSelectedRows: (rows) => set({ selectedRows: rows }), + privileges: [], + setPrivileges: (privileges) => set({ privileges }), + tableConfig: null, + setTableConfig: (config) => set({ tableConfig: config }), +})); diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts b/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts index e63972e..b39ab3e 100644 --- a/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts +++ b/apps/web/src/apps/main/modules/sales/orders/domain/entities/sales-order.entity.ts @@ -7,4 +7,5 @@ export interface SalesOrderEntity extends SalesDocumentEntity { export interface SalesOrderDto extends SalesDocumentDto { salesRequestId?: string | null; + salesRequest?: { id: string; code?: string; name?: string } | null; } diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.test.ts b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.test.ts index 1b82028..3520dbe 100644 --- a/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.test.ts +++ b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.test.ts @@ -4,6 +4,21 @@ import { SalesOrdersRemoteDataTransformer } from './sales-order.remote.transform const transformer = new SalesOrdersRemoteDataTransformer(); describe('SalesOrdersRemoteDataTransformer', () => { + it('keeps nested salesRequest code from the API response', () => { + const entity = transformer.transformToEntity({ + id: 'so-1', + date: Date.UTC(2026, 7, 26), + address: 'depok', + salesRequest: { id: 'sr-1', code: 'SR-20260801-0001' }, + branch: { id: 'br-1', code: 'B_411587', name: 'Jakarta Pusat' }, + products: [], + } as any); + + expect(entity.salesRequest).toEqual({ id: 'sr-1', code: 'SR-20260801-0001' }); + expect(entity.salesRequestId).toBe('sr-1'); + expect(entity.branch).toEqual({ id: 'br-1', code: 'B_411587', name: 'Jakarta Pusat' }); + }); + it('includes salesRequestId on create and omits it on edit', () => { const entity = { date: '2026-08-26', diff --git a/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts index 5ab9f64..a1e3f50 100644 --- a/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts +++ b/apps/web/src/apps/main/modules/sales/orders/domain/transformers/sales-order.remote.transformer.ts @@ -2,7 +2,8 @@ import { BaseDataTransformer } from '@repo/core-api/data-services'; import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; import { mapSalesDocumentFromDto, - toRelationStub, + relationId, + toLookup, toSalesFilterPayload, toSalesWritePayload, } from '../../../shared/sales-document.mapper'; @@ -10,11 +11,13 @@ import type { SalesOrderDto, SalesOrderEntity } from '../entities'; export class SalesOrdersRemoteDataTransformer extends BaseDataTransformer { transformToEntity(dto: SalesOrderDto | SalesOrderEntity): SalesOrderEntity { - const base = mapSalesDocumentFromDto(dto as SalesOrderDto); + const orderDto = dto as SalesOrderDto; + const base = mapSalesDocumentFromDto(orderDto); + const salesRequest = toLookup(orderDto.salesRequest, orderDto.salesRequestId); return { ...base, - salesRequestId: (dto as SalesOrderDto).salesRequestId ?? null, - salesRequest: toRelationStub((dto as SalesOrderDto).salesRequestId), + salesRequestId: relationId(orderDto.salesRequest) ?? orderDto.salesRequestId ?? null, + salesRequest, }; } diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json index a2ea2e1..e4e1816 100644 --- a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json +++ b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/en/sales-orders.json @@ -26,6 +26,7 @@ "csv_file": "CSV file", "import_success": "CSV imported.", "status_updated": "Status updated.", + "create_sales_invoice": "Create Sales Invoice", "action_process": "Process", "action_complete": "Complete", "action_cancel": "Cancel", diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json index 2c86b60..b1555e4 100644 --- a/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json +++ b/apps/web/src/apps/main/modules/sales/orders/presentation/languages/id/sales-orders.json @@ -26,6 +26,7 @@ "csv_file": "File CSV", "import_success": "CSV berhasil diimpor.", "status_updated": "Status diperbarui.", + "create_sales_invoice": "Buat Faktur Penjualan", "action_process": "Proses", "action_complete": "Selesaikan", "action_cancel": "Batalkan", diff --git a/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx index f9e8cf1..e7b628d 100644 --- a/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx +++ b/apps/web/src/apps/main/modules/sales/orders/presentation/pages/sales-order.page.detail.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from 'react-router-dom'; import { Stack } from '@repo/ui/components'; import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { salesOrdersModuleConfig } from '../../domain/constants'; @@ -7,10 +8,12 @@ import { DetailProducts } from '../../../shared/detail-products'; import { DetailImages } from '../../../shared/detail-images'; import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions'; import { salesRequestsModuleConfig } from '../../../requests/domain/constants'; +import { salesInvoicesModuleConfig } from '../../../invoices/domain/constants'; import type { SalesOrderEntity } from '../../domain/entities'; export default function SalesOrderPageDetail() { const { t } = useEnterpriseModuleTranslationContext(); + const navigate = useNavigate(); const actions = useSalesDocumentActions('order'); return ( @@ -24,9 +27,13 @@ export default function SalesOrderPageDetail() { { label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` }, ], }} - customPageActions={(data, pageActions) => - actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? []) - } + customPageActions={(data, pageActions) => { + const createInvoice = actions.createInvoiceAction(data as SalesOrderEntity, () => { + navigate(`${salesInvoicesModuleConfig.webUrl}/create?salesOrderId=${data.id}`); + }); + const withStatus = actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? []); + return createInvoice ? [createInvoice, ...withStatus] : withStatus; + }} > `${salesRequestsModuleConfig.webUrl}/detail/${id}`} /> diff --git a/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.test.ts b/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.test.ts new file mode 100644 index 0000000..79a69e2 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AxiosInstance } from '@repo/core-api/http-client'; +import { SalesPaymentsRemoteDataServices } from './sales-payment.remote.service'; +import { SalesPaymentsRemoteDataTransformer } from '../domain/transformers/sales-payment.remote.transformer'; + +function createMockHttpClient(): AxiosInstance { + return { + request: vi.fn().mockResolvedValue({ data: {}, status: 200 }), + defaults: {} as AxiosInstance['defaults'], + interceptors: { + request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, + response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, + }, + getUri: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + head: vi.fn(), + options: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + postForm: vi.fn(), + putForm: vi.fn(), + patchForm: vi.fn(), + } as unknown as AxiosInstance; +} + +describe('SalesPaymentsRemoteDataServices', () => { + let httpClient: AxiosInstance; + let service: SalesPaymentsRemoteDataServices; + + beforeEach(() => { + httpClient = createMockHttpClient(); + service = new SalesPaymentsRemoteDataServices(httpClient, { + apiUrl: '/sales-payments', + moduleKey: 'SALES.PAYMENT', + transformer: new SalesPaymentsRemoteDataTransformer(), + }); + }); + + it('uses PATCH when editing', async () => { + await service.edit('so-1', { address: 'Jl Sudirman 1' } as any); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/sales-payments/so-1', method: 'PATCH' }), + ); + }); + + it('bulk-changes status via POST /bulk-status', async () => { + await service.bulkChangeStatus(['so-1'], 'processed'); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/sales-payments/bulk-status', + method: 'POST', + data: { ids: ['so-1'], status: 'processed' }, + }), + ); + }); +}); diff --git a/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.ts b/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.ts new file mode 100644 index 0000000..7ea4398 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/data/sales-payment.remote.service.ts @@ -0,0 +1,13 @@ +import type { AxiosInstance } from '@repo/core-api/http-client'; +import type { DataServicesConfig } from '@repo/core-api/data-services'; +import { SalesDocumentRemoteDataServices } from '../../shared/sales-document.remote.service'; +import type { SalesPaymentEntity } from '../domain/entities'; + +export class SalesPaymentsRemoteDataServices extends SalesDocumentRemoteDataServices { + constructor(httpClient: AxiosInstance, config: DataServicesConfig) { + super(httpClient, { + ...config, + apiUrl: config.apiUrl ?? '/sales-payments', + }); + } +} diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/constants/index.ts b/apps/web/src/apps/main/modules/sales/payments/domain/constants/index.ts new file mode 100644 index 0000000..12a0640 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/domain/constants/index.ts @@ -0,0 +1 @@ +export * from './sales-payment.constants'; diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/constants/sales-payment.constants.ts b/apps/web/src/apps/main/modules/sales/payments/domain/constants/sales-payment.constants.ts new file mode 100644 index 0000000..6856178 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/domain/constants/sales-payment.constants.ts @@ -0,0 +1,11 @@ +import { ModuleConfigEntity } from '@repo/ui/foundations'; +import type { SalesPaymentEntity } from '../entities'; + +export const salesPaymentsModuleConfig: ModuleConfigEntity = { + moduleKey: 'SALES.PAYMENT', + translationNamespace: 'SALES_PAYMENTS', + apiUrl: '/sales-payments', + webUrl: '/app/sales/payments', + moduleCategory: 'FULL_PAGE', + moduleType: 'TRANSACTION', +} as const; diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/entities/index.ts b/apps/web/src/apps/main/modules/sales/payments/domain/entities/index.ts new file mode 100644 index 0000000..76e1d6a --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './sales-payment.entity'; diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/entities/sales-payment.entity.ts b/apps/web/src/apps/main/modules/sales/payments/domain/entities/sales-payment.entity.ts new file mode 100644 index 0000000..b46ffed --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/domain/entities/sales-payment.entity.ts @@ -0,0 +1,41 @@ +import type { BaseEntity } from '@repo/core-api/data-services'; +import type { LookupStub, SalesImageEntity } from '../../../shared/sales-document.entity'; + +export interface SalesPaymentAllocationEntity { + id?: string; + invoiceId?: string; + invoice?: LookupStub | null; + amount: string; +} + +export interface SalesPaymentEntity extends BaseEntity { + code?: string | null; + date: string; + notes?: string | null; + invoices?: SalesPaymentAllocationEntity[]; + images?: SalesImageEntity[]; + status?: string; + createdAt?: number; + updatedAt?: number; + createdBy?: string | { id?: string; username?: string }; + updatedBy?: string | { id?: string; username?: string }; +} + +export interface SalesPaymentDto { + id?: string; + code?: string | null; + date: string | number; + notes?: string | null; + invoices?: Array<{ + id?: string; + invoiceId?: string; + invoice?: LookupStub | null; + amount: string; + }>; + images?: Array<{ id?: string; url: string; description?: string | null }>; + status?: string; + createdAt?: number; + updatedAt?: number; + createdBy?: string | { id?: string; username?: string }; + updatedBy?: string | { id?: string; username?: string }; +} diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/factories/index.ts b/apps/web/src/apps/main/modules/sales/payments/domain/factories/index.ts new file mode 100644 index 0000000..4c41893 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/domain/factories/index.ts @@ -0,0 +1,12 @@ +import { apiClient } from '../../../../../../../core/lib/api-client'; +import { SalesPaymentsRemoteDataServices } from '../../data/sales-payment.remote.service'; +import { salesPaymentsModuleConfig } from '../constants/sales-payment.constants'; +import { SalesPaymentsRemoteDataTransformer } from '../transformers/sales-payment.remote.transformer'; + +export const salesPaymentsDataTransformer = new SalesPaymentsRemoteDataTransformer(); + +export const salesPaymentsDataService = new SalesPaymentsRemoteDataServices(apiClient, { + apiUrl: salesPaymentsModuleConfig.apiUrl, + moduleKey: salesPaymentsModuleConfig.moduleKey, + transformer: salesPaymentsDataTransformer, +}); diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.test.ts b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.test.ts new file mode 100644 index 0000000..f78facb --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { SalesPaymentsRemoteDataTransformer } from './sales-payment.remote.transformer'; + +const transformer = new SalesPaymentsRemoteDataTransformer(); + +describe('SalesPaymentsRemoteDataTransformer', () => { + it('maps list rows without allocations and detail rows with invoices and images', () => { + const listEntity = transformer.transformToEntity({ + id: 'pay-1', + code: 'SP-1', + date: Date.UTC(2026, 7, 26), + notes: null, + status: 'draft', + } as any); + expect(listEntity.invoices).toEqual([]); + expect(listEntity.images).toEqual([]); + + const detailEntity = transformer.transformToEntity({ + id: 'pay-1', + date: Date.UTC(2026, 7, 26), + invoices: [{ id: 'a-1', invoiceId: 'inv-1', invoice: { id: 'inv-1', code: 'INV-1' }, amount: '10000.0000' }], + images: [{ id: 'img-1', url: 'https://cdn.example/a.png', description: null }], + } as any); + expect(detailEntity.invoices).toEqual([ + { id: 'a-1', invoiceId: 'inv-1', invoice: { id: 'inv-1', code: 'INV-1' }, amount: '10000.0000' }, + ]); + expect(detailEntity.images?.[0].url).toBe('https://cdn.example/a.png'); + }); + + it('writes invoiceId and amount on create', () => { + const payload = transformer.transformCreatePayload({ + date: '2026-08-26', + invoices: [{ invoice: { id: 'inv-1' }, amount: '10000.0000' }], + images: [{ url: 'https://cdn.example/a.png', description: '' }], + }); + expect(payload.invoices).toEqual([{ invoiceId: 'inv-1', amount: '10000.0000' }]); + expect(payload.images).toEqual([{ url: 'https://cdn.example/a.png' }]); + }); +}); diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts new file mode 100644 index 0000000..465987d --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts @@ -0,0 +1,70 @@ +import { BaseDataTransformer } from '@repo/core-api/data-services'; +import { formatDateValue, parseDateValue } from '@repo/ui/form'; +import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; +import { mapSalesImagesFromDto, relationId, toLookup } from '../../../shared/sales-document.mapper'; +import type { SalesPaymentDto, SalesPaymentEntity } from '../entities'; + +export class SalesPaymentsRemoteDataTransformer extends BaseDataTransformer { + transformToEntity(dto: SalesPaymentDto | SalesPaymentEntity): SalesPaymentEntity { + const paymentDto = dto as SalesPaymentDto; + return { + id: paymentDto.id, + code: paymentDto.code ?? null, + date: formatDateValue(parseDateValue(paymentDto.date) ?? undefined), + notes: paymentDto.notes ?? null, + invoices: (paymentDto.invoices ?? []).map((row) => { + const invoice = toLookup(row.invoice, row.invoiceId); + return { + id: row.id, + invoiceId: relationId(row.invoice) ?? row.invoiceId, + invoice, + amount: row.amount, + }; + }), + images: mapSalesImagesFromDto({ images: paymentDto.images } as any), + status: paymentDto.status, + createdAt: paymentDto.createdAt, + updatedAt: paymentDto.updatedAt, + createdBy: paymentDto.createdBy, + updatedBy: paymentDto.updatedBy, + }; + } + + transformToDTO(entity: SalesPaymentEntity): SalesPaymentEntity { + return { ...entity }; + } + + transformCreatePayload(entity: Partial): Partial { + return omitEmptyFields(this.toWritePayload(entity)) as Partial; + } + + transformEditPayload(entity: Partial): Partial { + return this.toWritePayload(entity) as Partial; + } + + transformPayloadFilter(filter: Record): Record { + return omitEmptyFields({ ...filter }); + } + + private toWritePayload(entity: Partial): Record { + const invoices = (entity.invoices ?? []) + .map((row) => { + const invoiceId = relationId(row.invoice) ?? row.invoiceId; + if (!invoiceId) return null; + return omitEmptyFields({ invoiceId, amount: row.amount }); + }) + .filter(Boolean); + + const images = (entity.images ?? []) + .filter((image) => Boolean(image?.url)) + .map((image) => omitEmptyFields({ url: image.url, description: image.description })); + + return { + code: entity.code, + date: formatDateValue(entity.date), + notes: emptyToNull(entity.notes), + invoices, + images, + }; + } +} diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.test.ts b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.test.ts new file mode 100644 index 0000000..a39effa --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { createSalesPaymentSchema } from './sales-payment.validator'; + +const t = (key: string) => key; +const invoice = { id: 'inv-1', code: 'INV-1' }; + +describe('createSalesPaymentSchema', () => { + const schema = createSalesPaymentSchema(t); + + it('accepts a payment with at least one allocation', () => { + expect( + schema.safeParse({ + date: '2026-08-26', + invoices: [{ invoice, amount: '10000.0000' }], + }).success, + ).toBe(true); + }); + + it('rejects empty allocations', () => { + expect(schema.safeParse({ date: '2026-08-26', invoices: [] }).success).toBe(false); + }); + + it('rejects a non-decimal amount', () => { + expect( + schema.safeParse({ + date: '2026-08-26', + invoices: [{ invoice, amount: 'abc' }], + }).success, + ).toBe(false); + }); +}); diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts new file mode 100644 index 0000000..6f7a493 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import { compose, maxLength, required } from '@repo/ui/validators'; +import { decimalStringSchema } from '../../../../../../../core/domain/decimal-string.schema'; +import { salesImageSchema } from '../../../shared/sales-document.validator'; + +const NOTES_MAX = 1024; +const CODE_MAX = 32; +const CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; + +function emptyToUndefined(value: unknown) { + if (value === '' || value === null || value === undefined) { + return undefined; + } + return value; +} + +const relationSchema = z.object({ + id: z.string(), + code: z.string().optional(), + name: z.string().optional(), +}); + +export function createSalesPaymentSchema(t: (key: string) => string) { + return z.object({ + code: z.preprocess( + emptyToUndefined, + compose(z.string(), maxLength(CODE_MAX, t('common:fields.code'))) + .regex(CODE_PATTERN, { + message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.code') } }), + }) + .optional(), + ), + date: compose(z.string(), required(t('common:fields.date'))), + notes: z.preprocess( + emptyToUndefined, + compose(z.string(), maxLength(NOTES_MAX, t('common:fields.notes'))).optional(), + ), + invoices: z + .array( + z.object({ + id: z.string().optional(), + invoice: relationSchema, + amount: decimalStringSchema(t, 'common:fields.amount'), + }), + ) + .min(1), + images: z.array(salesImageSchema(t)).optional(), + }); +} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx new file mode 100644 index 0000000..95fb3c8 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx @@ -0,0 +1,46 @@ +import { Box, Paper, Table, Text } from '@repo/ui/components'; +import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import type { SalesPaymentEntity } from '../../../domain/entities'; +import { relationLabel } from '../../../../../field/shared/relation-label'; + +export function DetailAllocations() { + const { detailData } = useDetailPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const rows = detailData?.invoices ?? []; + + return ( + + + {t('section_allocations')} + + + + + + {t('common:fields.invoice')} + {t('common:fields.amount')} + + + + {rows.length === 0 ? ( + + + + {t('empty_allocations')} + + + + ) : ( + rows.map((row, index) => ( + + {relationLabel(row.invoice) || row.invoiceId} + {row.amount} + + )) + )} + +
+
+
+ ); +} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-payment-general.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-payment-general.tsx new file mode 100644 index 0000000..6055a22 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-payment-general.tsx @@ -0,0 +1,43 @@ +import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components'; +import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import type { SalesPaymentEntity } from '../../../domain/entities'; + +export function DetailPaymentGeneral() { + const { detailData } = useDetailPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const data = detailData; + + return ( + + + {t('section_general')} + + + + + } + /> + } + /> + + } + /> + } + /> + + + + ); +} 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 new file mode 100644 index 0000000..7066825 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx @@ -0,0 +1,85 @@ +import { ActionIcon, Box, Button, FieldAsyncSelect, FieldTextInput, Group, Paper, Table, Text } from '@repo/ui/components'; +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 { relationLabel } from '../../../../../field/shared/relation-label'; +import type { LookupEntity } from '../../../../../field/shared/lookup.entity'; + +export function FormAllocations() { + const { formControl } = useFormPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const { fields, append, remove } = useFieldArray({ + control: formControl.control, + name: 'invoices', + }); + const invoices = useWatch({ control: formControl.control, name: 'invoices' }) ?? []; + + return ( + + + {t('section_allocations')} + + + + + + {t('common:fields.invoice')} + {t('common:fields.amount')} + + + + + {fields.map((field, index) => { + const line = invoices[index]; + return ( + + + + control={formControl.control} + name={`invoices.${index}.invoice`} + valueKey="id" + labelKey="code" + searchable + loadOptions={loadSalesInvoiceOptions} + defaultOptions={line?.invoice ? [line.invoice] : []} + renderLabel={relationLabel} + /> + + + + + + remove(index)} + aria-label={t('remove_allocation')} + > + + + + + ); + })} + +
+
+ + + {fields.length === 0 ? ( + + {t('empty_allocations')} + + ) : null} + +
+ ); +} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx new file mode 100644 index 0000000..734e746 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-payment-general.tsx @@ -0,0 +1,32 @@ +import { Box, FieldDatePicker, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components'; +import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; + +export function FormPaymentGeneral() { + const { formControl } = useFormPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + + return ( + + + {t('section_general')} + + + + + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/factory/index.tsx new file mode 100644 index 0000000..1bd5743 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/factory/index.tsx @@ -0,0 +1,40 @@ +import { lazy } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { EnterpriseModuleProvider } from '@repo/ui/foundations'; +import { registerModuleNamespace } from '@repo/core-i18n'; +import { salesPaymentsModuleConfig } from '../../domain/constants'; +import { salesPaymentsDataService } from '../../domain/factories'; +import { SalesPaymentEntity } from '../../domain/entities'; +import { salesPaymentsStore } from '../store'; + +import salesPaymentsId from '../languages/id/sales-payments.json'; +import salesPaymentsEn from '../languages/en/sales-payments.json'; + +const IndexPage = lazy(() => import('../pages/sales-payment.page.index')); +const FormPage = lazy(() => import('../pages/sales-payment.page.form')); +const DetailPage = lazy(() => import('../pages/sales-payment.page.detail')); + +registerModuleNamespace(salesPaymentsModuleConfig.translationNamespace, { + id: salesPaymentsId, + en: salesPaymentsEn, +}); + +export default function SalesPaymentsModule() { + return ( + + config={salesPaymentsModuleConfig} + dataServices={salesPaymentsDataService} + store={salesPaymentsStore} + > + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/languages/en/sales-payments.json b/apps/web/src/apps/main/modules/sales/payments/presentation/languages/en/sales-payments.json new file mode 100644 index 0000000..c9dcaaa --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/languages/en/sales-payments.json @@ -0,0 +1,34 @@ +{ + "title": "Sales Payments", + "detail_page_title": "Sales Payment Detail", + "create_page_title": "New Sales Payment", + "edit_page_title": "Edit Sales Payment", + "duplicate_page_title": "Duplicate Sales Payment", + "description": "Record <1>sales payments as allocations against one or more invoices.", + "detail_page_description": "Review payment header, invoice allocations, and images.", + "create_page_description": "Create a sales payment with invoice allocations and optional images.", + "edit_page_description": "Update payment date, allocations, notes, and images.", + "duplicate_page_description": "Copy an existing sales payment to create a new one.", + "section_general": "General", + "section_allocations": "Allocations", + "section_images": "Images", + "section_notes": "Notes", + "add_allocation": "Add allocation", + "remove_allocation": "Remove allocation", + "empty_allocations": "No invoice allocations.", + "add_image": "Add image", + "remove_image": "Remove image", + "empty_images": "No images.", + "change_status": "Change status", + "import_csv": "Import CSV", + "csv_file": "CSV file", + "import_success": "CSV imported.", + "status_updated": "Status updated.", + "action_submit": "Submit", + "action_approve": "Approve", + "action_reject": "Reject", + "status_draft": "Draft", + "status_pending": "Pending", + "status_approved": "Approved", + "status_rejected": "Rejected" +} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/languages/id/sales-payments.json b/apps/web/src/apps/main/modules/sales/payments/presentation/languages/id/sales-payments.json new file mode 100644 index 0000000..ed1ec3d --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/languages/id/sales-payments.json @@ -0,0 +1,34 @@ +{ + "title": "Pembayaran Penjualan", + "detail_page_title": "Detail Pembayaran Penjualan", + "create_page_title": "Pembayaran Penjualan Baru", + "edit_page_title": "Ubah Pembayaran Penjualan", + "duplicate_page_title": "Duplikat Pembayaran Penjualan", + "description": "Catat <1>pembayaran penjualan sebagai alokasi ke satu atau lebih faktur.", + "detail_page_description": "Tinjau header pembayaran, alokasi faktur, dan gambar.", + "create_page_description": "Buat pembayaran penjualan dengan alokasi faktur dan gambar opsional.", + "edit_page_description": "Perbarui tanggal, alokasi, catatan, dan gambar pembayaran.", + "duplicate_page_description": "Salin pembayaran penjualan yang ada untuk membuat data baru.", + "section_general": "Umum", + "section_allocations": "Alokasi", + "section_images": "Gambar", + "section_notes": "Catatan", + "add_allocation": "Tambah alokasi", + "remove_allocation": "Hapus alokasi", + "empty_allocations": "Tidak ada alokasi faktur.", + "add_image": "Tambah gambar", + "remove_image": "Hapus gambar", + "empty_images": "Tidak ada gambar.", + "change_status": "Ubah status", + "import_csv": "Impor CSV", + "csv_file": "File CSV", + "import_success": "CSV berhasil diimpor.", + "status_updated": "Status diperbarui.", + "action_submit": "Kirim", + "action_approve": "Setujui", + "action_reject": "Tolak", + "status_draft": "Draft", + "status_pending": "Menunggu", + "status_approved": "Disetujui", + "status_rejected": "Ditolak" +} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.detail.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.detail.tsx new file mode 100644 index 0000000..d787888 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.detail.tsx @@ -0,0 +1,37 @@ +import { Stack } from '@repo/ui/components'; +import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import { salesPaymentsModuleConfig } from '../../domain/constants'; +import { DetailPaymentGeneral } from '../components/detail-component/detail-payment-general'; +import { DetailAllocations } from '../components/detail-component/detail-allocations'; +import { DetailImages } from '../../../shared/detail-images'; +import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions'; +import type { SalesPaymentEntity } from '../../domain/entities'; + +export default function SalesPaymentPageDetail() { + const { t } = useEnterpriseModuleTranslationContext(); + const actions = useSalesDocumentActions('payment'); + + return ( + + actions.detailStatusActions(data as SalesPaymentEntity, pageActions ?? []) + } + > + + + + + + {actions.modals} + + ); +} 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 new file mode 100644 index 0000000..34f867d --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations'; +import { Stack } from '@repo/ui/components'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { salesPaymentsModuleConfig } from '../../domain/constants'; +import { createSalesPaymentSchema } from '../../domain/validators/sales-payment.validator'; +import { FormPaymentGeneral } from '../components/form-component/form-payment-general'; +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 type { SalesInvoiceEntity } from '../../../invoices/domain/entities'; + +export default function SalesPaymentPageForm({ formPageType }: { formPageType: FormPageType }) { + const { t } = useEnterpriseModuleTranslationContext(); + const [searchParams] = useSearchParams(); + const prefilled = useRef(false); + + const title = useMemo(() => { + if (formPageType === 'CREATE') { + return { title: t('create_page_title'), description: t('create_page_description') }; + } + if (formPageType === 'EDIT') { + return { title: t('edit_page_title'), description: t('edit_page_description') }; + } + if (formPageType === 'DUPLICATE') { + return { title: t('duplicate_page_title'), description: t('duplicate_page_description') }; + } + return { title: '', description: '' }; + }, [formPageType, t]); + + const validator = useMemo(() => createSalesPaymentSchema(t), [t]); + const formControl = useForm({ + resolver: zodResolver(validator), + defaultValues: { + invoices: [{ amount: '' }], + images: [], + }, + }); + + useEffect(() => { + const invoiceId = searchParams.get('invoiceId'); + if (formPageType !== 'CREATE' || !invoiceId || prefilled.current) return; + prefilled.current = true; + void salesInvoicesDataService.getOne(invoiceId).then((result) => { + const entity = (result.data as { data?: SalesInvoiceEntity })?.data; + if (!entity) return; + formControl.reset({ + invoices: [ + { + invoice: { id: entity.id as string, code: entity.code ?? undefined }, + amount: entity.balance ?? '', + }, + ], + images: [], + } as any); + }); + }, [formControl, formPageType, searchParams]); + + return ( + + + + + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.index.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.index.tsx new file mode 100644 index 0000000..0201599 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.index.tsx @@ -0,0 +1,59 @@ +import { useMemo } from 'react'; +import { + EnterpriseIndexPageProvider, + useEnterpriseModuleTranslationContext, + EnterpriseDataTable, +} from '@repo/ui/foundations'; +import { ColDef, Text } from '@repo/ui/components'; +import { Trans } from '@repo/core-i18n'; +import { CreditCard } from 'lucide-react'; +import { SalesFilterFormContent } from '../../../shared/filter-content'; +import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions'; +import type { SalesPaymentEntity } from '../../domain/entities'; + +export default function SalesPaymentPageIndex() { + const { t } = useEnterpriseModuleTranslationContext(); + const actions = useSalesDocumentActions('payment'); + + const columnDefs: ColDef[] = useMemo( + () => [ + { field: 'code', headerName: t('common:fields.code'), minWidth: 160 }, + { field: 'date', headerName: t('common:fields.date'), minWidth: 140 }, + ], + [t], + ); + + const filterConfig = useMemo( + () => ({ + renderBody: (form: any) => (form ? : null), + }), + [t], + ); + + return ( + }} /> + ), + icon: CreditCard, + breadcrumbs: [ + { label: t('nav:sales'), type: 'text' }, + { label: t('nav:sales-payments'), type: 'text' }, + ], + }} + customPageActions={(pageActions) => + actions.importPageAction ? [actions.importPageAction, ...(pageActions ?? [])] : pageActions + } + > + actions.namedRowActions(data, defaultActions)} + customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)} + /> + {actions.modals} + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/store/index.ts b/apps/web/src/apps/main/modules/sales/payments/presentation/store/index.ts new file mode 100644 index 0000000..6135cd7 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/store/index.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; +import { EnterpriseModuleState } from '@repo/ui/foundations'; +import { SalesPaymentEntity } from '../../domain/entities'; + +export interface SalesPaymentsStoreState extends EnterpriseModuleState {} + +export const salesPaymentsStore = create((set) => ({ + metaData: { limit: 15 }, + setMetaData: (data) => set({ metaData: data }), + filterData: {}, + setFilterData: (data) => set({ filterData: data }), + selectedRows: [], + setSelectedRows: (rows) => set({ selectedRows: rows }), + privileges: [], + setPrivileges: (privileges) => set({ privileges }), + tableConfig: null, + setTableConfig: (config) => set({ tableConfig: config }), +})); diff --git a/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.test.ts b/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.test.ts index 80263e6..75a9191 100644 --- a/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.test.ts +++ b/apps/web/src/apps/main/modules/sales/requests/domain/transformers/sales-request.remote.transformer.test.ts @@ -19,6 +19,35 @@ describe('SalesRequestsRemoteDataTransformer', () => { expect(entity.salesPerson).toEqual({ id: 'emp-1' }); }); + it('keeps nested branch and division labels from the API response', () => { + const entity = transformer.transformToEntity({ + id: 'fccaf567-17aa-4534-8ab3-2cee0b1905b9', + code: 'SR-20260801-0001', + date: 1785517200000, + salesPerson: { id: 'ef0ee9ee-7cd0-4240-86b4-7fd4abf5964a', code: 'CYC_E_108858', name: 'Ada Lovelace' }, + branch: { id: '28179106-7f23-424a-ae70-05cfda01261a', code: 'B_411587', name: 'Jakarta Pusat' }, + division: { id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1', code: 'D_411587', name: 'Sales Division' }, + customer: { id: '74e9f045-e176-429c-8e24-8dd77a4e89f2', code: 'C_411587', name: 'Acme Corp' }, + address: 'depok', + products: [ + { + id: 'd0211f93-66d9-4aab-b1c6-eafb187875e6', + product: { id: '023db166-be0a-46ed-9e70-cebeea672f08', code: 'FUEL_103044', name: 'Fuel 95' }, + quantity: '1.0000', + price: '100000.0000', + }, + ], + }); + + expect(entity.branch).toEqual({ + id: '28179106-7f23-424a-ae70-05cfda01261a', + code: 'B_411587', + name: 'Jakarta Pusat', + }); + expect(entity.division?.name).toBe('Sales Division'); + expect(entity.products?.[0].product?.name).toBe('Fuel 95'); + }); + it('writes nested products and omits status', () => { const payload = transformer.transformCreatePayload({ date: '2026-08-26', diff --git a/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx b/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx index cf76777..d6f57ab 100644 --- a/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx +++ b/apps/web/src/apps/main/modules/sales/shared/detail-general.tsx @@ -5,15 +5,33 @@ import { relationLabel } from '../../field/shared/relation-label'; export function DetailGeneral({ salesRequestHref, + salesOrderHref, + packingSlipHref, + showBalance, }: { salesRequestHref?: (id: string) => string; + salesOrderHref?: (id: string) => string; + packingSlipHref?: (id: string) => string; + showBalance?: boolean; } = {}) { const { detailData } = useDetailPageContext< - SalesDocumentEntity & { salesRequestId?: string | null; salesRequest?: { id?: string; code?: string } | null } + SalesDocumentEntity & { + salesRequestId?: string | null; + salesRequest?: { id?: string; code?: string } | null; + salesOrderId?: string | null; + salesOrder?: { id?: string; code?: string } | null; + packingSlipId?: string | null; + packingSlip?: { id?: string; code?: string } | null; + salesOrderCode?: string | null; + packingSlipCode?: string | null; + balance?: string | null; + } >(); const { t } = useEnterpriseModuleTranslationContext(); const data = detailData; const salesRequestId = data?.salesRequestId ?? data?.salesRequest?.id; + const salesOrderId = data?.salesOrderId ?? data?.salesOrder?.id; + const packingSlipId = data?.packingSlipId ?? data?.packingSlip?.id; return ( @@ -35,6 +53,33 @@ export function DetailGeneral({ + {salesOrderHref && ( + + salesOrderId ? ( + {data?.salesOrder?.code || data?.salesOrderCode || salesOrderId} + ) : ( + '-' + ) + } + /> + )} + {packingSlipHref && ( + + packingSlipId ? ( + {data?.packingSlip?.code || data?.packingSlipCode || packingSlipId} + ) : ( + '-' + ) + } + /> + )} + {showBalance ? : null} {salesRequestHref && ( + {documentType !== 'payment' ? ( + <> control={form.control} name="customer" @@ -75,6 +81,34 @@ export function SalesFilterFormContent({ loadOptions={loadDivisionOptions} renderLabel={relationLabel} /> + + ) : null} + {documentType === 'invoice' ? ( + <> + + control={form.control} + name="salesOrder" + label={t('common:fields.salesOrder')} + valueKey="id" + labelKey="code" + searchable + clearable + loadOptions={loadSalesOrderOptions} + renderLabel={relationLabel} + /> + + control={form.control} + name="packingSlip" + label={t('common:fields.packingSlip')} + valueKey="id" + labelKey="code" + searchable + clearable + loadOptions={loadPackingSlipOptions} + renderLabel={relationLabel} + /> + + ) : null} ); } diff --git a/apps/web/src/apps/main/modules/sales/shared/load-sales-order-options.ts b/apps/web/src/apps/main/modules/sales/shared/load-sales-order-options.ts new file mode 100644 index 0000000..6107ccc --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/load-sales-order-options.ts @@ -0,0 +1,7 @@ +import { createOptionLoader } from '../../field/shared/create-option-loader'; +import { salesOrdersDataService } from '../orders/domain/factories'; +import type { SalesOrderEntity } from '../orders/domain/entities'; + +export const loadSalesOrderOptions = createOptionLoader((config) => + salesOrdersDataService.getMany(config), +); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.entity.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.entity.ts index 917f6c4..0f30214 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.entity.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.entity.ts @@ -40,8 +40,16 @@ export interface SalesDocumentEntity extends BaseEntity { status?: string; createdAt?: number; updatedAt?: number; - createdBy?: string; - updatedBy?: string; + createdBy?: string | { id?: string; username?: string }; + updatedBy?: string | { id?: string; username?: string }; +} + +export interface SalesLineDto { + id?: string; + productId?: string; + product?: LookupStub | null; + quantity: string; + price?: string | null; } export interface SalesDocumentDto { @@ -49,19 +57,18 @@ export interface SalesDocumentDto { code?: string | null; date: string | number; salesPersonId?: string; + salesPerson?: LookupStub | null; branchId?: string; + branch?: LookupStub | null; divisionId?: string; + division?: LookupStub | null; customerId?: string; + customer?: LookupStub | null; address: string; latitude?: number | null; longitude?: number | null; notes?: string | null; - products?: Array<{ - id?: string; - productId: string; - quantity: string; - price?: string | null; - }>; + products?: SalesLineDto[]; images?: Array<{ id?: string; url: string; @@ -70,6 +77,6 @@ export interface SalesDocumentDto { status?: string; createdAt?: number; updatedAt?: number; - createdBy?: string; - updatedBy?: string; + createdBy?: string | { id?: string; username?: string }; + updatedBy?: string | { id?: string; username?: string }; } diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts index b56b154..42c13a6 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts @@ -1,5 +1,58 @@ import { describe, expect, it } from 'vitest'; -import { mapSalesDocumentFromDto, toSalesFilterPayload, toSalesWritePayload } from './sales-document.mapper'; +import { + mapSalesDocumentFromDto, + salesRequestToFormValues, + toSalesFilterPayload, + toSalesWritePayload, +} from './sales-document.mapper'; + +const nestedResponse = { + id: 'fccaf567-17aa-4534-8ab3-2cee0b1905b9', + code: 'SR-20260801-0001', + date: 1785517200000, + salesPerson: { + id: 'ef0ee9ee-7cd0-4240-86b4-7fd4abf5964a', + code: 'CYC_E_108858', + name: 'Ada Lovelace', + }, + branch: { + id: '28179106-7f23-424a-ae70-05cfda01261a', + code: 'B_411587', + name: 'Jakarta Pusat', + }, + division: { + id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1', + code: 'D_411587', + name: 'Sales Division', + }, + customer: { + id: '74e9f045-e176-429c-8e24-8dd77a4e89f2', + code: 'C_411587', + name: 'Acme Corp', + }, + address: 'depok', + latitude: -6.474014, + longitude: 106.814575, + notes: 'notes', + status: 'draft', + createdAt: 1787814580147, + updatedAt: 1787814580147, + createdBy: { id: '1b946785-f6be-4f0a-8527-4bf97b737df8', username: 'alice' }, + updatedBy: { id: '1b946785-f6be-4f0a-8527-4bf97b737df8', username: 'alice' }, + products: [ + { + id: 'd0211f93-66d9-4aab-b1c6-eafb187875e6', + product: { + id: '023db166-be0a-46ed-9e70-cebeea672f08', + code: 'FUEL_103044', + name: 'Fuel 95', + }, + quantity: '1.0000', + price: '100000.0000', + }, + ], + images: [], +}; describe('sales document mapper', () => { it('maps unix date and relation stubs onto the entity', () => { @@ -26,6 +79,58 @@ describe('sales document mapper', () => { expect(entity.products?.[0]).toMatchObject({ productId: 'prd-1', product: { id: 'prd-1' }, quantity: '2.0000' }); }); + it('keeps nested lookup code and name from the API response', () => { + const entity = mapSalesDocumentFromDto(nestedResponse); + + expect(entity.branch).toEqual({ + id: '28179106-7f23-424a-ae70-05cfda01261a', + code: 'B_411587', + name: 'Jakarta Pusat', + }); + expect(entity.branchId).toBe('28179106-7f23-424a-ae70-05cfda01261a'); + expect(entity.division).toEqual({ + id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1', + code: 'D_411587', + name: 'Sales Division', + }); + expect(entity.salesPerson).toEqual({ + id: 'ef0ee9ee-7cd0-4240-86b4-7fd4abf5964a', + code: 'CYC_E_108858', + name: 'Ada Lovelace', + }); + expect(entity.customer).toEqual({ + id: '74e9f045-e176-429c-8e24-8dd77a4e89f2', + code: 'C_411587', + name: 'Acme Corp', + }); + expect(entity.products?.[0]).toMatchObject({ + productId: '023db166-be0a-46ed-9e70-cebeea672f08', + product: { id: '023db166-be0a-46ed-9e70-cebeea672f08', code: 'FUEL_103044', name: 'Fuel 95' }, + quantity: '1.0000', + price: '100000.0000', + }); + }); + + it('copies nested lookups onto form values for edit', () => { + const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse)); + + expect(formValues.branch).toEqual({ + id: '28179106-7f23-424a-ae70-05cfda01261a', + code: 'B_411587', + name: 'Jakarta Pusat', + }); + expect(formValues.division).toEqual({ + id: 'a671b98b-9e6c-447b-bfe7-5beaa80319a1', + code: 'D_411587', + name: 'Sales Division', + }); + expect(formValues.products[0].product).toEqual({ + id: '023db166-be0a-46ed-9e70-cebeea672f08', + code: 'FUEL_103044', + name: 'Fuel 95', + }); + }); + it('writes relation ids and nested lines on create', () => { const payload = toSalesWritePayload( { @@ -72,3 +177,35 @@ describe('sales document mapper', () => { ).toEqual({ customerId: 'cus-1', status: 'draft' }); }); }); + + it('includes parent ids on create and omits images when requested', () => { + const payload = toSalesWritePayload( + { + date: '2026-08-26', + salesPerson: { id: 'emp-1' } as any, + branch: { id: 'br-1' } as any, + division: { id: 'div-1' } as any, + customer: { id: 'cus-1' } as any, + address: 'Jl Sudirman 1', + products: [{ product: { id: 'prd-1' } as any, quantity: '1' }], + images: [{ url: 'https://cdn.example/a.png' }], + salesOrder: { id: 'so-1' } as any, + packingSlip: { id: 'ps-1' } as any, + } as any, + { includeSalesOrderId: true, includePackingSlipId: true, omitImages: true }, + ); + + expect(payload.salesOrderId).toBe('so-1'); + expect(payload.packingSlipId).toBe('ps-1'); + expect(payload).not.toHaveProperty('images'); + }); + + it('flattens sales order and packing slip in filters', () => { + expect( + toSalesFilterPayload({ + salesOrder: { id: 'so-1' }, + packingSlip: { id: 'ps-1' }, + status: 'draft', + }), + ).toEqual({ salesOrderId: 'so-1', packingSlipId: 'ps-1', status: 'draft' }); + }); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts index 9658158..e7fdb56 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts @@ -1,12 +1,19 @@ import { formatDateValue, parseDateValue } from '@repo/ui/form'; import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators'; -import type { SalesDocumentDto, SalesDocumentEntity, SalesImageEntity, SalesLineEntity } from './sales-document.entity'; +import type { + LookupStub, + SalesDocumentDto, + SalesDocumentEntity, + SalesImageEntity, + SalesLineEntity, +} from './sales-document.entity'; export function relationId(value: unknown): string | undefined { if (value && typeof value === 'object' && 'id' in value) { const id = (value as { id?: unknown }).id; return id == null ? undefined : String(id); } + if (typeof value === 'string' && value) return value; return undefined; } @@ -14,14 +21,36 @@ export function toRelationStub(id?: string | null) { return id ? { id } : null; } +export function toLookup(value: unknown, fallbackId?: string | null): LookupStub | null { + if (value && typeof value === 'object' && 'id' in value) { + const item = value as { id?: unknown; code?: unknown; name?: unknown }; + if (item.id == null || item.id === '') { + return toRelationStub(fallbackId); + } + return { + id: String(item.id), + ...(item.code != null && item.code !== '' ? { code: String(item.code) } : {}), + ...(item.name != null && item.name !== '' ? { name: String(item.name) } : {}), + }; + } + return toRelationStub(fallbackId ?? (typeof value === 'string' ? value : null)); +} + +function lookupId(value: unknown, fallbackId?: string | null): string | undefined { + return relationId(value) ?? (fallbackId == null || fallbackId === '' ? undefined : String(fallbackId)); +} + export function mapSalesLinesFromDto(dto: SalesDocumentDto): SalesLineEntity[] { - return (dto.products ?? []).map((line) => ({ - id: line.id, - productId: line.productId, - product: toRelationStub(line.productId), - quantity: line.quantity, - price: line.price ?? null, - })); + return (dto.products ?? []).map((line) => { + const product = toLookup(line.product, line.productId); + return { + id: line.id, + productId: lookupId(line.product, line.productId), + product, + quantity: line.quantity, + price: line.price ?? null, + }; + }); } export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[] { @@ -33,18 +62,22 @@ export function mapSalesImagesFromDto(dto: SalesDocumentDto): SalesImageEntity[] } export function mapSalesDocumentFromDto(dto: SalesDocumentDto): SalesDocumentEntity { + const salesPerson = toLookup(dto.salesPerson, dto.salesPersonId); + const branch = toLookup(dto.branch, dto.branchId); + const division = toLookup(dto.division, dto.divisionId); + const customer = toLookup(dto.customer, dto.customerId); return { id: dto.id, code: dto.code ?? null, date: formatDateValue(parseDateValue(dto.date) ?? undefined), - salesPersonId: dto.salesPersonId, - salesPerson: toRelationStub(dto.salesPersonId), - branchId: dto.branchId, - branch: toRelationStub(dto.branchId), - divisionId: dto.divisionId, - division: toRelationStub(dto.divisionId), - customerId: dto.customerId, - customer: toRelationStub(dto.customerId), + salesPersonId: lookupId(dto.salesPerson, dto.salesPersonId), + salesPerson, + branchId: lookupId(dto.branch, dto.branchId), + branch, + divisionId: lookupId(dto.division, dto.divisionId), + division, + customerId: lookupId(dto.customer, dto.customerId), + customer, address: dto.address, latitude: dto.latitude ?? null, longitude: dto.longitude ?? null, @@ -61,7 +94,12 @@ export function mapSalesDocumentFromDto(dto: SalesDocumentDto): SalesDocumentEnt export function toSalesWritePayload( entity: Partial, - options?: { includeSalesRequestId?: boolean }, + options?: { + includeSalesRequestId?: boolean; + includeSalesOrderId?: boolean; + includePackingSlipId?: boolean; + omitImages?: boolean; + }, ): Record { const products = (entity.products ?? []) .map((line) => { @@ -91,14 +129,27 @@ export function toSalesWritePayload( longitude: emptyToNull(entity.longitude), notes: emptyToNull(entity.notes), products, - images, }; + if (!options?.omitImages) { + payload.images = images; + } + if (options?.includeSalesRequestId) { payload.salesRequestId = relationId((entity as { salesRequest?: unknown }).salesRequest) ?? (entity as { salesRequestId?: string }).salesRequestId; } + if (options?.includeSalesOrderId) { + payload.salesOrderId = + relationId((entity as { salesOrder?: unknown }).salesOrder) ?? + (entity as { salesOrderId?: string }).salesOrderId; + } + if (options?.includePackingSlipId) { + payload.packingSlipId = + relationId((entity as { packingSlip?: unknown }).packingSlip) ?? + (entity as { packingSlipId?: string }).packingSlipId; + } return payload; } @@ -125,6 +176,14 @@ export function toSalesFilterPayload(filter: Record): Record key; @@ -43,3 +43,16 @@ describe('createSalesOrderSchema', () => { expect(schema.safeParse(validRequest).success).toBe(true); }); }); + +describe('createSalesInvoiceSchema', () => { + it('accepts optional sales order and packing slip', () => { + const schema = createSalesInvoiceSchema(t); + expect(schema.safeParse({ ...validRequest, salesOrder: relation, packingSlip: relation }).success).toBe(true); + expect(schema.safeParse(validRequest).success).toBe(true); + }); + + it('rejects a document with no product lines', () => { + const schema = createSalesInvoiceSchema(t); + expect(schema.safeParse({ ...validRequest, products: [] }).success).toBe(false); + }); +}); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts index 9473df3..075407d 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts @@ -82,3 +82,10 @@ export function createSalesOrderSchema(t: (key: string) => string) { salesRequest: relationSchema.nullable().optional(), }); } + +export function createSalesInvoiceSchema(t: (key: string) => string) { + return salesDocumentBaseSchema(t).extend({ + salesOrder: relationSchema.nullable().optional(), + packingSlip: relationSchema.nullable().optional(), + }); +} 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 48ff615..3c0524e 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 @@ -19,4 +19,19 @@ describe('sales status transitions', () => { expect(commonTransitions('order', ['draft', 'processed'])).toEqual(['cancelled']); expect(commonTransitions('order', ['draft'])).toEqual(['processed', 'cancelled']); }); + + it('allows invoice processed to partial, completed, or cancelled', () => { + expect(allowedTransitions('invoice', 'processed')).toEqual(['partial', 'completed', 'cancelled']); + expect(allowedTransitions('invoice', 'partial')).toEqual(['completed', 'cancelled']); + }); + + it('returns named invoice complete only from processed', () => { + expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['complete', 'cancel']); + expect(namedActionsFor('invoice', 'partial')).toEqual([]); + }); + + it('reuses request transitions and named actions for payments', () => { + expect(allowedTransitions('payment', 'draft')).toEqual(['pending', 'rejected']); + expect(namedActionsFor('payment', 'pending').map((action) => action.key)).toEqual(['approve', 'reject']); + }); }); 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 85ff3a9..7ffd637 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 @@ -4,7 +4,13 @@ export type SalesRequestStatus = (typeof SALES_REQUEST_STATUSES)[number]; export const SALES_ORDER_STATUSES = ['draft', 'processed', 'completed', 'cancelled'] as const; export type SalesOrderStatus = (typeof SALES_ORDER_STATUSES)[number]; -export type SalesDocumentType = 'request' | 'order'; +export const SALES_INVOICE_STATUSES = ['draft', 'processed', 'partial', 'completed', 'cancelled'] as const; +export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number]; + +export const SALES_PAYMENT_STATUSES = SALES_REQUEST_STATUSES; +export type SalesPaymentStatus = SalesRequestStatus; + +export type SalesDocumentType = 'request' | 'order' | 'invoice' | 'payment'; const REQUEST_TRANSITIONS: Record = { draft: ['pending', 'rejected'], @@ -20,6 +26,14 @@ const ORDER_TRANSITIONS: Record = { cancelled: [], }; +const INVOICE_TRANSITIONS: Record = { + draft: ['processed', 'cancelled'], + processed: ['partial', 'completed', 'cancelled'], + partial: ['completed', 'cancelled'], + completed: [], + cancelled: [], +}; + export const SALES_REQUEST_NAMED_ACTIONS = [ { key: 'submit', target: 'pending' as const, from: ['draft'] as const }, { key: 'approve', target: 'approved' as const, from: ['pending'] as const }, @@ -32,15 +46,28 @@ export const SALES_ORDER_NAMED_ACTIONS = [ { key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const }, ] as const; +export const SALES_INVOICE_NAMED_ACTIONS = [ + { key: 'process', target: 'processed' as const, from: ['draft'] as const }, + { key: 'complete', target: 'completed' as const, from: ['processed'] as const }, + { key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const }, +] as const; + +export const SALES_PAYMENT_NAMED_ACTIONS = SALES_REQUEST_NAMED_ACTIONS; + export function documentStatuses(type: SalesDocumentType): readonly string[] { - return type === 'request' ? SALES_REQUEST_STATUSES : SALES_ORDER_STATUSES; + if (type === 'request' || type === 'payment') return SALES_REQUEST_STATUSES; + if (type === 'invoice') return SALES_INVOICE_STATUSES; + return SALES_ORDER_STATUSES; } export function allowedTransitions(type: SalesDocumentType, current?: string | null): string[] { if (!current) return []; - if (type === 'request') { + if (type === 'request' || type === 'payment') { return REQUEST_TRANSITIONS[current as SalesRequestStatus] ?? []; } + if (type === 'invoice') { + return INVOICE_TRANSITIONS[current as SalesInvoiceStatus] ?? []; + } return ORDER_TRANSITIONS[current as SalesOrderStatus] ?? []; } @@ -52,7 +79,12 @@ export function commonTransitions(type: SalesDocumentType, statuses: Array current && (action.from as readonly string[]).includes(current)); } diff --git a/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx b/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx index 55c8677..9913d8f 100644 --- a/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx +++ b/apps/web/src/apps/main/modules/sales/shared/use-sales-document-actions.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { Check, FileUp, ShoppingCart, XCircle, Play, Ban } from 'lucide-react'; +import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, Receipt, CreditCard } from 'lucide-react'; import { notifications } from '@repo/ui/components'; import { useDetailPageContext, @@ -10,6 +10,8 @@ import { import { namedActionsFor, type SalesDocumentType } from './sales-status'; import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service'; import type { SalesDocumentEntity } from './sales-document.entity'; + +type SalesActionEntity = { id?: string | number; status?: string }; import { ChangeStatusModal } from './change-status-modal'; import { ImportCsvModal } from './import-csv-modal'; @@ -44,7 +46,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) { notifications.show({ color: 'green', message: t('status_updated') }); }; - const namedRowActions = (data: SalesDocumentEntity, defaultActions: any[]) => { + const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => { if (!canEdit) return defaultActions; const extras: any[] = namedActionsFor(documentType, data.status).map((action) => { const Icon = ACTION_ICONS[action.key] ?? Check; @@ -69,7 +71,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) { return [...extras, ...defaultActions]; }; - const namedBulkActions = (selectedRows: SalesDocumentEntity[], defaultActions: any[]) => { + const namedBulkActions = (selectedRows: SalesActionEntity[], defaultActions: any[]) => { if (!canEdit || selectedRows.length === 0) return defaultActions; const statuses = selectedRows.map((row) => row.status); const extras: any[] = namedActionsFor(documentType, statuses[0]) @@ -114,7 +116,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) { } : null; - const createOrderAction = (_data: SalesDocumentEntity, onClick: () => void) => + const createOrderAction = (_data: SalesActionEntity, onClick: () => void) => canEdit ? { key: 'create-order', @@ -126,7 +128,31 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) { } : null; - const detailStatusActions = (data: SalesDocumentEntity, defaultActions: any[]) => { + const createInvoiceAction = (_data: SalesActionEntity, onClick: () => void) => + canEdit + ? { + key: 'create-invoice', + label: t('create_sales_invoice'), + icon: , + intent: 'primary' as const, + variant: 'light' as const, + onClick, + } + : null; + + const createPaymentAction = (_data: SalesActionEntity, onClick: () => void) => + canEdit + ? { + key: 'create-payment', + label: t('create_sales_payment'), + icon: , + intent: 'primary' as const, + variant: 'light' as const, + onClick, + } + : null; + + const detailStatusActions = (data: SalesActionEntity, defaultActions: any[]) => { if (!canEdit) return defaultActions; const extras: any[] = namedActionsFor(documentType, data.status).map((action) => { const Icon = ACTION_ICONS[action.key] ?? Check; @@ -182,6 +208,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) { namedBulkActions, importPageAction, createOrderAction, + createInvoiceAction, + createPaymentAction, detailStatusActions, applyStatus, modals, diff --git a/apps/web/src/core/constants/api-url.ts b/apps/web/src/core/constants/api-url.ts index b1f5307..fcc2d25 100644 --- a/apps/web/src/core/constants/api-url.ts +++ b/apps/web/src/core/constants/api-url.ts @@ -16,4 +16,5 @@ export const API_URL = { PLANS: '/plans', SALES_INVOICES: '/sales-invoices', PACKING_SLIPS: '/packing-slips', + SALES_PAYMENTS: '/sales-payments', } as const; diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index f8fa573..54f6264 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -232,6 +232,9 @@ "brand": "Brand", "url": "URL", "images": "Images", + "salesOrder": "Sales Order", + "packingSlip": "Packing Slip", + "invoice": "Invoice", "salesRequest": "Sales Request", "lineTotal": "Line Total" }, diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index 172c555..1d83c4a 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -232,6 +232,9 @@ "brand": "Merek", "url": "URL", "images": "Gambar", + "salesOrder": "Pesanan Penjualan", + "packingSlip": "Surat Jalan", + "invoice": "Faktur", "salesRequest": "Permintaan Penjualan", "lineTotal": "Total Baris" }, diff --git a/packages/ui/src/components/status-badge/status-badge.tsx b/packages/ui/src/components/status-badge/status-badge.tsx index 8f158ed..aa4773a 100644 --- a/packages/ui/src/components/status-badge/status-badge.tsx +++ b/packages/ui/src/components/status-badge/status-badge.tsx @@ -57,6 +57,7 @@ export enum STATUS_DATA { TODO = 'todo', DONE = 'done', + PARTIAL = 'partial', PARTIAL_DONE = 'partial-done', ON_HOLD = 'on-hold', @@ -150,6 +151,7 @@ export enum STATUS_COLOR { WAIT = '#AB6103', WAITING = '#B87104', WAITING_LIST = '#C68106', + PARTIAL = '#D39308', PARTIAL_DONE = '#D39308', ON_HOLD = '#E0A50B', REFUND = '#EDB70D', @@ -208,6 +210,7 @@ export const DEFAULT_STATUS_MAP: Record = { [STATUS_DATA.ACTIVATED]: { color: STATUS_COLOR.ACTIVATED, leftSection: getIcon(CheckCircle2) }, [STATUS_DATA.APPROVED]: { color: STATUS_COLOR.APPROVED, leftSection: getIcon(CheckCircle2) }, [STATUS_DATA.PROCESSED]: { color: STATUS_COLOR.PROCESSED, leftSection: getIcon(Check) }, + [STATUS_DATA.PARTIAL]: { color: STATUS_COLOR.PARTIAL, leftSection: getIcon(Clock) }, [STATUS_DATA.COMPLETED]: { color: STATUS_COLOR.COMPLETED, leftSection: getIcon(CheckCircle2) }, [STATUS_DATA.COMPLETE]: { color: STATUS_COLOR.COMPLETE, leftSection: getIcon(CheckCircle2) }, [STATUS_DATA.DONE]: { color: STATUS_COLOR.DONE, leftSection: getIcon(CheckCircle2) },