<script lang="ts" setup>
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
|
|
import type { DefaultOptionType } from 'ant-design-vue/es/select';
|
|
import { nextTick, watch } from 'vue';
|
|
import { DICT_TYPE } from '@vben/constants';
|
import { getDictOptions } from '@vben/hooks';
|
import { erpPriceInputFormatter } from '@vben/utils';
|
|
import { Button, DatePicker, Input, InputNumber, Select } from 'ant-design-vue';
|
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { $t } from '#/locales';
|
|
import { toSubmitItems, useItemColumns } from '../data';
|
|
const props = withDefaults(
|
defineProps<{
|
items?: FinanceReimbursementApi.ReimbursementItem[];
|
disabled?: boolean;
|
}>(),
|
{
|
items: () => [],
|
disabled: false,
|
},
|
);
|
|
const emit = defineEmits(['update:items', 'update:totalAmount']);
|
|
const expenseTypeOptions = getDictOptions(
|
DICT_TYPE.FINANCE_EXPENSE_TYPE,
|
'number',
|
) as DefaultOptionType[];
|
|
const [Grid, gridApi] = useVbenVxeGrid({
|
gridOptions: {
|
columns: useItemColumns(),
|
data: [],
|
minHeight: 250,
|
autoResize: true,
|
border: true,
|
rowConfig: {
|
keyField: 'id',
|
isHover: true,
|
},
|
pagerConfig: {
|
enabled: false,
|
},
|
toolbarConfig: {
|
enabled: false,
|
},
|
} as VxeTableGridOptions<FinanceReimbursementApi.ReimbursementItem>,
|
});
|
|
/** 输入时不补零,失焦后仍按金额格式保留两位小数 */
|
const amountFormatter = (
|
value: number | string | undefined,
|
info: { input: string; userTyping: boolean },
|
) => {
|
return info.userTyping ? info.input : erpPriceInputFormatter(value);
|
};
|
|
/** 读取当前全部明细行(提交形态:不含行 id) */
|
function collectItems(): FinanceReimbursementApi.ReimbursementItem[] {
|
const rows =
|
(gridApi.grid.getData() as FinanceReimbursementApi.ReimbursementItem[]) ??
|
[];
|
const removed =
|
(gridApi.grid.getRemoveRecords() as FinanceReimbursementApi.ReimbursementItem[]) ??
|
[];
|
const inserted =
|
(gridApi.grid.getInsertRecords() as FinanceReimbursementApi.ReimbursementItem[]) ??
|
[];
|
const isRemoved = (row: FinanceReimbursementApi.ReimbursementItem) =>
|
removed.some(
|
(item) => item === row || (row.id != null && item.id === row.id),
|
);
|
return toSubmitItems([...rows.filter((row) => !isRemoved(row)), ...inserted]);
|
}
|
|
/** 重新汇总明细并同步给父组件 */
|
function recalc() {
|
const rows = collectItems();
|
const totalAmount = rows.reduce(
|
(sum, row) => sum + (Number(row.invoiceAmount) || 0),
|
0,
|
);
|
emit('update:items', rows);
|
emit('update:totalAmount', Number(totalAmount.toFixed(2)));
|
}
|
|
/** 添加明细行 */
|
async function handleAdd() {
|
await gridApi.grid.insertAt(
|
{} as unknown as FinanceReimbursementApi.ReimbursementItem,
|
-1,
|
);
|
recalc();
|
}
|
|
/** 删除明细行 */
|
async function handleDelete(row: FinanceReimbursementApi.ReimbursementItem) {
|
await gridApi.grid.remove(row);
|
recalc();
|
}
|
|
/** 校验明细:至少 1 行,且每行费用类型与发票金额必填 */
|
function validate() {
|
const rows = collectItems();
|
if (rows.length === 0) {
|
throw new Error('请至少添加一条报销明细');
|
}
|
for (let i = 0; i < rows.length; i++) {
|
const row = rows[i];
|
if (row?.expenseType === undefined || row?.expenseType === null) {
|
throw new Error(`第 ${i + 1} 行:请选择费用类型`);
|
}
|
if (!row?.invoiceAmount || Number(row.invoiceAmount) <= 0) {
|
throw new Error(`第 ${i + 1} 行:发票金额必须大于 0`);
|
}
|
}
|
}
|
|
defineExpose({ validate });
|
|
/**
|
* 回显明细(单向:父 → 表格)。
|
* 此 watcher 内**禁止**回传 emit('update:items'):父组件会把新数组写回 :items,
|
* 于是本 watcher 被再次触发,构成 prop ↔ emit 死循环,新增/编辑时页面会直接卡死。
|
* 总金额同理,回显时不回填,避免覆盖用户手工调整过的报销总金额。
|
*/
|
watch(
|
() => props.items,
|
async (items) => {
|
if (!items) {
|
return;
|
}
|
await nextTick();
|
await gridApi.grid.loadData([...items]);
|
},
|
{ immediate: true },
|
);
|
</script>
|
|
<template>
|
<Grid class="w-full">
|
<template #expenseType="{ row }">
|
<Select
|
v-model:value="row.expenseType"
|
:disabled="disabled"
|
:options="expenseTypeOptions"
|
placeholder="请选择费用类型"
|
allow-clear
|
@change="() => recalc()"
|
/>
|
</template>
|
<template #invoiceNo="{ row }">
|
<Input
|
v-model:value="row.invoiceNo"
|
:disabled="disabled"
|
placeholder="请输入发票号码"
|
@change="() => recalc()"
|
/>
|
</template>
|
<template #invoiceAmount="{ row }">
|
<InputNumber
|
v-model:value="row.invoiceAmount"
|
:disabled="disabled"
|
:precision="2"
|
:formatter="amountFormatter"
|
class="!w-full"
|
placeholder="请输入发票金额"
|
@change="() => recalc()"
|
/>
|
</template>
|
<template #invoiceDate="{ row }">
|
<DatePicker
|
v-model:value="row.invoiceDate"
|
:disabled="disabled"
|
value-format="YYYY-MM-DD"
|
placeholder="请选择发票日期"
|
class="w-full"
|
@change="() => recalc()"
|
/>
|
</template>
|
<template #drawer="{ row }">
|
<Input
|
v-model:value="row.drawer"
|
:disabled="disabled"
|
placeholder="请输入开票方"
|
@change="() => recalc()"
|
/>
|
</template>
|
<template #description="{ row }">
|
<Input
|
v-model:value="row.description"
|
:disabled="disabled"
|
placeholder="请输入费用说明"
|
@change="() => recalc()"
|
/>
|
</template>
|
<template #actions="{ row }">
|
<TableAction
|
:actions="[
|
{
|
label: $t('common.delete'),
|
danger: true,
|
type: 'link',
|
icon: ACTION_ICON.DELETE,
|
disabled,
|
onClick: handleDelete.bind(null, row),
|
},
|
]"
|
/>
|
</template>
|
</Grid>
|
<div v-if="!disabled" class="-mt-4 flex justify-center">
|
<Button type="primary" ghost @click="handleAdd"> 添加报销明细 </Button>
|
</div>
|
</template>
|