financial_system/frontend/src/views/SalaryProfilesView.vue
2026-06-29 14:59:49 +08:00

454 lines
17 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,
Plus,
Save,
Search,
TimerReset,
WalletCards,
X,
} 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 DatePicker from "../components/DatePicker.vue";
const employees = ref<EmployeeRecord[]>([]);
const profiles = ref<SalaryProfileRecord[]>([]);
const loading = ref(false);
const saving = ref(false);
const showEditor = 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;
});
const editorTitle = computed(() => {
return profiles.value.some((item) => item.employee_id === form.employee_id) ? "编辑薪资" : "新增薪资";
});
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 = "薪资档案已保存";
showEditor.value = false;
} 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 = "";
showEditor.value = true;
}
function openCreateProfile(): void {
resetForm();
successMessage.value = "";
errorMessage.value = "";
showEditor.value = true;
}
function closeEditor(): void {
showEditor.value = false;
}
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 salary-profile-page">
<header class="page-header">
<div>
<h1>薪资维护</h1>
<p>关联员工维护包月计时计件试用期规则和独立加班费单价</p>
</div>
</header>
<section class="salary-profile-summary-strip" aria-label="薪资档案指标">
<div class="salary-profile-summary-item">
<WalletCards :size="18" aria-hidden="true" />
<span>薪资档案</span>
<strong>{{ profiles.length }}</strong>
<small>已维护员工</small>
</div>
<div class="salary-profile-summary-item">
<BadgeDollarSign :size="18" aria-hidden="true" />
<span>基础工资合计</span>
<strong>{{ money(totalBaseSalary) }}</strong>
<small>当前筛选</small>
</div>
<div class="salary-profile-summary-item">
<HandCoins :size="18" aria-hidden="true" />
<span>提成合计</span>
<strong>{{ money(totalCommission) }}</strong>
<small>手工维护</small>
</div>
<div class="salary-profile-summary-item">
<TimerReset :size="18" aria-hidden="true" />
<span>平均加班单价</span>
<strong>{{ money(avgOvertimeRate) }}</strong>
<small>独立维护</small>
</div>
</section>
<p v-if="errorMessage && !showEditor" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel salary-profile-list-panel">
<div class="panel-header salary-profile-list-header">
<div>
<h2>薪资结构一览</h2>
<p>{{ profiles.length }} 条薪资档案,按员工编号、姓名、部门或岗位查询</p>
</div>
<form class="salary-profile-list-tools salary-profile-toolbar" @submit.prevent="loadData">
<input v-model="keyword" type="search" placeholder="员工姓名、编号、部门..." />
<button class="secondary-button compact-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="16" aria-hidden="true" />
<Search v-else :size="16" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
<button class="primary-button compact-button" type="button" @click="openCreateProfile">
<Plus :size="16" aria-hidden="true" />
<span>新增档案</span>
</button>
</form>
</div>
<div v-if="loading" class="empty-state">
<Loader2 class="spin" :size="18" aria-hidden="true" />
<p>正在加载薪资档案</p>
</div>
<div v-else-if="profiles.length" class="table-wrap">
<table class="data-table salary-profile-table">
<thead>
<tr>
<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="salary-profile-pay-cell">
<strong>{{ money(profile.base_salary) }}</strong>
<span>提成 {{ money(profile.commission_amount) }}</span>
</td>
<td>
<span class="salary-profile-rate-stack">
<span>工作日 {{ money(profile.overtime_rate) }}/h</span>
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
</span>
</td>
<td>{{ profile.effective_month || "长期" }}</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-else class="empty-state">
<p>暂无薪资档案</p>
</div>
</section>
<div v-if="showEditor" class="modal-backdrop salary-profile-edit-modal" @click.self="closeEditor">
<section class="profile-modal salary-profile-edit-card" aria-label="薪资档案维护">
<div class="modal-header">
<div>
<h2>{{ editorTitle }}</h2>
<p>在弹出卡片内维护员工当前薪资档案和独立加班单价。</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeEditor">
<X :size="18" aria-hidden="true" />
</button>
</div>
<form class="salary-profile-form salary-profile-modal-form" @submit.prevent="submit">
<section class="salary-profile-modal-section">
<div class="salary-profile-modal-section-title">
<strong>基础档案</strong>
<span>员工、薪资模式和生效月份</span>
</div>
<div class="salary-profile-modal-fields">
<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>
<DatePicker v-model="form.effective_month" mode="month" placeholder="长期" />
</label>
</div>
</section>
<section class="salary-profile-modal-section">
<div class="salary-profile-modal-section-title">
<strong>计时 / 计件 / 试用期</strong>
<span>按员工类型维护对应口径</span>
</div>
<div class="salary-profile-modal-fields">
<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>
</div>
</section>
<section class="salary-profile-modal-section">
<div class="salary-profile-modal-section-title">
<strong>提成与加班费</strong>
<span>提成和不同加班单价单独维护</span>
</div>
<div class="salary-profile-modal-fields">
<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>
</div>
</section>
<section class="salary-profile-modal-section salary-profile-modal-section-muted">
<div class="salary-profile-modal-section-title">
<strong>备注</strong>
<span>记录特殊薪资说明</span>
</div>
<label class="field">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
</section>
<p v-if="errorMessage" class="form-error salary-profile-modal-message">{{ errorMessage }}</p>
<div class="form-actions align-right salary-profile-modal-actions">
<button class="secondary-button" type="button" @click="resetForm">清空</button>
<button class="secondary-button" type="button" @click="closeEditor">取消</button>
<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>
</section>
</div>
</div>
</template>