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,17 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readRiskIssueSae = () => readFileSync(resolve(__dirname, "./RiskIssueSae.vue"), "utf8");
describe("RiskIssueSae status", () => {
it("only exposes follow-up and closed statuses in the risk issue status column", () => {
const source = readRiskIssueSae();
expect(source).toContain("const aeStatusOptions = [");
expect(source).toContain('{ value: "FOLLOW_UP", label: TEXT.enums.aeStatus.FOLLOW_UP }');
expect(source).toContain('{ value: "CLOSED", label: TEXT.enums.aeStatus.CLOSED }');
expect(source).toContain("normalizeAeStatus");
expect(source).not.toContain("Object.entries(TEXT.enums.aeStatus)");
});
});
+125 -144
View File
@@ -1,89 +1,93 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar bar--flush">
<el-form :inline="true" class="filter-form">
<div class="filter-item-form">
<el-input v-model="filters.keyword" :placeholder="TEXT.common.fields.keyword" clearable class="filter-input-comp">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="page">
<div class="page-header">
<h2 class="page-title">{{ TEXT.menu.riskIssueSae }}</h2>
</div>
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.keyword }}</span>
<el-input v-model="filters.keyword" clearable placeholder="请输入" class="filter-input" />
</div>
<div class="filter-item-form">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.site }}</span>
<el-select v-model="filters.siteId" clearable filterable placeholder="请选择" class="filter-select">
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<div class="filter-item-form">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.status }}</span>
<el-select v-model="filters.status" clearable placeholder="请选择" class="filter-select">
<el-option v-for="option in aeStatusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
<div class="filter-item-form">
<el-select v-model="filters.seriousness" :placeholder="TEXT.common.fields.seriousness" clearable class="filter-select-comp">
<template #prefix>
<el-icon><Warning /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.seriousness }}</span>
<el-select v-model="filters.seriousness" clearable placeholder="请选择" class="filter-select">
<el-option v-for="option in aeSeriousnessOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
<div class="filter-item-form">
<el-select v-model="filters.aeType" :placeholder="TEXT.common.fields.aeType" clearable class="filter-select-comp">
<template #prefix>
<el-icon><Warning /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.aeType }}</span>
<el-select v-model="filters.aeType" clearable placeholder="请选择" class="filter-select">
<el-option v-for="option in aeTypeOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
<div class="filter-actions-comp">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
<div class="filter-actions">
<el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
</div>
</el-form>
</div>
</div>
<div class="unified-section table-section section--flush-x section--flush-top section--flush-bottom">
<el-table :data="filteredItems" v-loading="loading" style="width: 100%" class="risk-table" table-layout="fixed">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate">
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
</el-table-column>
<el-table-column prop="subject_no" :label="TEXT.modules.riskIssueSae.subjectNo" show-overflow-tooltip>
<template #default="scope">{{ subjectMap[scope.row.subject_id] || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="term" :label="TEXT.common.fields.title" show-overflow-tooltip />
<el-table-column prop="seriousness" :label="TEXT.common.fields.seriousness">
<template #default="scope">{{ displayEnum(TEXT.enums.aeSeriousness, scope.row.seriousness) }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope">{{ displayEnum(TEXT.enums.aeStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.updated_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-table :data="filteredItems" v-loading="loading" style="width: 100%" class="risk-table" table-layout="fixed">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.onset_date) }}</span></template>
</el-table-column>
<el-table-column prop="subject_no" :label="TEXT.modules.riskIssueSae.subjectNo" show-overflow-tooltip>
<template #default="scope"><span class="cell-mono cell-nowrap">{{ subjectMap[scope.row.subject_id] || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="term" :label="TEXT.common.fields.title" show-overflow-tooltip>
<template #default="scope"><span class="cell-nowrap">{{ scope.row.term || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="seriousness" :label="TEXT.common.fields.seriousness">
<template #default="scope">
<span :class="['severity-pill', getSeverityClass(scope.row.seriousness)]">
{{ displayEnum(TEXT.enums.aeSeriousness, scope.row.seriousness) }}
</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope">
<span :class="['status-pill', `status-pill--${normalizeAeStatus(scope.row.status) === 'CLOSED' ? 'success' : 'pending'}`]">
{{ displayEnum(TEXT.enums.aeStatus, normalizeAeStatus(scope.row.status)) }}
</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt">
<template #default="scope"><span class="cell-muted cell-nowrap">{{ displayDate(scope.row.updated_at) }}</span></template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button link type="primary" size="small" :disabled="!scope.row.subject_id" @click="goSubject(scope.row.subject_id, 'ae')">
{{ TEXT.modules.riskIssueSae.toSubject }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="risk-table-empty">{{ TEXT.modules.riskIssueSae.emptyDescription }}</div>
</div>
</template>
</el-table>
</div>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<span>{{ TEXT.modules.riskIssueSae.emptyDescription }}</span>
</div>
</template>
</el-table>
</div>
</div>
</template>
@@ -92,7 +96,6 @@
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { CircleCheck, Location, Search, Warning } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { fetchRiskIssueAes } from "../../api/aes";
import { fetchSubjects } from "../../api/subjects";
@@ -109,14 +112,13 @@ const siteMap = ref<Record<string, string>>({});
const subjectMap = ref<Record<string, string>>({});
const subjectSiteMap = ref<Record<string, string>>({});
const filters = ref({
keyword: "",
siteId: study.currentSite?.id || "",
status: "",
seriousness: "",
aeType: "",
keyword: "", siteId: study.currentSite?.id || "", status: "", seriousness: "", aeType: "",
});
const aeStatusOptions = Object.entries(TEXT.enums.aeStatus).map(([value, label]) => ({ value, label }));
const aeStatusOptions = [
{ value: "FOLLOW_UP", label: TEXT.enums.aeStatus.FOLLOW_UP },
{ value: "CLOSED", label: TEXT.enums.aeStatus.CLOSED },
];
const aeSeriousnessOptions = Object.entries(TEXT.enums.aeSeriousness).map(([value, label]) => ({ value, label }));
const aeTypeOptions = [
{ value: "ae", label: TEXT.modules.subjectDetail.aeType.ae },
@@ -125,39 +127,31 @@ const aeTypeOptions = [
];
const resetFilters = () => {
filters.value.keyword = "";
filters.value.siteId = "";
filters.value.status = "";
filters.value.seriousness = "";
filters.value.aeType = "";
filters.value.keyword = ""; filters.value.siteId = ""; filters.value.status = ""; filters.value.seriousness = ""; filters.value.aeType = "";
};
const getSeverityClass = (seriousness: string) => {
if (seriousness === "NON_SERIOUS") return "severity-pill--low";
if (seriousness === "SERIOUS") return "severity-pill--high";
if (seriousness === "FATAL") return "severity-pill--fatal";
return "severity-pill--low";
};
const normalizeAeStatus = (status?: string | null) => (status === "CLOSED" ? "CLOSED" : "FOLLOW_UP");
const loadSites = async (studyId: string) => {
try {
const { data } = await fetchSites(studyId, { limit: 500 });
const list = Array.isArray(data) ? data : data.items || [];
siteOptions.value = list.map((site: any) => ({ id: site.id, name: site.name }));
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => {
acc[site.id] = site.name;
return acc;
}, {});
} catch {
siteOptions.value = [];
siteMap.value = {};
}
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => { acc[site.id] = site.name; return acc; }, {});
} catch { siteOptions.value = []; siteMap.value = {}; }
};
const loadSubjects = async (studyId: string) => {
const { data } = await fetchSubjects(studyId);
const list = Array.isArray(data) ? data : data.items || [];
subjectMap.value = list.reduce((acc: Record<string, string>, item: any) => {
acc[item.id] = item.subject_no;
return acc;
}, {});
subjectSiteMap.value = list.reduce((acc: Record<string, string>, item: any) => {
acc[item.id] = item.site_id;
return acc;
}, {});
subjectMap.value = list.reduce((acc: Record<string, string>, item: any) => { acc[item.id] = item.subject_no; return acc; }, {});
subjectSiteMap.value = list.reduce((acc: Record<string, string>, item: any) => { acc[item.id] = item.site_id; return acc; }, {});
};
const load = async () => {
@@ -170,11 +164,8 @@ const load = async () => {
items.value = (Array.isArray(data) ? data : data.items || []).sort((a: any, b: any) =>
String(b.updated_at || "").localeCompare(String(a.updated_at || ""))
);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); }
finally { loading.value = false; }
};
const filteredItems = computed(() => {
@@ -186,7 +177,7 @@ const filteredItems = computed(() => {
const term = String(item?.term || "").toLowerCase();
if (keyword && !subjectNo.includes(keyword) && !term.includes(keyword)) return false;
if (filters.value.siteId && String(siteId) !== filters.value.siteId) return false;
if (filters.value.status && item?.status !== filters.value.status) return false;
if (filters.value.status && normalizeAeStatus(item?.status) !== filters.value.status) return false;
if (filters.value.seriousness && item?.seriousness !== filters.value.seriousness) return false;
if (filters.value.aeType && getAeType(item) !== filters.value.aeType) return false;
return true;
@@ -204,59 +195,49 @@ const goSubject = (subjectId: string, tab: "ae" | "pd") => {
router.push({ path: `/subjects/${subjectId}`, query: { tab } });
};
watch(
() => study.currentSite,
(newSite) => {
filters.value.siteId = newSite?.id || "";
}
);
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
onMounted(load);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
}
.page { display: flex; flex-direction: column; gap: 0; padding: 0; background: #ffffff; }
.filter-form {
display: flex;
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.page-header { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-size: 18px; font-weight: 700; color: #0a0a0a; letter-spacing: -0.01em; }
.filter-item-form {
margin-bottom: 0;
margin-right: 0;
}
.table-card { background: #ffffff; overflow: hidden; }
.filter-input-comp {
width: 200px;
}
.table-card-toolbar { display: flex; align-items: center; padding: 14px 20px; border-bottom: 1px solid #f0f0f0; }
.toolbar-filters { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
.filter-item { display: flex; flex-direction: column; gap: 4px; }
.filter-label { font-size: 11px; font-weight: 600; color: #8a8a8a; text-transform: uppercase; letter-spacing: 0.03em; }
.filter-input { width: 160px; }
.filter-select { width: 140px; }
.filter-actions { display: flex; gap: 8px; align-items: flex-end; padding-bottom: 1px; }
.filter-btn { border-radius: 6px; }
.filter-select-comp {
width: 170px;
}
.risk-table { --el-table-border-color: transparent; --el-table-row-hover-bg-color: #f8f9fb; }
.risk-table :deep(.el-table__inner-wrapper::before) { display: none; }
.risk-table :deep(th.el-table__cell) { background: #f4f5f7; color: #2a2a2a; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; padding: 14px 14px; border-bottom: 2px solid #e0e0e0 !important; }
.risk-table :deep(td.el-table__cell) { padding: 12px 14px; color: #0a0a0a; font-size: 14px; font-weight: 500; }
.risk-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
.filter-actions-comp {
margin-bottom: 0;
margin-right: 0;
}
.cell-nowrap { white-space: nowrap; }
.cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; font-weight: 600; color: #0a0a0a; }
.cell-muted { color: #8a8a8a; }
.cell-actions { display: flex; gap: 8px; white-space: nowrap; }
.cell-actions :deep(.el-button) { font-size: 13px; padding: 0; height: auto; }
.risk-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.severity-pill { display: inline-flex; align-items: center; padding: 2px 10px; font-size: 11px; font-weight: 600; border-radius: 20px; line-height: 1.6; }
.severity-pill--low { background: #f0fdf4; color: #16a34a; }
.severity-pill--high { background: #fef2f2; color: #dc2626; }
.severity-pill--fatal { background: #7f1d1d; color: #fca5a5; }
.risk-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;
}
.status-pill { display: inline-flex; align-items: center; padding: 2px 10px; font-size: 11px; font-weight: 600; border-radius: 20px; line-height: 1.6; }
.status-pill--success { background: #dcfce7; color: #16a34a; }
.status-pill--pending { background: #f5f5f5; color: #737373; }
.table-empty { min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; }
.empty-icon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; background: #f5f5f5; color: #c4c4c4; }
.table-empty span { font-size: 13px; font-weight: 500; color: #a3a3a3; }
</style>
@@ -86,7 +86,8 @@ describe("SubjectDetail medication adherence", () => {
expect(source).toContain("v-if=\"canUpdateAe || canDeleteAe\"");
expect(source).toContain("v-if=\"canUpdatePd || canDeletePd\"");
expect(source).toContain("const canSaveHistory = computed(() => (historyEditingId.value ? canUpdateHistory.value : canCreateHistory.value));");
expect(source).toContain("const canSaveVisit = computed(() => (visitEditingId.value ? canUpdateVisit.value : canCreateVisit.value));");
expect(source).toContain(':can-create="canCreateVisit"');
expect(source).toContain(':can-update="canUpdateVisit"');
expect(source).toContain("const canSaveAe = computed(() => (aeEditingId.value ? canUpdateAe.value : canCreateAe.value));");
expect(source).toContain("const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));");
expect(source).toContain("const canReadHistory = computed(() => canUseApiPermission(\"subject_histories:read\"));");
@@ -111,4 +112,39 @@ describe("SubjectDetail drawer editor", () => {
expect(source).not.toContain("subjectEditing");
expect(source).not.toContain("saveSubjectEdit");
});
it("uses drawer editors for participant subresource create and edit flows", () => {
const source = readSubjectDetail();
expect(source).toContain("VisitEditorDrawer");
expect(source).toContain("adherenceDrawerVisible");
expect(source).toContain("aeDrawerVisible");
expect(source).toContain("pdDrawerVisible");
expect(source).toContain("openVisitDrawer");
expect(source).toContain("openAeDrawer");
expect(source).toContain("openPdDrawer");
expect(source).toContain("openMedicationAdherenceDrawer");
expect(source).not.toContain("visitDialogVisible");
expect(source).not.toContain("aeDialogVisible");
expect(source).not.toContain("pdDialogVisible");
expect(source).not.toContain("adherenceEditing");
expect(source).not.toContain("<el-dialog v-if=\"visitDialogVisible\"");
expect(source).not.toContain("<el-dialog v-if=\"aeDialogVisible\"");
expect(source).not.toContain("<el-dialog v-if=\"pdDialogVisible\"");
});
});
describe("VisitEditorDrawer business form", () => {
const readVisitEditorDrawer = () => readFileSync(resolve(__dirname, "./VisitEditorDrawer.vue"), "utf8");
it("keeps create and edit visit flows on the same business fields", () => {
const source = readVisitEditorDrawer();
expect(source).not.toContain("<template v-if=\"isEditing\">");
expect(source).not.toContain("<template v-else>");
expect(source).toContain("v-model=\"form.actual_date\"");
expect(source).toContain("v-model=\"form.notes\"");
expect(source).toContain("actual_date: form.actual_date || null");
expect(source).toContain("notes: form.notes || null");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSubjectEditorDrawer = () => readFileSync(resolve(__dirname, "./SubjectEditorDrawer.vue"), "utf8");
describe("SubjectEditorDrawer screening date editing", () => {
it("keeps screening date editable when editing an existing participant", () => {
const source = readSubjectEditorDrawer();
const screeningDateField = source.slice(
source.indexOf('v-model="form.screening_date"'),
source.indexOf('v-model="form.consent_date"')
);
expect(screeningDateField).toContain(':disabled="isReadOnly"');
expect(screeningDateField).not.toContain(':disabled="isEdit || isReadOnly"');
});
it("submits screening date in the edit payload", () => {
const source = readSubjectEditorDrawer();
const editPayload = source.slice(
source.indexOf("await updateSubject(studyId.value, props.subjectId, {"),
source.indexOf("});", source.indexOf("await updateSubject(studyId.value, props.subjectId, {"))
);
expect(editPayload).toContain("screening_date: form.screening_date || null");
});
});
@@ -46,7 +46,7 @@
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.screeningDate">
<el-date-picker v-model="form.screening_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isEdit || isReadOnly" class="full-width" />
<el-date-picker v-model="form.screening_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="12">
@@ -200,6 +200,7 @@ const submit = async () => {
let id = props.subjectId || "";
if (props.subjectId) {
await updateSubject(studyId.value, props.subjectId, {
screening_date: form.screening_date || null,
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_date || null,
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSubjectForm = () => readFileSync(resolve(__dirname, "./SubjectForm.vue"), "utf8");
describe("SubjectForm screening date editing", () => {
it("keeps screening date editable when editing an existing participant", () => {
const source = readSubjectForm();
const screeningDateField = source.slice(
source.indexOf('v-model="form.screening_date"'),
source.indexOf('v-model="form.consent_date"')
);
expect(screeningDateField).toContain(':disabled="isReadOnly"');
expect(screeningDateField).not.toContain(':disabled="isEdit || isReadOnly"');
});
it("submits screening date in the edit payload", () => {
const source = readSubjectForm();
const editPayload = source.slice(
source.indexOf("const payload = {"),
source.indexOf("};", source.indexOf("const payload = {"))
);
expect(editPayload).toContain("screening_date: form.screening_date || null");
});
});
+2 -1
View File
@@ -37,7 +37,7 @@
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isEdit || isReadOnly"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
@@ -199,6 +199,7 @@ const submit = async () => {
try {
if (isEdit.value && subjectId.value) {
const payload = {
screening_date: form.screening_date || null,
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_date || null,
@@ -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>