454 lines
17 KiB
Vue
454 lines
17 KiB
Vue
<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>
|