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>