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