feat(subjects): add visit drawer and editable screening dates

This commit is contained in:
Cheng Zhou
2026-06-08 11:01:32 +08:00
parent e5ed18c3cd
commit a3f6a04f35
15 changed files with 1515 additions and 525 deletions
@@ -0,0 +1,344 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="640px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="visit-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ isEditing ? TEXT.common.actions.edit : TEXT.modules.subjectDetail.dialogVisit }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" label-position="top" class="visit-editor-form">
<div class="form-group">
<div class="form-group-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#3b82f6;flex-shrink:0"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{{ TEXT.modules.subjectDetail.dialogVisit }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.visitCode" required>
<el-input v-model="form.visit_code" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.plannedDate" :required="!isEditing && isFirstVisit">
<el-input v-if="isEditing" :model-value="displayDate(form.planned_date) || TEXT.common.fallback" disabled />
<el-date-picker
v-else
v-model="form.planned_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
:disabled="isFormReadOnly"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.actualDate">
<el-date-picker
v-model="form.actual_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
:disabled="isFormReadOnly"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.status">
<el-tag
:type="getVisitStatusTagType(getVisitStatus(form, form.actual_date))"
effect="light"
class="visit-status-tag"
>
{{ getVisitStatusLabel(form, form.actual_date) }}
</el-tag>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.notes">
<el-input
v-model="form.notes"
type="textarea"
:rows="3"
:disabled="isFormReadOnly"
:placeholder="TEXT.common.placeholders.input"
/>
</el-form-item>
<div v-if="isFormReadOnly" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">
{{ 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 } from "element-plus";
import { createVisit, updateVisit } from "../../api/visits";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
import { displayDate, displayEnum } from "../../utils/display";
const props = defineProps<{
modelValue: boolean;
studyId: string;
subjectId: string;
visit: any;
isReadOnly?: boolean;
canCreate?: boolean;
canUpdate?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
saved: [];
}>();
const saving = ref(false);
const formRef = ref<FormInstance>();
const form = reactive({
visit_code: "",
planned_date: "",
window_start: "",
window_end: "",
actual_date: "",
notes: "",
});
const isEditing = computed(() => !!props.visit?.id);
const isFirstVisit = computed(() => form.visit_code === "V1");
const canSave = computed(() => (isEditing.value ? !!props.canUpdate : !!props.canCreate));
const isFormReadOnly = computed(() => !canSave.value || !!props.isReadOnly);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: [],
}));
const parseDate = (value?: string | null) => {
if (!value) return null;
const date = new Date(value);
if (isNaN(date.getTime())) return null;
return date;
};
const getVisitStatus = (row: any, actualDateOverride?: string) => {
if (row?.status === "CANCELLED") return "CANCELLED";
const actualDate = parseDate(actualDateOverride ?? row?.actual_date);
const windowStart = parseDate(row?.window_start);
const windowEnd = parseDate(row?.window_end);
const plannedDate = parseDate(row?.planned_date);
if (actualDate) {
if ((windowStart && actualDate < windowStart) || (windowEnd && actualDate > windowEnd)) {
return "OVERDUE";
}
return "DONE";
}
const today = new Date();
const deadline = windowEnd || plannedDate;
if (deadline && today > deadline) {
return "LOST";
}
return "PLANNED";
};
const getVisitStatusTagType = (status: string) => {
switch (status) {
case "DONE": return "success";
case "OVERDUE": return "warning";
case "LOST": return "danger";
case "CANCELLED": return "info";
default: return "info";
}
};
const getVisitStatusLabel = (row: any, actualDateOverride?: string) => {
const status = getVisitStatus(row, actualDateOverride);
if (status === "CANCELLED" && String(row?.notes || "").includes("提前终止")) {
return "不适用";
}
return displayEnum(TEXT.enums.visitStatus, status);
};
const loadDetail = () => {
form.visit_code = props.visit?.visit_code || "";
form.planned_date = props.visit?.planned_date || "";
form.window_start = props.visit?.window_start || "";
form.window_end = props.visit?.window_end || "";
form.actual_date = props.visit?.actual_date || "";
form.notes = props.visit?.notes || "";
drawerDirtyGuard.syncBaseline();
};
const saveForm = async () => {
if (!props.studyId || !props.subjectId) return;
if (isFormReadOnly.value) {
ElMessage.warning(props.isReadOnly ? "中心已停用" : "权限不足");
return;
}
if (!isEditing.value && isFirstVisit.value && !form.planned_date) {
ElMessage.error("首次新增访视必须填写计划访视日期");
return;
}
saving.value = true;
try {
if (isEditing.value) {
const payload = {
actual_date: form.actual_date || null,
notes: form.notes || null,
};
await updateVisit(props.studyId, props.subjectId, props.visit.id, payload);
} else {
await createVisit(props.studyId, props.subjectId, {
subject_id: props.subjectId,
visit_code: form.visit_code,
planned_date: form.planned_date || null,
actual_date: form.actual_date || null,
notes: form.notes || null,
});
}
drawerDirtyGuard.syncBaseline();
emit("update:modelValue", false);
emit("saved");
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
loadDetail();
},
{ immediate: true },
);
</script>
<style scoped>
:deep(.visit-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.visit-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px;
}
.editor-header {
display: flex;
flex-direction: column;
}
.editor-title {
font-size: 20px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.2;
}
.visit-editor-form {
padding: 0 4px 0 0;
}
.form-group {
border: 1px solid #e8eef6;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
}
.form-group:hover {
border-color: #d0dced;
}
.form-group + .form-group {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
display: flex;
align-items: center;
gap: 8px;
}
.full-width {
width: 100%;
}
.inactive-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #fff7d6;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 13px;
color: #92400e;
margin-bottom: 8px;
}
.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;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 20px 16px;
}
.visit-status-tag {
pointer-events: none;
}
</style>