文件版本管理-初步优化
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { apiDelete, apiGet, apiPost } from "./axios";
|
||||
import type { ApiListResponse } from "../types/api";
|
||||
import type {
|
||||
Acknowledgement,
|
||||
Distribution,
|
||||
DocumentDetail,
|
||||
DocumentSummary,
|
||||
DocumentVersion,
|
||||
WorkflowTemplate,
|
||||
} from "../types/documents";
|
||||
|
||||
export const fetchDocuments = (params: Record<string, any>) =>
|
||||
apiGet<ApiListResponse<DocumentSummary>>("/api/v1/documents", { params });
|
||||
|
||||
export const createDocument = (payload: Record<string, any>) =>
|
||||
apiPost<DocumentSummary>("/api/v1/documents", payload);
|
||||
|
||||
export const deleteDocument = (documentId: string) =>
|
||||
apiDelete<DocumentSummary>(`/api/v1/documents/${documentId}`);
|
||||
|
||||
export const fetchDocumentDetail = (documentId: string) =>
|
||||
apiGet<DocumentDetail>(`/api/v1/documents/${documentId}`);
|
||||
|
||||
export const uploadDocumentVersion = (documentId: string, payload: FormData) =>
|
||||
apiPost<DocumentVersion>(`/api/v1/documents/${documentId}/versions`, payload, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
export const submitVersion = (versionId: string, payload: { template_id: string; comment?: string }) =>
|
||||
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/submit`, payload);
|
||||
|
||||
export const approveVersion = (versionId: string, payload: { comment?: string }) =>
|
||||
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/approve`, payload);
|
||||
|
||||
export const rejectVersion = (versionId: string, payload: { comment?: string }) =>
|
||||
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/reject`, payload);
|
||||
|
||||
export const makeVersionEffective = (versionId: string) =>
|
||||
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/make-effective`);
|
||||
|
||||
export const downloadDocumentVersion = (versionId: string) =>
|
||||
apiGet<Blob>(`/api/v1/versions/${versionId}/download`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
export const listWorkflowTemplates = (params?: { trial_id?: string }) =>
|
||||
apiGet<WorkflowTemplate[]>(`/api/v1/workflow-templates`, { params });
|
||||
|
||||
export const listDistributions = (versionId: string) =>
|
||||
apiGet<Distribution[]>(`/api/v1/versions/${versionId}/distributions`);
|
||||
|
||||
export const createDistributions = (versionId: string, payload: Record<string, any>) =>
|
||||
apiPost<Distribution[]>(`/api/v1/versions/${versionId}/distributions`, payload);
|
||||
|
||||
export const createAcknowledgement = (distributionId: string, payload: { ack_type: string; evidence?: string }) =>
|
||||
apiPost<Acknowledgement>(`/api/v1/distributions/${distributionId}/acknowledgements`, payload);
|
||||
@@ -172,7 +172,7 @@ const activeMenu = computed(() => {
|
||||
if (path.startsWith("/finance/contracts")) return "/fees/contracts";
|
||||
if (path.startsWith("/finance/special")) return "/fees/special";
|
||||
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
||||
if (path.startsWith("/file-versions")) return "/file-versions";
|
||||
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
|
||||
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
|
||||
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
||||
return "/startup/meeting-auth";
|
||||
|
||||
@@ -46,6 +46,7 @@ export const TEXT = {
|
||||
deleteFailed: "删除失败",
|
||||
uploadSuccess: "上传成功",
|
||||
uploadFailed: "上传失败",
|
||||
downloadFailed: "下载失败",
|
||||
actionFailed: "操作失败",
|
||||
noPermission: "无权限执行该操作",
|
||||
networkError: "网络错误,请稍后重试",
|
||||
@@ -436,9 +437,108 @@ export const TEXT = {
|
||||
},
|
||||
fileVersionManagement: {
|
||||
title: "文件版本管理",
|
||||
subtitle: "功能建设中",
|
||||
subtitle: "管理试验文档与版本审批",
|
||||
listTitle: "文件版本列表",
|
||||
emptyDescription: "文件版本管理正在搭建基础能力,敬请期待。",
|
||||
emptyDescription: "暂无文档记录",
|
||||
newDocument: "新增文档",
|
||||
filters: {
|
||||
scope: "范围",
|
||||
site: "分中心",
|
||||
docType: "类型",
|
||||
status: "状态",
|
||||
all: "全部",
|
||||
},
|
||||
columns: {
|
||||
docNo: "编号",
|
||||
title: "标题",
|
||||
scope: "范围",
|
||||
site: "分中心",
|
||||
docType: "类型",
|
||||
currentVersion: "当前版本",
|
||||
status: "状态",
|
||||
effectiveAt: "生效时间",
|
||||
updatedAt: "更新",
|
||||
actions: "操作",
|
||||
},
|
||||
actions: {
|
||||
detail: "详情",
|
||||
uploadVersion: "上传新版本",
|
||||
submit: "提交审批",
|
||||
approve: "通过",
|
||||
reject: "驳回",
|
||||
makeEffective: "设为生效",
|
||||
download: "下载",
|
||||
distribute: "新建抄送",
|
||||
ackReceived: "已接收",
|
||||
ackRead: "已阅读",
|
||||
ackTrained: "已培训",
|
||||
addTarget: "新增目标",
|
||||
},
|
||||
dialog: {
|
||||
createTitle: "新建文档",
|
||||
uploadTitle: "上传新版本",
|
||||
distributeTitle: "新建抄送",
|
||||
},
|
||||
fields: {
|
||||
docNo: "编号",
|
||||
title: "标题",
|
||||
docType: "类型",
|
||||
scope: "范围",
|
||||
site: "分中心",
|
||||
versionNo: "版本号",
|
||||
changeSummary: "变更摘要",
|
||||
parentVersion: "父版本",
|
||||
file: "文件",
|
||||
workflowTemplate: "审批模板",
|
||||
targetType: "目标类型",
|
||||
target: "目标",
|
||||
targetList: "目标列表",
|
||||
dueAt: "截止日期",
|
||||
},
|
||||
tabs: {
|
||||
versions: "版本时间线",
|
||||
distributions: "抄送",
|
||||
audit: "审计",
|
||||
},
|
||||
labels: {
|
||||
currentEffective: "当前生效版本",
|
||||
versionTimeline: "版本时间线",
|
||||
distributionList: "抄送列表",
|
||||
approvalSummary: "审批摘要",
|
||||
createdBy: "创建者",
|
||||
createdAt: "创建时间",
|
||||
submitAt: "提交",
|
||||
approveAt: "审批",
|
||||
overdue: "超期",
|
||||
ackStats: "回执统计",
|
||||
ackAction: "回执",
|
||||
dueAt: "截止日期",
|
||||
auditAction: "动作",
|
||||
auditDetail: "详情",
|
||||
auditTime: "时间",
|
||||
},
|
||||
placeholders: {
|
||||
workflowTemplate: "审批模板 ID",
|
||||
versionNo: "如 1.0",
|
||||
target: "目标 ID/角色",
|
||||
},
|
||||
messages: {
|
||||
createSuccess: "文档已创建",
|
||||
uploadSuccess: "版本已上传",
|
||||
submitSuccess: "已提交审批",
|
||||
approveSuccess: "审批通过",
|
||||
rejectSuccess: "已驳回",
|
||||
effectiveSuccess: "已设为生效版本",
|
||||
distributeSuccess: "抄送已创建",
|
||||
ackSuccess: "回执已提交",
|
||||
missingFile: "请选择文件",
|
||||
missingTarget: "请填写目标",
|
||||
noEffective: "暂无生效版本",
|
||||
versionNoInvalid: "版本号格式应为 1.0",
|
||||
workflowTemplateRequired: "请选择审批模板",
|
||||
submitPrompt: "请输入审批模板 ID",
|
||||
submitTitle: "提交审批",
|
||||
},
|
||||
},
|
||||
startupFeasibilityEthics: {
|
||||
title: "立项与伦理",
|
||||
@@ -812,6 +912,7 @@ export const TEXT = {
|
||||
CRA: "CRA",
|
||||
PV: "PV",
|
||||
IMP: "药品管理员",
|
||||
QA: "QA",
|
||||
},
|
||||
userStatus: {
|
||||
PENDING: "待审核",
|
||||
@@ -916,5 +1017,37 @@ export const TEXT = {
|
||||
true: "是",
|
||||
false: "否",
|
||||
},
|
||||
documentType: {
|
||||
SOP: "SOP",
|
||||
ICF: "知情同意书",
|
||||
Training: "培训材料",
|
||||
Manual: "手册",
|
||||
Guidance: "研究快速指引",
|
||||
},
|
||||
documentStatus: {
|
||||
DRAFT: "草稿",
|
||||
ACTIVE: "已生效",
|
||||
ARCHIVED: "已归档",
|
||||
},
|
||||
documentVersionStatus: {
|
||||
DRAFT: "草稿",
|
||||
SUBMITTED: "已提交",
|
||||
APPROVED: "已审批",
|
||||
EFFECTIVE: "已生效",
|
||||
SUPERSEDED: "已废止",
|
||||
ARCHIVED: "已归档",
|
||||
WITHDRAWN: "已撤回",
|
||||
REJECTED: "已驳回",
|
||||
},
|
||||
distributionTargetType: {
|
||||
SITE: "中心",
|
||||
ROLE: "角色",
|
||||
USER: "人员",
|
||||
},
|
||||
scopeType: {
|
||||
GLOBAL: "全局",
|
||||
SITE: "中心",
|
||||
DERIVED: "派生",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -27,6 +27,8 @@ import FeeSpecialForm from "../views/fees/SpecialExpenseForm.vue";
|
||||
import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue";
|
||||
import DrugShipments from "../views/ia/DrugShipments.vue";
|
||||
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
|
||||
import DocumentList from "../views/documents/DocumentList.vue";
|
||||
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
||||
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
|
||||
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
|
||||
import SubjectManagement from "../views/ia/SubjectManagement.vue";
|
||||
@@ -224,6 +226,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: FileVersionManagement,
|
||||
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "trial/:trialId/documents",
|
||||
name: "DocumentList",
|
||||
component: DocumentList,
|
||||
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "documents/:id",
|
||||
name: "DocumentDetail",
|
||||
component: DocumentDetail,
|
||||
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "startup/feasibility-ethics",
|
||||
name: "StartupFeasibilityEthics",
|
||||
|
||||
@@ -43,12 +43,12 @@
|
||||
--ctms-radius: 10px;
|
||||
--ctms-radius-lg: 12px;
|
||||
--ctms-radius-round: 50%;
|
||||
|
||||
|
||||
--ctms-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--ctms-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
--ctms-shadow-md: 0 12px 26px rgba(15, 23, 42, 0.08);
|
||||
--ctms-shadow-lg: 0 20px 40px rgba(15, 23, 42, 0.1);
|
||||
|
||||
|
||||
/* 动画 */
|
||||
--ctms-transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
@@ -186,7 +186,7 @@ body {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.el-table--enable-row-hover .el-table__row:hover > td.el-table__cell {
|
||||
.el-table--enable-row-hover .el-table__row:hover>td.el-table__cell {
|
||||
background-color: #f2f5f8;
|
||||
}
|
||||
|
||||
@@ -239,10 +239,28 @@ body {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.el-tag--info { background-color: #f5f5f5; color: var(--ctms-text-secondary); }
|
||||
.el-tag--success { background-color: rgba(63, 143, 107, 0.12); color: var(--ctms-success); border: 1px solid rgba(63, 143, 107, 0.2); }
|
||||
.el-tag--warning { background-color: rgba(197, 139, 42, 0.12); color: var(--ctms-warning); border: 1px solid rgba(197, 139, 42, 0.2); }
|
||||
.el-tag--danger { background-color: rgba(194, 75, 75, 0.12); color: var(--ctms-danger); border: 1px solid rgba(194, 75, 75, 0.2); }
|
||||
.el-tag--info {
|
||||
background-color: #f5f5f5;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.el-tag--success {
|
||||
background-color: rgba(63, 143, 107, 0.12);
|
||||
color: var(--ctms-success);
|
||||
border: 1px solid rgba(63, 143, 107, 0.2);
|
||||
}
|
||||
|
||||
.el-tag--warning {
|
||||
background-color: rgba(197, 139, 42, 0.12);
|
||||
color: var(--ctms-warning);
|
||||
border: 1px solid rgba(197, 139, 42, 0.2);
|
||||
}
|
||||
|
||||
.el-tag--danger {
|
||||
background-color: rgba(194, 75, 75, 0.12);
|
||||
color: var(--ctms-danger);
|
||||
border: 1px solid rgba(194, 75, 75, 0.2);
|
||||
}
|
||||
|
||||
.el-alert {
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
@@ -359,3 +377,83 @@ body {
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.ctms-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ctms-section-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ctms-section-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ctms-page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.ctms-descriptions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ctms-descriptions :deep(.el-descriptions__label) {
|
||||
font-weight: 500;
|
||||
color: var(--ctms-text-regular);
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.ctms-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px;
|
||||
background-color: var(--ctms-border-color);
|
||||
}
|
||||
|
||||
.ctms-tabs :deep(.el-tabs__item) {
|
||||
font-weight: 500;
|
||||
color: var(--ctms-text-regular);
|
||||
}
|
||||
|
||||
.ctms-tabs :deep(.el-tabs__item.is-active) {
|
||||
color: var(--ctms-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Tool classes */
|
||||
.text-main {
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--ctms-danger);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export interface LoginResponse {
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "ADMIN";
|
||||
export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "QA" | "ADMIN";
|
||||
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
|
||||
|
||||
export interface UserInfo {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
export type DocumentStatus = "DRAFT" | "ACTIVE" | "ARCHIVED";
|
||||
export type DocumentScopeType = "GLOBAL" | "SITE" | "DERIVED";
|
||||
export type DocumentVersionStatus =
|
||||
| "DRAFT"
|
||||
| "SUBMITTED"
|
||||
| "APPROVED"
|
||||
| "EFFECTIVE"
|
||||
| "SUPERSEDED"
|
||||
| "ARCHIVED"
|
||||
| "WITHDRAWN";
|
||||
|
||||
export interface DocumentVersionSummary {
|
||||
id: string;
|
||||
version_no: string;
|
||||
status: DocumentVersionStatus;
|
||||
effective_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentSummary {
|
||||
id: string;
|
||||
trial_id: string;
|
||||
site_id?: string | null;
|
||||
doc_no: string;
|
||||
doc_type: string;
|
||||
title: string;
|
||||
scope_type: DocumentScopeType;
|
||||
owner_id?: string | null;
|
||||
status: DocumentStatus;
|
||||
current_effective_version_id?: string | null;
|
||||
current_effective_version?: DocumentVersionSummary | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
can_actions?: string[];
|
||||
}
|
||||
|
||||
export interface DocumentVersion {
|
||||
id: string;
|
||||
document_id: string;
|
||||
version_no: string;
|
||||
parent_version_id?: string | null;
|
||||
derived_from_version_id?: string | null;
|
||||
status: DocumentVersionStatus;
|
||||
effective_at?: string | null;
|
||||
superseded_at?: string | null;
|
||||
file_uri: string;
|
||||
file_hash: string;
|
||||
file_size: number;
|
||||
mime_type?: string | null;
|
||||
change_summary?: string | null;
|
||||
created_by?: string | null;
|
||||
submitted_at?: string | null;
|
||||
approved_at?: string | null;
|
||||
withdrawn_at?: string | null;
|
||||
created_at: string;
|
||||
can_actions?: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
id: string;
|
||||
node_order: number;
|
||||
role: string;
|
||||
name?: string | null;
|
||||
is_required: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WorkflowTemplate {
|
||||
id: string;
|
||||
trial_id?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
created_by?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
nodes?: WorkflowNode[];
|
||||
}
|
||||
|
||||
export interface DistributionStats {
|
||||
total: number;
|
||||
received: number;
|
||||
read: number;
|
||||
trained: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
export type DistributionTargetType = "SITE" | "ROLE" | "USER";
|
||||
export type DistributionStatus = "ACTIVE" | "CLOSED";
|
||||
|
||||
export interface Distribution {
|
||||
id: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
target_type: DistributionTargetType;
|
||||
target_id: string;
|
||||
status: DistributionStatus;
|
||||
due_at?: string | null;
|
||||
created_by?: string | null;
|
||||
created_at: string;
|
||||
stats?: DistributionStats;
|
||||
}
|
||||
|
||||
export interface Acknowledgement {
|
||||
id: string;
|
||||
distribution_id: string;
|
||||
user_id: string;
|
||||
site_id?: string | null;
|
||||
ack_type: "RECEIVED" | "READ" | "TRAINED";
|
||||
due_at?: string | null;
|
||||
acked_at?: string | null;
|
||||
note?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DocumentDetail extends DocumentSummary {
|
||||
description?: string | null;
|
||||
version_timeline: DocumentVersion[];
|
||||
distribution_stats: DistributionStats;
|
||||
owner?: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: string;
|
||||
avatar_url?: string | null;
|
||||
} | null;
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">
|
||||
{{ detail.doc_no || TEXT.common.fallback }}
|
||||
<span class="text-secondary mx-2">/</span>
|
||||
{{ detail.title || TEXT.modules.fileVersionManagement.title }}
|
||||
</h1>
|
||||
<p class="ctms-page-subtitle">
|
||||
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
|
||||
<span class="text-secondary text-sm">
|
||||
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}:
|
||||
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-section-card" v-loading="loading">
|
||||
<el-descriptions :column="3" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.docNo">{{ detail.doc_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.docType">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.status">
|
||||
<el-tag :type="getStatusType(detail.status)" effect="light" size="small">{{ displayEnum(TEXT.enums.documentStatus, detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.effectiveAt">{{ formatDate(detail.current_effective_version?.effective_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="ctms-tabs">
|
||||
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.versions" name="versions">
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.fileVersionManagement.labels.versionTimeline }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" @click="openUpload" v-if="canAction('create_version')">
|
||||
<el-icon class="el-icon--left"><Upload /></el-icon>
|
||||
{{ TEXT.modules.fileVersionManagement.actions.uploadVersion }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card class="ctms-table-card">
|
||||
<el-table :data="detail.version_timeline" v-loading="versionLoading" class="ctms-table">
|
||||
<el-table-column prop="version_no" :label="TEXT.modules.fileVersionManagement.fields.versionNo" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium">V{{ row.version_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.status" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getVersionStatusType(row.status)" effect="light" size="small">{{ displayEnum(TEXT.enums.documentVersionStatus, row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="change_summary" :label="TEXT.modules.fileVersionManagement.fields.changeSummary" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.createdBy" width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ displayCreator(row.created_by) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.createdAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.approvalSummary" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="text-xs text-secondary lh-sm">
|
||||
<div v-if="row.submitted_at">{{ TEXT.modules.fileVersionManagement.labels.submitAt }}:{{ formatDate(row.submitted_at) }}</div>
|
||||
<div v-if="row.approved_at" class="mt-1">
|
||||
{{ TEXT.modules.fileVersionManagement.labels.approveAt }}:{{ formatDate(row.approved_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canAction('view')">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.download }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openSubmit(row)" v-if="canAction('submit') && row.status === 'DRAFT'">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.submit }}
|
||||
</el-button>
|
||||
<el-button link type="success" size="small" @click="approve(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.approve }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="reject(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.reject }}
|
||||
</el-button>
|
||||
<el-button link type="warning" size="small" @click="makeEffective(row)" v-if="canAction('approve') && row.status === 'APPROVED'">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.makeEffective }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.distributions" name="distributions">
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.fileVersionManagement.labels.distributionList }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" @click="openDistribute" v-if="canAction('distribute')">
|
||||
<el-icon class="el-icon--left"><Share /></el-icon>
|
||||
{{ TEXT.modules.fileVersionManagement.actions.distribute }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card class="ctms-table-card">
|
||||
<el-table :data="distributions" v-loading="distributionLoading" class="ctms-table">
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.targetType" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain" type="info">{{ displayEnum(TEXT.enums.distributionTargetType, row.target_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.target" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium">{{ displayTarget(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackStats" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span>{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}: {{ row.stats?.received || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.overdue" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.stats?.overdue" type="danger" effect="dark" size="small">{{ TEXT.modules.fileVersionManagement.labels.overdue }}</el-tag>
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.dueAt" width="160">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'text-danger': row.stats?.overdue }">{{ formatDate(row.due_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackAction" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="ack(row, 'RECEIVED')" v-if="canAction('ack')">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.audit" name="audit">
|
||||
<el-card class="ctms-table-card">
|
||||
<el-table :data="auditLogs" v-loading="auditLoading" class="ctms-table">
|
||||
<el-table-column prop="action" :label="TEXT.modules.fileVersionManagement.labels.auditAction" width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium">{{ row.action }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="detail" :label="TEXT.modules.fileVersionManagement.labels.auditDetail" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="operator_role" :label="TEXT.common.labels.role" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain" size="small">{{ row.operator_role }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.auditTime" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
<el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="110px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.versionNo" prop="version_no">
|
||||
<el-input v-model="uploadForm.version_no" :placeholder="TEXT.modules.fileVersionManagement.placeholders.versionNo" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.changeSummary">
|
||||
<el-input v-model="uploadForm.change_summary" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.parentVersion">
|
||||
<el-select v-model="uploadForm.parent_version_id" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in detail.version_timeline"
|
||||
:key="item.id"
|
||||
:label="item.version_no"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.file">
|
||||
<input type="file" @change="onFileChange" class="ctms-file-input" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="submitUpload">{{ TEXT.common.actions.confirm }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="distributeVisible" :title="TEXT.modules.fileVersionManagement.dialog.distributeTitle" width="600px" destroy-on-close class="ctms-dialog">
|
||||
<el-form :model="distributeForm" label-width="100px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.targetList">
|
||||
<div class="target-list w-full">
|
||||
<div class="target-row" v-for="(item, index) in distributeForm.targets" :key="item.uid">
|
||||
<el-select
|
||||
v-model="item.target_type"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 120px"
|
||||
@change="onTargetTypeChange(index)"
|
||||
>
|
||||
<el-option :label="TEXT.enums.distributionTargetType.ROLE" value="ROLE" />
|
||||
<el-option :label="TEXT.enums.distributionTargetType.USER" value="USER" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-if="item.target_type === 'ROLE'"
|
||||
v-model="item.target_id"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="target-select"
|
||||
>
|
||||
<el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-else
|
||||
v-model="item.target_id"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="target-select"
|
||||
>
|
||||
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" />
|
||||
</el-select>
|
||||
<el-button link type="danger" @click="removeTarget(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="addTarget" class="w-full dashed-button">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.fileVersionManagement.actions.addTarget }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.dueAt">
|
||||
<el-date-picker v-model="distributeForm.due_at" type="datetime" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="distributeVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="distributing" @click="submitDistribute">{{ TEXT.common.actions.confirm }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="submitVisible" :title="TEXT.modules.fileVersionManagement.messages.submitTitle" width="480px" destroy-on-close class="ctms-dialog">
|
||||
<el-form :model="submitForm" label-width="100px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.workflowTemplate">
|
||||
<el-select
|
||||
v-model="submitForm.template_id"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="TEXT.modules.fileVersionManagement.placeholders.workflowTemplate"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="item in workflowTemplates" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="confirmSubmit">{{ TEXT.common.actions.confirm }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, FormInstance, FormRules } from "element-plus";
|
||||
import { ArrowLeft, Upload, Plus, Download, Check, Close, Link, Share, Delete } from "@element-plus/icons-vue";
|
||||
import {
|
||||
approveVersion,
|
||||
createAcknowledgement,
|
||||
createDistributions,
|
||||
downloadDocumentVersion,
|
||||
fetchDocumentDetail,
|
||||
listDistributions,
|
||||
listWorkflowTemplates,
|
||||
makeVersionEffective,
|
||||
rejectVersion,
|
||||
submitVersion,
|
||||
uploadDocumentVersion,
|
||||
} from "../../api/documents";
|
||||
import { fetchAuditLogs } from "../../api/auditLogs";
|
||||
import { listMembers } from "../../api/members";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import type { Distribution, DocumentDetail, DocumentVersion, WorkflowTemplate } from "../../types/documents";
|
||||
import type { Site, StudyMember } from "../../types/api";
|
||||
import type { UserInfo } from "../../types/api";
|
||||
import { TEXT } from "../../locales";
|
||||
import { displayEnum, displayText, getUserDisplayName } from "../../utils/display";
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "DRAFT":
|
||||
return "info";
|
||||
case "ARCHIVED":
|
||||
return "info";
|
||||
case "OBSOLETE":
|
||||
return "danger";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getVersionStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "APPROVED":
|
||||
return "success";
|
||||
case "DRAFT":
|
||||
return "info";
|
||||
case "SUBMITTED":
|
||||
return "warning";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const documentId = computed(() => route.params.id as string);
|
||||
|
||||
const loading = ref(false);
|
||||
const versionLoading = ref(false);
|
||||
const distributionLoading = ref(false);
|
||||
const auditLoading = ref(false);
|
||||
const detail = reactive<DocumentDetail>({
|
||||
id: "",
|
||||
trial_id: "",
|
||||
site_id: null,
|
||||
doc_no: "",
|
||||
doc_type: "",
|
||||
title: "",
|
||||
scope_type: "GLOBAL",
|
||||
status: "ACTIVE",
|
||||
current_effective_version_id: null,
|
||||
current_effective_version: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
version_timeline: [],
|
||||
distribution_stats: { total: 0, received: 0, read: 0, trained: 0, overdue: 0 },
|
||||
});
|
||||
|
||||
const activeTab = ref("versions");
|
||||
const distributions = ref<Distribution[]>([]);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const users = ref<UserInfo[]>([]);
|
||||
const sites = ref<Site[]>([]);
|
||||
const members = ref<StudyMember[]>([]);
|
||||
const workflowTemplates = ref<WorkflowTemplate[]>([]);
|
||||
|
||||
const userMap = computed(() =>
|
||||
users.value.reduce<Record<string, string>>((acc, user) => {
|
||||
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const displayCreator = (userId?: string | null) => {
|
||||
if (!userId) return TEXT.common.fallback;
|
||||
return userMap.value[userId] || userId;
|
||||
};
|
||||
|
||||
const displaySite = (siteId?: string | null) => {
|
||||
if (!siteId) return TEXT.enums.scopeType.GLOBAL;
|
||||
return sites.value.find((s) => s.id === siteId)?.name || siteId;
|
||||
};
|
||||
|
||||
const uploadVisible = ref(false);
|
||||
const uploading = ref(false);
|
||||
const uploadFormRef = ref<FormInstance>();
|
||||
const uploadForm = reactive({
|
||||
version_no: "",
|
||||
change_summary: "",
|
||||
parent_version_id: "",
|
||||
});
|
||||
const uploadFile = ref<File | null>(null);
|
||||
|
||||
const distributeVisible = ref(false);
|
||||
const distributing = ref(false);
|
||||
const targetSeed = ref(0);
|
||||
const distributeForm = reactive({
|
||||
targets: [{ uid: 0, target_type: "ROLE", target_id: "" }],
|
||||
due_at: "",
|
||||
});
|
||||
|
||||
const submitVisible = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const submitForm = reactive({
|
||||
template_id: "",
|
||||
});
|
||||
const submitTarget = ref<DocumentVersion | null>(null);
|
||||
|
||||
const uploadRules: FormRules = {
|
||||
version_no: [
|
||||
{ required: true, message: TEXT.common.messages.required, trigger: "blur" },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
const valid = /^\d+\.\d+$/.test(value);
|
||||
callback(valid ? undefined : new Error(TEXT.modules.fileVersionManagement.messages.versionNoInvalid));
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
const fallbackRoles = ["CRA", "PM", "IMP", "PV"];
|
||||
const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean));
|
||||
const values = roles.size ? Array.from(roles) : fallbackRoles;
|
||||
return values.map((value) => ({
|
||||
value,
|
||||
label: displayEnum(TEXT.enums.userRole, value),
|
||||
}));
|
||||
});
|
||||
|
||||
const memberOptions = computed(() =>
|
||||
members.value.map((m) => {
|
||||
const name = getUserDisplayName((m as any).user) || (m as any).user?.email || m.user_id;
|
||||
const roleLabel = displayEnum(TEXT.enums.userRole, m.role_in_study);
|
||||
return {
|
||||
value: m.user_id,
|
||||
label: `${name}(${roleLabel})`,
|
||||
role: m.role_in_study,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const displayTarget = (row: Distribution) => {
|
||||
if (row.target_type === "ROLE") {
|
||||
return displayEnum(TEXT.enums.userRole, row.target_id);
|
||||
}
|
||||
if (row.target_type === "USER") {
|
||||
const hit = memberOptions.value.find((m) => m.value === row.target_id);
|
||||
return hit?.label || row.target_id;
|
||||
}
|
||||
return row.target_id;
|
||||
};
|
||||
|
||||
const createTarget = () => ({
|
||||
uid: ++targetSeed.value,
|
||||
target_type: "ROLE",
|
||||
target_id: "",
|
||||
});
|
||||
|
||||
const canAction = (action: string) => {
|
||||
const list = detail.can_actions || [];
|
||||
if (list.length === 0) return true;
|
||||
return list.includes(action);
|
||||
};
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!detail.trial_id) {
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await listMembers(detail.trial_id, { limit: 500 });
|
||||
members.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
members.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!detail.trial_id) {
|
||||
sites.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await fetchSites(detail.trial_id, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const { data } = await fetchUsers({ limit: 500 });
|
||||
users.value = (data as any).items || data || [];
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!documentId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchDocumentDetail(documentId.value);
|
||||
Object.assign(detail, data);
|
||||
if (!users.value.length) {
|
||||
await loadUsers();
|
||||
}
|
||||
if (!members.value.length) {
|
||||
await loadMembers();
|
||||
}
|
||||
if (!sites.value.length) {
|
||||
await loadSites();
|
||||
}
|
||||
if (activeTab.value === "distributions") {
|
||||
await loadDistributions();
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadDistributions = async () => {
|
||||
if (!detail.current_effective_version_id) {
|
||||
distributions.value = [];
|
||||
return;
|
||||
}
|
||||
distributionLoading.value = true;
|
||||
try {
|
||||
const { data } = await listDistributions(detail.current_effective_version_id);
|
||||
distributions.value = data || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
distributionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuditLogs = async () => {
|
||||
if (!detail.trial_id) return;
|
||||
auditLoading.value = true;
|
||||
try {
|
||||
const { data } = await fetchAuditLogs(detail.trial_id, {
|
||||
entity_type: "DOCUMENT",
|
||||
entity_id: documentId.value,
|
||||
limit: 20,
|
||||
});
|
||||
auditLogs.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
auditLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadWorkflowTemplates = async () => {
|
||||
try {
|
||||
const params = detail.trial_id ? { trial_id: detail.trial_id } : undefined;
|
||||
const { data } = await listWorkflowTemplates(params);
|
||||
workflowTemplates.value = data || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = async (name: string) => {
|
||||
if (name === "distributions") {
|
||||
await loadDistributions();
|
||||
}
|
||||
if (name === "audit") {
|
||||
await loadAuditLogs();
|
||||
}
|
||||
};
|
||||
|
||||
const openUpload = () => {
|
||||
uploadForm.version_no = "";
|
||||
uploadForm.change_summary = "";
|
||||
uploadForm.parent_version_id = "";
|
||||
uploadFile.value = null;
|
||||
uploadVisible.value = true;
|
||||
};
|
||||
|
||||
const onFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
uploadFile.value = target.files[0];
|
||||
}
|
||||
};
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (!uploadFormRef.value) return;
|
||||
await uploadFormRef.value.validate(async (valid) => {
|
||||
if (!valid || !documentId.value || !uploadFile.value) {
|
||||
if (!uploadFile.value) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingFile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("version_no", uploadForm.version_no);
|
||||
if (uploadForm.change_summary) {
|
||||
formData.append("change_summary", uploadForm.change_summary);
|
||||
}
|
||||
if (uploadForm.parent_version_id) {
|
||||
formData.append("parent_version_id", uploadForm.parent_version_id);
|
||||
}
|
||||
formData.append("file", uploadFile.value);
|
||||
await uploadDocumentVersion(documentId.value, formData);
|
||||
uploadVisible.value = false;
|
||||
await loadDetail();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.uploadSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openSubmit = async (version: DocumentVersion) => {
|
||||
submitTarget.value = version;
|
||||
submitForm.template_id = "";
|
||||
submitVisible.value = true;
|
||||
if (!workflowTemplates.value.length) {
|
||||
await loadWorkflowTemplates();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSubmit = async () => {
|
||||
if (!submitTarget.value) return;
|
||||
if (!submitForm.template_id) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.workflowTemplateRequired);
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
await submitVersion(submitTarget.value.id, { template_id: submitForm.template_id, comment: "" });
|
||||
submitVisible.value = false;
|
||||
await loadDetail();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.submitSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const approve = async (version: DocumentVersion) => {
|
||||
versionLoading.value = true;
|
||||
try {
|
||||
await approveVersion(version.id, { comment: "" });
|
||||
await loadDetail();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.approveSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
versionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const reject = async (version: DocumentVersion) => {
|
||||
versionLoading.value = true;
|
||||
try {
|
||||
await rejectVersion(version.id, { comment: "" });
|
||||
await loadDetail();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.rejectSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
versionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const makeEffective = async (version: DocumentVersion) => {
|
||||
versionLoading.value = true;
|
||||
try {
|
||||
await makeVersionEffective(version.id);
|
||||
await loadDetail();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.effectiveSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
versionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getFilename = (header?: string | null) => {
|
||||
if (!header) return null;
|
||||
const match = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(header);
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match[1] || match[2] || "");
|
||||
};
|
||||
|
||||
const downloadVersion = async (version: DocumentVersion) => {
|
||||
try {
|
||||
const response = await downloadDocumentVersion(version.id);
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename =
|
||||
getFilename(response.headers?.["content-disposition"]) ||
|
||||
`document-${version.version_no || version.id}.bin`;
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const openDistribute = () => {
|
||||
distributeForm.targets = [createTarget()];
|
||||
distributeForm.due_at = "";
|
||||
distributeVisible.value = true;
|
||||
if (!members.value.length) {
|
||||
loadMembers();
|
||||
}
|
||||
};
|
||||
|
||||
const addTarget = () => {
|
||||
distributeForm.targets.push(createTarget());
|
||||
};
|
||||
|
||||
const removeTarget = (index: number) => {
|
||||
distributeForm.targets.splice(index, 1);
|
||||
};
|
||||
|
||||
const onTargetTypeChange = (index: number) => {
|
||||
const target = distributeForm.targets[index];
|
||||
if (!target) return;
|
||||
target.target_id = "";
|
||||
};
|
||||
|
||||
const submitDistribute = async () => {
|
||||
if (!detail.current_effective_version_id) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.noEffective);
|
||||
return;
|
||||
}
|
||||
if (distributeForm.targets.some((t) => !t.target_id)) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
|
||||
return;
|
||||
}
|
||||
distributing.value = true;
|
||||
try {
|
||||
const targets = distributeForm.targets.map((t) => ({
|
||||
target_type: t.target_type,
|
||||
target_id: t.target_id,
|
||||
}));
|
||||
await createDistributions(detail.current_effective_version_id, {
|
||||
targets,
|
||||
due_at: distributeForm.due_at || undefined,
|
||||
});
|
||||
distributeVisible.value = false;
|
||||
await loadDistributions();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.distributeSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
distributing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const ack = async (distribution: Distribution, ackType: string) => {
|
||||
try {
|
||||
await createAcknowledgement(distribution.id, { ack_type: ackType });
|
||||
await loadDistributions();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.ackSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (detail.trial_id) {
|
||||
router.push(`/trial/${detail.trial_id}/documents`);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return TEXT.common.fallback;
|
||||
return value.replace("T", " ").replace("Z", "").split(".")[0];
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDetail();
|
||||
await loadDistributions();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => documentId.value,
|
||||
async (value, oldValue) => {
|
||||
if (value && value !== oldValue) {
|
||||
await loadDetail();
|
||||
await loadDistributions();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-secondary {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.text-main {
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bg-primary-light {
|
||||
background-color: var(--ctms-primary-light);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--ctms-danger);
|
||||
}
|
||||
|
||||
.lh-sm {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dashed-button {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mx-2 {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.fileVersionManagement.subtitle }}</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.fileVersionManagement.newDocument }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-filter-card">
|
||||
<div class="ctms-filter-row">
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.scope }}</span>
|
||||
<el-select v-model="filters.scope_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
|
||||
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.site }}</span>
|
||||
<el-select v-model="filters.site_id" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable filterable>
|
||||
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.docType }}</span>
|
||||
<el-select v-model="filters.doc_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
|
||||
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.status }}</span>
|
||||
<el-select v-model="filters.status" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
|
||||
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-spacer"></div>
|
||||
<div class="ctms-filter-actions">
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="ctms-table-card">
|
||||
<el-table :data="items" v-loading="loading" @row-click="handleRowClick" row-class-name="clickable-row" class="ctms-table">
|
||||
<el-table-column prop="doc_no" :label="TEXT.modules.fileVersionManagement.columns.docNo" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="font-mono font-medium">{{ row.doc_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold">{{ row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
effect="plain"
|
||||
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ displaySite(row.site_id) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.status" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" effect="light">
|
||||
{{ displayEnum(TEXT.enums.documentStatus, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.effectiveAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDate(row.current_effective_version?.effective_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click.stop="confirmDelete(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="TEXT.modules.fileVersionManagement.dialog.createTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docNo" prop="doc_no">
|
||||
<el-input v-model="createForm.doc_no" placeholder="e.g. SOP-001" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.title" prop="title">
|
||||
<el-input v-model="createForm.title" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.scope" prop="scope_type">
|
||||
<el-select v-model="createForm.scope_type" style="width: 100%">
|
||||
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="createForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
|
||||
<el-select v-model="createForm.site_id" filterable style="width: 100%">
|
||||
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
|
||||
<el-select v-model="createForm.doc_type" style="width: 100%">
|
||||
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="submitCreate">{{ TEXT.common.actions.confirm }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { fetchDocuments, createDocument, deleteDocument } from "../../api/documents";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import type { DocumentSummary } from "../../types/documents";
|
||||
import type { Site } from "../../types/api";
|
||||
import { displayEnum, displayText } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "DRAFT":
|
||||
return "info";
|
||||
case "ARCHIVED":
|
||||
return "info";
|
||||
case "OBSOLETE":
|
||||
return "danger";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<DocumentSummary[]>([]);
|
||||
const sites = ref<Site[]>([]);
|
||||
const createVisible = ref(false);
|
||||
const creating = ref(false);
|
||||
const createFormRef = ref<FormInstance>();
|
||||
|
||||
const trialId = computed(() => (route.params.trialId as string) || "");
|
||||
|
||||
const filters = reactive({
|
||||
scope_type: "",
|
||||
site_id: "",
|
||||
doc_type: "",
|
||||
status: "",
|
||||
});
|
||||
|
||||
const docTypeOptions = Object.keys(TEXT.enums.documentType).map((value) => ({
|
||||
value,
|
||||
label: TEXT.enums.documentType[value as keyof typeof TEXT.enums.documentType],
|
||||
}));
|
||||
const statusOptions = Object.keys(TEXT.enums.documentStatus).map((value) => ({
|
||||
value,
|
||||
label: TEXT.enums.documentStatus[value as keyof typeof TEXT.enums.documentStatus],
|
||||
}));
|
||||
const scopeOptions = [
|
||||
{ value: "GLOBAL", label: TEXT.enums.scopeType.GLOBAL },
|
||||
{ value: "SITE", label: TEXT.enums.scopeType.SITE },
|
||||
];
|
||||
const siteOptions = computed(() =>
|
||||
sites.value.map((site) => ({ value: site.id, label: site.name }))
|
||||
);
|
||||
|
||||
const createForm = reactive({
|
||||
doc_no: "",
|
||||
title: "",
|
||||
scope_type: "GLOBAL",
|
||||
site_id: "",
|
||||
doc_type: "",
|
||||
});
|
||||
|
||||
const createRules: FormRules = {
|
||||
doc_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
||||
title: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
||||
scope_type: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
||||
site_id: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (createForm.scope_type === "SITE" && !value) {
|
||||
callback(new Error(TEXT.common.messages.required));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: "change",
|
||||
},
|
||||
],
|
||||
doc_type: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
||||
};
|
||||
|
||||
const displaySite = (siteId?: string | null) => {
|
||||
if (!siteId) return TEXT.common.fallback;
|
||||
return sites.value.find((s) => s.id === siteId)?.name || siteId;
|
||||
};
|
||||
|
||||
const ensureTrialRoute = () => {
|
||||
if (trialId.value) return;
|
||||
const currentStudy = study.currentStudy?.id;
|
||||
if (currentStudy) {
|
||||
router.replace(`/trial/${currentStudy}/documents`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!trialId.value) return;
|
||||
try {
|
||||
const { data } = await fetchSites(trialId.value, { limit: 500 });
|
||||
const items = Array.isArray(data) ? data : data.items || [];
|
||||
sites.value = items;
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!trialId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchDocuments({
|
||||
trial_id: trialId.value,
|
||||
scope_type: filters.scope_type || undefined,
|
||||
site_id: filters.site_id || undefined,
|
||||
doc_type: filters.doc_type || undefined,
|
||||
status: filters.status || undefined,
|
||||
});
|
||||
items.value = data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.scope_type = "";
|
||||
filters.site_id = "";
|
||||
filters.doc_type = "";
|
||||
filters.status = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
createForm.doc_no = "";
|
||||
createForm.title = "";
|
||||
createForm.scope_type = "GLOBAL";
|
||||
createForm.site_id = "";
|
||||
createForm.doc_type = "";
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!createFormRef.value) return;
|
||||
await createFormRef.value.validate(async (valid) => {
|
||||
if (!valid || !trialId.value) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
await createDocument({
|
||||
trial_id: trialId.value,
|
||||
site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined,
|
||||
doc_no: createForm.doc_no,
|
||||
title: createForm.title,
|
||||
doc_type: createForm.doc_type,
|
||||
scope_type: createForm.scope_type,
|
||||
});
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.createSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const goDetail = (id: string) => router.push(`/documents/${id}`);
|
||||
|
||||
const handleRowClick = (row: DocumentSummary) => {
|
||||
if (!row?.id) return;
|
||||
goDetail(row.id);
|
||||
};
|
||||
|
||||
const confirmDelete = async (row: DocumentSummary) => {
|
||||
if (!row?.id) return;
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteDocument(row.id);
|
||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return TEXT.common.fallback;
|
||||
return value.replace("T", " ").replace("Z", "").split(".")[0];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
ensureTrialRoute();
|
||||
load();
|
||||
loadSites();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => trialId.value,
|
||||
(value, oldValue) => {
|
||||
if (value && value !== oldValue) {
|
||||
load();
|
||||
loadSites();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-secondary {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,28 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.fileVersionManagement.title"
|
||||
:subtitle="TEXT.modules.fileVersionManagement.subtitle"
|
||||
:list-title="TEXT.modules.fileVersionManagement.listTitle"
|
||||
:empty-description="TEXT.modules.fileVersionManagement.emptyDescription"
|
||||
/>
|
||||
<div class="redirecting">
|
||||
<el-empty :description="`${TEXT.common.loading}...`" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
|
||||
onMounted(() => {
|
||||
const currentStudy = study.currentStudy?.id;
|
||||
if (currentStudy) {
|
||||
router.replace(`/trial/${currentStudy}/documents`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.redirecting {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user