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
+1 -1
View File
@@ -73,7 +73,7 @@ async def create_ae(
is_sae=bool(ae_in.is_sae or ae_in.is_susar), is_sae=bool(ae_in.is_sae or ae_in.is_susar),
is_susar=bool(ae_in.is_susar), is_susar=bool(ae_in.is_susar),
report_due_date=due_date, report_due_date=due_date,
status="NEW", status="FOLLOW_UP",
description=ae_in.description, description=ae_in.description,
created_by=created_by, created_by=created_by,
) )
+32 -10
View File
@@ -111,6 +111,20 @@ def get_last_planned_visit_window_start_date(visits: Sequence[Visit]) -> date |
return max(window_start_dates) if window_start_dates else None return max(window_start_dates) if window_start_dates else None
def derive_visit_status_from_actual_date(
*,
actual_date: date | None,
window_start: date | None,
window_end: date | None,
default_status: str = "PLANNED",
) -> str:
if actual_date is None:
return default_status
if (window_start and actual_date < window_start) or (window_end and actual_date > window_end):
return "OVERDUE"
return "DONE"
def validate_early_termination_date(termination_date: date, visits: Sequence[Visit]) -> None: def validate_early_termination_date(termination_date: date, visits: Sequence[Visit]) -> None:
last_window_start_date = get_last_planned_visit_window_start_date(visits) last_window_start_date = get_last_planned_visit_window_start_date(visits)
if last_window_start_date is not None and termination_date >= last_window_start_date: if last_window_start_date is not None and termination_date >= last_window_start_date:
@@ -157,16 +171,24 @@ async def create_visit(
actual_date: date | None = None, actual_date: date | None = None,
status: str = "PLANNED", status: str = "PLANNED",
) -> Visit: ) -> Visit:
resolved_window_start = window_start if window_start is not None else (visit_in.window_start if visit_in else None)
resolved_window_end = window_end if window_end is not None else (visit_in.window_end if visit_in else None)
resolved_actual_date = actual_date if actual_date is not None else (visit_in.actual_date if visit_in else None)
visit = Visit( visit = Visit(
study_id=study_id, study_id=study_id,
subject_id=subject.id, subject_id=subject.id,
visit_code=visit_code, visit_code=visit_code,
planned_date=planned_date, planned_date=planned_date,
actual_date=actual_date, actual_date=resolved_actual_date,
status=status, status=derive_visit_status_from_actual_date(
window_start=window_start if window_start is not None else (visit_in.window_start if visit_in else None), actual_date=resolved_actual_date,
window_end=window_end if window_end is not None else (visit_in.window_end if visit_in else None), window_start=resolved_window_start,
notes=None, window_end=resolved_window_end,
default_status=status,
),
window_start=resolved_window_start,
window_end=resolved_window_end,
notes=visit_in.notes if visit_in else None,
) )
db.add(visit) db.add(visit)
await db.commit() await db.commit()
@@ -240,11 +262,11 @@ async def get_visit(db: AsyncSession, visit_id: uuid.UUID) -> Visit | None:
async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) -> Visit: async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) -> Visit:
update_data = visit_in.model_dump(exclude_unset=True) update_data = visit_in.model_dump(exclude_unset=True)
if "actual_date" in update_data and update_data["actual_date"] is not None and "status" not in update_data: if "actual_date" in update_data and update_data["actual_date"] is not None and "status" not in update_data:
actual_date = update_data["actual_date"] update_data["status"] = derive_visit_status_from_actual_date(
if (visit.window_start and actual_date < visit.window_start) or (visit.window_end and actual_date > visit.window_end): actual_date=update_data["actual_date"],
update_data["status"] = "OVERDUE" window_start=visit.window_start,
else: window_end=visit.window_end,
update_data["status"] = "DONE" )
if update_data: if update_data:
await db.execute( await db.execute(
sa_update(Visit) sa_update(Visit)
+1 -1
View File
@@ -31,7 +31,7 @@ class AdverseEvent(Base):
is_sae: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_sae: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_susar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_susar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
report_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) report_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NEW") status: Mapped[str] = mapped_column(String(20), nullable=False, default="FOLLOW_UP")
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+2
View File
@@ -11,6 +11,8 @@ class VisitCreate(BaseModel):
planned_date: Optional[date] = None planned_date: Optional[date] = None
window_start: Optional[date] = None window_start: Optional[date] = None
window_end: Optional[date] = None window_end: Optional[date] = None
actual_date: Optional[date] = None
notes: Optional[str] = None
class VisitUpdate(BaseModel): class VisitUpdate(BaseModel):
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
def test_new_ae_records_default_to_follow_up_status():
crud_source = Path("app/crud/ae.py").read_text(encoding="utf-8")
model_source = Path("app/models/ae.py").read_text(encoding="utf-8")
assert 'status="FOLLOW_UP"' in crud_source
assert 'default="FOLLOW_UP"' in model_source
assert 'status="NEW"' not in crud_source
assert 'default="NEW"' not in model_source
+14
View File
@@ -10,6 +10,7 @@ from app.crud.visit import (
validate_early_termination_date, validate_early_termination_date,
) )
from app.schemas.study import StudyUpdate from app.schemas.study import StudyUpdate
from app.schemas.visit import VisitCreate
def test_build_visit_schedule_dates_uses_per_visit_windows(): def test_build_visit_schedule_dates_uses_per_visit_windows():
@@ -105,6 +106,19 @@ def test_study_update_allows_multiple_visits_with_same_baseline_offset():
assert [item.visit_code for item in payload.visit_schedule or []] == ["筛选访视", "基线访视"] assert [item.visit_code for item in payload.visit_schedule or []] == ["筛选访视", "基线访视"]
def test_visit_create_accepts_actual_date_and_notes_for_shared_drawer_create_flow():
payload = VisitCreate(
subject_id="00000000-0000-0000-0000-000000000001",
visit_code="V4",
planned_date=date(2026, 6, 1),
actual_date=date(2026, 6, 2),
notes="现场已完成",
)
assert payload.actual_date == date(2026, 6, 2)
assert payload.notes == "现场已完成"
def test_sort_visits_for_display_follows_configured_visit_order_without_dates(): def test_sort_visits_for_display_follows_configured_visit_order_without_dates():
visits = [ visits = [
SimpleNamespace(visit_code="V1", planned_date=None), SimpleNamespace(visit_code="V1", planned_date=None),
@@ -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)");
});
});
+110 -129
View File
@@ -1,98 +1,101 @@
<template> <template>
<div class="page ctms-page-shell page--flush"> <div class="page">
<div class="main-content-flat unified-shell"> <div class="page-header">
<div class="filter-container unified-action-bar bar--flush"> <h2 class="page-title">{{ TEXT.menu.riskIssueSae }}</h2>
<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> </div>
<div class="filter-item-form"> <div class="table-card">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp"> <div class="table-card-toolbar">
<template #prefix> <div class="toolbar-filters">
<el-icon><Location /></el-icon> <div class="filter-item">
</template> <span class="filter-label">{{ TEXT.common.fields.keyword }}</span>
<el-option :label="TEXT.common.labels.all" value="" /> <el-input v-model="filters.keyword" clearable placeholder="请输入" class="filter-input" />
</div>
<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-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select> </el-select>
</div> </div>
<div class="filter-item-form"> <div class="filter-item">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp"> <span class="filter-label">{{ TEXT.common.fields.status }}</span>
<template #prefix> <el-select v-model="filters.status" clearable placeholder="请选择" class="filter-select">
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in aeStatusOptions" :key="option.value" :label="option.label" :value="option.value" /> <el-option v-for="option in aeStatusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select> </el-select>
</div> </div>
<div class="filter-item-form"> <div class="filter-item">
<el-select v-model="filters.seriousness" :placeholder="TEXT.common.fields.seriousness" clearable class="filter-select-comp"> <span class="filter-label">{{ TEXT.common.fields.seriousness }}</span>
<template #prefix> <el-select v-model="filters.seriousness" clearable placeholder="请选择" class="filter-select">
<el-icon><Warning /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in aeSeriousnessOptions" :key="option.value" :label="option.label" :value="option.value" /> <el-option v-for="option in aeSeriousnessOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select> </el-select>
</div> </div>
<div class="filter-item-form"> <div class="filter-item">
<el-select v-model="filters.aeType" :placeholder="TEXT.common.fields.aeType" clearable class="filter-select-comp"> <span class="filter-label">{{ TEXT.common.fields.aeType }}</span>
<template #prefix> <el-select v-model="filters.aeType" clearable placeholder="请选择" class="filter-select">
<el-icon><Warning /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in aeTypeOptions" :key="option.value" :label="option.label" :value="option.value" /> <el-option v-for="option in aeTypeOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select> </el-select>
</div> </div>
<div class="filter-actions-comp"> <div class="filter-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
</div> </div>
</el-form>
</div> </div>
<div class="unified-section table-section section--flush-x section--flush-top section--flush-bottom"> </div>
<el-table :data="filteredItems" v-loading="loading" style="width: 100%" class="risk-table" table-layout="fixed"> <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"> <el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate">
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template> <template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.onset_date) }}</span></template>
</el-table-column> </el-table-column>
<el-table-column prop="subject_no" :label="TEXT.modules.riskIssueSae.subjectNo" show-overflow-tooltip> <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> <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>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip> <el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template> <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>
<el-table-column prop="term" :label="TEXT.common.fields.title" show-overflow-tooltip />
<el-table-column prop="seriousness" :label="TEXT.common.fields.seriousness"> <el-table-column prop="seriousness" :label="TEXT.common.fields.seriousness">
<template #default="scope">{{ displayEnum(TEXT.enums.aeSeriousness, scope.row.seriousness) }}</template> <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>
<el-table-column prop="status" :label="TEXT.common.fields.status"> <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"> <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')"> <el-button link type="primary" size="small" :disabled="!scope.row.subject_id" @click="goSubject(scope.row.subject_id, 'ae')">
{{ TEXT.modules.riskIssueSae.toSubject }} {{ TEXT.modules.riskIssueSae.toSubject }}
</el-button> </el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
<div v-if="!loading" class="risk-table-empty">{{ TEXT.modules.riskIssueSae.emptyDescription }}</div> <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> </template>
</el-table> </el-table>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { CircleCheck, Location, Search, Warning } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { fetchRiskIssueAes } from "../../api/aes"; import { fetchRiskIssueAes } from "../../api/aes";
import { fetchSubjects } from "../../api/subjects"; import { fetchSubjects } from "../../api/subjects";
@@ -109,14 +112,13 @@ const siteMap = ref<Record<string, string>>({});
const subjectMap = ref<Record<string, string>>({}); const subjectMap = ref<Record<string, string>>({});
const subjectSiteMap = ref<Record<string, string>>({}); const subjectSiteMap = ref<Record<string, string>>({});
const filters = ref({ const filters = ref({
keyword: "", keyword: "", siteId: study.currentSite?.id || "", status: "", seriousness: "", aeType: "",
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 aeSeriousnessOptions = Object.entries(TEXT.enums.aeSeriousness).map(([value, label]) => ({ value, label }));
const aeTypeOptions = [ const aeTypeOptions = [
{ value: "ae", label: TEXT.modules.subjectDetail.aeType.ae }, { value: "ae", label: TEXT.modules.subjectDetail.aeType.ae },
@@ -125,39 +127,31 @@ const aeTypeOptions = [
]; ];
const resetFilters = () => { const resetFilters = () => {
filters.value.keyword = ""; filters.value.keyword = ""; filters.value.siteId = ""; filters.value.status = ""; filters.value.seriousness = ""; filters.value.aeType = "";
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) => { const loadSites = async (studyId: string) => {
try { try {
const { data } = await fetchSites(studyId, { limit: 500 }); const { data } = await fetchSites(studyId, { limit: 500 });
const list = Array.isArray(data) ? data : data.items || []; const list = Array.isArray(data) ? data : data.items || [];
siteOptions.value = list.map((site: any) => ({ id: site.id, name: site.name })); siteOptions.value = list.map((site: any) => ({ id: site.id, name: site.name }));
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => { siteMap.value = list.reduce((acc: Record<string, string>, site: any) => { acc[site.id] = site.name; return acc; }, {});
acc[site.id] = site.name; } catch { siteOptions.value = []; siteMap.value = {}; }
return acc;
}, {});
} catch {
siteOptions.value = [];
siteMap.value = {};
}
}; };
const loadSubjects = async (studyId: string) => { const loadSubjects = async (studyId: string) => {
const { data } = await fetchSubjects(studyId); const { data } = await fetchSubjects(studyId);
const list = Array.isArray(data) ? data : data.items || []; const list = Array.isArray(data) ? data : data.items || [];
subjectMap.value = list.reduce((acc: Record<string, string>, item: any) => { subjectMap.value = list.reduce((acc: Record<string, string>, item: any) => { acc[item.id] = item.subject_no; return acc; }, {});
acc[item.id] = item.subject_no; subjectSiteMap.value = list.reduce((acc: Record<string, string>, item: any) => { acc[item.id] = item.site_id; return acc; }, {});
return acc;
}, {});
subjectSiteMap.value = list.reduce((acc: Record<string, string>, item: any) => {
acc[item.id] = item.site_id;
return acc;
}, {});
}; };
const load = async () => { const load = async () => {
@@ -170,11 +164,8 @@ const load = async () => {
items.value = (Array.isArray(data) ? data : data.items || []).sort((a: any, b: any) => items.value = (Array.isArray(data) ? data : data.items || []).sort((a: any, b: any) =>
String(b.updated_at || "").localeCompare(String(a.updated_at || "")) String(b.updated_at || "").localeCompare(String(a.updated_at || ""))
); );
} catch (e: any) { } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); }
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); finally { loading.value = false; }
} finally {
loading.value = false;
}
}; };
const filteredItems = computed(() => { const filteredItems = computed(() => {
@@ -186,7 +177,7 @@ const filteredItems = computed(() => {
const term = String(item?.term || "").toLowerCase(); const term = String(item?.term || "").toLowerCase();
if (keyword && !subjectNo.includes(keyword) && !term.includes(keyword)) return false; if (keyword && !subjectNo.includes(keyword) && !term.includes(keyword)) return false;
if (filters.value.siteId && String(siteId) !== filters.value.siteId) 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.seriousness && item?.seriousness !== filters.value.seriousness) return false;
if (filters.value.aeType && getAeType(item) !== filters.value.aeType) return false; if (filters.value.aeType && getAeType(item) !== filters.value.aeType) return false;
return true; return true;
@@ -204,59 +195,49 @@ const goSubject = (subjectId: string, tab: "ae" | "pd") => {
router.push({ path: `/subjects/${subjectId}`, query: { tab } }); router.push({ path: `/subjects/${subjectId}`, query: { tab } });
}; };
watch( watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
() => study.currentSite,
(newSite) => {
filters.value.siteId = newSite?.id || "";
}
);
onMounted(load); onMounted(load);
</script> </script>
<style scoped> <style scoped>
.page { .page { display: flex; flex-direction: column; gap: 0; padding: 0; background: #ffffff; }
display: flex;
flex-direction: column;
}
.filter-form { .page-header { display: flex; justify-content: space-between; align-items: flex-start; }
display: flex; .page-title { margin: 0; font-size: 18px; font-weight: 700; color: #0a0a0a; letter-spacing: -0.01em; }
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.filter-item-form { .table-card { background: #ffffff; overflow: hidden; }
margin-bottom: 0;
margin-right: 0;
}
.filter-input-comp { .table-card-toolbar { display: flex; align-items: center; padding: 14px 20px; border-bottom: 1px solid #f0f0f0; }
width: 200px; .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 { .risk-table { --el-table-border-color: transparent; --el-table-row-hover-bg-color: #f8f9fb; }
width: 170px; .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 { .cell-nowrap { white-space: nowrap; }
margin-bottom: 0; .cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; font-weight: 600; color: #0a0a0a; }
margin-right: 0; .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) { .severity-pill { display: inline-flex; align-items: center; padding: 2px 10px; font-size: 11px; font-weight: 600; border-radius: 20px; line-height: 1.6; }
display: none; .severity-pill--low { background: #f0fdf4; color: #16a34a; }
} .severity-pill--high { background: #fef2f2; color: #dc2626; }
.severity-pill--fatal { background: #7f1d1d; color: #fca5a5; }
.risk-table-empty { .status-pill { display: inline-flex; align-items: center; padding: 2px 10px; font-size: 11px; font-weight: 600; border-radius: 20px; line-height: 1.6; }
min-height: 220px; .status-pill--success { background: #dcfce7; color: #16a34a; }
display: flex; .status-pill--pending { background: #f5f5f5; color: #737373; }
align-items: center;
justify-content: center; .table-empty { min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; }
color: #8a97ab; .empty-icon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; background: #f5f5f5; color: #c4c4c4; }
font-size: 14px; .table-empty span { font-size: 13px; font-weight: 500; color: #a3a3a3; }
font-weight: 500;
letter-spacing: 0.02em;
}
</style> </style>
@@ -86,7 +86,8 @@ describe("SubjectDetail medication adherence", () => {
expect(source).toContain("v-if=\"canUpdateAe || canDeleteAe\""); expect(source).toContain("v-if=\"canUpdateAe || canDeleteAe\"");
expect(source).toContain("v-if=\"canUpdatePd || canDeletePd\""); expect(source).toContain("v-if=\"canUpdatePd || canDeletePd\"");
expect(source).toContain("const canSaveHistory = computed(() => (historyEditingId.value ? canUpdateHistory.value : canCreateHistory.value));"); 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 canSaveAe = computed(() => (aeEditingId.value ? canUpdateAe.value : canCreateAe.value));");
expect(source).toContain("const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));"); expect(source).toContain("const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));");
expect(source).toContain("const canReadHistory = computed(() => canUseApiPermission(\"subject_histories:read\"));"); 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("subjectEditing");
expect(source).not.toContain("saveSubjectEdit"); 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-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.screeningDate"> <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-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
@@ -200,6 +200,7 @@ const submit = async () => {
let id = props.subjectId || ""; let id = props.subjectId || "";
if (props.subjectId) { if (props.subjectId) {
await updateSubject(studyId.value, props.subjectId, { await updateSubject(studyId.value, props.subjectId, {
screening_date: form.screening_date || null,
consent_date: form.consent_date || null, consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null, enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_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" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select" :placeholder="TEXT.common.placeholders.select"
:disabled="isEdit || isReadOnly" :disabled="isReadOnly"
size="large" size="large"
/> />
</el-form-item> </el-form-item>
@@ -199,6 +199,7 @@ const submit = async () => {
try { try {
if (isEdit.value && subjectId.value) { if (isEdit.value && subjectId.value) {
const payload = { const payload = {
screening_date: form.screening_date || null,
consent_date: form.consent_date || null, consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null, enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_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>