diff --git a/README.md b/README.md index f9c87f2e..98f3a51b 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ ## 角色权限概要(前端操作级提示,后端仍最终裁决) - ADMIN:全权限 -- PM:里程碑/任务维护、受试者状态、AE 创建/关闭、Issue 创建/关闭、Finance 创建/审批/支付、IMP 交易、FAQ 维护 -- CRA:任务/受试者/AE/Data Query 创建,受试者状态更新,Finance 创建/提交,其他只读 +- PM:里程碑/任务维护、参与者状态、AE 创建/关闭、Issue 创建/关闭、Finance 创建/审批/支付、IMP 交易、FAQ 维护 +- CRA:任务/参与者/AE/Data Query 创建,参与者状态更新,Finance 创建/提交,其他只读 - PV:AE 创建/关闭,Issue 创建/关闭,其余只读 - IMP:IMP 交易/产品/批次维护,其余只读 - 普通成员(无项目角色):仅浏览 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 00000000..2f774c88 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic + +sqlalchemy.url = postgresql+asyncpg://postgres:postgres@db:5432/ctms + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 00000000..314a01ea --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from app.core.config import settings +from app.db.base import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _get_database_url() -> str: + url = os.getenv("DATABASE_URL", settings.DATABASE_URL) + if url and url.startswith("postgresql+asyncpg://"): + return url + return url + + +def run_migrations_offline() -> None: + url = _get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = _get_database_url() + + connectable = async_engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/versions/20240501_000003_drop_visit_name_from_visits.py b/backend/alembic/versions/20240501_000003_drop_visit_name_from_visits.py new file mode 100644 index 00000000..b7546f44 --- /dev/null +++ b/backend/alembic/versions/20240501_000003_drop_visit_name_from_visits.py @@ -0,0 +1,22 @@ +"""drop visit_name from visits""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20240501_000003" +down_revision = "20240501_000002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("visits", "visit_name") + + +def downgrade() -> None: + op.add_column( + "visits", + sa.Column("visit_name", sa.String(length=200), nullable=False, server_default=""), + ) diff --git a/backend/alembic/versions/20240501_000004_add_visit_template_to_studies.py b/backend/alembic/versions/20240501_000004_add_visit_template_to_studies.py new file mode 100644 index 00000000..93a6f36d --- /dev/null +++ b/backend/alembic/versions/20240501_000004_add_visit_template_to_studies.py @@ -0,0 +1,25 @@ +"""add visit template fields to studies""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20240501_000004" +down_revision = "20240501_000003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("studies", sa.Column("visit_interval_days", sa.Integer(), nullable=True)) + op.add_column("studies", sa.Column("visit_total", sa.Integer(), nullable=True)) + op.add_column("studies", sa.Column("visit_window_start_offset", sa.Integer(), nullable=True)) + op.add_column("studies", sa.Column("visit_window_end_offset", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("studies", "visit_window_end_offset") + op.drop_column("studies", "visit_window_start_offset") + op.drop_column("studies", "visit_total") + op.drop_column("studies", "visit_interval_days") diff --git a/backend/alembic/versions/20240501_000005_add_consent_date_to_subjects.py b/backend/alembic/versions/20240501_000005_add_consent_date_to_subjects.py new file mode 100644 index 00000000..29122119 --- /dev/null +++ b/backend/alembic/versions/20240501_000005_add_consent_date_to_subjects.py @@ -0,0 +1,19 @@ +"""add consent_date to subjects""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20240501_000005" +down_revision = "20240501_000004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("subjects", sa.Column("consent_date", sa.Date(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("subjects", "consent_date") diff --git a/backend/app/api/v1/subject_histories.py b/backend/app/api/v1/subject_histories.py index dc4ced75..b7fe66bc 100644 --- a/backend/app/api/v1/subject_histories.py +++ b/backend/app/api/v1/subject_histories.py @@ -34,7 +34,7 @@ async def create_history( ) -> SubjectHistoryRead: await _ensure_study_exists(db, study_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) await audit_crud.log_action( db, diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index 41efa8c5..95d47274 100644 --- a/backend/app/api/v1/subjects.py +++ b/backend/app/api/v1/subjects.py @@ -42,7 +42,7 @@ async def create_subject( entity_type="subject", entity_id=subject.id, action="CREATE_SUBJECT", - detail=f"受试者 {subject.subject_no} 已创建", + detail=f"参与者 {subject.subject_no} 已创建", operator_id=current_user.id, operator_role=current_user.role, ) @@ -81,7 +81,7 @@ async def get_subject( 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="受试者不存在") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在") return subject @@ -100,7 +100,7 @@ async def update_subject( 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="受试者不存在") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在") old_status = subject.status updated = await subject_crud.update_subject(db, subject, subject_in) # auto-generate visits when enrolled @@ -108,14 +108,14 @@ async def update_subject( await subject_crud.generate_default_visits(db, updated) detail = None if subject_in.status and subject_in.status != old_status: - detail = f"受试者 {updated.subject_no} 状态 {old_status} -> {subject_in.status}" + detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {subject_in.status}" await audit_crud.log_action( db, study_id=study_id, entity_type="subject", entity_id=subject_id, action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT", - detail=detail or "受试者已更新", + detail=detail or "参与者已更新", operator_id=current_user.id, operator_role=current_user.role, ) @@ -136,7 +136,7 @@ async def delete_subject( 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="受试者不存在") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在") await subject_crud.delete_subject(db, subject) await audit_crud.log_action( db, @@ -144,7 +144,7 @@ async def delete_subject( entity_type="subject", entity_id=subject_id, action="DELETE_SUBJECT", - detail=f"受试者 {subject_id} 已删除", + detail=f"参与者 {subject_id} 已删除", operator_id=current_user.id, operator_role=current_user.role, ) diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index 5a8030df..075b56ab 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -6,6 +6,7 @@ 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.crud import audit as audit_crud from app.crud import subject as subject_crud +from app.crud import study as study_crud from app.crud import visit as visit_crud from app.schemas.visit import VisitCreate, VisitRead, VisitUpdate @@ -15,7 +16,7 @@ router = APIRouter() async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID): 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="受试者不存在") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在") return subject @@ -49,16 +50,28 @@ async def create_visit( ) -> VisitRead: subject = await _ensure_subject(db, study_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( db, study_id=study_id, visit_in=visit_in, subject=subject, visit_code=visit_in.visit_code, - visit_name=visit_in.visit_name, planned_date=visit_in.planned_date, ) + if visit_in.visit_code == "V1" and visit_in.planned_date: + study = await study_crud.get(db, study_id) + if study: + await visit_crud.create_followup_visits( + db, + study_id=study_id, + subject=subject, + base_date=visit_in.planned_date, + visit_total=study.visit_total, + visit_interval_days=study.visit_interval_days, + window_start_offset=study.visit_window_start_offset, + window_end_offset=study.visit_window_end_offset, + ) await audit_crud.log_action( db, study_id=study_id, diff --git a/backend/app/crud/ae.py b/backend/app/crud/ae.py index 3fcdb6d3..10008268 100644 --- a/backend/app/crud/ae.py +++ b/backend/app/crud/ae.py @@ -28,7 +28,7 @@ async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: result = await db.execute(select(Subject).where(Subject.id == subject_id)) subj = result.scalar_one_or_none() if not subj or subj.study_id != study_id: - raise ValueError("受试者不属于当前项目") + raise ValueError("参与者不属于当前项目") async def create_ae( diff --git a/backend/app/crud/study.py b/backend/app/crud/study.py index b85c7537..50542a5a 100644 --- a/backend/app/crud/study.py +++ b/backend/app/crud/study.py @@ -16,6 +16,10 @@ async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UU protocol_no=study_in.protocol_no, phase=study_in.phase, status=study_in.status, + visit_interval_days=study_in.visit_interval_days, + visit_total=study_in.visit_total, + visit_window_start_offset=study_in.visit_window_start_offset, + visit_window_end_offset=study_in.visit_window_end_offset, created_by=created_by, ) db.add(study) diff --git a/backend/app/crud/subject.py b/backend/app/crud/subject.py index 79367ebf..6411c255 100644 --- a/backend/app/crud/subject.py +++ b/backend/app/crud/subject.py @@ -6,6 +6,8 @@ from sqlalchemy import select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.crud import visit as visit_crud +from app.models.study import Study +from app.models.visit import Visit from app.models.site import Site from app.models.subject import Subject from app.schemas.subject import SubjectCreate, SubjectUpdate @@ -26,6 +28,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj subject_no=subject_in.subject_no, status="SCREENING", screening_date=subject_in.screening_date, + consent_date=subject_in.consent_date, enrollment_date=None, completion_date=None, drop_reason=None, @@ -41,7 +44,6 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj visit_in=None, subject=subject, visit_code="V0", - visit_name="Screening", planned_date=subject.screening_date, ) return subject @@ -74,25 +76,44 @@ async def generate_default_visits(db: AsyncSession, subject: Subject) -> None: # Baseline + Follow-up visits based on enrollment_date if not subject.enrollment_date: return + result = await db.execute(select(Study).where(Study.id == subject.study_id)) + study = result.scalar_one_or_none() + if not study: + return + + visit_total = study.visit_total or 3 + visit_interval_days = study.visit_interval_days or 30 + window_start_offset = study.visit_window_start_offset + window_end_offset = study.visit_window_end_offset baseline_date = subject.enrollment_date - follow1 = baseline_date + timedelta(days=30) - follow2 = baseline_date + timedelta(days=60) - visits_data = [ - ("V1", "Baseline", baseline_date), - ("FU1", "Follow-up 1", follow1), - ("FU2", "Follow-up 2", follow2), - ] - for code, name, plan_date in visits_data: + + result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id)) + existing_codes = {row[0] for row in result.all()} + if "V1" not in existing_codes: + window_start = baseline_date + timedelta(days=window_start_offset) if window_start_offset is not None else None + window_end = baseline_date + timedelta(days=window_end_offset) if window_end_offset is not None else None await visit_crud.create_visit( db, study_id=subject.study_id, visit_in=None, subject=subject, - visit_code=code, - visit_name=name, - planned_date=plan_date, + visit_code="V1", + planned_date=baseline_date, + window_start=window_start, + window_end=window_end, ) + await visit_crud.create_followup_visits( + db, + study_id=subject.study_id, + subject=subject, + base_date=baseline_date, + visit_total=visit_total, + visit_interval_days=visit_interval_days, + window_start_offset=window_start_offset, + window_end_offset=window_end_offset, + ) + async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject: update_data = subject_in.model_dump(exclude_unset=True) diff --git a/backend/app/crud/subject_history.py b/backend/app/crud/subject_history.py index 1e6386fa..cf5317e2 100644 --- a/backend/app/crud/subject_history.py +++ b/backend/app/crud/subject_history.py @@ -13,7 +13,7 @@ async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uui result = await db.execute(select(Subject).where(Subject.id == subject_id)) subject = result.scalar_one_or_none() if not subject or subject.study_id != study_id: - raise ValueError("受试者不属于当前项目") + raise ValueError("参与者不属于当前项目") async def create_history( diff --git a/backend/app/crud/visit.py b/backend/app/crud/visit.py index 315a0626..e0b048f9 100644 --- a/backend/app/crud/visit.py +++ b/backend/app/crud/visit.py @@ -1,4 +1,5 @@ import uuid +from datetime import date, timedelta from typing import Sequence from sqlalchemy import select, update as sa_update @@ -16,19 +17,19 @@ async def create_visit( visit_in: VisitCreate | None, subject: Subject, visit_code: str, - visit_name: str, planned_date, + window_start: date | None = None, + window_end: date | None = None, ) -> Visit: visit = Visit( study_id=study_id, subject_id=subject.id, visit_code=visit_code, - visit_name=visit_name, planned_date=planned_date, actual_date=None, status="PLANNED", - window_start=visit_in.window_start if visit_in else None, - window_end=visit_in.window_end if visit_in else None, + window_start=window_start if window_start is not None else (visit_in.window_start if visit_in else None), + window_end=window_end if window_end is not None else (visit_in.window_end if visit_in else None), notes=None, ) db.add(visit) @@ -49,6 +50,12 @@ async def get_visit(db: AsyncSession, visit_id: uuid.UUID) -> Visit | None: async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) -> Visit: update_data = visit_in.model_dump(exclude_unset=True) + if "actual_date" in update_data and update_data["actual_date"] is not None and "status" not in update_data: + actual_date = update_data["actual_date"] + if (visit.window_start and actual_date < visit.window_start) or (visit.window_end and actual_date > visit.window_end): + update_data["status"] = "OVERDUE" + else: + update_data["status"] = "DONE" if update_data: await db.execute( sa_update(Visit) @@ -63,3 +70,44 @@ async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) -> async def delete_visit(db: AsyncSession, visit: Visit) -> None: await db.delete(visit) await db.commit() + + +async def create_followup_visits( + db: AsyncSession, + *, + study_id: uuid.UUID, + subject: Subject, + base_date: date, + visit_total: int | None, + visit_interval_days: int | None, + window_start_offset: int | None, + window_end_offset: int | None, +) -> Sequence[Visit]: + if not visit_total or not visit_interval_days or visit_total < 2: + return [] + + result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id)) + existing_codes = {row[0] for row in result.all()} + created: list[Visit] = [] + for index in range(2, visit_total + 1): + code = f"V{index}" + if code in existing_codes: + continue + planned_date = base_date + timedelta(days=visit_interval_days * (index - 1)) + window_start = ( + planned_date + timedelta(days=window_start_offset) if window_start_offset is not None else None + ) + window_end = planned_date + timedelta(days=window_end_offset) if window_end_offset is not None else None + created.append( + await create_visit( + db, + study_id=study_id, + visit_in=None, + subject=subject, + visit_code=code, + planned_date=planned_date, + window_start=window_start, + window_end=window_end, + ) + ) + return created diff --git a/backend/app/main.py b/backend/app/main.py index 0a8d94e0..09a1f9c9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -75,7 +75,7 @@ def create_app() -> FastAPI: {"name": "attachments", "description": "通用附件"}, {"name": "audit-logs", "description": "审计日志"}, {"name": "dashboard", "description": "项目总览与统计"}, - {"name": "subjects", "description": "受试者"}, + {"name": "subjects", "description": "参与者"}, {"name": "visits", "description": "访视"}, {"name": "aes", "description": "不良事件"}, {"name": "finance", "description": "费用管理"}, diff --git a/backend/app/models/study.py b/backend/app/models/study.py index 4748004d..71e12c4a 100644 --- a/backend/app/models/study.py +++ b/backend/app/models/study.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy import DateTime, ForeignKey, Integer, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -18,5 +18,9 @@ class Study(Base): protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True) phase: Mapped[str | None] = mapped_column(String(50), nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT") + visit_interval_days: 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_end_offset: Mapped[int | None] = mapped_column(Integer, nullable=True) created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py index 86601a67..2c118b04 100644 --- a/backend/app/models/subject.py +++ b/backend/app/models/subject.py @@ -18,6 +18,7 @@ class Subject(Base): subject_no: Mapped[str] = mapped_column(String(50), nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False, default="SCREENING") screening_date: Mapped[date | None] = mapped_column(Date, nullable=True) + consent_date: Mapped[date | None] = mapped_column(Date, nullable=True) enrollment_date: Mapped[date | None] = mapped_column(Date, nullable=True) completion_date: Mapped[date | None] = mapped_column(Date, nullable=True) drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/visit.py b/backend/app/models/visit.py index 305c3146..668e9da7 100644 --- a/backend/app/models/visit.py +++ b/backend/app/models/visit.py @@ -15,7 +15,6 @@ class Visit(Base): study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False) subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False) visit_code: Mapped[str] = mapped_column(String(50), nullable=False) - visit_name: Mapped[str] = mapped_column(String(200), nullable=False) planned_date: Mapped[date | None] = mapped_column(Date, nullable=True) actual_date: Mapped[date | None] = mapped_column(Date, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="PLANNED") diff --git a/backend/app/schemas/study.py b/backend/app/schemas/study.py index 1bda9bbf..d4d2b202 100644 --- a/backend/app/schemas/study.py +++ b/backend/app/schemas/study.py @@ -14,6 +14,10 @@ class StudyCreate(BaseModel): protocol_no: Optional[str] = None phase: Optional[str] = None status: StudyStatus = "DRAFT" + visit_interval_days: Optional[int] = None + visit_total: Optional[int] = None + visit_window_start_offset: Optional[int] = None + visit_window_end_offset: Optional[int] = None class StudyUpdate(BaseModel): @@ -23,6 +27,10 @@ class StudyUpdate(BaseModel): protocol_no: Optional[str] = None phase: Optional[str] = None status: Optional[StudyStatus] = None + visit_interval_days: Optional[int] = None + visit_total: Optional[int] = None + visit_window_start_offset: Optional[int] = None + visit_window_end_offset: Optional[int] = None class StudyRead(BaseModel): @@ -33,6 +41,10 @@ class StudyRead(BaseModel): protocol_no: Optional[str] phase: Optional[str] status: StudyStatus + visit_interval_days: Optional[int] + visit_total: Optional[int] + visit_window_start_offset: Optional[int] + visit_window_end_offset: Optional[int] created_by: Optional[uuid.UUID] created_at: datetime diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py index 2e690732..4ecd1c9f 100644 --- a/backend/app/schemas/subject.py +++ b/backend/app/schemas/subject.py @@ -9,10 +9,12 @@ class SubjectCreate(BaseModel): site_id: uuid.UUID subject_no: str screening_date: Optional[date] = None + consent_date: Optional[date] = None class SubjectUpdate(BaseModel): status: Optional[str] = None + consent_date: Optional[date] = None enrollment_date: Optional[date] = None completion_date: Optional[date] = None drop_reason: Optional[str] = None @@ -25,6 +27,7 @@ class SubjectRead(BaseModel): subject_no: str status: str screening_date: Optional[date] + consent_date: Optional[date] enrollment_date: Optional[date] completion_date: Optional[date] drop_reason: Optional[str] diff --git a/backend/app/schemas/visit.py b/backend/app/schemas/visit.py index f9170871..d77ac411 100644 --- a/backend/app/schemas/visit.py +++ b/backend/app/schemas/visit.py @@ -8,7 +8,6 @@ from pydantic import BaseModel, ConfigDict class VisitCreate(BaseModel): subject_id: uuid.UUID visit_code: str - visit_name: str planned_date: Optional[date] = None window_start: Optional[date] = None window_end: Optional[date] = None @@ -25,7 +24,6 @@ class VisitRead(BaseModel): study_id: uuid.UUID subject_id: uuid.UUID visit_code: str - visit_name: str planned_date: Optional[date] actual_date: Optional[date] status: str diff --git a/backend/requirements.txt b/backend/requirements.txt index 49b58b76..857d57f2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,7 @@ fastapi==0.104.1 uvicorn[standard]==0.24.0.post1 sqlalchemy==2.0.23 asyncpg==0.29.0 +alembic==1.13.1 pydantic-settings==2.1.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 diff --git a/database/init.sql b/database/init.sql index 3d9fb543..f2c3c2ab 100644 --- a/database/init.sql +++ b/database/init.sql @@ -38,6 +38,10 @@ CREATE TABLE IF NOT EXISTS public.studies ( protocol_no character varying(100), phase character varying(50), status character varying(20) NOT NULL DEFAULT 'DRAFT', + visit_interval_days integer, + visit_total integer, + visit_window_start_offset integer, + visit_window_end_offset integer, created_by uuid, created_at timestamp with time zone NOT NULL DEFAULT now(), CONSTRAINT uq_studies_code UNIQUE (code), @@ -93,6 +97,7 @@ CREATE TABLE IF NOT EXISTS public.subjects ( subject_no character varying(50) NOT NULL, status character varying(20) NOT NULL DEFAULT 'SCREENING', screening_date date, + consent_date date, enrollment_date date, completion_date date, drop_reason text, @@ -122,7 +127,6 @@ CREATE TABLE IF NOT EXISTS public.visits ( study_id uuid NOT NULL, subject_id uuid NOT NULL, visit_code character varying(50) NOT NULL, - visit_name character varying(200) NOT NULL, planned_date date, actual_date date, status character varying(20) NOT NULL DEFAULT 'PLANNED', @@ -453,7 +457,7 @@ ON CONFLICT (email) DO UPDATE SET updated_at = EXCLUDED.updated_at; INSERT INTO public.studies ( - id, code, name, sponsor, protocol_no, phase, status, created_by, created_at + id, code, name, sponsor, protocol_no, phase, status, visit_interval_days, visit_total, visit_window_start_offset, visit_window_end_offset, created_by, created_at ) VALUES ( 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'DEMO-CTMS', @@ -462,6 +466,10 @@ INSERT INTO public.studies ( 'DP-001', 'Phase II', 'ACTIVE', + 7, + 3, + -2, + 2, (SELECT id FROM public.users WHERE email = 'admin@example.com'), '2025-01-06 08:00:00+00' ) ON CONFLICT (code) DO UPDATE SET @@ -470,6 +478,10 @@ INSERT INTO public.studies ( protocol_no = EXCLUDED.protocol_no, phase = EXCLUDED.phase, status = EXCLUDED.status, + visit_interval_days = EXCLUDED.visit_interval_days, + visit_total = EXCLUDED.visit_total, + visit_window_start_offset = EXCLUDED.visit_window_start_offset, + visit_window_end_offset = EXCLUDED.visit_window_end_offset, created_by = EXCLUDED.created_by; INSERT INTO public.sites ( @@ -564,15 +576,16 @@ INSERT INTO public.training_authorizations ( ON CONFLICT (id) DO NOTHING; INSERT INTO public.subjects ( - id, study_id, site_id, subject_no, status, screening_date, enrollment_date, completion_date, drop_reason, created_at, updated_at + id, study_id, site_id, subject_no, status, screening_date, consent_date, enrollment_date, completion_date, drop_reason, created_at, updated_at ) VALUES - ('11111111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-001', 'ENROLLED', '2025-01-05', '2025-01-10', NULL, NULL, '2025-01-10 10:00:00+00', '2025-01-10 10:00:00+00'), - ('22222222-3333-4444-5555-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'SUBJ-002', 'SCREENING', '2025-01-12', NULL, NULL, NULL, '2025-01-12 10:00:00+00', '2025-01-12 10:00:00+00'), - ('33333333-4444-5555-6666-777777777777', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-003', 'COMPLETED', '2024-12-20', '2024-12-28', '2025-02-05', NULL, '2025-02-05 10:00:00+00', '2025-02-05 10:00:00+00') + ('11111111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-001', 'ENROLLED', '2025-01-05', '2025-01-06', '2025-01-10', NULL, NULL, '2025-01-10 10:00:00+00', '2025-01-10 10:00:00+00'), + ('22222222-3333-4444-5555-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'SUBJ-002', 'SCREENING', '2025-01-12', '2025-01-13', NULL, NULL, NULL, '2025-01-12 10:00:00+00', '2025-01-12 10:00:00+00'), + ('33333333-4444-5555-6666-777777777777', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-003', 'COMPLETED', '2024-12-20', '2024-12-21', '2024-12-28', '2025-02-05', NULL, '2025-02-05 10:00:00+00', '2025-02-05 10:00:00+00') ON CONFLICT (study_id, subject_no) DO UPDATE SET site_id = EXCLUDED.site_id, status = EXCLUDED.status, screening_date = EXCLUDED.screening_date, + consent_date = EXCLUDED.consent_date, enrollment_date = EXCLUDED.enrollment_date, completion_date = EXCLUDED.completion_date, drop_reason = EXCLUDED.drop_reason, @@ -587,20 +600,17 @@ INSERT INTO public.subject_histories ( ON CONFLICT (id) DO NOTHING; INSERT INTO public.visits ( - id, study_id, subject_id, visit_code, visit_name, planned_date, actual_date, status, window_start, window_end, notes, created_at, updated_at + id, study_id, subject_id, visit_code, planned_date, actual_date, status, window_start, window_end, notes, created_at, updated_at ) VALUES - ('44444444-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'V1', '基线访视', '2025-01-09', '2025-01-10', 'DONE', '2025-01-07', '2025-01-12', '按期完成', '2025-01-10 12:00:00+00', '2025-01-10 12:00:00+00'), - ('44444444-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'V2', '随访访视', '2025-02-09', NULL, 'PLANNED', '2025-02-07', '2025-02-12', NULL, '2025-01-20 12:00:00+00', '2025-01-20 12:00:00+00'), - ('44444444-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '22222222-3333-4444-5555-666666666666', 'V1', '筛选访视', '2025-01-15', NULL, 'PLANNED', '2025-01-13', '2025-01-18', NULL, '2025-01-15 12:00:00+00', '2025-01-15 12:00:00+00'), - ('44444444-aaaa-bbbb-cccc-444444444444', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '22222222-3333-4444-5555-666666666666', 'V2', '基线访视', '2025-01-22', NULL, 'PLANNED', '2025-01-20', '2025-01-25', NULL, '2025-01-22 12:00:00+00', '2025-01-22 12:00:00+00'), - ('44444444-aaaa-bbbb-cccc-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '33333333-4444-5555-6666-777777777777', 'V1', '筛选访视', '2024-12-22', '2024-12-22', 'DONE', '2024-12-20', '2024-12-25', '完成筛选', '2024-12-22 12:00:00+00', '2024-12-22 12:00:00+00'), - ('44444444-aaaa-bbbb-cccc-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '33333333-4444-5555-6666-777777777777', 'V2', '随访访视', '2025-01-22', '2025-01-23', 'DONE', '2025-01-20', '2025-01-25', '随访完成', '2025-01-23 12:00:00+00', '2025-01-23 12:00:00+00') + ('44444444-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'V1', '2025-01-09', '2025-01-10', 'DONE', '2025-01-07', '2025-01-12', '按期完成', '2025-01-10 12:00:00+00', '2025-01-10 12:00:00+00'), + ('44444444-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'FU1', '2025-02-09', NULL, 'PLANNED', '2025-02-07', '2025-02-12', NULL, '2025-01-20 12:00:00+00', '2025-01-20 12:00:00+00'), + ('44444444-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'FU2', '2025-03-11', NULL, 'PLANNED', '2025-03-09', '2025-03-14', NULL, '2025-02-01 12:00:00+00', '2025-02-01 12:00:00+00') ON CONFLICT (id) DO NOTHING; INSERT INTO public.adverse_events ( id, study_id, site_id, subject_id, visit_id, term, onset_date, resolution_date, seriousness, severity, causality, action_taken, outcome, reported_to_sponsor, report_due_date, status, description, created_by, created_at, updated_at ) VALUES - ('66666666-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-2222-3333-4444-555555555555', '44444444-aaaa-bbbb-cccc-111111111111', '轻微头痛', '2025-01-10', '2025-01-11', 'NON_SERIOUS', 'MILD', '可能相关', '休息观察', '恢复', true, '2025-01-20', 'CLOSED', '受试者轻微头痛,次日缓解。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-10 13:00:00+00', '2025-01-11 09:00:00+00'), + ('66666666-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-2222-3333-4444-555555555555', '44444444-aaaa-bbbb-cccc-111111111111', '轻微头痛', '2025-01-10', '2025-01-11', 'NON_SERIOUS', 'MILD', '可能相关', '休息观察', '恢复', true, '2025-01-20', 'CLOSED', '参与者轻微头痛,次日缓解。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-10 13:00:00+00', '2025-01-11 09:00:00+00'), ('66666666-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', '22222222-3333-4444-5555-666666666666', NULL, '血糖升高', '2025-01-18', NULL, 'SERIOUS', 'SEVERE', '相关', '调整用药', '未恢复', false, '2025-01-28', 'NEW', '筛选期血糖异常。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-18 13:00:00+00', '2025-01-18 13:00:00+00'), ('66666666-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-2222-3333-4444-555555555555', NULL, '轻度皮疹', '2025-02-10', NULL, 'NON_SERIOUS', 'MODERATE', '待评估', NULL, '未恢复', false, '2025-02-20', 'FOLLOW_UP', '出现皮疹,持续观察。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-10 13:00:00+00', '2025-02-10 13:00:00+00') ON CONFLICT (id) DO NOTHING; diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts new file mode 100644 index 00000000..870bdbe0 --- /dev/null +++ b/frontend/src/api/overview.ts @@ -0,0 +1,3 @@ +import { apiGet } from "./axios"; + +export const fetchProjectOverview = (studyId: string) => apiGet(`/api/v1/studies/${studyId}/overview`); diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 23f2546b..00a20ded 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -84,7 +84,7 @@ export const TEXT = { case: "例", }, fields: { - subjectNo: "受试者编号", + subjectNo: "参与者编号", site: "分中心", status: "状态", keyword: "关键词", @@ -109,17 +109,21 @@ export const TEXT = { category: "分类", question: "问题", screeningDate: "筛选日期", + consentDate: "知情日期", enrollmentDate: "入组日期", completionDate: "完成日期", dropReason: "退出原因", recordDate: "日期", content: "内容", visitCode: "访视编号", - visitName: "访视名称", - plannedDate: "计划日期", - actualDate: "实际日期", + plannedDate: "计划访视", + actualDate: "实际访视", windowStart: "窗口开始", windowEnd: "窗口结束", + visitTotal: "访视总次数", + visitIntervalDays: "访视间隔(天)", + visitWindowStartOffset: "窗口开始偏移(天)", + visitWindowEndOffset: "窗口结束偏移(天)", notes: "备注", event: "事件", onsetDate: "发生日期", @@ -204,7 +208,7 @@ export const TEXT = { }, eventDict: { AE_CLOSED: { label: "关闭 AE(不良事件)", actionText: "关闭了 AE", targetLabel: "不良事件" }, - SUBJECT_STATUS_CHANGED: { label: "受试者状态变更", actionText: "变更了受试者状态", targetLabel: "受试者" }, + SUBJECT_STATUS_CHANGED: { label: "参与者状态变更", actionText: "变更了参与者状态", targetLabel: "参与者" }, FINANCE_STATUS_CHANGED: { label: "费用状态变更", actionText: "变更了费用状态", targetLabel: "费用记录" }, ADMIN_RESET_PASSWORD: { label: "重置用户密码", actionText: "重置了用户密码", targetLabel: "用户账号" }, ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" }, @@ -236,7 +240,7 @@ export const TEXT = { drugShipments: "运输/流向", startupFeasibilityEthics: "立项与伦理", startupMeetingAuth: "启动与授权", - subjects: "受试者管理", + subjects: "参与者管理", monitoring: "监查", audit: "稽查", knowledge: "知识库", @@ -367,18 +371,18 @@ export const TEXT = { trainingDetailSubtitle: "查看人员培训与授权信息", }, subjectManagement: { - title: "受试者管理", - subtitle: "受试者、病史、访视、AE 综合管理", - subjectLabel: "受试者", - empty: "暂无受试者记录", + title: "参与者管理", + subtitle: "参与者、病史、访视、AE 综合管理", + subjectLabel: "参与者", + empty: "暂无参与者记录", }, subjectForm: { - titleEdit: "编辑受试者", - titleNew: "新增受试者", - subtitle: "维护受试者基本信息", + titleEdit: "编辑参与者", + titleNew: "新增参与者", + subtitle: "维护参与者基本信息", }, subjectDetail: { - title: "受试者详情", + title: "参与者详情", subtitle: "查看病史、访视与 AE 信息", tabs: { history: "病史", @@ -527,6 +531,7 @@ export const TEXT = { createdAt: "创建时间", newTitle: "新建项目", editTitle: "编辑项目", + visitTemplate: "访视模板", sponsorPlaceholder: "申办方(可选)", protocolPlaceholder: "方案号(可选)", phasePlaceholder: "研究分期(可选)", @@ -644,9 +649,9 @@ export const TEXT = { loadFailed: "工作台数据加载失败", }, permissions: { - subjectEnroll: "仅 PM/CRA 可更新受试者状态", - subjectComplete: "仅 PM/CRA 可更新受试者状态", - subjectDrop: "仅 PM/CRA 可更新受试者状态", + subjectEnroll: "仅 PM/CRA 可更新参与者状态", + subjectComplete: "仅 PM/CRA 可更新参与者状态", + subjectDrop: "仅 PM/CRA 可更新参与者状态", aeClose: "仅 PM/PV/ADMIN 可关闭 AE", faqEdit: "仅 PM/ADMIN 可维护 FAQ", faqCreate: "仅项目成员可新建 FAQ", @@ -699,14 +704,10 @@ export const TEXT = { PLANNED: "计划中", DONE: "已完成", MISSED: "未完成", + OVERDUE: "超窗", + LOST: "失访", CANCELLED: "已取消", }, - visitName: { - Screening: "筛选访视", - Baseline: "基线访视", - "Follow-up 1": "随访 1", - "Follow-up 2": "随访 2", - }, aeSeriousness: { SERIOUS: "严重", NON_SERIOUS: "一般", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c791b970..6031a5c5 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,6 +1,9 @@ import { createApp } from "vue"; import { createPinia } from "pinia"; import ElementPlus from "element-plus"; +import zhCn from "element-plus/es/locale/lang/zh-cn"; +import dayjs from "dayjs"; +import "dayjs/locale/zh-cn"; import "element-plus/dist/index.css"; import "./styles/main.css"; @@ -12,7 +15,8 @@ const app = createApp(App); const pinia = createPinia(); app.use(pinia); app.use(router); -app.use(ElementPlus); +dayjs.locale("zh-cn"); +app.use(ElementPlus, { locale: zhCn }); // 初始化项目上下文 const studyStore = useStudyStore(); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d0c30276..78cf0edf 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -59,6 +59,10 @@ export interface Study { protocol_no?: string | null; phase?: string | null; status: string; + visit_interval_days?: number | null; + visit_total?: number | null; + visit_window_start_offset?: number | null; + visit_window_end_offset?: number | null; created_by?: string | null; created_at?: string; role_in_study?: string | null; diff --git a/frontend/src/utils/display/index.ts b/frontend/src/utils/display/index.ts index 262fbbee..c3dff0f0 100644 --- a/frontend/src/utils/display/index.ts +++ b/frontend/src/utils/display/index.ts @@ -64,5 +64,5 @@ export const displayDateTime = (value?: string | number | Date | null) => { const pad = (n: number) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad( date.getMinutes() - )}`; + )}:${pad(date.getSeconds())}`; }; diff --git a/frontend/src/views/admin/ProjectForm.vue b/frontend/src/views/admin/ProjectForm.vue index b0faeeeb..58b92edf 100644 --- a/frontend/src/views/admin/ProjectForm.vue +++ b/frontend/src/views/admin/ProjectForm.vue @@ -23,6 +23,19 @@ + {{ TEXT.modules.adminProjects.visitTemplate }} + + + + + + + + + + + + {{ TEXT.common.actions.cancel }} @@ -62,6 +75,10 @@ const form = reactive({ 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 = reactive({ @@ -77,6 +94,10 @@ const resetForm = () => { form.protocol_no = ""; form.phase = ""; form.status = "DRAFT"; + form.visit_interval_days = null; + form.visit_total = null; + form.visit_window_start_offset = null; + form.visit_window_end_offset = null; }; watch( @@ -91,6 +112,10 @@ watch( form.protocol_no = props.project.protocol_no || ""; form.phase = props.project.phase || ""; form.status = props.project.status || "DRAFT"; + form.visit_interval_days = props.project.visit_interval_days ?? null; + form.visit_total = props.project.visit_total ?? null; + form.visit_window_start_offset = props.project.visit_window_start_offset ?? null; + form.visit_window_end_offset = props.project.visit_window_end_offset ?? null; } } } @@ -108,6 +133,10 @@ const onSubmit = async () => { protocol_no: form.protocol_no, phase: form.phase, status: form.status, + visit_interval_days: form.visit_interval_days, + visit_total: form.visit_total, + visit_window_start_offset: form.visit_window_start_offset, + visit_window_end_offset: form.visit_window_end_offset, }); ElMessage.success(TEXT.modules.adminProjects.updateSuccess); } else { @@ -118,6 +147,10 @@ const onSubmit = async () => { protocol_no: form.protocol_no, phase: form.phase, status: form.status, + visit_interval_days: form.visit_interval_days, + visit_total: form.visit_total, + visit_window_start_offset: form.visit_window_start_offset, + visit_window_end_offset: form.visit_window_end_offset, }); ElMessage.success(TEXT.modules.adminProjects.createSuccess); } diff --git a/frontend/src/views/ia/ProjectOverview.vue b/frontend/src/views/ia/ProjectOverview.vue index 3e5a6f11..d5c0f6f7 100644 --- a/frontend/src/views/ia/ProjectOverview.vue +++ b/frontend/src/views/ia/ProjectOverview.vue @@ -1,7 +1,326 @@ - + + + + + 项目概览 + 多中心项目整体进度与入组情况 + + {{ study.currentStudy.name }} + / + {{ study.currentStudy.code }} + + + + 示例数据 + 更新:{{ nowLabel }} + 刷新 + + + + + + + + 中心整体进度 + + + 已完成 + 进行中 + 未开始 + 阻塞/延期 + + + + + + + + + + + + + + + + 入组进度 + + {{ enrollmentSummary }} + + + + 按中心 + 按月份 + + + + + + + + + + diff --git a/frontend/src/views/ia/project-overview/CenterProgressRow.vue b/frontend/src/views/ia/project-overview/CenterProgressRow.vue new file mode 100644 index 00000000..1c00c8c2 --- /dev/null +++ b/frontend/src/views/ia/project-overview/CenterProgressRow.vue @@ -0,0 +1,177 @@ + + + + {{ centerName }} + + 入组 + {{ enrollmentLabel }} + + + + + + + + + + + + + + diff --git a/frontend/src/views/ia/project-overview/EnrollmentBarChart.vue b/frontend/src/views/ia/project-overview/EnrollmentBarChart.vue new file mode 100644 index 00000000..ec9843ea --- /dev/null +++ b/frontend/src/views/ia/project-overview/EnrollmentBarChart.vue @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + + + + + + + + {{ formatNumber(tick.value) }} + + + + + + + {{ item.label }} + + + + {{ formatNumber(item.actual) }} + / + {{ formatNumber(item.target || 0) }} + + + {{ truncateLabel(item.label) }} + + + + + + + + + + + + + diff --git a/frontend/src/views/ia/project-overview/StageNode.vue b/frontend/src/views/ia/project-overview/StageNode.vue new file mode 100644 index 00000000..28645e00 --- /dev/null +++ b/frontend/src/views/ia/project-overview/StageNode.vue @@ -0,0 +1,107 @@ + + + + {{ label }} + {{ completionLabel }} + + + + + + diff --git a/frontend/src/views/ia/project-overview/overview.adapter.ts b/frontend/src/views/ia/project-overview/overview.adapter.ts new file mode 100644 index 00000000..7836e48e --- /dev/null +++ b/frontend/src/views/ia/project-overview/overview.adapter.ts @@ -0,0 +1,160 @@ +export type StageStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" | "BLOCKED"; + +export type StageKey = + | "ethics_status" + | "startup_status" + | "enrollment_status" + | "inspection_status" + | "closeout_status"; + +export type StageCompletionKey = + | "ethics_completed_at" + | "startup_completed_at" + | "enrollment_completed_at" + | "inspection_completed_at" + | "closeout_completed_at"; + +export interface CenterOverview { + center_id: string; + center_name: string; + ethics_status: StageStatus; + startup_status: StageStatus; + enrollment_status: StageStatus; + inspection_status: StageStatus; + closeout_status: StageStatus; + enrollment_target: number; + enrollment_actual: number; + ethics_completed_at?: string; + startup_completed_at?: string; + enrollment_completed_at?: string; + inspection_completed_at?: string; + closeout_completed_at?: string; +} + +export interface EnrollmentByMonth { + month: string; + count: number; +} + +export interface ProjectOverviewResponse { + study_id?: string; + updated_at?: string; + centers?: CenterOverview[]; + enrollment_by_month?: EnrollmentByMonth[]; +} + +export interface ProjectOverviewViewModel { + study_id: string; + updated_at: string; + centers: CenterOverview[]; + months: EnrollmentByMonth[]; + summary: { + total_target: number; + total_actual: number; + }; +} + +export const STAGE_ORDER: Array<{ + key: StageKey; + label: string; + completedKey: StageCompletionKey; +}> = [ + { key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" }, + { key: "startup_status", label: "启动", completedKey: "startup_completed_at" }, + { key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" }, + { key: "inspection_status", label: "稽查", completedKey: "inspection_completed_at" }, + { key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" }, +]; + +const STATUS_ALIAS: Record = { + NOT_STARTED: "NOT_STARTED", + PENDING: "NOT_STARTED", + TODO: "NOT_STARTED", + IN_PROGRESS: "IN_PROGRESS", + ACTIVE: "IN_PROGRESS", + ONGOING: "IN_PROGRESS", + COMPLETED: "COMPLETED", + DONE: "COMPLETED", + FINISHED: "COMPLETED", + BLOCKED: "BLOCKED", + DELAYED: "BLOCKED", + HOLD: "BLOCKED", +}; + +const toString = (value: unknown) => (typeof value === "string" ? value : ""); + +const toNumber = (value: unknown) => { + const num = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(num) || num < 0) return 0; + return num; +}; + +const normalizeStatus = (value: unknown): StageStatus => { + if (typeof value !== "string") return "NOT_STARTED"; + return STATUS_ALIAS[value.toUpperCase()] || "NOT_STARTED"; +}; + +export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel => { + const payload = raw && typeof raw === "object" ? (raw as ProjectOverviewResponse) : {}; + const centersRaw = Array.isArray(payload.centers) ? payload.centers : []; + const centers = centersRaw.map((center) => { + const enrollmentStatus = normalizeStatus((center as any).enrollment_status); + const enrollmentActualRaw = toNumber((center as any).enrollment_actual); + const hasEnrollmentStage = enrollmentStatus === "IN_PROGRESS" || enrollmentStatus === "COMPLETED"; + return { + center_id: toString((center as any).center_id || (center as any).id), + center_name: toString((center as any).center_name || (center as any).name), + ethics_status: normalizeStatus((center as any).ethics_status), + startup_status: normalizeStatus((center as any).startup_status), + enrollment_status: enrollmentStatus, + inspection_status: normalizeStatus((center as any).inspection_status), + closeout_status: normalizeStatus((center as any).closeout_status), + enrollment_target: toNumber((center as any).enrollment_target), + enrollment_actual: hasEnrollmentStage ? enrollmentActualRaw : 0, + ethics_completed_at: toString((center as any).ethics_completed_at), + startup_completed_at: toString((center as any).startup_completed_at), + enrollment_completed_at: toString((center as any).enrollment_completed_at), + inspection_completed_at: toString((center as any).inspection_completed_at), + closeout_completed_at: toString((center as any).closeout_completed_at), + }; + }); + + let monthsRaw = Array.isArray(payload.enrollment_by_month) ? payload.enrollment_by_month : []; + if (monthsRaw.length === 0) { + const merged: Record = {}; + centersRaw.forEach((center: any) => { + const list = Array.isArray(center.enrollment_by_month) ? center.enrollment_by_month : []; + list.forEach((item: any) => { + const month = toString(item.month); + if (!month) return; + merged[month] = (merged[month] || 0) + toNumber(item.count); + }); + }); + monthsRaw = Object.entries(merged).map(([month, count]) => ({ month, count })); + } + + const months = monthsRaw + .map((item) => ({ + month: toString((item as any).month), + count: toNumber((item as any).count), + })) + .filter((item) => item.month) + .sort((a, b) => a.month.localeCompare(b.month)); + + const summary = centers.reduce( + (acc, center) => { + acc.total_target += center.enrollment_target; + acc.total_actual += center.enrollment_actual; + return acc; + }, + { total_target: 0, total_actual: 0 } + ); + + return { + study_id: toString((payload as any).study_id), + updated_at: toString((payload as any).updated_at), + centers, + months, + summary, + }; +}; diff --git a/frontend/src/views/ia/project-overview/overview.mock.ts b/frontend/src/views/ia/project-overview/overview.mock.ts new file mode 100644 index 00000000..fb8bc9d3 --- /dev/null +++ b/frontend/src/views/ia/project-overview/overview.mock.ts @@ -0,0 +1,80 @@ +import type { ProjectOverviewResponse } from "./overview.adapter"; + +// demo / overview only + +export const overviewMock: ProjectOverviewResponse = { + study_id: "demo-overview", + updated_at: "2025-06-18", + centers: [ + { + center_id: "center-001", + center_name: "中心01", + ethics_status: "COMPLETED", + ethics_completed_at: "2025-01-12", + startup_status: "COMPLETED", + startup_completed_at: "2025-02-03", + enrollment_status: "IN_PROGRESS", + inspection_status: "NOT_STARTED", + closeout_status: "NOT_STARTED", + enrollment_target: 80, + enrollment_actual: 52, + }, + { + center_id: "center-002", + center_name: "中心02", + ethics_status: "COMPLETED", + ethics_completed_at: "2025-02-08", + startup_status: "IN_PROGRESS", + enrollment_status: "NOT_STARTED", + inspection_status: "NOT_STARTED", + closeout_status: "NOT_STARTED", + enrollment_target: 60, + enrollment_actual: 0, + }, + { + center_id: "center-003", + center_name: "中心03", + ethics_status: "COMPLETED", + ethics_completed_at: "2024-12-28", + startup_status: "COMPLETED", + startup_completed_at: "2025-01-20", + enrollment_status: "COMPLETED", + enrollment_completed_at: "2025-04-05", + inspection_status: "IN_PROGRESS", + closeout_status: "NOT_STARTED", + enrollment_target: 70, + enrollment_actual: 70, + }, + { + center_id: "center-004", + center_name: "中心04", + ethics_status: "COMPLETED", + ethics_completed_at: "2025-01-15", + startup_status: "BLOCKED", + enrollment_status: "NOT_STARTED", + inspection_status: "NOT_STARTED", + closeout_status: "NOT_STARTED", + enrollment_target: 50, + enrollment_actual: 0, + }, + { + center_id: "center-005", + center_name: "中心05", + ethics_status: "IN_PROGRESS", + startup_status: "NOT_STARTED", + enrollment_status: "NOT_STARTED", + inspection_status: "NOT_STARTED", + closeout_status: "NOT_STARTED", + enrollment_target: 40, + enrollment_actual: 0, + }, + ], + enrollment_by_month: [ + { month: "2025-01", count: 12 }, + { month: "2025-02", count: 18 }, + { month: "2025-03", count: 24 }, + { month: "2025-04", count: 28 }, + { month: "2025-05", count: 32 }, + { month: "2025-06", count: 30 }, + ], +}; diff --git a/frontend/src/views/subjects/SubjectDetail.vue b/frontend/src/views/subjects/SubjectDetail.vue index 7ffefaa7..8bbb51c3 100644 --- a/frontend/src/views/subjects/SubjectDetail.vue +++ b/frontend/src/views/subjects/SubjectDetail.vue @@ -6,8 +6,14 @@ {{ TEXT.modules.subjectDetail.subtitle }} - {{ TEXT.common.actions.edit }} - {{ TEXT.common.actions.back }} + + {{ TEXT.common.actions.save }} + {{ TEXT.common.actions.cancel }} + + + {{ TEXT.common.actions.edit }} + {{ TEXT.common.actions.back }} + @@ -15,11 +21,50 @@ {{ detail.subject_no || TEXT.common.fallback }} {{ siteMap[detail.site_id] || TEXT.common.fallback }} - {{ displayEnum(TEXT.enums.subjectStatus, detail.status) }} + + + + + + + + {{ displayEnum(TEXT.enums.subjectStatus, detail.status) }} + {{ displayDate(detail.screening_date) }} - {{ displayDate(detail.enrollment_date) }} - {{ displayDate(detail.completion_date) }} - {{ detail.drop_reason || TEXT.common.fallback }} + + + {{ displayDate(detail.consent_date) }} + + + + {{ displayDate(detail.enrollment_date) }} + + + + {{ displayDate(detail.completion_date) }} + + + + {{ detail.drop_reason || TEXT.common.fallback }} + @@ -50,24 +95,53 @@ - - - {{ displayText(scope.row.visit_name, TEXT.enums.visitName) }} - - - + {{ displayDate(scope.row.planned_date) }} - - {{ displayDate(scope.row.actual_date) }} - - - {{ displayEnum(TEXT.enums.visitStatus, scope.row.status) }} - - + - {{ TEXT.common.actions.edit }} - {{ TEXT.common.actions.delete }} + + {{ displayDate(scope.row.actual_date) }} + + + + + + {{ displayEnum(TEXT.enums.visitStatus, getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined)) }} + + + + + + + {{ scope.row.notes || TEXT.common.fallback }} + + + + + + {{ TEXT.common.actions.save }} + {{ TEXT.common.actions.cancel }} + + + {{ TEXT.common.actions.edit }} + {{ TEXT.common.actions.delete }} + @@ -122,9 +196,6 @@ - - - @@ -139,13 +210,6 @@ - - - - - - - @@ -201,12 +265,12 @@ import { onMounted, reactive, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; import { ElMessage, ElMessageBox } from "element-plus"; import { useStudyStore } from "../../store/study"; -import { getSubject } from "../../api/subjects"; +import { getSubject, updateSubject } from "../../api/subjects"; import { fetchSites } from "../../api/sites"; import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories"; import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits"; import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes"; -import { displayDate, displayEnum, displayText } from "../../utils/display"; +import { displayDate, displayEnum } from "../../utils/display"; import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; @@ -222,11 +286,22 @@ const detail = reactive({ site_id: "", status: "", screening_date: "", + consent_date: "", enrollment_date: "", completion_date: "", drop_reason: "", }); +const subjectEditing = ref(false); +const subjectSaving = ref(false); +const subjectForm = reactive({ + status: "SCREENING", + enrollment_date: "", + consent_date: "", + completion_date: "", + drop_reason: "", +}); + const siteMap = ref>({}); const activeTab = ref("history"); @@ -243,14 +318,17 @@ const visitItems = ref([]); const loadingVisits = ref(false); const visitDialogVisible = ref(false); const visitEditingId = ref(null); +const visitEditingRowId = ref(null); +const visitRowDraft = reactive({ + actual_date: "", + notes: "", +}); const visitForm = reactive({ visit_code: "", - visit_name: "", planned_date: "", window_start: "", window_end: "", actual_date: "", - status: "PLANNED", notes: "", }); @@ -295,6 +373,41 @@ const loadSubject = async () => { } }; +const startSubjectEdit = () => { + subjectForm.status = detail.status || "SCREENING"; + subjectForm.consent_date = detail.consent_date || ""; + subjectForm.enrollment_date = detail.enrollment_date || ""; + subjectForm.completion_date = detail.completion_date || ""; + subjectForm.drop_reason = detail.drop_reason || ""; + subjectEditing.value = true; +}; + +const cancelSubjectEdit = () => { + subjectEditing.value = false; +}; + +const saveSubjectEdit = async () => { + if (!studyId || !subjectId) return; + subjectSaving.value = true; + try { + const payload = { + status: subjectForm.status || null, + consent_date: subjectForm.consent_date || null, + enrollment_date: subjectForm.enrollment_date || null, + completion_date: subjectForm.completion_date || null, + drop_reason: subjectForm.drop_reason || null, + }; + await updateSubject(studyId, subjectId, payload); + ElMessage.success(TEXT.common.messages.saveSuccess); + subjectEditing.value = false; + loadSubject(); + } catch (e: any) { + ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); + } finally { + subjectSaving.value = false; + } +}; + const loadHistories = async () => { if (!studyId || !subjectId) return; loadingHistory.value = true; @@ -364,28 +477,93 @@ const openVisitDialog = (row?: any) => { visitEditingId.value = row?.id || null; if (row) { visitForm.actual_date = row.actual_date || ""; - visitForm.status = row.status || "PLANNED"; visitForm.notes = row.notes || ""; } else { visitForm.visit_code = ""; - visitForm.visit_name = ""; visitForm.planned_date = ""; visitForm.window_start = ""; visitForm.window_end = ""; visitForm.actual_date = ""; - visitForm.status = "PLANNED"; visitForm.notes = ""; } visitDialogVisible.value = true; }; +const startVisitRowEdit = (row: any) => { + visitEditingRowId.value = row?.id || null; + visitRowDraft.actual_date = row?.actual_date || ""; + visitRowDraft.notes = row?.notes || ""; +}; + +const cancelVisitRow = () => { + visitEditingRowId.value = null; +}; + +const saveVisitRow = async (row: any) => { + if (!studyId || !subjectId || !row?.id) return; + try { + const payload = { + actual_date: visitRowDraft.actual_date || null, + notes: visitRowDraft.notes || null, + }; + await updateVisit(studyId, subjectId, row.id, payload); + visitEditingRowId.value = null; + loadVisits(); + } catch (e: any) { + ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); + } +}; + +const parseDate = (value?: string | null) => { + if (!value) return null; + const date = new Date(value); + if (isNaN(date.getTime())) return null; + return date; +}; + +const getVisitStatus = (row: any, actualDateOverride?: string) => { + if (row?.status === "CANCELLED") return "CANCELLED"; + const actualDate = parseDate(actualDateOverride ?? row?.actual_date); + const windowStart = parseDate(row?.window_start); + const windowEnd = parseDate(row?.window_end); + const plannedDate = parseDate(row?.planned_date); + + if (actualDate) { + if ((windowStart && actualDate < windowStart) || (windowEnd && actualDate > windowEnd)) { + return "OVERDUE"; + } + return "DONE"; + } + + const today = new Date(); + const deadline = windowEnd || plannedDate; + if (deadline && today > deadline) { + return "LOST"; + } + return "PLANNED"; +}; + +const getVisitStatusTagType = (status: string) => { + switch (status) { + case "DONE": + return "success"; + case "OVERDUE": + return "warning"; + case "LOST": + return "danger"; + case "CANCELLED": + return "info"; + default: + return "info"; + } +}; + const saveVisit = async () => { if (!studyId || !subjectId) return; try { if (visitEditingId.value) { const payload = { actual_date: visitForm.actual_date || null, - status: visitForm.status || null, notes: visitForm.notes || null, }; await updateVisit(studyId, subjectId, visitEditingId.value, payload); @@ -393,7 +571,6 @@ const saveVisit = async () => { const payload = { subject_id: subjectId, visit_code: visitForm.visit_code, - visit_name: visitForm.visit_name, planned_date: visitForm.planned_date || null, window_start: visitForm.window_start || null, window_end: visitForm.window_end || null, @@ -484,7 +661,6 @@ const removeAe = async (row: any) => { } }; -const goEdit = () => router.push(`/subjects/${subjectId}/edit`); const goBack = () => router.push("/subjects"); onMounted(async () => { diff --git a/frontend/src/views/subjects/SubjectForm.vue b/frontend/src/views/subjects/SubjectForm.vue index 92e0219e..c4d99fcc 100644 --- a/frontend/src/views/subjects/SubjectForm.vue +++ b/frontend/src/views/subjects/SubjectForm.vue @@ -27,6 +27,14 @@ :disabled="isEdit" /> + + + @@ -76,6 +84,7 @@ const form = reactive({ subject_no: "", site_id: "", screening_date: "", + consent_date: "", status: "SCREENING", enrollment_date: "", completion_date: "", @@ -100,6 +109,7 @@ const load = async () => { subject_no: data.subject_no || "", site_id: data.site_id || "", screening_date: data.screening_date || "", + consent_date: data.consent_date || "", status: data.status || "SCREENING", enrollment_date: data.enrollment_date || "", completion_date: data.completion_date || "", @@ -117,6 +127,7 @@ const submit = async () => { if (isEdit.value && subjectId.value) { const payload = { status: form.status || null, + consent_date: form.consent_date || null, enrollment_date: form.enrollment_date || null, completion_date: form.completion_date || null, drop_reason: form.drop_reason || null, @@ -129,6 +140,7 @@ const submit = async () => { subject_no: form.subject_no, site_id: form.site_id, screening_date: form.screening_date || null, + consent_date: form.consent_date || null, }; const { data } = await createSubject(studyId.value, payload); ElMessage.success(TEXT.common.messages.createSuccess);
多中心项目整体进度与入组情况
+ {{ study.currentStudy.name }} + / + {{ study.currentStudy.code }} +
{{ TEXT.modules.subjectDetail.subtitle }}