项目概览、参与者管理初步优化(未接入真实数据)
This commit is contained in:
@@ -6,8 +6,14 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.subjectDetail.subtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
<template v-if="subjectEditing">
|
||||
<el-button type="primary" :loading="subjectSaving" @click="saveSubjectEdit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="primary" @click="startSubjectEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,11 +21,50 @@
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.subjectNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-select v-if="subjectEditing" v-model="subjectForm.status" size="small">
|
||||
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
|
||||
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
|
||||
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
|
||||
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
|
||||
</el-select>
|
||||
<span v-else>{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.screeningDate">{{ displayDate(detail.screening_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.enrollmentDate">{{ displayDate(detail.enrollment_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.completionDate">{{ displayDate(detail.completion_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">{{ detail.drop_reason || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.consentDate">
|
||||
<el-date-picker
|
||||
v-if="subjectEditing"
|
||||
v-model="subjectForm.consent_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.consent_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.enrollmentDate">
|
||||
<el-date-picker
|
||||
v-if="subjectEditing"
|
||||
v-model="subjectForm.enrollment_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.enrollment_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.completionDate">
|
||||
<el-date-picker
|
||||
v-if="subjectEditing"
|
||||
v-model="subjectForm.completion_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.completion_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">
|
||||
<el-input v-if="subjectEditing" v-model="subjectForm.drop_reason" type="textarea" :rows="2" />
|
||||
<span v-else>{{ detail.drop_reason || TEXT.common.fallback }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
@@ -50,24 +95,53 @@
|
||||
</div>
|
||||
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
|
||||
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" />
|
||||
<el-table-column prop="visit_name" :label="TEXT.common.fields.visitName" min-width="140">
|
||||
<template #default="scope">
|
||||
{{ displayText(scope.row.visit_name, TEXT.enums.visitName) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" width="140">
|
||||
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" min-width="180">
|
||||
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.actual_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.visitStatus, scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="200">
|
||||
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" min-width="180">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openVisitDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-date-picker
|
||||
v-if="visitEditingRowId === scope.row.id"
|
||||
v-model="visitRowDraft.actual_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else>{{ displayDate(scope.row.actual_date) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="getVisitStatusTagType(getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined))"
|
||||
effect="light"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.visitStatus, getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined)) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="notes" :label="TEXT.common.fields.notes" min-width="200">
|
||||
<template #default="scope">
|
||||
<el-input
|
||||
v-if="visitEditingRowId === scope.row.id"
|
||||
v-model="visitRowDraft.notes"
|
||||
size="small"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
/>
|
||||
<span v-else>{{ scope.row.notes || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" min-width="160">
|
||||
<template #default="scope">
|
||||
<template v-if="visitEditingRowId === scope.row.id">
|
||||
<el-button link type="primary" size="small" @click="saveVisitRow(scope.row)">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button link size="small" @click="cancelVisitRow">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button link type="primary" size="small" @click="startVisitRowEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -122,9 +196,6 @@
|
||||
<el-form-item :label="TEXT.common.fields.visitCode" required>
|
||||
<el-input v-model="visitForm.visit_code" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.visitName" required>
|
||||
<el-input v-model="visitForm.visit_name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.plannedDate">
|
||||
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
@@ -139,13 +210,6 @@
|
||||
<el-form-item :label="TEXT.common.fields.actualDate">
|
||||
<el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.status">
|
||||
<el-select v-model="visitForm.status">
|
||||
<el-option :label="TEXT.enums.visitStatus.PLANNED" value="PLANNED" />
|
||||
<el-option :label="TEXT.enums.visitStatus.DONE" value="DONE" />
|
||||
<el-option :label="TEXT.enums.visitStatus.MISSED" value="MISSED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="visitForm.notes" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
@@ -201,12 +265,12 @@ import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getSubject } from "../../api/subjects";
|
||||
import { getSubject, updateSubject } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
|
||||
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
|
||||
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
|
||||
import { displayDate, displayEnum, displayText } from "../../utils/display";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
@@ -222,11 +286,22 @@ const detail = reactive<any>({
|
||||
site_id: "",
|
||||
status: "",
|
||||
screening_date: "",
|
||||
consent_date: "",
|
||||
enrollment_date: "",
|
||||
completion_date: "",
|
||||
drop_reason: "",
|
||||
});
|
||||
|
||||
const subjectEditing = ref(false);
|
||||
const subjectSaving = ref(false);
|
||||
const subjectForm = reactive({
|
||||
status: "SCREENING",
|
||||
enrollment_date: "",
|
||||
consent_date: "",
|
||||
completion_date: "",
|
||||
drop_reason: "",
|
||||
});
|
||||
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const activeTab = ref("history");
|
||||
|
||||
@@ -243,14 +318,17 @@ const visitItems = ref<any[]>([]);
|
||||
const loadingVisits = ref(false);
|
||||
const visitDialogVisible = ref(false);
|
||||
const visitEditingId = ref<string | null>(null);
|
||||
const visitEditingRowId = ref<string | null>(null);
|
||||
const visitRowDraft = reactive({
|
||||
actual_date: "",
|
||||
notes: "",
|
||||
});
|
||||
const visitForm = reactive<any>({
|
||||
visit_code: "",
|
||||
visit_name: "",
|
||||
planned_date: "",
|
||||
window_start: "",
|
||||
window_end: "",
|
||||
actual_date: "",
|
||||
status: "PLANNED",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
@@ -295,6 +373,41 @@ const loadSubject = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const startSubjectEdit = () => {
|
||||
subjectForm.status = detail.status || "SCREENING";
|
||||
subjectForm.consent_date = detail.consent_date || "";
|
||||
subjectForm.enrollment_date = detail.enrollment_date || "";
|
||||
subjectForm.completion_date = detail.completion_date || "";
|
||||
subjectForm.drop_reason = detail.drop_reason || "";
|
||||
subjectEditing.value = true;
|
||||
};
|
||||
|
||||
const cancelSubjectEdit = () => {
|
||||
subjectEditing.value = false;
|
||||
};
|
||||
|
||||
const saveSubjectEdit = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
subjectSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
status: subjectForm.status || null,
|
||||
consent_date: subjectForm.consent_date || null,
|
||||
enrollment_date: subjectForm.enrollment_date || null,
|
||||
completion_date: subjectForm.completion_date || null,
|
||||
drop_reason: subjectForm.drop_reason || null,
|
||||
};
|
||||
await updateSubject(studyId, subjectId, payload);
|
||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||
subjectEditing.value = false;
|
||||
loadSubject();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
subjectSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistories = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingHistory.value = true;
|
||||
@@ -364,28 +477,93 @@ const openVisitDialog = (row?: any) => {
|
||||
visitEditingId.value = row?.id || null;
|
||||
if (row) {
|
||||
visitForm.actual_date = row.actual_date || "";
|
||||
visitForm.status = row.status || "PLANNED";
|
||||
visitForm.notes = row.notes || "";
|
||||
} else {
|
||||
visitForm.visit_code = "";
|
||||
visitForm.visit_name = "";
|
||||
visitForm.planned_date = "";
|
||||
visitForm.window_start = "";
|
||||
visitForm.window_end = "";
|
||||
visitForm.actual_date = "";
|
||||
visitForm.status = "PLANNED";
|
||||
visitForm.notes = "";
|
||||
}
|
||||
visitDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const startVisitRowEdit = (row: any) => {
|
||||
visitEditingRowId.value = row?.id || null;
|
||||
visitRowDraft.actual_date = row?.actual_date || "";
|
||||
visitRowDraft.notes = row?.notes || "";
|
||||
};
|
||||
|
||||
const cancelVisitRow = () => {
|
||||
visitEditingRowId.value = null;
|
||||
};
|
||||
|
||||
const saveVisitRow = async (row: any) => {
|
||||
if (!studyId || !subjectId || !row?.id) return;
|
||||
try {
|
||||
const payload = {
|
||||
actual_date: visitRowDraft.actual_date || null,
|
||||
notes: visitRowDraft.notes || null,
|
||||
};
|
||||
await updateVisit(studyId, subjectId, row.id, payload);
|
||||
visitEditingRowId.value = null;
|
||||
loadVisits();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
}
|
||||
};
|
||||
|
||||
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 saveVisit = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
if (visitEditingId.value) {
|
||||
const payload = {
|
||||
actual_date: visitForm.actual_date || null,
|
||||
status: visitForm.status || null,
|
||||
notes: visitForm.notes || null,
|
||||
};
|
||||
await updateVisit(studyId, subjectId, visitEditingId.value, payload);
|
||||
@@ -393,7 +571,6 @@ const saveVisit = async () => {
|
||||
const payload = {
|
||||
subject_id: subjectId,
|
||||
visit_code: visitForm.visit_code,
|
||||
visit_name: visitForm.visit_name,
|
||||
planned_date: visitForm.planned_date || null,
|
||||
window_start: visitForm.window_start || null,
|
||||
window_end: visitForm.window_end || null,
|
||||
@@ -484,7 +661,6 @@ const removeAe = async (row: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/subjects/${subjectId}/edit`);
|
||||
const goBack = () => router.push("/subjects");
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
Reference in New Issue
Block a user