401 lines
14 KiB
Vue
401 lines
14 KiB
Vue
<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>
|