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