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

401 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 {
FileSpreadsheet,
HandCoins,
Loader2,
Pencil,
Plus,
Save,
Search,
Trash2,
Upload,
X,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import {
createCommission,
deleteCommission,
importCommissions,
listCommissions,
updateCommission,
} from "../api/commissions";
import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http";
import type { CommissionRecord, CommissionRecordRequest, EmployeeRecord } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
import StatCard from "../components/StatCard.vue";
const employees = ref<EmployeeRecord[]>([]);
const records = ref<CommissionRecord[]>([]);
const loading = ref(false);
const saving = ref(false);
const importing = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const editingId = ref<number | null>(null);
const showCommissionModal = ref(false);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const filters = reactive({
commission_month: new Date().toISOString().slice(0, 7),
keyword: "",
});
const form = reactive<CommissionRecordRequest>({
employee_id: 0,
commission_month: new Date().toISOString().slice(0, 7),
commission_type: "销售提成",
amount: 0,
source_type: "manual",
business_ref: "",
remark: "",
});
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const totalAmount = computed(() => records.value.reduce((sum, item) => sum + Number(item.amount || 0), 0));
onMounted(loadData);
async function loadData(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [employeeRows, pageData] = await Promise.all([
listEmployees({ employment_status: "active" }),
listCommissions({
page: page.value,
page_size: pageSize.value,
commission_month: filters.commission_month,
keyword: filters.keyword.trim(),
}),
]);
employees.value = employeeRows;
records.value = pageData.items;
total.value = pageData.total;
page.value = pageData.page;
pageSize.value = pageData.page_size;
} 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 {
if (editingId.value) {
await updateCommission(editingId.value, normalizedForm());
successMessage.value = "提成记录已更新";
} else {
await createCommission(normalizedForm());
successMessage.value = "提成记录已新增";
}
await loadData();
showCommissionModal.value = false;
resetForm();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成记录保存失败";
} finally {
saving.value = false;
}
}
async function removeRecord(record: CommissionRecord): Promise<void> {
if (!window.confirm(`确认删除 ${record.employee.name}${record.commission_month} 提成?`)) {
return;
}
try {
await deleteCommission(record.id);
successMessage.value = "提成记录已删除";
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成记录删除失败";
}
}
async function handleImport(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
importing.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const response = await importCommissions(file);
successMessage.value = `导入完成:${response.imported_count}`;
await loadData();
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成导入失败";
} finally {
importing.value = false;
input.value = "";
}
}
function editRecord(record: CommissionRecord): void {
errorMessage.value = "";
successMessage.value = "";
editingId.value = record.id;
Object.assign(form, {
employee_id: record.employee_id,
commission_month: record.commission_month,
commission_type: record.commission_type,
amount: record.amount,
source_type: record.source_type,
business_ref: record.business_ref,
remark: record.remark,
});
showCommissionModal.value = true;
}
function resetForm(): void {
editingId.value = null;
Object.assign(form, {
employee_id: 0,
commission_month: filters.commission_month || new Date().toISOString().slice(0, 7),
commission_type: "销售提成",
amount: 0,
source_type: "manual",
business_ref: "",
remark: "",
});
}
function openCreateModal(): void {
errorMessage.value = "";
successMessage.value = "";
resetForm();
showCommissionModal.value = true;
}
function closeCommissionModal(): void {
showCommissionModal.value = false;
errorMessage.value = "";
resetForm();
}
function searchRecords(): void {
page.value = 1;
loadData();
}
function previousPage(): void {
if (page.value <= 1) {
return;
}
page.value -= 1;
loadData();
}
function nextPage(): void {
if (page.value >= totalPages.value) {
return;
}
page.value += 1;
loadData();
}
function normalizedForm(): CommissionRecordRequest {
return {
employee_id: Number(form.employee_id),
commission_month: form.commission_month,
commission_type: form.commission_type.trim() || "销售提成",
amount: Number(form.amount || 0),
source_type: "manual",
business_ref: form.business_ref.trim(),
remark: form.remark.trim(),
};
}
function sourceTypeLabel(sourceType: string): string {
const labels: Record<string, string> = {
manual: "手工录入",
excel: "Excel导入",
dingtalk: "钉钉同步",
};
return labels[sourceType] || sourceType || "-";
}
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 commissions-page">
<header class="page-header">
<div>
<h1>提成管理</h1>
<p>维护销售提成项目奖金和绩效奖励工资计算时按月份自动汇总</p>
</div>
<div class="header-actions">
<label class="secondary-button file-button">
<Upload :size="18" aria-hidden="true" />
<span>{{ importing ? "导入中" : "导入Excel" }}</span>
<input accept=".xlsx,.xls" type="file" :disabled="importing" @change="handleImport" />
</label>
</div>
</header>
<section class="summary-strip commission-summary-strip">
<StatCard title="当前记录" :value="String(records.length)" :icon="FileSpreadsheet" tone="blue" meta="本页" />
<StatCard title="筛选总数" :value="String(total)" :icon="HandCoins" tone="neutral" meta="数据库" />
<StatCard title="本页提成" :value="money(totalAmount)" :icon="HandCoins" tone="green" meta="当前页合计" />
</section>
<p v-if="errorMessage && !showCommissionModal" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel table-panel commission-list-panel">
<div class="panel-header commission-list-header">
<div>
<h2>提成列表</h2>
<p>第 {{ page }} / {{ totalPages }} 页,共 {{ total }} 条</p>
</div>
<button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" />
<span>新增提成</span>
</button>
</div>
<form class="table-filter-bar commission-filter-bar" @submit.prevent="searchRecords">
<label class="field commission-keyword-field">
<span>关键字</span>
<input v-model="filters.keyword" type="search" placeholder="员工、类型或业务单号" />
</label>
<label class="field commission-month-field">
<span>月份</span>
<DatePicker v-model="filters.commission_month" mode="month" placeholder="请选择月份" />
</label>
<button class="primary-button commission-search-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>
<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>
</tr>
</thead>
<tbody>
<tr v-for="record in records" :key="record.id">
<td>
<strong>{{ record.employee.name }}</strong>
<span>{{ record.employee.employee_no }}</span>
</td>
<td class="mono">{{ record.commission_month }}</td>
<td>{{ record.commission_type }}</td>
<td class="mono strong">{{ money(record.amount) }}</td>
<td><span class="status-chip neutral">{{ sourceTypeLabel(record.source_type) }}</span></td>
<td>{{ record.business_ref || "-" }}</td>
<td class="note-cell">{{ record.remark || "-" }}</td>
<td class="commission-action-cell">
<div class="row-action-group commission-row-actions">
<button class="icon-button table-action-icon" type="button" title="编辑提成" aria-label="编辑提成" @click="editRecord(record)">
<Pencil :size="15" aria-hidden="true" />
</button>
<button class="icon-button table-action-icon danger" type="button" title="删除提成" aria-label="删除提成" @click="removeRecord(record)">
<Trash2 :size="15" aria-hidden="true" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!records.length && !loading" class="empty-state">
<p>暂无提成记录</p>
</div>
<div class="form-actions align-right">
<button class="secondary-button" type="button" :disabled="loading || page <= 1" @click="previousPage">上一页</button>
<button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">下一页</button>
</div>
</section>
<Teleport to="body">
<div v-if="showCommissionModal" class="modal-backdrop commission-modal-backdrop" @click.self="closeCommissionModal">
<section class="profile-modal commission-modal" aria-label="提成维护">
<div class="modal-header">
<div>
<h2>{{ editingId ? "编辑提成" : "新增提成" }}</h2>
<p>按员工和月份维护提成金额</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeCommissionModal">
<X :size="21" aria-hidden="true" />
</button>
</div>
<form class="commission-form" @submit.prevent="submit">
<div class="commission-form-grid">
<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>
<DatePicker v-model="form.commission_month" mode="month" placeholder="请选择月份" />
</label>
<label class="field">
<span>提成类型</span>
<input v-model="form.commission_type" type="text" placeholder="销售提成、项目奖金..." />
</label>
<label class="field">
<span>金额</span>
<input v-model.number="form.amount" min="0" step="0.01" type="number" />
</label>
<label class="field commission-business-field">
<span>业务单号</span>
<input v-model="form.business_ref" type="text" />
</label>
<label class="field commission-remark-field">
<span>备注</span>
<textarea v-model="form.remark" rows="2" />
</label>
<p v-if="errorMessage" class="form-error commission-modal-message">{{ errorMessage }}</p>
<div class="form-actions align-right commission-form-actions">
<button class="secondary-button" type="button" @click="resetForm">清空</button>
<button class="secondary-button" type="button" @click="closeCommissionModal">取消</button>
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : editingId ? "保存修改" : "新增提成" }}</span>
</button>
</div>
</div>
</form>
</section>
</div>
</Teleport>
</div>
</template>