Files
financial_system/frontend/src/views/SalaryProfilesView.vue
T
2026-06-18 13:21:20 +08:00

380 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
BadgeDollarSign,
HandCoins,
Loader2,
Pencil,
RefreshCw,
Save,
Search,
TimerReset,
WalletCards,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http";
import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles";
import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types";
import StatCard from "../components/StatCard.vue";
const employees = ref<EmployeeRecord[]>([]);
const profiles = ref<SalaryProfileRecord[]>([]);
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const keyword = ref("");
const form = reactive<SalaryProfileRequest>({
employee_id: 0,
salary_mode: "monthly",
base_salary: 0,
hourly_rate: 0,
piece_quantity: 0,
piece_unit_price: 0,
probation_type: "ratio",
probation_ratio: 1,
probation_salary: 0,
commission_amount: 0,
overtime_rate: 0,
weekend_overtime_rate: 0,
holiday_overtime_rate: 0,
effective_month: "",
remark: "",
});
const salaryModeOptions = [
{ value: "monthly", label: "包月" },
{ value: "hourly", label: "计时" },
{ value: "piecework", label: "计件" },
{ value: "probation", label: "试用期" },
];
const totalBaseSalary = computed(() => profiles.value.reduce((total, item) => total + Number(item.base_salary || 0), 0));
const totalCommission = computed(() => profiles.value.reduce((total, item) => total + Number(item.commission_amount || 0), 0));
const avgOvertimeRate = computed(() => {
if (!profiles.value.length) {
return 0;
}
return profiles.value.reduce((total, item) => total + Number(item.overtime_rate || 0), 0) / profiles.value.length;
});
onMounted(loadData);
async function loadData(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [employeeRows, profileRows] = await Promise.all([
listEmployees({ employment_status: "active" }),
listSalaryProfiles(keyword.value.trim()),
]);
employees.value = employeeRows;
profiles.value = profileRows;
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案加载失败";
} finally {
loading.value = false;
}
}
async function submit(): Promise<void> {
if (!form.employee_id) {
errorMessage.value = "请选择关联员工";
return;
}
saving.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const profile = await saveSalaryProfile({
employee_id: Number(form.employee_id),
salary_mode: form.salary_mode,
base_salary: Number(form.base_salary || 0),
hourly_rate: Number(form.hourly_rate || 0),
piece_quantity: Number(form.piece_quantity || 0),
piece_unit_price: Number(form.piece_unit_price || 0),
probation_type: form.probation_type,
probation_ratio: Number(form.probation_ratio || 1),
probation_salary: Number(form.probation_salary || 0),
commission_amount: Number(form.commission_amount || 0),
overtime_rate: Number(form.overtime_rate || 0),
weekend_overtime_rate: Number(form.weekend_overtime_rate || 0),
holiday_overtime_rate: Number(form.holiday_overtime_rate || 0),
effective_month: form.effective_month || "",
remark: form.remark.trim(),
});
profiles.value = [profile, ...profiles.value.filter((item) => item.employee_id !== profile.employee_id)];
successMessage.value = "薪资档案已保存";
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案保存失败";
} finally {
saving.value = false;
}
}
function editProfile(profile: SalaryProfileRecord): void {
Object.assign(form, {
employee_id: profile.employee_id,
salary_mode: profile.salary_mode,
base_salary: profile.base_salary,
hourly_rate: profile.hourly_rate,
piece_quantity: profile.piece_quantity,
piece_unit_price: profile.piece_unit_price,
probation_type: profile.probation_type,
probation_ratio: profile.probation_ratio,
probation_salary: profile.probation_salary,
commission_amount: profile.commission_amount,
overtime_rate: profile.overtime_rate,
weekend_overtime_rate: profile.weekend_overtime_rate,
holiday_overtime_rate: profile.holiday_overtime_rate,
effective_month: profile.effective_month,
remark: profile.remark,
});
successMessage.value = "";
errorMessage.value = "";
}
function resetForm(): void {
Object.assign(form, {
employee_id: 0,
salary_mode: "monthly",
base_salary: 0,
hourly_rate: 0,
piece_quantity: 0,
piece_unit_price: 0,
probation_type: "ratio",
probation_ratio: 1,
probation_salary: 0,
commission_amount: 0,
overtime_rate: 0,
weekend_overtime_rate: 0,
holiday_overtime_rate: 0,
effective_month: "",
remark: "",
});
}
function salaryModeLabel(value: string): string {
return salaryModeOptions.find((item) => item.value === value)?.label || value;
}
function profileRuleText(profile: SalaryProfileRecord): string {
if (profile.salary_mode === "hourly") {
return `${money(profile.hourly_rate)}/h`;
}
if (profile.salary_mode === "piecework") {
return `${profile.piece_quantity} × ${money(profile.piece_unit_price)}`;
}
if (profile.salary_mode === "probation") {
return profile.probation_type === "fixed"
? `固定 ${money(profile.probation_salary)}`
: `${Math.round(profile.probation_ratio * 100)}%`;
}
return money(profile.base_salary);
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(Number(value || 0));
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>薪资维护</h1>
<p>关联员工维护包月、计时、计件、试用期规则和独立加班费单价。</p>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header>
<section class="stats-grid">
<StatCard title="薪资档案" :value="String(profiles.length)" :icon="WalletCards" tone="blue" meta="已维护" />
<StatCard title="基础工资合计" :value="money(totalBaseSalary)" :icon="BadgeDollarSign" tone="neutral" meta="当前筛选" />
<StatCard title="提成合计" :value="money(totalCommission)" :icon="HandCoins" tone="green" meta="手工维护" />
<StatCard title="平均加班单价" :value="money(avgOvertimeRate)" :icon="TimerReset" tone="red" meta="独立维护" />
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>查询薪资档案</h2>
<p>按员工编号、姓名、部门或岗位查询</p>
</div>
</div>
<form class="search-form" @submit.prevent="loadData">
<label class="field">
<span>关键字</span>
<input v-model="keyword" type="search" placeholder="员工姓名、编号、部门..." />
</label>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</form>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>维护薪资档案</h2>
<p>一名员工维护一条当前薪资档案</p>
</div>
<button class="secondary-button" type="button" @click="resetForm">清空</button>
</div>
<form class="form-grid salary-profile-form" @submit.prevent="submit">
<label class="field">
<span>关联员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>薪资模式</span>
<select v-model="form.salary_mode">
<option v-for="item in salaryModeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</label>
<label class="field">
<span>基础工资/底薪</span>
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计时小时单价</span>
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件数量</span>
<input v-model.number="form.piece_quantity" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件单价</span>
<input v-model.number="form.piece_unit_price" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期方式</span>
<select v-model="form.probation_type">
<option value="ratio">固定比例</option>
<option value="fixed">固定金额</option>
</select>
</label>
<label class="field">
<span>试用期比例</span>
<input v-model.number="form.probation_ratio" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期固定金额</span>
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>默认提成</span>
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>工作日加班单价</span>
<input v-model.number="form.overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>周末加班单价</span>
<input v-model.number="form.weekend_overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>节假日加班单价</span>
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>生效月份</span>
<input v-model="form.effective_month" type="month" />
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : "保存薪资" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</section>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>薪资档案列表</h2>
<p>{{ profiles.length }} 条薪资档案</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>员工</th>
<th>部门/岗位</th>
<th>薪资模式</th>
<th>计算规则</th>
<th>默认提成</th>
<th>加班单价</th>
<th>生效月份</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="profile in profiles" :key="profile.id">
<td>
<strong>{{ profile.employee.name }}</strong>
<span>{{ profile.employee.employee_no }}</span>
</td>
<td>
<strong>{{ profile.employee.department || "-" }}</strong>
<span>{{ profile.employee.position || "-" }}</span>
</td>
<td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td>
<td class="mono strong">{{ profileRuleText(profile) }}</td>
<td class="mono">{{ money(profile.commission_amount) }}</td>
<td class="mono">
工作日 {{ money(profile.overtime_rate) }}/h
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
</td>
<td>{{ profile.effective_month || "长期" }}</td>
<td class="note-cell">{{ profile.remark || "-" }}</td>
<td>
<button class="link-button" type="button" @click="editProfile(profile)">
<Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!profiles.length && !loading" class="empty-state">
<p>暂无薪资档案</p>
</div>
</section>
</div>
</template>