fix bug
This commit is contained in:
parent
1910f15564
commit
5bb6609ef8
@ -186,367 +186,7 @@ function getBasicFeeFromScale(scaleValue, scaleType) {
|
|||||||
return res;
|
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 = {
|
let data2 = {
|
||||||
name: 'test001',
|
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">
|
<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 { 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 { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
import { parseNumberOrNull } from '@/lib/number'
|
||||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import { roundTo, toDecimal } from '@/lib/decimal'
|
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 {
|
interface FeeRow {
|
||||||
id: string
|
id: string
|
||||||
@ -17,6 +30,7 @@ interface FeeRow {
|
|||||||
unitPrice: number | null
|
unitPrice: number | null
|
||||||
budgetFee: number | null
|
budgetFee: number | null
|
||||||
remark: string
|
remark: string
|
||||||
|
actions?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeeGridState {
|
interface FeeGridState {
|
||||||
@ -26,9 +40,13 @@ interface FeeGridState {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string
|
title: string
|
||||||
storageKey: string
|
storageKey: string
|
||||||
|
syncMainStorageKey?: string
|
||||||
|
syncRowId?: string
|
||||||
}>()
|
}>()
|
||||||
|
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
|
||||||
|
|
||||||
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||||
|
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
|
||||||
|
|
||||||
const createDefaultRow = (): FeeRow => ({
|
const createDefaultRow = (): FeeRow => ({
|
||||||
id: createRowId(),
|
id: createRowId(),
|
||||||
@ -40,20 +58,77 @@ const createDefaultRow = (): FeeRow => ({
|
|||||||
remark: ''
|
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 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) => {
|
const formatEditableText = (params: any) => {
|
||||||
|
if (isSubtotalRow(params.data)) return ''
|
||||||
if (params.value == null || params.value === '') return '点击输入'
|
if (params.value == null || params.value === '') return '点击输入'
|
||||||
return String(params.value)
|
return String(params.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatEditableQuantity = (params: any) => {
|
const formatEditableQuantity = (params: any) => {
|
||||||
|
if (isSubtotalRow(params.data)) return ''
|
||||||
if (params.value == null || params.value === '') return '点击输入'
|
if (params.value == null || params.value === '') return '点击输入'
|
||||||
return formatThousandsFlexible(params.value, 3)
|
return formatThousandsFlexible(params.value, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatEditableUnitPrice = (params: any) => {
|
const formatEditableUnitPrice = (params: any) => {
|
||||||
|
if (isSubtotalRow(params.data)) return ''
|
||||||
if (params.value == null || params.value === '') return '点击输入'
|
if (params.value == null || params.value === '') return '点击输入'
|
||||||
return formatThousandsFlexible(params.value, 3)
|
return formatThousandsFlexible(params.value, 3)
|
||||||
}
|
}
|
||||||
@ -64,18 +139,32 @@ const formatReadonlyBudgetFee = (params: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const syncComputedValuesToRows = () => {
|
const syncComputedValuesToRows = () => {
|
||||||
|
let totalBudgetFee = 0
|
||||||
for (const row of detailRows.value) {
|
for (const row of detailRows.value) {
|
||||||
|
if (isSubtotalRow(row)) continue
|
||||||
if (row.quantity == null || row.unitPrice == null) {
|
if (row.quantity == null || row.unitPrice == null) {
|
||||||
row.budgetFee = null
|
row.budgetFee = null
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
row.budgetFee = roundTo(toDecimal(row.quantity).mul(row.unitPrice), 2)
|
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[] => {
|
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
|
||||||
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
|
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
|
||||||
return [createDefaultRow()]
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: FeeRow[] = rowsFromDb.map(item => {
|
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 = () => {
|
const buildPersistDetailRows = () => {
|
||||||
@ -104,7 +193,11 @@ const saveToIndexedDB = async () => {
|
|||||||
const payload: FeeGridState = {
|
const payload: FeeGridState = {
|
||||||
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
||||||
}
|
}
|
||||||
|
|
||||||
await localforage.setItem(props.storageKey, payload)
|
await localforage.setItem(props.storageKey, payload)
|
||||||
|
if (props.syncMainStorageKey && props.syncRowId) {
|
||||||
|
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
}
|
}
|
||||||
@ -117,7 +210,7 @@ const loadFromIndexedDB = async () => {
|
|||||||
syncComputedValuesToRows()
|
syncComputedValuesToRows()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('loadFromIndexedDB failed:', error)
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
detailRows.value = [createDefaultRow()]
|
detailRows.value = []
|
||||||
syncComputedValuesToRows()
|
syncComputedValuesToRows()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -136,18 +229,22 @@ const columnDefs: ColDef<FeeRow>[] = [
|
|||||||
valueGetter: params =>
|
valueGetter: params =>
|
||||||
params.node?.rowPinned
|
params.node?.rowPinned
|
||||||
? ''
|
? ''
|
||||||
|
: isSubtotalRow(params.data)
|
||||||
|
? '小计'
|
||||||
: typeof params.node?.rowIndex === 'number'
|
: typeof params.node?.rowIndex === 'number'
|
||||||
? params.node.rowIndex + 1
|
? params.node.rowIndex + 1
|
||||||
: ''
|
: '',
|
||||||
|
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '费用项',
|
headerName: '费用项',
|
||||||
field: 'feeItem',
|
field: 'feeItem',
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
flex: 1.4,
|
flex: 1.4,
|
||||||
editable: true,
|
editable: params => !isSubtotalRow(params.data),
|
||||||
|
valueGetter: params => (isSubtotalRow(params.data) ? '' : (params.data?.feeItem ?? '')),
|
||||||
valueFormatter: formatEditableText,
|
valueFormatter: formatEditableText,
|
||||||
cellClass: 'editable-cell-line',
|
cellClass: params => (isSubtotalRow(params.data) ? '' : 'editable-cell-line'),
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||||
}
|
}
|
||||||
@ -157,7 +254,7 @@ const columnDefs: ColDef<FeeRow>[] = [
|
|||||||
field: 'unit',
|
field: 'unit',
|
||||||
minWidth: 90,
|
minWidth: 90,
|
||||||
flex: 0.9,
|
flex: 0.9,
|
||||||
editable: true,
|
editable: params => !isSubtotalRow(params.data),
|
||||||
valueFormatter: formatEditableText,
|
valueFormatter: formatEditableText,
|
||||||
cellClass: 'editable-cell-line',
|
cellClass: 'editable-cell-line',
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
@ -171,7 +268,7 @@ const columnDefs: ColDef<FeeRow>[] = [
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
headerClass: 'ag-right-aligned-header',
|
headerClass: 'ag-right-aligned-header',
|
||||||
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
||||||
editable: true,
|
editable: params => !isSubtotalRow(params.data),
|
||||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||||
valueFormatter: formatEditableQuantity,
|
valueFormatter: formatEditableQuantity,
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
@ -185,7 +282,7 @@ const columnDefs: ColDef<FeeRow>[] = [
|
|||||||
flex: 1.1,
|
flex: 1.1,
|
||||||
headerClass: 'ag-right-aligned-header',
|
headerClass: 'ag-right-aligned-header',
|
||||||
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
||||||
editable: true,
|
editable: params => !isSubtotalRow(params.data),
|
||||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
|
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
|
||||||
valueFormatter: formatEditableUnitPrice,
|
valueFormatter: formatEditableUnitPrice,
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
@ -207,7 +304,7 @@ const columnDefs: ColDef<FeeRow>[] = [
|
|||||||
field: 'remark',
|
field: 'remark',
|
||||||
minWidth: 170,
|
minWidth: 170,
|
||||||
flex: 2,
|
flex: 2,
|
||||||
editable: true,
|
editable: params => !isSubtotalRow(params.data),
|
||||||
cellEditor: 'agLargeTextCellEditor',
|
cellEditor: 'agLargeTextCellEditor',
|
||||||
wrapText: true,
|
wrapText: true,
|
||||||
autoHeight: true,
|
autoHeight: true,
|
||||||
@ -217,6 +314,47 @@ const columnDefs: ColDef<FeeRow>[] = [
|
|||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
'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 = () => {
|
const handleCellValueChanged = () => {
|
||||||
syncComputedValuesToRows()
|
syncComputedValuesToRows()
|
||||||
gridApi.value?.refreshCells({ columns: ['budgetFee'], force: true })
|
gridApi.value?.refreshCells({ force: true })
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
gridPersistTimer = setTimeout(() => {
|
gridPersistTimer = setTimeout(() => {
|
||||||
void saveToIndexedDB()
|
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="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">
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||||
|
<Button type="button" variant="outline" size="sm" @click="addRow">添加行</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
@ -292,4 +431,24 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
@ -10,6 +10,17 @@ import { formatThousandsFlexible } from '@/lib/numberFormat'
|
|||||||
import { Pencil, Eraser } from 'lucide-vue-next'
|
import { Pencil, Eraser } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
|
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
|
||||||
|
import {
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogRoot,
|
||||||
|
AlertDialogTitle
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
interface FeeMethodRow {
|
interface FeeMethodRow {
|
||||||
id: string
|
id: string
|
||||||
@ -25,6 +36,42 @@ interface FeeMethodState {
|
|||||||
detailRows: FeeMethodRow[]
|
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 {
|
interface LegacyFeeRow {
|
||||||
id?: string
|
id?: string
|
||||||
feeItem?: string
|
feeItem?: string
|
||||||
@ -36,10 +83,12 @@ interface LegacyFeeRow {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string
|
title: string
|
||||||
storageKey: string
|
storageKey: string
|
||||||
readonly?: boolean
|
contractId?: string
|
||||||
|
contractName?: string
|
||||||
fixedNames?: string[]
|
fixedNames?: string[]
|
||||||
}>()
|
}>()
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
|
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
|
||||||
|
|
||||||
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||||
const createDefaultRow = (name = ''): FeeMethodRow => ({
|
const createDefaultRow = (name = ''): FeeMethodRow => ({
|
||||||
@ -56,8 +105,115 @@ const toFinite = (value: number | null | undefined) =>
|
|||||||
const round3 = (value: number) => Number(value.toFixed(3))
|
const round3 = (value: number) => Number(value.toFixed(3))
|
||||||
const getRowSubtotal = (row: FeeMethodRow | null | undefined) =>
|
const getRowSubtotal = (row: FeeMethodRow | null | undefined) =>
|
||||||
row ? round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee)) : null
|
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(() =>
|
const fixedNames = computed(() =>
|
||||||
Array.isArray(props.fixedNames)
|
Array.isArray(props.fixedNames)
|
||||||
? props.fixedNames.map(item => String(item || '').trim()).filter(Boolean)
|
? 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 displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value])
|
||||||
const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
|
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) => {
|
const formatEditableText = (params: any) => {
|
||||||
if (params.value == null || params.value === '') {
|
if (params.value == null || params.value === '') {
|
||||||
if (params.context?.readonly === true || isSummaryRow(params.data)) return ''
|
if (isSummaryRow(params.data)) return ''
|
||||||
return '点击输入'
|
return ''
|
||||||
}
|
}
|
||||||
return String(params.value)
|
return String(params.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatEditableNumber = (params: any) => {
|
const formatEditableNumber = (params: any) => {
|
||||||
if (params.value == null || params.value === '') {
|
if (params.value == null || params.value === '') {
|
||||||
if (params.context?.readonly === true || isSummaryRow(params.data)) return ''
|
if (isSummaryRow(params.data)) return ''
|
||||||
return '点击输入'
|
return ''
|
||||||
}
|
}
|
||||||
return formatThousandsFlexible(params.value, 3)
|
return formatThousandsFlexible(params.value, 3)
|
||||||
}
|
}
|
||||||
@ -175,22 +353,33 @@ const saveToIndexedDB = async () => {
|
|||||||
|
|
||||||
const loadFromIndexedDB = async () => {
|
const loadFromIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const data = await localforage.getItem<FeeMethodState>(props.storageKey)
|
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) {
|
} catch (error) {
|
||||||
console.error('loadFromIndexedDB failed:', error)
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
detailRows.value = mergeWithStoredRows([])
|
const mergedRows = mergeWithStoredRows([])
|
||||||
|
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
|
||||||
|
await saveToIndexedDB()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addRow = () => {
|
const addRow = () => {
|
||||||
if (isReadonly.value) return
|
|
||||||
detailRows.value = [...detailRows.value, createDefaultRow()]
|
detailRows.value = [...detailRows.value, createDefaultRow()]
|
||||||
void saveToIndexedDB()
|
void saveToIndexedDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearRow = (id: string) => {
|
const clearRow = async (id: string) => {
|
||||||
if (isReadonly.value) return
|
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 =>
|
detailRows.value = detailRows.value.map(row =>
|
||||||
row.id !== id
|
row.id !== id
|
||||||
? row
|
? row
|
||||||
@ -201,11 +390,10 @@ const clearRow = (id: string) => {
|
|||||||
quantityUnitPriceFee: null
|
quantityUnitPriceFee: null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
void saveToIndexedDB()
|
await saveToIndexedDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
const editRow = (id: string) => {
|
const editRow = (id: string) => {
|
||||||
if (isReadonly.value) return
|
|
||||||
const row = detailRows.value.find(item => item.id === id)
|
const row = detailRows.value.find(item => item.id === id)
|
||||||
if (!row) return
|
if (!row) return
|
||||||
tabStore.openTab({
|
tabStore.openTab({
|
||||||
@ -216,7 +404,9 @@ const editRow = (id: string) => {
|
|||||||
sourceTitle: props.title,
|
sourceTitle: props.title,
|
||||||
storageKey: props.storageKey,
|
storageKey: props.storageKey,
|
||||||
rowId: id,
|
rowId: id,
|
||||||
rowName: row.name || ''
|
rowName: row.name || '',
|
||||||
|
contractId: props.contractId,
|
||||||
|
contractName: props.contractName
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -232,26 +422,36 @@ const ActionCellRenderer = defineComponent({
|
|||||||
setup(props) {
|
setup(props) {
|
||||||
return () => {
|
return () => {
|
||||||
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
|
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' }, [
|
return h('div', { class: 'zxfw-action-wrap' }, [
|
||||||
h('div', { class: 'zxfw-action-group' }, [
|
h('div', { class: 'zxfw-action-group' }, [
|
||||||
h('button', {
|
h('button', {
|
||||||
class: ['zxfw-action-btn', disabled ? 'zxfw-action-btn--disabled' : ''],
|
class: 'zxfw-action-btn',
|
||||||
'data-action': 'edit',
|
'data-action': 'edit',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
disabled
|
onClick: onActionClick('edit')
|
||||||
}, [
|
}, [
|
||||||
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
|
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
|
||||||
h('span', '编辑')
|
h('span', '编辑')
|
||||||
]),
|
]),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: ['zxfw-action-btn', disabled ? 'zxfw-action-btn--disabled' : ''],
|
class: 'zxfw-action-btn zxfw-action-btn--danger',
|
||||||
'data-action': 'clear',
|
'data-action': 'clear',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
disabled
|
onClick: onActionClick('clear')
|
||||||
}, [
|
}, [
|
||||||
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
|
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
|
||||||
h('span', '恢复默认')
|
h('span', '清空')
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
@ -265,11 +465,10 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
|||||||
field: 'name',
|
field: 'name',
|
||||||
minWidth: 180,
|
minWidth: 180,
|
||||||
flex: 1.8,
|
flex: 1.8,
|
||||||
editable: params =>
|
editable: false,
|
||||||
!(params.context?.readonly === true || params.context?.fixedNames === true || isSummaryRow(params.data)),
|
|
||||||
valueFormatter: formatEditableText,
|
valueFormatter: formatEditableText,
|
||||||
cellClass: params =>
|
cellClass: params =>
|
||||||
params.context?.readonly === true || params.context?.fixedNames === true || isSummaryRow(params.data)
|
params.context?.fixedNames === true || isSummaryRow(params.data)
|
||||||
? ''
|
? ''
|
||||||
: 'editable-cell-line',
|
: 'editable-cell-line',
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
@ -281,12 +480,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
|||||||
field: 'rateFee',
|
field: 'rateFee',
|
||||||
minWidth: 130,
|
minWidth: 130,
|
||||||
flex: 1.2,
|
flex: 1.2,
|
||||||
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data),
|
editable: false,
|
||||||
headerClass: 'ag-right-aligned-header',
|
headerClass: 'ag-right-aligned-header',
|
||||||
cellClass: params =>
|
cellClass: 'ag-right-aligned-cell',
|
||||||
params.context?.readonly === true
|
|
||||||
? 'ag-right-aligned-cell'
|
|
||||||
: 'ag-right-aligned-cell editable-cell-line',
|
|
||||||
valueParser: params => numericParser(params.newValue),
|
valueParser: params => numericParser(params.newValue),
|
||||||
valueFormatter: formatEditableNumber,
|
valueFormatter: formatEditableNumber,
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
@ -298,12 +494,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
|||||||
field: 'hourlyFee',
|
field: 'hourlyFee',
|
||||||
minWidth: 130,
|
minWidth: 130,
|
||||||
flex: 1.2,
|
flex: 1.2,
|
||||||
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data),
|
editable: false,
|
||||||
headerClass: 'ag-right-aligned-header',
|
headerClass: 'ag-right-aligned-header',
|
||||||
cellClass: params =>
|
cellClass: 'ag-right-aligned-cell',
|
||||||
params.context?.readonly === true
|
|
||||||
? 'ag-right-aligned-cell'
|
|
||||||
: 'ag-right-aligned-cell editable-cell-line',
|
|
||||||
valueParser: params => numericParser(params.newValue),
|
valueParser: params => numericParser(params.newValue),
|
||||||
valueFormatter: formatEditableNumber,
|
valueFormatter: formatEditableNumber,
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
@ -315,12 +508,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
|||||||
field: 'quantityUnitPriceFee',
|
field: 'quantityUnitPriceFee',
|
||||||
minWidth: 130,
|
minWidth: 130,
|
||||||
flex: 1.2,
|
flex: 1.2,
|
||||||
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data),
|
editable: false,
|
||||||
headerClass: 'ag-right-aligned-header',
|
headerClass: 'ag-right-aligned-header',
|
||||||
cellClass: params =>
|
cellClass: 'ag-right-aligned-cell',
|
||||||
params.context?.readonly === true
|
|
||||||
? 'ag-right-aligned-cell'
|
|
||||||
: 'ag-right-aligned-cell editable-cell-line',
|
|
||||||
valueParser: params => numericParser(params.newValue),
|
valueParser: params => numericParser(params.newValue),
|
||||||
valueFormatter: formatEditableNumber,
|
valueFormatter: formatEditableNumber,
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
@ -357,12 +547,13 @@ const detailGridOptions: GridOptions<FeeMethodRow> = {
|
|||||||
treeData: false,
|
treeData: false,
|
||||||
getDataPath: undefined,
|
getDataPath: undefined,
|
||||||
context: {
|
context: {
|
||||||
readonly: isReadonly.value,
|
fixedNames: hasFixedNames.value,
|
||||||
fixedNames: hasFixedNames.value
|
onActionEdit: editRow,
|
||||||
|
onActionClear: clearRow,
|
||||||
|
onActionRequestClear: requestClearRow
|
||||||
},
|
},
|
||||||
onCellClicked: params => {
|
onCellClicked: params => {
|
||||||
if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(params.data)) return
|
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 target = params.event?.target as HTMLElement | null
|
||||||
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
|
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
|
||||||
const action = btn?.dataset.action
|
const action = btn?.dataset.action
|
||||||
@ -371,7 +562,7 @@ const detailGridOptions: GridOptions<FeeMethodRow> = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'clear') {
|
if (action === 'clear') {
|
||||||
clearRow(params.data.id)
|
requestClearRow(params.data.id, params.data.name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -384,7 +575,6 @@ const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
||||||
if (isReadonly.value) return
|
|
||||||
if (isSummaryRow(event.data)) return
|
if (isSummaryRow(event.data)) return
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
gridPersistTimer = setTimeout(() => {
|
gridPersistTimer = setTimeout(() => {
|
||||||
@ -405,10 +595,23 @@ watch(storageKeyRef, () => {
|
|||||||
void loadFromIndexedDB()
|
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
|
if (!detailGridOptions.context) return
|
||||||
detailGridOptions.context.readonly = isReadonly.value
|
|
||||||
detailGridOptions.context.fixedNames = hasFixedNames.value
|
detailGridOptions.context.fixedNames = hasFixedNames.value
|
||||||
|
detailGridOptions.context.onActionEdit = editRow
|
||||||
|
detailGridOptions.context.onActionClear = clearRow
|
||||||
|
detailGridOptions.context.onActionRequestClear = requestClearRow
|
||||||
gridApi.value?.refreshCells({ force: true })
|
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="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">
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
<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>
|
||||||
|
|
||||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
@ -450,11 +653,24 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
<AlertDialogRoot :open="clearConfirmOpen" @update:open="handleClearConfirmOpenChange">
|
||||||
.zxfw-action-btn--disabled {
|
<AlertDialogPortal>
|
||||||
opacity: 0.45;
|
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||||
cursor: not-allowed;
|
<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>
|
||||||
</style>
|
<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<{
|
const props = defineProps<{
|
||||||
contractId: string
|
contractId: string
|
||||||
|
contractName?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
|
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
|
||||||
@ -15,7 +16,8 @@ const additionalWorkNames = computed(() => additionalWorkList.map(item => String
|
|||||||
<HtFeeMethodGrid
|
<HtFeeMethodGrid
|
||||||
title="附加工作费"
|
title="附加工作费"
|
||||||
:storageKey="STORAGE_KEY"
|
:storageKey="STORAGE_KEY"
|
||||||
:readonly="true"
|
:contract-id="props.contractId"
|
||||||
|
:contract-name="props.contractName"
|
||||||
:fixed-names="additionalWorkNames"
|
:fixed-names="additionalWorkNames"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<TypeLine
|
<TypeLine
|
||||||
scene="ht-fee-method-type-line"
|
scene="ht-fee-method-type-line"
|
||||||
:title="`${sourceTitleText}:${rowNameText}`"
|
:title="titleText"
|
||||||
:subtitle="`明细ID:${props.rowId}`"
|
:subtitle="`合同ID:${contractIdText}`"
|
||||||
:copy-text="props.rowId"
|
:copy-text="contractIdText"
|
||||||
:storage-key="activeTypeStorageKey"
|
:storage-key="activeTypeStorageKey"
|
||||||
default-category="rate-fee"
|
default-category="rate-fee"
|
||||||
:categories="categories"
|
:categories="categories"
|
||||||
@ -13,6 +13,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent, h, markRaw, type Component } from 'vue'
|
import { computed, defineComponent, h, markRaw, type Component } from 'vue'
|
||||||
import TypeLine from '@/layout/typeLine.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 {
|
interface TypeLineCategoryItem {
|
||||||
key: string
|
key: string
|
||||||
@ -25,31 +28,70 @@ const props = defineProps<{
|
|||||||
storageKey: string
|
storageKey: string
|
||||||
rowId: string
|
rowId: string
|
||||||
rowName?: string
|
rowName?: string
|
||||||
|
contractId?: string
|
||||||
|
contractName?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const sourceTitleText = computed(() => props.sourceTitle || '费用明细')
|
const sourceTitleText = computed(() => props.sourceTitle || '费用明细')
|
||||||
const rowNameText = computed(() => props.rowName || '未命名')
|
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 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) =>
|
const quantityUnitPricePane = markRaw(
|
||||||
markRaw(
|
|
||||||
defineComponent({
|
defineComponent({
|
||||||
name: `HtFeeMethodTypePane-${key}`,
|
name: 'HtFeeGrid',
|
||||||
setup() {
|
setup() {
|
||||||
|
const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee'))
|
||||||
return () =>
|
return () =>
|
||||||
h('div', { class: 'h-full min-h-0 flex flex-col' }, [
|
h(HtFeeGrid, {
|
||||||
h('div', { class: 'rounded-lg border bg-card p-4' }, [
|
title: '数量单价',
|
||||||
h('h3', { class: 'text-sm font-semibold text-foreground' }, methodLabel),
|
storageKey: quantityStorageKey.value,
|
||||||
h('p', { class: 'mt-2 text-xs text-muted-foreground' }, `当前编辑类型:${methodLabel}`)
|
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[] = [
|
const categories: TypeLineCategoryItem[] = [
|
||||||
{ key: 'rate-fee', label: '费率计取', component: createMethodPane('费率计取', 'rate-fee') },
|
{ key: 'rate-fee', label: '费率计取', component: rateFeePane },
|
||||||
{ key: 'hourly-fee', label: '工时法', component: createMethodPane('工时法', 'hourly-fee') },
|
{ key: 'hourly-fee', label: '工时法', component: hourlyFeePane },
|
||||||
{ key: 'quantity-unit-price-fee', label: '数量单价', component: createMethodPane('数量单价', 'quantity-unit-price-fee') }
|
{ key: 'quantity-unit-price-fee', label: '数量单价', component: quantityUnitPricePane }
|
||||||
]
|
]
|
||||||
</script>
|
</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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HtFeeMethodGrid title="预备费" :storageKey="STORAGE_KEY" />
|
<HtFeeMethodGrid title="预备费" :storageKey="STORAGE_KEY" :contract-id="props.contractId" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -99,7 +99,7 @@ const additionalWorkFeeView = markRaw(
|
|||||||
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
|
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">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import HourlyFeeGrid from '@/components/common/HourlyFeeGrid.vue'
|
||||||
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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
contractId: string,
|
contractId: string
|
||||||
|
|
||||||
serviceId: string | number
|
serviceId: string | number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex flex-col">
|
<HourlyFeeGrid
|
||||||
|
title="工时法明细"
|
||||||
|
:storage-key="DB_KEY"
|
||||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
:contract-id="props.contractId"
|
||||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
:service-id="props.serviceId"
|
||||||
<h3 class="text-sm font-semibold text-foreground">工时法明细</h3>
|
:enable-zx-fw-sync="true"
|
||||||
<div class="text-xs text-muted-foreground"></div>
|
sync-field="hourly"
|
||||||
</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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
|||||||
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
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 { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
import { parseNumberOrNull } from '@/lib/number'
|
||||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||||
@ -93,6 +93,7 @@ const props = defineProps<{
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
serviceId: string | number
|
serviceId: string | number
|
||||||
}>()
|
}>()
|
||||||
|
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||||
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
|
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
|
||||||
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${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 activeIndustryCode = ref('')
|
||||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
|
||||||
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
let factorDefaultsLoaded = false
|
let factorDefaultsLoaded = false
|
||||||
const paneInstanceCreatedAt = Date.now()
|
const paneInstanceCreatedAt = Date.now()
|
||||||
|
const reloadSignal = ref(0)
|
||||||
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
|
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
|
||||||
const industryNameMap = new Map(
|
const industryNameMap = new Map(
|
||||||
industryTypeList.flatMap(item => [
|
industryTypeList.flatMap(item => [
|
||||||
@ -1026,7 +1027,7 @@ const saveToIndexedDB = async () => {
|
|||||||
value: totalBudgetFee.value
|
value: totalBudgetFee.value
|
||||||
})
|
})
|
||||||
if (synced) {
|
if (synced) {
|
||||||
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
@ -1224,7 +1225,16 @@ const clearAllData = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
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) => {
|
(nextVersion, prevVersion) => {
|
||||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||||
void loadFromIndexedDB()
|
void loadFromIndexedDB()
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
|||||||
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
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 { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
import { parseNumberOrNull } from '@/lib/number'
|
||||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||||
@ -93,6 +93,7 @@ const props = defineProps<{
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
serviceId: string | number
|
serviceId: string | number
|
||||||
}>()
|
}>()
|
||||||
|
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||||
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
|
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
|
||||||
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${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 activeIndustryCode = ref('')
|
||||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
|
||||||
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
let factorDefaultsLoaded = false
|
let factorDefaultsLoaded = false
|
||||||
const paneInstanceCreatedAt = Date.now()
|
const paneInstanceCreatedAt = Date.now()
|
||||||
|
const reloadSignal = ref(0)
|
||||||
const industryNameMap = new Map(
|
const industryNameMap = new Map(
|
||||||
industryTypeList.flatMap(item => [
|
industryTypeList.flatMap(item => [
|
||||||
[String(item.id).trim(), item.name],
|
[String(item.id).trim(), item.name],
|
||||||
@ -116,6 +117,7 @@ const totalLabel = computed(() => {
|
|||||||
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || ''
|
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || ''
|
||||||
return industryName ? `${industryName}总投资` : '总投资'
|
return industryName ? `${industryName}总投资` : '总投资'
|
||||||
})
|
})
|
||||||
|
|
||||||
const isMutipleService = computed(() => {
|
const isMutipleService = computed(() => {
|
||||||
const service = getServiceDictItemById(props.serviceId) as ServiceLite | undefined
|
const service = getServiceDictItemById(props.serviceId) as ServiceLite | undefined
|
||||||
return service?.mutiple === true
|
return service?.mutiple === true
|
||||||
@ -878,7 +880,7 @@ const saveToIndexedDB = async () => {
|
|||||||
value: totalBudgetFee.value
|
value: totalBudgetFee.value
|
||||||
})
|
})
|
||||||
if (synced) {
|
if (synced) {
|
||||||
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
@ -1048,7 +1050,16 @@ const clearAllData = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
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) => {
|
(nextVersion, prevVersion) => {
|
||||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||||
void loadFromIndexedDB()
|
void loadFromIndexedDB()
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
|||||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
import { parseNumberOrNull } from '@/lib/number'
|
||||||
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
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 { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
|
||||||
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
||||||
|
|
||||||
@ -42,14 +42,15 @@ const props = defineProps<{
|
|||||||
|
|
||||||
serviceId: string | number
|
serviceId: string | number
|
||||||
}>()
|
}>()
|
||||||
|
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||||||
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
|
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
|
||||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
||||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
|
||||||
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
let factorDefaultsLoaded = false
|
let factorDefaultsLoaded = false
|
||||||
const paneInstanceCreatedAt = Date.now()
|
const paneInstanceCreatedAt = Date.now()
|
||||||
|
const reloadSignal = ref(0)
|
||||||
|
|
||||||
const getDefaultConsultCategoryFactor = () =>
|
const getDefaultConsultCategoryFactor = () =>
|
||||||
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
|
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
|
||||||
@ -236,7 +237,6 @@ const formatEditableNumber = (params: any) => {
|
|||||||
const spanRowsByTaskName = (params: any) => {
|
const spanRowsByTaskName = (params: any) => {
|
||||||
const rowA = params?.nodeA?.data as DetailRow | undefined
|
const rowA = params?.nodeA?.data as DetailRow | undefined
|
||||||
const rowB = params?.nodeB?.data as DetailRow | undefined
|
const rowB = params?.nodeB?.data as DetailRow | undefined
|
||||||
// debugger
|
|
||||||
if (!rowA || !rowB) return false
|
if (!rowA || !rowB) return false
|
||||||
if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false
|
if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false
|
||||||
|
|
||||||
@ -433,7 +433,7 @@ const saveToIndexedDB = async () => {
|
|||||||
value: totalServiceFee.value
|
value: totalServiceFee.value
|
||||||
})
|
})
|
||||||
if (synced) {
|
if (synced) {
|
||||||
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
@ -468,7 +468,16 @@ const loadFromIndexedDB = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
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) => {
|
(nextVersion, prevVersion) => {
|
||||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||||
void loadFromIndexedDB()
|
void loadFromIndexedDB()
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
type PricingMethodTotals
|
type PricingMethodTotals
|
||||||
} from '@/lib/pricingMethodTotals'
|
} from '@/lib/pricingMethodTotals'
|
||||||
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||||
|
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||||
import { Pencil, Eraser, Trash2 } from 'lucide-vue-next'
|
import { Pencil, Eraser, Trash2 } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -33,7 +34,6 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { getServiceDictEntries, isIndustryEnabledByType,getIndustryTypeValue } from '@/sql'
|
import { getServiceDictEntries, isIndustryEnabledByType,getIndustryTypeValue } from '@/sql'
|
||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
|
||||||
import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue'
|
import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue'
|
||||||
|
|
||||||
interface ServiceItem {
|
interface ServiceItem {
|
||||||
@ -85,6 +85,7 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
|||||||
const PRICING_CLEAR_SKIP_TTL_MS = 5000
|
const PRICING_CLEAR_SKIP_TTL_MS = 5000
|
||||||
const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const
|
const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const
|
||||||
const projectIndustry = ref('')
|
const projectIndustry = ref('')
|
||||||
|
const reloadSignal = ref(0)
|
||||||
|
|
||||||
type ServiceListItem = {
|
type ServiceListItem = {
|
||||||
code?: string
|
code?: string
|
||||||
@ -388,7 +389,7 @@ const clearPricingPaneValues = async (serviceId: string) => {
|
|||||||
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
|
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
|
||||||
}
|
}
|
||||||
await Promise.all(keys.map(key => localforage.removeItem(key)))
|
await Promise.all(keys.map(key => localforage.removeItem(key)))
|
||||||
pricingPaneReloadStore.markReload(props.contractId, serviceId)
|
pricingPaneReloadStore.emit(props.contractId, serviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearRowValues = async (row: DetailRow) => {
|
const clearRowValues = async (row: DetailRow) => {
|
||||||
@ -492,8 +493,30 @@ const ActionCellRenderer = defineComponent({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const columnDefs: ColDef<DetailRow>[] = [
|
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: '投资规模法',
|
headerName: '投资规模法',
|
||||||
field: 'investScale',
|
field: 'investScale',
|
||||||
@ -965,7 +988,16 @@ const loadProjectIndustry = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
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) => {
|
(nextVersion, prevVersion) => {
|
||||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||||
void loadFromIndexedDB()
|
void loadFromIndexedDB()
|
||||||
|
|||||||
@ -1363,7 +1363,7 @@ watch(
|
|||||||
<ScrollArea :ref="setTabScrollAreaRef" type="auto"
|
<ScrollArea :ref="setTabScrollAreaRef" type="auto"
|
||||||
class="h-full tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
|
class="h-full tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
|
||||||
<draggable v-model="tabsModel" item-key="id" tag="div"
|
<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"
|
: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"
|
chosen-class="tab-drag-chosen" drag-class="tab-drag-active" :move="canMoveTab" @start="handleTabDragStart"
|
||||||
@end="handleTabDragEnd">
|
@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 { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const buildReloadKey = (contractId: string, serviceId: string | number) =>
|
export interface PricingPaneReloadDetail {
|
||||||
`${contractId}::${String(serviceId)}`
|
contractId: string
|
||||||
|
serviceId: string
|
||||||
export const usePricingPaneReloadStore = defineStore('pricing-pane-reload', () => {
|
at: number
|
||||||
const reloadVersionMap = ref<Record<string, number>>({})
|
|
||||||
|
|
||||||
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 getReloadVersion = (contractId: string, serviceId: string | number) => {
|
interface PricingPaneReloadState {
|
||||||
const key = buildReloadKey(contractId, serviceId)
|
seq: number
|
||||||
return reloadVersionMap.value[key] || 0
|
lastEvent: PricingPaneReloadDetail | null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const toKey = (value: string | number) => String(value)
|
||||||
markReload,
|
|
||||||
getReloadVersion
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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