管理后台前后端逻辑、UI美化
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
"""add is_locked to studies
|
||||||
|
|
||||||
|
Revision ID: 20260116_01
|
||||||
|
Revises: 20250310_02
|
||||||
|
Create Date: 2026-01-16 11:41:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '20260116_01'
|
||||||
|
down_revision: Union[str, None] = '20250310_02'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 添加 is_locked 字段到 studies 表
|
||||||
|
op.add_column('studies', sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 删除 is_locked 字段
|
||||||
|
op.drop_column('studies', 'is_locked')
|
||||||
@@ -5,10 +5,12 @@ from datetime import date
|
|||||||
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_current_user, get_db_session, require_study_member
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked
|
||||||
from app.crud import ae as ae_crud
|
from app.crud import ae as ae_crud
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
|
from app.crud import subject as subject_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.ae import AECreate, AERead, AEUpdate
|
from app.schemas.ae import AECreate, AERead, AEUpdate
|
||||||
|
|
||||||
@@ -25,6 +27,22 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
return study
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_ae_visible(db: AsyncSession, study_id: uuid.UUID, ae: AERead | None):
|
||||||
|
if not ae:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
||||||
|
if ae.site_id:
|
||||||
|
site = await site_crud.get_site(db, ae.site_id)
|
||||||
|
if not site or site.study_id != study_id or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
||||||
|
if ae.subject_id:
|
||||||
|
subject = await subject_crud.get_subject(db, ae.subject_id)
|
||||||
|
if not subject or subject.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
||||||
|
site = await site_crud.get_site(db, subject.site_id)
|
||||||
|
if not site or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
||||||
|
|
||||||
|
|
||||||
async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
|
async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
|
||||||
member = await member_crud.get_member(db, study_id, user_id)
|
member = await member_crud.get_member(db, study_id, user_id)
|
||||||
return member.role_in_study if member else None
|
return member.role_in_study if member else None
|
||||||
@@ -38,7 +56,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_study_member())],
|
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_ae(
|
async def create_ae(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -117,7 +135,7 @@ async def get_ae(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/{ae_id}",
|
"/{ae_id}",
|
||||||
response_model=AERead,
|
response_model=AERead,
|
||||||
dependencies=[Depends(require_study_member())],
|
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_ae(
|
async def update_ae(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -132,6 +150,7 @@ async def update_ae(
|
|||||||
ae = await ae_crud.get_ae(db, ae_id)
|
ae = await ae_crud.get_ae(db, ae_id)
|
||||||
if not ae or ae.study_id != study_id:
|
if not ae or ae.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
||||||
|
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
|
||||||
|
|
||||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||||
if current_user.role == "ADMIN":
|
if current_user.role == "ADMIN":
|
||||||
@@ -184,7 +203,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_study_member())],
|
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_ae(
|
async def delete_ae(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -196,6 +215,7 @@ async def delete_ae(
|
|||||||
ae = await ae_crud.get_ae(db, ae_id)
|
ae = await ae_crud.get_ae(db, ae_id)
|
||||||
if not ae or ae.study_id != study_id:
|
if not ae or ae.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
||||||
|
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
|
||||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||||
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
|
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member
|
from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member, require_study_not_locked
|
||||||
from app.crud import attachment as attachment_crud
|
from app.crud import attachment as attachment_crud
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
@@ -41,7 +41,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
"/",
|
"/",
|
||||||
response_model=AttachmentRead,
|
response_model=AttachmentRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_study_member())],
|
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def upload_attachment(
|
async def upload_attachment(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -281,7 +281,7 @@ async def global_delete_attachment(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{attachment_id}",
|
"/{attachment_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(require_study_member())],
|
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_attachment(
|
async def delete_attachment(
|
||||||
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_current_user, get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import drug_shipment as shipment_crud
|
from app.crud import drug_shipment as shipment_crud
|
||||||
from app.crud import site as site_crud
|
from app.crud import site as site_crud
|
||||||
@@ -20,11 +20,20 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
return study
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID | None):
|
||||||
|
if not center_id:
|
||||||
|
return None
|
||||||
|
site = await site_crud.get_site(db, center_id)
|
||||||
|
if not site or site.study_id != study_id or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||||
|
return site
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/shipments",
|
"/shipments",
|
||||||
response_model=DrugShipmentRead,
|
response_model=DrugShipmentRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_shipment(
|
async def create_shipment(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -33,9 +42,7 @@ async def create_shipment(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> DrugShipmentRead:
|
) -> DrugShipmentRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
site = await site_crud.get_site(db, shipment_in.center_id)
|
site = await _ensure_center_active(db, study_id, shipment_in.center_id)
|
||||||
if not site or site.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不存在")
|
|
||||||
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
|
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
|
||||||
shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id)
|
shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
@@ -102,7 +109,7 @@ async def get_shipment(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/shipments/{shipment_id}",
|
"/shipments/{shipment_id}",
|
||||||
response_model=DrugShipmentRead,
|
response_model=DrugShipmentRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_shipment(
|
async def update_shipment(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -116,10 +123,10 @@ async def update_shipment(
|
|||||||
if not shipment or shipment.study_id != study_id:
|
if not shipment or shipment.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
|
||||||
if shipment_in.center_id:
|
if shipment_in.center_id:
|
||||||
site = await site_crud.get_site(db, shipment_in.center_id)
|
site = await _ensure_center_active(db, study_id, shipment_in.center_id)
|
||||||
if not site or site.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不存在")
|
|
||||||
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
|
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
|
||||||
|
else:
|
||||||
|
await _ensure_center_active(db, study_id, shipment.center_id)
|
||||||
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
|
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -137,7 +144,7 @@ async def update_shipment(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/shipments/{shipment_id}",
|
"/shipments/{shipment_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_shipment(
|
async def delete_shipment(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -149,6 +156,7 @@ async def delete_shipment(
|
|||||||
shipment = await shipment_crud.get_shipment(db, shipment_id)
|
shipment = await shipment_crud.get_shipment(db, shipment_id)
|
||||||
if not shipment or shipment.study_id != study_id:
|
if not shipment or shipment.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
|
||||||
|
await _ensure_center_active(db, study_id, shipment.center_id)
|
||||||
await shipment_crud.delete_shipment(db, shipment)
|
await shipment_crud.delete_shipment(db, shipment)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -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_current_user, get_db_session
|
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import faq_category as category_crud
|
from app.crud import faq_category as category_crud
|
||||||
from app.crud import faq_item as item_crud
|
from app.crud import faq_item as item_crud
|
||||||
@@ -26,6 +26,7 @@ def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role:
|
|||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="创建 FAQ 分类",
|
summary="创建 FAQ 分类",
|
||||||
description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN。",
|
description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_category(
|
async def create_category(
|
||||||
payload: CategoryCreate,
|
payload: CategoryCreate,
|
||||||
@@ -84,6 +85,7 @@ async def list_categories(
|
|||||||
response_model=CategoryRead,
|
response_model=CategoryRead,
|
||||||
summary="更新 FAQ 分类",
|
summary="更新 FAQ 分类",
|
||||||
description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN。",
|
description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_category(
|
async def update_category(
|
||||||
category_id: uuid.UUID,
|
category_id: uuid.UUID,
|
||||||
@@ -129,6 +131,7 @@ async def update_category(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="删除 FAQ 分类",
|
summary="删除 FAQ 分类",
|
||||||
description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。",
|
description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_category(
|
async def delete_category(
|
||||||
category_id: uuid.UUID,
|
category_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_current_user, get_db_session
|
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import faq_category as category_crud
|
from app.crud import faq_category as category_crud
|
||||||
from app.crud import faq_item as faq_crud
|
from app.crud import faq_item as faq_crud
|
||||||
@@ -41,6 +41,7 @@ def _check_create_permission(current_user, is_member: bool):
|
|||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="创建 FAQ",
|
summary="创建 FAQ",
|
||||||
description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。",
|
description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_faq(
|
async def create_faq(
|
||||||
payload: FaqCreate,
|
payload: FaqCreate,
|
||||||
@@ -178,6 +179,7 @@ async def get_faq(
|
|||||||
response_model=FaqRead,
|
response_model=FaqRead,
|
||||||
summary="更新 FAQ",
|
summary="更新 FAQ",
|
||||||
description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。",
|
description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_faq(
|
async def update_faq(
|
||||||
item_id: uuid.UUID,
|
item_id: uuid.UUID,
|
||||||
@@ -219,6 +221,7 @@ async def update_faq(
|
|||||||
response_model=FaqRead,
|
response_model=FaqRead,
|
||||||
summary="更新 FAQ 状态",
|
summary="更新 FAQ 状态",
|
||||||
description="提问者或项目 PM/ADMIN 可确认已解决。",
|
description="提问者或项目 PM/ADMIN 可确认已解决。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_faq_status(
|
async def update_faq_status(
|
||||||
item_id: uuid.UUID,
|
item_id: uuid.UUID,
|
||||||
@@ -248,6 +251,7 @@ async def update_faq_status(
|
|||||||
response_model=FaqRead,
|
response_model=FaqRead,
|
||||||
summary="设置最佳回复",
|
summary="设置最佳回复",
|
||||||
description="项目成员可设置最佳回复,全局仅 ADMIN。",
|
description="项目成员可设置最佳回复,全局仅 ADMIN。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def set_best_reply(
|
async def set_best_reply(
|
||||||
item_id: uuid.UUID,
|
item_id: uuid.UUID,
|
||||||
@@ -330,6 +334,7 @@ async def list_replies(
|
|||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="创建 FAQ 回复",
|
summary="创建 FAQ 回复",
|
||||||
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
|
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_reply(
|
async def create_reply(
|
||||||
item_id: uuid.UUID,
|
item_id: uuid.UUID,
|
||||||
@@ -392,6 +397,7 @@ async def create_reply(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="删除 FAQ",
|
summary="删除 FAQ",
|
||||||
description="删除 FAQ,项目内需 PM/ADMIN 权限,全局仅 ADMIN。",
|
description="删除 FAQ,项目内需 PM/ADMIN 权限,全局仅 ADMIN。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_faq(
|
async def delete_faq(
|
||||||
item_id: uuid.UUID,
|
item_id: uuid.UUID,
|
||||||
@@ -427,6 +433,7 @@ async def delete_faq(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="删除 FAQ 回复",
|
summary="删除 FAQ 回复",
|
||||||
description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。",
|
description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。",
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_reply(
|
async def delete_reply(
|
||||||
item_id: uuid.UUID,
|
item_id: uuid.UUID,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session
|
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import contract_fee as contract_fee_crud
|
from app.crud import contract_fee as contract_fee_crud
|
||||||
@@ -86,7 +86,7 @@ async def _authorize_download_user(request: Request, db: AsyncSession):
|
|||||||
"/attachments",
|
"/attachments",
|
||||||
response_model=FeeApiResponse[FeeAttachmentRead],
|
response_model=FeeApiResponse[FeeAttachmentRead],
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def upload_fee_attachment(
|
async def upload_fee_attachment(
|
||||||
entity_type: str = Form(...),
|
entity_type: str = Form(...),
|
||||||
@@ -220,7 +220,7 @@ async def download_fee_attachment(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/attachments/{attachment_id}",
|
"/attachments/{attachment_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_fee_attachment(
|
async def delete_fee_attachment(
|
||||||
attachment_id: uuid.UUID,
|
attachment_id: uuid.UUID,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session
|
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import contract_fee as contract_fee_crud
|
from app.crud import contract_fee as contract_fee_crud
|
||||||
from app.crud import contract_fee_payment as payment_crud
|
from app.crud import contract_fee_payment as payment_crud
|
||||||
@@ -43,6 +43,14 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren
|
|||||||
return membership
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
|
||||||
|
if not center_id:
|
||||||
|
return
|
||||||
|
site = await site_crud.get_site(db, center_id)
|
||||||
|
if not site or site.study_id != project_id or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
def _to_decimal(value: Any) -> Decimal:
|
def _to_decimal(value: Any) -> Decimal:
|
||||||
if isinstance(value, Decimal):
|
if isinstance(value, Decimal):
|
||||||
return value
|
return value
|
||||||
@@ -186,7 +194,7 @@ async def get_contract_fee(
|
|||||||
"/contracts",
|
"/contracts",
|
||||||
response_model=FeeApiResponse[ContractFeeRead],
|
response_model=FeeApiResponse[ContractFeeRead],
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_contract_fee(
|
async def create_contract_fee(
|
||||||
contract_in: ContractFeeCreate,
|
contract_in: ContractFeeCreate,
|
||||||
@@ -201,8 +209,8 @@ async def create_contract_fee(
|
|||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
|
||||||
|
|
||||||
site = await site_crud.get_site(db, contract_in.center_id)
|
site = await site_crud.get_site(db, contract_in.center_id)
|
||||||
if not site or site.study_id != contract_in.project_id:
|
if not site or site.study_id != contract_in.project_id or not site.is_active:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
|
||||||
|
|
||||||
contract = await contract_fee_crud.create_contract_fee(db, contract_in)
|
contract = await contract_fee_crud.create_contract_fee(db, contract_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
@@ -221,7 +229,7 @@ async def create_contract_fee(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/contracts/{contract_id}",
|
"/contracts/{contract_id}",
|
||||||
response_model=FeeApiResponse[ContractFeeRead],
|
response_model=FeeApiResponse[ContractFeeRead],
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_contract_fee(
|
async def update_contract_fee(
|
||||||
contract_id: uuid.UUID,
|
contract_id: uuid.UUID,
|
||||||
@@ -233,6 +241,7 @@ async def update_contract_fee(
|
|||||||
if not contract:
|
if not contract:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||||
await _ensure_project_access(db, contract.project_id, current_user, write=True)
|
await _ensure_project_access(db, contract.project_id, current_user, write=True)
|
||||||
|
await _ensure_center_active(db, contract.project_id, contract.center_id)
|
||||||
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
|
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -250,7 +259,7 @@ async def update_contract_fee(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/contracts/{contract_id}",
|
"/contracts/{contract_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_contract_fee(
|
async def delete_contract_fee(
|
||||||
contract_id: uuid.UUID,
|
contract_id: uuid.UUID,
|
||||||
@@ -261,6 +270,7 @@ async def delete_contract_fee(
|
|||||||
if not contract:
|
if not contract:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||||
await _ensure_project_access(db, contract.project_id, current_user, write=True)
|
await _ensure_project_access(db, contract.project_id, current_user, write=True)
|
||||||
|
await _ensure_center_active(db, contract.project_id, contract.center_id)
|
||||||
await contract_fee_crud.delete_contract_fee(db, contract)
|
await contract_fee_crud.delete_contract_fee(db, contract)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -278,7 +288,7 @@ async def delete_contract_fee(
|
|||||||
"/contracts/{contract_id}/payments",
|
"/contracts/{contract_id}/payments",
|
||||||
response_model=FeeApiResponse[ContractFeePaymentRead],
|
response_model=FeeApiResponse[ContractFeePaymentRead],
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_contract_payment(
|
async def create_contract_payment(
|
||||||
contract_id: uuid.UUID,
|
contract_id: uuid.UUID,
|
||||||
@@ -308,7 +318,7 @@ async def create_contract_payment(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/payments/{payment_id}",
|
"/payments/{payment_id}",
|
||||||
response_model=FeeApiResponse[ContractFeePaymentRead],
|
response_model=FeeApiResponse[ContractFeePaymentRead],
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_contract_payment(
|
async def update_contract_payment(
|
||||||
payment_id: uuid.UUID,
|
payment_id: uuid.UUID,
|
||||||
@@ -349,7 +359,7 @@ async def update_contract_payment(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/payments/{payment_id}",
|
"/payments/{payment_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_contract_payment(
|
async def delete_contract_payment(
|
||||||
payment_id: uuid.UUID,
|
payment_id: uuid.UUID,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import date
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session
|
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
from app.crud import special_expense as special_crud
|
from app.crud import special_expense as special_crud
|
||||||
@@ -52,6 +52,14 @@ def _validate_category(value: str | None):
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效")
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
|
||||||
|
if not center_id:
|
||||||
|
return
|
||||||
|
site = await site_crud.get_site(db, center_id)
|
||||||
|
if not site or site.study_id != project_id or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/special",
|
"/special",
|
||||||
response_model=FeeApiResponse[list[SpecialExpenseListItem]],
|
response_model=FeeApiResponse[list[SpecialExpenseListItem]],
|
||||||
@@ -122,7 +130,7 @@ async def get_special_expense(
|
|||||||
"/special",
|
"/special",
|
||||||
response_model=FeeApiResponse[SpecialExpenseRead],
|
response_model=FeeApiResponse[SpecialExpenseRead],
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_special_expense(
|
async def create_special_expense(
|
||||||
expense_in: SpecialExpenseCreate,
|
expense_in: SpecialExpenseCreate,
|
||||||
@@ -134,8 +142,9 @@ async def create_special_expense(
|
|||||||
_validate_special_rules(expense_in)
|
_validate_special_rules(expense_in)
|
||||||
if expense_in.center_id:
|
if expense_in.center_id:
|
||||||
site = await site_crud.get_site(db, expense_in.center_id)
|
site = await site_crud.get_site(db, expense_in.center_id)
|
||||||
if not site or site.study_id != expense_in.project_id:
|
if not site or site.study_id != expense_in.project_id or not site.is_active:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
|
||||||
|
await _ensure_center_active(db, expense_in.project_id, expense_in.center_id)
|
||||||
expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id)
|
expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -153,7 +162,7 @@ async def create_special_expense(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/special/{expense_id}",
|
"/special/{expense_id}",
|
||||||
response_model=FeeApiResponse[SpecialExpenseRead],
|
response_model=FeeApiResponse[SpecialExpenseRead],
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_special_expense(
|
async def update_special_expense(
|
||||||
expense_id: uuid.UUID,
|
expense_id: uuid.UUID,
|
||||||
@@ -181,8 +190,11 @@ async def update_special_expense(
|
|||||||
_validate_special_rules(merged)
|
_validate_special_rules(merged)
|
||||||
if expense_in.center_id:
|
if expense_in.center_id:
|
||||||
site = await site_crud.get_site(db, expense_in.center_id)
|
site = await site_crud.get_site(db, expense_in.center_id)
|
||||||
if not site or site.study_id != expense.project_id:
|
if not site or site.study_id != expense.project_id or not site.is_active:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
|
||||||
|
await _ensure_center_active(db, expense.project_id, expense_in.center_id)
|
||||||
|
else:
|
||||||
|
await _ensure_center_active(db, expense.project_id, expense.center_id)
|
||||||
expense = await special_crud.update_special_expense(db, expense, expense_in)
|
expense = await special_crud.update_special_expense(db, expense, expense_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -200,7 +212,7 @@ async def update_special_expense(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/special/{expense_id}",
|
"/special/{expense_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(get_current_user)],
|
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_special_expense(
|
async def delete_special_expense(
|
||||||
expense_id: uuid.UUID,
|
expense_id: uuid.UUID,
|
||||||
@@ -211,6 +223,7 @@ async def delete_special_expense(
|
|||||||
if not expense:
|
if not expense:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||||
await _ensure_project_access(db, expense.project_id, current_user, write=True)
|
await _ensure_project_access(db, expense.project_id, current_user, write=True)
|
||||||
|
await _ensure_center_active(db, expense.project_id, expense.center_id)
|
||||||
await special_crud.delete_special_expense(db, expense)
|
await special_crud.delete_special_expense(db, expense)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ 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_current_user, get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import finance_contract as contract_crud
|
from app.crud import finance_contract as contract_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractRead, FinanceContractUpdate
|
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractRead, FinanceContractUpdate
|
||||||
|
|
||||||
@@ -19,6 +20,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
return study
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
|
||||||
|
if not site_name:
|
||||||
|
return
|
||||||
|
active_names = await site_crud.list_active_names(db, study_id)
|
||||||
|
if site_name not in active_names:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/contracts",
|
"/contracts",
|
||||||
response_model=FinanceContractRead,
|
response_model=FinanceContractRead,
|
||||||
@@ -32,6 +41,7 @@ async def create_contract(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> FinanceContractRead:
|
) -> FinanceContractRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
await _ensure_site_name_active(db, study_id, contract_in.site_name)
|
||||||
contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id)
|
contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -97,6 +107,7 @@ async def update_contract(
|
|||||||
contract = await contract_crud.get_contract(db, contract_id)
|
contract = await contract_crud.get_contract(db, contract_id)
|
||||||
if not contract or contract.study_id != study_id:
|
if not contract or contract.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
|
||||||
|
await _ensure_site_name_active(db, study_id, contract.site_name)
|
||||||
contract = await contract_crud.update_contract(db, contract, contract_in)
|
contract = await contract_crud.update_contract(db, contract, contract_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -126,6 +137,7 @@ async def delete_contract(
|
|||||||
contract = await contract_crud.get_contract(db, contract_id)
|
contract = await contract_crud.get_contract(db, contract_id)
|
||||||
if not contract or contract.study_id != study_id:
|
if not contract or contract.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
|
||||||
|
await _ensure_site_name_active(db, study_id, contract.site_name)
|
||||||
await contract_crud.delete_contract(db, contract)
|
await contract_crud.delete_contract(db, contract)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ 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_current_user, get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import finance_special as special_crud
|
from app.crud import finance_special as special_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
|
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
|
||||||
|
|
||||||
@@ -19,6 +20,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
return study
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
|
||||||
|
if not site_name:
|
||||||
|
return
|
||||||
|
active_names = await site_crud.list_active_names(db, study_id)
|
||||||
|
if site_name not in active_names:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/specials",
|
"/specials",
|
||||||
response_model=FinanceSpecialRead,
|
response_model=FinanceSpecialRead,
|
||||||
@@ -32,6 +41,7 @@ async def create_special(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> FinanceSpecialRead:
|
) -> FinanceSpecialRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
await _ensure_site_name_active(db, study_id, special_in.site_name)
|
||||||
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
|
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -97,6 +107,7 @@ async def update_special(
|
|||||||
item = await special_crud.get_special(db, special_id)
|
item = await special_crud.get_special(db, special_id)
|
||||||
if not item or item.study_id != study_id:
|
if not item or item.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||||
|
await _ensure_site_name_active(db, study_id, item.site_name)
|
||||||
item = await special_crud.update_special(db, item, special_in)
|
item = await special_crud.update_special(db, item, special_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -126,6 +137,7 @@ async def delete_special(
|
|||||||
item = await special_crud.get_special(db, special_id)
|
item = await special_crud.get_special(db, special_id)
|
||||||
if not item or item.study_id != study_id:
|
if not item or item.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||||
|
await _ensure_site_name_active(db, study_id, item.site_name)
|
||||||
await special_crud.delete_special(db, item)
|
await special_crud.delete_special(db, item)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ 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_current_user, get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import knowledge_note as note_crud
|
from app.crud import knowledge_note as note_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteRead, KnowledgeNoteUpdate
|
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteRead, KnowledgeNoteUpdate
|
||||||
|
|
||||||
@@ -19,6 +20,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
return study
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
|
||||||
|
if not site_name:
|
||||||
|
return
|
||||||
|
active_names = await site_crud.list_active_names(db, study_id)
|
||||||
|
if site_name not in active_names:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/notes",
|
"/notes",
|
||||||
response_model=KnowledgeNoteRead,
|
response_model=KnowledgeNoteRead,
|
||||||
@@ -32,6 +41,7 @@ async def create_note(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> KnowledgeNoteRead:
|
) -> KnowledgeNoteRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
await _ensure_site_name_active(db, study_id, note_in.site_name)
|
||||||
note = await note_crud.create_note(db, study_id, note_in, created_by=current_user.id)
|
note = await note_crud.create_note(db, study_id, note_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -97,6 +107,7 @@ async def update_note(
|
|||||||
note = await note_crud.get_note(db, note_id)
|
note = await note_crud.get_note(db, note_id)
|
||||||
if not note or note.study_id != study_id:
|
if not note or note.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||||
|
await _ensure_site_name_active(db, study_id, note.site_name)
|
||||||
note = await note_crud.update_note(db, note, note_in)
|
note = await note_crud.update_note(db, note, note_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -126,6 +137,7 @@ async def delete_note(
|
|||||||
note = await note_crud.get_note(db, note_id)
|
note = await note_crud.get_note(db, note_id)
|
||||||
if not note or note.study_id != study_id:
|
if not note or note.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||||
|
await _ensure_site_name_active(db, study_id, note.site_name)
|
||||||
await note_crud.delete_note(db, note)
|
await note_crud.delete_note(db, note)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -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_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
@@ -24,7 +24,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
"/",
|
"/",
|
||||||
response_model=StudyMemberRead,
|
response_model=StudyMemberRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def add_member(
|
async def add_member(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -34,6 +34,13 @@ async def add_member(
|
|||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
existing = await member_crud.get_member(db, study_id, member_in.user_id)
|
existing = await member_crud.get_member(db, study_id, member_in.user_id)
|
||||||
if existing:
|
if existing:
|
||||||
|
if not existing.is_active:
|
||||||
|
updated = await member_crud.update_member(
|
||||||
|
db,
|
||||||
|
existing,
|
||||||
|
StudyMemberUpdate(is_active=True, role_in_study=member_in.role_in_study),
|
||||||
|
)
|
||||||
|
return updated
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
|
||||||
member = await member_crud.add_member(db, study_id, member_in)
|
member = await member_crud.add_member(db, study_id, member_in)
|
||||||
return member
|
return member
|
||||||
@@ -48,6 +55,7 @@ async def list_members(
|
|||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
include_inactive: bool = False,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[StudyMemberReadWithUser]:
|
) -> list[StudyMemberReadWithUser]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
@@ -56,12 +64,13 @@ async def list_members(
|
|||||||
users_map = await user_crud.get_users_by_ids(db, user_ids)
|
users_map = await user_crud.get_users_by_ids(db, user_ids)
|
||||||
result: list[StudyMemberReadWithUser] = []
|
result: list[StudyMemberReadWithUser] = []
|
||||||
for m in members:
|
for m in members:
|
||||||
# 仅返回项目内启用的成员 + 账号启用的用户
|
|
||||||
if not m.is_active:
|
|
||||||
continue
|
|
||||||
user = users_map.get(m.user_id)
|
user = users_map.get(m.user_id)
|
||||||
if not user or not user.is_active:
|
if not include_inactive:
|
||||||
continue
|
# 仅返回项目内启用的成员 + 账号启用的用户
|
||||||
|
if not m.is_active:
|
||||||
|
continue
|
||||||
|
if not user or not user.is_active:
|
||||||
|
continue
|
||||||
result.append(
|
result.append(
|
||||||
StudyMemberReadWithUser(
|
StudyMemberReadWithUser(
|
||||||
id=m.id,
|
id=m.id,
|
||||||
@@ -79,7 +88,7 @@ async def list_members(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/{member_id}",
|
"/{member_id}",
|
||||||
response_model=StudyMemberRead,
|
response_model=StudyMemberRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_member(
|
async def update_member(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -98,7 +107,7 @@ async def update_member(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{member_id}",
|
"/{member_id}",
|
||||||
response_model=StudyMemberRead,
|
response_model=StudyMemberRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def remove_member(
|
async def remove_member(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ 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_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import site as site_crud
|
from app.crud import site as site_crud
|
||||||
|
from app.crud import startup as startup_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.site import SiteCreate, SiteRead, SiteUpdate
|
from app.schemas.site import SiteCreate, SiteRead, SiteUpdate
|
||||||
|
from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -22,15 +24,28 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
"/",
|
"/",
|
||||||
response_model=SiteRead,
|
response_model=SiteRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_site(
|
async def create_site(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
site_in: SiteCreate,
|
site_in: SiteCreate,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
) -> SiteRead:
|
) -> SiteRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
site = await site_crud.create_site(db, study_id, site_in)
|
site = await site_crud.create_site(db, study_id, site_in)
|
||||||
|
await startup_crud.create_feasibility(
|
||||||
|
db,
|
||||||
|
study_id,
|
||||||
|
StartupFeasibilityCreate(site_id=site.id),
|
||||||
|
created_by=getattr(current_user, "id", None),
|
||||||
|
)
|
||||||
|
await startup_crud.create_ethics(
|
||||||
|
db,
|
||||||
|
study_id,
|
||||||
|
StartupEthicsCreate(site_id=site.id),
|
||||||
|
created_by=getattr(current_user, "id", None),
|
||||||
|
)
|
||||||
return site
|
return site
|
||||||
|
|
||||||
|
|
||||||
@@ -43,17 +58,24 @@ async def list_sites(
|
|||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
include_inactive: bool = False,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SiteRead]:
|
) -> list[SiteRead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
sites = await site_crud.list_by_study(db, study_id, skip=skip, limit=limit)
|
sites = await site_crud.list_by_study(
|
||||||
|
db,
|
||||||
|
study_id,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
)
|
||||||
return list(sites)
|
return list(sites)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{site_id}",
|
"/{site_id}",
|
||||||
response_model=SiteRead,
|
response_model=SiteRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_site(
|
async def update_site(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -67,3 +89,25 @@ async def update_site(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
|
||||||
updated = await site_crud.update_site(db, site, site_in)
|
updated = await site_crud.update_site(db, site, site_in)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{site_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_not_locked())],
|
||||||
|
)
|
||||||
|
async def delete_site(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
site_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||||
|
if role_value != "ADMIN":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除分中心")
|
||||||
|
site = await site_crud.get_site(db, site_id)
|
||||||
|
if not site or site.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
|
||||||
|
await site_crud.delete_site_and_related(db, site)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ 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_current_user, get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
from app.crud import startup as startup_crud
|
from app.crud import startup as startup_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.startup import (
|
from app.schemas.startup import (
|
||||||
@@ -32,6 +33,22 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
return study
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
|
||||||
|
if not site_name:
|
||||||
|
return
|
||||||
|
active_names = await site_crud.list_active_names(db, study_id)
|
||||||
|
if site_name not in active_names:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None):
|
||||||
|
if not site_id:
|
||||||
|
return
|
||||||
|
site = await site_crud.get_site(db, site_id)
|
||||||
|
if not site or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/feasibility",
|
"/feasibility",
|
||||||
response_model=StartupFeasibilityRead,
|
response_model=StartupFeasibilityRead,
|
||||||
@@ -45,6 +62,7 @@ async def create_feasibility(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> StartupFeasibilityRead:
|
) -> StartupFeasibilityRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
await _ensure_site_active(db, record_in.site_id)
|
||||||
record = await startup_crud.create_feasibility(db, study_id, record_in, created_by=current_user.id)
|
record = await startup_crud.create_feasibility(db, study_id, record_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -108,6 +126,7 @@ async def update_feasibility(
|
|||||||
record = await startup_crud.get_feasibility(db, record_id)
|
record = await startup_crud.get_feasibility(db, record_id)
|
||||||
if not record or record.study_id != study_id:
|
if not record or record.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
|
||||||
|
await _ensure_site_active(db, record.site_id)
|
||||||
record = await startup_crud.update_feasibility(db, record, record_in)
|
record = await startup_crud.update_feasibility(db, record, record_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -137,6 +156,7 @@ async def delete_feasibility(
|
|||||||
record = await startup_crud.get_feasibility(db, record_id)
|
record = await startup_crud.get_feasibility(db, record_id)
|
||||||
if not record or record.study_id != study_id:
|
if not record or record.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
|
||||||
|
await _ensure_site_active(db, record.site_id)
|
||||||
await startup_crud.delete_feasibility(db, record)
|
await startup_crud.delete_feasibility(db, record)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -163,6 +183,7 @@ async def create_ethics(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> StartupEthicsRead:
|
) -> StartupEthicsRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
await _ensure_site_active(db, record_in.site_id)
|
||||||
record = await startup_crud.create_ethics(db, study_id, record_in, created_by=current_user.id)
|
record = await startup_crud.create_ethics(db, study_id, record_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -226,6 +247,7 @@ async def update_ethics(
|
|||||||
record = await startup_crud.get_ethics(db, record_id)
|
record = await startup_crud.get_ethics(db, record_id)
|
||||||
if not record or record.study_id != study_id:
|
if not record or record.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
|
||||||
|
await _ensure_site_active(db, record.site_id)
|
||||||
record = await startup_crud.update_ethics(db, record, record_in)
|
record = await startup_crud.update_ethics(db, record, record_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -255,6 +277,7 @@ async def delete_ethics(
|
|||||||
record = await startup_crud.get_ethics(db, record_id)
|
record = await startup_crud.get_ethics(db, record_id)
|
||||||
if not record or record.study_id != study_id:
|
if not record or record.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
|
||||||
|
await _ensure_site_active(db, record.site_id)
|
||||||
await startup_crud.delete_ethics(db, record)
|
await startup_crud.delete_ethics(db, record)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -281,6 +304,7 @@ async def create_kickoff(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> KickoffMeetingRead:
|
) -> KickoffMeetingRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
await _ensure_site_active(db, meeting_in.site_id)
|
||||||
meeting = await startup_crud.create_kickoff(db, study_id, meeting_in, created_by=current_user.id)
|
meeting = await startup_crud.create_kickoff(db, study_id, meeting_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -344,6 +368,7 @@ async def update_kickoff(
|
|||||||
meeting = await startup_crud.get_kickoff(db, meeting_id)
|
meeting = await startup_crud.get_kickoff(db, meeting_id)
|
||||||
if not meeting or meeting.study_id != study_id:
|
if not meeting or meeting.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
|
||||||
|
await _ensure_site_active(db, meeting.site_id)
|
||||||
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
|
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -371,6 +396,7 @@ async def create_training_authorization(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> TrainingAuthorizationRead:
|
) -> TrainingAuthorizationRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
await _ensure_site_name_active(db, study_id, record_in.site_name)
|
||||||
record = await startup_crud.create_training_authorization(db, study_id, record_in, created_by=current_user.id)
|
record = await startup_crud.create_training_authorization(db, study_id, record_in, created_by=current_user.id)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -434,6 +460,7 @@ async def update_training_authorization(
|
|||||||
record = await startup_crud.get_training_authorization(db, record_id)
|
record = await startup_crud.get_training_authorization(db, record_id)
|
||||||
if not record or record.study_id != study_id:
|
if not record or record.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
|
||||||
|
await _ensure_site_name_active(db, study_id, record.site_name)
|
||||||
record = await startup_crud.update_training_authorization(db, record, record_in)
|
record = await startup_crud.update_training_authorization(db, record, record_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
@@ -463,6 +490,7 @@ async def delete_training_authorization(
|
|||||||
record = await startup_crud.get_training_authorization(db, record_id)
|
record = await startup_crud.get_training_authorization(db, record_id)
|
||||||
if not record or record.study_id != study_id:
|
if not record or record.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
|
||||||
|
await _ensure_site_name_active(db, study_id, record.site_name)
|
||||||
await startup_crud.delete_training_authorization(db, record)
|
await startup_crud.delete_training_authorization(db, record)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_roles
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
|
from app.crud import member as member_crud
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
from app.schemas.common import PaginatedResponse
|
from app.schemas.common import PaginatedResponse
|
||||||
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
|
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
|
||||||
|
from app.schemas.member import StudyMemberCreate
|
||||||
from app.utils.pagination import paginate
|
from app.utils.pagination import paginate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -22,6 +25,15 @@ async def create_study(
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||||||
study = await study_crud.create(db, study_in, created_by=current_user.id)
|
study = await study_crud.create(db, study_in, created_by=current_user.id)
|
||||||
|
|
||||||
|
# 自动将创建者添加为项目成员,角色为PM
|
||||||
|
member_in = StudyMemberCreate(
|
||||||
|
user_id=current_user.id,
|
||||||
|
role_in_study="PM",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
await member_crud.add_member(db, study.id, member_in)
|
||||||
|
|
||||||
return study
|
return study
|
||||||
|
|
||||||
|
|
||||||
@@ -65,5 +77,96 @@ async def update_study(
|
|||||||
study = await study_crud.get(db, study_id)
|
study = await study_crud.get(db, study_id)
|
||||||
if not study:
|
if not study:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||||
|
|
||||||
|
# 检查项目是否已锁定(除非是解锁操作)
|
||||||
|
if study.is_locked and not (study_in.is_locked is False):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="项目已锁定,无法编辑。请先解锁项目。"
|
||||||
|
)
|
||||||
|
|
||||||
updated = await study_crud.update(db, study, study_in)
|
updated = await study_crud.update(db, study, study_in)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{study_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_roles(["ADMIN"]))],
|
||||||
|
)
|
||||||
|
async def delete_study(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
"""硬删除项目及其所有关联数据(不可逆操作)"""
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||||
|
|
||||||
|
# 删除项目及其所有关联数据(包括审计日志)
|
||||||
|
await study_crud.delete(db, study_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{study_id}/lock",
|
||||||
|
response_model=StudyRead,
|
||||||
|
dependencies=[Depends(require_roles(["ADMIN"]))],
|
||||||
|
)
|
||||||
|
async def lock_study(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> StudyRead:
|
||||||
|
"""锁定项目(仅管理员)"""
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||||
|
|
||||||
|
locked_study = await study_crud.lock(db, study_id)
|
||||||
|
|
||||||
|
# 记录审计日志
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="study",
|
||||||
|
entity_id=study_id,
|
||||||
|
action="LOCK_STUDY",
|
||||||
|
detail=f"项目已锁定:{study.name} ({study.code})",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
return locked_study
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{study_id}/unlock",
|
||||||
|
response_model=StudyRead,
|
||||||
|
dependencies=[Depends(require_roles(["ADMIN"]))],
|
||||||
|
)
|
||||||
|
async def unlock_study(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> StudyRead:
|
||||||
|
"""解锁项目(仅管理员)"""
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||||
|
|
||||||
|
unlocked_study = await study_crud.unlock(db, study_id)
|
||||||
|
|
||||||
|
# 记录审计日志
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="study",
|
||||||
|
entity_id=study_id,
|
||||||
|
action="UNLOCK_STUDY",
|
||||||
|
detail=f"项目已解锁:{study.name} ({study.code})",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
return unlocked_study
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ 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_current_user, get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
|
from app.crud import subject as subject_crud
|
||||||
from app.crud import subject_history as history_crud
|
from app.crud import subject_history as history_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead, SubjectHistoryUpdate
|
from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead, SubjectHistoryUpdate
|
||||||
@@ -19,6 +21,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
return study
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
|
||||||
|
if not subject or not subject.site_id:
|
||||||
|
return
|
||||||
|
site = await site_crud.get_site(db, subject.site_id)
|
||||||
|
if not site or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/histories",
|
"/histories",
|
||||||
response_model=SubjectHistoryRead,
|
response_model=SubjectHistoryRead,
|
||||||
@@ -33,6 +43,10 @@ async def create_history(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> SubjectHistoryRead:
|
) -> SubjectHistoryRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
subject = await subject_crud.get_subject(db, subject_id)
|
||||||
|
if not subject or subject.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
|
||||||
|
await _ensure_subject_active(db, subject)
|
||||||
if history_in.subject_id != subject_id:
|
if history_in.subject_id != subject_id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
|
||||||
history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id)
|
history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id)
|
||||||
@@ -62,6 +76,9 @@ async def list_histories(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SubjectHistoryRead]:
|
) -> list[SubjectHistoryRead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
subject = await subject_crud.get_subject(db, subject_id)
|
||||||
|
if not subject or subject.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
|
||||||
items = await history_crud.list_histories(db, study_id, subject_id, skip=skip, limit=limit)
|
items = await history_crud.list_histories(db, study_id, subject_id, skip=skip, limit=limit)
|
||||||
return [SubjectHistoryRead.model_validate(item) for item in items]
|
return [SubjectHistoryRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
@@ -78,6 +95,9 @@ async def get_history(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> SubjectHistoryRead:
|
) -> SubjectHistoryRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
subject = await subject_crud.get_subject(db, subject_id)
|
||||||
|
if not subject or subject.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
|
||||||
history = await history_crud.get_history(db, history_id)
|
history = await history_crud.get_history(db, history_id)
|
||||||
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
|
||||||
@@ -98,6 +118,9 @@ async def update_history(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> SubjectHistoryRead:
|
) -> SubjectHistoryRead:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
subject = await subject_crud.get_subject(db, subject_id)
|
||||||
|
if not subject or subject.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
|
||||||
history = await history_crud.get_history(db, history_id)
|
history = await history_crud.get_history(db, history_id)
|
||||||
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
|
||||||
@@ -128,6 +151,9 @@ async def delete_history(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> None:
|
) -> None:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
|
subject = await subject_crud.get_subject(db, subject_id)
|
||||||
|
if not subject or subject.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
|
||||||
history = await history_crud.get_history(db, history_id)
|
history = await history_crud.get_history(db, history_id)
|
||||||
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ 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_current_user, get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
from app.crud import subject as subject_crud
|
from app.crud import subject as subject_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
|
from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
|
||||||
@@ -19,11 +20,19 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
return study
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
|
||||||
|
if not subject or not subject.site_id:
|
||||||
|
return
|
||||||
|
site = await site_crud.get_site(db, subject.site_id)
|
||||||
|
if not site or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/",
|
"/",
|
||||||
response_model=SubjectRead,
|
response_model=SubjectRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_subject(
|
async def create_subject(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -57,14 +66,10 @@ async def create_subject(
|
|||||||
async def list_subjects(
|
async def list_subjects(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
site_id: uuid.UUID | None = None,
|
site_id: uuid.UUID | None = None,
|
||||||
status_filter: str | None = None,
|
|
||||||
subject_no: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SubjectRead]:
|
) -> list[SubjectRead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
subjects = await subject_crud.list_subjects(
|
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id)
|
||||||
db, study_id, site_id=site_id, status=status_filter, subject_no=subject_no
|
|
||||||
)
|
|
||||||
return list(subjects)
|
return list(subjects)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,13 +87,14 @@ async def get_subject(
|
|||||||
subject = await subject_crud.get_subject(db, subject_id)
|
subject = await subject_crud.get_subject(db, subject_id)
|
||||||
if not subject or subject.study_id != study_id:
|
if not subject or subject.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
|
||||||
|
await _ensure_subject_active(db, subject)
|
||||||
return subject
|
return subject
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{subject_id}",
|
"/{subject_id}",
|
||||||
response_model=SubjectRead,
|
response_model=SubjectRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_subject(
|
async def update_subject(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -101,6 +107,7 @@ async def update_subject(
|
|||||||
subject = await subject_crud.get_subject(db, subject_id)
|
subject = await subject_crud.get_subject(db, subject_id)
|
||||||
if not subject or subject.study_id != study_id:
|
if not subject or subject.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
|
||||||
|
await _ensure_subject_active(db, subject)
|
||||||
old_status = subject.status
|
old_status = subject.status
|
||||||
updated = await subject_crud.update_subject(db, subject, subject_in)
|
updated = await subject_crud.update_subject(db, subject, subject_in)
|
||||||
# auto-generate visits when enrolled
|
# auto-generate visits when enrolled
|
||||||
@@ -125,7 +132,7 @@ async def update_subject(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{subject_id}",
|
"/{subject_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_subject(
|
async def delete_subject(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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_db_session, require_roles
|
from app.core.deps import get_db_session, require_roles
|
||||||
from app.core.security import verify_password
|
|
||||||
from app.schemas.common import PaginatedResponse
|
from app.schemas.common import PaginatedResponse
|
||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
@@ -70,13 +69,9 @@ async def update_user(
|
|||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
payload: dict,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(require_roles(["ADMIN"])),
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
):
|
):
|
||||||
admin_password = payload.get("admin_password")
|
|
||||||
if not admin_password or not verify_password(admin_password, current_user.password_hash):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员密码错误")
|
|
||||||
db_user = await user_crud.get_by_id(db, user_id)
|
db_user = await user_crud.get_by_id(db, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ 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_current_user, get_db_session, require_study_member, require_study_roles
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
from app.crud import subject as subject_crud
|
from app.crud import subject as subject_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.crud import visit as visit_crud
|
from app.crud import visit as visit_crud
|
||||||
@@ -20,6 +21,14 @@ async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uui
|
|||||||
return subject
|
return subject
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
|
||||||
|
if not subject or not subject.site_id:
|
||||||
|
return
|
||||||
|
site = await site_crud.get_site(db, subject.site_id)
|
||||||
|
if not site or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/",
|
"/",
|
||||||
response_model=list[VisitRead],
|
response_model=list[VisitRead],
|
||||||
@@ -39,7 +48,7 @@ async def list_visits(
|
|||||||
"/",
|
"/",
|
||||||
response_model=VisitRead,
|
response_model=VisitRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_visit(
|
async def create_visit(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -49,6 +58,7 @@ async def create_visit(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> VisitRead:
|
) -> VisitRead:
|
||||||
subject = await _ensure_subject(db, study_id, subject_id)
|
subject = await _ensure_subject(db, study_id, subject_id)
|
||||||
|
await _ensure_subject_active(db, subject)
|
||||||
if visit_in.subject_id != subject_id:
|
if visit_in.subject_id != subject_id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
|
||||||
visit = await visit_crud.create_visit(
|
visit = await visit_crud.create_visit(
|
||||||
@@ -88,7 +98,7 @@ async def create_visit(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/{visit_id}",
|
"/{visit_id}",
|
||||||
response_model=VisitRead,
|
response_model=VisitRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_visit(
|
async def update_visit(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -98,7 +108,8 @@ async def update_visit(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> VisitRead:
|
) -> VisitRead:
|
||||||
await _ensure_subject(db, study_id, subject_id)
|
subject = await _ensure_subject(db, study_id, subject_id)
|
||||||
|
await _ensure_subject_active(db, subject)
|
||||||
visit = await visit_crud.get_visit(db, visit_id)
|
visit = await visit_crud.get_visit(db, visit_id)
|
||||||
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
|
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
|
||||||
@@ -124,7 +135,7 @@ async def update_visit(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{visit_id}",
|
"/{visit_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_visit(
|
async def delete_visit(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -133,7 +144,8 @@ async def delete_visit(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> None:
|
) -> None:
|
||||||
await _ensure_subject(db, study_id, subject_id)
|
subject = await _ensure_subject(db, study_id, subject_id)
|
||||||
|
await _ensure_subject_active(db, subject)
|
||||||
visit = await visit_crud.get_visit(db, visit_id)
|
visit = await visit_crud.get_visit(db, visit_id)
|
||||||
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
|
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
|
||||||
|
|||||||
@@ -115,3 +115,27 @@ def require_study_roles(roles: Iterable[str]):
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def require_study_not_locked():
|
||||||
|
"""检查项目是否被锁定,如果锁定则拒绝所有修改请求"""
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
|
||||||
|
async def dependency(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="项目不存在",
|
||||||
|
)
|
||||||
|
if study.is_locked:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="项目已锁定,无法进行任何修改操作",
|
||||||
|
)
|
||||||
|
return study
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id:
|
|||||||
site = result.scalar_one_or_none()
|
site = result.scalar_one_or_none()
|
||||||
if not site or site.study_id != study_id:
|
if not site or site.study_id != study_id:
|
||||||
raise ValueError("分中心不属于当前项目")
|
raise ValueError("分中心不属于当前项目")
|
||||||
|
if not site.is_active:
|
||||||
|
raise ValueError("中心已停用")
|
||||||
if subject_id:
|
if subject_id:
|
||||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
||||||
subj = result.scalar_one_or_none()
|
subj = result.scalar_one_or_none()
|
||||||
if not subj or subj.study_id != study_id:
|
if not subj or subj.study_id != study_id:
|
||||||
raise ValueError("参与者不属于当前项目")
|
raise ValueError("参与者不属于当前项目")
|
||||||
|
result = await db.execute(select(Site).where(Site.id == subj.site_id))
|
||||||
|
subj_site = result.scalar_one_or_none()
|
||||||
|
if subj_site and not subj_site.is_active:
|
||||||
|
raise ValueError("中心已停用")
|
||||||
|
|
||||||
|
|
||||||
async def create_ae(
|
async def create_ae(
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ async def list_contracts(
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> Sequence[FinanceContract]:
|
) -> Sequence[FinanceContract]:
|
||||||
stmt = select(FinanceContract).where(FinanceContract.study_id == study_id)
|
stmt = (
|
||||||
|
select(FinanceContract)
|
||||||
|
.where(FinanceContract.study_id == study_id)
|
||||||
|
)
|
||||||
if site_name:
|
if site_name:
|
||||||
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
|
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
|
||||||
if contract_no:
|
if contract_no:
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ async def list_specials(
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> Sequence[FinanceSpecial]:
|
) -> Sequence[FinanceSpecial]:
|
||||||
stmt = select(FinanceSpecial).where(FinanceSpecial.study_id == study_id)
|
stmt = (
|
||||||
|
select(FinanceSpecial)
|
||||||
|
.where(FinanceSpecial.study_id == study_id)
|
||||||
|
)
|
||||||
if site_name:
|
if site_name:
|
||||||
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
|
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
|
||||||
if fee_type:
|
if fee_type:
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ async def list_notes(
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> Sequence[KnowledgeNote]:
|
) -> Sequence[KnowledgeNote]:
|
||||||
stmt = select(KnowledgeNote).where(KnowledgeNote.study_id == study_id)
|
stmt = (
|
||||||
|
select(KnowledgeNote)
|
||||||
|
.where(KnowledgeNote.study_id == study_id)
|
||||||
|
)
|
||||||
if site_name:
|
if site_name:
|
||||||
stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%"))
|
stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%"))
|
||||||
if keyword:
|
if keyword:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import delete, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.study_member import StudyMember
|
from app.models.study_member import StudyMember
|
||||||
@@ -54,13 +54,8 @@ async def update_member(db: AsyncSession, member: StudyMember, member_in: StudyM
|
|||||||
|
|
||||||
|
|
||||||
async def remove_member(db: AsyncSession, member: StudyMember) -> StudyMember:
|
async def remove_member(db: AsyncSession, member: StudyMember) -> StudyMember:
|
||||||
await db.execute(
|
await db.execute(delete(StudyMember).where(StudyMember.id == member.id))
|
||||||
update(StudyMember)
|
|
||||||
.where(StudyMember.id == member.id)
|
|
||||||
.values(is_active=False)
|
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(member)
|
|
||||||
return member
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+312
-6
@@ -1,12 +1,42 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import delete, or_, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.acknowledgement import Acknowledgement
|
||||||
|
from app.models.ae import AdverseEvent
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.contract_fee import ContractFee
|
||||||
|
from app.models.contract_fee_payment import ContractFeePayment
|
||||||
|
from app.models.document import Document
|
||||||
|
from app.models.document_version import DocumentVersion
|
||||||
|
from app.models.distribution import Distribution
|
||||||
|
from app.models.drug_shipment import DrugShipment
|
||||||
|
from app.models.fee_attachment import FeeAttachment
|
||||||
|
from app.models.finance import FinanceItem
|
||||||
|
from app.models.finance_contract import FinanceContract
|
||||||
|
from app.models.finance_special import FinanceSpecial
|
||||||
|
from app.models.kickoff_meeting import KickoffMeeting
|
||||||
|
from app.models.knowledge_note import KnowledgeNote
|
||||||
|
from app.models.milestone import Milestone
|
||||||
from app.models.site import Site
|
from app.models.site import Site
|
||||||
|
from app.models.special_expense import SpecialExpense
|
||||||
|
from app.models.startup_ethics import StartupEthics
|
||||||
|
from app.models.startup_feasibility import StartupFeasibility
|
||||||
|
from app.models.subject import Subject
|
||||||
|
from app.models.subject_history import SubjectHistory
|
||||||
|
from app.models.training_authorization import TrainingAuthorization
|
||||||
|
from app.models.version_workflow import VersionWorkflow
|
||||||
|
from app.models.visit import Visit
|
||||||
|
from app.models.workflow_action import WorkflowAction
|
||||||
from app.schemas.site import SiteCreate, SiteUpdate
|
from app.schemas.site import SiteCreate, SiteUpdate
|
||||||
|
|
||||||
|
ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads"
|
||||||
|
FEE_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "fees"
|
||||||
|
DOCUMENT_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "documents"
|
||||||
|
|
||||||
|
|
||||||
async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate) -> Site:
|
async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate) -> Site:
|
||||||
site = Site(
|
site = Site(
|
||||||
@@ -41,10 +71,17 @@ async def update_site(db: AsyncSession, site: Site, site_in: SiteUpdate) -> Site
|
|||||||
return site
|
return site
|
||||||
|
|
||||||
|
|
||||||
async def list_by_study(db: AsyncSession, study_id: uuid.UUID, skip: int = 0, limit: int = 100) -> Sequence[Site]:
|
async def list_by_study(
|
||||||
result = await db.execute(
|
db: AsyncSession,
|
||||||
select(Site).where(Site.study_id == study_id).offset(skip).limit(limit)
|
study_id: uuid.UUID,
|
||||||
)
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
) -> Sequence[Site]:
|
||||||
|
stmt = select(Site).where(Site.study_id == study_id)
|
||||||
|
if not include_inactive:
|
||||||
|
stmt = stmt.where(Site.is_active.is_(True))
|
||||||
|
result = await db.execute(stmt.offset(skip).limit(limit))
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
@@ -54,3 +91,272 @@ async def get_sites_by_ids(db: AsyncSession, ids: set[uuid.UUID]) -> dict[uuid.U
|
|||||||
result = await db.execute(select(Site).where(Site.id.in_(ids)))
|
result = await db.execute(select(Site).where(Site.id.in_(ids)))
|
||||||
sites = result.scalars().all()
|
sites = result.scalars().all()
|
||||||
return {s.id: s for s in sites}
|
return {s.id: s for s in sites}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_active_names(db: AsyncSession, study_id: uuid.UUID) -> set[str]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Site.name).where(Site.study_id == study_id, Site.is_active.is_(True))
|
||||||
|
)
|
||||||
|
return {row[0] for row in result.all() if row[0]}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
||||||
|
site_id = site.id
|
||||||
|
study_id = site.study_id
|
||||||
|
site_name = site.name
|
||||||
|
|
||||||
|
subject_ids = (
|
||||||
|
await db.execute(select(Subject.id).where(Subject.site_id == site_id))
|
||||||
|
).scalars().all()
|
||||||
|
contract_fee_ids = (
|
||||||
|
await db.execute(select(ContractFee.id).where(ContractFee.center_id == site_id))
|
||||||
|
).scalars().all()
|
||||||
|
special_expense_ids = (
|
||||||
|
await db.execute(select(SpecialExpense.id).where(SpecialExpense.center_id == site_id))
|
||||||
|
).scalars().all()
|
||||||
|
drug_shipment_ids = (
|
||||||
|
await db.execute(select(DrugShipment.id).where(DrugShipment.center_id == site_id))
|
||||||
|
).scalars().all()
|
||||||
|
kickoff_ids = (
|
||||||
|
await db.execute(select(KickoffMeeting.id).where(KickoffMeeting.site_id == site_id))
|
||||||
|
).scalars().all()
|
||||||
|
feasibility_ids = (
|
||||||
|
await db.execute(select(StartupFeasibility.id).where(StartupFeasibility.site_id == site_id))
|
||||||
|
).scalars().all()
|
||||||
|
ethics_ids = (
|
||||||
|
await db.execute(select(StartupEthics.id).where(StartupEthics.site_id == site_id))
|
||||||
|
).scalars().all()
|
||||||
|
training_ids = (
|
||||||
|
await db.execute(
|
||||||
|
select(TrainingAuthorization.id).where(
|
||||||
|
TrainingAuthorization.study_id == study_id,
|
||||||
|
TrainingAuthorization.site_name == site_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
finance_contract_ids = (
|
||||||
|
await db.execute(
|
||||||
|
select(FinanceContract.id).where(
|
||||||
|
FinanceContract.study_id == study_id,
|
||||||
|
FinanceContract.site_name == site_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
finance_special_ids = (
|
||||||
|
await db.execute(
|
||||||
|
select(FinanceSpecial.id).where(
|
||||||
|
FinanceSpecial.study_id == study_id,
|
||||||
|
FinanceSpecial.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()
|
||||||
|
document_ids = (
|
||||||
|
await db.execute(select(Document.id).where(Document.site_id == site_id))
|
||||||
|
).scalars().all()
|
||||||
|
version_ids = []
|
||||||
|
if document_ids:
|
||||||
|
version_ids = (
|
||||||
|
await db.execute(select(DocumentVersion.id).where(DocumentVersion.document_id.in_(document_ids)))
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
workflow_ids = []
|
||||||
|
workflow_conditions = []
|
||||||
|
if document_ids:
|
||||||
|
workflow_conditions.append(VersionWorkflow.document_id.in_(document_ids))
|
||||||
|
if version_ids:
|
||||||
|
workflow_conditions.append(VersionWorkflow.version_id.in_(version_ids))
|
||||||
|
if workflow_conditions:
|
||||||
|
workflow_ids = (
|
||||||
|
await db.execute(select(VersionWorkflow.id).where(or_(*workflow_conditions)))
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
distribution_conditions = []
|
||||||
|
if document_ids:
|
||||||
|
distribution_conditions.append(Distribution.document_id.in_(document_ids))
|
||||||
|
if version_ids:
|
||||||
|
distribution_conditions.append(Distribution.version_id.in_(version_ids))
|
||||||
|
distribution_conditions.append((Distribution.target_type == "SITE") & (Distribution.target_id == str(site_id)))
|
||||||
|
distribution_ids = (
|
||||||
|
await db.execute(select(Distribution.id).where(or_(*distribution_conditions)))
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
attachment_groups: dict[str, list[uuid.UUID]] = {
|
||||||
|
"startup_feasibility": feasibility_ids,
|
||||||
|
"startup_ethics": ethics_ids,
|
||||||
|
"startup_kickoff": kickoff_ids,
|
||||||
|
"startup_kickoff_minutes": kickoff_ids,
|
||||||
|
"startup_kickoff_signin": kickoff_ids,
|
||||||
|
"startup_kickoff_ppt": kickoff_ids,
|
||||||
|
"training_authorization": training_ids,
|
||||||
|
"finance_contract": finance_contract_ids,
|
||||||
|
"finance_special": finance_special_ids,
|
||||||
|
"knowledge_note": knowledge_note_ids,
|
||||||
|
"drug_shipment": drug_shipment_ids,
|
||||||
|
}
|
||||||
|
attachment_paths: list[str] = []
|
||||||
|
for entity_type, ids in attachment_groups.items():
|
||||||
|
if not ids:
|
||||||
|
continue
|
||||||
|
attachment_paths.extend(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Attachment.file_path).where(
|
||||||
|
Attachment.study_id == study_id,
|
||||||
|
Attachment.entity_type == entity_type,
|
||||||
|
Attachment.entity_id.in_(ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(Attachment).where(
|
||||||
|
Attachment.study_id == study_id,
|
||||||
|
Attachment.entity_type == entity_type,
|
||||||
|
Attachment.entity_id.in_(ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fee_attachment_paths: list[str] = []
|
||||||
|
if contract_fee_ids:
|
||||||
|
fee_attachment_paths.extend(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(FeeAttachment.storage_key).where(
|
||||||
|
FeeAttachment.entity_type == "contract_fee",
|
||||||
|
FeeAttachment.entity_id.in_(contract_fee_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(FeeAttachment).where(
|
||||||
|
FeeAttachment.entity_type == "contract_fee",
|
||||||
|
FeeAttachment.entity_id.in_(contract_fee_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
|
||||||
|
if special_expense_ids:
|
||||||
|
fee_attachment_paths.extend(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(FeeAttachment.storage_key).where(
|
||||||
|
FeeAttachment.entity_type == "special_expense",
|
||||||
|
FeeAttachment.entity_id.in_(special_expense_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(FeeAttachment).where(
|
||||||
|
FeeAttachment.entity_type == "special_expense",
|
||||||
|
FeeAttachment.entity_id.in_(special_expense_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if workflow_ids:
|
||||||
|
await db.execute(delete(WorkflowAction).where(WorkflowAction.workflow_id.in_(workflow_ids)))
|
||||||
|
await db.execute(delete(VersionWorkflow).where(VersionWorkflow.id.in_(workflow_ids)))
|
||||||
|
if distribution_ids:
|
||||||
|
await db.execute(delete(Acknowledgement).where(Acknowledgement.distribution_id.in_(distribution_ids)))
|
||||||
|
await db.execute(delete(Distribution).where(Distribution.id.in_(distribution_ids)))
|
||||||
|
await db.execute(delete(Acknowledgement).where(Acknowledgement.site_id == site_id))
|
||||||
|
version_file_paths: list[str] = []
|
||||||
|
if version_ids:
|
||||||
|
version_file_paths = (
|
||||||
|
await db.execute(select(DocumentVersion.file_uri).where(DocumentVersion.id.in_(version_ids)))
|
||||||
|
).scalars().all()
|
||||||
|
await db.execute(delete(DocumentVersion).where(DocumentVersion.id.in_(version_ids)))
|
||||||
|
if document_ids:
|
||||||
|
await db.execute(delete(Document).where(Document.id.in_(document_ids)))
|
||||||
|
|
||||||
|
if subject_ids:
|
||||||
|
await db.execute(delete(SubjectHistory).where(SubjectHistory.subject_id.in_(subject_ids)))
|
||||||
|
await db.execute(delete(Visit).where(Visit.subject_id.in_(subject_ids)))
|
||||||
|
await db.execute(delete(AdverseEvent).where(AdverseEvent.subject_id.in_(subject_ids)))
|
||||||
|
await db.execute(delete(FinanceItem).where(FinanceItem.subject_id.in_(subject_ids)))
|
||||||
|
|
||||||
|
await db.execute(delete(AdverseEvent).where(AdverseEvent.site_id == site_id))
|
||||||
|
await db.execute(delete(FinanceItem).where(FinanceItem.site_id == site_id))
|
||||||
|
await db.execute(delete(Subject).where(Subject.site_id == site_id))
|
||||||
|
|
||||||
|
await db.execute(delete(Milestone).where(Milestone.site_id == site_id))
|
||||||
|
await db.execute(delete(KickoffMeeting).where(KickoffMeeting.site_id == site_id))
|
||||||
|
await db.execute(delete(StartupFeasibility).where(StartupFeasibility.site_id == site_id))
|
||||||
|
await db.execute(delete(StartupEthics).where(StartupEthics.site_id == site_id))
|
||||||
|
|
||||||
|
await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id))
|
||||||
|
await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id))
|
||||||
|
await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id))
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
delete(TrainingAuthorization).where(
|
||||||
|
TrainingAuthorization.study_id == study_id,
|
||||||
|
TrainingAuthorization.site_name == site_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(FinanceContract).where(
|
||||||
|
FinanceContract.study_id == study_id,
|
||||||
|
FinanceContract.site_name == site_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(FinanceSpecial).where(
|
||||||
|
FinanceSpecial.study_id == study_id,
|
||||||
|
FinanceSpecial.site_name == site_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(KnowledgeNote).where(
|
||||||
|
KnowledgeNote.study_id == study_id,
|
||||||
|
KnowledgeNote.site_name == site_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(delete(Site).where(Site.id == site_id))
|
||||||
|
await _remove_files(attachment_paths, ATTACHMENT_ROOT)
|
||||||
|
await _remove_files(fee_attachment_paths, FEE_ATTACHMENT_ROOT)
|
||||||
|
await _remove_files(version_file_paths, DOCUMENT_ATTACHMENT_ROOT)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _remove_files(paths: Iterable[str | None], root: Path) -> None:
|
||||||
|
for raw in paths:
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
file_path = Path(str(raw))
|
||||||
|
try:
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_prune_empty_parents(file_path.parent, root)
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_empty_parents(start: Path, root: Path) -> None:
|
||||||
|
try:
|
||||||
|
root_resolved = root.resolve()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
current = start.resolve()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if root_resolved not in current.parents and current != root_resolved:
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
if current == root_resolved:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
current.rmdir()
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
current = current.parent
|
||||||
|
|||||||
+132
-3
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
from sqlalchemy import select, update as sa_update
|
from sqlalchemy import delete as sa_delete, select, update as sa_update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.study import Study
|
from app.models.study import Study
|
||||||
@@ -9,10 +9,22 @@ from app.schemas.study import StudyCreate, StudyUpdate
|
|||||||
|
|
||||||
|
|
||||||
async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study:
|
async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study:
|
||||||
|
import uuid as uuid_lib # Import uuid to generate unique code if needed
|
||||||
|
|
||||||
|
code = study_in.code
|
||||||
|
if not code:
|
||||||
|
# Generate a unique code if not provided
|
||||||
|
# Use first 8 chars of a new UUID, uppercase
|
||||||
|
code = f"P-{str(uuid_lib.uuid4())[:8].upper()}"
|
||||||
|
|
||||||
study = Study(
|
study = Study(
|
||||||
code=study_in.code,
|
code=code,
|
||||||
name=study_in.name,
|
name=study_in.name,
|
||||||
sponsor=study_in.sponsor,
|
# sponsor is removed from schema, but model has it. Set to None/empty or keep existing value if schema had it (which it doesn't now)
|
||||||
|
# If we removed it from schema, study_in.sponsor will fail if we try to access it via .sponsor attribute directly if it's not in the pydantic model anymore.
|
||||||
|
# Since we removed `sponsor` field from StudyCreate, `study_in` object won't have `sponsor` attribute unless we access dict or use default.
|
||||||
|
# However, the Study MODEL still has sponsor. We should set it to None.
|
||||||
|
sponsor=None,
|
||||||
protocol_no=study_in.protocol_no,
|
protocol_no=study_in.protocol_no,
|
||||||
phase=study_in.phase,
|
phase=study_in.phase,
|
||||||
status=study_in.status,
|
status=study_in.status,
|
||||||
@@ -73,3 +85,120 @@ async def list_studies_for_user(
|
|||||||
)
|
)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
||||||
|
"""硬删除项目及其所有关联数据"""
|
||||||
|
# 导入所有关联的模型
|
||||||
|
from app.models.study_member import StudyMember
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.site import Site
|
||||||
|
from app.models.subject import Subject
|
||||||
|
from app.models.subject_history import SubjectHistory
|
||||||
|
from app.models.visit import Visit
|
||||||
|
from app.models.ae import AdverseEvent
|
||||||
|
from app.models.finance import FinanceItem
|
||||||
|
from app.models.finance_contract import FinanceContract
|
||||||
|
from app.models.finance_special import FinanceSpecial
|
||||||
|
from app.models.contract_fee import ContractFee
|
||||||
|
from app.models.special_expense import SpecialExpense
|
||||||
|
from app.models.milestone import Milestone
|
||||||
|
from app.models.document import Document
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.drug_shipment import DrugShipment
|
||||||
|
from app.models.training_authorization import TrainingAuthorization
|
||||||
|
from app.models.startup_feasibility import StartupFeasibility
|
||||||
|
from app.models.startup_ethics import StartupEthics
|
||||||
|
from app.models.kickoff_meeting import KickoffMeeting
|
||||||
|
from app.models.knowledge_note import KnowledgeNote
|
||||||
|
from app.models.faq_category import FaqCategory
|
||||||
|
from app.models.faq_item import FaqItem
|
||||||
|
from app.models.faq_reply import FaqReply
|
||||||
|
from app.models.workflow_template import WorkflowTemplate
|
||||||
|
|
||||||
|
# 按依赖关系顺序删除关联数据
|
||||||
|
# 1. 删除审计日志
|
||||||
|
await db.execute(sa_delete(AuditLog).where(AuditLog.study_id == study_id))
|
||||||
|
|
||||||
|
# 2. 删除FAQ相关(可能有依赖关系)
|
||||||
|
await db.execute(sa_delete(FaqReply).where(FaqReply.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))
|
||||||
|
|
||||||
|
# 3. 删除知识库
|
||||||
|
await db.execute(sa_delete(KnowledgeNote).where(KnowledgeNote.study_id == study_id))
|
||||||
|
|
||||||
|
# 4. 删除工作流模板
|
||||||
|
await db.execute(sa_delete(WorkflowTemplate).where(WorkflowTemplate.trial_id == study_id))
|
||||||
|
|
||||||
|
# 5. 删除启动相关
|
||||||
|
await db.execute(sa_delete(KickoffMeeting).where(KickoffMeeting.study_id == study_id))
|
||||||
|
await db.execute(sa_delete(StartupEthics).where(StartupEthics.study_id == study_id))
|
||||||
|
await db.execute(sa_delete(StartupFeasibility).where(StartupFeasibility.study_id == study_id))
|
||||||
|
await db.execute(sa_delete(TrainingAuthorization).where(TrainingAuthorization.study_id == study_id))
|
||||||
|
|
||||||
|
# 6. 删除药物配送
|
||||||
|
await db.execute(sa_delete(DrugShipment).where(DrugShipment.study_id == study_id))
|
||||||
|
|
||||||
|
# 7. 删除附件和文档
|
||||||
|
await db.execute(sa_delete(Attachment).where(Attachment.study_id == study_id))
|
||||||
|
await db.execute(sa_delete(Document).where(Document.trial_id == study_id))
|
||||||
|
|
||||||
|
# 8. 删除里程碑
|
||||||
|
await db.execute(sa_delete(Milestone).where(Milestone.study_id == study_id))
|
||||||
|
|
||||||
|
# 9. 删除财务相关
|
||||||
|
await db.execute(sa_delete(SpecialExpense).where(SpecialExpense.project_id == study_id))
|
||||||
|
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
|
||||||
|
await db.execute(sa_delete(FinanceSpecial).where(FinanceSpecial.study_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))
|
||||||
|
|
||||||
|
# 10. 删除不良事件
|
||||||
|
await db.execute(sa_delete(AdverseEvent).where(AdverseEvent.study_id == study_id))
|
||||||
|
|
||||||
|
# 11. 删除访视
|
||||||
|
await db.execute(sa_delete(Visit).where(Visit.study_id == study_id))
|
||||||
|
|
||||||
|
# 12. 删除受试者相关
|
||||||
|
await db.execute(sa_delete(SubjectHistory).where(SubjectHistory.study_id == study_id))
|
||||||
|
await db.execute(sa_delete(Subject).where(Subject.study_id == study_id))
|
||||||
|
|
||||||
|
# 13. 删除站点
|
||||||
|
await db.execute(sa_delete(Site).where(Site.study_id == study_id))
|
||||||
|
|
||||||
|
# 14. 删除成员
|
||||||
|
await db.execute(sa_delete(StudyMember).where(StudyMember.study_id == study_id))
|
||||||
|
|
||||||
|
# 15. 最后删除项目本身
|
||||||
|
await db.execute(sa_delete(Study).where(Study.id == study_id))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def lock(db: AsyncSession, study_id: uuid.UUID) -> Study | None:
|
||||||
|
"""锁定项目"""
|
||||||
|
await db.execute(
|
||||||
|
sa_update(Study)
|
||||||
|
.where(Study.id == study_id)
|
||||||
|
.values(is_locked=True)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get(db, study_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def unlock(db: AsyncSession, study_id: uuid.UUID) -> Study | None:
|
||||||
|
"""解锁项目"""
|
||||||
|
await db.execute(
|
||||||
|
sa_update(Study)
|
||||||
|
.where(Study.id == study_id)
|
||||||
|
.values(is_locked=False)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get(db, study_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def is_locked(db: AsyncSession, study_id: uuid.UUID) -> bool:
|
||||||
|
"""检查项目是否已锁定"""
|
||||||
|
study = await get(db, study_id)
|
||||||
|
return study.is_locked if study else False
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UU
|
|||||||
site = result.scalar_one_or_none()
|
site = result.scalar_one_or_none()
|
||||||
if not site or site.study_id != study_id:
|
if not site or site.study_id != study_id:
|
||||||
raise ValueError("分中心不属于当前项目")
|
raise ValueError("分中心不属于当前项目")
|
||||||
|
if not site.is_active:
|
||||||
|
raise ValueError("中心已停用")
|
||||||
|
|
||||||
|
|
||||||
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
|
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
|
||||||
@@ -58,16 +60,10 @@ async def list_subjects(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
site_id: uuid.UUID | None = None,
|
site_id: uuid.UUID | None = None,
|
||||||
status: str | None = None,
|
|
||||||
subject_no: str | None = None,
|
|
||||||
) -> Sequence[Subject]:
|
) -> Sequence[Subject]:
|
||||||
stmt = select(Subject).where(Subject.study_id == study_id)
|
stmt = select(Subject).where(Subject.study_id == study_id)
|
||||||
if site_id:
|
if site_id:
|
||||||
stmt = stmt.where(Subject.site_id == site_id)
|
stmt = stmt.where(Subject.site_id == site_id)
|
||||||
if status:
|
|
||||||
stmt = stmt.where(Subject.status == status)
|
|
||||||
if subject_no:
|
|
||||||
stmt = stmt.where(Subject.subject_no.ilike(f"%{subject_no}%"))
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ class Study(Base):
|
|||||||
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
|
||||||
|
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
visit_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
visit_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
visit_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
visit_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
visit_window_start_offset: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
visit_window_start_offset: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"]
|
|||||||
|
|
||||||
|
|
||||||
class StudyCreate(BaseModel):
|
class StudyCreate(BaseModel):
|
||||||
code: str = Field(min_length=1)
|
|
||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
sponsor: Optional[str] = None
|
code: Optional[str] = None
|
||||||
protocol_no: Optional[str] = None
|
protocol_no: Optional[str] = None
|
||||||
phase: Optional[str] = None
|
phase: Optional[str] = None
|
||||||
status: StudyStatus = "DRAFT"
|
status: StudyStatus = "DRAFT"
|
||||||
@@ -21,12 +20,11 @@ class StudyCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class StudyUpdate(BaseModel):
|
class StudyUpdate(BaseModel):
|
||||||
code: Optional[str] = None
|
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
sponsor: Optional[str] = None
|
|
||||||
protocol_no: Optional[str] = None
|
protocol_no: Optional[str] = None
|
||||||
phase: Optional[str] = None
|
phase: Optional[str] = None
|
||||||
status: Optional[StudyStatus] = None
|
status: Optional[StudyStatus] = None
|
||||||
|
is_locked: Optional[bool] = None
|
||||||
visit_interval_days: Optional[int] = None
|
visit_interval_days: Optional[int] = None
|
||||||
visit_total: Optional[int] = None
|
visit_total: Optional[int] = None
|
||||||
visit_window_start_offset: Optional[int] = None
|
visit_window_start_offset: Optional[int] = None
|
||||||
@@ -35,12 +33,12 @@ class StudyUpdate(BaseModel):
|
|||||||
|
|
||||||
class StudyRead(BaseModel):
|
class StudyRead(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
code: str
|
|
||||||
name: str
|
name: str
|
||||||
sponsor: Optional[str]
|
protocol_no: Optional[str]
|
||||||
protocol_no: Optional[str]
|
protocol_no: Optional[str]
|
||||||
phase: Optional[str]
|
phase: Optional[str]
|
||||||
status: StudyStatus
|
status: StudyStatus
|
||||||
|
is_locked: bool
|
||||||
visit_interval_days: Optional[int]
|
visit_interval_days: Optional[int]
|
||||||
visit_total: Optional[int]
|
visit_total: Optional[int]
|
||||||
visit_window_start_offset: Optional[int]
|
visit_window_start_offset: Optional[int]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.crud import distribution as distribution_crud
|
|||||||
from app.crud import document as document_crud
|
from app.crud import document as document_crud
|
||||||
from app.crud import document_version as version_crud
|
from app.crud import document_version as version_crud
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
|
from app.crud import site as site_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
from app.crud import version_workflow as workflow_crud
|
from app.crud import version_workflow as workflow_crud
|
||||||
@@ -91,6 +92,10 @@ async def create_document(
|
|||||||
if doc_in.scope_type == DocumentScopeType.SITE and not doc_in.site_id:
|
if doc_in.scope_type == DocumentScopeType.SITE and not doc_in.site_id:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分中心文件需指定分中心")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分中心文件需指定分中心")
|
||||||
site_id = doc_in.site_id if doc_in.scope_type == DocumentScopeType.SITE else None
|
site_id = doc_in.site_id if doc_in.scope_type == DocumentScopeType.SITE else None
|
||||||
|
if site_id:
|
||||||
|
site = await site_crud.get_site(db, site_id)
|
||||||
|
if not site or not site.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心已停用")
|
||||||
existing = await document_crud.get_by_trial_doc_no(db, doc_in.trial_id, doc_in.doc_no)
|
existing = await document_crud.get_by_trial_doc_no(db, doc_in.trial_id, doc_in.doc_no)
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="文档编号已存在")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="文档编号已存在")
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
import type { ApiListResponse, Site } from "../types/api";
|
import type { ApiListResponse, Site } from "../types/api";
|
||||||
|
|
||||||
export const fetchSites = (studyId: string, params?: Record<string, any>) =>
|
export const fetchSites = (studyId: string, params?: Record<string, any>) =>
|
||||||
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, { params });
|
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, {
|
||||||
|
params: { include_inactive: true, ...(params || {}) },
|
||||||
|
});
|
||||||
|
|
||||||
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
|
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
|
||||||
apiPost<Site>(`/api/v1/studies/${studyId}/sites/`, payload);
|
apiPost<Site>(`/api/v1/studies/${studyId}/sites/`, payload);
|
||||||
|
|
||||||
export const updateSite = (studyId: string, siteId: string, payload: Partial<Site>) =>
|
export const updateSite = (studyId: string, siteId: string, payload: Partial<Site>) =>
|
||||||
apiPatch<Site>(`/api/v1/studies/${studyId}/sites/${siteId}`, payload);
|
apiPatch<Site>(`/api/v1/studies/${studyId}/sites/${siteId}`, payload);
|
||||||
|
|
||||||
|
export const deleteSite = (studyId: string, siteId: string) =>
|
||||||
|
apiDelete<void>(`/api/v1/studies/${studyId}/sites/${siteId}`);
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
import { apiGet, apiPatch, apiPost, apiDelete } from "./axios";
|
||||||
import type { ApiListResponse, Study } from "../types/api";
|
import type { ApiListResponse, Study } from "../types/api";
|
||||||
|
|
||||||
// Backend expects trailing slash to avoid 307 redirect
|
// Backend expects trailing slash to avoid 307 redirect
|
||||||
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
|
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
|
||||||
|
|
||||||
export const createStudy = (payload: Partial<Study> & { code: string; name: string }) =>
|
export const createStudy = (payload: Partial<Study> & { name: string }) =>
|
||||||
apiPost<Study>("/api/v1/studies/", payload);
|
apiPost<Study>("/api/v1/studies/", payload);
|
||||||
|
|
||||||
export const updateStudy = (studyId: string, payload: Partial<Study>) =>
|
export const updateStudy = (studyId: string, payload: Partial<Study>) =>
|
||||||
apiPatch<Study>(`/api/v1/studies/${studyId}`, payload);
|
apiPatch<Study>(`/api/v1/studies/${studyId}`, payload);
|
||||||
|
|
||||||
export const fetchStudyDetail = (studyId: string) => apiGet<Study>(`/api/v1/studies/${studyId}`);
|
export const fetchStudyDetail = (studyId: string) => apiGet<Study>(`/api/v1/studies/${studyId}`);
|
||||||
|
|
||||||
|
export const deleteStudy = (studyId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}`);
|
||||||
|
|
||||||
|
export const lockStudy = (studyId: string) =>
|
||||||
|
apiPatch<Study>(`/api/v1/studies/${studyId}/lock`, {});
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,5 @@ export const updateUser = (
|
|||||||
) =>
|
) =>
|
||||||
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
|
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
|
||||||
|
|
||||||
export const deleteUser = (userId: string, payload: { admin_password: string }) =>
|
export const deleteUser = (userId: string) =>
|
||||||
apiDelete<void>(`/api/v1/users/${userId}`, { data: payload });
|
apiDelete<void>(`/api/v1/users/${userId}`);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<span>{{ headerTitle }}</span>
|
<span>{{ headerTitle }}</span>
|
||||||
<AttachmentUploader
|
<AttachmentUploader
|
||||||
|
v-if="!readonly"
|
||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
:entity-type="entityType"
|
:entity-type="entityType"
|
||||||
:entity-id="entityId"
|
:entity-id="entityId"
|
||||||
@@ -75,6 +76,7 @@ const props = defineProps<{
|
|||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
readonly?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const attachments = ref<any[]>([]);
|
const attachments = ref<any[]>([]);
|
||||||
@@ -199,6 +201,7 @@ const preview = (row: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canDelete = (row: any) => {
|
const canDelete = (row: any) => {
|
||||||
|
if (props.readonly) return false;
|
||||||
const userId = auth.user?.id;
|
const userId = auth.user?.id;
|
||||||
const role = auth.user?.role;
|
const role = auth.user?.role;
|
||||||
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
|
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
|
||||||
|
|||||||
@@ -716,10 +716,10 @@ export const TEXT = {
|
|||||||
loadFailed: "用户列表加载失败",
|
loadFailed: "用户列表加载失败",
|
||||||
updateSuccess: "用户已更新",
|
updateSuccess: "用户已更新",
|
||||||
createSuccess: "用户已创建",
|
createSuccess: "用户已创建",
|
||||||
deleteConfirm: "删除后账号将无法登录且无法恢复,需先在各项目成员配置中移除该账号。请输入当前管理员密码确认删除。",
|
deleteConfirm: "删除后不可恢复,请输入“确认删除”继续。",
|
||||||
deleteTitle: "危险操作:删除账号",
|
deleteTitle: "危险操作:删除账号",
|
||||||
deletePasswordPlaceholder: "请输入 admin 密码",
|
deleteConfirmPlaceholder: "确认删除",
|
||||||
deleteConfirmButton: "确认删除",
|
deleteConfirmInputError: "请输入“确认删除”",
|
||||||
deleteSuccess: "账号已删除",
|
deleteSuccess: "账号已删除",
|
||||||
deleteFailed: "删除失败",
|
deleteFailed: "删除失败",
|
||||||
enableConfirm: "确认启用该用户账号?",
|
enableConfirm: "确认启用该用户账号?",
|
||||||
@@ -752,7 +752,7 @@ export const TEXT = {
|
|||||||
resetFailed: "重置失败",
|
resetFailed: "重置失败",
|
||||||
},
|
},
|
||||||
adminProjects: {
|
adminProjects: {
|
||||||
title: "项目治理",
|
title: "项目管理",
|
||||||
subtitle: "项目是独立业务实体,账号加入项目后才成为项目成员(角色在成员配置中设置)",
|
subtitle: "项目是独立业务实体,账号加入项目后才成为项目成员(角色在成员配置中设置)",
|
||||||
projectLabel: "项目",
|
projectLabel: "项目",
|
||||||
members: "项目账号/成员配置",
|
members: "项目账号/成员配置",
|
||||||
@@ -827,6 +827,7 @@ export const TEXT = {
|
|||||||
craSaveSuccess: "绑定已保存",
|
craSaveSuccess: "绑定已保存",
|
||||||
},
|
},
|
||||||
adminAuditLogs: {
|
adminAuditLogs: {
|
||||||
|
title: "审计日志",
|
||||||
filterEvent: "操作类型",
|
filterEvent: "操作类型",
|
||||||
filterOperator: "操作人",
|
filterOperator: "操作人",
|
||||||
filterResult: "结果",
|
filterResult: "结果",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface Study {
|
|||||||
protocol_no?: string | null;
|
protocol_no?: string | null;
|
||||||
phase?: string | null;
|
phase?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
is_locked?: boolean;
|
||||||
visit_interval_days?: number | null;
|
visit_interval_days?: number | null;
|
||||||
visit_total?: number | null;
|
visit_total?: number | null;
|
||||||
visit_window_start_offset?: number | null;
|
visit_window_start_offset?: number | null;
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-card>
|
<el-card>
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h3>{{ TEXT.modules.adminAuditLogs.title }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="ctms-table-toolbar">
|
<div class="ctms-table-toolbar">
|
||||||
<div class="ctms-filter-group">
|
<div class="ctms-filter-group">
|
||||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="ctms-filter-item">
|
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="ctms-filter-item w-40">
|
||||||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="ctms-filter-item">
|
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="ctms-filter-item w-40">
|
||||||
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" style="width: 100px">
|
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" class="ctms-filter-item w-32">
|
||||||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||||||
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
|
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -21,7 +26,7 @@
|
|||||||
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
|
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
style="width: 240px"
|
class="ctms-filter-item date-range-picker"
|
||||||
@change="loadLogs"
|
@change="loadLogs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,33 +54,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
<el-table :data="logs" v-loading="loading" style="width: 100%" stripe>
|
||||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180">
|
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180" align="center">
|
||||||
<template #default="scope">{{ displayDateTime(scope.row.timestamp) }}</template>
|
<template #default="scope">
|
||||||
|
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
|
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
|
||||||
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
|
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
|
||||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" min-width="160" />
|
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
|
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
|
||||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
|
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span v-if="scope.row.targetTypeLabel">
|
<span v-if="scope.row.targetTypeLabel">
|
||||||
{{ scope.row.targetTypeLabel }}({{ scope.row.targetName || TEXT.common.fallback }})
|
<strong>{{ scope.row.targetTypeLabel }}</strong>
|
||||||
|
<span class="text-gray-500" v-if="scope.row.targetName">({{ scope.row.targetName }})</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="220">
|
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div v-if="scope.row.diffText?.length">
|
<div v-if="scope.row.diffText?.length" class="diff-container">
|
||||||
<div v-for="(line, idx) in scope.row.diffText" :key="idx">{{ line }}</div>
|
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else>{{ TEXT.audit.emptyValue }}</span>
|
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="120">
|
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="100" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'">
|
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
|
||||||
{{ scope.row.resultLabel }}
|
{{ scope.row.resultLabel }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -322,10 +334,53 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
padding: 16px;
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ctms-table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ctms-filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ctms-filter-item {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.w-40 { width: 160px; }
|
||||||
|
.w-32 { width: 130px; }
|
||||||
|
.w-64 { width: 260px; }
|
||||||
|
|
||||||
|
.diff-container {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.diff-line {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.date-range-picker {
|
||||||
|
width: auto;
|
||||||
|
min-width: 320px;
|
||||||
}
|
}
|
||||||
.pagination {
|
.pagination {
|
||||||
margin-top: 12px;
|
margin-top: 24px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,79 +1,320 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ctms-page">
|
<div class="ctms-page">
|
||||||
<div class="ctms-page-header">
|
<div class="page-header-section">
|
||||||
<div>
|
<div class="header-top">
|
||||||
<h1 class="ctms-page-title">{{ TEXT.modules.adminProjects.detailTitle }}</h1>
|
<h1 class="page-title">{{ TEXT.modules.adminProjects.detailTitle }}</h1>
|
||||||
|
<el-tag v-if="project?.is_locked" type="warning" size="large" effect="dark">
|
||||||
|
<el-icon><Lock /></el-icon>
|
||||||
|
已锁定
|
||||||
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctms-page-actions">
|
<div class="quick-actions">
|
||||||
<el-button type="primary" @click="enterProject" :disabled="!project">{{ TEXT.modules.adminProjects.enter }}</el-button>
|
<div class="action-card" @click="enterProject" :class="{ disabled: !project }">
|
||||||
<el-button @click="goMembers" :disabled="!project">{{ TEXT.modules.adminProjects.members }}</el-button>
|
<el-icon class="action-icon"><Promotion /></el-icon>
|
||||||
<el-button @click="goSites" :disabled="!project">{{ TEXT.modules.adminProjects.sites }}</el-button>
|
<span class="action-text">{{ TEXT.modules.adminProjects.enter }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-card" @click="goMembers" :class="{ disabled: !project }">
|
||||||
|
<el-icon class="action-icon"><User /></el-icon>
|
||||||
|
<span class="action-text">{{ TEXT.modules.adminProjects.members }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-card" @click="goSites" :class="{ disabled: !project }">
|
||||||
|
<el-icon class="action-icon"><OfficeBuilding /></el-icon>
|
||||||
|
<span class="action-text">{{ TEXT.modules.adminProjects.sites }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card class="ctms-section-card">
|
<el-card class="ctms-section-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div>
|
<div class="ctms-section-header">
|
||||||
<div class="ctms-section-title">{{ TEXT.modules.adminProjects.basicInfo }}</div>
|
<div class="ctms-section-title">{{ TEXT.modules.adminProjects.basicInfo }}</div>
|
||||||
|
<div class="ctms-section-actions">
|
||||||
|
<template v-if="!isEditing">
|
||||||
|
<el-button type="primary" @click="startEdit" :disabled="!project || project.is_locked">
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!project?.is_locked"
|
||||||
|
type="info"
|
||||||
|
@click="handleLock"
|
||||||
|
:disabled="!project"
|
||||||
|
>
|
||||||
|
<el-icon><Lock /></el-icon>
|
||||||
|
锁定项目
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="goBack">
|
||||||
|
<el-icon><ArrowLeft /></el-icon>
|
||||||
|
{{ TEXT.common.actions.back }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" @click="handleDelete" :disabled="!project">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-button @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="saveEdit">
|
||||||
|
{{ TEXT.common.actions.save }}
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="goBack">
|
||||||
|
<el-icon><ArrowLeft /></el-icon>
|
||||||
|
{{ TEXT.common.actions.back }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" @click="handleDelete" :disabled="!project">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-descriptions v-if="project" :column="2" border class="detail-descriptions">
|
<el-form v-if="project" ref="formRef" :model="form" :rules="rules" label-width="120px" class="detail-form" label-position="top">
|
||||||
<el-descriptions-item :label="TEXT.common.fields.projectCode">{{ project.code }}</el-descriptions-item>
|
<el-row :gutter="24">
|
||||||
<el-descriptions-item :label="TEXT.common.fields.projectName">{{ project.name }}</el-descriptions-item>
|
<el-col :span="16">
|
||||||
<el-descriptions-item :label="TEXT.common.fields.sponsor">{{ project.sponsor || TEXT.common.fallback }}</el-descriptions-item>
|
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
||||||
<el-descriptions-item :label="TEXT.common.fields.status">{{ statusLabel(project.status) }}</el-descriptions-item>
|
<el-input v-model="form.name" :disabled="!isEditing" />
|
||||||
<el-descriptions-item :label="TEXT.modules.adminProjects.createdAt">{{ displayDateTime(project.created_at) }}</el-descriptions-item>
|
</el-form-item>
|
||||||
</el-descriptions>
|
</el-col>
|
||||||
<StateLoading v-else :rows="4" />
|
<el-col :span="8">
|
||||||
</el-card>
|
<el-form-item :label="TEXT.common.fields.projectCode" prop="code">
|
||||||
|
<el-input v-model="form.code" :disabled="!isEditing" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<el-card class="ctms-section-card">
|
<el-row :gutter="24">
|
||||||
<template #header>
|
<el-col :span="8">
|
||||||
<div>
|
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||||
<div class="ctms-section-title">{{ TEXT.modules.adminProjects.filesTitle }}</div>
|
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="!isEditing" class="w-full">
|
||||||
</div>
|
<el-option :label="TEXT.enums.projectStatus.DRAFT" value="DRAFT" />
|
||||||
</template>
|
<el-option :label="TEXT.enums.projectStatus.ACTIVE" value="ACTIVE" />
|
||||||
<AttachmentList
|
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
|
||||||
v-if="project"
|
</el-select>
|
||||||
:study-id="project.id"
|
</el-form-item>
|
||||||
entity-type="project"
|
</el-col>
|
||||||
:entity-id="project.id"
|
<el-col :span="8">
|
||||||
:show-uploader="true"
|
<el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
|
||||||
/>
|
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" :disabled="!isEditing" />
|
||||||
<StateLoading v-else :rows="3" />
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item :label="TEXT.common.fields.phase" prop="phase">
|
||||||
|
<el-input v-model="form.phase" :placeholder="TEXT.modules.adminProjects.phasePlaceholder" :disabled="!isEditing" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-divider content-position="left" class="form-divider">{{ TEXT.modules.adminProjects.visitTemplate }}</el-divider>
|
||||||
|
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item :label="TEXT.common.fields.visitTotal">
|
||||||
|
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" :disabled="!isEditing" class="w-full" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
|
||||||
|
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" :disabled="!isEditing" class="w-full" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
|
||||||
|
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" :disabled="!isEditing" class="w-full" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
|
||||||
|
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" :disabled="!isEditing" class="w-full" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="24" class="mt-4">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item :label="TEXT.modules.adminProjects.createdAt">
|
||||||
|
<span class="info-text">{{ displayDateTime(project.created_at) }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-form>
|
||||||
|
<StateLoading v-else :rows="4" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||||
import { fetchStudyDetail } from "../../api/studies";
|
import { ArrowLeft, Promotion, User, OfficeBuilding, Delete, Lock } from "@element-plus/icons-vue";
|
||||||
|
import { fetchStudyDetail, updateStudy, deleteStudy, lockStudy } from "../../api/studies";
|
||||||
import type { Study } from "../../types/api";
|
import type { Study } from "../../types/api";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
|
||||||
import StateLoading from "../../components/StateLoading.vue";
|
import StateLoading from "../../components/StateLoading.vue";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { displayDateTime } from "../../utils/display";
|
import { displayDateTime } from "../../utils/display";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT, requiredMessage } from "../../locales";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const studyStore = useStudyStore();
|
const studyStore = useStudyStore();
|
||||||
|
|
||||||
const project = ref<Study | null>(null);
|
const project = ref<Study | null>(null);
|
||||||
|
const isEditing = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const form = ref({
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
protocol_no: "",
|
||||||
|
phase: "",
|
||||||
|
status: "DRAFT",
|
||||||
|
visit_interval_days: null as number | null,
|
||||||
|
visit_total: null as number | null,
|
||||||
|
visit_window_start_offset: null as number | null,
|
||||||
|
visit_window_end_offset: null as number | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = ref<FormRules>({
|
||||||
|
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
|
||||||
|
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
|
||||||
|
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncForm = (data: Study | null) => {
|
||||||
|
form.value.code = data?.code || "";
|
||||||
|
form.value.name = data?.name || "";
|
||||||
|
form.value.protocol_no = data?.protocol_no || "";
|
||||||
|
form.value.phase = data?.phase || "";
|
||||||
|
form.value.status = data?.status || "DRAFT";
|
||||||
|
form.value.visit_interval_days = data?.visit_interval_days ?? null;
|
||||||
|
form.value.visit_total = data?.visit_total ?? null;
|
||||||
|
form.value.visit_window_start_offset = data?.visit_window_start_offset ?? null;
|
||||||
|
form.value.visit_window_end_offset = data?.visit_window_end_offset ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
const loadProject = async () => {
|
const loadProject = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await fetchStudyDetail(route.params.projectId as string);
|
const { data } = await fetchStudyDetail(route.params.projectId as string);
|
||||||
project.value = data as Study;
|
project.value = data as Study;
|
||||||
|
syncForm(project.value);
|
||||||
|
isEditing.value = false;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel = (status: string) =>
|
const startEdit = () => {
|
||||||
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
|
if (!project.value) return;
|
||||||
|
if (project.value.is_locked) {
|
||||||
|
ElMessage.warning("项目已锁定,无法编辑。请先解锁项目。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isEditing.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
syncForm(project.value);
|
||||||
|
isEditing.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!project.value || !formRef.value) return;
|
||||||
|
await formRef.value.validate();
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await updateStudy(project.value.id, {
|
||||||
|
code: form.value.code,
|
||||||
|
name: form.value.name,
|
||||||
|
protocol_no: form.value.protocol_no,
|
||||||
|
phase: form.value.phase,
|
||||||
|
status: form.value.status,
|
||||||
|
visit_interval_days: form.value.visit_interval_days,
|
||||||
|
visit_total: form.value.visit_total,
|
||||||
|
visit_window_start_offset: form.value.visit_window_start_offset,
|
||||||
|
visit_window_end_offset: form.value.visit_window_end_offset,
|
||||||
|
});
|
||||||
|
project.value = data as Study;
|
||||||
|
syncForm(project.value);
|
||||||
|
isEditing.value = false;
|
||||||
|
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!project.value) return;
|
||||||
|
try {
|
||||||
|
// 第一次确认
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除项目"${project.value.name}"吗?该操作将永久删除项目及其所有关联数据(成员、站点等),且不可恢复!`,
|
||||||
|
TEXT.common.actions.delete,
|
||||||
|
{
|
||||||
|
confirmButtonText: "继续",
|
||||||
|
cancelButtonText: TEXT.common.actions.cancel,
|
||||||
|
type: "error",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 第二次确认:要求输入项目名称
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
`请输入项目名称 "${project.value.name}" 以确认删除`,
|
||||||
|
"删除确认",
|
||||||
|
{
|
||||||
|
confirmButtonText: TEXT.common.actions.confirm,
|
||||||
|
cancelButtonText: TEXT.common.actions.cancel,
|
||||||
|
inputPattern: new RegExp(`^${project.value.name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}$`),
|
||||||
|
inputErrorMessage: "项目名称不匹配",
|
||||||
|
inputPlaceholder: "请输入项目名称",
|
||||||
|
type: "error",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value !== project.value.name) {
|
||||||
|
ElMessage.warning("项目名称不匹配,已取消删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteStudy(project.value.id);
|
||||||
|
ElMessage.success("项目已删除");
|
||||||
|
router.push("/admin/projects");
|
||||||
|
} catch (err) {
|
||||||
|
if (err !== "cancel") {
|
||||||
|
ElMessage.error("删除项目失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLock = async () => {
|
||||||
|
if (!project.value) return;
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要锁定项目"${project.value.name}"吗?锁定后项目内数据将无法编辑,且不可解锁!`,
|
||||||
|
"锁定项目",
|
||||||
|
{
|
||||||
|
confirmButtonText: TEXT.common.actions.confirm,
|
||||||
|
cancelButtonText: TEXT.common.actions.cancel,
|
||||||
|
type: "warning",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = await lockStudy(project.value.id);
|
||||||
|
project.value = data as Study;
|
||||||
|
syncForm(project.value);
|
||||||
|
ElMessage.success("项目已锁定");
|
||||||
|
} catch (err) {
|
||||||
|
if (err !== "cancel") {
|
||||||
|
ElMessage.error("锁定项目失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
const enterProject = () => {
|
const enterProject = () => {
|
||||||
if (!project.value) return;
|
if (!project.value) return;
|
||||||
@@ -97,7 +338,169 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.detail-descriptions :deep(.el-descriptions__label) {
|
.page-header-section {
|
||||||
color: var(--ctms-text-secondary);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 32px 40px;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
color: white;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover:not(.disabled) {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
box-shadow: 0 8px 24px rgba(247, 107, 28, 0.4);
|
||||||
|
border-color: #f76b1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover:not(.disabled)::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card.primary {
|
||||||
|
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
|
||||||
|
box-shadow: 0 2px 8px rgba(252, 182, 159, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card.primary::before {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 107, 28, 0.15) 0%, rgba(252, 107, 79, 0.15) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card.primary:hover:not(.disabled) {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
box-shadow: 0 8px 24px rgba(247, 107, 28, 0.4);
|
||||||
|
border-color: #f76b1c;
|
||||||
|
background: linear-gradient(135deg, #fff0e0 0%, #ffd0b8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #667eea;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover:not(.disabled) .action-icon {
|
||||||
|
transform: scale(1.15);
|
||||||
|
color: #f76b1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card.primary .action-icon {
|
||||||
|
color: #f76b1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card.primary:hover:not(.disabled) .action-icon {
|
||||||
|
color: #e05a0a;
|
||||||
|
transform: scale(1.15) rotate(5deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2d3748;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover:not(.disabled) .action-text {
|
||||||
|
color: #1a202c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctms-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctms-section-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.detail-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0; /* Let rows handle internal spacing if needed, or keep minimal */
|
||||||
|
}
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.form-divider {
|
||||||
|
margin: 32px 0 24px;
|
||||||
|
}
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.info-text {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure inputs are full width in grid */
|
||||||
|
.detail-form :deep(.el-input),
|
||||||
|
.detail-form :deep(.el-select),
|
||||||
|
.detail-form :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
|
<el-dialog :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||||
<el-form-item :label="TEXT.common.fields.projectCode" prop="code">
|
|
||||||
<el-input v-model="form.code" :disabled="!!project" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectCode" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
||||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" />
|
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.sponsor" prop="sponsor">
|
|
||||||
<el-input v-model="form.sponsor" :placeholder="TEXT.modules.adminProjects.sponsorPlaceholder" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
|
<el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
|
||||||
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" />
|
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -69,9 +63,7 @@ const visibleProxy = computed({
|
|||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
code: "",
|
|
||||||
name: "",
|
name: "",
|
||||||
sponsor: "",
|
|
||||||
protocol_no: "",
|
protocol_no: "",
|
||||||
phase: "",
|
phase: "",
|
||||||
status: "DRAFT",
|
status: "DRAFT",
|
||||||
@@ -82,15 +74,12 @@ const form = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rules = reactive<FormRules>({
|
const rules = reactive<FormRules>({
|
||||||
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
|
|
||||||
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
|
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
|
||||||
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
|
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.code = "";
|
|
||||||
form.name = "";
|
form.name = "";
|
||||||
form.sponsor = "";
|
|
||||||
form.protocol_no = "";
|
form.protocol_no = "";
|
||||||
form.phase = "";
|
form.phase = "";
|
||||||
form.status = "DRAFT";
|
form.status = "DRAFT";
|
||||||
@@ -106,9 +95,7 @@ watch(
|
|||||||
if (val) {
|
if (val) {
|
||||||
resetForm();
|
resetForm();
|
||||||
if (props.project) {
|
if (props.project) {
|
||||||
form.code = props.project.code;
|
|
||||||
form.name = props.project.name;
|
form.name = props.project.name;
|
||||||
form.sponsor = props.project.sponsor || "";
|
|
||||||
form.protocol_no = props.project.protocol_no || "";
|
form.protocol_no = props.project.protocol_no || "";
|
||||||
form.phase = props.project.phase || "";
|
form.phase = props.project.phase || "";
|
||||||
form.status = props.project.status || "DRAFT";
|
form.status = props.project.status || "DRAFT";
|
||||||
@@ -129,7 +116,6 @@ const onSubmit = async () => {
|
|||||||
if (props.project) {
|
if (props.project) {
|
||||||
await updateStudy(props.project.id, {
|
await updateStudy(props.project.id, {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
sponsor: form.sponsor,
|
|
||||||
protocol_no: form.protocol_no,
|
protocol_no: form.protocol_no,
|
||||||
phase: form.phase,
|
phase: form.phase,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
@@ -141,9 +127,7 @@ const onSubmit = async () => {
|
|||||||
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
|
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
|
||||||
} else {
|
} else {
|
||||||
await createStudy({
|
await createStudy({
|
||||||
code: form.code,
|
|
||||||
name: form.name,
|
name: form.name,
|
||||||
sponsor: form.sponsor,
|
|
||||||
protocol_no: form.protocol_no,
|
protocol_no: form.protocol_no,
|
||||||
phase: form.phase,
|
phase: form.phase,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3>{{ TEXT.modules.adminProjectMembers.title }}</h3>
|
<h3>{{ TEXT.modules.adminProjectMembers.title }}</h3>
|
||||||
<div class="sub" v-if="project">{{ TEXT.modules.adminProjectMembers.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
|
<div class="sub" v-if="project">{{ TEXT.modules.adminProjectMembers.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
|
||||||
<div class="sub">{{ TEXT.modules.adminProjectMembers.subtitle }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
|
<el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
v-model="scope.row.role_in_study"
|
v-model="scope.row.role_in_study"
|
||||||
size="small"
|
size="small"
|
||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
@change="(val) => updateRole(scope.row.id, val)"
|
@change="(val: string) => updateRole(scope.row.id, val)"
|
||||||
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||||
>
|
>
|
||||||
<el-option :label="TEXT.enums.userRole.PM" value="PM" />
|
<el-option :label="TEXT.enums.userRole.PM" value="PM" />
|
||||||
@@ -144,7 +143,7 @@ const loadMembers = async () => {
|
|||||||
if (!projectId.value) return;
|
if (!projectId.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await listMembers(projectId.value, { limit: 500 });
|
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
|
||||||
members.value = Array.isArray(data) ? data : data.items || [];
|
members.value = Array.isArray(data) ? data : data.items || [];
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
|
||||||
@@ -382,7 +381,10 @@ const onDelete = async (row: StudyMember) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableUsers = computed(() => users.value);
|
const availableUsers = computed(() => {
|
||||||
|
const memberUserIds = new Set(members.value.map((m) => m.user_id));
|
||||||
|
return users.value.filter((u) => !memberUserIds.has(u.id));
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadProject(), loadUsers()]);
|
await Promise.all([loadProject(), loadUsers()]);
|
||||||
|
|||||||
@@ -4,36 +4,54 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div>
|
<div>
|
||||||
<h3>{{ TEXT.modules.adminProjects.title }}</h3>
|
<h3>{{ TEXT.modules.adminProjects.title }}</h3>
|
||||||
<p class="sub">{{ TEXT.modules.adminProjects.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}</el-button>
|
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="projects" v-loading="loading" stripe>
|
<el-table :data="projects" v-loading="loading" stripe>
|
||||||
<el-table-column prop="code" :label="TEXT.modules.adminProjects.projectCode" width="140">
|
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="300">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.code }}</el-link>
|
<el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="200">
|
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.name }}</el-link>
|
<el-tag :type="statusTag(scope.row.status)" effect="plain" round>{{ statusLabel(scope.row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="sponsor" :label="TEXT.common.fields.sponsor" min-width="160" />
|
<el-table-column prop="is_locked" label="锁定状态" min-width="100" align="center">
|
||||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
|
<el-tag v-if="scope.row.is_locked" type="warning" effect="plain" round>已锁定</el-tag>
|
||||||
|
<el-tag v-else type="success" effect="plain" round>未锁定</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
|
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" min-width="180" align="center">
|
||||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
<span class="text-gray-500">{{ displayDateTime(scope.row.created_at) }}</span>
|
||||||
<el-button link type="primary" size="small" @click="goMembers(scope.row)">{{ TEXT.modules.adminProjects.members }}</el-button>
|
</template>
|
||||||
<el-button link type="primary" size="small" @click="goSites(scope.row)">{{ TEXT.modules.adminProjects.sites }}</el-button>
|
</el-table-column>
|
||||||
<el-button link type="success" size="small" @click="enterStudy(scope.row)">{{ TEXT.modules.adminProjects.enter }}</el-button>
|
<el-table-column :label="TEXT.common.labels.actions" min-width="280" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top">
|
||||||
|
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top">
|
||||||
|
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
|
||||||
|
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="info"
|
||||||
|
:icon="Lock"
|
||||||
|
class="action-btn"
|
||||||
|
@click="handleLockToggle(scope.row)"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
|
||||||
|
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
|
||||||
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -45,8 +63,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchStudies } from "../../api/studies";
|
import { User, OfficeBuilding, ArrowRight, Delete, Lock } from "@element-plus/icons-vue";
|
||||||
|
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
|
||||||
import type { Study } from "../../types/api";
|
import type { Study } from "../../types/api";
|
||||||
import ProjectForm from "./ProjectForm.vue";
|
import ProjectForm from "./ProjectForm.vue";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
@@ -78,11 +97,6 @@ const openCreate = () => {
|
|||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (row: Study) => {
|
|
||||||
editingProject.value = row;
|
|
||||||
formVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const goMembers = (row: Study) => {
|
const goMembers = (row: Study) => {
|
||||||
router.push(`/admin/projects/${row.id}/members`);
|
router.push(`/admin/projects/${row.id}/members`);
|
||||||
};
|
};
|
||||||
@@ -91,6 +105,79 @@ const goSites = (row: Study) => {
|
|||||||
router.push(`/admin/projects/${row.id}/sites`);
|
router.push(`/admin/projects/${row.id}/sites`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (study: Study) => {
|
||||||
|
try {
|
||||||
|
// 第一次确认
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除项目\"${study.name}\"吗?该操作将永久删除项目及其所有关联数据(成员、站点等),且不可恢复!`,
|
||||||
|
TEXT.common.actions.delete,
|
||||||
|
{
|
||||||
|
confirmButtonText: "继续",
|
||||||
|
cancelButtonText: TEXT.common.actions.cancel,
|
||||||
|
type: "error",
|
||||||
|
dangerouslyUseHTMLString: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 第二次确认:要求输入项目名称
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
`请输入项目名称 "${study.name}" 以确认删除`,
|
||||||
|
"删除确认",
|
||||||
|
{
|
||||||
|
confirmButtonText: TEXT.common.actions.confirm,
|
||||||
|
cancelButtonText: TEXT.common.actions.cancel,
|
||||||
|
inputPattern: new RegExp(`^${study.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`),
|
||||||
|
inputErrorMessage: "项目名称不匹配",
|
||||||
|
inputPlaceholder: "请输入项目名称",
|
||||||
|
type: "error",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value !== study.name) {
|
||||||
|
ElMessage.warning("项目名称不匹配,已取消删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteStudy(study.id);
|
||||||
|
ElMessage.success("项目已删除");
|
||||||
|
await loadProjects();
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== "cancel") {
|
||||||
|
const errorMsg = err?.response?.data?.detail || err?.message || "删除项目失败";
|
||||||
|
console.error("删除项目错误:", err);
|
||||||
|
ElMessage.error(errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLockToggle = async (study: Study) => {
|
||||||
|
// 锁定后不可解锁
|
||||||
|
if (study.is_locked) {
|
||||||
|
ElMessage.warning("项目已锁定,不可解锁");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要锁定项目\"${study.name}\"吗?锁定后项目内数据将无法编辑,且不可解锁!`,
|
||||||
|
"锁定项目",
|
||||||
|
{
|
||||||
|
confirmButtonText: TEXT.common.actions.confirm,
|
||||||
|
cancelButtonText: TEXT.common.actions.cancel,
|
||||||
|
type: "warning",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await lockStudy(study.id);
|
||||||
|
ElMessage.success("项目已锁定");
|
||||||
|
await loadProjects();
|
||||||
|
} catch (err) {
|
||||||
|
if (err !== "cancel") {
|
||||||
|
ElMessage.error("锁定项目失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const enterStudy = (row: Study) => {
|
const enterStudy = (row: Study) => {
|
||||||
studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study);
|
studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study);
|
||||||
router.push("/project/overview");
|
router.push("/project/overview");
|
||||||
@@ -117,16 +204,64 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
padding: 16px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sub {
|
.sub {
|
||||||
color: #666;
|
color: #6b7280;
|
||||||
margin-top: 4px;
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式美化 */
|
||||||
|
:deep(.el-table) {
|
||||||
|
--el-table-header-bg-color: #f9fafb;
|
||||||
|
--el-table-header-text-color: #374151;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th.el-table__cell) {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 增加单元格间距 */
|
||||||
|
:deep(.el-table .el-table__cell) {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮美化 */
|
||||||
|
.action-btn {
|
||||||
|
font-size: 20px; /* 图标变更大 */
|
||||||
|
padding: 6px;
|
||||||
|
margin: 0 4px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enter-btn {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}</el-button>
|
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="sites" v-loading="loading" stripe>
|
<el-table :data="sites" v-loading="loading" stripe :row-class-name="siteRowClass">
|
||||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" min-width="180" />
|
<el-table-column prop="name" :label="TEXT.common.fields.siteName" min-width="180" />
|
||||||
<el-table-column prop="city" :label="TEXT.common.fields.city" width="140" />
|
<el-table-column prop="city" :label="TEXT.common.fields.city" width="140" />
|
||||||
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="160" />
|
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="160" />
|
||||||
@@ -24,11 +24,11 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.fields.status" width="120">
|
<el-table-column :label="TEXT.common.fields.status" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag type="success" v-if="scope.row.is_active">{{ TEXT.common.actions.enable }}</el-tag>
|
<el-tag type="success" v-if="scope.row.is_active">已解锁</el-tag>
|
||||||
<el-tag type="danger" v-else>{{ TEXT.common.actions.disable }}</el-tag>
|
<el-tag type="danger" v-else>已锁定</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="220" fixed="right">
|
<el-table-column :label="TEXT.common.labels.actions" width="260" fixed="right">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -37,7 +37,16 @@
|
|||||||
size="small"
|
size="small"
|
||||||
@click="toggleSite(scope.row)"
|
@click="toggleSite(scope.row)"
|
||||||
>
|
>
|
||||||
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
{{ scope.row.is_active ? "锁定" : "解锁" }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="isAdmin"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@click="confirmDelete(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -60,7 +69,7 @@ import { computed, onMounted, ref } from "vue";
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchStudyDetail } from "../../api/studies";
|
import { fetchStudyDetail } from "../../api/studies";
|
||||||
import { fetchSites, updateSite } from "../../api/sites";
|
import { deleteSite, fetchSites, updateSite } from "../../api/sites";
|
||||||
import { fetchUsers } from "../../api/users";
|
import { fetchUsers } from "../../api/users";
|
||||||
import { listMembers } from "../../api/members";
|
import { listMembers } from "../../api/members";
|
||||||
import SiteForm from "./SiteForm.vue";
|
import SiteForm from "./SiteForm.vue";
|
||||||
@@ -81,6 +90,7 @@ const members = ref<any[]>([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const formVisible = ref(false);
|
const formVisible = ref(false);
|
||||||
const editingSite = ref<Site | null>(null);
|
const editingSite = ref<Site | null>(null);
|
||||||
|
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||||
|
|
||||||
const loadProject = async () => {
|
const loadProject = async () => {
|
||||||
if (!projectId.value) return;
|
if (!projectId.value) return;
|
||||||
@@ -96,7 +106,7 @@ const loadSites = async () => {
|
|||||||
if (!projectId.value) return;
|
if (!projectId.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await fetchSites(projectId.value);
|
const { data } = await fetchSites(projectId.value, { include_inactive: true });
|
||||||
sites.value = Array.isArray(data) ? data : (data as any).items || [];
|
sites.value = Array.isArray(data) ? data : (data as any).items || [];
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminSites.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminSites.loadFailed);
|
||||||
@@ -147,6 +157,8 @@ const contactLabel = (row: any) => {
|
|||||||
|
|
||||||
const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
|
const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
|
||||||
|
|
||||||
|
const siteRowClass = ({ row }: { row: Site }) => (row.is_active ? "" : "row-inactive");
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
loadMembers();
|
loadMembers();
|
||||||
@@ -179,15 +191,16 @@ const toggleSite = async (row: Site) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.is_active) {
|
if (row.is_active) {
|
||||||
const ok = await ElMessageBox.confirm(TEXT.modules.adminSites.disableConfirm, TEXT.modules.adminSites.disableTitle, {
|
const ok = await ElMessageBox.confirm("锁定后该中心将不可编辑,但数据会保留,确认锁定?", "锁定中心", {
|
||||||
type: "warning",
|
type: "warning",
|
||||||
confirmButtonText: TEXT.common.actions.confirm,
|
confirmButtonText: TEXT.common.actions.confirm,
|
||||||
|
cancelButtonText: TEXT.common.actions.cancel,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await updateSite(projectId.value, row.id, { is_active: !row.is_active });
|
await updateSite(projectId.value, row.id, { is_active: !row.is_active });
|
||||||
ElMessage.success(row.is_active ? TEXT.modules.adminSites.disableSuccess : TEXT.modules.adminSites.enableSuccess);
|
ElMessage.success(row.is_active ? "已锁定" : "已解锁");
|
||||||
loadSites();
|
loadSites();
|
||||||
logAudit("SITE_STATUS_CHANGED", {
|
logAudit("SITE_STATUS_CHANGED", {
|
||||||
targetId: row.id,
|
targetId: row.id,
|
||||||
@@ -209,6 +222,42 @@ const toggleSite = async (row: Site) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async (row: Site) => {
|
||||||
|
if (!projectId.value) return;
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
ElMessage.warning("仅管理员可删除中心");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = "删除该中心会删除中心所有数据,请谨慎操作。输入“确认删除”继续。";
|
||||||
|
const result = await ElMessageBox.prompt(message, "危险操作", {
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: TEXT.common.actions.confirm,
|
||||||
|
cancelButtonText: TEXT.common.actions.cancel,
|
||||||
|
inputPlaceholder: "确认删除",
|
||||||
|
inputPattern: /^确认删除$/,
|
||||||
|
inputErrorMessage: "请输入“确认删除”",
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!result) return;
|
||||||
|
try {
|
||||||
|
await deleteSite(projectId.value, row.id);
|
||||||
|
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||||
|
loadSites();
|
||||||
|
logAudit("SITE_DELETED", {
|
||||||
|
targetId: row.id,
|
||||||
|
targetName: row.name,
|
||||||
|
severity: "high",
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||||
|
logAudit("SITE_DELETED", {
|
||||||
|
targetId: row.id,
|
||||||
|
targetName: row.name,
|
||||||
|
severity: "warning",
|
||||||
|
reason: e?.response?.data?.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadProject(), loadUsers(), loadMembers()]);
|
await Promise.all([loadProject(), loadUsers(), loadMembers()]);
|
||||||
loadSites();
|
loadSites();
|
||||||
@@ -229,4 +278,16 @@ onMounted(async () => {
|
|||||||
color: #666;
|
color: #666;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div>
|
<div>
|
||||||
<h3>{{ TEXT.modules.adminUsers.title }}</h3>
|
<h3>{{ TEXT.modules.adminUsers.title }}</h3>
|
||||||
<p class="sub">{{ TEXT.modules.adminUsers.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,28 +21,32 @@
|
|||||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
|
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
|
||||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right">
|
<el-table-column :label="TEXT.common.labels.actions" min-width="200" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
|
||||||
<el-button
|
<el-button link type="primary" :icon="Edit" @click="openEdit(scope.row)" />
|
||||||
link
|
</el-tooltip>
|
||||||
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
|
<el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
|
||||||
size="small"
|
<el-button
|
||||||
:disabled="isLastAdmin(scope.row)"
|
link
|
||||||
@click="toggleStatus(scope.row)"
|
:type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
|
||||||
>
|
:icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
|
||||||
{{ scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
:disabled="isLastAdmin(scope.row)"
|
||||||
</el-button>
|
@click="toggleStatus(scope.row)"
|
||||||
<el-button link type="danger" size="small" @click="openReset(scope.row)">{{ TEXT.modules.adminUsers.resetPassword }}</el-button>
|
/>
|
||||||
<el-button
|
</el-tooltip>
|
||||||
link
|
<el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
|
||||||
type="danger"
|
<el-button link type="warning" :icon="Key" @click="openReset(scope.row)" />
|
||||||
size="small"
|
</el-tooltip>
|
||||||
:disabled="scope.row.id === auth.user?.id"
|
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
|
||||||
@click="onDelete(scope.row)"
|
<el-button
|
||||||
>
|
link
|
||||||
{{ TEXT.common.actions.delete }}
|
type="danger"
|
||||||
</el-button>
|
:icon="Delete"
|
||||||
|
:disabled="scope.row.id === auth.user?.id"
|
||||||
|
@click="onDelete(scope.row)"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -66,7 +69,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Edit, Delete, Key, Lock, Unlock } from "@element-plus/icons-vue";
|
||||||
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
||||||
|
import { fetchStudies } from "../../api/studies";
|
||||||
|
import { listMembers } from "../../api/members";
|
||||||
import type { UserInfo } from "../../types/api";
|
import type { UserInfo } from "../../types/api";
|
||||||
import UserForm from "./UserForm.vue";
|
import UserForm from "./UserForm.vue";
|
||||||
import UserResetPassword from "./UserResetPassword.vue";
|
import UserResetPassword from "./UserResetPassword.vue";
|
||||||
@@ -86,6 +92,37 @@ const editingUser = ref<UserInfo | null>(null);
|
|||||||
const resetUser = ref<UserInfo | null>(null);
|
const resetUser = ref<UserInfo | null>(null);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const statusType = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "ACTIVE":
|
||||||
|
return "success";
|
||||||
|
case "PENDING":
|
||||||
|
return "warning";
|
||||||
|
case "REJECTED":
|
||||||
|
return "danger";
|
||||||
|
default:
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "ACTIVE":
|
||||||
|
return TEXT.enums.userStatus.ACTIVE;
|
||||||
|
case "PENDING":
|
||||||
|
return TEXT.enums.userStatus.PENDING;
|
||||||
|
case "REJECTED":
|
||||||
|
return TEXT.enums.userStatus.REJECTED;
|
||||||
|
case "DISABLED":
|
||||||
|
return TEXT.enums.userStatus.DISABLED;
|
||||||
|
default:
|
||||||
|
return status || TEXT.common.fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLastAdmin = (row: UserInfo) =>
|
||||||
|
row.role === "ADMIN" && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -146,67 +183,71 @@ const openReset = (row: UserInfo) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = async (row: UserInfo) => {
|
const onDelete = async (row: UserInfo) => {
|
||||||
const { value, action } = await ElMessageBox.prompt(
|
const { action } = await ElMessageBox.prompt(
|
||||||
TEXT.modules.adminUsers.deleteConfirm,
|
TEXT.modules.adminUsers.deleteConfirm,
|
||||||
TEXT.modules.adminUsers.deleteTitle,
|
TEXT.modules.adminUsers.deleteTitle,
|
||||||
{
|
{
|
||||||
inputType: "password",
|
inputPlaceholder: TEXT.modules.adminUsers.deleteConfirmPlaceholder,
|
||||||
inputPlaceholder: TEXT.modules.adminUsers.deletePasswordPlaceholder,
|
confirmButtonText: TEXT.common.actions.confirm,
|
||||||
confirmButtonText: TEXT.modules.adminUsers.deleteConfirmButton,
|
|
||||||
confirmButtonClass: "el-button--danger",
|
confirmButtonClass: "el-button--danger",
|
||||||
cancelButtonText: TEXT.common.actions.cancel,
|
cancelButtonText: TEXT.common.actions.cancel,
|
||||||
|
inputPattern: /^确认删除$/,
|
||||||
|
inputErrorMessage: TEXT.modules.adminUsers.deleteConfirmInputError,
|
||||||
}
|
}
|
||||||
).catch(() => ({ action: "cancel", value: "" }));
|
).catch(() => ({ action: "cancel" }));
|
||||||
if (action !== "confirm" || !value) return;
|
if (action !== "confirm") return;
|
||||||
try {
|
try {
|
||||||
await deleteUser(row.id, { admin_password: value });
|
await deleteUser(row.id);
|
||||||
ElMessage.success(TEXT.modules.adminUsers.deleteSuccess);
|
ElMessage.success(TEXT.modules.adminUsers.deleteSuccess);
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const detail = e?.response?.data?.detail || e?.response?.data?.message;
|
const detail = e?.response?.data?.detail || e?.response?.data?.message;
|
||||||
if (detail && detail.includes(TEXT.modules.adminProjectMembers.memberLabel)) {
|
if (detail && detail.includes(TEXT.modules.adminProjectMembers.memberLabel)) {
|
||||||
ElMessage.closeAll();
|
ElMessage.closeAll();
|
||||||
ElMessage.error(detail);
|
const blockingProjects = await findUserProjects(row.id);
|
||||||
|
if (blockingProjects.length > 0) {
|
||||||
|
ElMessage.error(`${detail},该用户仍在以下项目中:${blockingProjects.join("、")}`);
|
||||||
|
} else {
|
||||||
|
ElMessage.error(detail);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(detail || TEXT.modules.adminUsers.deleteFailed);
|
ElMessage.error(detail || TEXT.modules.adminUsers.deleteFailed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findUserProjects = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchStudies();
|
||||||
|
const studies = Array.isArray(data) ? data : (data as any).items || [];
|
||||||
|
const blocking: string[] = [];
|
||||||
|
|
||||||
|
// Check each study for membership
|
||||||
|
// We run these in parallel but limited batch might be better if many studies.
|
||||||
|
// For now assuming reasonable number of studies.
|
||||||
|
await Promise.all(studies.map(async (study: any) => {
|
||||||
|
try {
|
||||||
|
const { data: membersData } = await listMembers(study.id, { limit: 1000, include_inactive: true }); // Large limit to catch all
|
||||||
|
const members = Array.isArray(membersData) ? membersData : (membersData as any).items || [];
|
||||||
|
if (members.some((m: any) => m.user_id === userId)) {
|
||||||
|
blocking.push(study.name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors checking specific study
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return blocking;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusType = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "ACTIVE":
|
|
||||||
return "success";
|
|
||||||
case "PENDING":
|
|
||||||
return "warning";
|
|
||||||
case "REJECTED":
|
|
||||||
return "danger";
|
|
||||||
default:
|
|
||||||
return "info";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabel = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "ACTIVE":
|
|
||||||
return TEXT.enums.userStatus.ACTIVE;
|
|
||||||
case "PENDING":
|
|
||||||
return TEXT.enums.userStatus.PENDING;
|
|
||||||
case "REJECTED":
|
|
||||||
return TEXT.enums.userStatus.REJECTED;
|
|
||||||
case "DISABLED":
|
|
||||||
return TEXT.enums.userStatus.DISABLED;
|
|
||||||
default:
|
|
||||||
return status || TEXT.common.fallback;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLastAdmin = (row: UserInfo) =>
|
|
||||||
row.role === "ADMIN" && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<p class="ctms-page-subtitle">
|
<p class="ctms-page-subtitle">
|
||||||
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
|
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||||
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
|
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
|
||||||
|
<el-tag v-if="isReadOnly" size="small" effect="plain" type="info">中心已停用</el-tag>
|
||||||
<span class="text-secondary text-sm">
|
<span class="text-secondary text-sm">
|
||||||
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}:
|
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}:
|
||||||
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
|
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
|
||||||
@@ -373,6 +374,15 @@ const sites = ref<Site[]>([]);
|
|||||||
const members = ref<StudyMember[]>([]);
|
const members = ref<StudyMember[]>([]);
|
||||||
const workflowTemplates = ref<WorkflowTemplate[]>([]);
|
const workflowTemplates = ref<WorkflowTemplate[]>([]);
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
||||||
|
|
||||||
const userMap = computed(() =>
|
const userMap = computed(() =>
|
||||||
users.value.reduce<Record<string, string>>((acc, user) => {
|
users.value.reduce<Record<string, string>>((acc, user) => {
|
||||||
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
|
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
|
||||||
@@ -468,6 +478,7 @@ const createTarget = () => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const canAction = (action: string) => {
|
const canAction = (action: string) => {
|
||||||
|
if (isReadOnly.value && action !== "view") return false;
|
||||||
const list = detail.can_actions || [];
|
const list = detail.can_actions || [];
|
||||||
if (list.length === 0) return true;
|
if (list.length === 0) return true;
|
||||||
return list.includes(action);
|
return list.includes(action);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="ctms-page-header">
|
<div class="ctms-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
|
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
|
||||||
<p class="page-subtitle">{{ TEXT.modules.fileVersionManagement.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ctms-page-actions">
|
<div class="ctms-page-actions">
|
||||||
<el-button type="primary" @click="openCreate">
|
<el-button type="primary" @click="openCreate">
|
||||||
@@ -48,7 +47,13 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="ctms-table-card">
|
<el-card class="ctms-table-card">
|
||||||
<el-table :data="items" v-loading="loading" @row-click="handleRowClick" row-class-name="clickable-row" class="ctms-table">
|
<el-table
|
||||||
|
:data="sortedItems"
|
||||||
|
v-loading="loading"
|
||||||
|
@row-click="handleRowClick"
|
||||||
|
:row-class-name="documentRowClass"
|
||||||
|
class="ctms-table"
|
||||||
|
>
|
||||||
<el-table-column prop="doc_no" :label="TEXT.modules.fileVersionManagement.columns.docNo" width="160">
|
<el-table-column prop="doc_no" :label="TEXT.modules.fileVersionManagement.columns.docNo" width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="font-mono font-medium">{{ row.doc_no }}</span>
|
<span class="font-mono font-medium">{{ row.doc_no }}</span>
|
||||||
@@ -104,7 +109,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right">
|
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="danger" @click.stop="confirmDelete(row)">
|
<el-button link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
|
||||||
{{ TEXT.common.actions.delete }}
|
{{ TEXT.common.actions.delete }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -130,7 +135,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="createForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
|
<el-form-item v-if="createForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
|
||||||
<el-select v-model="createForm.site_id" filterable style="width: 100%">
|
<el-select v-model="createForm.site_id" filterable style="width: 100%">
|
||||||
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
|
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" :disabled="item.disabled" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
|
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
|
||||||
@@ -208,7 +213,20 @@ const scopeOptions = [
|
|||||||
{ value: "SITE", label: TEXT.enums.scopeType.SITE },
|
{ value: "SITE", label: TEXT.enums.scopeType.SITE },
|
||||||
];
|
];
|
||||||
const siteOptions = computed(() =>
|
const siteOptions = computed(() =>
|
||||||
sites.value.map((site) => ({ value: site.id, label: site.name }))
|
sites.value.map((site) => ({ value: site.id, label: site.name, disabled: !site.is_active }))
|
||||||
|
);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||||
|
const documentRowClass = ({ row }: { row: DocumentSummary }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||||
);
|
);
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
@@ -304,6 +322,11 @@ const submitCreate = async () => {
|
|||||||
if (!valid || !trialId.value) return;
|
if (!valid || !trialId.value) return;
|
||||||
creating.value = true;
|
creating.value = true;
|
||||||
try {
|
try {
|
||||||
|
if (createForm.scope_type === "SITE" && isInactiveSite(createForm.site_id)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
creating.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
await createDocument({
|
await createDocument({
|
||||||
trial_id: trialId.value,
|
trial_id: trialId.value,
|
||||||
site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined,
|
site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined,
|
||||||
@@ -385,4 +408,16 @@ watch(
|
|||||||
.font-semibold {
|
.font-semibold {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,19 +14,20 @@
|
|||||||
<el-row :gutter="24">
|
<el-row :gutter="24">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||||
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
|
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="site in sites"
|
v-for="site in sites"
|
||||||
:key="site.id"
|
:key="site.id"
|
||||||
:label="site.name || TEXT.common.fallback"
|
:label="site.name || TEXT.common.fallback"
|
||||||
:value="site.id"
|
:value="site.id"
|
||||||
|
:disabled="!site.is_active"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
||||||
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
|
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||||
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
|
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
||||||
@@ -49,43 +50,45 @@
|
|||||||
<el-row :gutter="24">
|
<el-row :gutter="24">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
|
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
|
||||||
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
|
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
|
||||||
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
|
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
|
||||||
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" />
|
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
|
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
|
||||||
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" />
|
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
|
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
|
||||||
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" />
|
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
|
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
|
||||||
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" />
|
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
|
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
|
||||||
|
{{ TEXT.common.actions.save }}
|
||||||
|
</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -121,10 +124,18 @@ const study = useStudyStore();
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
const shipmentId = computed(() => route.params.shipmentId as string | undefined);
|
const shipmentId = computed(() => route.params.shipmentId as string | undefined);
|
||||||
const isEdit = computed(() => !!shipmentId.value);
|
const isEdit = computed(() => !!shipmentId.value);
|
||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
center_id: "",
|
center_id: "",
|
||||||
@@ -185,6 +196,10 @@ const load = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
const valid = await formRef.value?.validate().catch(() => false);
|
const valid = await formRef.value?.validate().catch(() => false);
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="page-subtitle">{{ TEXT.modules.feeContracts.detailSubtitle }}</p>
|
<p class="page-subtitle">{{ TEXT.modules.feeContracts.detailSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button type="primary" :disabled="!canWrite" @click="goEdit" class="header-action-btn copy-btn">
|
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||||
{{ TEXT.common.actions.edit }}
|
{{ TEXT.common.actions.edit }}
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -106,6 +106,7 @@
|
|||||||
:entity-type="'contract_fee'"
|
:entity-type="'contract_fee'"
|
||||||
:entity-id="contractId"
|
:entity-id="contractId"
|
||||||
:groups="attachmentGroups"
|
:groups="attachmentGroups"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
import { Edit } from "@element-plus/icons-vue";
|
import { Edit } from "@element-plus/icons-vue";
|
||||||
import { getContractFee } from "../../api/feeContracts";
|
import { getContractFee } from "../../api/feeContracts";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
@@ -145,6 +147,14 @@ const detail = reactive<any>({
|
|||||||
payments: [],
|
payments: [],
|
||||||
});
|
});
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
|
||||||
|
|
||||||
const attachmentGroups = [
|
const attachmentGroups = [
|
||||||
{
|
{
|
||||||
@@ -204,7 +214,13 @@ const formatAmountWan = (value: any) => {
|
|||||||
return (numberValue / 10000).toFixed(2);
|
return (numberValue / 10000).toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => router.push(`/fees/contracts/${contractId}/edit`);
|
const goEdit = () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/fees/contracts/${contractId}/edit`);
|
||||||
|
};
|
||||||
const goBack = () => router.push("/fees/contracts");
|
const goBack = () => router.push("/fees/contracts");
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -29,39 +29,69 @@
|
|||||||
<el-row :gutter="24">
|
<el-row :gutter="24">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id" required>
|
<el-form-item :label="TEXT.common.fields.site" prop="center_id" required>
|
||||||
<el-select v-model="form.center_id" :disabled="isEdit" :placeholder="TEXT.common.placeholders.select" class="full-width">
|
<el-select
|
||||||
|
v-model="form.center_id"
|
||||||
|
:disabled="isEdit || isReadOnly"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
class="full-width"
|
||||||
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="site in sites"
|
v-for="site in sites"
|
||||||
:key="site.id"
|
:key="site.id"
|
||||||
:label="site.name || TEXT.common.fallback"
|
:label="site.name || TEXT.common.fallback"
|
||||||
:value="site.id"
|
:value="site.id"
|
||||||
|
:disabled="!site.is_active"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.modules.feeContracts.contractAmount" prop="contract_amount" required>
|
<el-form-item :label="TEXT.modules.feeContracts.contractAmount" prop="contract_amount" required>
|
||||||
<el-input-number v-model="form.contract_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" />
|
<el-input-number
|
||||||
|
v-model="form.contract_amount"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1000"
|
||||||
|
class="full-width"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases" required>
|
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases" required>
|
||||||
<el-input-number v-model="form.contract_cases" :min="0" :step="1" class="full-width" controls-position="right" />
|
<el-input-number v-model="form.contract_cases" :disabled="isReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
|
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
|
||||||
<el-input-number v-model="form.actual_cases" :min="0" :step="1" class="full-width" controls-position="right" />
|
<el-input-number v-model="form.actual_cases" :disabled="isReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.modules.feeContracts.settlementAmount" prop="settlement_amount">
|
<el-form-item :label="TEXT.modules.feeContracts.settlementAmount" prop="settlement_amount">
|
||||||
<el-input-number v-model="form.settlement_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" />
|
<el-input-number
|
||||||
|
v-model="form.settlement_amount"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1000"
|
||||||
|
class="full-width"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.modules.feeContracts.finalPaymentAmount" prop="final_payment_amount">
|
<el-form-item :label="TEXT.modules.feeContracts.finalPaymentAmount" prop="final_payment_amount">
|
||||||
<el-input-number v-model="form.final_payment_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" />
|
<el-input-number
|
||||||
|
v-model="form.final_payment_amount"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1000"
|
||||||
|
class="full-width"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -71,7 +101,7 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header actions-header">
|
<div class="card-header actions-header">
|
||||||
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
|
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
|
||||||
<el-button type="primary" @click="addPayment">
|
<el-button type="primary" :disabled="isReadOnly" @click="addPayment">
|
||||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||||
{{ TEXT.common.actions.add }}
|
{{ TEXT.common.actions.add }}
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -90,7 +120,7 @@
|
|||||||
<span class="seq-circle">{{ index + 1 }}</span>
|
<span class="seq-circle">{{ index + 1 }}</span>
|
||||||
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
|
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-button link type="danger" @click="removePayment(index)">
|
<el-button link type="danger" :disabled="isReadOnly" @click="removePayment(index)">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,13 +128,13 @@
|
|||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item :label="TEXT.common.fields.amount" :error="paymentErrors[index]?.amount">
|
<el-form-item :label="TEXT.common.fields.amount" :error="paymentErrors[index]?.amount">
|
||||||
<el-input-number v-model="payment.amount" :min="0" :precision="2" class="full-width" controls-position="right" />
|
<el-input-number v-model="payment.amount" :disabled="isReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
|
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
|
||||||
<div class="status-control">
|
<div class="status-control">
|
||||||
<el-checkbox v-model="payment.is_paid" @change="() => onPaidToggle(payment)">
|
<el-checkbox v-model="payment.is_paid" :disabled="isReadOnly" @change="() => onPaidToggle(payment)">
|
||||||
{{ TEXT.modules.feeContracts.isPaid }}
|
{{ TEXT.modules.feeContracts.isPaid }}
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
@@ -113,6 +143,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
:placeholder="TEXT.common.placeholders.select"
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
:disabled="isReadOnly"
|
||||||
class="status-picker"
|
class="status-picker"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
@@ -122,7 +153,7 @@
|
|||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
|
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
|
||||||
<div class="status-control">
|
<div class="status-control">
|
||||||
<el-checkbox v-model="payment.is_verified" @change="() => onVerifiedToggle(payment)">
|
<el-checkbox v-model="payment.is_verified" :disabled="isReadOnly" @change="() => onVerifiedToggle(payment)">
|
||||||
{{ TEXT.modules.feeContracts.isVerified }}
|
{{ TEXT.modules.feeContracts.isVerified }}
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
@@ -131,6 +162,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
:placeholder="TEXT.common.placeholders.select"
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
:disabled="isReadOnly"
|
||||||
class="status-picker"
|
class="status-picker"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
@@ -140,7 +172,7 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-form-item :label="TEXT.common.fields.remark" class="mb-0">
|
<el-form-item :label="TEXT.common.fields.remark" class="mb-0">
|
||||||
<el-input v-model="payment.remark" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
|
<el-input v-model="payment.remark" :disabled="isReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<div v-if="paymentErrors[index]?.verification" class="payment-error">
|
<div v-if="paymentErrors[index]?.verification" class="payment-error">
|
||||||
@@ -162,6 +194,7 @@
|
|||||||
:entity-type="'contract_fee'"
|
:entity-type="'contract_fee'"
|
||||||
:entity-id="form.id"
|
:entity-id="form.id"
|
||||||
:groups="attachmentGroups"
|
:groups="attachmentGroups"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StateEmpty v-else :description="TEXT.modules.feeContracts.uploadHint" class="compact-empty" />
|
<StateEmpty v-else :description="TEXT.modules.feeContracts.uploadHint" class="compact-empty" />
|
||||||
@@ -169,7 +202,7 @@
|
|||||||
|
|
||||||
<div class="footer-actions">
|
<div class="footer-actions">
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
<el-button type="primary" :loading="saving" @click="submit" class="save-btn">
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit" class="save-btn">
|
||||||
{{ TEXT.common.actions.save }}
|
{{ TEXT.common.actions.save }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,6 +240,14 @@ const loading = ref(false);
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
|
||||||
|
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -302,6 +343,10 @@ const loadDetail = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addPayment = () => {
|
const addPayment = () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
payments.value.push({
|
payments.value.push({
|
||||||
tempId: `${Date.now()}-${Math.random()}`,
|
tempId: `${Date.now()}-${Math.random()}`,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
@@ -314,6 +359,10 @@ const addPayment = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removePayment = (index: number) => {
|
const removePayment = (index: number) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payment = payments.value[index];
|
const payment = payments.value[index];
|
||||||
if (payment?.id) {
|
if (payment?.id) {
|
||||||
removedPaymentIds.value.push(payment.id);
|
removedPaymentIds.value.push(payment.id);
|
||||||
@@ -322,6 +371,7 @@ const removePayment = (index: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onPaidToggle = (payment: any) => {
|
const onPaidToggle = (payment: any) => {
|
||||||
|
if (isReadOnly.value) return;
|
||||||
if (!payment.is_paid) {
|
if (!payment.is_paid) {
|
||||||
payment.paid_date = "";
|
payment.paid_date = "";
|
||||||
payment.is_verified = false;
|
payment.is_verified = false;
|
||||||
@@ -330,6 +380,7 @@ const onPaidToggle = (payment: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onVerifiedToggle = (payment: any) => {
|
const onVerifiedToggle = (payment: any) => {
|
||||||
|
if (isReadOnly.value) return;
|
||||||
if (payment.is_verified && !payment.is_paid) {
|
if (payment.is_verified && !payment.is_paid) {
|
||||||
payment.is_paid = true;
|
payment.is_paid = true;
|
||||||
}
|
}
|
||||||
@@ -367,6 +418,14 @@ const validatePayments = () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!study.currentStudy?.id) return;
|
if (!study.currentStudy?.id) return;
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const formOk = await formRef.value?.validate?.().catch(() => false);
|
const formOk = await formRef.value?.validate?.().catch(() => false);
|
||||||
if (!formOk) return;
|
if (!formOk) return;
|
||||||
if (!validatePayments()) {
|
if (!validatePayments()) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ TEXT.modules.feeContracts.title }}</h1>
|
<h1 class="page-title">{{ TEXT.modules.feeContracts.title }}</h1>
|
||||||
<p class="page-subtitle">{{ TEXT.modules.feeContracts.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
||||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||||
@@ -94,7 +93,13 @@
|
|||||||
<StateLoading v-else-if="loading" :rows="6" />
|
<StateLoading v-else-if="loading" :rows="6" />
|
||||||
|
|
||||||
<el-card v-else class="table-card" shadow="never">
|
<el-card v-else class="table-card" shadow="never">
|
||||||
<el-table :data="contracts" style="width: 100%" @row-click="onRowClick" :header-cell-style="{ background: '#f8f9fb' }">
|
<el-table
|
||||||
|
:data="sortedContracts"
|
||||||
|
style="width: 100%"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
:row-class-name="contractRowClass"
|
||||||
|
:header-cell-style="{ background: '#f8f9fb' }"
|
||||||
|
>
|
||||||
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="180">
|
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="site-cell">
|
<div class="site-cell">
|
||||||
@@ -163,7 +168,7 @@
|
|||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!canWrite"
|
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
|
||||||
@click.stop="remove(scope.row)"
|
@click.stop="remove(scope.row)"
|
||||||
>
|
>
|
||||||
{{ TEXT.common.actions.delete }}
|
{{ TEXT.common.actions.delete }}
|
||||||
@@ -171,7 +176,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="contracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
|
<StateEmpty v-if="sortedContracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -201,6 +206,19 @@ const loading = ref(false);
|
|||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const contracts = ref<any[]>([]);
|
const contracts = ref<any[]>([]);
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||||
|
const sortedContracts = computed(() =>
|
||||||
|
[...contracts.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||||
|
);
|
||||||
|
const contractRowClass = ({ row }: { row: any }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
centerId: "",
|
centerId: "",
|
||||||
@@ -279,6 +297,10 @@ const onRowClick = (row: any) => {
|
|||||||
|
|
||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
if (!row?.id || !canWrite.value) return;
|
if (!row?.id || !canWrite.value) return;
|
||||||
|
if (isInactiveSite(row?.center_id)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
try {
|
try {
|
||||||
@@ -438,4 +460,16 @@ onMounted(async () => {
|
|||||||
color: var(--ctms-text-regular);
|
color: var(--ctms-text-regular);
|
||||||
font-family: var(--el-font-family);
|
font-family: var(--el-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.detailSubtitle }}</p>
|
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.detailSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button type="primary" :disabled="!canWrite" @click="goEdit" class="header-action-btn copy-btn">
|
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||||
{{ TEXT.common.actions.edit }}
|
{{ TEXT.common.actions.edit }}
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
:entity-type="'special_expense'"
|
:entity-type="'special_expense'"
|
||||||
:entity-id="expenseId"
|
:entity-id="expenseId"
|
||||||
:groups="attachmentGroups"
|
:groups="attachmentGroups"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
import { Edit } from "@element-plus/icons-vue";
|
import { Edit } from "@element-plus/icons-vue";
|
||||||
import { getSpecialExpense } from "../../api/feeSpecials";
|
import { getSpecialExpense } from "../../api/feeSpecials";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
@@ -123,6 +125,14 @@ const detail = reactive<any>({
|
|||||||
verified_date: "",
|
verified_date: "",
|
||||||
});
|
});
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
|
||||||
|
|
||||||
const attachmentGroups = [
|
const attachmentGroups = [
|
||||||
{
|
{
|
||||||
@@ -188,7 +198,13 @@ const getCategoryType = (category: string) => {
|
|||||||
return 'info';
|
return 'info';
|
||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => router.push(`/fees/special/${expenseId}/edit`);
|
const goEdit = () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/fees/special/${expenseId}/edit`);
|
||||||
|
};
|
||||||
const goBack = () => router.push("/fees/special");
|
const goBack = () => router.push("/fees/special");
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -29,36 +29,37 @@
|
|||||||
<el-row :gutter="24">
|
<el-row :gutter="24">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.site">
|
<el-form-item :label="TEXT.common.fields.site">
|
||||||
<el-select v-model="form.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="full-width">
|
<el-select v-model="form.center_id" clearable :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="site in sites"
|
v-for="site in sites"
|
||||||
:key="site.id"
|
:key="site.id"
|
||||||
:label="site.name || TEXT.common.fallback"
|
:label="site.name || TEXT.common.fallback"
|
||||||
:value="site.id"
|
:value="site.id"
|
||||||
|
:disabled="!site.is_active"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.occurDate" prop="happen_date">
|
<el-form-item :label="TEXT.common.fields.occurDate" prop="happen_date">
|
||||||
<el-date-picker v-model="form.happen_date" type="date" value-format="YYYY-MM-DD" class="full-width" />
|
<el-date-picker v-model="form.happen_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" class="full-width" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.category" prop="category" required>
|
<el-form-item :label="TEXT.common.fields.category" prop="category" required>
|
||||||
<el-select v-model="form.category" :placeholder="TEXT.common.placeholders.select" class="full-width">
|
<el-select v-model="form.category" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
|
||||||
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
|
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
|
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
|
||||||
<el-input-number v-model="form.amount" :min="0" :precision="2" class="full-width" controls-position="right" />
|
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-form-item :label="TEXT.common.fields.description">
|
<el-form-item :label="TEXT.common.fields.description">
|
||||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
<el-input v-model="form.description" :disabled="isReadOnly" type="textarea" :rows="3" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -76,7 +77,7 @@
|
|||||||
<div class="status-box">
|
<div class="status-box">
|
||||||
<div class="status-header">
|
<div class="status-header">
|
||||||
<span class="status-title">{{ TEXT.modules.feeSpecials.paidFlag }}</span>
|
<span class="status-title">{{ TEXT.modules.feeSpecials.paidFlag }}</span>
|
||||||
<el-switch v-model="form.is_paid" @change="onPaidToggle" />
|
<el-switch v-model="form.is_paid" :disabled="isReadOnly" @change="onPaidToggle" />
|
||||||
</div>
|
</div>
|
||||||
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.paid_date" class="status-form-item">
|
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.paid_date" class="status-form-item">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
:placeholder="TEXT.common.placeholders.select"
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
:disabled="!form.is_paid"
|
:disabled="isReadOnly || !form.is_paid"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
<div class="status-box">
|
<div class="status-box">
|
||||||
<div class="status-header">
|
<div class="status-header">
|
||||||
<span class="status-title">{{ TEXT.modules.feeSpecials.verifiedFlag }}</span>
|
<span class="status-title">{{ TEXT.modules.feeSpecials.verifiedFlag }}</span>
|
||||||
<el-switch v-model="form.is_verified" @change="onVerifiedToggle" />
|
<el-switch v-model="form.is_verified" :disabled="isReadOnly" @change="onVerifiedToggle" />
|
||||||
</div>
|
</div>
|
||||||
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.verified_date" class="status-form-item">
|
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.verified_date" class="status-form-item">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
@@ -102,7 +103,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
:placeholder="TEXT.common.placeholders.select"
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
:disabled="!form.is_verified"
|
:disabled="isReadOnly || !form.is_verified"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -126,6 +127,7 @@
|
|||||||
:entity-type="'special_expense'"
|
:entity-type="'special_expense'"
|
||||||
:entity-id="form.id"
|
:entity-id="form.id"
|
||||||
:groups="attachmentGroups"
|
:groups="attachmentGroups"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StateEmpty v-else :description="TEXT.modules.feeSpecials.uploadHint" class="compact-empty" />
|
<StateEmpty v-else :description="TEXT.modules.feeSpecials.uploadHint" class="compact-empty" />
|
||||||
@@ -133,7 +135,7 @@
|
|||||||
|
|
||||||
<div class="footer-actions">
|
<div class="footer-actions">
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
<el-button type="primary" :loading="saving" @click="submit" class="save-btn">
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit" class="save-btn">
|
||||||
{{ TEXT.common.actions.save }}
|
{{ TEXT.common.actions.save }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,6 +165,14 @@ const loading = ref(false);
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
|
||||||
|
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -301,6 +311,14 @@ const validateSpecial = () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!study.currentStudy?.id) return;
|
if (!study.currentStudy?.id) return;
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const formOk = await formRef.value?.validate?.().catch(() => false);
|
const formOk = await formRef.value?.validate?.().catch(() => false);
|
||||||
if (!formOk) return;
|
if (!formOk) return;
|
||||||
if (!validateSpecial()) {
|
if (!validateSpecial()) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ TEXT.modules.feeSpecials.title }}</h1>
|
<h1 class="page-title">{{ TEXT.modules.feeSpecials.title }}</h1>
|
||||||
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
||||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||||
@@ -109,7 +108,13 @@
|
|||||||
<StateLoading v-else-if="loading" :rows="6" />
|
<StateLoading v-else-if="loading" :rows="6" />
|
||||||
|
|
||||||
<el-card v-else class="table-card" shadow="never">
|
<el-card v-else class="table-card" shadow="never">
|
||||||
<el-table :data="expenses" style="width: 100%" @row-click="onRowClick" :header-cell-style="{ background: '#f8f9fb' }">
|
<el-table
|
||||||
|
:data="sortedExpenses"
|
||||||
|
style="width: 100%"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
:header-cell-style="{ background: '#f8f9fb' }"
|
||||||
|
:row-class-name="expenseRowClass"
|
||||||
|
>
|
||||||
<el-table-column :label="TEXT.common.fields.occurDate" width="140">
|
<el-table-column :label="TEXT.common.fields.occurDate" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
|
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
|
||||||
@@ -161,7 +166,7 @@
|
|||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!canWrite"
|
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
|
||||||
@click.stop="remove(scope.row)"
|
@click.stop="remove(scope.row)"
|
||||||
>
|
>
|
||||||
{{ TEXT.common.actions.delete }}
|
{{ TEXT.common.actions.delete }}
|
||||||
@@ -169,7 +174,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="expenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
|
<StateEmpty v-if="sortedExpenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -199,6 +204,19 @@ const loading = ref(false);
|
|||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const expenses = ref<any[]>([]);
|
const expenses = ref<any[]>([]);
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||||
|
const expenseRowClass = ({ row }: { row: any }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
||||||
|
const sortedExpenses = computed(() =>
|
||||||
|
[...expenses.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||||
|
);
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
centerId: "",
|
centerId: "",
|
||||||
@@ -287,6 +305,10 @@ const onRowClick = (row: any) => {
|
|||||||
|
|
||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
if (!row?.id || !canWrite.value) return;
|
if (!row?.id || !canWrite.value) return;
|
||||||
|
if (isInactiveSite(row?.center_id)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
try {
|
try {
|
||||||
@@ -444,4 +466,16 @@ onMounted(async () => {
|
|||||||
.text-muted {
|
.text-muted {
|
||||||
color: var(--ctms-text-placeholder);
|
color: var(--ctms-text-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="page-subtitle">{{ TEXT.modules.financeContracts.detailSubtitle }}</p>
|
<p class="page-subtitle">{{ TEXT.modules.financeContracts.detailSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,16 +28,18 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="finance_contract"
|
entity-type="finance_contract"
|
||||||
:entity-id="contractId"
|
:entity-id="contractId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { getFinanceContract } from "../../api/financeContracts";
|
import { getFinanceContract } from "../../api/financeContracts";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate } from "../../utils/display";
|
import { displayDate } from "../../utils/display";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -48,6 +50,7 @@ const study = useStudyStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const contractId = route.params.contractId as string;
|
const contractId = route.params.contractId as string;
|
||||||
const studyId = study.currentStudy?.id || "";
|
const studyId = study.currentStudy?.id || "";
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const detail = reactive<any>({
|
const detail = reactive<any>({
|
||||||
site_name: "",
|
site_name: "",
|
||||||
@@ -58,6 +61,25 @@ const detail = reactive<any>({
|
|||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!studyId || !contractId) return;
|
if (!studyId || !contractId) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -71,10 +93,19 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => router.push(`/finance/contracts/${contractId}/edit`);
|
const goEdit = () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/finance/contracts/${contractId}/edit`);
|
||||||
|
};
|
||||||
const goBack = () => router.push("/finance/contracts");
|
const goBack = () => router.push("/finance/contracts");
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -11,29 +11,29 @@
|
|||||||
<el-card>
|
<el-card>
|
||||||
<el-form ref="formRef" :model="form" label-width="110px">
|
<el-form ref="formRef" :model="form" label-width="110px">
|
||||||
<el-form-item :label="TEXT.common.fields.site" prop="site_name" required>
|
<el-form-item :label="TEXT.common.fields.site" prop="site_name" required>
|
||||||
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required>
|
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required>
|
||||||
<el-input v-model="form.contract_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
|
<el-input v-model="form.contract_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.signedDate">
|
<el-form-item :label="TEXT.common.fields.signedDate">
|
||||||
<el-date-picker v-model="form.signed_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
<el-date-picker v-model="form.signed_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
|
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
|
||||||
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="1000" />
|
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="1000" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.currency">
|
<el-form-item :label="TEXT.common.fields.currency">
|
||||||
<el-select v-model="form.currency" :placeholder="TEXT.common.placeholders.select">
|
<el-select v-model="form.currency" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||||
<el-option label="CNY" value="CNY" />
|
<el-option label="CNY" value="CNY" />
|
||||||
<el-option label="USD" value="USD" />
|
<el-option label="USD" value="USD" />
|
||||||
<el-option label="EUR" value="EUR" />
|
<el-option label="EUR" value="EUR" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.remark">
|
<el-form-item :label="TEXT.common.fields.remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="finance_contract"
|
entity-type="finance_contract"
|
||||||
:entity-id="contractId"
|
:entity-id="contractId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
<el-card v-else class="hint-card">
|
<el-card v-else class="hint-card">
|
||||||
<StateEmpty :description="TEXT.modules.financeContracts.uploadHint" />
|
<StateEmpty :description="TEXT.modules.financeContracts.uploadHint" />
|
||||||
@@ -57,6 +58,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { createFinanceContract, getFinanceContract, updateFinanceContract } from "../../api/financeContracts";
|
import { createFinanceContract, getFinanceContract, updateFinanceContract } from "../../api/financeContracts";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -65,6 +67,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const contractId = computed(() => route.params.contractId as string | undefined);
|
const contractId = computed(() => route.params.contractId as string | undefined);
|
||||||
const isEdit = computed(() => !!contractId.value);
|
const isEdit = computed(() => !!contractId.value);
|
||||||
@@ -79,6 +82,25 @@ const form = reactive({
|
|||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!isEdit.value || !studyId.value || !contractId.value) return;
|
if (!isEdit.value || !studyId.value || !contractId.value) return;
|
||||||
try {
|
try {
|
||||||
@@ -98,6 +120,18 @@ const load = async () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.site_name) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.required);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (siteActiveMap.value[form.site_name] === false) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -126,7 +160,10 @@ const submit = async () => {
|
|||||||
|
|
||||||
const goBack = () => router.push("/finance/contracts");
|
const goBack = () => router.push("/finance/contracts");
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.detailSubtitle }}</p>
|
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.detailSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,16 +27,18 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="finance_special"
|
entity-type="finance_special"
|
||||||
:entity-id="specialId"
|
:entity-id="specialId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { getFinanceSpecial } from "../../api/financeSpecials";
|
import { getFinanceSpecial } from "../../api/financeSpecials";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -47,6 +49,7 @@ const study = useStudyStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const specialId = route.params.specialId as string;
|
const specialId = route.params.specialId as string;
|
||||||
const studyId = study.currentStudy?.id || "";
|
const studyId = study.currentStudy?.id || "";
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const detail = reactive<any>({
|
const detail = reactive<any>({
|
||||||
site_name: "",
|
site_name: "",
|
||||||
@@ -57,6 +60,25 @@ const detail = reactive<any>({
|
|||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!studyId || !specialId) return;
|
if (!studyId || !specialId) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -70,10 +92,19 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => router.push(`/finance/special/${specialId}/edit`);
|
const goEdit = () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/finance/special/${specialId}/edit`);
|
||||||
|
};
|
||||||
const goBack = () => router.push("/finance/special");
|
const goBack = () => router.push("/finance/special");
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
<el-card>
|
<el-card>
|
||||||
<el-form :model="form" label-width="110px">
|
<el-form :model="form" label-width="110px">
|
||||||
<el-form-item :label="TEXT.common.fields.site" required>
|
<el-form-item :label="TEXT.common.fields.site" required>
|
||||||
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.type" required>
|
<el-form-item :label="TEXT.common.fields.type" required>
|
||||||
<el-select v-model="form.fee_type" :placeholder="TEXT.common.placeholders.select">
|
<el-select v-model="form.fee_type" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||||
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
|
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
|
||||||
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
|
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
|
||||||
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
|
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
|
||||||
@@ -22,19 +22,19 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.amount" required>
|
<el-form-item :label="TEXT.common.fields.amount" required>
|
||||||
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="100" />
|
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="100" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.occurDate">
|
<el-form-item :label="TEXT.common.fields.occurDate">
|
||||||
<el-date-picker v-model="form.occur_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
<el-date-picker v-model="form.occur_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.staff">
|
<el-form-item :label="TEXT.common.fields.staff">
|
||||||
<el-input v-model="form.staff_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
|
<el-input v-model="form.staff_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.remark">
|
<el-form-item :label="TEXT.common.fields.remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="finance_special"
|
entity-type="finance_special"
|
||||||
:entity-id="specialId"
|
:entity-id="specialId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
<el-card v-else class="hint-card">
|
<el-card v-else class="hint-card">
|
||||||
<StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" />
|
<StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" />
|
||||||
@@ -58,6 +59,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials";
|
import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -66,6 +68,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const specialId = computed(() => route.params.specialId as string | undefined);
|
const specialId = computed(() => route.params.specialId as string | undefined);
|
||||||
const isEdit = computed(() => !!specialId.value);
|
const isEdit = computed(() => !!specialId.value);
|
||||||
@@ -80,6 +83,25 @@ const form = reactive({
|
|||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!isEdit.value || !studyId.value || !specialId.value) return;
|
if (!isEdit.value || !studyId.value || !specialId.value) return;
|
||||||
try {
|
try {
|
||||||
@@ -99,6 +121,18 @@ const load = async () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.site_name) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.required);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (siteActiveMap.value[form.site_name] === false) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -127,7 +161,10 @@ const submit = async () => {
|
|||||||
|
|
||||||
const goBack = () => router.push("/finance/special");
|
const goBack = () => router.push("/finance/special");
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ TEXT.modules.drugShipments.title }}</h1>
|
<h1 class="page-title">{{ TEXT.modules.drugShipments.title }}</h1>
|
||||||
<p class="page-subtitle">{{ TEXT.modules.drugShipments.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="goNew">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
|
<el-button type="primary" @click="goNew">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +42,13 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-table :data="items" v-loading="loading" style="width: 100%" @row-click="onRowClick">
|
<el-table
|
||||||
|
:data="sortedItems"
|
||||||
|
v-loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
:row-class-name="shipmentRowClass"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
|
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
|
||||||
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
|
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -78,17 +83,25 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isInactiveSite(scope.row.center_id)"
|
||||||
|
@click.stop="remove(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.drugShipments.empty" />
|
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.drugShipments.empty" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
@@ -103,6 +116,7 @@ const study = useStudyStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const items = ref<any[]>([]);
|
const items = ref<any[]>([]);
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
center_id: "",
|
center_id: "",
|
||||||
direction: "",
|
direction: "",
|
||||||
@@ -115,8 +129,13 @@ const loadSites = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
siteActiveMap.value = sites.value.reduce((acc: Record<string, boolean>, site: any) => {
|
||||||
|
acc[site.id] = !!site.is_active;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
} catch {
|
} catch {
|
||||||
sites.value = [];
|
sites.value = [];
|
||||||
|
siteActiveMap.value = {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,6 +170,12 @@ const onRowClick = (row: any) => {
|
|||||||
if (!row?.id) return;
|
if (!row?.id) return;
|
||||||
goDetail(row.id);
|
goDetail(row.id);
|
||||||
};
|
};
|
||||||
|
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||||
|
const shipmentRowClass = ({ row }: { row: any }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||||
|
);
|
||||||
|
|
||||||
const statusType = (status: string) => {
|
const statusType = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -172,6 +197,10 @@ const statusType = (status: string) => {
|
|||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
|
if (isInactiveSite(row?.center_id)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips).catch(() => null);
|
const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips).catch(() => null);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
@@ -196,6 +225,7 @@ onMounted(async () => {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -218,3 +248,14 @@ onMounted(async () => {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -24,7 +24,14 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
|
<el-table
|
||||||
|
:data="sortedItems"
|
||||||
|
v-loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
class="ctms-table"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
:row-class-name="contractRowClass"
|
||||||
|
>
|
||||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||||
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
|
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
|
||||||
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
|
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
|
||||||
@@ -44,21 +51,30 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isInactiveSite(scope.row.site_name)"
|
||||||
|
@click.stop="remove(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.financeContracts.empty" />
|
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeContracts.empty" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts";
|
import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayDateTime } from "../../utils/display";
|
import { displayDate, displayDateTime } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -67,11 +83,37 @@ const router = useRouter();
|
|||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const items = ref<any[]>([]);
|
const items = ref<any[]>([]);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
site_name: "",
|
site_name: "",
|
||||||
contract_no: "",
|
contract_no: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
|
||||||
|
const contractRowClass = ({ row }: { row: any }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
const studyId = study.currentStudy?.id;
|
||||||
|
if (!studyId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
@@ -105,6 +147,10 @@ const onRowClick = (row: any) => {
|
|||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
|
if (isInactiveSite(row?.site_name)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
try {
|
try {
|
||||||
@@ -116,7 +162,10 @@ const remove = async (row: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -147,4 +196,17 @@ onMounted(load);
|
|||||||
.filter-card :deep(.el-form-item) {
|
.filter-card :deep(.el-form-item) {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -29,7 +29,14 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
|
<el-table
|
||||||
|
:data="sortedItems"
|
||||||
|
v-loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
class="ctms-table"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
:row-class-name="specialRowClass"
|
||||||
|
>
|
||||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||||
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
|
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
@@ -54,21 +61,30 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isInactiveSite(scope.row.site_name)"
|
||||||
|
@click.stop="remove(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.financeSpecials.empty" />
|
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeSpecials.empty" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
|
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayDateTime, displayEnum } from "../../utils/display";
|
import { displayDate, displayDateTime, displayEnum } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -77,11 +93,37 @@ const router = useRouter();
|
|||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const items = ref<any[]>([]);
|
const items = ref<any[]>([]);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
site_name: "",
|
site_name: "",
|
||||||
fee_type: "",
|
fee_type: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
|
||||||
|
const specialRowClass = ({ row }: { row: any }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
const studyId = study.currentStudy?.id;
|
||||||
|
if (!studyId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
@@ -115,6 +157,10 @@ const onRowClick = (row: any) => {
|
|||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
|
if (isInactiveSite(row?.site_name)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
try {
|
try {
|
||||||
@@ -126,7 +172,10 @@ const remove = async (row: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -157,4 +206,16 @@ onMounted(load);
|
|||||||
.filter-card :deep(.el-form-item) {
|
.filter-card :deep(.el-form-item) {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModulePlaceholder
|
<ModulePlaceholder
|
||||||
:title="TEXT.modules.knowledgeInstructionFiles.title"
|
:title="TEXT.modules.knowledgeInstructionFiles.title"
|
||||||
:subtitle="TEXT.modules.knowledgeInstructionFiles.subtitle"
|
|
||||||
:list-title="TEXT.modules.knowledgeInstructionFiles.listTitle"
|
:list-title="TEXT.modules.knowledgeInstructionFiles.listTitle"
|
||||||
:empty-description="TEXT.modules.knowledgeInstructionFiles.emptyDescription"
|
:empty-description="TEXT.modules.knowledgeInstructionFiles.emptyDescription"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ TEXT.modules.knowledgeNotes.title }}</h1>
|
<h1 class="page-title">{{ TEXT.modules.knowledgeNotes.title }}</h1>
|
||||||
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="goNew">{{ TEXT.modules.knowledgeNotes.newTitle }}</el-button>
|
<el-button type="primary" @click="goNew">{{ TEXT.modules.knowledgeNotes.newTitle }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,7 +23,14 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
|
<el-table
|
||||||
|
:data="sortedItems"
|
||||||
|
v-loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
class="ctms-table"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
:row-class-name="noteRowClass"
|
||||||
|
>
|
||||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||||
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
|
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
|
||||||
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
|
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
|
||||||
@@ -33,21 +39,30 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isInactiveSite(scope.row.site_name)"
|
||||||
|
@click.stop="remove(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
|
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes";
|
import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDateTime } from "../../utils/display";
|
import { displayDateTime } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -56,11 +71,37 @@ const router = useRouter();
|
|||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const items = ref<any[]>([]);
|
const items = ref<any[]>([]);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
site_name: "",
|
site_name: "",
|
||||||
keyword: "",
|
keyword: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
|
||||||
|
const noteRowClass = ({ row }: { row: any }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
const studyId = study.currentStudy?.id;
|
||||||
|
if (!studyId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
@@ -94,6 +135,10 @@ const onRowClick = (row: any) => {
|
|||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
|
if (isInactiveSite(row?.site_name)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
try {
|
try {
|
||||||
@@ -105,7 +150,10 @@ const remove = async (row: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -136,4 +184,16 @@ onMounted(load);
|
|||||||
.filter-card :deep(.el-form-item) {
|
.filter-card :deep(.el-form-item) {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModulePlaceholder
|
<ModulePlaceholder
|
||||||
:title="TEXT.modules.knowledgeSupportFiles.title"
|
:title="TEXT.modules.knowledgeSupportFiles.title"
|
||||||
:subtitle="TEXT.modules.knowledgeSupportFiles.subtitle"
|
|
||||||
:list-title="TEXT.modules.knowledgeSupportFiles.listTitle"
|
:list-title="TEXT.modules.knowledgeSupportFiles.listTitle"
|
||||||
:empty-description="TEXT.modules.knowledgeSupportFiles.emptyDescription"
|
:empty-description="TEXT.modules.knowledgeSupportFiles.emptyDescription"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">项目概览</h1>
|
<h1 class="page-title">项目概览</h1>
|
||||||
<p class="page-subtitle">多中心项目整体进度与入组情况</p>
|
|
||||||
<p class="study-meta">
|
<p class="study-meta">
|
||||||
<span class="study-name">{{ study.currentStudy.name }}</span>
|
<span class="study-name">{{ study.currentStudy.name }}</span>
|
||||||
<span class="study-divider">/</span>
|
<span class="study-divider">/</span>
|
||||||
@@ -81,6 +80,7 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
import { displayDateTime } from "../../utils/display";
|
import { displayDateTime } from "../../utils/display";
|
||||||
import { fetchProjectOverview } from "../../api/overview";
|
import { fetchProjectOverview } from "../../api/overview";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import StateLoading from "../../components/StateLoading.vue";
|
import StateLoading from "../../components/StateLoading.vue";
|
||||||
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
|
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
|
||||||
@@ -146,14 +146,28 @@ const loadOverview = async () => {
|
|||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
const sitesResp = await fetchSites(studyId, { limit: 500 });
|
||||||
|
const siteList = Array.isArray(sitesResp.data) ? sitesResp.data : (sitesResp.data as any).items || [];
|
||||||
|
const siteActiveMap = new Map<string, boolean>(siteList.map((s: any) => [s.id, !!s.is_active]));
|
||||||
|
|
||||||
if (preferApi) {
|
if (preferApi) {
|
||||||
const { data } = await fetchProjectOverview(studyId);
|
const { data } = await fetchProjectOverview(studyId);
|
||||||
overview.value = adaptProjectOverview(data);
|
overview.value = adaptProjectOverview(data);
|
||||||
usingDemo.value = false;
|
usingDemo.value = false;
|
||||||
return;
|
} else {
|
||||||
|
overview.value = adaptProjectOverview(overviewMock);
|
||||||
|
usingDemo.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overview.value) {
|
||||||
|
overview.value.centers.forEach(c => {
|
||||||
|
if (siteActiveMap.has(c.center_id)) {
|
||||||
|
c.is_active = siteActiveMap.get(c.center_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Sort: Inactive last
|
||||||
|
overview.value.centers.sort((a, b) => Number(b.is_active) - Number(a.is_active));
|
||||||
}
|
}
|
||||||
overview.value = adaptProjectOverview(overviewMock);
|
|
||||||
usingDemo.value = true;
|
|
||||||
} catch {
|
} catch {
|
||||||
overview.value = adaptProjectOverview(overviewMock);
|
overview.value = adaptProjectOverview(overviewMock);
|
||||||
usingDemo.value = true;
|
usingDemo.value = true;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="ctms-page-header">
|
<div class="ctms-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.title }}</h1>
|
<h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.title }}</h1>
|
||||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,11 +20,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table
|
<el-table
|
||||||
:data="feasibilityItems"
|
:data="sortedFeasibilityItems"
|
||||||
v-loading="loadingFeasibility"
|
v-loading="loadingFeasibility"
|
||||||
class="ctms-table"
|
class="ctms-table"
|
||||||
|
:row-class-name="feasibilityRowClass"
|
||||||
@row-click="onFeasibilityRowClick"
|
@row-click="onFeasibilityRowClick"
|
||||||
row-class-name="clickable-row"
|
|
||||||
>
|
>
|
||||||
<el-table-column :label="TEXT.common.fields.site" width="180">
|
<el-table-column :label="TEXT.common.fields.site" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
@@ -55,11 +54,18 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="danger" @click.stop="removeFeasibility(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:disabled="isInactiveSite(scope.row.site_id)"
|
||||||
|
@click.stop="removeFeasibility(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loadingFeasibility && feasibilityItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyFeasibility" />
|
<StateEmpty v-if="!loadingFeasibility && sortedFeasibilityItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyFeasibility" />
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
|
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
|
||||||
@@ -74,11 +80,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table
|
<el-table
|
||||||
:data="ethicsItems"
|
:data="sortedEthicsItems"
|
||||||
v-loading="loadingEthics"
|
v-loading="loadingEthics"
|
||||||
class="ctms-table"
|
class="ctms-table"
|
||||||
|
:row-class-name="ethicsRowClass"
|
||||||
@row-click="onEthicsRowClick"
|
@row-click="onEthicsRowClick"
|
||||||
row-class-name="clickable-row"
|
|
||||||
>
|
>
|
||||||
<el-table-column :label="TEXT.common.fields.site" width="180">
|
<el-table-column :label="TEXT.common.fields.site" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
@@ -111,11 +117,18 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="danger" @click.stop="removeEthics(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:disabled="isInactiveSite(scope.row.site_id)"
|
||||||
|
@click.stop="removeEthics(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loadingEthics && ethicsItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyEthics" />
|
<StateEmpty v-if="!loadingEthics && sortedEthicsItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyEthics" />
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -148,7 +161,15 @@ const loadingEthics = ref(false);
|
|||||||
const sites = ref<Site[]>([]);
|
const sites = ref<Site[]>([]);
|
||||||
|
|
||||||
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
||||||
|
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
|
||||||
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
||||||
|
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
|
||||||
|
const sortedFeasibilityItems = computed(() =>
|
||||||
|
[...feasibilityItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||||
|
);
|
||||||
|
const sortedEthicsItems = computed(() =>
|
||||||
|
[...ethicsItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||||
|
);
|
||||||
|
|
||||||
const statusLabelMap: Record<StageStatus, string> = {
|
const statusLabelMap: Record<StageStatus, string> = {
|
||||||
COMPLETED: "已完成",
|
COMPLETED: "已完成",
|
||||||
@@ -249,6 +270,10 @@ const goNewEthics = () => router.push("/startup/ethics/new");
|
|||||||
const goEthicsDetail = (id: string) => router.push(`/startup/ethics/${id}`);
|
const goEthicsDetail = (id: string) => router.push(`/startup/ethics/${id}`);
|
||||||
const onFeasibilityRowClick = (row: any) => goFeasibilityDetail(row.id);
|
const onFeasibilityRowClick = (row: any) => goFeasibilityDetail(row.id);
|
||||||
const onEthicsRowClick = (row: any) => goEthicsDetail(row.id);
|
const onEthicsRowClick = (row: any) => goEthicsDetail(row.id);
|
||||||
|
const feasibilityRowClass = ({ row }: { row: any }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||||
|
const ethicsRowClass = ({ row }: { row: any }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||||
|
|
||||||
const removeFeasibility = async (row: any) => {
|
const removeFeasibility = async (row: any) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
@@ -291,3 +316,14 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.title }}</h1>
|
<h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.title }}</h1>
|
||||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-table :data="kickoffRows" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onKickoffRowClick">
|
<el-table
|
||||||
|
:data="kickoffRows"
|
||||||
|
v-loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
class="ctms-table"
|
||||||
|
:row-class-name="kickoffRowClass"
|
||||||
|
@row-click="onKickoffRowClick"
|
||||||
|
>
|
||||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="180" />
|
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="180" />
|
||||||
<el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160">
|
<el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160">
|
||||||
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
|
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
|
||||||
@@ -61,6 +67,14 @@ const memberNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
const contactLabel = (row: any) => {
|
const contactLabel = (row: any) => {
|
||||||
if (!row?.contact) return TEXT.common.fallback;
|
if (!row?.contact) return TEXT.common.fallback;
|
||||||
return String(row.contact)
|
return String(row.contact)
|
||||||
@@ -76,17 +90,20 @@ const kickoffRows = computed(() => {
|
|||||||
if (item.site_id) acc[item.site_id] = item;
|
if (item.site_id) acc[item.site_id] = item;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
return sites.value.map((site) => {
|
return sites.value
|
||||||
const meeting = kickoffMap[site.id];
|
.map((site) => {
|
||||||
return {
|
const meeting = kickoffMap[site.id];
|
||||||
site_id: site.id,
|
return {
|
||||||
site_name: site.name || TEXT.common.fallback,
|
site_id: site.id,
|
||||||
contact: site.contact,
|
site_name: site.name || TEXT.common.fallback,
|
||||||
kickoff_date: meeting?.kickoff_date || null,
|
contact: site.contact,
|
||||||
meeting_id: meeting?.id,
|
kickoff_date: meeting?.kickoff_date || null,
|
||||||
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
|
meeting_id: meeting?.id,
|
||||||
};
|
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
|
||||||
});
|
is_active: !!site.is_active,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => Number(a.is_active === false) - Number(b.is_active === false));
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadKickoffs = async () => {
|
const loadKickoffs = async () => {
|
||||||
@@ -115,6 +132,10 @@ const goKickoffDetail = (id: string) => router.push(`/startup/kickoff/${id}`);
|
|||||||
const onKickoffRowClick = async (row: any) => {
|
const onKickoffRowClick = async (row: any) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId || !row?.site_id) return;
|
if (!studyId || !row?.site_id) return;
|
||||||
|
if (row.is_active === false && !row.meeting_id) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (row.meeting_id) {
|
if (row.meeting_id) {
|
||||||
goKickoffDetail(row.meeting_id);
|
goKickoffDetail(row.meeting_id);
|
||||||
return;
|
return;
|
||||||
@@ -126,6 +147,8 @@ const onKickoffRowClick = async (row: any) => {
|
|||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const kickoffRowClass = ({ row }: { row: any }) => (row?.is_active === false ? "row-inactive" : "");
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadKickoffs();
|
loadKickoffs();
|
||||||
});
|
});
|
||||||
@@ -157,3 +180,14 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,33 +3,19 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ TEXT.modules.subjectManagement.title }}</h1>
|
<h1 class="page-title">{{ TEXT.modules.subjectManagement.title }}</h1>
|
||||||
<p class="page-subtitle">{{ TEXT.modules.subjectManagement.subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="goNew">{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</el-button>
|
<el-button type="primary" @click="goNew">{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card class="filter-card">
|
|
||||||
<el-form :inline="true" :model="filters">
|
|
||||||
<el-form-item :label="TEXT.common.fields.subjectNo">
|
|
||||||
<el-input v-model="filters.subject_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo" clearable />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item :label="TEXT.common.fields.status">
|
|
||||||
<el-select v-model="filters.status" :placeholder="TEXT.common.placeholders.select" clearable>
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
|
|
||||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
|
<el-table
|
||||||
|
:data="sortedItems"
|
||||||
|
v-loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
class="ctms-table"
|
||||||
|
:row-class-name="subjectRowClass"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
<el-table-column prop="subject_no" :label="TEXT.common.fields.subjectNo" min-width="140" />
|
<el-table-column prop="subject_no" :label="TEXT.common.fields.subjectNo" min-width="140" />
|
||||||
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
||||||
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
|
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
|
||||||
@@ -42,17 +28,25 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isInactiveSite(scope.row.site_id)"
|
||||||
|
@click.stop="remove(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.subjectManagement.empty" />
|
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
@@ -67,11 +61,7 @@ const study = useStudyStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const items = ref<any[]>([]);
|
const items = ref<any[]>([]);
|
||||||
const siteMap = ref<Record<string, string>>({});
|
const siteMap = ref<Record<string, string>>({});
|
||||||
const filters = reactive({
|
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||||
subject_no: "",
|
|
||||||
status: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadSites = async () => {
|
const loadSites = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
@@ -82,8 +72,13 @@ const loadSites = async () => {
|
|||||||
acc[site.id] = site.name;
|
acc[site.id] = site.name;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
siteActiveMap.value = list.reduce((acc: Record<string, boolean>, site: any) => {
|
||||||
|
acc[site.id] = !!site.is_active;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
} catch {
|
} catch {
|
||||||
siteMap.value = {};
|
siteMap.value = {};
|
||||||
|
siteActiveMap.value = {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,10 +87,7 @@ const load = async () => {
|
|||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await fetchSubjects(studyId, {
|
const { data } = await fetchSubjects(studyId);
|
||||||
subject_no: filters.subject_no || undefined,
|
|
||||||
status_filter: filters.status || undefined,
|
|
||||||
});
|
|
||||||
items.value = Array.isArray(data) ? data : data.items || [];
|
items.value = Array.isArray(data) ? data : data.items || [];
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
@@ -104,12 +96,6 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetFilters = () => {
|
|
||||||
filters.subject_no = "";
|
|
||||||
filters.status = "";
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const goNew = () => router.push("/subjects/new");
|
const goNew = () => router.push("/subjects/new");
|
||||||
const goDetail = (id: string) => router.push(`/subjects/${id}`);
|
const goDetail = (id: string) => router.push(`/subjects/${id}`);
|
||||||
const onRowClick = (row: any) => {
|
const onRowClick = (row: any) => {
|
||||||
@@ -119,6 +105,10 @@ const onRowClick = (row: any) => {
|
|||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
|
if (isInactiveSite(row?.site_id)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
try {
|
try {
|
||||||
@@ -130,6 +120,13 @@ const remove = async (row: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||||
|
);
|
||||||
|
const subjectRowClass = ({ row }: { row: any }) =>
|
||||||
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSites();
|
await loadSites();
|
||||||
load();
|
load();
|
||||||
@@ -161,7 +158,15 @@ onMounted(async () => {
|
|||||||
color: var(--ctms-text-secondary);
|
color: var(--ctms-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-card :deep(.el-form-item) {
|
</style>
|
||||||
margin-bottom: 0;
|
|
||||||
|
<style>
|
||||||
|
.row-inactive td {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
background-color: #fff7d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .el-tag {
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="center-row">
|
<div class="center-row" :class="{ 'row-inactive': isInactive }">
|
||||||
<div class="center-meta">
|
<div class="center-meta">
|
||||||
<div class="center-name">{{ centerName }}</div>
|
<div class="center-name">{{ centerName }}</div>
|
||||||
<div class="center-enrollment">
|
<div class="center-enrollment">
|
||||||
@@ -46,6 +46,7 @@ const stages = computed(() =>
|
|||||||
completedAt: props.center[stage.completedKey],
|
completedAt: props.center[stage.completedKey],
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
const isInactive = computed(() => props.center.is_active === false);
|
||||||
|
|
||||||
const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase()}`;
|
const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase()}`;
|
||||||
</script>
|
</script>
|
||||||
@@ -174,4 +175,17 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-inactive {
|
||||||
|
background-color: #fff7d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .center-name {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive .stage-connector,
|
||||||
|
.row-inactive .center-timeline {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface CenterOverview {
|
|||||||
enrollment_completed_at?: string;
|
enrollment_completed_at?: string;
|
||||||
inspection_completed_at?: string;
|
inspection_completed_at?: string;
|
||||||
closeout_completed_at?: string;
|
closeout_completed_at?: string;
|
||||||
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnrollmentByMonth {
|
export interface EnrollmentByMonth {
|
||||||
@@ -67,14 +68,14 @@ export const STAGE_ORDER: Array<{
|
|||||||
label: string;
|
label: string;
|
||||||
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" },
|
||||||
{ key: "inspection_status", label: "末次稽查", completedKey: "inspection_completed_at" },
|
{ key: "inspection_status", label: "末次稽查", completedKey: "inspection_completed_at" },
|
||||||
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
|
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_ALIAS: Record<string, StageStatus> = {
|
const STATUS_ALIAS: Record<string, StageStatus> = {
|
||||||
NOT_STARTED: "NOT_STARTED",
|
NOT_STARTED: "NOT_STARTED",
|
||||||
@@ -130,6 +131,7 @@ export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel =>
|
|||||||
enrollment_completed_at: toString((center as any).enrollment_completed_at),
|
enrollment_completed_at: toString((center as any).enrollment_completed_at),
|
||||||
inspection_completed_at: toString((center as any).inspection_completed_at),
|
inspection_completed_at: toString((center as any).inspection_completed_at),
|
||||||
closeout_completed_at: toString((center as any).closeout_completed_at),
|
closeout_completed_at: toString((center as any).closeout_completed_at),
|
||||||
|
is_active: true, // Will be updated by view
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.detailSubtitle }}</p>
|
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.detailSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,16 +25,18 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="knowledge_note"
|
entity-type="knowledge_note"
|
||||||
:entity-id="noteId"
|
:entity-id="noteId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { getKnowledgeNote } from "../../api/knowledgeNotes";
|
import { getKnowledgeNote } from "../../api/knowledgeNotes";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ const study = useStudyStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const noteId = route.params.noteId as string;
|
const noteId = route.params.noteId as string;
|
||||||
const studyId = study.currentStudy?.id || "";
|
const studyId = study.currentStudy?.id || "";
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const detail = reactive<any>({
|
const detail = reactive<any>({
|
||||||
site_name: "",
|
site_name: "",
|
||||||
@@ -52,6 +55,25 @@ const detail = reactive<any>({
|
|||||||
content: "",
|
content: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!studyId || !noteId) return;
|
if (!studyId || !noteId) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -65,10 +87,19 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => router.push(`/knowledge/notes/${noteId}/edit`);
|
const goEdit = () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/knowledge/notes/${noteId}/edit`);
|
||||||
|
};
|
||||||
const goBack = () => router.push("/knowledge/notes");
|
const goBack = () => router.push("/knowledge/notes");
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -11,19 +11,19 @@
|
|||||||
<el-card>
|
<el-card>
|
||||||
<el-form :model="form" label-width="120px">
|
<el-form :model="form" label-width="120px">
|
||||||
<el-form-item :label="TEXT.common.fields.site" required>
|
<el-form-item :label="TEXT.common.fields.site" required>
|
||||||
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.title" required>
|
<el-form-item :label="TEXT.common.fields.title" required>
|
||||||
<el-input v-model="form.title" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" />
|
<el-input v-model="form.title" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.level">
|
<el-form-item :label="TEXT.common.fields.level">
|
||||||
<el-input v-model="form.level" :placeholder="TEXT.common.placeholders.input" />
|
<el-input v-model="form.level" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.content" required>
|
<el-form-item :label="TEXT.common.fields.content" required>
|
||||||
<el-input v-model="form.content" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" />
|
<el-input v-model="form.content" :disabled="isReadOnly" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="knowledge_note"
|
entity-type="knowledge_note"
|
||||||
:entity-id="noteId"
|
:entity-id="noteId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
<el-card v-else class="hint-card">
|
<el-card v-else class="hint-card">
|
||||||
<StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" />
|
<StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" />
|
||||||
@@ -47,6 +48,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { createKnowledgeNote, getKnowledgeNote, updateKnowledgeNote } from "../../api/knowledgeNotes";
|
import { createKnowledgeNote, getKnowledgeNote, updateKnowledgeNote } from "../../api/knowledgeNotes";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -55,6 +57,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const noteId = computed(() => route.params.noteId as string | undefined);
|
const noteId = computed(() => route.params.noteId as string | undefined);
|
||||||
const isEdit = computed(() => !!noteId.value);
|
const isEdit = computed(() => !!noteId.value);
|
||||||
@@ -67,6 +70,25 @@ const form = reactive({
|
|||||||
content: "",
|
content: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!isEdit.value || !studyId.value || !noteId.value) return;
|
if (!isEdit.value || !studyId.value || !noteId.value) return;
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +106,18 @@ const load = async () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.site_name) {
|
||||||
|
ElMessage.warning(TEXT.common.messages.required);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (siteActiveMap.value[form.site_name] === false) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -110,7 +144,10 @@ const submit = async () => {
|
|||||||
|
|
||||||
const goBack = () => router.push("/knowledge/notes");
|
const goBack = () => router.push("/knowledge/notes");
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailSubtitle }}</p>
|
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctms-page-actions">
|
<div class="ctms-page-actions">
|
||||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
<el-button @click="goBack">
|
<el-button @click="goBack">
|
||||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||||
{{ TEXT.common.actions.back }}
|
{{ TEXT.common.actions.back }}
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="startup_ethics"
|
entity-type="startup_ethics"
|
||||||
:entity-id="recordId"
|
:entity-id="recordId"
|
||||||
|
:readonly="isInactiveSite(detail.site_id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,7 +64,9 @@ const studyId = study.currentStudy?.id || "";
|
|||||||
const sites = ref<Site[]>([]);
|
const sites = ref<Site[]>([]);
|
||||||
|
|
||||||
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
||||||
|
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
|
||||||
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
||||||
|
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
|
||||||
|
|
||||||
const detail = reactive<any>({
|
const detail = reactive<any>({
|
||||||
site_id: "",
|
site_id: "",
|
||||||
@@ -137,7 +142,13 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => router.push(`/startup/ethics/${recordId}/edit`);
|
const goEdit = () => {
|
||||||
|
if (isInactiveSite(detail.site_id)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/startup/ethics/${recordId}/edit`);
|
||||||
|
};
|
||||||
const goBack = () => router.push("/startup/feasibility-ethics");
|
const goBack = () => router.push("/startup/feasibility-ethics");
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -18,14 +18,14 @@
|
|||||||
<el-row :gutter="24">
|
<el-row :gutter="24">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.site">
|
<el-form-item :label="TEXT.common.fields.site">
|
||||||
<el-select v-model="form.site_id" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" />
|
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.approvalNo">
|
<el-form-item :label="TEXT.common.fields.approvalNo">
|
||||||
<el-input v-model="form.approval_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" />
|
<el-input v-model="form.approval_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -33,28 +33,56 @@
|
|||||||
<el-row :gutter="24">
|
<el-row :gutter="24">
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item :label="TEXT.common.fields.submitDate">
|
<el-form-item :label="TEXT.common.fields.submitDate">
|
||||||
<el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
<el-date-picker
|
||||||
|
v-model="form.submit_date"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item :label="TEXT.common.fields.acceptDate">
|
<el-form-item :label="TEXT.common.fields.acceptDate">
|
||||||
<el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
<el-date-picker
|
||||||
|
v-model="form.accept_date"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item :label="TEXT.common.fields.meetingDate">
|
<el-form-item :label="TEXT.common.fields.meetingDate">
|
||||||
<el-date-picker v-model="form.meeting_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
<el-date-picker
|
||||||
|
v-model="form.meeting_date"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item :label="TEXT.common.fields.approvedDate">
|
<el-form-item :label="TEXT.common.fields.approvedDate">
|
||||||
<el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
<el-date-picker
|
||||||
|
v-model="form.approved_date"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-form-item class="form-actions">
|
<el-form-item class="form-actions">
|
||||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -68,6 +96,7 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="startup_ethics"
|
entity-type="startup_ethics"
|
||||||
:entity-id="recordId"
|
:entity-id="recordId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<el-card v-else class="hint-card">
|
<el-card v-else class="hint-card">
|
||||||
@@ -100,8 +129,16 @@ const studyId = computed(() => study.currentStudy?.id || "");
|
|||||||
const sites = ref<Site[]>([]);
|
const sites = ref<Site[]>([]);
|
||||||
|
|
||||||
const siteOptions = computed(() =>
|
const siteOptions = computed(() =>
|
||||||
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback }))
|
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback, disabled: !site.is_active }))
|
||||||
);
|
);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
site_id: "",
|
site_id: "",
|
||||||
@@ -141,6 +178,14 @@ const load = async () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.site_id && siteActiveMap.value[form.site_id] === false) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailSubtitle }}</p>
|
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctms-page-actions">
|
<div class="ctms-page-actions">
|
||||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
<el-button @click="goBack">
|
<el-button @click="goBack">
|
||||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||||
{{ TEXT.common.actions.back }}
|
{{ TEXT.common.actions.back }}
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="startup_feasibility"
|
entity-type="startup_feasibility"
|
||||||
:entity-id="recordId"
|
:entity-id="recordId"
|
||||||
|
:readonly="isInactiveSite(detail.site_id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,7 +63,9 @@ const studyId = study.currentStudy?.id || "";
|
|||||||
const sites = ref<Site[]>([]);
|
const sites = ref<Site[]>([]);
|
||||||
|
|
||||||
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
||||||
|
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
|
||||||
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
||||||
|
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
|
||||||
|
|
||||||
const detail = reactive<any>({
|
const detail = reactive<any>({
|
||||||
site_id: "",
|
site_id: "",
|
||||||
@@ -135,7 +140,13 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => router.push(`/startup/feasibility/${recordId}/edit`);
|
const goEdit = () => {
|
||||||
|
if (isInactiveSite(detail.site_id)) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/startup/feasibility/${recordId}/edit`);
|
||||||
|
};
|
||||||
const goBack = () => router.push("/startup/feasibility-ethics");
|
const goBack = () => router.push("/startup/feasibility-ethics");
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -18,14 +18,14 @@
|
|||||||
<el-row :gutter="24">
|
<el-row :gutter="24">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.site">
|
<el-form-item :label="TEXT.common.fields.site">
|
||||||
<el-select v-model="form.site_id" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" />
|
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item :label="TEXT.common.fields.projectNo">
|
<el-form-item :label="TEXT.common.fields.projectNo">
|
||||||
<el-input v-model="form.project_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" />
|
<el-input v-model="form.project_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -33,23 +33,44 @@
|
|||||||
<el-row :gutter="24">
|
<el-row :gutter="24">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item :label="TEXT.common.fields.submitDate">
|
<el-form-item :label="TEXT.common.fields.submitDate">
|
||||||
<el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
<el-date-picker
|
||||||
|
v-model="form.submit_date"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item :label="TEXT.common.fields.acceptDate">
|
<el-form-item :label="TEXT.common.fields.acceptDate">
|
||||||
<el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
<el-date-picker
|
||||||
|
v-model="form.accept_date"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item :label="TEXT.common.fields.approvedDate">
|
<el-form-item :label="TEXT.common.fields.approvedDate">
|
||||||
<el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
<el-date-picker
|
||||||
|
v-model="form.approved_date"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-form-item class="form-actions">
|
<el-form-item class="form-actions">
|
||||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -63,6 +84,7 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="startup_feasibility"
|
entity-type="startup_feasibility"
|
||||||
:entity-id="recordId"
|
:entity-id="recordId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<el-card v-else class="hint-card">
|
<el-card v-else class="hint-card">
|
||||||
@@ -95,8 +117,16 @@ const studyId = computed(() => study.currentStudy?.id || "");
|
|||||||
const sites = ref<Site[]>([]);
|
const sites = ref<Site[]>([]);
|
||||||
|
|
||||||
const siteOptions = computed(() =>
|
const siteOptions = computed(() =>
|
||||||
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback }))
|
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback, disabled: !site.is_active }))
|
||||||
);
|
);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
site_id: "",
|
site_id: "",
|
||||||
@@ -134,6 +164,14 @@ const load = async () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.site_id && siteActiveMap.value[form.site_id] === false) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -6,7 +6,14 @@
|
|||||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p>
|
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button v-if="!editMode" type="primary" :disabled="loading" @click="startEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button
|
||||||
|
v-if="!editMode"
|
||||||
|
type="primary"
|
||||||
|
:disabled="loading || isReadOnly"
|
||||||
|
@click="startEdit"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||||
@@ -59,9 +66,16 @@
|
|||||||
<el-checkbox
|
<el-checkbox
|
||||||
v-if="isTrainingEditing(scope.row)"
|
v-if="isTrainingEditing(scope.row)"
|
||||||
v-model="trainingRowDraft.trained"
|
v-model="trainingRowDraft.trained"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<el-checkbox
|
||||||
|
v-else
|
||||||
|
v-model="scope.row.trained"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@change="toggleTraining(scope.row)"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<el-checkbox v-else v-model="scope.row.trained" @change="toggleTraining(scope.row)" @click.stop />
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.fields.authorized" width="120">
|
<el-table-column :label="TEXT.common.fields.authorized" width="120">
|
||||||
@@ -69,9 +83,16 @@
|
|||||||
<el-checkbox
|
<el-checkbox
|
||||||
v-if="isTrainingEditing(scope.row)"
|
v-if="isTrainingEditing(scope.row)"
|
||||||
v-model="trainingRowDraft.authorized"
|
v-model="trainingRowDraft.authorized"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<el-checkbox
|
||||||
|
v-else
|
||||||
|
v-model="scope.row.authorized"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@change="toggleTraining(scope.row)"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<el-checkbox v-else v-model="scope.row.authorized" @change="toggleTraining(scope.row)" @click.stop />
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||||
@@ -85,17 +106,19 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-button link type="primary" size="small" @click.stop="startTrainingEdit(scope.row)">
|
<el-button link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
|
||||||
{{ TEXT.common.actions.edit }}
|
{{ TEXT.common.actions.edit }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="danger" size="small" @click.stop="removeTraining(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
<el-button link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div
|
<div
|
||||||
class="training-add-row"
|
class="training-add-row"
|
||||||
:class="{ disabled: newTrainingActive || savingTraining }"
|
:class="{ disabled: newTrainingActive || savingTraining || isReadOnly }"
|
||||||
@click="startTrainingAdd"
|
@click="startTrainingAdd"
|
||||||
>
|
>
|
||||||
<span class="training-add-icon">+</span>
|
<span class="training-add-icon">+</span>
|
||||||
@@ -149,7 +172,7 @@ const saving = ref(false);
|
|||||||
const editMode = ref(false);
|
const editMode = ref(false);
|
||||||
const meetingId = route.params.meetingId as string;
|
const meetingId = route.params.meetingId as string;
|
||||||
const studyId = study.currentStudy?.id || "";
|
const studyId = study.currentStudy?.id || "";
|
||||||
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" });
|
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
|
||||||
const users = ref<any[]>([]);
|
const users = ref<any[]>([]);
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
const kickoffAttachmentGroups = [
|
const kickoffAttachmentGroups = [
|
||||||
@@ -217,6 +240,7 @@ const statusLabel = computed(() =>
|
|||||||
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending
|
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending
|
||||||
);
|
);
|
||||||
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
|
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
|
||||||
|
const isReadOnly = computed(() => siteInfo.is_active === false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!studyId || !meetingId) return;
|
if (!studyId || !meetingId) return;
|
||||||
@@ -256,6 +280,10 @@ const loadTraining = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleTraining = async (row: any) => {
|
const toggleTraining = async (row: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
try {
|
try {
|
||||||
await updateTrainingAuthorization(studyId, row.id, {
|
await updateTrainingAuthorization(studyId, row.id, {
|
||||||
@@ -282,14 +310,14 @@ const isTrainingEditing = (row: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startTrainingEdit = (row: any) => {
|
const startTrainingEdit = (row: any) => {
|
||||||
if (savingTraining.value) return;
|
if (savingTraining.value || isReadOnly.value) return;
|
||||||
newTrainingActive.value = false;
|
newTrainingActive.value = false;
|
||||||
trainingEditingRowId.value = row?.id || null;
|
trainingEditingRowId.value = row?.id || null;
|
||||||
resetTrainingDraft(row);
|
resetTrainingDraft(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTrainingAdd = () => {
|
const startTrainingAdd = () => {
|
||||||
if (newTrainingActive.value || savingTraining.value) return;
|
if (newTrainingActive.value || savingTraining.value || isReadOnly.value) return;
|
||||||
trainingEditingRowId.value = null;
|
trainingEditingRowId.value = null;
|
||||||
newTrainingActive.value = true;
|
newTrainingActive.value = true;
|
||||||
resetTrainingDraft();
|
resetTrainingDraft();
|
||||||
@@ -302,6 +330,10 @@ const cancelTrainingEdit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveTraining = async (row: any) => {
|
const saveTraining = async (row: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
if (!trainingRowDraft.name.trim()) {
|
if (!trainingRowDraft.name.trim()) {
|
||||||
ElMessage.error(requiredMessage(TEXT.common.fields.name));
|
ElMessage.error(requiredMessage(TEXT.common.fields.name));
|
||||||
@@ -332,6 +364,10 @@ const saveTraining = async (row: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeTraining = async (row: any) => {
|
const removeTraining = async (row: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
@@ -345,6 +381,10 @@ const removeTraining = async (row: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
editMode.value = true;
|
editMode.value = true;
|
||||||
draft.kickoff_date = detail.kickoff_date || "";
|
draft.kickoff_date = detail.kickoff_date || "";
|
||||||
};
|
};
|
||||||
@@ -355,6 +395,10 @@ const cancelEdit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveEdit = async () => {
|
const saveEdit = async () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !meetingId) return;
|
if (!studyId || !meetingId) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,18 +17,19 @@
|
|||||||
<el-input :model-value="ownerLabel" disabled />
|
<el-input :model-value="ownerLabel" disabled />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.kickoffDate">
|
<el-form-item :label="TEXT.common.fields.kickoffDate">
|
||||||
<el-date-picker v-model="form.kickoff_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
<el-date-picker v-model="form.kickoff_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.attendees">
|
<el-form-item :label="TEXT.common.fields.attendees">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.attendees"
|
v-model="form.attendees"
|
||||||
|
:disabled="isReadOnly"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="4"
|
:rows="4"
|
||||||
:placeholder="TEXT.modules.startupMeetingAuth.attendeesPlaceholder"
|
:placeholder="TEXT.modules.startupMeetingAuth.attendeesPlaceholder"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
:entity-type="group.entityType"
|
:entity-type="group.entityType"
|
||||||
:entity-id="meetingId"
|
:entity-id="meetingId"
|
||||||
:title="group.title"
|
:title="group.title"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<el-card v-else class="hint-card">
|
<el-card v-else class="hint-card">
|
||||||
@@ -71,7 +73,7 @@ const saving = ref(false);
|
|||||||
const meetingId = computed(() => route.params.meetingId as string | undefined);
|
const meetingId = computed(() => route.params.meetingId as string | undefined);
|
||||||
const isEdit = computed(() => !!meetingId.value);
|
const isEdit = computed(() => !!meetingId.value);
|
||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" });
|
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "", is_active: true });
|
||||||
const siteId = ref("");
|
const siteId = ref("");
|
||||||
const users = ref<any[]>([]);
|
const users = ref<any[]>([]);
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
@@ -96,6 +98,7 @@ const ownerLabel = computed(() => {
|
|||||||
.map((c) => memberNameMap.value[c] || c)
|
.map((c) => memberNameMap.value[c] || c)
|
||||||
.join("、") || TEXT.common.fallback;
|
.join("、") || TEXT.common.fallback;
|
||||||
});
|
});
|
||||||
|
const isReadOnly = computed(() => siteInfo.is_active === false);
|
||||||
const kickoffAttachmentGroups = [
|
const kickoffAttachmentGroups = [
|
||||||
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
||||||
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
||||||
@@ -132,7 +135,7 @@ const load = async () => {
|
|||||||
const { data: sitesData } = await fetchSites(studyId.value, { limit: 500 });
|
const { data: sitesData } = await fetchSites(studyId.value, { limit: 500 });
|
||||||
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
|
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
|
||||||
const matched = list.find((site: any) => site.id === siteId.value);
|
const matched = list.find((site: any) => site.id === siteId.value);
|
||||||
Object.assign(siteInfo, matched || { name: "", contact: "" });
|
Object.assign(siteInfo, matched || { name: "", contact: "", is_active: true });
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
@@ -141,6 +144,10 @@ const load = async () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingDetailSubtitle }}</p>
|
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingDetailSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,16 +29,18 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="training_authorization"
|
entity-type="training_authorization"
|
||||||
:entity-id="recordId"
|
:entity-id="recordId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { getTrainingAuthorization } from "../../api/startup";
|
import { getTrainingAuthorization } from "../../api/startup";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -49,6 +51,7 @@ const study = useStudyStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const recordId = route.params.recordId as string;
|
const recordId = route.params.recordId as string;
|
||||||
const studyId = study.currentStudy?.id || "";
|
const studyId = study.currentStudy?.id || "";
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const detail = reactive<any>({
|
const detail = reactive<any>({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -61,6 +64,25 @@ const detail = reactive<any>({
|
|||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!studyId || !recordId) return;
|
if (!studyId || !recordId) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -74,10 +96,19 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => router.push(`/startup/training/${recordId}/edit`);
|
const goEdit = () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/startup/training/${recordId}/edit`);
|
||||||
|
};
|
||||||
const goBack = () => router.push("/startup/meeting-auth");
|
const goBack = () => router.push("/startup/meeting-auth");
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -11,31 +11,31 @@
|
|||||||
<el-card>
|
<el-card>
|
||||||
<el-form :model="form" label-width="120px">
|
<el-form :model="form" label-width="120px">
|
||||||
<el-form-item :label="TEXT.common.fields.name" required>
|
<el-form-item :label="TEXT.common.fields.name" required>
|
||||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
<el-input v-model="form.name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.labels.role">
|
<el-form-item :label="TEXT.common.labels.role">
|
||||||
<el-input v-model="form.role" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" />
|
<el-input v-model="form.role" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.site">
|
<el-form-item :label="TEXT.common.fields.site">
|
||||||
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.trained">
|
<el-form-item :label="TEXT.common.fields.trained">
|
||||||
<el-switch v-model="form.trained" />
|
<el-switch v-model="form.trained" :disabled="isReadOnly" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.trainedDate">
|
<el-form-item :label="TEXT.common.fields.trainedDate">
|
||||||
<el-date-picker v-model="form.trained_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
<el-date-picker v-model="form.trained_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.authorized">
|
<el-form-item :label="TEXT.common.fields.authorized">
|
||||||
<el-switch v-model="form.authorized" />
|
<el-switch v-model="form.authorized" :disabled="isReadOnly" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.authorizedDate">
|
<el-form-item :label="TEXT.common.fields.authorizedDate">
|
||||||
<el-date-picker v-model="form.authorized_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
<el-date-picker v-model="form.authorized_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.remark">
|
<el-form-item :label="TEXT.common.fields.remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="training_authorization"
|
entity-type="training_authorization"
|
||||||
:entity-id="recordId"
|
:entity-id="recordId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
/>
|
/>
|
||||||
<el-card v-else class="hint-card">
|
<el-card v-else class="hint-card">
|
||||||
<StateEmpty :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
|
<StateEmpty :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
|
||||||
@@ -59,6 +60,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
|
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -67,6 +69,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const recordId = computed(() => route.params.recordId as string | undefined);
|
const recordId = computed(() => route.params.recordId as string | undefined);
|
||||||
const isEdit = computed(() => !!recordId.value);
|
const isEdit = computed(() => !!recordId.value);
|
||||||
@@ -83,6 +86,25 @@ const form = reactive({
|
|||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!isEdit.value || !studyId.value || !recordId.value) return;
|
if (!isEdit.value || !studyId.value || !recordId.value) return;
|
||||||
try {
|
try {
|
||||||
@@ -104,6 +126,14 @@ const load = async () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.site_name && siteActiveMap.value[form.site_name] === false) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -134,7 +164,10 @@ const submit = async () => {
|
|||||||
|
|
||||||
const goBack = () => router.push("/startup/meeting-auth");
|
const goBack = () => router.push("/startup/meeting-auth");
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-button type="primary" @click="startSubjectEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +74,9 @@
|
|||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
|
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
|
||||||
<div class="tab-actions">
|
<div class="tab-actions">
|
||||||
<el-button type="primary" @click="openHistoryDialog()">{{ TEXT.common.actions.newHistory }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="openHistoryDialog()">
|
||||||
|
{{ TEXT.common.actions.newHistory }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%">
|
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%">
|
||||||
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" width="140">
|
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" width="140">
|
||||||
@@ -81,8 +85,24 @@
|
|||||||
<el-table-column prop="content" :label="TEXT.common.fields.content" min-width="240" />
|
<el-table-column prop="content" :label="TEXT.common.fields.content" min-width="240" />
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="200">
|
<el-table-column :label="TEXT.common.labels.actions" width="200">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" size="small" @click="openHistoryDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button
|
||||||
<el-button link type="danger" size="small" @click="removeHistory(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click="openHistoryDialog(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click="removeHistory(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -91,7 +111,9 @@
|
|||||||
|
|
||||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
|
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
|
||||||
<div class="tab-actions">
|
<div class="tab-actions">
|
||||||
<el-button type="primary" @click="openVisitDialog()">{{ TEXT.common.actions.newVisit }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="openVisitDialog()">
|
||||||
|
{{ TEXT.common.actions.newVisit }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
|
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
|
||||||
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" />
|
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" />
|
||||||
@@ -135,12 +157,32 @@
|
|||||||
<el-table-column :label="TEXT.common.labels.actions" min-width="160">
|
<el-table-column :label="TEXT.common.labels.actions" min-width="160">
|
||||||
<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" @click="saveVisitRow(scope.row)">{{ TEXT.common.actions.save }}</el-button>
|
<el-button link type="primary" size="small" :disabled="isReadOnly" @click="saveVisitRow(scope.row)">
|
||||||
<el-button link size="small" @click="cancelVisitRow">{{ TEXT.common.actions.cancel }}</el-button>
|
{{ TEXT.common.actions.save }}
|
||||||
|
</el-button>
|
||||||
|
<el-button link size="small" :disabled="isReadOnly" @click="cancelVisitRow">
|
||||||
|
{{ TEXT.common.actions.cancel }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-button link type="primary" size="small" @click="startVisitRowEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button
|
||||||
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click="startVisitRowEdit(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click="removeVisit(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -150,7 +192,9 @@
|
|||||||
|
|
||||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
|
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
|
||||||
<div class="tab-actions">
|
<div class="tab-actions">
|
||||||
<el-button type="primary" @click="openAeDialog()">{{ TEXT.common.actions.newAe }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="openAeDialog()">
|
||||||
|
{{ TEXT.common.actions.newAe }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%">
|
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%">
|
||||||
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" width="140">
|
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" width="140">
|
||||||
@@ -165,8 +209,24 @@
|
|||||||
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" />
|
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" />
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="200">
|
<el-table-column :label="TEXT.common.labels.actions" width="200">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" size="small" @click="openAeDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button
|
||||||
<el-button link type="danger" size="small" @click="removeAe(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click="openAeDialog(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
@click="removeAe(scope.row)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -186,7 +246,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" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -217,7 +277,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" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -254,14 +314,14 @@
|
|||||||
</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" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :disabled="isReadOnly" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } 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 { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
@@ -303,6 +363,8 @@ const subjectForm = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const siteMap = ref<Record<string, string>>({});
|
const siteMap = ref<Record<string, string>>({});
|
||||||
|
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||||
|
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
||||||
const activeTab = ref("history");
|
const activeTab = ref("history");
|
||||||
|
|
||||||
const historyItems = ref<any[]>([]);
|
const historyItems = ref<any[]>([]);
|
||||||
@@ -355,8 +417,13 @@ const loadSites = async () => {
|
|||||||
acc[site.id] = site.name;
|
acc[site.id] = site.name;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
siteActiveMap.value = list.reduce((acc: Record<string, boolean>, site: any) => {
|
||||||
|
acc[site.id] = !!site.is_active;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
} catch {
|
} catch {
|
||||||
siteMap.value = {};
|
siteMap.value = {};
|
||||||
|
siteActiveMap.value = {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -374,6 +441,10 @@ const loadSubject = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startSubjectEdit = () => {
|
const startSubjectEdit = () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
subjectForm.status = detail.status || "SCREENING";
|
subjectForm.status = detail.status || "SCREENING";
|
||||||
subjectForm.consent_date = detail.consent_date || "";
|
subjectForm.consent_date = detail.consent_date || "";
|
||||||
subjectForm.enrollment_date = detail.enrollment_date || "";
|
subjectForm.enrollment_date = detail.enrollment_date || "";
|
||||||
@@ -387,6 +458,10 @@ const cancelSubjectEdit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveSubjectEdit = async () => {
|
const saveSubjectEdit = async () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
subjectSaving.value = true;
|
subjectSaving.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -422,6 +497,10 @@ const loadHistories = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openHistoryDialog = (row?: any) => {
|
const openHistoryDialog = (row?: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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 || "";
|
||||||
@@ -429,6 +508,10 @@ const openHistoryDialog = (row?: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveHistory = async () => {
|
const saveHistory = async () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -449,6 +532,10 @@ const saveHistory = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeHistory = async (row: any) => {
|
const removeHistory = async (row: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
@@ -474,6 +561,10 @@ const loadVisits = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openVisitDialog = (row?: any) => {
|
const openVisitDialog = (row?: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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 || "";
|
||||||
@@ -490,6 +581,10 @@ const openVisitDialog = (row?: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startVisitRowEdit = (row: any) => {
|
const startVisitRowEdit = (row: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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 || "";
|
||||||
@@ -500,6 +595,10 @@ const cancelVisitRow = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveVisitRow = async (row: any) => {
|
const saveVisitRow = async (row: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId || !row?.id) return;
|
if (!studyId || !subjectId || !row?.id) return;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -559,6 +658,10 @@ const getVisitStatusTagType = (status: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveVisit = async () => {
|
const saveVisit = async () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
try {
|
try {
|
||||||
if (visitEditingId.value) {
|
if (visitEditingId.value) {
|
||||||
@@ -585,6 +688,10 @@ const saveVisit = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeVisit = async (row: any) => {
|
const removeVisit = async (row: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
@@ -610,6 +717,10 @@ const loadAes = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openAeDialog = (row?: any) => {
|
const openAeDialog = (row?: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
aeEditingId.value = row?.id || null;
|
aeEditingId.value = row?.id || null;
|
||||||
Object.assign(aeForm, {
|
Object.assign(aeForm, {
|
||||||
term: row?.term || "",
|
term: row?.term || "",
|
||||||
@@ -624,6 +735,10 @@ const openAeDialog = (row?: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveAe = async () => {
|
const saveAe = async () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId || !subjectId) return;
|
if (!studyId || !subjectId) return;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -650,6 +765,10 @@ const saveAe = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeAe = async (row: any) => {
|
const removeAe = async (row: any) => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
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;
|
||||||
|
|||||||
@@ -11,11 +11,15 @@
|
|||||||
<el-card>
|
<el-card>
|
||||||
<el-form :model="form" label-width="120px">
|
<el-form :model="form" label-width="120px">
|
||||||
<el-form-item :label="TEXT.common.fields.subjectNo" required>
|
<el-form-item :label="TEXT.common.fields.subjectNo" required>
|
||||||
<el-input v-model="form.subject_no" :disabled="isEdit" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo" />
|
<el-input
|
||||||
|
v-model="form.subject_no"
|
||||||
|
:disabled="isEdit || isReadOnly"
|
||||||
|
:placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.site" required>
|
<el-form-item :label="TEXT.common.fields.site" required>
|
||||||
<el-select v-model="form.site_id" :disabled="isEdit" :placeholder="TEXT.common.placeholders.select">
|
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||||
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" />
|
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.screeningDate">
|
<el-form-item :label="TEXT.common.fields.screeningDate">
|
||||||
@@ -24,7 +28,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
:placeholder="TEXT.common.placeholders.select"
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
:disabled="isEdit"
|
:disabled="isEdit || isReadOnly"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.consentDate">
|
<el-form-item :label="TEXT.common.fields.consentDate">
|
||||||
@@ -33,10 +37,11 @@
|
|||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
:placeholder="TEXT.common.placeholders.select"
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
:disabled="isReadOnly"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status">
|
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status">
|
||||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
|
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly">
|
||||||
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
|
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
|
||||||
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
|
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
|
||||||
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
|
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
|
||||||
@@ -44,16 +49,36 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="isEdit" :label="TEXT.common.fields.enrollmentDate">
|
<el-form-item v-if="isEdit" :label="TEXT.common.fields.enrollmentDate">
|
||||||
<el-date-picker v-model="form.enrollment_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
<el-date-picker
|
||||||
|
v-model="form.enrollment_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="isEdit" :label="TEXT.common.fields.completionDate">
|
<el-form-item v-if="isEdit" :label="TEXT.common.fields.completionDate">
|
||||||
<el-date-picker v-model="form.completion_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
<el-date-picker
|
||||||
|
v-model="form.completion_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="isEdit" :label="TEXT.common.fields.dropReason">
|
<el-form-item v-if="isEdit" :label="TEXT.common.fields.dropReason">
|
||||||
<el-input v-model="form.drop_reason" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
<el-input
|
||||||
|
v-model="form.drop_reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
:placeholder="TEXT.common.placeholders.input"
|
||||||
|
:disabled="isReadOnly"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
|
||||||
|
{{ TEXT.common.actions.save }}
|
||||||
|
</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -75,10 +100,18 @@ const router = useRouter();
|
|||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
sites.value.forEach((site) => {
|
||||||
|
if (site?.id) map[site.id] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
const subjectId = computed(() => route.params.subjectId as string | undefined);
|
const subjectId = computed(() => route.params.subjectId as string | undefined);
|
||||||
const isEdit = computed(() => !!subjectId.value);
|
const isEdit = computed(() => !!subjectId.value);
|
||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
|
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
subject_no: "",
|
subject_no: "",
|
||||||
@@ -121,6 +154,10 @@ const load = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user