补齐业务路由与页面权限控制
This commit is contained in:
+66
-17
@@ -48,7 +48,7 @@ def _is_overdue(ae: AERead) -> bool:
|
|||||||
"/",
|
"/",
|
||||||
response_model=AERead,
|
response_model=AERead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_api_permission("risk_issues:create")), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_api_permission("subject_aes:create")), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_ae(
|
async def create_ae(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -86,20 +86,15 @@ async def create_ae(
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
async def _list_ae_records(
|
||||||
"/",
|
|
||||||
response_model=list[AERead],
|
|
||||||
dependencies=[Depends(require_api_permission("risk_issues:read"))],
|
|
||||||
)
|
|
||||||
async def list_ae(
|
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
status_filter: str | None = None,
|
status_filter: str | None,
|
||||||
seriousness: str | None = None,
|
seriousness: str | None,
|
||||||
site_id: uuid.UUID | None = None,
|
site_id: uuid.UUID | None,
|
||||||
subject_id: uuid.UUID | None = None,
|
subject_id: uuid.UUID | None,
|
||||||
overdue: bool | None = None,
|
overdue: bool | None,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession,
|
||||||
current_user=Depends(get_current_user),
|
current_user,
|
||||||
) -> list[AERead]:
|
) -> list[AERead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||||
@@ -124,10 +119,64 @@ async def list_ae(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/summary",
|
||||||
|
response_model=list[AERead],
|
||||||
|
dependencies=[Depends(require_api_permission("subject_aes:list"))],
|
||||||
|
)
|
||||||
|
async def list_risk_issue_ae(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
seriousness: str | None = None,
|
||||||
|
site_id: uuid.UUID | None = None,
|
||||||
|
subject_id: uuid.UUID | None = None,
|
||||||
|
overdue: bool | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> list[AERead]:
|
||||||
|
return await _list_ae_records(
|
||||||
|
study_id,
|
||||||
|
status_filter,
|
||||||
|
seriousness,
|
||||||
|
site_id,
|
||||||
|
subject_id,
|
||||||
|
overdue,
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/",
|
||||||
|
response_model=list[AERead],
|
||||||
|
dependencies=[Depends(require_api_permission("subject_aes:list"))],
|
||||||
|
)
|
||||||
|
async def list_ae(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
seriousness: str | None = None,
|
||||||
|
site_id: uuid.UUID | None = None,
|
||||||
|
subject_id: uuid.UUID | None = None,
|
||||||
|
overdue: bool | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> list[AERead]:
|
||||||
|
return await _list_ae_records(
|
||||||
|
study_id,
|
||||||
|
status_filter,
|
||||||
|
seriousness,
|
||||||
|
site_id,
|
||||||
|
subject_id,
|
||||||
|
overdue,
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{ae_id}",
|
"/{ae_id}",
|
||||||
response_model=AERead,
|
response_model=AERead,
|
||||||
dependencies=[Depends(require_api_permission("risk_issues:read"))],
|
dependencies=[Depends(require_api_permission("subject_aes:read"))],
|
||||||
)
|
)
|
||||||
async def get_ae(
|
async def get_ae(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -155,7 +204,7 @@ async def get_ae(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/{ae_id}",
|
"/{ae_id}",
|
||||||
response_model=AERead,
|
response_model=AERead,
|
||||||
dependencies=[Depends(require_api_permission("risk_issues:update")), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_api_permission("subject_aes:update")), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_ae(
|
async def update_ae(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -210,7 +259,7 @@ async def update_ae(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{ae_id}",
|
"/{ae_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(require_api_permission("risk_issues:delete")), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_api_permission("subject_aes:delete")), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_ae(
|
async def delete_ae(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def list_lost_visits(
|
|||||||
|
|
||||||
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[
|
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[
|
||||||
Depends(require_study_member()),
|
Depends(require_study_member()),
|
||||||
Depends(require_api_permission("dashboard:read"))
|
Depends(require_api_permission("project_overview:read"))
|
||||||
])
|
])
|
||||||
async def get_center_summary(
|
async def get_center_summary(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -91,7 +91,7 @@ async def get_center_summary(
|
|||||||
|
|
||||||
stage_order = [
|
stage_order = [
|
||||||
("institution_initiation_status", "机构立项"),
|
("institution_initiation_status", "机构立项"),
|
||||||
("ethics_status", "伦理审批"),
|
("ethics_status", "伦理记录"),
|
||||||
("contract_sign_status", "合同签署"),
|
("contract_sign_status", "合同签署"),
|
||||||
("startup_status", "启动"),
|
("startup_status", "启动"),
|
||||||
("enrollment_status", "入组"),
|
("enrollment_status", "入组"),
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ PROJECT_ROLE_RANK = {
|
|||||||
"ADMIN": 100,
|
"ADMIN": 100,
|
||||||
"PM": 80,
|
"PM": 80,
|
||||||
"PV": 50,
|
"PV": 50,
|
||||||
"MEDICAL_REVIEW": 50,
|
"QA": 50,
|
||||||
"CRA": 40,
|
"CRA": 40,
|
||||||
"IMP": 40,
|
"CTA": 40,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/issues",
|
"/issues",
|
||||||
response_model=list[MonitoringVisitIssueRead],
|
response_model=list[MonitoringVisitIssueRead],
|
||||||
dependencies=[Depends(require_api_permission("monitoring_audit:read"))],
|
dependencies=[Depends(require_api_permission("monitoring_issues:list"))],
|
||||||
)
|
)
|
||||||
async def list_monitoring_visit_issues(
|
async def list_monitoring_visit_issues(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -382,7 +382,7 @@ async def list_monitoring_visit_issues(
|
|||||||
"/issues",
|
"/issues",
|
||||||
response_model=MonitoringVisitIssueRead,
|
response_model=MonitoringVisitIssueRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_api_permission("monitoring_audit:create")), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_api_permission("monitoring_issues:create")), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_monitoring_visit_issue(
|
async def create_monitoring_visit_issue(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -421,7 +421,7 @@ async def create_monitoring_visit_issue(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/issues/export",
|
"/issues/export",
|
||||||
response_class=StreamingResponse,
|
response_class=StreamingResponse,
|
||||||
dependencies=[Depends(require_api_permission("monitoring_audit:read"))],
|
dependencies=[Depends(require_api_permission("monitoring_issues:list"))],
|
||||||
)
|
)
|
||||||
async def export_monitoring_visit_issues(
|
async def export_monitoring_visit_issues(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -553,7 +553,7 @@ async def export_monitoring_visit_issues(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/issues/{issue_id}",
|
"/issues/{issue_id}",
|
||||||
response_model=MonitoringVisitIssueRead,
|
response_model=MonitoringVisitIssueRead,
|
||||||
dependencies=[Depends(require_api_permission("monitoring_audit:read"))],
|
dependencies=[Depends(require_api_permission("monitoring_issues:read"))],
|
||||||
)
|
)
|
||||||
async def get_monitoring_visit_issue(
|
async def get_monitoring_visit_issue(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -570,7 +570,7 @@ async def get_monitoring_visit_issue(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/issues/{issue_id}",
|
"/issues/{issue_id}",
|
||||||
response_model=MonitoringVisitIssueRead,
|
response_model=MonitoringVisitIssueRead,
|
||||||
dependencies=[Depends(require_api_permission("monitoring_audit:update")), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_api_permission("monitoring_issues:update")), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_monitoring_visit_issue(
|
async def update_monitoring_visit_issue(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -625,7 +625,7 @@ async def update_monitoring_visit_issue(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/issues/{issue_id}",
|
"/issues/{issue_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(require_api_permission("monitoring_audit:delete")), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_api_permission("monitoring_issues:delete")), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_monitoring_visit_issue(
|
async def delete_monitoring_visit_issue(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -655,7 +655,7 @@ async def delete_monitoring_visit_issue(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/issues/import",
|
"/issues/import",
|
||||||
response_model=MonitoringVisitIssueImportSummary,
|
response_model=MonitoringVisitIssueImportSummary,
|
||||||
dependencies=[Depends(require_api_permission("monitoring_audit:create")), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_api_permission("monitoring_issues:create")), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def import_monitoring_visit_issues(
|
async def import_monitoring_visit_issues(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async def get_project_overview(
|
|||||||
|
|
||||||
返回项目各中心的进度情况,包括:
|
返回项目各中心的进度情况,包括:
|
||||||
- 机构立项状态
|
- 机构立项状态
|
||||||
- 伦理审批状态
|
- 伦理记录状态
|
||||||
- 合同签署状态
|
- 合同签署状态
|
||||||
- 启动会状态
|
- 启动会状态
|
||||||
- 入组进度
|
- 入组进度
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||||
|
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -22,14 +22,12 @@ api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects"
|
|||||||
api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"])
|
api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"])
|
||||||
api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["aes"])
|
api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["aes"])
|
||||||
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
|
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
|
||||||
api_router.include_router(finance_contracts.router, prefix="/studies/{study_id}/finance", tags=["finance-contracts"])
|
|
||||||
api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-contracts"])
|
api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-contracts"])
|
||||||
api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"])
|
|
||||||
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
|
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
|
||||||
api_router.include_router(material_equipments.router, prefix="/studies/{study_id}/materials", tags=["material-equipments"])
|
api_router.include_router(material_equipments.router, prefix="/studies/{study_id}/materials", tags=["material-equipments"])
|
||||||
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
|
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
|
||||||
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
||||||
api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"])
|
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
|
||||||
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
|
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
|
||||||
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
||||||
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
|
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
|
||||||
@@ -37,6 +35,7 @@ api_router.include_router(study_subject_pds.router, prefix="/studies/{study_id}"
|
|||||||
api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"])
|
api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"])
|
||||||
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
|
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
|
||||||
api_router.include_router(documents.router, prefix="", tags=["documents"])
|
api_router.include_router(documents.router, prefix="", tags=["documents"])
|
||||||
|
api_router.include_router(etmf.router, prefix="/etmf", tags=["etmf"])
|
||||||
api_router.include_router(permission_monitoring.router)
|
api_router.include_router(permission_monitoring.router)
|
||||||
api_router.include_router(permission_templates.router)
|
api_router.include_router(permission_templates.router)
|
||||||
api_router.include_router(permission_templates.study_router, prefix="/studies/{study_id}", tags=["permission-templates"])
|
api_router.include_router(permission_templates.study_router, prefix="/studies/{study_id}", tags=["permission-templates"])
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async def create_site(
|
|||||||
module="sites",
|
module="sites",
|
||||||
action="read",
|
action="read",
|
||||||
description="查询中心列表",
|
description="查询中心列表",
|
||||||
default_roles=["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
|
default_roles=["PM", "CRA", "PV", "QA", "CTA"],
|
||||||
)
|
)
|
||||||
async def list_sites(
|
async def list_sites(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None):
|
|||||||
"/feasibility",
|
"/feasibility",
|
||||||
response_model=StartupFeasibilityRead,
|
response_model=StartupFeasibilityRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_api_permission("ethics:create"))],
|
dependencies=[Depends(require_api_permission("startup_initiation:create"))],
|
||||||
)
|
)
|
||||||
async def create_feasibility(
|
async def create_feasibility(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -83,7 +83,7 @@ async def create_feasibility(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/feasibility",
|
"/feasibility",
|
||||||
response_model=list[StartupFeasibilityRead],
|
response_model=list[StartupFeasibilityRead],
|
||||||
dependencies=[Depends(require_api_permission("ethics:read"))],
|
dependencies=[Depends(require_api_permission("startup_initiation:read"))],
|
||||||
)
|
)
|
||||||
async def list_feasibilities(
|
async def list_feasibilities(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -102,7 +102,7 @@ async def list_feasibilities(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/feasibility/{record_id}",
|
"/feasibility/{record_id}",
|
||||||
response_model=StartupFeasibilityRead,
|
response_model=StartupFeasibilityRead,
|
||||||
dependencies=[Depends(require_api_permission("ethics:read"))],
|
dependencies=[Depends(require_api_permission("startup_initiation:read"))],
|
||||||
)
|
)
|
||||||
async def get_feasibility(
|
async def get_feasibility(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -123,7 +123,7 @@ async def get_feasibility(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/feasibility/{record_id}",
|
"/feasibility/{record_id}",
|
||||||
response_model=StartupFeasibilityRead,
|
response_model=StartupFeasibilityRead,
|
||||||
dependencies=[Depends(require_api_permission("ethics:update"))],
|
dependencies=[Depends(require_api_permission("startup_initiation:update"))],
|
||||||
)
|
)
|
||||||
async def update_feasibility(
|
async def update_feasibility(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -157,7 +157,7 @@ async def update_feasibility(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/feasibility/{record_id}",
|
"/feasibility/{record_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(require_api_permission("ethics:delete"))],
|
dependencies=[Depends(require_api_permission("startup_initiation:delete"))],
|
||||||
)
|
)
|
||||||
async def delete_feasibility(
|
async def delete_feasibility(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -190,7 +190,7 @@ async def delete_feasibility(
|
|||||||
"/ethics",
|
"/ethics",
|
||||||
response_model=StartupEthicsRead,
|
response_model=StartupEthicsRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_api_permission("ethics:create"))],
|
dependencies=[Depends(require_api_permission("startup_ethics:create"))],
|
||||||
)
|
)
|
||||||
async def create_ethics(
|
async def create_ethics(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -220,7 +220,7 @@ async def create_ethics(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/ethics",
|
"/ethics",
|
||||||
response_model=list[StartupEthicsRead],
|
response_model=list[StartupEthicsRead],
|
||||||
dependencies=[Depends(require_api_permission("ethics:read"))],
|
dependencies=[Depends(require_api_permission("startup_ethics:read"))],
|
||||||
)
|
)
|
||||||
async def list_ethics(
|
async def list_ethics(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -239,7 +239,7 @@ async def list_ethics(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/ethics/{record_id}",
|
"/ethics/{record_id}",
|
||||||
response_model=StartupEthicsRead,
|
response_model=StartupEthicsRead,
|
||||||
dependencies=[Depends(require_api_permission("ethics:read"))],
|
dependencies=[Depends(require_api_permission("startup_ethics:read"))],
|
||||||
)
|
)
|
||||||
async def get_ethics(
|
async def get_ethics(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -260,7 +260,7 @@ async def get_ethics(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/ethics/{record_id}",
|
"/ethics/{record_id}",
|
||||||
response_model=StartupEthicsRead,
|
response_model=StartupEthicsRead,
|
||||||
dependencies=[Depends(require_api_permission("ethics:update"))],
|
dependencies=[Depends(require_api_permission("startup_ethics:update"))],
|
||||||
)
|
)
|
||||||
async def update_ethics(
|
async def update_ethics(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -294,7 +294,7 @@ async def update_ethics(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/ethics/{record_id}",
|
"/ethics/{record_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(require_api_permission("ethics:delete"))],
|
dependencies=[Depends(require_api_permission("startup_ethics:delete"))],
|
||||||
)
|
)
|
||||||
async def delete_ethics(
|
async def delete_ethics(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
|
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_api_permission
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.crud import subject_pd as subject_pd_crud
|
from app.crud import subject_pd as subject_pd_crud
|
||||||
from app.schemas.subject_pd import SubjectPdSummaryRead
|
from app.schemas.subject_pd import SubjectPdSummaryRead
|
||||||
@@ -21,7 +21,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/subject-pds",
|
"/subject-pds",
|
||||||
response_model=list[SubjectPdSummaryRead],
|
response_model=list[SubjectPdSummaryRead],
|
||||||
dependencies=[Depends(require_study_member())],
|
dependencies=[Depends(require_api_permission("subject_pds:list"))],
|
||||||
)
|
)
|
||||||
async def list_study_subject_pds(
|
async def list_study_subject_pds(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async def create_history(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/histories",
|
"/histories",
|
||||||
response_model=list[SubjectHistoryRead],
|
response_model=list[SubjectHistoryRead],
|
||||||
dependencies=[Depends(require_api_permission("subject_histories:read"))],
|
dependencies=[Depends(require_api_permission("subject_histories:list"))],
|
||||||
)
|
)
|
||||||
async def list_histories(
|
async def list_histories(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ def _normalize_choice(value: str | None, allowed: set[str], field_name: str) ->
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/pds",
|
"/pds",
|
||||||
response_model=list[SubjectPdRead],
|
response_model=list[SubjectPdRead],
|
||||||
dependencies=[Depends(require_api_permission("subject_pds:read"))],
|
dependencies=[Depends(require_api_permission("subject_pds:list"))],
|
||||||
)
|
)
|
||||||
async def list_subject_pds(
|
async def list_subject_pds(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/",
|
"/",
|
||||||
response_model=list[VisitRead],
|
response_model=list[VisitRead],
|
||||||
dependencies=[Depends(require_api_permission("visits:read"))],
|
dependencies=[Depends(require_api_permission("visits:list"))],
|
||||||
)
|
)
|
||||||
async def list_visits(
|
async def list_visits(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.models.site import Site
|
from app.models.site import Site
|
||||||
from app.models.startup_feasibility import StartupFeasibility
|
from app.models.startup_feasibility import StartupFeasibility
|
||||||
from app.models.startup_ethics import StartupEthics
|
from app.models.startup_ethics import StartupEthics
|
||||||
from app.models.finance_contract import FinanceContract
|
from app.models.contract_fee import ContractFee
|
||||||
from app.models.kickoff_meeting import KickoffMeeting
|
from app.models.kickoff_meeting import KickoffMeeting
|
||||||
from app.models.subject import Subject
|
from app.models.subject import Subject
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict:
|
|||||||
institution_status = "COMPLETED" if feas.get("approved_date") else "IN_PROGRESS" if feas.get("submit_date") else "NOT_STARTED"
|
institution_status = "COMPLETED" if feas.get("approved_date") else "IN_PROGRESS" if feas.get("submit_date") else "NOT_STARTED"
|
||||||
institution_completed = feas.get("approved_date")
|
institution_completed = feas.get("approved_date")
|
||||||
|
|
||||||
# 伦理审批状态 - 依赖机构立项完成
|
# 伦理记录状态 - 依赖机构立项完成
|
||||||
eth = ethics_data.get(site_id_str, {})
|
eth = ethics_data.get(site_id_str, {})
|
||||||
if institution_status == "COMPLETED":
|
if institution_status == "COMPLETED":
|
||||||
ethics_status = "COMPLETED" if eth.get("approved_date") else "IN_PROGRESS" if eth.get("submit_date") else "NOT_STARTED"
|
ethics_status = "COMPLETED" if eth.get("approved_date") else "IN_PROGRESS" if eth.get("submit_date") else "NOT_STARTED"
|
||||||
@@ -68,7 +68,7 @@ async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict:
|
|||||||
ethics_status = "NOT_STARTED"
|
ethics_status = "NOT_STARTED"
|
||||||
ethics_completed = eth.get("approved_date") if institution_status == "COMPLETED" else None
|
ethics_completed = eth.get("approved_date") if institution_status == "COMPLETED" else None
|
||||||
|
|
||||||
# 合同签署状态 - 依赖伦理审批完成
|
# 合同签署状态 - 依赖伦理记录完成
|
||||||
contract = contract_data.get(site_id_str, {})
|
contract = contract_data.get(site_id_str, {})
|
||||||
if ethics_status == "COMPLETED":
|
if ethics_status == "COMPLETED":
|
||||||
contract_status = "COMPLETED" if contract.get("signed_date") else "NOT_STARTED"
|
contract_status = "COMPLETED" if contract.get("signed_date") else "NOT_STARTED"
|
||||||
@@ -202,26 +202,20 @@ async def _get_ethics_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List
|
|||||||
|
|
||||||
async def _get_contract_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List[uuid.UUID]) -> Dict:
|
async def _get_contract_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List[uuid.UUID]) -> Dict:
|
||||||
"""获取各中心的合同签署数据"""
|
"""获取各中心的合同签署数据"""
|
||||||
# FinanceContract 通过 site_name 关联,需要先获取site名称映射
|
|
||||||
stmt_sites = select(Site.id, Site.name).where(Site.id.in_(site_ids))
|
|
||||||
result = await db.execute(stmt_sites)
|
|
||||||
site_name_map = {row.name: str(row.id) for row in result.all()}
|
|
||||||
|
|
||||||
stmt = select(
|
stmt = select(
|
||||||
FinanceContract.site_name,
|
ContractFee.center_id,
|
||||||
func.min(FinanceContract.signed_date).label("signed_date")
|
func.min(ContractFee.signed_date).label("signed_date")
|
||||||
).where(
|
).where(
|
||||||
FinanceContract.study_id == study_id
|
ContractFee.project_id == study_id,
|
||||||
).group_by(FinanceContract.site_name)
|
ContractFee.center_id.in_(site_ids),
|
||||||
|
).group_by(ContractFee.center_id)
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
data = {}
|
data = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
site_id_str = site_name_map.get(row.site_name)
|
data[str(row.center_id)] = {
|
||||||
if site_id_str:
|
|
||||||
data[site_id_str] = {
|
|
||||||
"signed_date": row.signed_date.isoformat() if row.signed_date else None,
|
"signed_date": row.signed_date.isoformat() if row.signed_date else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-25
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, Sequence
|
from typing import Iterable, Sequence
|
||||||
@@ -16,9 +18,8 @@ from app.models.distribution import Distribution
|
|||||||
from app.models.drug_shipment import DrugShipment
|
from app.models.drug_shipment import DrugShipment
|
||||||
from app.models.fee_attachment import FeeAttachment
|
from app.models.fee_attachment import FeeAttachment
|
||||||
from app.models.finance import FinanceItem
|
from app.models.finance import FinanceItem
|
||||||
from app.models.finance_contract import FinanceContract
|
|
||||||
from app.models.kickoff_meeting import KickoffMeeting
|
from app.models.kickoff_meeting import KickoffMeeting
|
||||||
from app.models.knowledge_note import KnowledgeNote
|
from app.models.precaution import Precaution
|
||||||
from app.models.milestone import Milestone
|
from app.models.milestone import Milestone
|
||||||
from app.models.monitoring_visit_issue import MonitoringVisitIssue
|
from app.models.monitoring_visit_issue import MonitoringVisitIssue
|
||||||
from app.models.site import Site
|
from app.models.site import Site
|
||||||
@@ -177,19 +178,11 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
finance_contract_ids = (
|
precaution_ids = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(FinanceContract.id).where(
|
select(Precaution.id).where(
|
||||||
FinanceContract.study_id == study_id,
|
Precaution.study_id == study_id,
|
||||||
FinanceContract.site_name == site_name,
|
Precaution.site_name == site_name,
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
knowledge_note_ids = (
|
|
||||||
await db.execute(
|
|
||||||
select(KnowledgeNote.id).where(
|
|
||||||
KnowledgeNote.study_id == study_id,
|
|
||||||
KnowledgeNote.site_name == site_name,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -220,8 +213,7 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
|||||||
"startup_kickoff_signin": kickoff_ids,
|
"startup_kickoff_signin": kickoff_ids,
|
||||||
"startup_kickoff_ppt": kickoff_ids,
|
"startup_kickoff_ppt": kickoff_ids,
|
||||||
"training_authorization": training_ids,
|
"training_authorization": training_ids,
|
||||||
"finance_contract": finance_contract_ids,
|
"precaution": precaution_ids,
|
||||||
"knowledge_note": knowledge_note_ids,
|
|
||||||
"drug_shipment": drug_shipment_ids,
|
"drug_shipment": drug_shipment_ids,
|
||||||
}
|
}
|
||||||
attachment_paths: list[str] = []
|
attachment_paths: list[str] = []
|
||||||
@@ -311,15 +303,9 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(FinanceContract).where(
|
delete(Precaution).where(
|
||||||
FinanceContract.study_id == study_id,
|
Precaution.study_id == study_id,
|
||||||
FinanceContract.site_name == site_name,
|
Precaution.site_name == site_name,
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
delete(KnowledgeNote).where(
|
|
||||||
KnowledgeNote.study_id == study_id,
|
|
||||||
KnowledgeNote.site_name == site_name,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
@@ -99,7 +101,6 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
|||||||
from app.models.visit import Visit
|
from app.models.visit import Visit
|
||||||
from app.models.ae import AdverseEvent
|
from app.models.ae import AdverseEvent
|
||||||
from app.models.finance import FinanceItem
|
from app.models.finance import FinanceItem
|
||||||
from app.models.finance_contract import FinanceContract
|
|
||||||
from app.models.contract_fee import ContractFee
|
from app.models.contract_fee import ContractFee
|
||||||
from app.models.contract_fee_payment import ContractFeePayment
|
from app.models.contract_fee_payment import ContractFeePayment
|
||||||
from app.models.milestone import Milestone
|
from app.models.milestone import Milestone
|
||||||
@@ -111,7 +112,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
|||||||
from app.models.startup_feasibility import StartupFeasibility
|
from app.models.startup_feasibility import StartupFeasibility
|
||||||
from app.models.startup_ethics import StartupEthics
|
from app.models.startup_ethics import StartupEthics
|
||||||
from app.models.kickoff_meeting import KickoffMeeting
|
from app.models.kickoff_meeting import KickoffMeeting
|
||||||
from app.models.knowledge_note import KnowledgeNote
|
from app.models.precaution import Precaution
|
||||||
from app.models.faq_category import FaqCategory
|
from app.models.faq_category import FaqCategory
|
||||||
from app.models.faq_item import FaqItem
|
from app.models.faq_item import FaqItem
|
||||||
from app.models.faq_reply import FaqReply
|
from app.models.faq_reply import FaqReply
|
||||||
@@ -129,8 +130,8 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
|||||||
await db.execute(sa_delete(FaqItem).where(FaqItem.study_id == study_id))
|
await db.execute(sa_delete(FaqItem).where(FaqItem.study_id == study_id))
|
||||||
await db.execute(sa_delete(FaqCategory).where(FaqCategory.study_id == study_id))
|
await db.execute(sa_delete(FaqCategory).where(FaqCategory.study_id == study_id))
|
||||||
|
|
||||||
# 3. 删除知识库
|
# 3. 删除注意事项
|
||||||
await db.execute(sa_delete(KnowledgeNote).where(KnowledgeNote.study_id == study_id))
|
await db.execute(sa_delete(Precaution).where(Precaution.study_id == study_id))
|
||||||
|
|
||||||
# 4. 删除启动相关
|
# 4. 删除启动相关
|
||||||
await db.execute(sa_delete(KickoffMeeting).where(KickoffMeeting.study_id == study_id))
|
await db.execute(sa_delete(KickoffMeeting).where(KickoffMeeting.study_id == study_id))
|
||||||
@@ -158,7 +159,6 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
|
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
|
||||||
await db.execute(sa_delete(FinanceContract).where(FinanceContract.study_id == study_id))
|
|
||||||
await db.execute(sa_delete(FinanceItem).where(FinanceItem.study_id == study_id))
|
await db.execute(sa_delete(FinanceItem).where(FinanceItem.study_id == study_id))
|
||||||
|
|
||||||
# 10. 删除不良事件
|
# 10. 删除不良事件
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.models.site import Site # noqa: F401
|
|||||||
from app.models.study_member import StudyMember # noqa: F401
|
from app.models.study_member import StudyMember # noqa: F401
|
||||||
from app.models.attachment import Attachment # noqa: F401
|
from app.models.attachment import Attachment # noqa: F401
|
||||||
from app.models.audit_log import AuditLog # noqa: F401
|
from app.models.audit_log import AuditLog # noqa: F401
|
||||||
|
from app.models.etmf import EtmfNode # noqa: F401
|
||||||
from app.models.document import Document # noqa: F401
|
from app.models.document import Document # noqa: F401
|
||||||
from app.models.document_version import DocumentVersion # noqa: F401
|
from app.models.document_version import DocumentVersion # noqa: F401
|
||||||
from app.models.distribution import Distribution # noqa: F401
|
from app.models.distribution import Distribution # noqa: F401
|
||||||
@@ -17,7 +18,6 @@ from app.models.subject_pd import SubjectPd # noqa: F401
|
|||||||
from app.models.visit import Visit # noqa: F401
|
from app.models.visit import Visit # noqa: F401
|
||||||
from app.models.ae import AdverseEvent # noqa: F401
|
from app.models.ae import AdverseEvent # noqa: F401
|
||||||
from app.models.finance import FinanceItem # noqa: F401
|
from app.models.finance import FinanceItem # noqa: F401
|
||||||
from app.models.finance_contract import FinanceContract # noqa: F401
|
|
||||||
from app.models.contract_fee import ContractFee # noqa: F401
|
from app.models.contract_fee import ContractFee # noqa: F401
|
||||||
from app.models.contract_fee_payment import ContractFeePayment # noqa: F401
|
from app.models.contract_fee_payment import ContractFeePayment # noqa: F401
|
||||||
from app.models.fee_attachment import FeeAttachment # noqa: F401
|
from app.models.fee_attachment import FeeAttachment # noqa: F401
|
||||||
@@ -28,7 +28,7 @@ from app.models.startup_feasibility import StartupFeasibility # noqa: F401
|
|||||||
from app.models.startup_ethics import StartupEthics # noqa: F401
|
from app.models.startup_ethics import StartupEthics # noqa: F401
|
||||||
from app.models.kickoff_meeting import KickoffMeeting # noqa: F401
|
from app.models.kickoff_meeting import KickoffMeeting # noqa: F401
|
||||||
from app.models.training_authorization import TrainingAuthorization # noqa: F401
|
from app.models.training_authorization import TrainingAuthorization # noqa: F401
|
||||||
from app.models.knowledge_note import KnowledgeNote # noqa: F401
|
from app.models.precaution import Precaution # noqa: F401
|
||||||
from app.models.subject_history import SubjectHistory # noqa: F401
|
from app.models.subject_history import SubjectHistory # noqa: F401
|
||||||
from app.models.faq_category import FaqCategory # noqa: F401
|
from app.models.faq_category import FaqCategory # noqa: F401
|
||||||
from app.models.faq_item import FaqItem # noqa: F401
|
from app.models.faq_item import FaqItem # noqa: F401
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class KickoffMeeting(Base):
|
|||||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
|
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
|
||||||
kickoff_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
kickoff_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||||
attendees: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
attendees: Mapped[Optional[list[str]]] = mapped_column(JSON, nullable=True)
|
||||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
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())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class StudySetupConfig(Base):
|
|||||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
config: Mapped[dict] = mapped_column(JSONB_TYPE, nullable=False)
|
config: Mapped[dict] = mapped_column(JSONB_TYPE, nullable=False)
|
||||||
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
|
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
|
||||||
published_config: Mapped[dict | None] = mapped_column(JSONB_TYPE, nullable=True)
|
published_config: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
|
||||||
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB_TYPE, nullable=True)
|
published_project_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
|
||||||
current_branch_name: Mapped[str] = mapped_column(nullable=False, default="main")
|
current_branch_name: Mapped[str] = mapped_column(nullable=False, default="main")
|
||||||
active_branch_base_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
active_branch_base_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class StudySetupConfigVersion(Base):
|
|||||||
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
config: Mapped[dict] = mapped_column(JSONB_TYPE, nullable=False)
|
config: Mapped[dict] = mapped_column(JSONB_TYPE, nullable=False)
|
||||||
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB_TYPE, nullable=True)
|
published_project_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True)
|
||||||
published_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
published_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
published_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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import type { ApiListResponse } from "../types/api";
|
|||||||
export const fetchAes = (studyId: string, params?: Record<string, any>) =>
|
export const fetchAes = (studyId: string, params?: Record<string, any>) =>
|
||||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params });
|
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params });
|
||||||
|
|
||||||
|
export const fetchRiskIssueAes = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/summary`, { params });
|
||||||
|
|
||||||
export const createAe = (studyId: string, payload: Record<string, any>) =>
|
export const createAe = (studyId: string, payload: Record<string, any>) =>
|
||||||
apiPost(`/api/v1/studies/${studyId}/aes/`, payload);
|
apiPost(`/api/v1/studies/${studyId}/aes/`, payload);
|
||||||
|
|
||||||
|
|||||||
@@ -88,21 +88,17 @@
|
|||||||
<el-menu-item v-if="canAccessProjectPath('/risk-issues/pd')" index="/risk-issues/pd">{{ TEXT.menu.riskIssuePd }}</el-menu-item>
|
<el-menu-item v-if="canAccessProjectPath('/risk-issues/pd')" index="/risk-issues/pd">{{ TEXT.menu.riskIssuePd }}</el-menu-item>
|
||||||
<el-menu-item v-if="canAccessProjectPath('/risk-issues/monitoring-visits')" index="/risk-issues/monitoring-visits">{{ TEXT.menu.riskIssueMonitoringVisits }}</el-menu-item>
|
<el-menu-item v-if="canAccessProjectPath('/risk-issues/monitoring-visits')" index="/risk-issues/monitoring-visits">{{ TEXT.menu.riskIssueMonitoringVisits }}</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
<el-menu-item v-if="canAccessProjectPath('/monitoring-audit')" index="/monitoring-audit">
|
|
||||||
<el-icon><ChatDotRound /></el-icon>
|
|
||||||
<span>{{ TEXT.menu.monitoringAudit }}</span>
|
|
||||||
</el-menu-item>
|
|
||||||
<el-menu-item v-if="canAccessProjectPath('/etmf')" index="/etmf">
|
<el-menu-item v-if="canAccessProjectPath('/etmf')" index="/etmf">
|
||||||
<el-icon><Flag /></el-icon>
|
<el-icon><Flag /></el-icon>
|
||||||
<span>{{ TEXT.menu.etmf }}</span>
|
<span>{{ TEXT.menu.etmf }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/notes', '/knowledge/support-files', '/knowledge/instruction-files'])" index="knowledge">
|
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/precautions', '/knowledge/support-files', '/knowledge/instruction-files'])" index="knowledge">
|
||||||
<template #title>
|
<template #title>
|
||||||
<el-icon><Notebook /></el-icon>
|
<el-icon><Notebook /></el-icon>
|
||||||
<span>{{ TEXT.menu.sharedLibrary }}</span>
|
<span>{{ TEXT.menu.sharedLibrary }}</span>
|
||||||
</template>
|
</template>
|
||||||
<el-menu-item v-if="canAccessProjectPath('/knowledge/medical-consult')" index="/knowledge/medical-consult">{{ TEXT.menu.knowledgeMedicalConsult }}</el-menu-item>
|
<el-menu-item v-if="canAccessProjectPath('/knowledge/medical-consult')" index="/knowledge/medical-consult">{{ TEXT.menu.knowledgeMedicalConsult }}</el-menu-item>
|
||||||
<el-menu-item v-if="canAccessProjectPath('/knowledge/notes')" index="/knowledge/notes">{{ TEXT.menu.knowledgeNotes }}</el-menu-item>
|
<el-menu-item v-if="canAccessProjectPath('/knowledge/precautions')" index="/knowledge/precautions">{{ TEXT.menu.knowledgeNotes }}</el-menu-item>
|
||||||
<el-menu-item v-if="canAccessProjectPath('/knowledge/support-files')" index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
|
<el-menu-item v-if="canAccessProjectPath('/knowledge/support-files')" index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
|
||||||
<el-menu-item v-if="canAccessProjectPath('/knowledge/instruction-files')" index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
|
<el-menu-item v-if="canAccessProjectPath('/knowledge/instruction-files')" index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
@@ -217,7 +213,7 @@ import { fetchStudies } from "../api/studies";
|
|||||||
import { fetchSites } from "../api/sites";
|
import { fetchSites } from "../api/sites";
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
import {
|
import {
|
||||||
User, Suitcase, House, Calendar, Flag, ChatDotRound,
|
User, Suitcase, House, Calendar, Flag,
|
||||||
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key
|
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key
|
||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
@@ -260,7 +256,6 @@ const activeMenu = computed(() => {
|
|||||||
if (path.startsWith("/project/milestones")) return "/project/milestones";
|
if (path.startsWith("/project/milestones")) return "/project/milestones";
|
||||||
if (path.startsWith("/project/")) return "/project/overview";
|
if (path.startsWith("/project/")) return "/project/overview";
|
||||||
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
|
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
|
||||||
if (path.startsWith("/finance/contracts")) return "/fees/contracts";
|
|
||||||
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
||||||
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
|
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
|
||||||
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
|
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
|
||||||
@@ -273,10 +268,9 @@ const activeMenu = computed(() => {
|
|||||||
if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd";
|
if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd";
|
||||||
if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits";
|
if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits";
|
||||||
if (path.startsWith("/risk-issues")) return "/risk-issues/sae";
|
if (path.startsWith("/risk-issues")) return "/risk-issues/sae";
|
||||||
if (path.startsWith("/monitoring-audit") || path.startsWith("/monitoring") || path.startsWith("/audit")) return "/monitoring-audit";
|
|
||||||
if (path.startsWith("/etmf")) return "/etmf";
|
if (path.startsWith("/etmf")) return "/etmf";
|
||||||
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
|
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
|
||||||
if (path.startsWith("/knowledge/notes")) return "/knowledge/notes";
|
if (path.startsWith("/knowledge/precautions")) return "/knowledge/precautions";
|
||||||
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
||||||
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
|
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
|
||||||
if (path.startsWith("/projects/")) return "/admin/projects";
|
if (path.startsWith("/projects/")) return "/admin/projects";
|
||||||
@@ -342,14 +336,13 @@ const breadcrumbs = computed(() => {
|
|||||||
"/risk-issues/sae": { label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" },
|
"/risk-issues/sae": { label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" },
|
||||||
"/risk-issues/pd": { label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" },
|
"/risk-issues/pd": { label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" },
|
||||||
"/risk-issues/monitoring-visits": { label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits" },
|
"/risk-issues/monitoring-visits": { label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits" },
|
||||||
"/monitoring-audit": { label: TEXT.menu.monitoringAudit, path: "/monitoring-audit" },
|
|
||||||
"/etmf": { label: TEXT.menu.etmf, path: "/etmf" },
|
"/etmf": { label: TEXT.menu.etmf, path: "/etmf" },
|
||||||
"/startup/feasibility": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
|
"/startup/feasibility": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
|
||||||
"/startup/ethics": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
|
"/startup/ethics": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
|
||||||
"/startup/kickoff": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
|
"/startup/kickoff": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
|
||||||
"/startup/training": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
|
"/startup/training": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
|
||||||
"/knowledge/medical-consult": { label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult" },
|
"/knowledge/medical-consult": { label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult" },
|
||||||
"/knowledge/notes": { label: TEXT.menu.knowledgeNotes, path: "/knowledge/notes" },
|
"/knowledge/precautions": { label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions" },
|
||||||
};
|
};
|
||||||
|
|
||||||
let matchedModule = null;
|
let matchedModule = null;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import ProjectDetail from "../views/admin/ProjectDetail.vue";
|
|||||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||||
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
||||||
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
|
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
|
||||||
import FinanceContracts from "../views/ia/FinanceContracts.vue";
|
|
||||||
import FeeContracts from "../views/fees/ContractFees.vue";
|
import FeeContracts from "../views/fees/ContractFees.vue";
|
||||||
import FeeContractForm from "../views/fees/ContractFeeForm.vue";
|
import FeeContractForm from "../views/fees/ContractFeeForm.vue";
|
||||||
import FeeContractDetail from "../views/fees/ContractFeeDetail.vue";
|
import FeeContractDetail from "../views/fees/ContractFeeDetail.vue";
|
||||||
@@ -37,14 +36,11 @@ import SubjectManagement from "../views/ia/SubjectManagement.vue";
|
|||||||
import RiskIssueSae from "../views/ia/RiskIssueSae.vue";
|
import RiskIssueSae from "../views/ia/RiskIssueSae.vue";
|
||||||
import RiskIssuePd from "../views/ia/RiskIssuePd.vue";
|
import RiskIssuePd from "../views/ia/RiskIssuePd.vue";
|
||||||
import RiskIssueMonitoringVisits from "../views/ia/RiskIssueMonitoringVisits.vue";
|
import RiskIssueMonitoringVisits from "../views/ia/RiskIssueMonitoringVisits.vue";
|
||||||
import MonitoringAuditPlaceholder from "../views/ia/MonitoringAuditPlaceholder.vue";
|
|
||||||
import EtmfPlaceholder from "../views/ia/EtmfPlaceholder.vue";
|
import EtmfPlaceholder from "../views/ia/EtmfPlaceholder.vue";
|
||||||
import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
|
import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
|
||||||
import KnowledgeNotes from "../views/ia/KnowledgeNotes.vue";
|
import Precautions from "../views/ia/Precautions.vue";
|
||||||
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
|
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
|
||||||
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
|
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
|
||||||
import ContractForm from "../views/finance/ContractForm.vue";
|
|
||||||
import ContractDetail from "../views/finance/ContractDetail.vue";
|
|
||||||
import ShipmentForm from "../views/drug/ShipmentForm.vue";
|
import ShipmentForm from "../views/drug/ShipmentForm.vue";
|
||||||
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
|
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
|
||||||
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
|
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
|
||||||
@@ -55,8 +51,8 @@ import KickoffForm from "../views/startup/KickoffForm.vue";
|
|||||||
import KickoffDetail from "../views/startup/KickoffDetail.vue";
|
import KickoffDetail from "../views/startup/KickoffDetail.vue";
|
||||||
import TrainingForm from "../views/startup/TrainingForm.vue";
|
import TrainingForm from "../views/startup/TrainingForm.vue";
|
||||||
import TrainingDetail from "../views/startup/TrainingDetail.vue";
|
import TrainingDetail from "../views/startup/TrainingDetail.vue";
|
||||||
import NoteForm from "../views/knowledge/NoteForm.vue";
|
import PrecautionForm from "../views/knowledge/PrecautionForm.vue";
|
||||||
import NoteDetail from "../views/knowledge/NoteDetail.vue";
|
import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
|
||||||
import SubjectForm from "../views/subjects/SubjectForm.vue";
|
import SubjectForm from "../views/subjects/SubjectForm.vue";
|
||||||
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
|
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
@@ -103,12 +99,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: ProjectMilestones,
|
component: ProjectMilestones,
|
||||||
meta: { title: TEXT.menu.projectMilestones, requiresStudy: true },
|
meta: { title: TEXT.menu.projectMilestones, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "finance/contracts",
|
|
||||||
name: "FinanceContracts",
|
|
||||||
component: FinanceContracts,
|
|
||||||
meta: { title: TEXT.menu.financeContracts, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "fees/contracts",
|
path: "fees/contracts",
|
||||||
name: "FeeContracts",
|
name: "FeeContracts",
|
||||||
@@ -133,24 +123,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: FeeContractForm,
|
component: FeeContractForm,
|
||||||
meta: { title: TEXT.common.actions.edit + TEXT.menu.feeContracts, requiresStudy: true },
|
meta: { title: TEXT.common.actions.edit + TEXT.menu.feeContracts, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "finance/contracts/new",
|
|
||||||
name: "FinanceContractNew",
|
|
||||||
component: ContractForm,
|
|
||||||
meta: { title: TEXT.common.actions.add + TEXT.menu.financeContracts, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "finance/contracts/:contractId",
|
|
||||||
name: "FinanceContractDetail",
|
|
||||||
component: ContractDetail,
|
|
||||||
meta: { title: TEXT.modules.financeContracts.detailTitle, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "finance/contracts/:contractId/edit",
|
|
||||||
name: "FinanceContractEdit",
|
|
||||||
component: ContractForm,
|
|
||||||
meta: { title: TEXT.common.actions.edit + TEXT.menu.financeContracts, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "drug/shipments",
|
path: "drug/shipments",
|
||||||
name: "DrugShipments",
|
name: "DrugShipments",
|
||||||
@@ -329,20 +301,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: RiskIssueMonitoringVisits,
|
component: RiskIssueMonitoringVisits,
|
||||||
meta: { title: TEXT.menu.riskIssueMonitoringVisits, requiresStudy: true },
|
meta: { title: TEXT.menu.riskIssueMonitoringVisits, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "monitoring-audit",
|
|
||||||
name: "MonitoringAuditPlaceholder",
|
|
||||||
component: MonitoringAuditPlaceholder,
|
|
||||||
meta: { title: TEXT.menu.monitoringAudit, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "monitoring",
|
|
||||||
redirect: "/monitoring-audit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "audit",
|
|
||||||
redirect: "/monitoring-audit",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "etmf",
|
path: "etmf",
|
||||||
name: "EtmfPlaceholder",
|
name: "EtmfPlaceholder",
|
||||||
@@ -362,27 +320,27 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { title: TEXT.modules.knowledgeMedicalConsult.detailTitle, requiresStudy: true },
|
meta: { title: TEXT.modules.knowledgeMedicalConsult.detailTitle, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "knowledge/notes",
|
path: "knowledge/precautions",
|
||||||
name: "KnowledgeNotes",
|
name: "Precautions",
|
||||||
component: KnowledgeNotes,
|
component: Precautions,
|
||||||
meta: { title: TEXT.menu.knowledgeNotes, requiresStudy: true },
|
meta: { title: TEXT.menu.knowledgeNotes, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "knowledge/notes/new",
|
path: "knowledge/precautions/new",
|
||||||
name: "KnowledgeNoteNew",
|
name: "PrecautionNew",
|
||||||
component: NoteForm,
|
component: PrecautionForm,
|
||||||
meta: { title: TEXT.common.actions.add + TEXT.menu.knowledgeNotes, requiresStudy: true },
|
meta: { title: TEXT.common.actions.add + TEXT.menu.knowledgeNotes, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "knowledge/notes/:noteId",
|
path: "knowledge/precautions/:id",
|
||||||
name: "KnowledgeNoteDetail",
|
name: "PrecautionDetail",
|
||||||
component: NoteDetail,
|
component: PrecautionDetail,
|
||||||
meta: { title: TEXT.modules.knowledgeNotes.detailTitle, requiresStudy: true },
|
meta: { title: TEXT.modules.knowledgeNotes.detailTitle, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "knowledge/notes/:noteId/edit",
|
path: "knowledge/precautions/:id/edit",
|
||||||
name: "KnowledgeNoteEdit",
|
name: "PrecautionEdit",
|
||||||
component: NoteForm,
|
component: PrecautionForm,
|
||||||
meta: { title: TEXT.common.actions.edit + TEXT.menu.knowledgeNotes, requiresStudy: true },
|
meta: { title: TEXT.common.actions.edit + TEXT.menu.knowledgeNotes, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
:quote-item="quoteReply"
|
:quote-item="quoteReply"
|
||||||
:member-map="memberMap"
|
:member-map="memberMap"
|
||||||
:user-map="userMap"
|
:user-map="userMap"
|
||||||
:allow-attachments="true"
|
:allow-attachments="canUploadReplyAttachment"
|
||||||
@submit="submitReply"
|
@submit="submitReply"
|
||||||
@clear-quote="clearQuote"
|
@clear-quote="clearQuote"
|
||||||
/>
|
/>
|
||||||
@@ -107,7 +107,10 @@ import { listMembers } from "../api/members";
|
|||||||
import { fetchAttachments, uploadAttachment } from "../api/attachments";
|
import { fetchAttachments, uploadAttachment } from "../api/attachments";
|
||||||
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display";
|
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import { useStudyStore } from "../store/study";
|
||||||
import { usePermission } from "../utils/permission";
|
import { usePermission } from "../utils/permission";
|
||||||
|
import { getAttachmentPermissionKey } from "../utils/attachmentPermissions";
|
||||||
|
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
|
||||||
import FaqItemForm from "../components/FaqItemForm.vue";
|
import FaqItemForm from "../components/FaqItemForm.vue";
|
||||||
import ThreadComposer from "../components/ThreadComposer.vue";
|
import ThreadComposer from "../components/ThreadComposer.vue";
|
||||||
import ThreadList from "../components/ThreadList.vue";
|
import ThreadList from "../components/ThreadList.vue";
|
||||||
@@ -115,6 +118,7 @@ import { TEXT } from "../locales";
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const study = useStudyStore();
|
||||||
const item = ref<any>(null);
|
const item = ref<any>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const categories = ref<any[]>([]);
|
const categories = ref<any[]>([]);
|
||||||
@@ -136,6 +140,14 @@ const canReply = computed(() => {
|
|||||||
return can("faq.reply");
|
return can("faq.reply");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canUploadReplyAttachment = computed(() => {
|
||||||
|
if (!item.value?.study_id) return false;
|
||||||
|
if (auth.user?.is_admin) return true;
|
||||||
|
const role = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
|
||||||
|
const operationKey = getAttachmentPermissionKey("faq_replies", "create");
|
||||||
|
return !!role && !!operationKey && isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
|
||||||
|
});
|
||||||
|
|
||||||
const canDeleteReply = (reply: any) => {
|
const canDeleteReply = (reply: any) => {
|
||||||
if (reply.is_deleted) return false;
|
if (reply.is_deleted) return false;
|
||||||
if (auth.user?.is_admin) return true;
|
if (auth.user?.is_admin) return true;
|
||||||
@@ -273,7 +285,7 @@ const submitReply = async () => {
|
|||||||
quote_reply_id: quoteReply.value?.id || null,
|
quote_reply_id: quoteReply.value?.id || null,
|
||||||
});
|
});
|
||||||
const created = data as any;
|
const created = data as any;
|
||||||
if (replyFiles.value.length && created?.id) {
|
if (canUploadReplyAttachment.value && replyFiles.value.length && created?.id) {
|
||||||
try {
|
try {
|
||||||
for (const file of replyFiles.value) {
|
for (const file of replyFiles.value) {
|
||||||
if (!file.raw) continue;
|
if (!file.raw) continue;
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ const uploadRules: FormRules = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const roleOptions = computed(() => {
|
const roleOptions = computed(() => {
|
||||||
const fallbackRoles = ["CRA", "PM", "IMP", "PV", "MEDICAL_REVIEW"];
|
const fallbackRoles = ["CRA", "PM", "CTA", "PV", "QA"];
|
||||||
const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean));
|
const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean));
|
||||||
const values = roles.size ? Array.from(roles) : fallbackRoles;
|
const values = roles.size ? Array.from(roles) : fallbackRoles;
|
||||||
return values.map((value) => ({
|
return values.map((value) => ({
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ModulePlaceholder
|
|
||||||
:title="TEXT.modules.audit.title"
|
|
||||||
:subtitle="TEXT.modules.audit.subtitle"
|
|
||||||
:list-title="TEXT.modules.audit.listTitle"
|
|
||||||
:empty-description="TEXT.modules.audit.emptyDescription"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
|
||||||
</script>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ModulePlaceholder
|
|
||||||
:title="TEXT.modules.monitoringAudit.title"
|
|
||||||
:subtitle="TEXT.modules.monitoringAudit.subtitle"
|
|
||||||
:list-title="TEXT.modules.monitoringAudit.listTitle"
|
|
||||||
:empty-description="TEXT.modules.monitoringAudit.emptyDescription"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
|
||||||
</script>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ModulePlaceholder
|
|
||||||
:title="TEXT.modules.monitoring.title"
|
|
||||||
:subtitle="TEXT.modules.monitoring.subtitle"
|
|
||||||
:list-title="TEXT.modules.monitoring.listTitle"
|
|
||||||
:empty-description="TEXT.modules.monitoring.emptyDescription"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readRiskIssueMonitoringVisits = () => readFileSync(resolve(__dirname, "./RiskIssueMonitoringVisits.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("RiskIssueMonitoringVisits permissions", () => {
|
||||||
|
it("gates monitoring visit issue actions by concrete API permissions", () => {
|
||||||
|
const source = readRiskIssueMonitoringVisits();
|
||||||
|
|
||||||
|
expect(source).toContain("useAuthStore");
|
||||||
|
expect(source).toContain("isSystemAdmin");
|
||||||
|
expect(source).toContain("getProjectRole");
|
||||||
|
expect(source).toContain("isApiPermissionAllowed");
|
||||||
|
[
|
||||||
|
"monitoring_issues:list",
|
||||||
|
"monitoring_issues:read",
|
||||||
|
"monitoring_issues:create",
|
||||||
|
"monitoring_issues:update",
|
||||||
|
"monitoring_issues:delete",
|
||||||
|
].forEach((operationKey) => {
|
||||||
|
expect(source).toContain(operationKey);
|
||||||
|
});
|
||||||
|
expect(source).toContain('v-if="canCreateIssue"');
|
||||||
|
expect(source).toContain('v-if="canListIssues"');
|
||||||
|
expect(source).toContain('v-if="canReadIssue"');
|
||||||
|
expect(source).toContain('v-if="canUpdateIssue"');
|
||||||
|
expect(source).toContain('v-if="canDeleteIssue"');
|
||||||
|
expect(source).toContain("const canSaveIssue = computed(() => (formMode.value === \"edit\" ? canUpdateIssue.value : canCreateIssue.value));");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="template-toolbar">
|
<div class="template-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<el-button class="toolbar-button toolbar-button-primary" type="primary" @click="openCreateDialog">
|
<el-button v-if="canCreateIssue" class="toolbar-button toolbar-button-primary" type="primary" @click="openCreateDialog">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon><Plus /></el-icon>
|
||||||
<span>新增</span>
|
<span>新增</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -117,7 +117,14 @@
|
|||||||
<el-icon><CircleCheck /></el-icon>
|
<el-icon><CircleCheck /></el-icon>
|
||||||
<span>一键完成</span>
|
<span>一键完成</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button class="toolbar-button toolbar-button-success" type="success" plain :loading="exporting" @click="handleExportExcel">
|
<el-button
|
||||||
|
v-if="canListIssues"
|
||||||
|
class="toolbar-button toolbar-button-success"
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
:loading="exporting"
|
||||||
|
@click="handleExportExcel"
|
||||||
|
>
|
||||||
<el-icon><Download /></el-icon>
|
<el-icon><Download /></el-icon>
|
||||||
<span>导出</span>
|
<span>导出</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -126,6 +133,7 @@
|
|||||||
<span>问题导入模板</span>
|
<span>问题导入模板</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-upload
|
<el-upload
|
||||||
|
v-if="canCreateIssue"
|
||||||
ref="importUploadRef"
|
ref="importUploadRef"
|
||||||
:auto-upload="false"
|
:auto-upload="false"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
@@ -166,12 +174,12 @@
|
|||||||
<el-table-column prop="recommendation" label="建议" min-width="190" show-overflow-tooltip />
|
<el-table-column prop="recommendation" label="建议" min-width="190" show-overflow-tooltip />
|
||||||
<el-table-column prop="center_query" label="中心质疑" min-width="180" show-overflow-tooltip />
|
<el-table-column prop="center_query" label="中心质疑" min-width="180" show-overflow-tooltip />
|
||||||
<el-table-column prop="center_latest_reply" label="中心最新回复" min-width="180" show-overflow-tooltip />
|
<el-table-column prop="center_latest_reply" label="中心最新回复" min-width="180" show-overflow-tooltip />
|
||||||
<el-table-column label="操作" width="140" align="center">
|
<el-table-column v-if="canReadIssue || canUpdateIssue || canDeleteIssue" label="操作" width="140" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-cell">
|
<div class="action-cell">
|
||||||
<el-button class="action-btn" link type="primary" size="small" @click="openViewDialog(row)">查看</el-button>
|
<el-button v-if="canReadIssue" class="action-btn" link type="primary" size="small" @click="openViewDialog(row)">查看</el-button>
|
||||||
<el-button class="action-btn" link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
|
<el-button v-if="canUpdateIssue" class="action-btn" link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
<el-button class="action-btn" link type="danger" size="small" @click="removeIssue(row)">删除</el-button>
|
<el-button v-if="canDeleteIssue" class="action-btn" link type="danger" size="small" @click="removeIssue(row)">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -403,7 +411,7 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="editor-footer">
|
<div class="editor-footer">
|
||||||
<el-button @click="formDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="formDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
<el-button type="primary" :loading="saving" @click="submitForm">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="!canSaveIssue" @click="submitForm">{{ TEXT.common.actions.save }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
@@ -460,9 +468,12 @@ import {
|
|||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import type { Site } from "../../types/api";
|
import type { Site } from "../../types/api";
|
||||||
|
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||||
import { displayDateTime } from "../../utils/display";
|
import { displayDateTime } from "../../utils/display";
|
||||||
|
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||||
|
|
||||||
interface MonitoringIssueRow {
|
interface MonitoringIssueRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -499,6 +510,7 @@ interface MonitoringIssueRow {
|
|||||||
}
|
}
|
||||||
type DateRange = [string, string] | [];
|
type DateRange = [string, string] | [];
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
@@ -515,6 +527,19 @@ const viewIssue = ref<MonitoringIssueRow | null>(null);
|
|||||||
const sitesLoading = ref(false);
|
const sitesLoading = ref(false);
|
||||||
const siteOptions = ref<Array<Pick<Site, "id" | "name" | "is_active">>>([]);
|
const siteOptions = ref<Array<Pick<Site, "id" | "name" | "is_active">>>([]);
|
||||||
const siteMap = ref<Record<string, string>>({});
|
const siteMap = ref<Record<string, string>>({});
|
||||||
|
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||||
|
const canUseApiPermission = (operationKey: string) => {
|
||||||
|
if (isSystemAdmin(auth.user)) return true;
|
||||||
|
const role = projectRole.value;
|
||||||
|
if (!role) return false;
|
||||||
|
return isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
|
||||||
|
};
|
||||||
|
const canListIssues = computed(() => canUseApiPermission("monitoring_issues:list"));
|
||||||
|
const canReadIssue = computed(() => canUseApiPermission("monitoring_issues:read"));
|
||||||
|
const canCreateIssue = computed(() => canUseApiPermission("monitoring_issues:create"));
|
||||||
|
const canUpdateIssue = computed(() => canUseApiPermission("monitoring_issues:update"));
|
||||||
|
const canDeleteIssue = computed(() => canUseApiPermission("monitoring_issues:delete"));
|
||||||
|
const canSaveIssue = computed(() => (formMode.value === "edit" ? canUpdateIssue.value : canCreateIssue.value));
|
||||||
|
|
||||||
const currentProjectCenter = computed(() => study.currentStudy?.name || study.currentStudy?.project_full_name || "");
|
const currentProjectCenter = computed(() => study.currentStudy?.name || study.currentStudy?.project_full_name || "");
|
||||||
|
|
||||||
@@ -802,6 +827,7 @@ const resetFiltersForStudy = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
|
if (!canCreateIssue.value) return;
|
||||||
resetCreateForm();
|
resetCreateForm();
|
||||||
formMode.value = "create";
|
formMode.value = "create";
|
||||||
editingIssueId.value = "";
|
editingIssueId.value = "";
|
||||||
@@ -810,7 +836,7 @@ const openCreateDialog = () => {
|
|||||||
|
|
||||||
const openEditDialog = async (row: MonitoringIssueRow) => {
|
const openEditDialog = async (row: MonitoringIssueRow) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId || !row?.id) return;
|
if (!studyId || !row?.id || !canUpdateIssue.value) return;
|
||||||
try {
|
try {
|
||||||
const { data } = await getMonitoringVisitIssue(studyId, row.id);
|
const { data } = await getMonitoringVisitIssue(studyId, row.id);
|
||||||
const issue = (data || row) as MonitoringIssueRow;
|
const issue = (data || row) as MonitoringIssueRow;
|
||||||
@@ -825,7 +851,7 @@ const openEditDialog = async (row: MonitoringIssueRow) => {
|
|||||||
|
|
||||||
const openViewDialog = async (row: MonitoringIssueRow) => {
|
const openViewDialog = async (row: MonitoringIssueRow) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId || !row?.id) return;
|
if (!studyId || !row?.id || !canReadIssue.value) return;
|
||||||
try {
|
try {
|
||||||
const { data } = await getMonitoringVisitIssue(studyId, row.id);
|
const { data } = await getMonitoringVisitIssue(studyId, row.id);
|
||||||
viewIssue.value = (data || row) as MonitoringIssueRow;
|
viewIssue.value = (data || row) as MonitoringIssueRow;
|
||||||
@@ -837,7 +863,7 @@ const openViewDialog = async (row: MonitoringIssueRow) => {
|
|||||||
|
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId || !canSaveIssue.value) return;
|
||||||
const ok = await createFormRef.value?.validate().catch(() => false);
|
const ok = await createFormRef.value?.validate().catch(() => false);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
@@ -891,7 +917,7 @@ const submitForm = async () => {
|
|||||||
|
|
||||||
const removeIssue = async (row: MonitoringIssueRow) => {
|
const removeIssue = async (row: MonitoringIssueRow) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId || !row?.id) return;
|
if (!studyId || !row?.id || !canDeleteIssue.value) return;
|
||||||
const confirmed = await ElMessageBox.confirm(`确认删除问题「${row.issue_no}」?`, TEXT.common.labels.tips).catch(() => null);
|
const confirmed = await ElMessageBox.confirm(`确认删除问题「${row.issue_no}」?`, TEXT.common.labels.tips).catch(() => null);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
@@ -914,7 +940,7 @@ const getFilename = (header?: string | null) => {
|
|||||||
|
|
||||||
const handleExportExcel = async () => {
|
const handleExportExcel = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId || !canListIssues.value) return;
|
||||||
if (filteredItems.value.length === 0) {
|
if (filteredItems.value.length === 0) {
|
||||||
ElMessage.warning("暂无可导出数据");
|
ElMessage.warning("暂无可导出数据");
|
||||||
return;
|
return;
|
||||||
@@ -964,7 +990,7 @@ const handleExportExcel = async () => {
|
|||||||
|
|
||||||
const onImportChange = async (uploadFile: UploadFile) => {
|
const onImportChange = async (uploadFile: UploadFile) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId || !canCreateIssue.value) return;
|
||||||
const file = uploadFile.raw;
|
const file = uploadFile.raw;
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ 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 { CircleCheck, Location, Search, Warning } from "@element-plus/icons-vue";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { fetchAes } from "../../api/aes";
|
import { fetchRiskIssueAes } from "../../api/aes";
|
||||||
import { fetchSubjects } from "../../api/subjects";
|
import { fetchSubjects } from "../../api/subjects";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
@@ -166,7 +166,7 @@ const load = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadSites(studyId), loadSubjects(studyId)]);
|
await Promise.all([loadSites(studyId), loadSubjects(studyId)]);
|
||||||
const { data } = await fetchAes(studyId);
|
const { data } = await fetchRiskIssueAes(studyId);
|
||||||
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 || ""))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const STAGE_ORDER: Array<{
|
|||||||
completedKey: StageCompletionKey;
|
completedKey: StageCompletionKey;
|
||||||
}> = [
|
}> = [
|
||||||
{ key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" },
|
{ key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" },
|
||||||
{ key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" },
|
{ key: "ethics_status", label: "伦理记录", completedKey: "ethics_completed_at" },
|
||||||
{ key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" },
|
{ key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" },
|
||||||
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
|
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
|
||||||
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
|
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
|
||||||
|
|||||||
@@ -48,4 +48,45 @@ describe("SubjectDetail medication adherence", () => {
|
|||||||
expect(source).toContain("row.window_start === row.planned_date && row.window_end === row.planned_date");
|
expect(source).toContain("row.window_start === row.planned_date && row.window_end === row.planned_date");
|
||||||
expect(source).toContain("return \"不适用\";");
|
expect(source).toContain("return \"不适用\";");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gates participant subresource mutations by concrete API permissions", () => {
|
||||||
|
const source = readSubjectDetail();
|
||||||
|
|
||||||
|
expect(source).toContain("isApiPermissionAllowed");
|
||||||
|
expect(source).toContain("getProjectRole");
|
||||||
|
expect(source).toContain("isSystemAdmin");
|
||||||
|
[
|
||||||
|
"subjects:update",
|
||||||
|
"subject_histories:create",
|
||||||
|
"subject_histories:update",
|
||||||
|
"subject_histories:delete",
|
||||||
|
"visits:create",
|
||||||
|
"visits:update",
|
||||||
|
"visits:delete",
|
||||||
|
"subject_aes:create",
|
||||||
|
"subject_aes:list",
|
||||||
|
"subject_aes:update",
|
||||||
|
"subject_aes:delete",
|
||||||
|
"subject_pds:create",
|
||||||
|
"subject_pds:list",
|
||||||
|
"subject_pds:update",
|
||||||
|
"subject_pds:delete",
|
||||||
|
].forEach((operationKey) => {
|
||||||
|
expect(source).toContain(operationKey);
|
||||||
|
});
|
||||||
|
expect(source).toContain("v-if=\"currentTabAction.allowed\"");
|
||||||
|
expect(source).toContain("v-if=\"canUpdateHistory || canDeleteHistory\"");
|
||||||
|
expect(source).toContain("v-if=\"canUpdateVisit || canDeleteVisit\"");
|
||||||
|
expect(source).toContain("v-if=\"canUpdateAe || canDeleteAe\"");
|
||||||
|
expect(source).toContain("v-if=\"canUpdatePd || canDeletePd\"");
|
||||||
|
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("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 canCreateAe = computed(() => canUseApiPermission(\"subject_aes:create\"));");
|
||||||
|
expect(source).toContain("const canUpdateAe = computed(() => canUseApiPermission(\"subject_aes:update\"));");
|
||||||
|
expect(source).toContain("const canDeleteAe = computed(() => canUseApiPermission(\"subject_aes:delete\"));");
|
||||||
|
expect(source).toContain("const canListAe = computed(() => canUseApiPermission(\"subject_aes:list\"));");
|
||||||
|
expect(source).toContain("const canListPd = computed(() => canUseApiPermission(\"subject_pds:list\"));");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
v-if="subjectEditing"
|
v-if="subjectEditing"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="subjectSaving"
|
:loading="subjectSaving"
|
||||||
:disabled="isReadOnly"
|
:disabled="isReadOnly || !canUpdateSubject"
|
||||||
@click="saveSubjectEdit"
|
@click="saveSubjectEdit"
|
||||||
>
|
>
|
||||||
{{ TEXT.common.actions.save }}
|
{{ TEXT.common.actions.save }}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<el-button v-if="subjectEditing" :disabled="isReadOnly" @click="cancelSubjectEdit">
|
<el-button v-if="subjectEditing" :disabled="isReadOnly" @click="cancelSubjectEdit">
|
||||||
{{ TEXT.common.actions.cancel }}
|
{{ TEXT.common.actions.cancel }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-else type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
|
<el-button v-else-if="canUpdateSubject" type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
|
||||||
{{ TEXT.common.actions.edit }}
|
{{ TEXT.common.actions.edit }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||||
@@ -91,10 +91,22 @@
|
|||||||
|
|
||||||
<el-card class="unified-shell subject-shell subject-tabs-shell">
|
<el-card class="unified-shell subject-shell subject-tabs-shell">
|
||||||
<div class="subject-tabs-action">
|
<div class="subject-tabs-action">
|
||||||
<el-button type="primary" :loading="currentTabAction.loading" :disabled="isReadOnly" @click="currentTabAction.onClick">
|
<el-button
|
||||||
|
v-if="currentTabAction.allowed"
|
||||||
|
type="primary"
|
||||||
|
:loading="currentTabAction.loading"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click="currentTabAction.onClick"
|
||||||
|
>
|
||||||
{{ currentTabAction.label }}
|
{{ currentTabAction.label }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="activeTab === 'visits'" type="warning" plain :disabled="isReadOnly" @click="openEarlyTerminationDialog">
|
<el-button
|
||||||
|
v-if="activeTab === 'visits' && canCreateVisit"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click="openEarlyTerminationDialog"
|
||||||
|
>
|
||||||
提前终止
|
提前终止
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="adherenceEditing && activeTab === 'medicationAdherence'" :disabled="isReadOnly" @click="adherenceEditing = false">
|
<el-button v-if="adherenceEditing && activeTab === 'medicationAdherence'" :disabled="isReadOnly" @click="adherenceEditing = false">
|
||||||
@@ -108,9 +120,10 @@
|
|||||||
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
|
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="content" :label="TEXT.common.fields.content" show-overflow-tooltip />
|
<el-table-column prop="content" :label="TEXT.common.fields.content" show-overflow-tooltip />
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="140">
|
<el-table-column v-if="canUpdateHistory || canDeleteHistory" :label="TEXT.common.labels.actions" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canUpdateHistory"
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -120,6 +133,7 @@
|
|||||||
{{ TEXT.common.actions.edit }}
|
{{ TEXT.common.actions.edit }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canDeleteHistory"
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -183,10 +197,10 @@
|
|||||||
<span v-else>{{ scope.row.notes || TEXT.common.fallback }}</span>
|
<span v-else>{{ scope.row.notes || TEXT.common.fallback }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="140">
|
<el-table-column v-if="canUpdateVisit || canDeleteVisit" :label="TEXT.common.labels.actions" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<template v-if="visitEditingRowId === scope.row.id">
|
<template v-if="visitEditingRowId === scope.row.id">
|
||||||
<el-button link type="primary" size="small" :disabled="isReadOnly" @click="saveVisitRow(scope.row)">
|
<el-button v-if="canUpdateVisit" link type="primary" size="small" :disabled="isReadOnly" @click="saveVisitRow(scope.row)">
|
||||||
{{ TEXT.common.actions.save }}
|
{{ TEXT.common.actions.save }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link size="small" :disabled="isReadOnly" @click="cancelVisitRow">
|
<el-button link size="small" :disabled="isReadOnly" @click="cancelVisitRow">
|
||||||
@@ -195,6 +209,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canUpdateVisit"
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -204,6 +219,7 @@
|
|||||||
{{ TEXT.common.actions.edit }}
|
{{ TEXT.common.actions.edit }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canDeleteVisit"
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -244,7 +260,7 @@
|
|||||||
:controls="false"
|
:controls="false"
|
||||||
size="small"
|
size="small"
|
||||||
class="adherence-count-input"
|
class="adherence-count-input"
|
||||||
:disabled="isReadOnly"
|
:disabled="isReadOnly || !canUpdateSubject"
|
||||||
/>
|
/>
|
||||||
<span v-else>{{ actualMedicationCountText }}</span>
|
<span v-else>{{ actualMedicationCountText }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -283,9 +299,10 @@
|
|||||||
<template #default="scope">{{ displayText(scope.row.outcome, TEXT.enums.aeOutcome) }}</template>
|
<template #default="scope">{{ displayText(scope.row.outcome, TEXT.enums.aeOutcome) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="description" :label="TEXT.common.fields.remark" show-overflow-tooltip />
|
<el-table-column prop="description" :label="TEXT.common.fields.remark" show-overflow-tooltip />
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="140">
|
<el-table-column v-if="canUpdateAe || canDeleteAe" :label="TEXT.common.labels.actions" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canUpdateAe"
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -295,6 +312,7 @@
|
|||||||
{{ TEXT.common.actions.edit }}
|
{{ TEXT.common.actions.edit }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canDeleteAe"
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -324,9 +342,10 @@
|
|||||||
<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.pdStatus, scope.row.status) }}</template>
|
<template #default="scope">{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="140">
|
<el-table-column v-if="canUpdatePd || canDeletePd" :label="TEXT.common.labels.actions" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canUpdatePd"
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -336,6 +355,7 @@
|
|||||||
{{ TEXT.common.actions.edit }}
|
{{ TEXT.common.actions.edit }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canDeletePd"
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -365,7 +385,7 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="historyDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="historyDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
<el-button type="primary" :disabled="isReadOnly" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly || !canSaveHistory" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -396,7 +416,7 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="visitDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="visitDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
<el-button type="primary" :disabled="isReadOnly" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly || !canSaveVisit" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -423,7 +443,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="earlyTerminationDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="earlyTerminationDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
<el-button type="primary" :loading="earlyTerminationSaving" :disabled="isReadOnly" @click="saveEarlyTermination">
|
<el-button type="primary" :loading="earlyTerminationSaving" :disabled="isReadOnly || !canCreateVisit" @click="saveEarlyTermination">
|
||||||
{{ TEXT.common.actions.save }}
|
{{ TEXT.common.actions.save }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -464,7 +484,7 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="aeDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="aeDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
<el-button type="primary" :disabled="isReadOnly" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly || !canSaveAe" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -494,7 +514,7 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="pdDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="pdDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
<el-button type="primary" :disabled="isReadOnly" @click="savePd">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly || !canSavePd" @click="savePd">{{ TEXT.common.actions.save }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -504,6 +524,7 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { getSubject, updateSubject } from "../../api/subjects";
|
import { getSubject, updateSubject } from "../../api/subjects";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
@@ -512,13 +533,23 @@ import { fetchVisits, createEarlyTerminationVisit, createVisit, updateVisit, del
|
|||||||
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
|
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
|
||||||
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
|
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
|
||||||
import { displayDate, displayEnum, displayText } from "../../utils/display";
|
import { displayDate, displayEnum, displayText } from "../../utils/display";
|
||||||
|
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||||
|
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const subjectId = route.params.subjectId as string;
|
const subjectId = route.params.subjectId as string;
|
||||||
const studyId = study.currentStudy?.id || "";
|
const studyId = study.currentStudy?.id || "";
|
||||||
|
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||||
|
const canUseApiPermission = (operationKey: string) => {
|
||||||
|
if (isSystemAdmin(auth.user)) return true;
|
||||||
|
const role = projectRole.value;
|
||||||
|
if (!role) return false;
|
||||||
|
return isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
|
||||||
|
};
|
||||||
const resolveTabFromQuery = () => {
|
const resolveTabFromQuery = () => {
|
||||||
const tab = typeof route.query.tab === "string" ? route.query.tab : "";
|
const tab = typeof route.query.tab === "string" ? route.query.tab : "";
|
||||||
return ["history", "visits", "medicationAdherence", "ae", "pd"].includes(tab) ? tab : "history";
|
return ["history", "visits", "medicationAdherence", "ae", "pd"].includes(tab) ? tab : "history";
|
||||||
@@ -552,12 +583,17 @@ const subjectForm = reactive({
|
|||||||
const siteMap = ref<Record<string, string>>({});
|
const siteMap = ref<Record<string, string>>({});
|
||||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||||
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
||||||
|
const canUpdateSubject = computed(() => canUseApiPermission("subjects:update"));
|
||||||
const activeTab = ref(resolveTabFromQuery());
|
const activeTab = ref(resolveTabFromQuery());
|
||||||
|
|
||||||
const historyItems = ref<any[]>([]);
|
const historyItems = ref<any[]>([]);
|
||||||
const loadingHistory = ref(false);
|
const loadingHistory = ref(false);
|
||||||
const historyDialogVisible = ref(false);
|
const historyDialogVisible = ref(false);
|
||||||
const historyEditingId = ref<string | null>(null);
|
const historyEditingId = ref<string | null>(null);
|
||||||
|
const canCreateHistory = computed(() => canUseApiPermission("subject_histories:create"));
|
||||||
|
const canUpdateHistory = computed(() => canUseApiPermission("subject_histories:update"));
|
||||||
|
const canDeleteHistory = computed(() => canUseApiPermission("subject_histories:delete"));
|
||||||
|
const canSaveHistory = computed(() => (historyEditingId.value ? canUpdateHistory.value : canCreateHistory.value));
|
||||||
const historyForm = reactive({
|
const historyForm = reactive({
|
||||||
record_date: "",
|
record_date: "",
|
||||||
content: "",
|
content: "",
|
||||||
@@ -570,6 +606,10 @@ const earlyTerminationDialogVisible = ref(false);
|
|||||||
const earlyTerminationSaving = ref(false);
|
const earlyTerminationSaving = ref(false);
|
||||||
const visitEditingId = ref<string | null>(null);
|
const visitEditingId = ref<string | null>(null);
|
||||||
const visitEditingRowId = ref<string | null>(null);
|
const visitEditingRowId = ref<string | null>(null);
|
||||||
|
const canCreateVisit = computed(() => canUseApiPermission("visits:create"));
|
||||||
|
const canUpdateVisit = computed(() => canUseApiPermission("visits:update"));
|
||||||
|
const canDeleteVisit = computed(() => canUseApiPermission("visits:delete"));
|
||||||
|
const canSaveVisit = computed(() => (visitEditingId.value ? canUpdateVisit.value : canCreateVisit.value));
|
||||||
const visitRowDraft = reactive({
|
const visitRowDraft = reactive({
|
||||||
actual_date: "",
|
actual_date: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
@@ -590,6 +630,7 @@ const earlyTerminationForm = reactive({
|
|||||||
const isFirstVisit = computed(() => visitForm.visit_code === "V1");
|
const isFirstVisit = computed(() => visitForm.visit_code === "V1");
|
||||||
const adherenceEditing = ref(false);
|
const adherenceEditing = ref(false);
|
||||||
const adherenceSaving = ref(false);
|
const adherenceSaving = ref(false);
|
||||||
|
const canEditAdherence = computed(() => canUpdateSubject.value);
|
||||||
const adherenceForm = reactive({
|
const adherenceForm = reactive({
|
||||||
actual_medication_count: null as number | null,
|
actual_medication_count: null as number | null,
|
||||||
});
|
});
|
||||||
@@ -598,6 +639,11 @@ const aeItems = ref<any[]>([]);
|
|||||||
const loadingAes = ref(false);
|
const loadingAes = ref(false);
|
||||||
const aeDialogVisible = ref(false);
|
const aeDialogVisible = ref(false);
|
||||||
const aeEditingId = ref<string | null>(null);
|
const aeEditingId = ref<string | null>(null);
|
||||||
|
const canListAe = computed(() => canUseApiPermission("subject_aes:list"));
|
||||||
|
const canCreateAe = computed(() => canUseApiPermission("subject_aes:create"));
|
||||||
|
const canUpdateAe = computed(() => canUseApiPermission("subject_aes:update"));
|
||||||
|
const canDeleteAe = computed(() => canUseApiPermission("subject_aes:delete"));
|
||||||
|
const canSaveAe = computed(() => (aeEditingId.value ? canUpdateAe.value : canCreateAe.value));
|
||||||
const aeCausalityOptions = [
|
const aeCausalityOptions = [
|
||||||
{ value: "肯定有关", label: TEXT.enums.aeCausality["肯定有关"] },
|
{ value: "肯定有关", label: TEXT.enums.aeCausality["肯定有关"] },
|
||||||
{ value: "很可能有关", label: TEXT.enums.aeCausality["很可能有关"] },
|
{ value: "很可能有关", label: TEXT.enums.aeCausality["很可能有关"] },
|
||||||
@@ -636,6 +682,11 @@ const pdItems = ref<any[]>([]);
|
|||||||
const loadingPds = ref(false);
|
const loadingPds = ref(false);
|
||||||
const pdDialogVisible = ref(false);
|
const pdDialogVisible = ref(false);
|
||||||
const pdEditingId = ref<string | null>(null);
|
const pdEditingId = ref<string | null>(null);
|
||||||
|
const canListPd = computed(() => canUseApiPermission("subject_pds:list"));
|
||||||
|
const canCreatePd = computed(() => canUseApiPermission("subject_pds:create"));
|
||||||
|
const canUpdatePd = computed(() => canUseApiPermission("subject_pds:update"));
|
||||||
|
const canDeletePd = computed(() => canUseApiPermission("subject_pds:delete"));
|
||||||
|
const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));
|
||||||
const pdTypeOptions = ["知情同意", "排除标准", "药物使用", "入排标准", "访视流程", "检验检查", "其他"];
|
const pdTypeOptions = ["知情同意", "排除标准", "药物使用", "入排标准", "访视流程", "检验检查", "其他"];
|
||||||
const pdLevelOptions = Object.entries(TEXT.enums.pdLevel).map(([value, label]) => ({ value, label }));
|
const pdLevelOptions = Object.entries(TEXT.enums.pdLevel).map(([value, label]) => ({ value, label }));
|
||||||
const pdStatusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label }));
|
const pdStatusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label }));
|
||||||
@@ -717,6 +768,10 @@ const startSubjectEdit = () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canUpdateSubject.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
subjectForm.screening_date = detail.screening_date || "";
|
subjectForm.screening_date = detail.screening_date || "";
|
||||||
subjectForm.consent_date = detail.consent_date || "";
|
subjectForm.consent_date = detail.consent_date || "";
|
||||||
subjectForm.enrollment_date = detail.enrollment_date || "";
|
subjectForm.enrollment_date = detail.enrollment_date || "";
|
||||||
@@ -735,6 +790,10 @@ const saveSubjectEdit = async () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canUpdateSubject.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
subjectSaving.value = true;
|
subjectSaving.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -776,6 +835,11 @@ const openHistoryDialog = (row?: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const allowed = row?.id ? canUpdateHistory.value : canCreateHistory.value;
|
||||||
|
if (!allowed) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
historyEditingId.value = row?.id || null;
|
historyEditingId.value = row?.id || null;
|
||||||
historyForm.record_date = row?.record_date || "";
|
historyForm.record_date = row?.record_date || "";
|
||||||
historyForm.content = row?.content || "";
|
historyForm.content = row?.content || "";
|
||||||
@@ -787,6 +851,10 @@ const saveHistory = async () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canSaveHistory.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -811,6 +879,10 @@ const removeHistory = async (row: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canDeleteHistory.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -856,6 +928,11 @@ const openVisitDialog = (row?: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const allowed = row?.id ? canUpdateVisit.value : canCreateVisit.value;
|
||||||
|
if (!allowed) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
visitEditingId.value = row?.id || null;
|
visitEditingId.value = row?.id || null;
|
||||||
if (row) {
|
if (row) {
|
||||||
visitForm.actual_date = row.actual_date || "";
|
visitForm.actual_date = row.actual_date || "";
|
||||||
@@ -876,6 +953,10 @@ const openEarlyTerminationDialog = () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canCreateVisit.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
earlyTerminationForm.termination_date = "";
|
earlyTerminationForm.termination_date = "";
|
||||||
earlyTerminationForm.reason = "";
|
earlyTerminationForm.reason = "";
|
||||||
earlyTerminationForm.notes = "";
|
earlyTerminationForm.notes = "";
|
||||||
@@ -887,6 +968,10 @@ const startVisitRowEdit = (row: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canUpdateVisit.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
visitEditingRowId.value = row?.id || null;
|
visitEditingRowId.value = row?.id || null;
|
||||||
visitRowDraft.actual_date = row?.actual_date || "";
|
visitRowDraft.actual_date = row?.actual_date || "";
|
||||||
visitRowDraft.notes = row?.notes || "";
|
visitRowDraft.notes = row?.notes || "";
|
||||||
@@ -901,6 +986,10 @@ const saveVisitRow = async (row: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canUpdateVisit.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId || !row?.id) return;
|
if (!studyId || !subjectId || !row?.id) return;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -1072,6 +1161,10 @@ const saveVisit = async () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canSaveVisit.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
try {
|
try {
|
||||||
if (visitEditingId.value) {
|
if (visitEditingId.value) {
|
||||||
@@ -1106,6 +1199,10 @@ const saveEarlyTermination = async () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canCreateVisit.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
if (!earlyTerminationForm.termination_date) {
|
if (!earlyTerminationForm.termination_date) {
|
||||||
ElMessage.error("请填写提前终止日期");
|
ElMessage.error("请填写提前终止日期");
|
||||||
@@ -1146,6 +1243,10 @@ const removeVisit = async (row: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canDeleteVisit.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -1159,6 +1260,10 @@ const removeVisit = async (row: any) => {
|
|||||||
|
|
||||||
const loadAes = async () => {
|
const loadAes = async () => {
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
|
if (!canListAe.value) {
|
||||||
|
aeItems.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
loadingAes.value = true;
|
loadingAes.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = (await fetchAes(studyId, { subject_id: subjectId })) as any;
|
const { data } = (await fetchAes(studyId, { subject_id: subjectId })) as any;
|
||||||
@@ -1175,6 +1280,11 @@ const openAeDialog = (row?: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const allowed = row?.id ? canUpdateAe.value : canCreateAe.value;
|
||||||
|
if (!allowed) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
aeEditingId.value = row?.id || null;
|
aeEditingId.value = row?.id || null;
|
||||||
Object.assign(aeForm, {
|
Object.assign(aeForm, {
|
||||||
term: row?.term || "",
|
term: row?.term || "",
|
||||||
@@ -1194,6 +1304,10 @@ const saveAe = async () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canSaveAe.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -1225,6 +1339,10 @@ const removeAe = async (row: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canDeleteAe.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -1238,6 +1356,10 @@ const removeAe = async (row: any) => {
|
|||||||
|
|
||||||
const loadPds = async () => {
|
const loadPds = async () => {
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
|
if (!canListPd.value) {
|
||||||
|
pdItems.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
loadingPds.value = true;
|
loadingPds.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = (await listSubjectPds(studyId, subjectId)) as any;
|
const { data } = (await listSubjectPds(studyId, subjectId)) as any;
|
||||||
@@ -1262,6 +1384,11 @@ const openPdDialog = (row?: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const allowed = row?.id ? canUpdatePd.value : canCreatePd.value;
|
||||||
|
if (!allowed) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
pdEditingId.value = row?.id || null;
|
pdEditingId.value = row?.id || null;
|
||||||
Object.assign(pdForm, {
|
Object.assign(pdForm, {
|
||||||
pd_no: row?.pd_no || "",
|
pd_no: row?.pd_no || "",
|
||||||
@@ -1276,19 +1403,19 @@ const openPdDialog = (row?: any) => {
|
|||||||
const currentTabAction = computed(() => {
|
const currentTabAction = computed(() => {
|
||||||
switch (activeTab.value) {
|
switch (activeTab.value) {
|
||||||
case "visits":
|
case "visits":
|
||||||
return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() };
|
return { label: TEXT.common.actions.newVisit, loading: false, allowed: canCreateVisit.value, onClick: () => openVisitDialog() };
|
||||||
case "medicationAdherence":
|
case "medicationAdherence":
|
||||||
if (adherenceEditing.value) {
|
if (adherenceEditing.value) {
|
||||||
return { label: "保存用药次数", loading: adherenceSaving.value, onClick: () => saveMedicationAdherence() };
|
return { label: "保存用药次数", loading: adherenceSaving.value, allowed: canEditAdherence.value, onClick: () => saveMedicationAdherence() };
|
||||||
}
|
}
|
||||||
return { label: "编辑", loading: false, onClick: () => startMedicationAdherenceEdit() };
|
return { label: "编辑", loading: false, allowed: canEditAdherence.value, onClick: () => startMedicationAdherenceEdit() };
|
||||||
case "ae":
|
case "ae":
|
||||||
return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() };
|
return { label: TEXT.common.actions.newAe, loading: false, allowed: canCreateAe.value, onClick: () => openAeDialog() };
|
||||||
case "pd":
|
case "pd":
|
||||||
return { label: TEXT.modules.subjectDetail.newPd, onClick: () => openPdDialog() };
|
return { label: TEXT.modules.subjectDetail.newPd, loading: false, allowed: canCreatePd.value, onClick: () => openPdDialog() };
|
||||||
case "history":
|
case "history":
|
||||||
default:
|
default:
|
||||||
return { label: TEXT.common.actions.newHistory, onClick: () => openHistoryDialog() };
|
return { label: TEXT.common.actions.newHistory, loading: false, allowed: canCreateHistory.value, onClick: () => openHistoryDialog() };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1297,6 +1424,10 @@ const startMedicationAdherenceEdit = () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canEditAdherence.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
adherenceForm.actual_medication_count = detail.actual_medication_count ?? null;
|
adherenceForm.actual_medication_count = detail.actual_medication_count ?? null;
|
||||||
adherenceEditing.value = true;
|
adherenceEditing.value = true;
|
||||||
};
|
};
|
||||||
@@ -1306,6 +1437,10 @@ const saveMedicationAdherence = async () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canEditAdherence.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
adherenceSaving.value = true;
|
adherenceSaving.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -1327,6 +1462,10 @@ const savePd = async () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canSavePd.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
if (!pdForm.pd_type?.trim()) {
|
if (!pdForm.pd_type?.trim()) {
|
||||||
ElMessage.error(TEXT.common.messages.required);
|
ElMessage.error(TEXT.common.messages.required);
|
||||||
@@ -1356,6 +1495,10 @@ const removePd = async (row: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canDeletePd.value) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user