移除特殊费用模块

This commit is contained in:
Cheng Zhou
2026-05-12 10:15:18 +08:00
parent cd7e12ce1f
commit 6e90370a5f
30 changed files with 117 additions and 2819 deletions
@@ -1,280 +0,0 @@
<template>
<div class="page">
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<el-card class="detail-card unified-shell" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
<div class="status-badges actions">
<el-tag v-if="detail.is_paid" type="success" effect="dark" size="default">{{ TEXT.modules.feeSpecials.isPaid }}</el-tag>
<el-tag v-else type="info" effect="plain" size="default">{{ TEXT.modules.feeSpecials.unpaid }}</el-tag>
<el-tag v-if="detail.is_verified" type="info" effect="dark" size="default">{{ TEXT.modules.feeSpecials.isVerified }}</el-tag>
<el-tag v-else type="info" effect="plain" size="default">{{ TEXT.modules.feeSpecials.unverified }}</el-tag>
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack" class="header-action-btn">
{{ TEXT.common.actions.back }}
</el-button>
</div>
</div>
</template>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item :label="TEXT.common.fields.occurDate" label-class-name="desc-label">
<span class="date-text">{{ displayDate(detail.happen_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.site" label-class-name="desc-label">
{{ centerName || TEXT.common.fallback }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.category" label-class-name="desc-label">
<el-tag size="small" :type="getCategoryType(detail.category)" effect="light">
{{ displayEnum(TEXT.enums.feeSpecialCategory, detail.category) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.amount" label-class-name="desc-label">
<span class="amount-text">{{ formatAmount(detail.amount) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.feeSpecials.paidFlag" label-class-name="desc-label">
<span v-if="detail.is_paid">
{{ displayDate(detail.paid_date) }}
</span>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.feeSpecials.verifiedFlag" label-class-name="desc-label">
<span v-if="detail.is_verified">
{{ displayDate(detail.verified_date) }}
</span>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.description" :span="2" label-class-name="desc-label">
{{ detail.description || TEXT.common.fallback }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="section-card unified-shell" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeSpecials.attachmentTitle }}</span>
</div>
</template>
<FeeAttachmentPanel
v-if="expenseId"
:entity-type="'special_expense'"
:entity-id="expenseId"
:groups="attachmentGroups"
:readonly="isReadOnly"
/>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import { getSpecialExpense } from "../../api/feeSpecials";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { displayDate, displayEnum } from "../../utils/display";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const canWrite = computed(() => can("fees.special.write"));
const expenseId = route.params.expenseId as string;
const loading = ref(false);
const errorMessage = ref("");
const detail = reactive<any>({
happen_date: "",
center_id: "",
category: "",
amount: 0,
description: "",
is_paid: false,
paid_date: "",
is_verified: false,
verified_date: "",
});
const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
const attachmentGroups = [
{
key: "voucher",
label: TEXT.modules.feeSpecials.attachmentVoucher,
description: TEXT.modules.feeSpecials.attachmentVoucherDesc,
emptyText: TEXT.modules.feeSpecials.attachmentVoucherEmpty,
},
{
key: "invoice",
label: TEXT.modules.feeSpecials.attachmentInvoice,
description: TEXT.modules.feeSpecials.attachmentInvoiceDesc,
emptyText: TEXT.modules.feeSpecials.attachmentInvoiceEmpty,
},
{
key: "other",
label: TEXT.modules.feeSpecials.attachmentOther,
description: TEXT.modules.feeSpecials.attachmentOtherDesc,
emptyText: TEXT.modules.feeSpecials.attachmentOtherEmpty,
},
];
const centerName = computed(() => {
const match = sites.value.find((site) => site.id === detail.center_id);
return match?.name || "";
});
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
if (!expenseId) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await getSpecialExpense(expenseId);
Object.assign(detail, data?.data || {});
// 同步中心名称到面包屑
if (centerName.value) {
study.setViewContext({ siteName: centerName.value });
}
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const formatAmount = (value: any) => {
const numberValue = Number(value);
if (Number.isNaN(numberValue)) return TEXT.common.fallback;
return numberValue.toFixed(2);
};
const getCategoryType = (category: string) => {
if (category === 'travel') return 'warning';
if (category === 'meeting') return 'success';
if (category === 'material') return 'primary';
return 'info';
};
const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/fees/special/${expenseId}/edit`);
};
const goBack = () => router.push("/fees/special");
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.actions {
display: flex;
gap: 12px;
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
}
.detail-card, .section-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.section-card :deep(.el-card__body) {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
}
.status-badges {
display: flex;
gap: 8px;
}
.custom-descriptions :deep(.desc-label) {
background-color: var(--ctms-bg-color-page) !important;
color: var(--ctms-text-secondary);
width: 150px;
font-weight: 500;
}
.amount-text {
font-family: var(--el-font-family);
font-weight: 600;
}
.date-text {
font-family: var(--el-font-family);
}
.text-muted {
color: var(--ctms-text-placeholder);
font-size: 13px;
}
</style>
@@ -1,487 +0,0 @@
<template>
<div class="page">
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="loadDetail">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
<el-card class="form-card unified-shell" shadow="never">
<template #header>
<div class="card-header header-actions-row">
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</template>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site">
<el-select v-model="form.center_id" clearable :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
:disabled="!site.is_active"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.occurDate" prop="happen_date">
<el-date-picker v-model="form.happen_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.category" prop="category" required>
<el-select v-model="form.category" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item :label="TEXT.common.fields.description">
<el-input v-model="form.description" :disabled="isReadOnly" type="textarea" :rows="3" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-card class="form-card section-margin attachment-card unified-shell" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.common.fields.status }}</span>
</div>
</template>
<div class="status-section">
<el-row :gutter="24">
<el-col :span="12">
<div class="status-box">
<div class="status-header">
<span class="status-title">{{ TEXT.modules.feeSpecials.paidFlag }}</span>
<el-switch v-model="form.is_paid" :disabled="isReadOnly" @change="onPaidToggle" />
</div>
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.paid_date" class="status-form-item">
<el-date-picker
v-model="form.paid_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly || !form.is_paid"
class="full-width"
/>
</el-form-item>
</div>
</el-col>
<el-col :span="12">
<div class="status-box">
<div class="status-header">
<span class="status-title">{{ TEXT.modules.feeSpecials.verifiedFlag }}</span>
<el-switch v-model="form.is_verified" :disabled="isReadOnly" @change="onVerifiedToggle" />
</div>
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.verified_date" class="status-form-item">
<el-date-picker
v-model="form.verified_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly || !form.is_verified"
class="full-width"
/>
</el-form-item>
</div>
</el-col>
</el-row>
<div v-if="specialErrors.verification" class="payment-error">
{{ specialErrors.verification }}
</div>
</div>
</el-card>
<el-card class="form-card section-margin unified-shell" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeSpecials.attachmentTitle }}</span>
</div>
</template>
<FeeAttachmentPanel
v-if="form.id"
:entity-type="'special_expense'"
:entity-id="form.id"
:groups="attachmentGroups"
:readonly="isReadOnly"
/>
<StateEmpty v-else :description="TEXT.modules.feeSpecials.uploadHint" class="compact-empty" />
</el-card>
<div class="footer-actions">
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit" class="save-btn">
{{ TEXT.common.actions.save }}
</el-button>
</div>
</el-form>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { createSpecialExpense, getSpecialExpense, updateSpecialExpense } from "../../api/feeSpecials";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import StateEmpty from "../../components/StateEmpty.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const formRef = ref();
const form = reactive({
id: "",
project_id: "",
center_id: "",
category: "travel",
amount: 0,
happen_date: "",
description: "",
is_paid: false,
paid_date: "",
is_verified: false,
verified_date: "",
});
const specialErrors = reactive({
paid_date: "",
verified_date: "",
verification: "",
});
const expenseId = computed(() => route.params.expenseId as string | undefined);
const isEdit = computed(() => !!expenseId.value);
const attachmentGroups = [
{
key: "voucher",
label: TEXT.modules.feeSpecials.attachmentVoucher,
description: TEXT.modules.feeSpecials.attachmentVoucherDesc,
emptyText: TEXT.modules.feeSpecials.attachmentVoucherEmpty,
},
{
key: "invoice",
label: TEXT.modules.feeSpecials.attachmentInvoice,
description: TEXT.modules.feeSpecials.attachmentInvoiceDesc,
emptyText: TEXT.modules.feeSpecials.attachmentInvoiceEmpty,
},
{
key: "other",
label: TEXT.modules.feeSpecials.attachmentOther,
description: TEXT.modules.feeSpecials.attachmentOtherDesc,
emptyText: TEXT.modules.feeSpecials.attachmentOtherEmpty,
},
];
const categoryOptions = Object.keys(TEXT.enums.feeSpecialCategory).map((value) => ({
value,
label: (TEXT.enums.feeSpecialCategory as any)[value],
}));
const rules = {
category: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
amount: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
};
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const loadDetail = async () => {
if (!expenseId.value) return;
loading.value = true;
errorMessage.value = "";
resetSpecialErrors();
try {
const { data } = await getSpecialExpense(expenseId.value);
const detail = data?.data || {};
Object.assign(form, {
id: detail.id || expenseId.value,
project_id: detail.project_id || study.currentStudy?.id || "",
center_id: detail.center_id || "",
category: detail.category || "travel",
amount: Number(detail.amount || 0),
happen_date: detail.happen_date || "",
description: detail.description || "",
is_paid: !!detail.is_paid,
paid_date: detail.paid_date || "",
is_verified: !!detail.is_verified,
verified_date: detail.verified_date || "",
});
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const resetSpecialErrors = () => {
specialErrors.paid_date = "";
specialErrors.verified_date = "";
specialErrors.verification = "";
};
const onPaidToggle = () => {
if (!form.is_paid) {
form.paid_date = "";
form.is_verified = false;
form.verified_date = "";
}
};
const onVerifiedToggle = () => {
if (form.is_verified && !form.is_paid) {
form.is_paid = true;
}
if (!form.is_verified) {
form.verified_date = "";
}
};
const validateSpecial = () => {
resetSpecialErrors();
let ok = true;
if (form.is_paid && !form.paid_date) {
specialErrors.paid_date = TEXT.modules.feeSpecials.paidDateRequired;
ok = false;
}
if (form.is_verified && !form.verified_date) {
specialErrors.verified_date = TEXT.modules.feeSpecials.verifiedDateRequired;
ok = false;
}
if (form.is_verified && !form.is_paid) {
specialErrors.verification = TEXT.modules.feeSpecials.verifyRequiresPaid;
ok = false;
}
return ok;
};
const submit = async () => {
if (!study.currentStudy?.id) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
ElMessage.warning("中心已停用");
return;
}
const formOk = await formRef.value?.validate?.().catch(() => false);
if (!formOk) return;
if (!validateSpecial()) {
ElMessage.error(TEXT.modules.feeSpecials.validationFailed);
return;
}
saving.value = true;
try {
const payload = {
project_id: study.currentStudy.id,
center_id: form.center_id || null,
category: form.category,
amount: Number(form.amount || 0),
happen_date: form.happen_date || null,
description: form.description || null,
is_paid: !!form.is_paid,
paid_date: form.paid_date || null,
is_verified: !!form.is_verified,
verified_date: form.verified_date || null,
};
const updatePayload = {
center_id: payload.center_id,
category: payload.category,
amount: payload.amount,
happen_date: payload.happen_date,
description: payload.description,
is_paid: payload.is_paid,
paid_date: payload.paid_date,
is_verified: payload.is_verified,
verified_date: payload.verified_date,
};
let savedId = expenseId.value || form.id;
if (isEdit.value && savedId) {
await updateSpecialExpense(savedId, updatePayload);
} else {
const { data } = await createSpecialExpense(payload);
savedId = data?.data?.id;
form.id = savedId || "";
}
ElMessage.success(TEXT.common.messages.saveSuccess);
if (savedId) {
router.push(`/fees/special/${savedId}`);
} else {
goBack();
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/fees/special");
onMounted(async () => {
await loadSites();
if (isEdit.value) {
await loadDetail();
} else {
form.project_id = study.currentStudy?.id || "";
}
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
}
.form-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.section-margin {
margin-top: 24px;
}
.attachment-card :deep(.el-card__body) {
padding: 0;
}
.card-header {
padding: 4px 0;
}
.header-actions-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
}
.full-width {
width: 100%;
}
.status-section {
padding: 8px 0;
}
.status-box {
background-color: var(--ctms-bg-color-page);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--ctms-border-color);
height: 100%;
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.status-title {
font-weight: 600;
color: var(--ctms-text-main);
}
.status-form-item {
margin-bottom: 0 !important;
}
.payment-error {
color: var(--ctms-danger);
font-size: 12px;
margin-top: 12px;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-bottom: 32px;
}
.save-btn {
padding: 0 32px;
}
.compact-empty :deep(.state) {
padding: 24px 0 !important;
flex-direction: row;
gap: 12px;
}
.compact-empty :deep(.state-icon) {
font-size: 24px !important;
margin-bottom: 0 !important;
}
.compact-empty :deep(.state-title) {
font-size: 14px !important;
font-weight: normal !important;
margin: 0 !important;
}
.compact-empty :deep(.state-desc) {
margin: 0 !important;
font-size: 14px !important;
}
</style>
-450
View File
@@ -1,450 +0,0 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="overview">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeSpecials.totalAmount"
:value="formatAmount(overview.totalAmount)"
unit="元"
:loading="loading"
:icon="Coin"
type="primary"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeSpecials.paidAmount"
:value="formatAmount(overview.paidAmount)"
unit="元"
:loading="loading"
:icon="Wallet"
type="success"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeSpecials.verifiedAmount"
:value="formatAmount(overview.verifiedAmount)"
unit="元"
:loading="loading"
:icon="CircleCheck"
type="info"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeSpecials.attachmentTotal"
:value="overview.attachmentTotal"
:loading="loading"
:icon="Document"
type="warning"
/>
</el-col>
</el-row>
</div>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<div class="main-content-flat unified-shell" v-else>
<div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item">
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</div>
<div class="filter-item">
<el-select v-model="filters.category" clearable :placeholder="TEXT.common.fields.category" class="filter-select">
<template #prefix>
<el-icon><Menu /></el-icon>
</template>
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
<div class="filter-item">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
unlink-panels
value-format="YYYY-MM-DD"
:start-placeholder="TEXT.common.placeholders.startDate"
:end-placeholder="TEXT.common.placeholders.endDate"
class="filter-date"
/>
</div>
<div class="filter-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeSpecials.newTitle }}
</el-button>
</el-form>
</div>
<section class="unified-section special-expense-table-section section--flush-x section--flush-top section--flush-bottom">
<el-table
:data="sortedExpenses"
style="width: 100%"
@row-click="onRowClick"
:row-class-name="expenseRowClass"
class="special-expense-table"
table-layout="fixed"
>
<el-table-column :label="TEXT.common.fields.occurDate">
<template #default="scope">
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope">
<span class="site-text">{{ scope.row.center_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.category">
<template #default="scope">
<el-tag size="small" :type="getCategoryType(scope.row.category)" effect="light" class="category-tag">
{{ displayEnum(TEXT.enums.feeSpecialCategory, scope.row.category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" align="right">
<template #default="scope">
<span class="amount-text">{{ formatAmount(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.remark" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.description || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column width="120" align="center" :label="TEXT.common.fields.status">
<template #default="scope">
<el-tooltip :content="scope.row.is_paid ? `${TEXT.modules.feeSpecials.isPaid} (${displayDate(scope.row.paid_date)})` : TEXT.modules.feeSpecials.unpaid" placement="top">
<el-icon :class="scope.row.is_paid ? 'status-icon success' : 'status-icon muted'"><Wallet /></el-icon>
</el-tooltip>
<el-tooltip :content="scope.row.is_verified ? `${TEXT.modules.feeSpecials.isVerified} (${displayDate(scope.row.verified_date)})` : TEXT.modules.feeSpecials.unverified" placement="top">
<el-icon :class="scope.row.is_verified ? 'status-icon info' : 'status-icon muted'"><CircleCheck /></el-icon>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeSpecials.attachmentCount" align="center">
<template #default="scope">
<el-tag v-if="scope.row.attachments_count > 0" effect="plain" type="info" round size="small">
{{ scope.row.attachments_count }}
</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div class="table-empty">{{ TEXT.modules.feeSpecials.empty }}</div>
</template>
</el-table>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Coin, Wallet, CircleCheck, Document, Plus, Location, Menu } from "@element-plus/icons-vue";
import { deleteSpecialExpense, listSpecialExpenses } from "../../api/feeSpecials";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { displayDate, displayEnum } from "../../utils/display";
import StateLoading from "../../components/StateLoading.vue";
import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue";
import { TEXT } from "../../locales";
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const canWrite = computed(() => can("fees.special.write"));
const loading = ref(false);
const errorMessage = ref("");
const expenses = ref<any[]>([]);
const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const expenseRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const filteredExpenses = computed(() => {
const [dateFrom, dateTo] = filters.dateRange || [];
return expenses.value.filter((item) => {
if (filters.centerId && item?.center_id !== filters.centerId) return false;
if (filters.category && item?.category !== filters.category) return false;
if (dateFrom || dateTo) {
const value = item?.happen_date;
if (!value) return false;
if (dateFrom && value < dateFrom) return false;
if (dateTo && value > dateTo) return false;
}
return true;
});
});
const sortedExpenses = computed(() =>
[...filteredExpenses.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const filters = reactive({
centerId: study.currentSite?.id || "",
category: "",
dateRange: [] as string[],
});
const categoryOptions = Object.keys(TEXT.enums.feeSpecialCategory).map((value) => ({
value,
label: (TEXT.enums.feeSpecialCategory as any)[value],
}));
const toNumber = (value: any) => {
const numberValue = Number(value);
return Number.isNaN(numberValue) ? 0 : numberValue;
};
const overview = computed(() => {
const totals = filteredExpenses.value.reduce(
(acc, item) => {
acc.totalAmount += toNumber(item.amount);
if (item.is_paid) acc.paidAmount += toNumber(item.amount);
if (item.is_verified) acc.verifiedAmount += toNumber(item.amount);
acc.attachmentTotal += toNumber(item.attachments_count);
return acc;
},
{
totalAmount: 0,
paidAmount: 0,
verifiedAmount: 0,
attachmentTotal: 0,
}
);
return {
...totals,
};
});
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
const projectId = study.currentStudy?.id;
if (!projectId) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await listSpecialExpenses({ projectId });
expenses.value = data?.data || [];
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.centerId = "";
filters.category = "";
filters.dateRange = [];
};
const goNew = () => router.push("/fees/special/new");
const goDetail = (id: string) => router.push(`/fees/special/${id}`);
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => {
if (!row?.id || !canWrite.value) return;
if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteSpecialExpense(row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const formatAmount = (value: any) => {
const numberValue = Number(value);
if (Number.isNaN(numberValue)) return TEXT.common.fallback;
return numberValue.toFixed(2);
};
const getCategoryType = (category: string) => {
if (category === 'travel') return 'warning';
if (category === 'meeting') return 'success';
if (category === 'material') return 'primary';
return 'info';
};
watch(() => filters.centerId, (val: string) => {
if (val) {
const matched = sites.value.find(s => s.id === val);
if (matched) study.setCurrentSite(matched);
} else {
study.setCurrentSite(null);
}
});
watch(() => study.currentSite, (newSite: any) => {
filters.centerId = newSite?.id || "";
});
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.overview {
margin: 0;
}
.filter-form {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
}
.filter-item {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select {
width: 140px;
}
.filter-date {
width: 220px;
}
.filter-actions {
margin-bottom: 0 !important;
}
.filter-spacer {
flex: 1;
}
.special-expense-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.date-text {
font-family: var(--el-font-family);
color: var(--ctms-text-regular);
}
.site-text {
font-weight: 500;
color: var(--ctms-text-main);
}
.amount-text {
font-family: var(--el-font-family);
font-weight: 500;
}
.category-tag {
font-weight: 500;
}
.status-icon {
font-size: 18px;
margin: 0 6px;
outline: none;
}
.status-icon.success { color: var(--el-color-success); }
.status-icon.info { color: var(--el-color-info); }
.status-icon.muted { color: var(--ctms-border-color); opacity: 0.6; }
.text-muted {
color: var(--ctms-text-placeholder);
}
.table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style>