feat(subjects): add visit drawer and editable screening dates
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user