fix bug
This commit is contained in:
parent
1910f15564
commit
5bb6609ef8
@ -186,367 +186,7 @@ function getBasicFeeFromScale(scaleValue, scaleType) {
|
||||
return res;
|
||||
}
|
||||
|
||||
let data1 = {
|
||||
name: 'test001',
|
||||
writer: '张三',// 编制人
|
||||
reviewer: '李四',// 复核人
|
||||
company: '测试公司',// 公司名称
|
||||
date: '2021-09-24',// 编制日期
|
||||
industry: 0,// 0为公路工程,1为铁路工程,2为水运工程
|
||||
fee: 10000,
|
||||
scaleCost: 100000,// scale的cost的合计数
|
||||
scale: [// 规模信息
|
||||
{
|
||||
major: 0,
|
||||
cost: 100000,
|
||||
area: 200,
|
||||
},
|
||||
{
|
||||
major: 1,
|
||||
cost: 100000,
|
||||
area: 200,
|
||||
},
|
||||
],
|
||||
serviceCoes: [// 项目咨询分类系数
|
||||
{
|
||||
serviceid: 0,
|
||||
coe: 1.1,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
serviceid: 1,
|
||||
coe: 1.2,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
majorCoes: [// 项目工程专业系数
|
||||
{
|
||||
majorid: 0,
|
||||
coe: 1.1,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
majorid: 1,
|
||||
coe: 1.2,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
contracts: [// 合同段信息
|
||||
{
|
||||
name: 'A合同段',
|
||||
serviceFee: 100000,
|
||||
addtionalFee: 0,
|
||||
reserveFee: 0,
|
||||
fee: 10000,
|
||||
scale: [
|
||||
{
|
||||
major: 0,
|
||||
cost: 100000,
|
||||
area: 200,
|
||||
},
|
||||
{
|
||||
major: 1,
|
||||
cost: 100000,
|
||||
area: 200,
|
||||
},
|
||||
],
|
||||
serviceCoes: [// 合同段咨询分类系数
|
||||
{
|
||||
serviceid: 0,
|
||||
coe: 1.1,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
serviceid: 1,
|
||||
coe: 1.2,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
majorCoes: [// 合同段工程专业系数
|
||||
{
|
||||
majorid: 0,
|
||||
coe: 1.1,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
majorid: 1,
|
||||
coe: 1.2,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
services: [
|
||||
{
|
||||
id: 0,
|
||||
fee: 100000,
|
||||
process: 0,// 工作环节,0为编制,1为审核
|
||||
method1: { // 投资规模法
|
||||
cost: 100000,
|
||||
basicFee: 200,
|
||||
basicFee_basic: 200,
|
||||
basicFee_optional: 0,
|
||||
fee: 250000,
|
||||
proAmount: 3,
|
||||
det: [
|
||||
{
|
||||
proNum: 1,
|
||||
major: 0,
|
||||
cost: 100000,
|
||||
basicFee: 200,
|
||||
basicFormula: '856,000+(1,000,000,000-500,000,000)×1‰',
|
||||
basicFee_basic: 200,
|
||||
optionalFormula: '171,200+(1,000,000,000-500,000,000)×0.2‰',
|
||||
basicFee_optional: 0,
|
||||
serviceCoe: 1.1,
|
||||
majorCoe: 1.2,
|
||||
processCoe: 1,// 工作环节系数(编审系数)
|
||||
proportion: 0.5,// 工作占比
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
method2: { // 用地规模法
|
||||
area: 1200,
|
||||
basicFee: 200,
|
||||
basicFee_basic: 200,
|
||||
basicFee_optional: 0,
|
||||
fee: 250000,
|
||||
proAmount: 3,
|
||||
det: [
|
||||
{
|
||||
proNum: 1,
|
||||
major: 0,
|
||||
area: 1200,
|
||||
basicFee: 200,
|
||||
basicFormula: '106,000+(1,200-1,000)×60',
|
||||
basicFee_basic: 200,
|
||||
optionalFormula: '21,200+(1,200-1,000)×12',
|
||||
basicFee_optional: 0,
|
||||
serviceCoe: 1.1,
|
||||
majorCoe: 1.2,
|
||||
processCoe: 1,// 工作环节系数(编审系数)
|
||||
proportion: 0.5,// 工作占比
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
method3: { // 工作量法
|
||||
basicFee: 200,
|
||||
fee: 250000,
|
||||
det: [
|
||||
{
|
||||
task: 0,
|
||||
price: 100000,
|
||||
amount: 10,
|
||||
basicFee: 200,
|
||||
serviceCoe: 1.1,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
task: 1,
|
||||
price: 100000,
|
||||
amount: 10,
|
||||
basicFee: 200,
|
||||
serviceCoe: 1.1,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
method4: { // 工时法
|
||||
person_num: 10,
|
||||
work_day: 10,
|
||||
fee: 250000,
|
||||
det: [
|
||||
{
|
||||
expert: 0,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
expert: 1,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
addtional: {// 附加工作费
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
|
||||
name: '附加工作',
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
id: 0,
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }] },
|
||||
name: '人员驻场服务及其他附加工作',
|
||||
fee: 10000,
|
||||
m4: { //工时
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
expert: 0,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
expert: 1,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
m5: { //数量单价
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'X' }] },
|
||||
name: '咨询服务协调工作',
|
||||
fee: 10000,
|
||||
m0: {
|
||||
coe: 0.03,
|
||||
fee: 10000,
|
||||
},
|
||||
m4: {
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
expert: 0,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
expert: 1,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
m5: {
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
reserve: {// 预备费
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'Y' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }] },
|
||||
name: '预备费',
|
||||
fee: 10000,
|
||||
m0: {
|
||||
coe: 0.03,
|
||||
fee: 10000,
|
||||
},
|
||||
m4: {
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
expert: 0,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
expert: 1,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
m5: {
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
let data2 = {
|
||||
name: 'test001',
|
||||
|
||||
550
src/components/common/HourlyFeeGrid.vue
Normal file
550
src/components/common/HourlyFeeGrid.vue
Normal file
@ -0,0 +1,550 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { expertList } from '@/sql'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { syncPricingTotalToZxFw, type ZxFwPricingField, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
expertCode: string
|
||||
expertName: string
|
||||
laborBudgetUnitPrice: string
|
||||
compositeBudgetUnitPrice: string
|
||||
adoptedBudgetUnitPrice: number | null
|
||||
personnelCount: number | null
|
||||
workdayCount: number | null
|
||||
serviceBudget: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface GridState {
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
storageKey: string
|
||||
title?: string
|
||||
contractId?: string
|
||||
serviceId?: string | number
|
||||
enableZxFwSync?: boolean
|
||||
syncField?: ZxFwPricingField
|
||||
syncMainStorageKey?: string
|
||||
syncRowId?: string
|
||||
}>(),
|
||||
{
|
||||
title: '工时法明细',
|
||||
enableZxFwSync: false,
|
||||
syncField: 'hourly'
|
||||
}
|
||||
)
|
||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
|
||||
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
const reloadSignal = ref(0)
|
||||
|
||||
const shouldSkipPersist = () => {
|
||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}`
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
|
||||
if (raw.includes(':')) {
|
||||
const [issuedRaw, untilRaw] = raw.split(':')
|
||||
const issuedAt = Number(issuedRaw)
|
||||
const skipUntil = Number(untilRaw)
|
||||
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
|
||||
return paneInstanceCreatedAt <= issuedAt
|
||||
}
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const skipUntil = Number(raw)
|
||||
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const shouldForceDefaultLoad = () => {
|
||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${props.storageKey}`
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
|
||||
}
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||
|
||||
type ExpertLite = {
|
||||
code: string
|
||||
name: string
|
||||
maxPrice: number | null
|
||||
minPrice: number | null
|
||||
defPrice: number | null
|
||||
manageCoe: number | null
|
||||
}
|
||||
|
||||
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, ExpertLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const formatPriceRange = (min: number | null, max: number | null) => {
|
||||
const hasMin = typeof min === 'number' && Number.isFinite(min)
|
||||
const hasMax = typeof max === 'number' && Number.isFinite(max)
|
||||
if (hasMin && hasMax) return `${min}-${max}`
|
||||
if (hasMin) return String(min)
|
||||
if (hasMax) return String(max)
|
||||
return ''
|
||||
}
|
||||
|
||||
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
|
||||
if (typeof expert.manageCoe !== 'number' || !Number.isFinite(expert.manageCoe)) return ''
|
||||
const min =
|
||||
typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
|
||||
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
|
||||
: null
|
||||
const max =
|
||||
typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
|
||||
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
|
||||
: null
|
||||
return formatPriceRange(min, max)
|
||||
}
|
||||
|
||||
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
|
||||
if (
|
||||
typeof expert.defPrice !== 'number' ||
|
||||
!Number.isFinite(expert.defPrice) ||
|
||||
typeof expert.manageCoe !== 'number' ||
|
||||
!Number.isFinite(expert.manageCoe)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const [expertId, expert] of expertEntries) {
|
||||
const rowId = `expert-${expertId}`
|
||||
rows.push({
|
||||
id: rowId,
|
||||
expertCode: expert.code,
|
||||
expertName: expert.name,
|
||||
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
|
||||
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
|
||||
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
|
||||
personnelCount: null,
|
||||
workdayCount: null,
|
||||
serviceBudget: null,
|
||||
remark: '',
|
||||
path: [rowId]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
return {
|
||||
...row,
|
||||
adoptedBudgetUnitPrice:
|
||||
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
|
||||
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
|
||||
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
|
||||
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseNonNegativeIntegerOrNull = (value: unknown) => {
|
||||
if (value === '' || value == null) return null
|
||||
if (typeof value === 'number') return Number.isInteger(value) && value >= 0 ? value : null
|
||||
const normalized = String(value).trim()
|
||||
if (!/^\d+$/.test(normalized)) return null
|
||||
const v = Number(normalized)
|
||||
return Number.isSafeInteger(v) ? v : null
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const formatEditableInteger = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return String(Number(params.value))
|
||||
}
|
||||
|
||||
const calcServiceBudget = (row: DetailRow | undefined) => {
|
||||
const adopted = row?.adoptedBudgetUnitPrice
|
||||
const personnel = row?.personnelCount
|
||||
const workday = row?.workdayCount
|
||||
if (
|
||||
typeof adopted !== 'number' ||
|
||||
!Number.isFinite(adopted) ||
|
||||
typeof personnel !== 'number' ||
|
||||
!Number.isFinite(personnel) ||
|
||||
typeof workday !== 'number' ||
|
||||
!Number.isFinite(workday)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
|
||||
}
|
||||
|
||||
const syncServiceBudgetToRows = () => {
|
||||
for (const row of detailRows.value) {
|
||||
row.serviceBudget = calcServiceBudget(row)
|
||||
}
|
||||
}
|
||||
|
||||
const editableNumberCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: formatEditableNumber,
|
||||
...extra
|
||||
})
|
||||
|
||||
const editableMoneyCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'ag-right-aligned-cell editable-cell-line'
|
||||
: 'ag-right-aligned-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
},
|
||||
...extra
|
||||
})
|
||||
|
||||
const readonlyTextCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
editable: false,
|
||||
valueFormatter: params => params.value || '',
|
||||
...extra
|
||||
})
|
||||
|
||||
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
||||
{
|
||||
headerName: '编码',
|
||||
field: 'expertCode',
|
||||
minWidth: 120,
|
||||
width: 140,
|
||||
pinned: 'left',
|
||||
colSpan: params => (params.node?.rowPinned ? 2 : 1),
|
||||
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: '人员名称',
|
||||
field: 'expertName',
|
||||
minWidth: 200,
|
||||
width: 220,
|
||||
pinned: 'left',
|
||||
tooltipField: 'expertName',
|
||||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: '预算参考单价',
|
||||
marryChildren: true,
|
||||
children: [
|
||||
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
|
||||
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
|
||||
]
|
||||
},
|
||||
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
|
||||
editableNumberCol('personnelCount', '人员数量(人)', {
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
|
||||
valueFormatter: formatEditableInteger
|
||||
}),
|
||||
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
|
||||
{
|
||||
headerName: '服务预算(元)',
|
||||
field: 'serviceBudget',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
editable: false,
|
||||
aggFunc: decimalAggSum,
|
||||
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
|
||||
valueFormatter: params => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '说明',
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
||||
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
||||
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => calcServiceBudget(row)))
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
expertCode: '总合计',
|
||||
expertName: '',
|
||||
laborBudgetUnitPrice: '',
|
||||
compositeBudgetUnitPrice: '',
|
||||
adoptedBudgetUnitPrice: null,
|
||||
personnelCount: totalPersonnelCount.value,
|
||||
workdayCount: totalWorkdayCount.value,
|
||||
serviceBudget: totalServiceBudget.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
if (shouldSkipPersist()) return
|
||||
try {
|
||||
syncServiceBudgetToRows()
|
||||
const payload: GridState = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
|
||||
await localforage.setItem(props.storageKey, payload)
|
||||
|
||||
if (props.enableZxFwSync && props.contractId && props.serviceId != null) {
|
||||
const synced = await syncPricingTotalToZxFw({
|
||||
contractId: props.contractId,
|
||||
serviceId: props.serviceId,
|
||||
field: props.syncField,
|
||||
value: totalServiceBudget.value
|
||||
})
|
||||
if (synced) pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||
}
|
||||
if (props.syncMainStorageKey && props.syncRowId) {
|
||||
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
if (shouldForceDefaultLoad()) {
|
||||
detailRows.value = buildDefaultRows()
|
||||
syncServiceBudgetToRows()
|
||||
return
|
||||
}
|
||||
const data = await localforage.getItem<GridState>(props.storageKey)
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
syncServiceBudgetToRows()
|
||||
return
|
||||
}
|
||||
detailRows.value = buildDefaultRows()
|
||||
syncServiceBudgetToRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
syncServiceBudgetToRows()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => pricingPaneReloadStore.seq,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
if (!props.contractId || props.serviceId == null) return
|
||||
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, props.serviceId)) return
|
||||
reloadSignal.value += 1
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => reloadSignal.value,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
void loadFromIndexedDB()
|
||||
}
|
||||
)
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
syncServiceBudgetToRows()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) return JSON.stringify(params.value)
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (_error) {
|
||||
return params.value
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
() => {
|
||||
void loadFromIndexedDB()
|
||||
}
|
||||
)
|
||||
|
||||
onDeactivated(() => {
|
||||
gridApi.value?.stopEditing()
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridApi.value?.stopEditing()
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ props.title }}</h3>
|
||||
<div class="text-xs text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
:treeData="false"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,13 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
|
||||
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import localforage from 'localforage'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { roundTo, toDecimal } from '@/lib/decimal'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle
|
||||
} from 'reka-ui'
|
||||
|
||||
interface FeeRow {
|
||||
id: string
|
||||
@ -17,6 +30,7 @@ interface FeeRow {
|
||||
unitPrice: number | null
|
||||
budgetFee: number | null
|
||||
remark: string
|
||||
actions?: unknown
|
||||
}
|
||||
|
||||
interface FeeGridState {
|
||||
@ -26,9 +40,13 @@ interface FeeGridState {
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
storageKey: string
|
||||
syncMainStorageKey?: string
|
||||
syncRowId?: string
|
||||
}>()
|
||||
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
|
||||
|
||||
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
|
||||
|
||||
const createDefaultRow = (): FeeRow => ({
|
||||
id: createRowId(),
|
||||
@ -40,20 +58,77 @@ const createDefaultRow = (): FeeRow => ({
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const detailRows = ref<FeeRow[]>([createDefaultRow()])
|
||||
const createSubtotalRow = (): FeeRow => ({
|
||||
id: SUBTOTAL_ROW_ID,
|
||||
feeItem: '小计',
|
||||
unit: '',
|
||||
quantity: null,
|
||||
unitPrice: null,
|
||||
budgetFee: 0,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const isSubtotalRow = (row?: FeeRow | null) => row?.id === SUBTOTAL_ROW_ID
|
||||
|
||||
const ensureSubtotalRow = (rows: FeeRow[]) => {
|
||||
const normalRows = rows.filter(row => !isSubtotalRow(row))
|
||||
if (normalRows.length === 0) return []
|
||||
return [...normalRows, createSubtotalRow()]
|
||||
}
|
||||
|
||||
const detailRows = ref<FeeRow[]>([])
|
||||
const gridApi = ref<GridApi<FeeRow> | null>(null)
|
||||
const deleteConfirmOpen = ref(false)
|
||||
const pendingDeleteRowId = ref<string | null>(null)
|
||||
const pendingDeleteRowName = ref('')
|
||||
|
||||
const addRow = () => {
|
||||
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row))
|
||||
detailRows.value = ensureSubtotalRow([...normalRows, createDefaultRow()])
|
||||
syncComputedValuesToRows()
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const deleteRow = (id: string) => {
|
||||
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row) && row.id !== id)
|
||||
detailRows.value = ensureSubtotalRow(normalRows)
|
||||
syncComputedValuesToRows()
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const requestDeleteRow = (id: string, name?: string) => {
|
||||
pendingDeleteRowId.value = id
|
||||
pendingDeleteRowName.value = String(name || '').trim() || '当前行'
|
||||
deleteConfirmOpen.value = true
|
||||
}
|
||||
|
||||
const handleDeleteConfirmOpenChange = (open: boolean) => {
|
||||
deleteConfirmOpen.value = open
|
||||
}
|
||||
|
||||
const confirmDeleteRow = () => {
|
||||
const id = pendingDeleteRowId.value
|
||||
if (!id) return
|
||||
deleteRow(id)
|
||||
deleteConfirmOpen.value = false
|
||||
pendingDeleteRowId.value = null
|
||||
pendingDeleteRowName.value = ''
|
||||
}
|
||||
|
||||
const formatEditableText = (params: any) => {
|
||||
if (isSubtotalRow(params.data)) return ''
|
||||
if (params.value == null || params.value === '') return '点击输入'
|
||||
return String(params.value)
|
||||
}
|
||||
|
||||
const formatEditableQuantity = (params: any) => {
|
||||
if (isSubtotalRow(params.data)) return ''
|
||||
if (params.value == null || params.value === '') return '点击输入'
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const formatEditableUnitPrice = (params: any) => {
|
||||
if (isSubtotalRow(params.data)) return ''
|
||||
if (params.value == null || params.value === '') return '点击输入'
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
@ -64,18 +139,32 @@ const formatReadonlyBudgetFee = (params: any) => {
|
||||
}
|
||||
|
||||
const syncComputedValuesToRows = () => {
|
||||
let totalBudgetFee = 0
|
||||
for (const row of detailRows.value) {
|
||||
if (isSubtotalRow(row)) continue
|
||||
if (row.quantity == null || row.unitPrice == null) {
|
||||
row.budgetFee = null
|
||||
continue
|
||||
}
|
||||
row.budgetFee = roundTo(toDecimal(row.quantity).mul(row.unitPrice), 2)
|
||||
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) {
|
||||
totalBudgetFee = roundTo(toDecimal(totalBudgetFee).add(row.budgetFee), 2)
|
||||
}
|
||||
}
|
||||
const subtotalRow = detailRows.value.find(row => isSubtotalRow(row))
|
||||
if (subtotalRow) {
|
||||
subtotalRow.feeItem = '小计'
|
||||
subtotalRow.unit = ''
|
||||
subtotalRow.quantity = null
|
||||
subtotalRow.unitPrice = null
|
||||
subtotalRow.budgetFee = totalBudgetFee
|
||||
subtotalRow.remark = ''
|
||||
}
|
||||
}
|
||||
|
||||
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
|
||||
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
|
||||
return [createDefaultRow()]
|
||||
return []
|
||||
}
|
||||
|
||||
const rows: FeeRow[] = rowsFromDb.map(item => {
|
||||
@ -91,7 +180,7 @@ const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
|
||||
}
|
||||
})
|
||||
|
||||
return rows.length > 0 ? rows : [createDefaultRow()]
|
||||
return ensureSubtotalRow(rows)
|
||||
}
|
||||
|
||||
const buildPersistDetailRows = () => {
|
||||
@ -104,7 +193,11 @@ const saveToIndexedDB = async () => {
|
||||
const payload: FeeGridState = {
|
||||
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
||||
}
|
||||
|
||||
await localforage.setItem(props.storageKey, payload)
|
||||
if (props.syncMainStorageKey && props.syncRowId) {
|
||||
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
@ -117,7 +210,7 @@ const loadFromIndexedDB = async () => {
|
||||
syncComputedValuesToRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = [createDefaultRow()]
|
||||
detailRows.value = []
|
||||
syncComputedValuesToRows()
|
||||
}
|
||||
}
|
||||
@ -136,18 +229,22 @@ const columnDefs: ColDef<FeeRow>[] = [
|
||||
valueGetter: params =>
|
||||
params.node?.rowPinned
|
||||
? ''
|
||||
: isSubtotalRow(params.data)
|
||||
? '小计'
|
||||
: typeof params.node?.rowIndex === 'number'
|
||||
? params.node.rowIndex + 1
|
||||
: ''
|
||||
: '',
|
||||
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
|
||||
},
|
||||
{
|
||||
headerName: '费用项',
|
||||
field: 'feeItem',
|
||||
minWidth: 140,
|
||||
flex: 1.4,
|
||||
editable: true,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueGetter: params => (isSubtotalRow(params.data) ? '' : (params.data?.feeItem ?? '')),
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: 'editable-cell-line',
|
||||
cellClass: params => (isSubtotalRow(params.data) ? '' : 'editable-cell-line'),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
@ -157,7 +254,7 @@ const columnDefs: ColDef<FeeRow>[] = [
|
||||
field: 'unit',
|
||||
minWidth: 90,
|
||||
flex: 0.9,
|
||||
editable: true,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: 'editable-cell-line',
|
||||
cellClassRules: {
|
||||
@ -171,7 +268,7 @@ const columnDefs: ColDef<FeeRow>[] = [
|
||||
flex: 1,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
||||
editable: true,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: formatEditableQuantity,
|
||||
cellClassRules: {
|
||||
@ -185,7 +282,7 @@ const columnDefs: ColDef<FeeRow>[] = [
|
||||
flex: 1.1,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
||||
editable: true,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
|
||||
valueFormatter: formatEditableUnitPrice,
|
||||
cellClassRules: {
|
||||
@ -207,7 +304,7 @@ const columnDefs: ColDef<FeeRow>[] = [
|
||||
field: 'remark',
|
||||
minWidth: 170,
|
||||
flex: 2,
|
||||
editable: true,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
@ -217,6 +314,47 @@ const columnDefs: ColDef<FeeRow>[] = [
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '操作',
|
||||
field: 'actions',
|
||||
minWidth: 92,
|
||||
maxWidth: 110,
|
||||
flex: 0.8,
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
suppressMovable: true,
|
||||
cellRenderer: defineComponent({
|
||||
name: 'HtFeeGridActionCellRenderer',
|
||||
props: {
|
||||
params: {
|
||||
type: Object as PropType<ICellRendererParams<FeeRow>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(rendererProps) {
|
||||
return () => {
|
||||
const row = rendererProps.params.data
|
||||
if (!row || isSubtotalRow(row)) return null
|
||||
const onDelete = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
requestDeleteRow(row.id, row.feeItem)
|
||||
}
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class:
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
|
||||
onClick: onDelete
|
||||
},
|
||||
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')]
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
@ -233,7 +371,7 @@ const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
syncComputedValuesToRows()
|
||||
gridApi.value?.refreshCells({ columns: ['budgetFee'], force: true })
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
@ -267,6 +405,7 @@ onBeforeUnmount(() => {
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||
<Button type="button" variant="outline" size="sm" @click="addRow">添加行</Button>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
@ -292,4 +431,24 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||
<AlertDialogTitle class="text-base font-semibold">确认删除行</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
将删除“{{ pendingDeleteRowName }}”这条明细,是否继续?
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline">取消</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="confirmDeleteRow">确认删除</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
@ -10,6 +10,17 @@ import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { Pencil, Eraser } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle
|
||||
} from 'reka-ui'
|
||||
|
||||
interface FeeMethodRow {
|
||||
id: string
|
||||
@ -25,6 +36,42 @@ interface FeeMethodState {
|
||||
detailRows: FeeMethodRow[]
|
||||
}
|
||||
|
||||
interface MethodRateState {
|
||||
rate?: unknown
|
||||
budgetFee?: unknown
|
||||
}
|
||||
|
||||
interface MethodHourlyRowLike {
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
serviceBudget?: unknown
|
||||
}
|
||||
|
||||
interface MethodHourlyState {
|
||||
detailRows?: MethodHourlyRowLike[]
|
||||
}
|
||||
|
||||
interface MethodQuantityRowLike {
|
||||
id?: unknown
|
||||
budgetFee?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
}
|
||||
|
||||
interface MethodQuantityState {
|
||||
detailRows?: MethodQuantityRowLike[]
|
||||
}
|
||||
|
||||
interface ZxFwRowLike {
|
||||
id?: unknown
|
||||
subtotal?: unknown
|
||||
}
|
||||
|
||||
interface ZxFwStateLike {
|
||||
detailRows?: ZxFwRowLike[]
|
||||
}
|
||||
|
||||
interface LegacyFeeRow {
|
||||
id?: string
|
||||
feeItem?: string
|
||||
@ -36,10 +83,12 @@ interface LegacyFeeRow {
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
storageKey: string
|
||||
readonly?: boolean
|
||||
contractId?: string
|
||||
contractName?: string
|
||||
fixedNames?: string[]
|
||||
}>()
|
||||
const tabStore = useTabStore()
|
||||
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
|
||||
|
||||
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
const createDefaultRow = (name = ''): FeeMethodRow => ({
|
||||
@ -56,8 +105,115 @@ const toFinite = (value: number | null | undefined) =>
|
||||
const round3 = (value: number) => Number(value.toFixed(3))
|
||||
const getRowSubtotal = (row: FeeMethodRow | null | undefined) =>
|
||||
row ? round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee)) : null
|
||||
const toFiniteUnknown = (value: unknown): number | null => {
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
const buildMethodStorageKey = (
|
||||
rowId: string,
|
||||
method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee'
|
||||
) => `${props.storageKey}-${rowId}-${method}`
|
||||
|
||||
const loadContractServiceFeeBase = async (): Promise<number | null> => {
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (!contractId) return null
|
||||
try {
|
||||
const data = await localforage.getItem<ZxFwStateLike>(`zxFW-${contractId}`)
|
||||
const rows = Array.isArray(data?.detailRows) ? data.detailRows : []
|
||||
const fixedRow = rows.find(row => String(row?.id || '') === 'fixed-budget-c')
|
||||
const fixedSubtotal = toFiniteUnknown(fixedRow?.subtotal)
|
||||
if (fixedSubtotal != null) return round3(fixedSubtotal)
|
||||
const sum = rows.reduce((acc, row) => {
|
||||
if (String(row?.id || '') === 'fixed-budget-c') return acc
|
||||
const subtotal = toFiniteUnknown(row?.subtotal)
|
||||
return subtotal == null ? acc : acc + subtotal
|
||||
}, 0)
|
||||
return round3(sum)
|
||||
} catch (error) {
|
||||
console.error('loadContractServiceFeeBase failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
|
||||
if (rows.length === 0) return null
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
|
||||
const rowBudget = toFiniteUnknown(row?.serviceBudget)
|
||||
if (rowBudget != null) {
|
||||
total += rowBudget
|
||||
continue
|
||||
}
|
||||
const adopted = toFiniteUnknown(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteUnknown(row?.personnelCount)
|
||||
const workday = toFiniteUnknown(row?.workdayCount)
|
||||
|
||||
if (adopted == null || personnel == null || workday == null) continue
|
||||
total += adopted * personnel * workday
|
||||
}
|
||||
return round3(total)
|
||||
}
|
||||
|
||||
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
if (rows.length === 0) return null
|
||||
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
|
||||
const subtotalBudget = toFiniteUnknown(subtotalRow?.budgetFee)
|
||||
if (subtotalBudget != null) return round3(subtotalBudget)
|
||||
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFiniteUnknown(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
continue
|
||||
}
|
||||
const quantity = toFiniteUnknown(row?.quantity)
|
||||
const unitPrice = toFiniteUnknown(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
}
|
||||
return round3(total)
|
||||
}
|
||||
|
||||
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return rows
|
||||
const contractBase = await loadContractServiceFeeBase()
|
||||
const hydratedRows = await Promise.all(
|
||||
rows.map(async row => {
|
||||
if (!row?.id) return row
|
||||
const [rateData, hourlyData, quantityData] = await Promise.all([
|
||||
localforage.getItem<MethodRateState>(buildMethodStorageKey(row.id, 'rate-fee')),
|
||||
localforage.getItem<MethodHourlyState>(buildMethodStorageKey(row.id, 'hourly-fee')),
|
||||
localforage.getItem<MethodQuantityState>(buildMethodStorageKey(row.id, 'quantity-unit-price-fee'))
|
||||
])
|
||||
|
||||
const storedRateFee = toFiniteUnknown(rateData?.budgetFee)
|
||||
const rateValue = toFiniteUnknown(rateData?.rate)
|
||||
const rateFee =
|
||||
storedRateFee != null
|
||||
? round3(storedRateFee)
|
||||
: contractBase != null && rateValue != null
|
||||
? round3(contractBase * rateValue)
|
||||
: null
|
||||
const hourlyFee = sumHourlyMethodFee(hourlyData)
|
||||
const quantityUnitPriceFee = sumQuantityMethodFee(quantityData)
|
||||
|
||||
return {
|
||||
...row,
|
||||
rateFee,
|
||||
hourlyFee,
|
||||
quantityUnitPriceFee
|
||||
}
|
||||
})
|
||||
)
|
||||
return hydratedRows
|
||||
}
|
||||
|
||||
const isReadonly = computed(() => props.readonly === true)
|
||||
const fixedNames = computed(() =>
|
||||
Array.isArray(props.fixedNames)
|
||||
? props.fixedNames.map(item => String(item || '').trim()).filter(Boolean)
|
||||
@ -91,19 +247,41 @@ const summaryRow = computed<FeeMethodRow>(() => {
|
||||
})
|
||||
const displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value])
|
||||
const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
|
||||
const clearConfirmOpen = ref(false)
|
||||
const pendingClearRowId = ref<string | null>(null)
|
||||
const pendingClearRowName = ref('')
|
||||
|
||||
const requestClearRow = (id: string, name?: string) => {
|
||||
pendingClearRowId.value = id
|
||||
pendingClearRowName.value = String(name || '').trim() || '当前行'
|
||||
clearConfirmOpen.value = true
|
||||
}
|
||||
|
||||
const handleClearConfirmOpenChange = (open: boolean) => {
|
||||
clearConfirmOpen.value = open
|
||||
}
|
||||
|
||||
const confirmClearRow = async () => {
|
||||
const id = pendingClearRowId.value
|
||||
if (!id) return
|
||||
await clearRow(id)
|
||||
clearConfirmOpen.value = false
|
||||
pendingClearRowId.value = null
|
||||
pendingClearRowName.value = ''
|
||||
}
|
||||
|
||||
const formatEditableText = (params: any) => {
|
||||
if (params.value == null || params.value === '') {
|
||||
if (params.context?.readonly === true || isSummaryRow(params.data)) return ''
|
||||
return '点击输入'
|
||||
if (isSummaryRow(params.data)) return ''
|
||||
return ''
|
||||
}
|
||||
return String(params.value)
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (params.value == null || params.value === '') {
|
||||
if (params.context?.readonly === true || isSummaryRow(params.data)) return ''
|
||||
return '点击输入'
|
||||
if (isSummaryRow(params.data)) return ''
|
||||
return ''
|
||||
}
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
@ -175,22 +353,33 @@ const saveToIndexedDB = async () => {
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
|
||||
const data = await localforage.getItem<FeeMethodState>(props.storageKey)
|
||||
detailRows.value = mergeWithStoredRows(data?.detailRows)
|
||||
|
||||
const mergedRows = mergeWithStoredRows(data?.detailRows)
|
||||
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
|
||||
await saveToIndexedDB()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = mergeWithStoredRows([])
|
||||
const mergedRows = mergeWithStoredRows([])
|
||||
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
|
||||
await saveToIndexedDB()
|
||||
}
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
if (isReadonly.value) return
|
||||
detailRows.value = [...detailRows.value, createDefaultRow()]
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const clearRow = (id: string) => {
|
||||
if (isReadonly.value) return
|
||||
const clearRow = async (id: string) => {
|
||||
tabStore.removeTab(`ht-fee-edit-${props.storageKey}-${id}`)
|
||||
await nextTick()
|
||||
await Promise.all([
|
||||
localforage.removeItem(buildMethodStorageKey(id, 'rate-fee')),
|
||||
localforage.removeItem(buildMethodStorageKey(id, 'hourly-fee')),
|
||||
localforage.removeItem(buildMethodStorageKey(id, 'quantity-unit-price-fee'))
|
||||
])
|
||||
detailRows.value = detailRows.value.map(row =>
|
||||
row.id !== id
|
||||
? row
|
||||
@ -201,11 +390,10 @@ const clearRow = (id: string) => {
|
||||
quantityUnitPriceFee: null
|
||||
}
|
||||
)
|
||||
void saveToIndexedDB()
|
||||
await saveToIndexedDB()
|
||||
}
|
||||
|
||||
const editRow = (id: string) => {
|
||||
if (isReadonly.value) return
|
||||
const row = detailRows.value.find(item => item.id === id)
|
||||
if (!row) return
|
||||
tabStore.openTab({
|
||||
@ -216,7 +404,9 @@ const editRow = (id: string) => {
|
||||
sourceTitle: props.title,
|
||||
storageKey: props.storageKey,
|
||||
rowId: id,
|
||||
rowName: row.name || ''
|
||||
rowName: row.name || '',
|
||||
contractId: props.contractId,
|
||||
contractName: props.contractName
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -232,26 +422,36 @@ const ActionCellRenderer = defineComponent({
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
|
||||
const disabled = props.params.context?.readonly === true
|
||||
const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const rowId = props.params.data?.id
|
||||
if (!rowId) return
|
||||
if (action === 'edit') {
|
||||
props.params.context?.onActionEdit?.(rowId)
|
||||
return
|
||||
}
|
||||
void props.params.context?.onActionRequestClear?.(rowId, String(props.params.data?.name || ''))
|
||||
}
|
||||
return h('div', { class: 'zxfw-action-wrap' }, [
|
||||
h('div', { class: 'zxfw-action-group' }, [
|
||||
h('button', {
|
||||
class: ['zxfw-action-btn', disabled ? 'zxfw-action-btn--disabled' : ''],
|
||||
class: 'zxfw-action-btn',
|
||||
'data-action': 'edit',
|
||||
type: 'button',
|
||||
disabled
|
||||
onClick: onActionClick('edit')
|
||||
}, [
|
||||
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
|
||||
h('span', '编辑')
|
||||
]),
|
||||
h('button', {
|
||||
class: ['zxfw-action-btn', disabled ? 'zxfw-action-btn--disabled' : ''],
|
||||
class: 'zxfw-action-btn zxfw-action-btn--danger',
|
||||
'data-action': 'clear',
|
||||
type: 'button',
|
||||
disabled
|
||||
onClick: onActionClick('clear')
|
||||
}, [
|
||||
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
|
||||
h('span', '恢复默认')
|
||||
h('span', '清空')
|
||||
])
|
||||
])
|
||||
])
|
||||
@ -265,11 +465,10 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
||||
field: 'name',
|
||||
minWidth: 180,
|
||||
flex: 1.8,
|
||||
editable: params =>
|
||||
!(params.context?.readonly === true || params.context?.fixedNames === true || isSummaryRow(params.data)),
|
||||
editable: false,
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: params =>
|
||||
params.context?.readonly === true || params.context?.fixedNames === true || isSummaryRow(params.data)
|
||||
params.context?.fixedNames === true || isSummaryRow(params.data)
|
||||
? ''
|
||||
: 'editable-cell-line',
|
||||
cellClassRules: {
|
||||
@ -281,12 +480,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
||||
field: 'rateFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data),
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: params =>
|
||||
params.context?.readonly === true
|
||||
? 'ag-right-aligned-cell'
|
||||
: 'ag-right-aligned-cell editable-cell-line',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
@ -298,12 +494,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
||||
field: 'hourlyFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data),
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: params =>
|
||||
params.context?.readonly === true
|
||||
? 'ag-right-aligned-cell'
|
||||
: 'ag-right-aligned-cell editable-cell-line',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
@ -315,12 +508,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
||||
field: 'quantityUnitPriceFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data),
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: params =>
|
||||
params.context?.readonly === true
|
||||
? 'ag-right-aligned-cell'
|
||||
: 'ag-right-aligned-cell editable-cell-line',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
@ -357,12 +547,13 @@ const detailGridOptions: GridOptions<FeeMethodRow> = {
|
||||
treeData: false,
|
||||
getDataPath: undefined,
|
||||
context: {
|
||||
readonly: isReadonly.value,
|
||||
fixedNames: hasFixedNames.value
|
||||
fixedNames: hasFixedNames.value,
|
||||
onActionEdit: editRow,
|
||||
onActionClear: clearRow,
|
||||
onActionRequestClear: requestClearRow
|
||||
},
|
||||
onCellClicked: params => {
|
||||
if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(params.data)) return
|
||||
if (params.context?.readonly === true) return
|
||||
const target = params.event?.target as HTMLElement | null
|
||||
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
|
||||
const action = btn?.dataset.action
|
||||
@ -371,7 +562,7 @@ const detailGridOptions: GridOptions<FeeMethodRow> = {
|
||||
return
|
||||
}
|
||||
if (action === 'clear') {
|
||||
clearRow(params.data.id)
|
||||
requestClearRow(params.data.id, params.data.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -384,7 +575,6 @@ const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
|
||||
}
|
||||
|
||||
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
||||
if (isReadonly.value) return
|
||||
if (isSummaryRow(event.data)) return
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
@ -405,10 +595,23 @@ watch(storageKeyRef, () => {
|
||||
void loadFromIndexedDB()
|
||||
})
|
||||
|
||||
watch([isReadonly, hasFixedNames], () => {
|
||||
watch(
|
||||
() => htFeeMethodReloadStore.seq,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
const detail = htFeeMethodReloadStore.lastEvent
|
||||
if (!detail) return
|
||||
if (String(detail.mainStorageKey || '').trim() !== String(props.storageKey || '').trim()) return
|
||||
void loadFromIndexedDB()
|
||||
}
|
||||
)
|
||||
|
||||
watch([hasFixedNames], () => {
|
||||
if (!detailGridOptions.context) return
|
||||
detailGridOptions.context.readonly = isReadonly.value
|
||||
detailGridOptions.context.fixedNames = hasFixedNames.value
|
||||
detailGridOptions.context.onActionEdit = editRow
|
||||
detailGridOptions.context.onActionClear = clearRow
|
||||
detailGridOptions.context.onActionRequestClear = requestClearRow
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
})
|
||||
|
||||
@ -424,7 +627,7 @@ onBeforeUnmount(() => {
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||
<Button v-if="!isReadonly" type="button" variant="outline" size="sm" @click="addRow">新增</Button>
|
||||
<Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">新增</Button>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
@ -450,11 +653,24 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zxfw-action-btn--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
<AlertDialogRoot :open="clearConfirmOpen" @update:open="handleClearConfirmOpenChange">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||
<AlertDialogTitle class="text-base font-semibold">确认清空</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
将清空“{{ pendingClearRowName }}”及其编辑页面的可填和自动计算数据,是否继续?
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline">取消</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="confirmClearRow">确认清空</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
|
||||
@ -5,6 +5,7 @@ import { additionalWorkList } from '@/sql'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
contractName?: string
|
||||
}>()
|
||||
|
||||
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
|
||||
@ -15,7 +16,8 @@ const additionalWorkNames = computed(() => additionalWorkList.map(item => String
|
||||
<HtFeeMethodGrid
|
||||
title="附加工作费"
|
||||
:storageKey="STORAGE_KEY"
|
||||
:readonly="true"
|
||||
:contract-id="props.contractId"
|
||||
:contract-name="props.contractName"
|
||||
:fixed-names="additionalWorkNames"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<TypeLine
|
||||
scene="ht-fee-method-type-line"
|
||||
:title="`${sourceTitleText}:${rowNameText}`"
|
||||
:subtitle="`明细ID:${props.rowId}`"
|
||||
:copy-text="props.rowId"
|
||||
:title="titleText"
|
||||
:subtitle="`合同ID:${contractIdText}`"
|
||||
:copy-text="contractIdText"
|
||||
:storage-key="activeTypeStorageKey"
|
||||
default-category="rate-fee"
|
||||
:categories="categories"
|
||||
@ -13,6 +13,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, markRaw, type Component } from 'vue'
|
||||
import TypeLine from '@/layout/typeLine.vue'
|
||||
import HtFeeGrid from '@/components/common/HtFeeGrid.vue'
|
||||
import HtFeeRateMethodForm from '@/components/views/HtFeeRateMethodForm.vue'
|
||||
import HourlyFeeGrid from '@/components/common/HourlyFeeGrid.vue'
|
||||
|
||||
interface TypeLineCategoryItem {
|
||||
key: string
|
||||
@ -25,31 +28,70 @@ const props = defineProps<{
|
||||
storageKey: string
|
||||
rowId: string
|
||||
rowName?: string
|
||||
contractId?: string
|
||||
contractName?: string
|
||||
}>()
|
||||
|
||||
const sourceTitleText = computed(() => props.sourceTitle || '费用明细')
|
||||
const rowNameText = computed(() => props.rowName || '未命名')
|
||||
const contractIdText = computed(() => String(props.contractId || '').trim())
|
||||
const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-')
|
||||
const titleText = computed(() => `合同段:${contractNameText.value} · ${rowNameText.value || sourceTitleText.value}`)
|
||||
const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${props.rowId}`)
|
||||
const buildMethodStorageKey = (method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee') =>
|
||||
`${props.storageKey}-${props.rowId}-${method}`
|
||||
|
||||
const createMethodPane = (methodLabel: string, key: string) =>
|
||||
markRaw(
|
||||
defineComponent({
|
||||
name: `HtFeeMethodTypePane-${key}`,
|
||||
setup() {
|
||||
return () =>
|
||||
h('div', { class: 'h-full min-h-0 flex flex-col' }, [
|
||||
h('div', { class: 'rounded-lg border bg-card p-4' }, [
|
||||
h('h3', { class: 'text-sm font-semibold text-foreground' }, methodLabel),
|
||||
h('p', { class: 'mt-2 text-xs text-muted-foreground' }, `当前编辑类型:${methodLabel}`)
|
||||
])
|
||||
])
|
||||
}
|
||||
})
|
||||
)
|
||||
const quantityUnitPricePane = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtFeeGrid',
|
||||
setup() {
|
||||
const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee'))
|
||||
return () =>
|
||||
h(HtFeeGrid, {
|
||||
title: '数量单价',
|
||||
storageKey: quantityStorageKey.value,
|
||||
syncMainStorageKey: props.storageKey,
|
||||
syncRowId: props.rowId
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const rateFeePane = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtFeeMethodTypePane-rate-fee',
|
||||
setup() {
|
||||
const rateStorageKey = computed(() => buildMethodStorageKey('rate-fee'))
|
||||
return () =>
|
||||
h(HtFeeRateMethodForm, {
|
||||
storageKey: rateStorageKey.value,
|
||||
contractId: props.contractId,
|
||||
syncMainStorageKey: props.storageKey,
|
||||
syncRowId: props.rowId
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const hourlyFeePane = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtFeeMethodTypePane-hourly-fee',
|
||||
setup() {
|
||||
const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee'))
|
||||
return () =>
|
||||
h(HourlyFeeGrid, {
|
||||
title: '工时法明细',
|
||||
storageKey: hourlyStorageKey.value,
|
||||
syncMainStorageKey: props.storageKey,
|
||||
syncRowId: props.rowId
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const categories: TypeLineCategoryItem[] = [
|
||||
{ key: 'rate-fee', label: '费率计取', component: createMethodPane('费率计取', 'rate-fee') },
|
||||
{ key: 'hourly-fee', label: '工时法', component: createMethodPane('工时法', 'hourly-fee') },
|
||||
{ key: 'quantity-unit-price-fee', label: '数量单价', component: createMethodPane('数量单价', 'quantity-unit-price-fee') }
|
||||
{ key: 'rate-fee', label: '费率计取', component: rateFeePane },
|
||||
{ key: 'hourly-fee', label: '工时法', component: hourlyFeePane },
|
||||
{ key: 'quantity-unit-price-fee', label: '数量单价', component: quantityUnitPricePane }
|
||||
]
|
||||
</script>
|
||||
|
||||
223
src/components/views/HtFeeRateMethodForm.vue
Normal file
223
src/components/views/HtFeeRateMethodForm.vue
Normal file
@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import localforage from 'localforage'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
|
||||
|
||||
interface ZxFwRowLike {
|
||||
id?: unknown
|
||||
subtotal?: unknown
|
||||
}
|
||||
|
||||
interface ZxFwStateLike {
|
||||
detailRows?: ZxFwRowLike[]
|
||||
}
|
||||
|
||||
interface RateMethodState {
|
||||
rate: number | null
|
||||
budgetFee?: number | null
|
||||
remark: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
storageKey: string
|
||||
contractId?: string
|
||||
syncMainStorageKey?: string
|
||||
syncRowId?: string
|
||||
}>()
|
||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
|
||||
|
||||
const base = ref<number | null>(null)
|
||||
const rate = ref<number | null>(null)
|
||||
const remark = ref('')
|
||||
const rateInput = ref('')
|
||||
|
||||
const toFinite = (value: unknown): number | null => {
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
|
||||
const round3 = (value: number) => Number(value.toFixed(3))
|
||||
const budgetFee = computed<number | null>(() => {
|
||||
if (base.value == null || rate.value == null) return null
|
||||
return round3(base.value * rate.value)
|
||||
})
|
||||
|
||||
const formatAmount = (value: number | null) =>
|
||||
value == null ? '' : formatThousandsFlexible(value, 3)
|
||||
|
||||
const loadBase = async () => {
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (!contractId) {
|
||||
base.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await localforage.getItem<ZxFwStateLike>(`zxFW-${contractId}`)
|
||||
const rows = Array.isArray(data?.detailRows) ? data.detailRows : []
|
||||
const fixedRow = rows.find(row => String(row?.id || '') === 'fixed-budget-c')
|
||||
const fixedSubtotal = toFinite(fixedRow?.subtotal)
|
||||
if (fixedSubtotal != null) {
|
||||
base.value = round3(fixedSubtotal)
|
||||
return
|
||||
}
|
||||
const sum = rows.reduce((acc, row) => {
|
||||
if (String(row?.id || '') === 'fixed-budget-c') return acc
|
||||
const subtotal = toFinite(row?.subtotal)
|
||||
return subtotal == null ? acc : acc + subtotal
|
||||
}, 0)
|
||||
base.value = round3(sum)
|
||||
} catch (error) {
|
||||
console.error('load rate base failed:', error)
|
||||
base.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const loadForm = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<RateMethodState>(props.storageKey)
|
||||
rate.value = typeof data?.rate === 'number' ? data.rate : null
|
||||
remark.value = typeof data?.remark === 'string' ? data.remark : ''
|
||||
rateInput.value = rate.value == null ? '' : String(rate.value)
|
||||
} catch (error) {
|
||||
console.error('load rate form failed:', error)
|
||||
rate.value = null
|
||||
remark.value = ''
|
||||
rateInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const saveForm = async () => {
|
||||
try {
|
||||
await localforage.setItem<RateMethodState>(props.storageKey, {
|
||||
rate: rate.value,
|
||||
budgetFee: budgetFee.value,
|
||||
remark: remark.value
|
||||
})
|
||||
if (props.syncMainStorageKey && props.syncRowId) {
|
||||
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('save rate form failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const applyRateInput = () => {
|
||||
const next = parseNumberOrNull(rateInput.value, { sanitize: true, precision: 3 })
|
||||
rate.value = next
|
||||
rateInput.value = next == null ? '' : String(next)
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let basePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
watch([rate, remark, budgetFee], () => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
void saveForm()
|
||||
}, 250)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
() => {
|
||||
void loadForm()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => pricingPaneReloadStore.seq,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (!contractId) return
|
||||
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, contractId, ZXFW_RELOAD_SERVICE_KEY)) return
|
||||
void loadBase()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadBase(), loadForm()])
|
||||
if (!basePollTimer) {
|
||||
basePollTimer = setInterval(() => {
|
||||
void loadBase()
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
await Promise.all([loadBase(), loadForm()])
|
||||
if (!basePollTimer) {
|
||||
basePollTimer = setInterval(() => {
|
||||
void loadBase()
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
if (basePollTimer) {
|
||||
clearInterval(basePollTimer)
|
||||
basePollTimer = null
|
||||
}
|
||||
void saveForm()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card p-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label class="space-y-1.5">
|
||||
<div class="text-xs text-muted-foreground">基数(所有服务费预算合计)</div>
|
||||
<input
|
||||
type="text"
|
||||
:value="formatAmount(base)"
|
||||
readonly
|
||||
disabled
|
||||
tabindex="-1"
|
||||
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1.5">
|
||||
<div class="text-xs text-muted-foreground">费率(可编辑,三位小数)</div>
|
||||
<input
|
||||
v-model="rateInput"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
placeholder="请输入费率,建议0.01 ~ 0.05"
|
||||
class="h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
|
||||
@blur="applyRateInput"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1.5">
|
||||
<div class="text-xs text-muted-foreground">预算费用(自动计算)</div>
|
||||
<input
|
||||
type="text"
|
||||
:value="formatAmount(budgetFee)"
|
||||
readonly
|
||||
disabled
|
||||
tabindex="-1"
|
||||
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1.5 md:col-span-2">
|
||||
<div class="text-xs text-muted-foreground">说明</div>
|
||||
<textarea
|
||||
v-model="remark"
|
||||
rows="4"
|
||||
placeholder="请输入说明"
|
||||
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -10,5 +10,5 @@ const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HtFeeMethodGrid title="预备费" :storageKey="STORAGE_KEY" />
|
||||
<HtFeeMethodGrid title="预备费" :storageKey="STORAGE_KEY" :contract-id="props.contractId" />
|
||||
</template>
|
||||
|
||||
@ -99,7 +99,7 @@ const additionalWorkFeeView = markRaw(
|
||||
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtAdditionalWorkFee, { contractId: props.contractId });
|
||||
return () => h(AsyncHtAdditionalWorkFee, { contractId: props.contractId, contractName: props.contractName });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@ -1,495 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { expertList } from '@/sql'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
expertCode: string
|
||||
expertName: string
|
||||
laborBudgetUnitPrice: string
|
||||
compositeBudgetUnitPrice: string
|
||||
adoptedBudgetUnitPrice: number | null
|
||||
personnelCount: number | null
|
||||
workdayCount: number | null
|
||||
serviceBudget: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface XmInfoState {
|
||||
projectName: string
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
import { computed } from 'vue'
|
||||
import HourlyFeeGrid from '@/components/common/HourlyFeeGrid.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string,
|
||||
|
||||
contractId: string
|
||||
serviceId: string | number
|
||||
}>()
|
||||
|
||||
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
|
||||
const shouldSkipPersist = () => {
|
||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
|
||||
if (raw.includes(':')) {
|
||||
const [issuedRaw, untilRaw] = raw.split(':')
|
||||
const issuedAt = Number(issuedRaw)
|
||||
const skipUntil = Number(untilRaw)
|
||||
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
|
||||
return paneInstanceCreatedAt <= issuedAt
|
||||
}
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const skipUntil = Number(raw)
|
||||
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const shouldForceDefaultLoad = () => {
|
||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
|
||||
}
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
|
||||
type ExpertLite = {
|
||||
code: string
|
||||
name: string
|
||||
maxPrice: number | null
|
||||
minPrice: number | null
|
||||
defPrice: number | null
|
||||
manageCoe: number | null
|
||||
}
|
||||
|
||||
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, ExpertLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const formatPriceRange = (min: number | null, max: number | null) => {
|
||||
const hasMin = typeof min === 'number' && Number.isFinite(min)
|
||||
const hasMax = typeof max === 'number' && Number.isFinite(max)
|
||||
if (hasMin && hasMax) return `${min}-${max}`
|
||||
if (hasMin) return String(min)
|
||||
if (hasMax) return String(max)
|
||||
return ''
|
||||
}
|
||||
|
||||
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
|
||||
if (
|
||||
typeof expert.manageCoe !== 'number' ||
|
||||
!Number.isFinite(expert.manageCoe)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
const min = typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
|
||||
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
|
||||
: null
|
||||
const max = typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
|
||||
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
|
||||
: null
|
||||
return formatPriceRange(min, max)
|
||||
}
|
||||
|
||||
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
|
||||
if (
|
||||
typeof expert.defPrice !== 'number' ||
|
||||
!Number.isFinite(expert.defPrice) ||
|
||||
typeof expert.manageCoe !== 'number' ||
|
||||
!Number.isFinite(expert.manageCoe)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const [expertId, expert] of expertEntries) {
|
||||
const rowId = `expert-${expertId}`
|
||||
rows.push({
|
||||
id: rowId,
|
||||
expertCode: expert.code,
|
||||
expertName: expert.name,
|
||||
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
|
||||
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
|
||||
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
|
||||
personnelCount: null,
|
||||
workdayCount: null,
|
||||
serviceBudget: null,
|
||||
remark: '',
|
||||
path: [rowId]
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
return {
|
||||
...row,
|
||||
adoptedBudgetUnitPrice:
|
||||
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
|
||||
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
|
||||
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
|
||||
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseNonNegativeIntegerOrNull = (value: unknown) => {
|
||||
if (value === '' || value == null) return null
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) && value >= 0 ? value : null
|
||||
}
|
||||
const normalized = String(value).trim()
|
||||
if (!/^\d+$/.test(normalized)) return null
|
||||
const v = Number(normalized)
|
||||
return Number.isSafeInteger(v) ? v : null
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const formatEditableInteger = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return String(Number(params.value))
|
||||
}
|
||||
|
||||
const calcServiceBudget = (row: DetailRow | undefined) => {
|
||||
const adopted = row?.adoptedBudgetUnitPrice
|
||||
const personnel = row?.personnelCount
|
||||
const workday = row?.workdayCount
|
||||
if (
|
||||
typeof adopted !== 'number' ||
|
||||
!Number.isFinite(adopted) ||
|
||||
typeof personnel !== 'number' ||
|
||||
!Number.isFinite(personnel) ||
|
||||
typeof workday !== 'number' ||
|
||||
!Number.isFinite(workday)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
|
||||
}
|
||||
|
||||
const editableNumberCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: formatEditableNumber,
|
||||
...extra
|
||||
})
|
||||
|
||||
const editableMoneyCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
},
|
||||
...extra
|
||||
})
|
||||
|
||||
const readonlyTextCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
editable: false,
|
||||
valueFormatter: params => params.value || '',
|
||||
...extra
|
||||
})
|
||||
|
||||
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
||||
{
|
||||
headerName: '编码',
|
||||
field: 'expertCode',
|
||||
minWidth: 120,
|
||||
width: 140,
|
||||
pinned: 'left',
|
||||
colSpan: params => (params.node?.rowPinned ? 2 : 1),
|
||||
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: '人员名称',
|
||||
field: 'expertName',
|
||||
minWidth: 200,
|
||||
width: 220,
|
||||
pinned: 'left',
|
||||
tooltipField: 'expertName',
|
||||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: '预算参考单价',
|
||||
marryChildren: true,
|
||||
children: [
|
||||
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
|
||||
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
|
||||
]
|
||||
},
|
||||
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
|
||||
editableNumberCol('personnelCount', '人员数量(人)', {
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
|
||||
valueFormatter: formatEditableInteger
|
||||
}),
|
||||
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
|
||||
{
|
||||
headerName: '服务预算(元)',
|
||||
field: 'serviceBudget',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
editable: false,
|
||||
aggFunc: decimalAggSum,
|
||||
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
|
||||
valueFormatter: params => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '说明',
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
||||
|
||||
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
||||
|
||||
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => calcServiceBudget(row)))
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
expertCode: '总合计',
|
||||
expertName: '',
|
||||
laborBudgetUnitPrice: '',
|
||||
compositeBudgetUnitPrice: '',
|
||||
adoptedBudgetUnitPrice: null,
|
||||
personnelCount: totalPersonnelCount.value,
|
||||
workdayCount: totalWorkdayCount.value,
|
||||
serviceBudget: totalServiceBudget.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
if (shouldSkipPersist()) return
|
||||
try {
|
||||
const payload = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
console.log('Saving to IndexedDB:', payload)
|
||||
await localforage.setItem(DB_KEY.value, payload)
|
||||
const synced = await syncPricingTotalToZxFw({
|
||||
contractId: props.contractId,
|
||||
serviceId: props.serviceId,
|
||||
field: 'hourly',
|
||||
value: totalServiceBudget.value
|
||||
})
|
||||
if (synced) {
|
||||
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
if (shouldForceDefaultLoad()) {
|
||||
detailRows.value = buildDefaultRows()
|
||||
return
|
||||
}
|
||||
|
||||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
void loadFromIndexedDB()
|
||||
}
|
||||
)
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const handleGridReady = (params: any) => {
|
||||
const w = window as any
|
||||
if (!w.__agGridApis) w.__agGridApis = {}
|
||||
w.__agGridApis = params.api
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">工时法明细</h3>
|
||||
<div class="text-xs text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="myTheme" :treeData="false"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HourlyFeeGrid
|
||||
title="工时法明细"
|
||||
:storage-key="DB_KEY"
|
||||
:contract-id="props.contractId"
|
||||
:service-id="props.serviceId"
|
||||
:enable-zx-fw-sync="true"
|
||||
sync-field="hourly"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
@ -93,6 +93,7 @@ const props = defineProps<{
|
||||
contractId: string,
|
||||
serviceId: string | number
|
||||
}>()
|
||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
|
||||
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
||||
@ -101,11 +102,11 @@ const BASE_INFO_KEY = 'xm-base-info-v1'
|
||||
const activeIndustryCode = ref('')
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||
const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
||||
let factorDefaultsLoaded = false
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
const reloadSignal = ref(0)
|
||||
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
|
||||
const industryNameMap = new Map(
|
||||
industryTypeList.flatMap(item => [
|
||||
@ -1026,7 +1027,7 @@ const saveToIndexedDB = async () => {
|
||||
value: totalBudgetFee.value
|
||||
})
|
||||
if (synced) {
|
||||
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||
pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
@ -1224,7 +1225,16 @@ const clearAllData = async () => {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
|
||||
() => pricingPaneReloadStore.seq,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, props.serviceId)) return
|
||||
reloadSignal.value += 1
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => reloadSignal.value,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
void loadFromIndexedDB()
|
||||
|
||||
@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
@ -93,6 +93,7 @@ const props = defineProps<{
|
||||
contractId: string,
|
||||
serviceId: string | number
|
||||
}>()
|
||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
|
||||
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
||||
@ -101,11 +102,11 @@ const BASE_INFO_KEY = 'xm-base-info-v1'
|
||||
const activeIndustryCode = ref('')
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||
const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
||||
let factorDefaultsLoaded = false
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
const reloadSignal = ref(0)
|
||||
const industryNameMap = new Map(
|
||||
industryTypeList.flatMap(item => [
|
||||
[String(item.id).trim(), item.name],
|
||||
@ -116,6 +117,7 @@ const totalLabel = computed(() => {
|
||||
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || ''
|
||||
return industryName ? `${industryName}总投资` : '总投资'
|
||||
})
|
||||
|
||||
const isMutipleService = computed(() => {
|
||||
const service = getServiceDictItemById(props.serviceId) as ServiceLite | undefined
|
||||
return service?.mutiple === true
|
||||
@ -878,7 +880,7 @@ const saveToIndexedDB = async () => {
|
||||
value: totalBudgetFee.value
|
||||
})
|
||||
if (synced) {
|
||||
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||
pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
@ -1048,7 +1050,16 @@ const clearAllData = async () => {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
|
||||
() => pricingPaneReloadStore.seq,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, props.serviceId)) return
|
||||
reloadSignal.value += 1
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => reloadSignal.value,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
void loadFromIndexedDB()
|
||||
|
||||
@ -9,7 +9,7 @@ import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
|
||||
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
||||
|
||||
@ -42,14 +42,15 @@ const props = defineProps<{
|
||||
|
||||
serviceId: string | number
|
||||
}>()
|
||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
|
||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||
let factorDefaultsLoaded = false
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
const reloadSignal = ref(0)
|
||||
|
||||
const getDefaultConsultCategoryFactor = () =>
|
||||
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
|
||||
@ -236,7 +237,6 @@ const formatEditableNumber = (params: any) => {
|
||||
const spanRowsByTaskName = (params: any) => {
|
||||
const rowA = params?.nodeA?.data as DetailRow | undefined
|
||||
const rowB = params?.nodeB?.data as DetailRow | undefined
|
||||
// debugger
|
||||
if (!rowA || !rowB) return false
|
||||
if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false
|
||||
|
||||
@ -433,7 +433,7 @@ const saveToIndexedDB = async () => {
|
||||
value: totalServiceFee.value
|
||||
})
|
||||
if (synced) {
|
||||
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||
pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
@ -468,7 +468,16 @@ const loadFromIndexedDB = async () => {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
|
||||
() => pricingPaneReloadStore.seq,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, props.serviceId)) return
|
||||
reloadSignal.value += 1
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => reloadSignal.value,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
void loadFromIndexedDB()
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
type PricingMethodTotals
|
||||
} from '@/lib/pricingMethodTotals'
|
||||
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import { Pencil, Eraser, Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
@ -33,7 +34,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { getServiceDictEntries, isIndustryEnabledByType,getIndustryTypeValue } from '@/sql'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue'
|
||||
|
||||
interface ServiceItem {
|
||||
@ -85,6 +85,7 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const PRICING_CLEAR_SKIP_TTL_MS = 5000
|
||||
const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const
|
||||
const projectIndustry = ref('')
|
||||
const reloadSignal = ref(0)
|
||||
|
||||
type ServiceListItem = {
|
||||
code?: string
|
||||
@ -388,7 +389,7 @@ const clearPricingPaneValues = async (serviceId: string) => {
|
||||
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
|
||||
}
|
||||
await Promise.all(keys.map(key => localforage.removeItem(key)))
|
||||
pricingPaneReloadStore.markReload(props.contractId, serviceId)
|
||||
pricingPaneReloadStore.emit(props.contractId, serviceId)
|
||||
}
|
||||
|
||||
const clearRowValues = async (row: DetailRow) => {
|
||||
@ -492,8 +493,30 @@ const ActionCellRenderer = defineComponent({
|
||||
})
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
{ headerName: '编码', field: 'code', minWidth: 50, maxWidth: 100 },
|
||||
{ headerName: '名称', field: 'name', minWidth: 250, flex: 3, tooltipField: 'name' },
|
||||
{
|
||||
headerName: '编码',
|
||||
field: 'code',
|
||||
minWidth: 50,
|
||||
maxWidth: 100,
|
||||
valueGetter: params => {
|
||||
if (!params.data) return ''
|
||||
if (isFixedRow(params.data)) return '小计'
|
||||
return params.data.code
|
||||
},
|
||||
colSpan: params => (params.data && isFixedRow(params.data) ? 2 : 1)
|
||||
},
|
||||
{
|
||||
headerName: '名称',
|
||||
field: 'name',
|
||||
minWidth: 250,
|
||||
flex: 3,
|
||||
tooltipField: 'name',
|
||||
valueGetter: params => {
|
||||
if (!params.data) return ''
|
||||
if (isFixedRow(params.data)) return ''
|
||||
return params.data.name
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '投资规模法',
|
||||
field: 'investScale',
|
||||
@ -965,7 +988,16 @@ const loadProjectIndustry = async () => {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => pricingPaneReloadStore.getReloadVersion(props.contractId, ZXFW_RELOAD_SERVICE_KEY),
|
||||
() => pricingPaneReloadStore.seq,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, ZXFW_RELOAD_SERVICE_KEY)) return
|
||||
reloadSignal.value += 1
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => reloadSignal.value,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
void loadFromIndexedDB()
|
||||
|
||||
@ -1363,7 +1363,7 @@ watch(
|
||||
<ScrollArea :ref="setTabScrollAreaRef" type="auto"
|
||||
class="h-full tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
|
||||
<draggable v-model="tabsModel" item-key="id" tag="div"
|
||||
:class="['tab-strip-sortable h-[calc(3.49rem)] flex w-max gap-0', isTabDragging ? 'is-dragging' : '']"
|
||||
:class="['tab-strip-sortable h-[calc(3.50rem)] flex w-max gap-0', isTabDragging ? 'is-dragging' : '']"
|
||||
:animation="260" easing="cubic-bezier(0.22, 1, 0.36, 1)" ghost-class="tab-drag-ghost"
|
||||
chosen-class="tab-drag-chosen" drag-class="tab-drag-active" :move="canMoveTab" @start="handleTabDragStart"
|
||||
@end="handleTabDragEnd">
|
||||
|
||||
34
src/pinia/htFeeMethodReload.ts
Normal file
34
src/pinia/htFeeMethodReload.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface HtFeeMethodReloadDetail {
|
||||
mainStorageKey: string
|
||||
rowId: string
|
||||
at: number
|
||||
}
|
||||
|
||||
interface HtFeeMethodReloadState {
|
||||
seq: number
|
||||
lastEvent: HtFeeMethodReloadDetail | null
|
||||
}
|
||||
|
||||
export const useHtFeeMethodReloadStore = defineStore('htFeeMethodReload', {
|
||||
state: (): HtFeeMethodReloadState => ({
|
||||
seq: 0,
|
||||
lastEvent: null
|
||||
}),
|
||||
actions: {
|
||||
emit(mainStorageKey: string, rowId: string) {
|
||||
|
||||
const normalizedMainStorageKey = String(mainStorageKey || '').trim()
|
||||
const normalizedRowId = String(rowId || '').trim()
|
||||
if (!normalizedMainStorageKey || !normalizedRowId) return
|
||||
this.lastEvent = {
|
||||
mainStorageKey: normalizedMainStorageKey,
|
||||
rowId: normalizedRowId,
|
||||
at: Date.now()
|
||||
}
|
||||
this.seq += 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,28 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const buildReloadKey = (contractId: string, serviceId: string | number) =>
|
||||
`${contractId}::${String(serviceId)}`
|
||||
export interface PricingPaneReloadDetail {
|
||||
contractId: string
|
||||
serviceId: string
|
||||
at: number
|
||||
}
|
||||
|
||||
export const usePricingPaneReloadStore = defineStore('pricing-pane-reload', () => {
|
||||
const reloadVersionMap = ref<Record<string, number>>({})
|
||||
interface PricingPaneReloadState {
|
||||
seq: number
|
||||
lastEvent: PricingPaneReloadDetail | null
|
||||
}
|
||||
|
||||
const markReload = (contractId: string, serviceId: string | number) => {
|
||||
const key = buildReloadKey(contractId, serviceId)
|
||||
const current = reloadVersionMap.value[key] || 0
|
||||
reloadVersionMap.value = {
|
||||
...reloadVersionMap.value,
|
||||
[key]: current + 1
|
||||
const toKey = (value: string | number) => String(value)
|
||||
|
||||
export const usePricingPaneReloadStore = defineStore('pricingPaneReload', {
|
||||
state: (): PricingPaneReloadState => ({
|
||||
seq: 0,
|
||||
lastEvent: null
|
||||
}),
|
||||
actions: {
|
||||
emit(contractId: string, serviceId: string | number) {
|
||||
this.lastEvent = {
|
||||
contractId: toKey(contractId),
|
||||
serviceId: toKey(serviceId),
|
||||
at: Date.now()
|
||||
}
|
||||
this.seq += 1
|
||||
}
|
||||
}
|
||||
|
||||
const getReloadVersion = (contractId: string, serviceId: string | number) => {
|
||||
const key = buildReloadKey(contractId, serviceId)
|
||||
return reloadVersionMap.value[key] || 0
|
||||
}
|
||||
|
||||
return {
|
||||
markReload,
|
||||
getReloadVersion
|
||||
}
|
||||
})
|
||||
|
||||
export const matchPricingPaneReload = (
|
||||
detail: PricingPaneReloadDetail | null | undefined,
|
||||
contractId: string,
|
||||
serviceId: string | number
|
||||
) => {
|
||||
if (!detail) return false
|
||||
return detail.contractId === toKey(contractId) && detail.serviceId === toKey(serviceId)
|
||||
}
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingscalefee.ts","./src/lib/utils.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/pricingpanereload.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/common/htfeegrid.vue","./src/components/common/methodunavailablenotice.vue","./src/components/common/xmfactorgrid.vue","./src/components/common/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/ht.vue","./src/components/views/htadditionalworkfee.vue","./src/components/views/htconsultcategoryfactor.vue","./src/components/views/htmajorfactor.vue","./src/components/views/htreservefee.vue","./src/components/views/servicecheckboxselector.vue","./src/components/views/xmconsultcategoryfactor.vue","./src/components/views/xmmajorfactor.vue","./src/components/views/zxfwview.vue","./src/components/views/htcard.vue","./src/components/views/htinfo.vue","./src/components/views/info.vue","./src/components/views/xmcard.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
|
||||
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingscalefee.ts","./src/lib/utils.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/pricingpanereload.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/common/htfeegrid.vue","./src/components/common/htfeemethodgrid.vue","./src/components/common/methodunavailablenotice.vue","./src/components/common/xmfactorgrid.vue","./src/components/common/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/ht.vue","./src/components/views/htadditionalworkfee.vue","./src/components/views/htconsultcategoryfactor.vue","./src/components/views/htfeemethodtypelineview.vue","./src/components/views/htmajorfactor.vue","./src/components/views/htreservefee.vue","./src/components/views/servicecheckboxselector.vue","./src/components/views/xmconsultcategoryfactor.vue","./src/components/views/xmmajorfactor.vue","./src/components/views/zxfwview.vue","./src/components/views/htcard.vue","./src/components/views/htinfo.vue","./src/components/views/info.vue","./src/components/views/xmcard.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
|
||||
Loading…
x
Reference in New Issue
Block a user