217 lines
7.9 KiB
JavaScript
217 lines
7.9 KiB
JavaScript
|
|
import { computed, onMounted, ref } from 'vue'
|
|||
|
|
|
|||
|
|
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
|||
|
|
import { fetchEmployeeMeta } from '../../services/employees.js'
|
|||
|
|
|
|||
|
|
const FALLBACK_DEPARTMENTS = [
|
|||
|
|
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
|
|||
|
|
{ code: 'FINANCE-DEPT', name: '财务部', costCenter: 'CC-2100' },
|
|||
|
|
{ code: 'TECH-DEPT', name: '技术部', costCenter: 'CC-6100' },
|
|||
|
|
{ code: 'HR-DEPT', name: '人力资源部', costCenter: 'CC-3200' },
|
|||
|
|
{ code: 'PRODUCTION-DEPT', name: '生产部', costCenter: 'CC-7200' },
|
|||
|
|
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const EXPENSE_BLUEPRINTS = [
|
|||
|
|
{ expenseType: '市场推广费', total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
|
|||
|
|
{ expenseType: '差旅费', total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
|
|||
|
|
{ expenseType: '办公费', total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
|
|||
|
|
{ expenseType: '培训费', total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
|
|||
|
|
{ expenseType: '软件服务费', total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' }
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const currency = (value) =>
|
|||
|
|
Number(value || 0).toLocaleString('zh-CN', {
|
|||
|
|
minimumFractionDigits: 2,
|
|||
|
|
maximumFractionDigits: 2
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function buildDepartmentRows(departmentCode) {
|
|||
|
|
const seed = Array.from(String(departmentCode || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0)
|
|||
|
|
const factor = 0.88 + (seed % 18) / 100
|
|||
|
|
|
|||
|
|
return EXPENSE_BLUEPRINTS.map((item, index) => {
|
|||
|
|
const totalAmount = Math.round(item.total * factor)
|
|||
|
|
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
|
|||
|
|
const occupiedAmount = Math.round(item.occupied * (0.92 + ((seed + index * 3) % 10) / 100))
|
|||
|
|
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
|
|||
|
|
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
...item,
|
|||
|
|
totalAmount,
|
|||
|
|
usedAmount,
|
|||
|
|
occupiedAmount,
|
|||
|
|
leftAmount,
|
|||
|
|
rate,
|
|||
|
|
rateTone: rate >= item.warning ? 'danger' : rate >= item.warning - 12 ? 'warn' : 'ok',
|
|||
|
|
warningTone: item.warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
|
|||
|
|
warningLine: `${item.warning}%`,
|
|||
|
|
total: currency(totalAmount),
|
|||
|
|
used: currency(usedAmount),
|
|||
|
|
occupied: currency(occupiedAmount),
|
|||
|
|
left: currency(leftAmount)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildTrendData(rows) {
|
|||
|
|
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
|
|||
|
|
const used = rows.reduce((sum, item) => sum + item.usedAmount + item.occupiedAmount, 0)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
|||
|
|
budget: [0.05, 0.18, 0.25, 0.34, 0.45, 0.52, 0.68, 0.76, 0.84, 0.91, 0.96, 1].map((ratio) =>
|
|||
|
|
Math.round(total * ratio)
|
|||
|
|
),
|
|||
|
|
used: [0.03, 0.1, 0.13, 0.22, 0.3, 0.37, 0.51, 0.59, 0.69, 0.73, 0.86, 0.96].map((ratio) =>
|
|||
|
|
Math.round(used * ratio)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default {
|
|||
|
|
name: 'BudgetCenterView',
|
|||
|
|
components: {
|
|||
|
|
BudgetTrendChart
|
|||
|
|
},
|
|||
|
|
setup() {
|
|||
|
|
const departments = ref(FALLBACK_DEPARTMENTS)
|
|||
|
|
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
|||
|
|
const departmentKeyword = ref('')
|
|||
|
|
const filters = ref({
|
|||
|
|
period: '2026年度',
|
|||
|
|
expenseType: '全部',
|
|||
|
|
status: '全部'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const activeDepartment = computed(() =>
|
|||
|
|
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
|
|||
|
|
const departmentRows = computed(() => buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value))
|
|||
|
|
const visibleBudgetRows = computed(() =>
|
|||
|
|
departmentRows.value
|
|||
|
|
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
|
|||
|
|
.filter((row) => {
|
|||
|
|
if (filters.value.status === '全部') return true
|
|||
|
|
if (filters.value.status === '预警') return row.rateTone === 'warn'
|
|||
|
|
if (filters.value.status === '管控') return row.rateTone === 'danger'
|
|||
|
|
return row.rateTone === 'ok'
|
|||
|
|
})
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const totals = computed(() => {
|
|||
|
|
const rows = departmentRows.value
|
|||
|
|
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
|
|||
|
|
const used = rows.reduce((sum, item) => sum + item.usedAmount, 0)
|
|||
|
|
const occupied = rows.reduce((sum, item) => sum + item.occupiedAmount, 0)
|
|||
|
|
return {
|
|||
|
|
total,
|
|||
|
|
used,
|
|||
|
|
occupied,
|
|||
|
|
left: Math.max(total - used - occupied, 0)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const budgetMetrics = computed(() => [
|
|||
|
|
{
|
|||
|
|
label: '预算总额',
|
|||
|
|
value: `¥${currency(totals.value.total)}`,
|
|||
|
|
note: '本年累计',
|
|||
|
|
tone: 'green',
|
|||
|
|
icon: 'mdi mdi-wallet-outline'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '已发生',
|
|||
|
|
value: `¥${currency(totals.value.used)}`,
|
|||
|
|
note: `占比 ${((totals.value.used / totals.value.total) * 100).toFixed(2)}%`,
|
|||
|
|
tone: 'blue',
|
|||
|
|
icon: 'mdi mdi-chart-line'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '已占用',
|
|||
|
|
value: `¥${currency(totals.value.occupied)}`,
|
|||
|
|
note: `占比 ${((totals.value.occupied / totals.value.total) * 100).toFixed(2)}%`,
|
|||
|
|
tone: 'orange',
|
|||
|
|
icon: 'mdi mdi-briefcase-check-outline'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '剩余可用',
|
|||
|
|
value: `¥${currency(totals.value.left)}`,
|
|||
|
|
note: `占比 ${((totals.value.left / totals.value.total) * 100).toFixed(2)}%`,
|
|||
|
|
tone: 'green',
|
|||
|
|
icon: 'mdi mdi-currency-cny'
|
|||
|
|
}
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
const visibleDepartments = computed(() => {
|
|||
|
|
const keyword = departmentKeyword.value.trim()
|
|||
|
|
return departments.value
|
|||
|
|
.filter((item) => !keyword || item.name.includes(keyword) || item.code.includes(keyword))
|
|||
|
|
.map((item) => ({
|
|||
|
|
...item,
|
|||
|
|
icon: item.code === activeDepartmentCode.value ? 'mdi mdi-account-group-outline' : 'mdi mdi-domain'
|
|||
|
|
}))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const warnings = computed(() =>
|
|||
|
|
departmentRows.value
|
|||
|
|
.slice()
|
|||
|
|
.sort((a, b) => b.rate - a.rate)
|
|||
|
|
.slice(0, 4)
|
|||
|
|
.map((row, index) => ({
|
|||
|
|
title: row.expenseType,
|
|||
|
|
desc: `使用率已达 ${row.rate}%,${row.rate >= row.warning ? '已超过预警线' : '接近预警线'}(${row.warningLine})`,
|
|||
|
|
date: index < 2 ? '2026-05-12' : '2026-05-10',
|
|||
|
|
tone: row.rate >= row.warning ? 'danger' : row.rate >= row.warning - 12 ? 'warn' : 'ok'
|
|||
|
|
}))
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const trendData = computed(() => buildTrendData(departmentRows.value))
|
|||
|
|
|
|||
|
|
async function loadDepartments() {
|
|||
|
|
try {
|
|||
|
|
const payload = await fetchEmployeeMeta()
|
|||
|
|
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
|
|||
|
|
const nextDepartments = options
|
|||
|
|
.filter((item) => item?.code && item?.name)
|
|||
|
|
.map((item) => ({
|
|||
|
|
code: String(item.code),
|
|||
|
|
name: String(item.name),
|
|||
|
|
costCenter: String(item.costCenter || '')
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
if (nextDepartments.length) {
|
|||
|
|
departments.value = nextDepartments
|
|||
|
|
if (!nextDepartments.some((item) => item.code === activeDepartmentCode.value)) {
|
|||
|
|
activeDepartmentCode.value = nextDepartments[0].code
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Failed to load budget departments from employee meta:', error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
void loadDepartments()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
activeDepartmentCode,
|
|||
|
|
activeDepartmentName,
|
|||
|
|
budgetMetrics,
|
|||
|
|
departmentKeyword,
|
|||
|
|
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
|||
|
|
filters,
|
|||
|
|
periods: ['2026年度', '2026年Q2', '2026年5月'],
|
|||
|
|
statuses: ['全部', '正常', '预警', '管控'],
|
|||
|
|
trendData,
|
|||
|
|
visibleBudgetRows,
|
|||
|
|
visibleDepartments,
|
|||
|
|
warnings
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|