From b3cd8e03f244c46a9b20cb13082725631bdd7100 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 13 Jan 2026 10:10:28 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=A6=82=E8=A7=88=E3=80=81?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E8=80=85=E7=AE=A1=E7=90=86=E5=88=9D=E6=AD=A5?= =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=88=E6=9C=AA=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=95=B0=E6=8D=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- backend/alembic.ini | 36 ++ backend/alembic/env.py | 71 ++++ ...0501_000003_drop_visit_name_from_visits.py | 22 ++ ...01_000004_add_visit_template_to_studies.py | 25 ++ ...501_000005_add_consent_date_to_subjects.py | 19 ++ backend/app/api/v1/subject_histories.py | 2 +- backend/app/api/v1/subjects.py | 14 +- backend/app/api/v1/visits.py | 19 +- backend/app/crud/ae.py | 2 +- backend/app/crud/study.py | 4 + backend/app/crud/subject.py | 45 ++- backend/app/crud/subject_history.py | 2 +- backend/app/crud/visit.py | 56 ++- backend/app/main.py | 2 +- backend/app/models/study.py | 6 +- backend/app/models/subject.py | 1 + backend/app/models/visit.py | 1 - backend/app/schemas/study.py | 12 + backend/app/schemas/subject.py | 3 + backend/app/schemas/visit.py | 2 - backend/requirements.txt | 1 + database/init.sql | 38 ++- frontend/src/api/overview.ts | 3 + frontend/src/locales/zh-CN.ts | 47 +-- frontend/src/main.ts | 6 +- frontend/src/types/api.ts | 4 + frontend/src/utils/display/index.ts | 2 +- frontend/src/views/admin/ProjectForm.vue | 33 ++ frontend/src/views/ia/ProjectOverview.vue | 323 +++++++++++++++++- .../ia/project-overview/CenterProgressRow.vue | 177 ++++++++++ .../project-overview/EnrollmentBarChart.vue | 304 +++++++++++++++++ .../views/ia/project-overview/StageNode.vue | 107 ++++++ .../ia/project-overview/overview.adapter.ts | 160 +++++++++ .../ia/project-overview/overview.mock.ts | 80 +++++ frontend/src/views/subjects/SubjectDetail.vue | 258 +++++++++++--- frontend/src/views/subjects/SubjectForm.vue | 12 + 37 files changed, 1785 insertions(+), 118 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/versions/20240501_000003_drop_visit_name_from_visits.py create mode 100644 backend/alembic/versions/20240501_000004_add_visit_template_to_studies.py create mode 100644 backend/alembic/versions/20240501_000005_add_consent_date_to_subjects.py create mode 100644 frontend/src/api/overview.ts create mode 100644 frontend/src/views/ia/project-overview/CenterProgressRow.vue create mode 100644 frontend/src/views/ia/project-overview/EnrollmentBarChart.vue create mode 100644 frontend/src/views/ia/project-overview/StageNode.vue create mode 100644 frontend/src/views/ia/project-overview/overview.adapter.ts create mode 100644 frontend/src/views/ia/project-overview/overview.mock.ts 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 }} + + + + + + + + + + + +