From a44fd5f2356bde3913fc642c1859ad134bc46d3a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 16:40:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 ++- src/core/navigation/prototypeMenuIcons.ts | 52 ++++++++++++++ .../components/ContainerFeesPanel.test.ts | 65 +++++++++++++++++- .../components/ContainerFeesPanel.vue | 38 ++++++++--- .../pages/OceanBookingDetailView.test.ts | 22 ++++++ .../pages/OceanBookingDetailView.vue | 67 +++++++++++-------- .../pages/OceanBookingsView.test.ts | 20 ++++++ .../pages/OceanBookingsView.vue | 20 ++++-- .../InboundFeeDetailManager.test.ts | 38 ++++++++++- .../components/InboundFeeDetailManager.vue | 53 ++++++++++++--- .../pages/UnifiedOrdersView.test.ts | 11 ++- .../pages/UnifiedOrdersView.vue | 33 +++++---- 12 files changed, 355 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ac3f54ec..6be43048 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,12 +40,13 @@ | 工作台与业务总览 | `modules/dashboard/` | 按具体指标联查其所属业务模块和服务端,不在工作台复制领域契约 | | 客户、企业审核 | `modules/customers/` | `iam/`、`file/` | | 平台用户与 RBAC | `modules/iam/`、`core/access/` | `auth/`、`iam/` | +| 导航与原型菜单图标 | `core/navigation/adminNavigation.ts`、`core/navigation/prototypeMenuIcons.ts` | 菜单结构、权限与路由同步核对;已有原型图标优先使用映射资源 | | 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` | -| 订单列表与详情编排 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/` | -| 入库、测量和计费 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`file/`、`integration-service/` | +| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;费用入口 `modules/unified-orders/components/InboundFeeDetailManager.vue`、`modules/unified-orders/api/inboundFeeDetails.ts` | `order/`、`firstleg/`;费用来源审核发布联查 `firstleg/AGENTS.md` | +| 入库、测量和计费工作流 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`file/`、`integration-service/` | | 财务账户、账单、收款与成本 | `modules/finance/` | `finance/`;费用来源与柜次成本快照联查 `firstleg/`,凭证联查 `file/` | | 货柜与智能排柜 | `modules/container-loading/` | `firstleg/loading/`、`firstleg/split/` | -| 预装与装柜清单 | `modules/loading-lists/` | `firstleg/loading/`、`file/`、`warehouse/` | +| 预装详情、装柜清单与柜次费用 | `modules/loading-lists/pages/PreloadPlanDetailView.vue`、`modules/loading-lists/components/LoadingListDetailDrawer.vue`、`modules/loading-lists/components/ContainerFeesPanel.vue`、`modules/loading-lists/api/containerFees.ts` | `firstleg/loading/`、`firstleg` 柜次费用、`finance/` 成本分摊、`file/`、`warehouse/` | | 海运订舱与船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`、`integration-service/` | | 拖车预约 | `modules/trucking/` | `firstleg/outbound/dispatch/`、`firstleg/` | | 报关协同 | `modules/customs/` | `firstleg/customs/`、`file/` | @@ -79,6 +80,7 @@ ## UI 与交互 +- 修改已有 Admin 原型页面前先读 `../../docs/Admin-Client前端原型还原验收规则.md`,按页面和操作形成原型核对矩阵;本节维护 Admin 的实现约束,不复制跨前端验收文档全文。 - 已向客户讲解或确认过的产品原型是页面视觉与布局实现的直接事实来源。字段顺序、字段分组、字段摆放、列宽、控件宽高、对齐方式、换行方式、区块层级、上下左右边距、内边距、间隙、留白、圆角、边框、阴影、背景色、文字颜色和字号均必须按原型还原,不得只参考大致风格后自行重新排版。 - 原型中的按钮位置、顺序、尺寸、圆角、边框、背景色、文字色、图标、悬停态、激活态、禁用态和危险语义必须直接按原型实现;不得擅自改为文字链接、不同颜色、不同按钮类型、折叠菜单或自创交互效果,也不得增加原型没有的上浮、缩放、渐变线、装饰图标或其他视觉元素。 - 原型已经给出明确图标、字符图标、图片资源或按钮内图标时,直接使用原型中的同一方案和资源,不得以 Lucide、Element Plus 默认图标、手绘图标或个人偏好替换。仅当原型没有定义对应图标时,才按本节的默认图标语义补齐。 diff --git a/src/core/navigation/prototypeMenuIcons.ts b/src/core/navigation/prototypeMenuIcons.ts index 221a65cf..20f85ce4 100644 --- a/src/core/navigation/prototypeMenuIcons.ts +++ b/src/core/navigation/prototypeMenuIcons.ts @@ -22,6 +22,58 @@ function createPrototypeMenuIcon(name: string, children: SvgChild[]): Component }) } +function createOrderDetailIcon(name: string, children: SvgChild[]): Component { + return defineComponent({ + name, + inheritAttrs: false, + props: { size: { type: [Number, String], default: 20 } }, + setup(props, { attrs }) { + return () => h('svg', { + ...attrs, + width: props.size, + height: props.size, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': 1.8, + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'aria-hidden': 'true', + } satisfies SVGAttributes, children) + }, + }) +} + +export const OrderBasicInfoIcon = createOrderDetailIcon('OrderBasicInfoIcon', [ + h('path', { d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z' }), + h('path', { d: 'm4 7.5 8 4.5 8-4.5M12 12v9' }), +]) +export const OrderCargoInfoIcon = createOrderDetailIcon('OrderCargoInfoIcon', [ + h('path', { d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z' }), + h('path', { d: 'm4 7.5 8 4.5 8-4.5M12 12v9' }), + h('path', { d: 'M8 5.25 16 9.75' }), +]) +export const OrderFeeInfoIcon = createOrderDetailIcon('OrderFeeInfoIcon', [ + h('rect', { x: 3, y: 5, width: 18, height: 14, rx: 2 }), + h('path', { d: 'M3 9h18M7 14h4' }), +]) +export const OrderHistoryIcon = createOrderDetailIcon('OrderHistoryIcon', [ + h('path', { d: 'M3 12a9 9 0 1 0 3-6.7' }), + h('path', { d: 'M3 4v5h5M12 7v5l3 2' }), +]) +export const OrderMeasurementIcon = createOrderDetailIcon('OrderMeasurementIcon', [ + h('path', { d: 'm4 17 13-13 3 3L7 20H4v-3Z' }), + h('path', { d: 'm13 8 3 3M7 17l-1 1M10 14l-1 1M13 11l-1 1' }), +]) +export const OrderOtherIcon = createOrderDetailIcon('OrderOtherIcon', [ + h('path', { d: 'M6 3h9l4 4v14H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z' }), + h('path', { d: 'M14 3v5h5' }), +]) +export const OrderFulfillmentIcon = createOrderDetailIcon('OrderFulfillmentIcon', [ + h('path', { d: 'M4 6h2M4 12h2M4 18h2M9 6h11M9 12h11M9 18h11' }), + h('path', { d: 'm7 11 1.5 1.5L11 10M7 17l1.5 1.5L11 16' }), +]) + export const PrototypeDashboardIcon = createPrototypeMenuIcon('PrototypeDashboardIcon', [ h('rect', { x: 3, y: 3, width: 7, height: 8, rx: 1.5, fill: '#1677ff' }), h('rect', { x: 14, y: 3, width: 7, height: 5, rx: 1.5, fill: '#1677ff', opacity: 0.6 }), diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index de4ae7b0..cc729172 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -1,10 +1,39 @@ -import { describe, expect, it } from 'vitest' +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useAuthStore } from '@/core/auth/auth' +import { createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFees } from '../api/containerFees' +import ContainerFeesPanel from './ContainerFeesPanel.vue' + +vi.mock('../api/containerFees', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() } +}) const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8') const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8') +class ResizeObserverStub { observe() {}; unobserve() {}; disconnect() {} } +function memoryStorage(): Storage { const values = new Map(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } } +async function mountPanel(permissions: string[], customers = [{ accountId: '11', customerName: '客户A' }]) { + const pinia = createPinia(); setActivePinia(pinia) + const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions + useAccessPreviewStore().initializeAfterLogin() + const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', customers }, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises(); return wrapper +} + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()) + vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([]) + vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset() + document.body.innerHTML = '' +}) + describe('ContainerFeesPanel contract', () => { it('is exposed from loading-list detail and derives customer context from real items', () => { expect(detail).toContain('') @@ -31,4 +60,38 @@ describe('ContainerFeesPanel contract', () => { expect(panel).toContain('尚无与该柜次关联的财务分摊结果') expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/) }) + + it('clears the previous customer context and ignores a late response', async () => { + let resolveFirst!: (value: Awaited>) => void + vi.mocked(listContainerFees).mockImplementation((planId, accountId) => accountId === '11' + ? new Promise((resolve) => { resolveFirst = resolve }) + : Promise.resolve([])) + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'], [{ accountId: '11', customerName: '客户A' }, { accountId: '22', customerName: '客户B' }]) + const state = (wrapper.vm.$ as unknown as { setupState: { accountId: string; fees: unknown[]; costs: unknown[]; editorOpen: boolean } }).setupState + state.accountId = '11'; await flushPromises(); state.editorOpen = true + state.accountId = '22'; await flushPromises() + expect(state.fees).toEqual([]); expect(state.costs).toEqual([]); expect(state.editorOpen).toBe(false) + resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }]) + await flushPromises() + expect(state.fees).toEqual([]) + expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything()) + }) + + it('blocks write entry points without a selected customer or edit permission', async () => { + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'], [{ accountId: '11', customerName: '客户A' }, { accountId: '22', customerName: '客户B' }]) + const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; editorOpen: boolean } }).setupState + state.openCreate(); await state.save() + expect(state.editorOpen).toBe(false) + expect(createContainerFee).not.toHaveBeenCalled() + }) + + it('hides all mutation buttons when permissions are missing', async () => { + vi.mocked(listContainerFees).mockResolvedValue([ + { id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '待审费用', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, + { id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, + ]) + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view']) + expect(wrapper.text()).not.toContain('新增费用') + for (const label of ['通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false) + }) }) diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index 9cd49985..13f32c7a 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -26,6 +26,7 @@ const target = ref(null) const formRef = ref() const form = reactive({ feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null }) const rejection = ref('') +let loadSequence = 0 const canView = computed(() => access.can('firstleg:inbound:fee-detail:view')) const canEdit = computed(() => access.can('firstleg:inbound:fee-detail:edit')) @@ -67,21 +68,28 @@ function resetForm(item?: ContainerFeeResource) { async function load() { if (!accountId.value || !canView.value) return + const sequence = ++loadSequence + const requestedAccountId = accountId.value loading.value = true; error.value = null try { - const [feeRows, items] = await Promise.all([listContainerFees(props.planId, accountId.value), listApplicableFeeItems()]) + const [feeRows, items] = await Promise.all([listContainerFees(props.planId, requestedAccountId), listApplicableFeeItems()]) + if (sequence !== loadSequence || requestedAccountId !== accountId.value) return fees.value = feeRows; feeItems.value = items costs.value = [] const currentContainerId = feeRows.find((item) => item.containerBookingId)?.containerBookingId - if (currentContainerId) costs.value = await listContainerCostDetails(accountId.value, currentContainerId) - } catch (cause) { error.value = getApiErrorDetails(cause, '柜次费用加载失败') } - finally { loading.value = false } + if (currentContainerId) { + const result = await listContainerCostDetails(requestedAccountId, currentContainerId) + if (sequence !== loadSequence || requestedAccountId !== accountId.value) return + costs.value = result + } + } catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) error.value = getApiErrorDetails(cause, '柜次费用加载失败') } + finally { if (sequence === loadSequence) loading.value = false } } -function openCreate() { resetForm(); editorOpen.value = true } -function openAdjust(item: ContainerFeeResource) { resetForm(item); editorOpen.value = true } +function openCreate() { if (!canEdit.value || !accountId.value) return; resetForm(); editorOpen.value = true } +function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorOpen.value = true } async function save() { - if (!accountId.value || !await formRef.value?.validate().catch(() => false)) return + if (!canEdit.value || !accountId.value || !await formRef.value?.validate().catch(() => false)) return saving.value = true try { if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, { ...form }) @@ -90,22 +98,30 @@ async function save() { } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) } finally { saving.value = false } } -async function approve(item: ContainerFeeResource) { await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) } -function openReject(item: ContainerFeeResource) { target.value = item; rejection.value = ''; rejectOpen.value = true } +async function approve(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) } +function openReject(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; target.value = item; rejection.value = ''; rejectOpen.value = true } async function reject() { - if (!target.value || !rejection.value.trim()) return ElMessage.warning('请输入驳回原因') + if (!canReview.value || !accountId.value || !target.value) return + if (!rejection.value.trim()) return ElMessage.warning('请输入驳回原因') saving.value = true try { await rejectContainerFee(props.planId, accountId.value, target.value.id, rejection.value.trim()); rejectOpen.value = false; ElMessage.success('费用已驳回'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '驳回失败').message) } finally { saving.value = false } } async function voidFee(item: ContainerFeeResource) { + if (!canReview.value || !accountId.value) return await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' }) await voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion).then(() => ElMessage.success('费用已作废')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '作废失败').message)) } function money(value: number) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' } +function clearCustomerContext() { + loadSequence += 1 + fees.value = []; costs.value = []; feeItems.value = []; error.value = null; loading.value = false + editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = '' + resetForm() +} watch(customerOptions, initializeCustomer, { immediate: true }) -watch(accountId, () => { fees.value = []; costs.value = []; void load() }) +watch(accountId, () => { clearCustomerContext(); void load() }) watch(() => form.direction, () => { if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = '' }) onMounted(() => void load()) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index b4e3a65d..3fca8f92 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -40,4 +40,26 @@ describe('OceanBookingDetailView interaction contract', () => { '例如:PREPAID', '请输入订舱备注(可选)', ]) expect(source).toContain(`placeholder="${placeholder}"`) }) + + it('keeps view, create and edit on the formal detail route with a return action', () => { + expect(source).toContain("router.push('/ocean-bookings')") + expect(source).toContain("router.replace(`/ocean-bookings/${created.id}`)") + expect(source).not.toContain('AppDrawer') + expect(source).not.toContain('打印') + }) + + it('groups real detail fields into the frozen three-column prototype structure', () => { + expect(source).toContain('aria-label="基本信息"') + expect(source).toContain('aria-label="航线船期"') + expect(source).toContain('aria-label="条款与柜量"') + expect(source).toContain('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))') + expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}') + }) + + it('keeps the three real booking parties in their own three-column section', () => { + expect(source).toContain("{ value: 'SHIPPER', label: '发货人' }") + expect(source).toContain("{ value: 'CONSIGNEE', label: '收货人' }") + expect(source).toContain("{ value: 'NOTIFY_PARTY', label: '通知人' }") + expect(source).toContain('.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))') + }) }) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index b22d2c2f..81f5e2f4 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -249,23 +249,44 @@ onMounted(() => { void load(); void loadOptions() }) 重新加载选项 - - - - - - - - - - - - - - - - - + +
+

基本信息

+ + + + + + +
+
+
+

航线船期

+ + + + + + + + + +
+
+
+

条款与柜量

+ + +
+ + + + 删除 +
+ 新增箱型 + +
+
@@ -282,16 +303,6 @@ onMounted(() => { void load(); void loadOptions() }) -
-

箱型需求

新增箱型
-
- - - - 删除 -
-
-

状态历史

当前进度:{{ statusLabel(detail.status) }}

{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}

{{ event.operatorName || '系统' }} · {{ event.reason }}

@@ -312,5 +323,5 @@ onMounted(() => { void load(); void loadOptions() }) diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts index 1718fdcb..7890b7ba 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts @@ -2,3 +2,23 @@ import { describe, expect, it } from 'vitest' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('