Files
ctms/frontend/src/views/fees/ContractFeeEditorDrawer.vue
T
2026-06-08 11:06:07 +08:00

831 lines
24 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.
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="720px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="contract-fee-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ contractId ? TEXT.modules.feeContracts.editTitle : TEXT.modules.feeContracts.newTitle }}</div>
</div>
</template>
<StateError v-if="drawerErrorMessage" :description="drawerErrorMessage">
<template #action>
<el-button type="primary" @click="reloadEditingDetail">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="drawerLoading" :rows="6" />
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" class="contract-fee-form">
<!-- 合同信息 -->
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--blue" />
{{ TEXT.modules.feeContracts.contractInfoTitle }}
</div>
<div class="field-grid field-grid--3">
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select
v-model="form.center_id"
:disabled="!!contractId || isFormReadOnly"
: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-form-item :label="TEXT.common.fields.contractNo" prop="contract_no">
<el-input v-model="form.contract_no" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.signedDate" prop="signed_date">
<el-date-picker
v-model="form.signed_date"
:disabled="isFormReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
/>
</el-form-item>
</div>
<div class="field-grid field-grid--3">
<el-form-item :label="`${TEXT.modules.feeContracts.contractAmount}(万元)`" prop="contract_amount">
<el-input-number
v-model="form.contract_amount"
:disabled="isFormReadOnly"
:min="0"
:precision="2"
:step="1"
class="full-width"
controls-position="right"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases">
<el-input-number v-model="form.contract_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item>
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
<el-input-number v-model="form.actual_cases" :disabled="isFormReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item>
</div>
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" :disabled="isFormReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<div v-if="selectedSiteInactive" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</div>
<!-- 分期付款 -->
<div class="form-section">
<div class="form-section-title form-section-title--action">
<span class="section-title-left">
<span class="dot dot--amber" />
{{ TEXT.modules.feeContracts.paymentTitle }}
</span>
<el-button type="primary" size="small" :disabled="isFormReadOnly" @click="addPayment" class="add-payment-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}
</el-button>
</div>
<div v-if="payments.length === 0" class="section-empty">
{{ TEXT.modules.feeContracts.paymentEmpty }}
</div>
<div v-else class="payment-list">
<div v-for="(payment, index) in payments" :key="payment.tempId" class="payment-card">
<div class="payment-card-header">
<div class="payment-card-seq">
<span class="seq-badge">{{ index + 1 }}</span>
<span class="seq-label">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</div>
<el-button link type="danger" :disabled="isFormReadOnly" @click="removePayment(index)" class="remove-btn">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="payment-card-body">
<div class="field-grid field-grid--3">
<el-form-item :label="`${TEXT.common.fields.amount}(万元)`" :error="paymentErrors[index]?.amount">
<el-input-number v-model="payment.amount" :disabled="isFormReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
</el-form-item>
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
<div class="status-row">
<el-checkbox v-model="payment.is_paid" :disabled="isFormReadOnly" @change="() => onPaidToggle(payment)">
{{ TEXT.modules.feeContracts.isPaid }}
</el-checkbox>
<el-date-picker
v-if="payment.is_paid"
v-model="payment.paid_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isFormReadOnly"
class="status-datepicker"
size="small"
/>
</div>
</el-form-item>
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
<div class="status-row">
<el-checkbox v-model="payment.is_verified" :disabled="isFormReadOnly" @change="() => onVerifiedToggle(payment)">
{{ TEXT.modules.feeContracts.isVerified }}
</el-checkbox>
<el-date-picker
v-if="payment.is_verified"
v-model="payment.verified_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isFormReadOnly"
class="status-datepicker"
size="small"
/>
</div>
</el-form-item>
</div>
<el-form-item :label="TEXT.common.fields.remark" class="mb-0">
<el-input v-model="payment.remark" :disabled="isFormReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</div>
<div v-if="paymentErrors[index]?.verification" class="payment-error">
{{ paymentErrors[index].verification }}
</div>
</div>
</div>
</div>
<!-- 附件 -->
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--green" />
{{ TEXT.modules.feeContracts.attachmentTitle }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="currentStudyId"
:entity-type="'contract_fee'"
:entity-id="form.id"
:entity-groups="attachmentGroups"
:mode="'upload'"
:readonly="isFormReadOnly"
:center-upload-card-content="true"
:upload-card-columns="3"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)" class="footer-btn">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly || drawerLoading" @click="saveForm" class="footer-btn footer-btn--primary">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { Delete, Plus } from "@element-plus/icons-vue";
import {
createContractFee,
createContractPayment,
deleteContractPayment,
getContractFee,
updateContractFee,
updateContractPayment,
} from "../../api/feeContracts";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { TEXT } from "../../locales";
type ContractFormModel = {
id: string;
study_id: string;
center_id: string;
contract_no: string;
signed_date: string;
contract_amount: number;
remark: string;
contract_cases: number;
actual_cases: number | null;
};
type PaymentFormModel = {
id?: string;
tempId: string;
amount: number;
paid_date: string;
verified_date: string;
is_paid: boolean;
is_verified: boolean;
remark: string;
};
const props = defineProps<{
modelValue: boolean;
contractId?: string;
sites: any[];
initialCenterId?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
saved: [contractId: string];
}>();
const study = useStudyStore();
const { can } = usePermission();
const canCreate = computed(() => can("fees.contract.create"));
const canUpdate = computed(() => can("fees.contract.update"));
const currentStudyId = computed(() => study.currentStudy?.id || "");
const drawerLoading = ref(false);
const drawerErrorMessage = ref("");
const saving = ref(false);
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
props.sites.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const defaultForm: ContractFormModel = {
id: "",
study_id: "",
center_id: "",
contract_no: "",
signed_date: "",
contract_amount: 0,
remark: "",
contract_cases: 0,
actual_cases: null,
};
const form = reactive<ContractFormModel>({ ...defaultForm });
const payments = ref<PaymentFormModel[]>([]);
const removedPaymentIds = ref<string[]>([]);
const paymentErrors = ref<Record<string, string>[]>([]);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
payments: payments.value,
removedPaymentIds: removedPaymentIds.value,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const selectedSiteInactive = computed(() => !!props.contractId && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const isFormReadOnly = computed(() => (props.contractId ? !canUpdate.value : !canCreate.value) || selectedSiteInactive.value);
const attachmentGroups = [
{
entityType: "contract_fee_contract",
label: TEXT.modules.feeContracts.attachmentContract,
uploadText: "点击上传合同",
},
{
entityType: "contract_fee_voucher",
label: TEXT.modules.feeContracts.attachmentVoucher,
uploadText: "点击上传凭证",
},
{
entityType: "contract_fee_invoice",
label: TEXT.modules.feeContracts.attachmentInvoice,
uploadText: "点击上传发票",
},
];
const rules: FormRules<ContractFormModel> = {
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
contract_amount: [{ required: true, type: "number", message: TEXT.common.messages.required, trigger: "change" }],
contract_cases: [{ required: true, type: "number", message: TEXT.common.messages.required, trigger: "change" }],
};
const resetForm = () => {
Object.assign(form, { ...defaultForm, study_id: study.currentStudy?.id || "", center_id: props.initialCenterId || "" });
payments.value = [];
removedPaymentIds.value = [];
paymentErrors.value = [];
drawerErrorMessage.value = "";
formRef.value?.clearValidate();
};
const normalizePayment = (payment: any): PaymentFormModel => ({
id: payment.id,
tempId: payment.id || `${Date.now()}-${Math.random()}`,
amount: Number(payment.amount || 0) / 10000,
paid_date: payment.paid_date || "",
verified_date: payment.verified_date || "",
is_paid: !!payment.is_paid,
is_verified: !!payment.is_verified,
remark: payment.remark || "",
});
const loadEditingDetail = async (id: string) => {
drawerLoading.value = true;
drawerErrorMessage.value = "";
try {
const { data } = await getContractFee(id);
const detail = data?.data || {};
Object.assign(form, {
id: detail.id || id,
study_id: detail.study_id || study.currentStudy?.id || "",
center_id: detail.center_id || "",
contract_no: detail.contract_no || "",
signed_date: detail.signed_date || "",
contract_amount: Number(detail.contract_amount || 0) / 10000,
remark: detail.remark || "",
contract_cases: Number(detail.contract_cases || 0),
actual_cases: detail.actual_cases ?? null,
});
payments.value = (detail.payments || []).map(normalizePayment);
removedPaymentIds.value = [];
paymentErrors.value = [];
formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
drawerErrorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
drawerLoading.value = false;
}
};
const reloadEditingDetail = () => {
if (props.contractId) loadEditingDetail(props.contractId);
};
const addPayment = () => {
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
payments.value.push({
tempId: `${Date.now()}-${Math.random()}`,
amount: 0,
paid_date: "",
verified_date: "",
is_paid: false,
is_verified: false,
remark: "",
});
};
const removePayment = (index: number) => {
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
const payment = payments.value[index];
if (payment?.id) {
removedPaymentIds.value.push(payment.id);
}
payments.value.splice(index, 1);
};
const onPaidToggle = (payment: PaymentFormModel) => {
if (isFormReadOnly.value) return;
if (!payment.is_paid) {
payment.paid_date = "";
payment.is_verified = false;
payment.verified_date = "";
}
};
const onVerifiedToggle = (payment: PaymentFormModel) => {
if (isFormReadOnly.value) return;
if (payment.is_verified && !payment.is_paid) {
payment.is_paid = true;
}
if (!payment.is_verified) {
payment.verified_date = "";
}
};
const validatePayments = () => {
const errors: Record<string, string>[] = [];
let ok = true;
payments.value.forEach((payment, index) => {
const entry: Record<string, string> = {};
if (payment.amount === null || payment.amount === undefined || Number(payment.amount) < 0) {
entry.amount = TEXT.modules.feeContracts.amountInvalid;
ok = false;
}
if (payment.is_paid && !payment.paid_date) {
entry.paid_date = TEXT.modules.feeContracts.paidDateRequired;
ok = false;
}
if (payment.is_verified && !payment.verified_date) {
entry.verified_date = TEXT.modules.feeContracts.verifiedDateRequired;
ok = false;
}
if (payment.is_verified && !payment.is_paid) {
entry.verification = TEXT.modules.feeContracts.verifyRequiresPaid;
ok = false;
}
errors[index] = entry;
});
paymentErrors.value = errors;
return ok;
};
const saveForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
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 (!validatePayments()) {
ElMessage.error(TEXT.modules.feeContracts.paymentValidationFailed);
return;
}
saving.value = true;
try {
const payload = {
study_id: studyId,
center_id: form.center_id,
contract_no: form.contract_no || null,
signed_date: form.signed_date || null,
contract_amount: Number(form.contract_amount || 0) * 10000,
remark: form.remark || null,
contract_cases: Number(form.contract_cases || 0),
actual_cases: form.actual_cases === null ? null : Number(form.actual_cases),
};
const updatePayload = {
contract_amount: payload.contract_amount,
contract_no: payload.contract_no,
signed_date: payload.signed_date,
remark: payload.remark,
contract_cases: payload.contract_cases,
actual_cases: payload.actual_cases,
};
let savedId = props.contractId || form.id;
if (props.contractId && savedId) {
await updateContractFee(savedId, updatePayload);
} else {
const { data } = await createContractFee(payload);
savedId = data?.data?.id;
form.id = savedId || "";
}
if (savedId) {
for (const paymentId of removedPaymentIds.value) {
await deleteContractPayment(paymentId);
}
removedPaymentIds.value = [];
for (const payment of payments.value) {
const paymentPayload = {
amount: Number(payment.amount || 0) * 10000,
paid_date: payment.paid_date || null,
verified_date: payment.verified_date || null,
is_paid: !!payment.is_paid,
is_verified: !!payment.is_verified,
remark: payment.remark || null,
};
if (payment.id) {
await updateContractPayment(payment.id, paymentPayload);
} else {
const { data } = await createContractPayment(savedId, paymentPayload);
payment.id = data?.data?.id;
payment.tempId = payment.id || payment.tempId;
}
}
await attachmentPanelRef.value?.uploadPending(savedId);
}
drawerDirtyGuard.syncBaseline();
emit("update:modelValue", false);
emit("saved", savedId || "");
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => props.modelValue,
async (visible) => {
if (!visible) return;
resetForm();
if (props.contractId) {
await loadEditingDetail(props.contractId);
} else {
drawerDirtyGuard.syncBaseline();
}
},
);
</script>
<style scoped>
/* ==================== Drawer ==================== */
:deep(.contract-fee-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 20px 24px 8px;
}
:deep(.contract-fee-editor-drawer > .el-drawer__body) {
padding: 0 24px 4px;
}
.editor-header {
display: flex;
flex-direction: column;
}
.editor-title {
font-size: 18px;
font-weight: 700;
color: #0a0a0a;
line-height: 1.2;
letter-spacing: -0.01em;
}
/* ==================== Form ==================== */
.contract-fee-form {
padding: 0;
}
.form-section {
padding-bottom: 6px;
}
.form-section + .form-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.form-section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
margin-bottom: 16px;
}
.form-section-title--action {
justify-content: space-between;
}
.section-title-left {
display: flex;
align-items: center;
gap: 10px;
}
/* Dots */
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot--blue { background: #3b82f6; }
.dot--amber { background: #f59e0b; }
.dot--green { background: #22c55e; }
/* ==================== Field Grid ==================== */
.field-grid {
display: grid;
gap: 0 16px;
}
.field-grid--2 {
grid-template-columns: 1fr 1fr;
}
.field-grid--3 {
grid-template-columns: 1fr 1fr 1fr;
}
.full-width {
width: 100%;
}
/* ==================== Inactive Hint ==================== */
.inactive-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 13px;
color: #92400e;
margin-top: 4px;
}
.hint-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #f59e0b;
color: #ffffff;
font-size: 12px;
line-height: 1;
flex-shrink: 0;
}
/* ==================== Empty State ==================== */
.section-empty {
padding: 32px 0;
display: flex;
justify-content: center;
color: #a3a3a3;
font-size: 13px;
font-weight: 500;
}
/* ==================== Payment Cards ==================== */
.payment-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.payment-card {
border: 1px solid #f0f0f0;
border-radius: 10px;
padding: 16px 18px 14px;
background: #fafbfc;
transition: border-color 0.15s ease;
}
.payment-card:hover {
border-color: #d4d4d4;
}
.payment-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.payment-card-seq {
display: flex;
align-items: center;
gap: 8px;
}
.seq-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 4px;
background: #f0f0f0;
color: #525252;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.seq-label {
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
}
.remove-btn {
font-size: 13px;
}
.payment-card-body {
display: flex;
flex-direction: column;
gap: 0;
}
/* Status row (checkbox + date picker) */
.status-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.status-datepicker {
width: auto;
min-width: 0;
flex: 1;
}
.status-datepicker :deep(.el-date-editor.el-input),
.status-datepicker :deep(.el-input__wrapper) {
width: 100%;
min-width: 0;
}
/* ==================== Payment Error ==================== */
.payment-error {
color: #dc2626;
font-size: 12px;
margin-top: 10px;
padding: 6px 10px;
background: #fef2f2;
border-radius: 6px;
}
.mb-0 {
margin-bottom: 0 !important;
}
/* ==================== Footer ==================== */
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.footer-btn {
height: 36px;
border-radius: 8px;
padding: 0 20px;
font-size: 13px;
font-weight: 500;
}
.footer-btn--primary {
padding: 0 24px;
}
/* ==================== Element Overrides ==================== */
.contract-fee-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.contract-fee-form :deep(.el-form-item__label) {
font-size: 12px;
font-weight: 600;
color: #525252;
padding-bottom: 4px;
}
.contract-fee-form :deep(.el-form-item__error) {
font-size: 11px;
}
.add-payment-btn {
height: 28px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
padding: 0 10px;
}
/* ==================== Responsive ==================== */
@media (max-width: 700px) {
.field-grid--2,
.field-grid--3 {
grid-template-columns: 1fr;
}
}
</style>