822 lines
26 KiB
Vue
822 lines
26 KiB
Vue
|
|
<template>
|
|||
|
|
<ContentWrap>
|
|||
|
|
<el-form
|
|||
|
|
ref="queryFormRef"
|
|||
|
|
:inline="true"
|
|||
|
|
:model="queryParams"
|
|||
|
|
class="-mb-15px"
|
|||
|
|
label-width="88px"
|
|||
|
|
>
|
|||
|
|
<el-form-item label="工程名称" prop="projectName">
|
|||
|
|
<el-input
|
|||
|
|
v-model="queryParams.projectName"
|
|||
|
|
class="!w-240px"
|
|||
|
|
clearable
|
|||
|
|
placeholder="请输入工程名称"
|
|||
|
|
@keyup.enter="handleQuery"
|
|||
|
|
/>
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="开始年度" prop="projectStartYear">
|
|||
|
|
<el-date-picker
|
|||
|
|
v-model="queryProjectStartYearValue"
|
|||
|
|
class="!w-180px"
|
|||
|
|
clearable
|
|||
|
|
placeholder="请选择年度"
|
|||
|
|
type="year"
|
|||
|
|
value-format="YYYY"
|
|||
|
|
/>
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item>
|
|||
|
|
<el-button @click="handleQuery">
|
|||
|
|
<Icon class="mr-5px" icon="ep:search" />
|
|||
|
|
搜索
|
|||
|
|
</el-button>
|
|||
|
|
<el-button @click="resetQuery">
|
|||
|
|
<Icon class="mr-5px" icon="ep:refresh" />
|
|||
|
|
重置
|
|||
|
|
</el-button>
|
|||
|
|
</el-form-item>
|
|||
|
|
</el-form>
|
|||
|
|
</ContentWrap>
|
|||
|
|
|
|||
|
|
<ContentWrap>
|
|||
|
|
<el-table
|
|||
|
|
ref="projectTableRef"
|
|||
|
|
v-loading="loading"
|
|||
|
|
:data="projectList"
|
|||
|
|
highlight-current-row
|
|||
|
|
@current-change="handleCurrentProjectChange"
|
|||
|
|
>
|
|||
|
|
<el-table-column align="center" label="项目 ID" prop="id" width="88" />
|
|||
|
|
<el-table-column align="center" label="工程名称" min-width="220" prop="projectName" />
|
|||
|
|
<el-table-column align="center" label="项目经理" min-width="120" prop="projectManagerName" />
|
|||
|
|
<el-table-column align="center" label="工程负责人" min-width="120" prop="engineeringPrincipalName" />
|
|||
|
|
<el-table-column align="center" label="项目开始年度" prop="projectStartYear" width="120" />
|
|||
|
|
</el-table>
|
|||
|
|
<Pagination
|
|||
|
|
v-model:limit="queryParams.pageSize"
|
|||
|
|
v-model:page="queryParams.pageNo"
|
|||
|
|
:total="total"
|
|||
|
|
@pagination="getProjectList"
|
|||
|
|
/>
|
|||
|
|
</ContentWrap>
|
|||
|
|
|
|||
|
|
<el-row :gutter="16">
|
|||
|
|
<el-col :span="8">
|
|||
|
|
<ContentWrap>
|
|||
|
|
<div class="mb-12px flex items-center justify-between">
|
|||
|
|
<div class="text-14px font-600">
|
|||
|
|
{{ currentProject?.projectName || '专业所规划列表' }}
|
|||
|
|
</div>
|
|||
|
|
<el-button v-if="currentProject" size="small" @click="getPlanningList">刷新</el-button>
|
|||
|
|
</div>
|
|||
|
|
<el-table
|
|||
|
|
ref="planningTableRef"
|
|||
|
|
v-loading="planningLoading"
|
|||
|
|
:data="planningList"
|
|||
|
|
highlight-current-row
|
|||
|
|
@current-change="handleCurrentPlanningChange"
|
|||
|
|
>
|
|||
|
|
<el-table-column align="center" label="规划内容" min-width="180" prop="planningContent" />
|
|||
|
|
<el-table-column align="center" label="开始年度" prop="planningStartYear" width="100" />
|
|||
|
|
<el-table-column align="center" label="考核产值(元)" width="120">
|
|||
|
|
<template #default="scope">
|
|||
|
|
{{ formatAmountText(scope.row.assessmentOutputValue) }}
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
</el-table>
|
|||
|
|
</ContentWrap>
|
|||
|
|
</el-col>
|
|||
|
|
|
|||
|
|
<el-col :span="16">
|
|||
|
|
<ContentWrap v-if="currentPlanning && currentSpecialtyGroup">
|
|||
|
|
<div class="mb-16px flex items-center justify-between gap-12px">
|
|||
|
|
<div>
|
|||
|
|
<div class="text-16px font-600">{{ currentPlanning.planningContent }}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex items-center gap-12px">
|
|||
|
|
<el-button
|
|||
|
|
v-hasPermi="['tjt:specialty-role-split:update']"
|
|||
|
|
plain
|
|||
|
|
type="primary"
|
|||
|
|
@click="openEditDialog"
|
|||
|
|
>
|
|||
|
|
<Icon class="mr-5px" icon="ep:edit" />
|
|||
|
|
编辑人员分配
|
|||
|
|
</el-button>
|
|||
|
|
<el-button @click="refreshCurrentPlanning">
|
|||
|
|
<Icon class="mr-5px" icon="ep:refresh" />
|
|||
|
|
刷新结果
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-16px flex flex-wrap items-start justify-between gap-12px">
|
|||
|
|
<el-radio-group v-model="selectedSpecialtyCode" size="small">
|
|||
|
|
<el-radio-button
|
|||
|
|
v-for="item in specialtyTabOptions"
|
|||
|
|
:key="item.value"
|
|||
|
|
:label="item.value"
|
|||
|
|
>
|
|||
|
|
{{ item.label }}
|
|||
|
|
</el-radio-button>
|
|||
|
|
</el-radio-group>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<el-row :gutter="16" class="mb-16px">
|
|||
|
|
<el-col v-for="item in currentSpecialtySummaryCards" :key="item.label" :span="6">
|
|||
|
|
<div class="rounded-8px bg-[var(--el-fill-color-light)] px-16px py-12px">
|
|||
|
|
<div class="text-12px text-[var(--el-text-color-secondary)]">
|
|||
|
|
{{ item.label }}
|
|||
|
|
</div>
|
|||
|
|
<div class="mt-6px text-18px font-600">
|
|||
|
|
{{ item.value }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</el-col>
|
|||
|
|
</el-row>
|
|||
|
|
|
|||
|
|
<el-table :data="currentSpecialtyGroup.rows" border>
|
|||
|
|
<el-table-column align="center" label="角色" min-width="110" prop="roleName" />
|
|||
|
|
<el-table-column align="center" label="角色比例" min-width="120">
|
|||
|
|
<template #default="scope">
|
|||
|
|
{{ formatPercentText(scope.row.roleRatio) }}
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column align="center" label="角色金额(元)" min-width="130">
|
|||
|
|
<template #default="scope">
|
|||
|
|
{{ formatAmountText(scope.row.roleAmount) }}
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column align="center" label="人员合计" min-width="120">
|
|||
|
|
<template #default="scope">
|
|||
|
|
{{ formatPercentText(getPersonTotalRatio(scope.row)) }}
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column align="center" label="人员信息" min-width="360">
|
|||
|
|
<template #default="scope">
|
|||
|
|
<div v-if="scope.row.persons?.length" class="person-display-list">
|
|||
|
|
<div
|
|||
|
|
v-for="(person, index) in scope.row.persons"
|
|||
|
|
:key="`${scope.row.specialtyCode}-${scope.row.roleCode}-display-${index}`"
|
|||
|
|
class="person-display-row"
|
|||
|
|
>
|
|||
|
|
<span class="person-display-name">{{ person.personName || '-' }}</span>
|
|||
|
|
<span>{{ formatPercentText(person.personRatio) }}</span>
|
|||
|
|
<span>{{ formatAmountText(person.personAmount) }} 元</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="person-empty">暂无人员</div>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
</el-table>
|
|||
|
|
</ContentWrap>
|
|||
|
|
|
|||
|
|
<ContentWrap v-else>
|
|||
|
|
<el-empty description="请选择专业所规划后查看页面5结果" />
|
|||
|
|
</ContentWrap>
|
|||
|
|
</el-col>
|
|||
|
|
</el-row>
|
|||
|
|
|
|||
|
|
<Dialog v-model="dialogVisible" title="编辑专业人员角色分配" width="1180">
|
|||
|
|
<template v-if="currentEditSpecialtyGroup">
|
|||
|
|
|
|||
|
|
<div class="mb-16px flex flex-wrap items-start justify-between gap-12px">
|
|||
|
|
<el-radio-group v-model="editSpecialtyCode" size="small">
|
|||
|
|
<el-radio-button
|
|||
|
|
v-for="item in specialtyTabOptions"
|
|||
|
|
:key="`edit-${item.value}`"
|
|||
|
|
:label="item.value"
|
|||
|
|
>
|
|||
|
|
{{ item.label }}
|
|||
|
|
</el-radio-button>
|
|||
|
|
</el-radio-group>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<el-row :gutter="16" class="mb-16px">
|
|||
|
|
<el-col v-for="item in currentEditSpecialtySummaryCards" :key="item.label" :span="6">
|
|||
|
|
<div class="rounded-8px bg-[var(--el-fill-color-light)] px-16px py-12px">
|
|||
|
|
<div class="text-12px text-[var(--el-text-color-secondary)]">
|
|||
|
|
{{ item.label }}
|
|||
|
|
</div>
|
|||
|
|
<div class="mt-6px text-18px font-600">
|
|||
|
|
{{ item.value }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</el-col>
|
|||
|
|
</el-row>
|
|||
|
|
|
|||
|
|
<el-table :data="currentEditSpecialtyGroup.rows" border>
|
|||
|
|
<el-table-column align="center" label="角色" min-width="110" prop="roleName" />
|
|||
|
|
<el-table-column align="center" label="角色比例" min-width="120">
|
|||
|
|
<template #default="scope">
|
|||
|
|
<el-input-number
|
|||
|
|
:model-value="toPercentValue(scope.row.roleRatio)"
|
|||
|
|
:max="100"
|
|||
|
|
:min="0"
|
|||
|
|
:precision="2"
|
|||
|
|
:step="0.1"
|
|||
|
|
class="!w-1/1"
|
|||
|
|
controls-position="right"
|
|||
|
|
@update:model-value="(value) => updateRoleRatio(scope.row, value)"
|
|||
|
|
/>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column align="center" label="角色金额(元)" min-width="130">
|
|||
|
|
<template #default="scope">
|
|||
|
|
{{ formatAmountText(scope.row.roleAmount) }}
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column align="center" label="人员合计" min-width="120">
|
|||
|
|
<template #default="scope">
|
|||
|
|
<span :class="getPersonTotalClass(scope.row)">
|
|||
|
|
{{ formatPercentText(getPersonTotalRatio(scope.row)) }}
|
|||
|
|
</span>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column label="人员配置" min-width="520">
|
|||
|
|
<template #default="scope">
|
|||
|
|
<div class="person-editor">
|
|||
|
|
<div v-if="!scope.row.persons?.length" class="person-empty">
|
|||
|
|
暂无人员,请点击“添加人员”
|
|||
|
|
</div>
|
|||
|
|
<div
|
|||
|
|
v-for="(person, index) in scope.row.persons"
|
|||
|
|
:key="`${scope.row.specialtyCode}-${scope.row.roleCode}-${index}`"
|
|||
|
|
class="person-row"
|
|||
|
|
>
|
|||
|
|
<el-input
|
|||
|
|
v-model="person.personName"
|
|||
|
|
clearable
|
|||
|
|
maxlength="64"
|
|||
|
|
placeholder="请输入人员名称"
|
|||
|
|
/>
|
|||
|
|
<el-input-number
|
|||
|
|
:model-value="toPercentValue(person.personRatio)"
|
|||
|
|
:max="getPersonRatioMax(scope.row, person)"
|
|||
|
|
:min="0"
|
|||
|
|
:precision="2"
|
|||
|
|
:step="0.1"
|
|||
|
|
class="!w-1/1"
|
|||
|
|
controls-position="right"
|
|||
|
|
placeholder="比例(%)"
|
|||
|
|
@update:model-value="(value) => updatePersonRatio(scope.row, person, value)"
|
|||
|
|
/>
|
|||
|
|
<div class="person-amount">{{ formatAmountText(person.personAmount) }} 元</div>
|
|||
|
|
<el-button text type="danger" @click="removePerson(scope.row, index)">
|
|||
|
|
删除
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
<div class="person-toolbar">
|
|||
|
|
<el-button text type="primary" @click="addPerson(scope.row)">
|
|||
|
|
<Icon class="mr-5px" icon="ep:plus" />
|
|||
|
|
添加人员
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
</el-table>
|
|||
|
|
</template>
|
|||
|
|
<template #footer>
|
|||
|
|
<el-button :loading="saveLoading" type="primary" @click="handleSave">保存</el-button>
|
|||
|
|
<el-button @click="dialogVisible = false">取消</el-button>
|
|||
|
|
</template>
|
|||
|
|
</Dialog>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script lang="ts" setup>
|
|||
|
|
import * as ProjectApi from '@/api/tjt/project'
|
|||
|
|
import * as PlanningApi from '@/api/tjt/planning'
|
|||
|
|
import * as SpecialtyRoleSplitApi from '@/api/tjt/specialtyRoleSplit'
|
|||
|
|
import {
|
|||
|
|
formatAmountText,
|
|||
|
|
formatPercentText,
|
|||
|
|
fromPercentValue,
|
|||
|
|
isMajorOwnership,
|
|||
|
|
OUTPUT_SPLIT_SPECIALTY_OPTIONS,
|
|||
|
|
toPercentValue
|
|||
|
|
} from '@/views/tjt/shared/planning'
|
|||
|
|
|
|||
|
|
defineOptions({ name: 'TjtStaffAssignment' })
|
|||
|
|
|
|||
|
|
const DESIGN_ROLE_CODE = 'design'
|
|||
|
|
const EPSILON = 0.0001
|
|||
|
|
|
|||
|
|
type SpecialtyRolePersonVO = SpecialtyRoleSplitApi.SpecialtyRolePersonVO
|
|||
|
|
type SpecialtyRoleSplitVO = SpecialtyRoleSplitApi.SpecialtyRoleSplitVO
|
|||
|
|
|
|||
|
|
interface SpecialtyGroup {
|
|||
|
|
specialtyCode: string
|
|||
|
|
specialtyName: string
|
|||
|
|
specialtyAmount: number
|
|||
|
|
rows: SpecialtyRoleSplitVO[]
|
|||
|
|
roleTotal: number
|
|||
|
|
designRatio: number
|
|||
|
|
personCount: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const message = useMessage()
|
|||
|
|
|
|||
|
|
const loading = ref(false)
|
|||
|
|
const planningLoading = ref(false)
|
|||
|
|
const saveLoading = ref(false)
|
|||
|
|
const total = ref(0)
|
|||
|
|
const projectList = ref<ProjectApi.ProjectVO[]>([])
|
|||
|
|
const planningList = ref<PlanningApi.ProjectPlanningVO[]>([])
|
|||
|
|
const currentProject = ref<ProjectApi.ProjectVO>()
|
|||
|
|
const currentPlanning = ref<PlanningApi.ProjectPlanningVO>()
|
|||
|
|
const roleList = ref<SpecialtyRoleSplitVO[]>([])
|
|||
|
|
const editRoleList = ref<SpecialtyRoleSplitVO[]>([])
|
|||
|
|
const selectedSpecialtyCode = ref('')
|
|||
|
|
const editSpecialtyCode = ref('')
|
|||
|
|
const dialogVisible = ref(false)
|
|||
|
|
const queryFormRef = ref()
|
|||
|
|
const projectTableRef = ref()
|
|||
|
|
const planningTableRef = ref()
|
|||
|
|
|
|||
|
|
const queryParams = reactive<ProjectApi.ProjectPageReqVO>({
|
|||
|
|
pageNo: 1,
|
|||
|
|
pageSize: 10,
|
|||
|
|
projectName: undefined,
|
|||
|
|
projectStartYear: undefined
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const queryProjectStartYearValue = computed({
|
|||
|
|
get: () => (queryParams.projectStartYear ? String(queryParams.projectStartYear) : undefined),
|
|||
|
|
set: (value?: string) => {
|
|||
|
|
queryParams.projectStartYear = value ? Number(value) : undefined
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const roundRatio = (value?: number | string | null) => {
|
|||
|
|
const numericValue = Number(value || 0)
|
|||
|
|
if (Number.isNaN(numericValue)) {
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
return Number(numericValue.toFixed(4))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const roundAmount = (value?: number | string | null) => {
|
|||
|
|
const numericValue = Number(value || 0)
|
|||
|
|
if (Number.isNaN(numericValue)) {
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
return Number(numericValue.toFixed(2))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const multiplyAmount = (amount?: number | string | null, ratio?: number | string | null) => {
|
|||
|
|
return roundAmount(Number(amount || 0) * Number(ratio || 0))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const normalizePersonName = (value?: string) => value?.trim() || ''
|
|||
|
|
|
|||
|
|
const buildEditablePerson = (person?: SpecialtyRolePersonVO): SpecialtyRolePersonVO => ({
|
|||
|
|
personName: person?.personName || '',
|
|||
|
|
personRatio:
|
|||
|
|
person?.personRatio === undefined || person?.personRatio === null
|
|||
|
|
? undefined
|
|||
|
|
: roundRatio(person.personRatio),
|
|||
|
|
personAmount: roundAmount(person?.personAmount)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const buildEditableRow = (row: SpecialtyRoleSplitVO): SpecialtyRoleSplitVO => ({
|
|||
|
|
...row,
|
|||
|
|
specialtyAmount: roundAmount(row.specialtyAmount),
|
|||
|
|
roleRatio: roundRatio(row.roleRatio),
|
|||
|
|
roleAmount: roundAmount(row.roleAmount),
|
|||
|
|
persons: (row.persons || []).map((person) => buildEditablePerson(person))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const cloneRoleRows = (rows: SpecialtyRoleSplitVO[]) => rows.map((item) => buildEditableRow(item))
|
|||
|
|
|
|||
|
|
const sumPersonRatios = (persons?: SpecialtyRolePersonVO[]) =>
|
|||
|
|
roundRatio((persons || []).reduce((sum, person) => sum + Number(person?.personRatio || 0), 0))
|
|||
|
|
|
|||
|
|
const getPersonTotalRatio = (row: SpecialtyRoleSplitVO) => sumPersonRatios(row.persons)
|
|||
|
|
|
|||
|
|
const getPersonTotalClass = (row: SpecialtyRoleSplitVO) =>
|
|||
|
|
getPersonTotalRatio(row) > 1 + EPSILON ? 'text-[var(--el-color-danger)]' : ''
|
|||
|
|
|
|||
|
|
const getPersonRatioMax = (row: SpecialtyRoleSplitVO, currentPerson: SpecialtyRolePersonVO) => {
|
|||
|
|
const otherTotalRatio = roundRatio(
|
|||
|
|
(row.persons || []).reduce((sum, person) => {
|
|||
|
|
if (person === currentPerson) {
|
|||
|
|
return sum
|
|||
|
|
}
|
|||
|
|
return sum + Number(person?.personRatio || 0)
|
|||
|
|
}, 0)
|
|||
|
|
)
|
|||
|
|
const currentPercent = toPercentValue(currentPerson.personRatio) ?? 0
|
|||
|
|
const remainPercent = toPercentValue(Math.max(0, 1 - otherTotalRatio)) ?? 0
|
|||
|
|
return Math.min(100, Math.max(currentPercent, remainPercent))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const countConfiguredPersons = (persons?: SpecialtyRolePersonVO[]) =>
|
|||
|
|
(persons || []).filter(
|
|||
|
|
(person) =>
|
|||
|
|
normalizePersonName(person.personName) ||
|
|||
|
|
(person.personRatio !== undefined && person.personRatio !== null)
|
|||
|
|
).length
|
|||
|
|
|
|||
|
|
const syncSpecialtyRows = (source: SpecialtyRoleSplitVO[], specialtyCode: string) => {
|
|||
|
|
const rows = source.filter((item) => item.specialtyCode === specialtyCode)
|
|||
|
|
rows.forEach((row) => {
|
|||
|
|
row.persons = (row.persons || []).map((person) => buildEditablePerson(person))
|
|||
|
|
row.roleRatio = roundRatio(row.roleRatio)
|
|||
|
|
row.roleAmount = multiplyAmount(row.specialtyAmount, row.roleRatio)
|
|||
|
|
row.persons = (row.persons || []).map((person) => ({
|
|||
|
|
...person,
|
|||
|
|
personAmount: multiplyAmount(row.roleAmount, person.personRatio)
|
|||
|
|
}))
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const syncAllDerivedValues = (source: SpecialtyRoleSplitVO[]) => {
|
|||
|
|
const specialtyCodeSet = new Set(source.map((item) => item.specialtyCode))
|
|||
|
|
specialtyCodeSet.forEach((specialtyCode) => syncSpecialtyRows(source, specialtyCode))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const buildSpecialtyGroups = (source: SpecialtyRoleSplitVO[]): SpecialtyGroup[] => {
|
|||
|
|
const map = new Map<string, SpecialtyRoleSplitVO[]>()
|
|||
|
|
source.forEach((item) => {
|
|||
|
|
const list = map.get(item.specialtyCode) || []
|
|||
|
|
list.push(item)
|
|||
|
|
map.set(item.specialtyCode, list)
|
|||
|
|
})
|
|||
|
|
return OUTPUT_SPLIT_SPECIALTY_OPTIONS.map((option) => {
|
|||
|
|
const rows = map.get(option.value)
|
|||
|
|
if (!rows?.length) {
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
const sortedRows = [...rows].sort((a, b) => Number(a.sortNo || 0) - Number(b.sortNo || 0))
|
|||
|
|
const specialtyName = sortedRows[0]?.specialtyName || option.label
|
|||
|
|
const specialtyAmount = roundAmount(sortedRows[0]?.specialtyAmount)
|
|||
|
|
const roleTotal = roundRatio(
|
|||
|
|
sortedRows.reduce((sum, item) => sum + Number(item.roleRatio || 0), 0)
|
|||
|
|
)
|
|||
|
|
const designRow = sortedRows.find((item) => item.roleCode === DESIGN_ROLE_CODE)
|
|||
|
|
const personCount = sortedRows.reduce(
|
|||
|
|
(sum, row) => sum + countConfiguredPersons(row.persons),
|
|||
|
|
0
|
|||
|
|
)
|
|||
|
|
return {
|
|||
|
|
specialtyCode: option.value,
|
|||
|
|
specialtyName,
|
|||
|
|
specialtyAmount,
|
|||
|
|
rows: sortedRows,
|
|||
|
|
roleTotal,
|
|||
|
|
designRatio: roundRatio(designRow?.roleRatio),
|
|||
|
|
personCount
|
|||
|
|
}
|
|||
|
|
}).filter((item): item is SpecialtyGroup => !!item)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const specialtyGroups = computed(() => buildSpecialtyGroups(roleList.value))
|
|||
|
|
const editSpecialtyGroups = computed(() => buildSpecialtyGroups(editRoleList.value))
|
|||
|
|
const specialtyTabOptions = computed(() =>
|
|||
|
|
specialtyGroups.value.map((item) => ({
|
|||
|
|
label: item.specialtyName,
|
|||
|
|
value: item.specialtyCode
|
|||
|
|
}))
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const currentSpecialtyGroup = computed(
|
|||
|
|
() =>
|
|||
|
|
specialtyGroups.value.find((item) => item.specialtyCode === selectedSpecialtyCode.value) ||
|
|||
|
|
specialtyGroups.value[0]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const currentEditSpecialtyGroup = computed(
|
|||
|
|
() =>
|
|||
|
|
editSpecialtyGroups.value.find((item) => item.specialtyCode === editSpecialtyCode.value) ||
|
|||
|
|
editSpecialtyGroups.value[0]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const buildSpecialtySummaryCards = (group?: SpecialtyGroup) => {
|
|||
|
|
if (!group) {
|
|||
|
|
return []
|
|||
|
|
}
|
|||
|
|
return [
|
|||
|
|
{ label: '专业金额', value: `${formatAmountText(group.specialtyAmount)} 元` },
|
|||
|
|
{ label: '角色合计', value: formatPercentText(group.roleTotal) },
|
|||
|
|
{ label: '设计比例', value: formatPercentText(group.designRatio) },
|
|||
|
|
{ label: '已配置人数', value: `${group.personCount} 人` }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const currentSpecialtySummaryCards = computed(() =>
|
|||
|
|
buildSpecialtySummaryCards(currentSpecialtyGroup.value)
|
|||
|
|
)
|
|||
|
|
const currentEditSpecialtySummaryCards = computed(() =>
|
|||
|
|
buildSpecialtySummaryCards(currentEditSpecialtyGroup.value)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const ensureSelectedCode = (codes: string[], target: { value: string }) => {
|
|||
|
|
if (!codes.length) {
|
|||
|
|
target.value = ''
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if (!codes.includes(target.value)) {
|
|||
|
|
target.value = codes[0]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
specialtyGroups,
|
|||
|
|
(groups) => {
|
|||
|
|
ensureSelectedCode(
|
|||
|
|
groups.map((item) => item.specialtyCode),
|
|||
|
|
selectedSpecialtyCode
|
|||
|
|
)
|
|||
|
|
},
|
|||
|
|
{ immediate: true }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
editSpecialtyGroups,
|
|||
|
|
(groups) => {
|
|||
|
|
ensureSelectedCode(
|
|||
|
|
groups.map((item) => item.specialtyCode),
|
|||
|
|
editSpecialtyCode
|
|||
|
|
)
|
|||
|
|
},
|
|||
|
|
{ immediate: true }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const addPerson = (row: SpecialtyRoleSplitVO) => {
|
|||
|
|
if (!row.persons) {
|
|||
|
|
row.persons = []
|
|||
|
|
}
|
|||
|
|
row.persons.push(buildEditablePerson())
|
|||
|
|
syncSpecialtyRows(editRoleList.value, row.specialtyCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const removePerson = (row: SpecialtyRoleSplitVO, index: number) => {
|
|||
|
|
row.persons?.splice(index, 1)
|
|||
|
|
syncSpecialtyRows(editRoleList.value, row.specialtyCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updatePersonRatio = (
|
|||
|
|
row: SpecialtyRoleSplitVO,
|
|||
|
|
person: SpecialtyRolePersonVO,
|
|||
|
|
value?: number
|
|||
|
|
) => {
|
|||
|
|
const maxValue = getPersonRatioMax(row, person)
|
|||
|
|
const nextValue =
|
|||
|
|
value === undefined || value === null ? undefined : Math.min(Math.max(value, 0), maxValue)
|
|||
|
|
person.personRatio = fromPercentValue(nextValue) ?? undefined
|
|||
|
|
syncSpecialtyRows(editRoleList.value, row.specialtyCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updateRoleRatio = (row: SpecialtyRoleSplitVO, value?: number) => {
|
|||
|
|
row.roleRatio = fromPercentValue(value) ?? 0
|
|||
|
|
syncSpecialtyRows(editRoleList.value, row.specialtyCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const buildSavePersons = (
|
|||
|
|
row: SpecialtyRoleSplitVO
|
|||
|
|
):
|
|||
|
|
| { persons: SpecialtyRolePersonVO[]; error?: never }
|
|||
|
|
| { persons?: never; error: string } => {
|
|||
|
|
const persons: SpecialtyRolePersonVO[] = []
|
|||
|
|
for (const person of row.persons || []) {
|
|||
|
|
const personName = normalizePersonName(person.personName)
|
|||
|
|
const hasName = !!personName
|
|||
|
|
const hasRatio = person.personRatio !== undefined && person.personRatio !== null
|
|||
|
|
if (!hasName && !hasRatio) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if (!hasName || !hasRatio) {
|
|||
|
|
return {
|
|||
|
|
error: `${row.specialtyName || row.specialtyCode}-${row.roleName || row.roleCode}的人员名称和比例必须同时填写`
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
persons.push({
|
|||
|
|
personName,
|
|||
|
|
personRatio: roundRatio(person.personRatio)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return { persons }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validateAndBuildSaveItems = () => {
|
|||
|
|
const items: SpecialtyRoleSplitApi.SpecialtyRoleSplitSaveItemVO[] = []
|
|||
|
|
for (const group of editSpecialtyGroups.value) {
|
|||
|
|
let roleTotal = 0
|
|||
|
|
for (const row of group.rows) {
|
|||
|
|
const result = buildSavePersons(row)
|
|||
|
|
if (result.error) {
|
|||
|
|
message.warning(result.error)
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
const persons = result.persons || []
|
|||
|
|
const personTotalRatio = sumPersonRatios(persons)
|
|||
|
|
if (personTotalRatio > 1 + EPSILON) {
|
|||
|
|
message.warning(
|
|||
|
|
`${row.specialtyName || row.specialtyCode}-${row.roleName || row.roleCode}的人员比例合计不能大于 100%`
|
|||
|
|
)
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
if ((row.roleCode === DESIGN_ROLE_CODE && Number(row.roleAmount || 0) > EPSILON) && persons.length === 0) {
|
|||
|
|
message.warning(`${group.specialtyName}的设计角色金额大于 0 时必须至少配置 1 个人员`)
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
roleTotal = roundRatio(roleTotal + Number(row.roleRatio || 0))
|
|||
|
|
items.push({
|
|||
|
|
specialtyCode: row.specialtyCode,
|
|||
|
|
roleCode: row.roleCode,
|
|||
|
|
roleRatio: roundRatio(row.roleRatio),
|
|||
|
|
persons
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
if (Math.abs(roleTotal - 1) > EPSILON) {
|
|||
|
|
message.warning(`${group.specialtyName}五类角色比例合计必须等于 100%`)
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return items
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const getProjectList = async () => {
|
|||
|
|
loading.value = true
|
|||
|
|
try {
|
|||
|
|
const data = await ProjectApi.getProjectPage(queryParams)
|
|||
|
|
projectList.value = data.list
|
|||
|
|
total.value = data.total
|
|||
|
|
if (!projectList.value.length) {
|
|||
|
|
currentProject.value = undefined
|
|||
|
|
planningList.value = []
|
|||
|
|
currentPlanning.value = undefined
|
|||
|
|
roleList.value = []
|
|||
|
|
editRoleList.value = []
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const targetProjectId = currentProject.value?.id || projectList.value[0].id
|
|||
|
|
const targetProject =
|
|||
|
|
projectList.value.find((item) => item.id === targetProjectId) || projectList.value[0]
|
|||
|
|
await nextTick()
|
|||
|
|
projectTableRef.value?.setCurrentRow(targetProject)
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const getPlanningList = async () => {
|
|||
|
|
if (!currentProject.value?.id) {
|
|||
|
|
planningList.value = []
|
|||
|
|
currentPlanning.value = undefined
|
|||
|
|
roleList.value = []
|
|||
|
|
editRoleList.value = []
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
planningLoading.value = true
|
|||
|
|
try {
|
|||
|
|
const list = await PlanningApi.getProjectPlanningListByProjectId(currentProject.value.id)
|
|||
|
|
planningList.value = list.filter((item) => isMajorOwnership(item.ownershipType))
|
|||
|
|
if (!planningList.value.length) {
|
|||
|
|
currentPlanning.value = undefined
|
|||
|
|
roleList.value = []
|
|||
|
|
editRoleList.value = []
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const targetPlanningId = currentPlanning.value?.id || planningList.value[0].id
|
|||
|
|
const targetPlanning =
|
|||
|
|
planningList.value.find((item) => item.id === targetPlanningId) || planningList.value[0]
|
|||
|
|
await nextTick()
|
|||
|
|
planningTableRef.value?.setCurrentRow(targetPlanning)
|
|||
|
|
} finally {
|
|||
|
|
planningLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const loadRoleList = async (planningId: number) => {
|
|||
|
|
const data = await SpecialtyRoleSplitApi.getSpecialtyRoleSplitListByPlanningId(planningId)
|
|||
|
|
roleList.value = cloneRoleRows(data)
|
|||
|
|
syncAllDerivedValues(roleList.value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleQuery = () => {
|
|||
|
|
queryParams.pageNo = 1
|
|||
|
|
getProjectList()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resetQuery = () => {
|
|||
|
|
queryFormRef.value?.resetFields()
|
|||
|
|
handleQuery()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleCurrentProjectChange = async (row?: ProjectApi.ProjectVO) => {
|
|||
|
|
currentProject.value = row || undefined
|
|||
|
|
await getPlanningList()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleCurrentPlanningChange = async (row?: PlanningApi.ProjectPlanningVO) => {
|
|||
|
|
currentPlanning.value = row || undefined
|
|||
|
|
if (!row?.id) {
|
|||
|
|
roleList.value = []
|
|||
|
|
editRoleList.value = []
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
await loadRoleList(row.id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const refreshCurrentPlanning = async () => {
|
|||
|
|
if (!currentPlanning.value?.id) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
await loadRoleList(currentPlanning.value.id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const openEditDialog = () => {
|
|||
|
|
if (!roleList.value.length) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
editRoleList.value = cloneRoleRows(roleList.value)
|
|||
|
|
syncAllDerivedValues(editRoleList.value)
|
|||
|
|
editSpecialtyCode.value = selectedSpecialtyCode.value || specialtyTabOptions.value[0]?.value || ''
|
|||
|
|
dialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleSave = async () => {
|
|||
|
|
if (!currentPlanning.value?.id) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const items = validateAndBuildSaveItems()
|
|||
|
|
if (!items) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
saveLoading.value = true
|
|||
|
|
try {
|
|||
|
|
await SpecialtyRoleSplitApi.saveSpecialtyRoleSplitBatch({
|
|||
|
|
planningId: currentPlanning.value.id,
|
|||
|
|
items
|
|||
|
|
})
|
|||
|
|
message.success('保存成功')
|
|||
|
|
dialogVisible.value = false
|
|||
|
|
await loadRoleList(currentPlanning.value.id)
|
|||
|
|
} finally {
|
|||
|
|
saveLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
getProjectList()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onActivated(() => {
|
|||
|
|
getProjectList()
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style lang="scss" scoped>
|
|||
|
|
.person-empty {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--el-text-color-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-display-list,
|
|||
|
|
.person-editor {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-display-row,
|
|||
|
|
.person-row {
|
|||
|
|
display: grid;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-display-row {
|
|||
|
|
grid-template-columns: minmax(120px, 1fr) 100px 120px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-display-name {
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-row {
|
|||
|
|
grid-template-columns: minmax(160px, 1fr) 180px 120px 64px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-amount {
|
|||
|
|
color: var(--el-text-color-regular);
|
|||
|
|
text-align: right;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-toolbar {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: flex-start;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 1500px) {
|
|||
|
|
.person-row {
|
|||
|
|
grid-template-columns: minmax(140px, 1fr) 160px 110px 64px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|