抽屉化物资与启动授权流程
1、将药物发货新建和编辑从独立页面迁移到抽屉,详情页支持权限控制和停用中心只读状态。 2、补充药物发货状态字段校验,统一 PENDING、IN_TRANSIT、SIGNED、EXCEPTION 状态及演示数据。 3、为物资设备增加详情页和编辑抽屉,复用通用附件上传并按项目权限控制操作入口。 4、将启动会和培训授权编辑迁移到抽屉,详情页按中心过滤培训记录并移除旧独立编辑路由。
This commit is contained in:
@@ -8,7 +8,12 @@ from app.crud import audit as audit_crud
|
|||||||
from app.crud import drug_shipment as shipment_crud
|
from app.crud import drug_shipment as shipment_crud
|
||||||
from app.crud import site as site_crud
|
from app.crud import site as site_crud
|
||||||
from app.crud import study as study_crud
|
from app.crud import study as study_crud
|
||||||
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate
|
from app.schemas.drug_shipment import (
|
||||||
|
DrugShipmentCreate,
|
||||||
|
DrugShipmentRead,
|
||||||
|
DrugShipmentUpdate,
|
||||||
|
validate_drug_shipment_required_fields,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -143,6 +148,20 @@ async def update_shipment(
|
|||||||
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
|
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
|
||||||
else:
|
else:
|
||||||
await _ensure_center_active(db, study_id, shipment.center_id)
|
await _ensure_center_active(db, study_id, shipment.center_id)
|
||||||
|
update_data = shipment_in.model_dump(exclude_unset=True)
|
||||||
|
try:
|
||||||
|
validate_drug_shipment_required_fields(
|
||||||
|
update_data.get("status", shipment.status),
|
||||||
|
update_data.get("ship_date", shipment.ship_date),
|
||||||
|
update_data.get("receive_date", shipment.receive_date),
|
||||||
|
update_data.get("quantity", shipment.quantity),
|
||||||
|
update_data.get("batch_no", shipment.batch_no),
|
||||||
|
update_data.get("carrier", shipment.carrier),
|
||||||
|
update_data.get("tracking_no", shipment.tracking_no),
|
||||||
|
update_data.get("remark", shipment.remark),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
|
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -468,13 +468,14 @@ async def list_training_authorizations(
|
|||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
|
site_name: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> list[TrainingAuthorizationRead]:
|
) -> list[TrainingAuthorizationRead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||||
site_names = cra_scope[1] if cra_scope else None
|
site_names = cra_scope[1] if cra_scope else None
|
||||||
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names)
|
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names, site_name=site_name)
|
||||||
return [TrainingAuthorizationRead.model_validate(item) for item in items]
|
return [TrainingAuthorizationRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ async def list_training_authorizations(
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
site_names: set[str] | None = None,
|
site_names: set[str] | None = None,
|
||||||
|
site_name: str | None = None,
|
||||||
) -> Sequence[TrainingAuthorization]:
|
) -> Sequence[TrainingAuthorization]:
|
||||||
if site_names is not None and not site_names:
|
if site_names is not None and not site_names:
|
||||||
return []
|
return []
|
||||||
@@ -255,6 +256,8 @@ async def list_training_authorizations(
|
|||||||
)
|
)
|
||||||
if site_names is not None:
|
if site_names is not None:
|
||||||
stmt = stmt.where(TrainingAuthorization.site_name.in_(site_names))
|
stmt = stmt.where(TrainingAuthorization.site_name.in_(site_names))
|
||||||
|
if site_name:
|
||||||
|
stmt = stmt.where(TrainingAuthorization.site_name == site_name)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
SHIPMENT_STATUSES = {"PENDING", "IN_TRANSIT", "SIGNED", "EXCEPTION"}
|
||||||
|
COMPLETED_SHIPMENT_STATUS = "SIGNED"
|
||||||
|
EXCEPTION_SHIPMENT_STATUS = "EXCEPTION"
|
||||||
|
PENDING_SHIPMENT_STATUS = "PENDING"
|
||||||
|
|
||||||
|
|
||||||
|
def shipment_requires_execution_details(status: str | None) -> bool:
|
||||||
|
return status != PENDING_SHIPMENT_STATUS
|
||||||
|
|
||||||
|
|
||||||
|
def shipment_requires_receive_date(status: str | None) -> bool:
|
||||||
|
return status == COMPLETED_SHIPMENT_STATUS
|
||||||
|
|
||||||
|
|
||||||
|
def shipment_requires_remark(status: str | None) -> bool:
|
||||||
|
return status == EXCEPTION_SHIPMENT_STATUS
|
||||||
|
|
||||||
|
|
||||||
|
def validate_drug_shipment_required_fields(
|
||||||
|
status: str | None,
|
||||||
|
ship_date: date | None,
|
||||||
|
receive_date: date | None,
|
||||||
|
quantity: int | None,
|
||||||
|
batch_no: str | None,
|
||||||
|
carrier: str | None,
|
||||||
|
tracking_no: str | None,
|
||||||
|
remark: str | None,
|
||||||
|
) -> None:
|
||||||
|
if status not in SHIPMENT_STATUSES:
|
||||||
|
raise ValueError("无效状态")
|
||||||
|
if shipment_requires_execution_details(status):
|
||||||
|
missing_execution_fields = [
|
||||||
|
label
|
||||||
|
for label, value in (
|
||||||
|
("寄出/回收日期", ship_date),
|
||||||
|
("数量", quantity),
|
||||||
|
("批号", batch_no),
|
||||||
|
("快递公司", carrier),
|
||||||
|
("单号", tracking_no),
|
||||||
|
)
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip())
|
||||||
|
]
|
||||||
|
if missing_execution_fields:
|
||||||
|
raise ValueError(f"发运信息缺失:{', '.join(missing_execution_fields)}")
|
||||||
|
if shipment_requires_receive_date(status) and receive_date is None:
|
||||||
|
raise ValueError("已签收时必须填写接收日期")
|
||||||
|
if shipment_requires_remark(status) and not (remark or "").strip():
|
||||||
|
raise ValueError("异常状态必须填写备注")
|
||||||
|
|
||||||
|
|
||||||
class DrugShipmentCreate(BaseModel):
|
class DrugShipmentCreate(BaseModel):
|
||||||
direction: str
|
direction: str
|
||||||
center_id: uuid.UUID
|
center_id: uuid.UUID
|
||||||
site_name: Optional[str] = None
|
site_name: Optional[str] = None
|
||||||
ship_date: date
|
ship_date: Optional[date] = None
|
||||||
receive_date: date
|
receive_date: Optional[date] = None
|
||||||
quantity: int
|
quantity: Optional[int] = None
|
||||||
batch_no: str
|
batch_no: Optional[str] = None
|
||||||
carrier: str
|
carrier: Optional[str] = None
|
||||||
tracking_no: str
|
tracking_no: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
remark: str
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_status_required_fields(self):
|
||||||
|
validate_drug_shipment_required_fields(
|
||||||
|
self.status,
|
||||||
|
self.ship_date,
|
||||||
|
self.receive_date,
|
||||||
|
self.quantity,
|
||||||
|
self.batch_no,
|
||||||
|
self.carrier,
|
||||||
|
self.tracking_no,
|
||||||
|
self.remark,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class DrugShipmentUpdate(BaseModel):
|
class DrugShipmentUpdate(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.schemas.drug_shipment import DrugShipmentCreate
|
||||||
|
|
||||||
|
|
||||||
|
def shipment_payload(**overrides):
|
||||||
|
payload = {
|
||||||
|
"direction": "SEND",
|
||||||
|
"center_id": uuid.uuid4(),
|
||||||
|
"ship_date": date(2026, 6, 4),
|
||||||
|
"receive_date": None,
|
||||||
|
"quantity": 1,
|
||||||
|
"batch_no": "DP-001",
|
||||||
|
"carrier": "顺丰",
|
||||||
|
"tracking_no": "SF100003",
|
||||||
|
"status": "IN_TRANSIT",
|
||||||
|
"remark": None,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_allows_pending_shipment_execution_fields_to_be_empty():
|
||||||
|
shipment = DrugShipmentCreate.model_validate(
|
||||||
|
shipment_payload(
|
||||||
|
status="PENDING",
|
||||||
|
ship_date=None,
|
||||||
|
quantity=None,
|
||||||
|
batch_no=None,
|
||||||
|
carrier=None,
|
||||||
|
tracking_no=None,
|
||||||
|
receive_date=None,
|
||||||
|
remark=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert shipment.ship_date is None
|
||||||
|
assert shipment.quantity is None
|
||||||
|
assert shipment.batch_no is None
|
||||||
|
assert shipment.carrier is None
|
||||||
|
assert shipment.tracking_no is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field_name", ["ship_date", "quantity", "batch_no", "carrier", "tracking_no"])
|
||||||
|
def test_create_requires_shipment_execution_fields_after_pending(field_name):
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
DrugShipmentCreate.model_validate(shipment_payload(status="IN_TRANSIT", **{field_name: None}))
|
||||||
|
|
||||||
|
assert "发运信息" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_allows_pending_receipt_fields_to_be_empty():
|
||||||
|
shipment = DrugShipmentCreate.model_validate(shipment_payload())
|
||||||
|
|
||||||
|
assert shipment.receive_date is None
|
||||||
|
assert shipment.remark is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_requires_receive_date_when_signed():
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
DrugShipmentCreate.model_validate(shipment_payload(status="SIGNED", receive_date=None))
|
||||||
|
|
||||||
|
assert "接收日期" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_rejects_removed_returned_status():
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
DrugShipmentCreate.model_validate(shipment_payload(status="RETURNED", receive_date=date(2026, 6, 5)))
|
||||||
|
|
||||||
|
assert "无效状态" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_requires_remark_when_exceptional():
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
DrugShipmentCreate.model_validate(shipment_payload(status="EXCEPTION", remark=""))
|
||||||
|
|
||||||
|
assert "备注" in str(exc_info.value)
|
||||||
+8
-44
@@ -184,27 +184,15 @@ CREATE TABLE IF NOT EXISTS public.adverse_events (
|
|||||||
CONSTRAINT fk_adverse_events_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
|
CONSTRAINT fk_adverse_events_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.finance_contracts (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
study_id uuid NOT NULL,
|
|
||||||
site_name character varying(255) NOT NULL,
|
|
||||||
contract_no character varying(100) NOT NULL,
|
|
||||||
signed_date date,
|
|
||||||
amount numeric(12, 2) NOT NULL,
|
|
||||||
currency character varying(10) NOT NULL DEFAULT 'CNY',
|
|
||||||
remark text,
|
|
||||||
created_by uuid,
|
|
||||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT fk_finance_contracts_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT,
|
|
||||||
CONSTRAINT fk_finance_contracts_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.contract_fees (
|
CREATE TABLE IF NOT EXISTS public.contract_fees (
|
||||||
id uuid PRIMARY KEY,
|
id uuid PRIMARY KEY,
|
||||||
project_id uuid NOT NULL,
|
project_id uuid NOT NULL,
|
||||||
center_id uuid NOT NULL,
|
center_id uuid NOT NULL,
|
||||||
|
contract_no character varying(100),
|
||||||
|
signed_date date,
|
||||||
contract_amount numeric(12, 2) NOT NULL,
|
contract_amount numeric(12, 2) NOT NULL,
|
||||||
|
currency character varying(10) NOT NULL DEFAULT 'CNY',
|
||||||
|
remark text,
|
||||||
contract_cases integer NOT NULL,
|
contract_cases integer NOT NULL,
|
||||||
actual_cases integer,
|
actual_cases integer,
|
||||||
settlement_amount numeric(12, 2),
|
settlement_amount numeric(12, 2),
|
||||||
@@ -231,22 +219,6 @@ CREATE TABLE IF NOT EXISTS public.contract_fee_payments (
|
|||||||
CONSTRAINT fk_contract_fee_payments_contract_fee_id FOREIGN KEY (contract_fee_id) REFERENCES public.contract_fees(id) ON DELETE CASCADE
|
CONSTRAINT fk_contract_fee_payments_contract_fee_id FOREIGN KEY (contract_fee_id) REFERENCES public.contract_fees(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.fee_attachments (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
entity_type character varying(50) NOT NULL,
|
|
||||||
entity_id uuid NOT NULL,
|
|
||||||
file_type character varying(30) NOT NULL,
|
|
||||||
filename character varying(255) NOT NULL,
|
|
||||||
mime_type character varying(100),
|
|
||||||
size integer NOT NULL,
|
|
||||||
storage_key character varying(500),
|
|
||||||
url text,
|
|
||||||
uploaded_by uuid NOT NULL,
|
|
||||||
uploaded_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
||||||
is_deleted boolean NOT NULL DEFAULT false,
|
|
||||||
CONSTRAINT fk_fee_attachments_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES public.users(id) ON DELETE RESTRICT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.finance_items (
|
CREATE TABLE IF NOT EXISTS public.finance_items (
|
||||||
id uuid PRIMARY KEY,
|
id uuid PRIMARY KEY,
|
||||||
study_id uuid NOT NULL,
|
study_id uuid NOT NULL,
|
||||||
@@ -498,7 +470,6 @@ CREATE INDEX IF NOT EXISTS ix_visits_subject_id ON public.visits (subject_id);
|
|||||||
CREATE INDEX IF NOT EXISTS ix_adverse_events_study_id ON public.adverse_events (study_id);
|
CREATE INDEX IF NOT EXISTS ix_adverse_events_study_id ON public.adverse_events (study_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_adverse_events_site_id ON public.adverse_events (site_id);
|
CREATE INDEX IF NOT EXISTS ix_adverse_events_site_id ON public.adverse_events (site_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_adverse_events_subject_id ON public.adverse_events (subject_id);
|
CREATE INDEX IF NOT EXISTS ix_adverse_events_subject_id ON public.adverse_events (subject_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_finance_contracts_study_id ON public.finance_contracts (study_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_finance_items_study_id ON public.finance_items (study_id);
|
CREATE INDEX IF NOT EXISTS ix_finance_items_study_id ON public.finance_items (study_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (site_id);
|
CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (site_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id);
|
CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id);
|
||||||
@@ -590,13 +561,6 @@ INSERT INTO public.milestones (
|
|||||||
('20202020-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'LPI', '首例入组', '2025-02-01', NULL, 'NOT_STARTED', 'cccccccc-cccc-cccc-cccc-cccccccccccc', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '待完成', '2025-01-06 12:10:00+00', '2025-01-06 12:10:00+00')
|
('20202020-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'LPI', '首例入组', '2025-02-01', NULL, 'NOT_STARTED', 'cccccccc-cccc-cccc-cccc-cccccccccccc', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '待完成', '2025-01-06 12:10:00+00', '2025-01-06 12:10:00+00')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO public.finance_contracts (
|
|
||||||
id, study_id, site_name, contract_no, signed_date, amount, currency, remark, created_by, created_at, updated_at
|
|
||||||
) VALUES
|
|
||||||
('77777777-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', 'CON-001', '2025-01-08', 1200000.00, 'CNY', '首批合同签署', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-08 09:00:00+00', '2025-01-08 09:00:00+00'),
|
|
||||||
('77777777-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '上海瑞金医院', 'CON-002', '2025-01-10', 980000.00, 'CNY', '分中心合同签署', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-10 09:00:00+00', '2025-01-10 09:00:00+00')
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO public.finance_items (
|
INSERT INTO public.finance_items (
|
||||||
id, study_id, site_id, subject_id, visit_id, category, title, description, currency, amount, occur_date, status, submitted_at, approved_at, rejected_at, paid_at, approver_id, payer_id, reject_reason, created_by, created_at, updated_at
|
id, study_id, site_id, subject_id, visit_id, category, title, description, currency, amount, occur_date, status, submitted_at, approved_at, rejected_at, paid_at, approver_id, payer_id, reject_reason, created_by, created_at, updated_at
|
||||||
) VALUES
|
) VALUES
|
||||||
@@ -607,10 +571,10 @@ ON CONFLICT (id) DO NOTHING;
|
|||||||
INSERT INTO public.drug_shipments (
|
INSERT INTO public.drug_shipments (
|
||||||
id, study_id, direction, center_id, site_name, ship_date, receive_date, quantity, batch_no, carrier, tracking_no, status, remark, created_by, created_at, updated_at
|
id, study_id, direction, center_id, site_name, ship_date, receive_date, quantity, batch_no, carrier, tracking_no, status, remark, created_by, created_at, updated_at
|
||||||
) VALUES
|
) VALUES
|
||||||
('aaaa1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-01-12', NULL, 200, 'DP-001', '顺丰', 'SF100001', '运输中', '首批寄送:DP-001 片剂 50mg × 200 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 11:00:00+00', '2025-01-12 11:00:00+00'),
|
('aaaa1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-01-12', NULL, 200, 'DP-001', '顺丰', 'SF100001', 'IN_TRANSIT', '首批寄送:DP-001 片剂 50mg × 200 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 11:00:00+00', '2025-01-12 11:00:00+00'),
|
||||||
('bbbb1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-01-13', '2025-01-14', 150, 'DP-001', '京东物流', 'JD200002', '已签收', '分中心补货:DP-001 片剂 50mg × 150 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-14 09:00:00+00'),
|
('bbbb1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-01-13', '2025-01-14', 150, 'DP-001', '京东物流', 'JD200002', 'SIGNED', '分中心补货:DP-001 片剂 50mg × 150 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-14 09:00:00+00'),
|
||||||
('cccc1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-02-05', '2025-02-06', NULL, 'DP-001', '顺丰', 'SF100003', '已回收', '首批回收:空药盒及剩余药品', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-05 14:00:00+00', '2025-02-06 09:00:00+00'),
|
('cccc1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-02-05', '2025-02-06', 1, 'DP-001', '顺丰', 'SF100003', 'SIGNED', '首批回收:空药盒及剩余药品', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-05 14:00:00+00', '2025-02-06 09:00:00+00'),
|
||||||
('dddd1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-02-08', NULL, NULL, 'DP-001', '德邦', 'DB300004', '运输中', '回收途中:冷链箱回收', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-08 16:00:00+00', '2025-02-08 16:00:00+00')
|
('dddd1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-02-08', NULL, 1, 'DP-001', '德邦', 'DB300004', 'IN_TRANSIT', '回收途中:冷链箱回收', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-08 16:00:00+00', '2025-02-08 16:00:00+00')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO public.startup_feasibility (
|
INSERT INTO public.startup_feasibility (
|
||||||
|
|||||||
@@ -65,6 +65,27 @@ describe("admin project route permissions", () => {
|
|||||||
expect(source).not.toContain("finance/contracts");
|
expect(source).not.toContain("finance/contracts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not register standalone contract fee create or edit pages", () => {
|
||||||
|
const source = readRouter();
|
||||||
|
|
||||||
|
expect(source).not.toContain("FeeContractForm");
|
||||||
|
expect(source).not.toContain('name: "FeeContractNew"');
|
||||||
|
expect(source).not.toContain('name: "FeeContractEdit"');
|
||||||
|
expect(source).not.toContain('path: "fees/contracts/new"');
|
||||||
|
expect(source).not.toContain('path: "fees/contracts/:contractId/edit"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not register standalone startup auth edit pages", () => {
|
||||||
|
const source = readRouter();
|
||||||
|
|
||||||
|
expect(source).not.toContain('name: "StartupKickoffEdit"');
|
||||||
|
expect(source).not.toContain('name: "StartupTrainingEdit"');
|
||||||
|
expect(source).not.toContain('name: "StartupTrainingNew"');
|
||||||
|
expect(source).not.toContain('path: "startup/kickoff/:meetingId/edit"');
|
||||||
|
expect(source).not.toContain('path: "startup/training/new"');
|
||||||
|
expect(source).not.toContain('path: "startup/training/:recordId/edit"');
|
||||||
|
});
|
||||||
|
|
||||||
it("uses precautions routes instead of knowledge note routes", () => {
|
it("uses precautions routes instead of knowledge note routes", () => {
|
||||||
const source = readRouter();
|
const source = readRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ import ProfileSettings from "../views/ProfileSettings.vue";
|
|||||||
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
||||||
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
|
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
|
||||||
import FeeContracts from "../views/fees/ContractFees.vue";
|
import FeeContracts from "../views/fees/ContractFees.vue";
|
||||||
import FeeContractForm from "../views/fees/ContractFeeForm.vue";
|
|
||||||
import FeeContractDetail from "../views/fees/ContractFeeDetail.vue";
|
import FeeContractDetail from "../views/fees/ContractFeeDetail.vue";
|
||||||
import DrugShipments from "../views/ia/DrugShipments.vue";
|
import DrugShipments from "../views/ia/DrugShipments.vue";
|
||||||
|
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
|
||||||
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
|
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
|
||||||
|
import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue";
|
||||||
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
|
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
|
||||||
import DocumentList from "../views/documents/DocumentList.vue";
|
import DocumentList from "../views/documents/DocumentList.vue";
|
||||||
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
||||||
@@ -41,15 +42,12 @@ import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
|
|||||||
import Precautions from "../views/ia/Precautions.vue";
|
import Precautions from "../views/ia/Precautions.vue";
|
||||||
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
|
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
|
||||||
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
|
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
|
||||||
import ShipmentForm from "../views/drug/ShipmentForm.vue";
|
|
||||||
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
|
|
||||||
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
|
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
|
||||||
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
|
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
|
||||||
import EthicsForm from "../views/startup/EthicsForm.vue";
|
import EthicsForm from "../views/startup/EthicsForm.vue";
|
||||||
import EthicsDetail from "../views/startup/EthicsDetail.vue";
|
import EthicsDetail from "../views/startup/EthicsDetail.vue";
|
||||||
import KickoffForm from "../views/startup/KickoffForm.vue";
|
import KickoffForm from "../views/startup/KickoffForm.vue";
|
||||||
import KickoffDetail from "../views/startup/KickoffDetail.vue";
|
import KickoffDetail from "../views/startup/KickoffDetail.vue";
|
||||||
import TrainingForm from "../views/startup/TrainingForm.vue";
|
|
||||||
import TrainingDetail from "../views/startup/TrainingDetail.vue";
|
import TrainingDetail from "../views/startup/TrainingDetail.vue";
|
||||||
import PrecautionForm from "../views/knowledge/PrecautionForm.vue";
|
import PrecautionForm from "../views/knowledge/PrecautionForm.vue";
|
||||||
import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
|
import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
|
||||||
@@ -109,42 +107,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: FeeContracts,
|
component: FeeContracts,
|
||||||
meta: { title: TEXT.menu.feeContracts, requiresStudy: true },
|
meta: { title: TEXT.menu.feeContracts, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "fees/contracts/new",
|
|
||||||
name: "FeeContractNew",
|
|
||||||
component: FeeContractForm,
|
|
||||||
meta: { title: TEXT.common.actions.add + TEXT.menu.feeContracts, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "fees/contracts/:contractId",
|
path: "fees/contracts/:contractId",
|
||||||
name: "FeeContractDetail",
|
name: "FeeContractDetail",
|
||||||
component: FeeContractDetail,
|
component: FeeContractDetail,
|
||||||
meta: { title: TEXT.modules.feeContracts.detailTitle, requiresStudy: true },
|
meta: { title: TEXT.modules.feeContracts.detailTitle, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "fees/contracts/:contractId/edit",
|
|
||||||
name: "FeeContractEdit",
|
|
||||||
component: FeeContractForm,
|
|
||||||
meta: { title: TEXT.common.actions.edit + TEXT.menu.feeContracts, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "drug/shipments",
|
path: "drug/shipments",
|
||||||
name: "DrugShipments",
|
name: "DrugShipments",
|
||||||
component: DrugShipments,
|
component: DrugShipments,
|
||||||
meta: { title: TEXT.menu.drugShipments, requiresStudy: true },
|
meta: { title: TEXT.menu.drugShipments, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "materials/equipment",
|
|
||||||
name: "MaterialEquipment",
|
|
||||||
component: MaterialEquipment,
|
|
||||||
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "drug/shipments/new",
|
|
||||||
name: "DrugShipmentNew",
|
|
||||||
component: ShipmentForm,
|
|
||||||
meta: { title: TEXT.common.actions.add + TEXT.menu.drugShipments, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "drug/shipments/:shipmentId",
|
path: "drug/shipments/:shipmentId",
|
||||||
name: "DrugShipmentDetail",
|
name: "DrugShipmentDetail",
|
||||||
@@ -152,10 +126,16 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { title: TEXT.modules.drugShipments.detailTitle, requiresStudy: true },
|
meta: { title: TEXT.modules.drugShipments.detailTitle, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "drug/shipments/:shipmentId/edit",
|
path: "materials/equipment",
|
||||||
name: "DrugShipmentEdit",
|
name: "MaterialEquipment",
|
||||||
component: ShipmentForm,
|
component: MaterialEquipment,
|
||||||
meta: { title: TEXT.common.actions.edit + TEXT.menu.drugShipments, requiresStudy: true },
|
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "materials/equipment/:equipmentId",
|
||||||
|
name: "MaterialEquipmentDetail",
|
||||||
|
component: MaterialEquipmentDetail,
|
||||||
|
meta: { title: TEXT.modules.materialEquipment.detailTitle, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "file-versions",
|
path: "file-versions",
|
||||||
@@ -235,30 +215,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: KickoffDetail,
|
component: KickoffDetail,
|
||||||
meta: { title: TEXT.modules.startupMeetingAuth.kickoffDetailTitle, requiresStudy: true },
|
meta: { title: TEXT.modules.startupMeetingAuth.kickoffDetailTitle, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "startup/kickoff/:meetingId/edit",
|
|
||||||
name: "StartupKickoffEdit",
|
|
||||||
component: KickoffForm,
|
|
||||||
meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.kickoffTab, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "startup/training/new",
|
|
||||||
name: "StartupTrainingNew",
|
|
||||||
component: TrainingForm,
|
|
||||||
meta: { title: TEXT.common.actions.add + TEXT.common.fields.person, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "startup/training/:recordId",
|
path: "startup/training/:recordId",
|
||||||
name: "StartupTrainingDetail",
|
name: "StartupTrainingDetail",
|
||||||
component: TrainingDetail,
|
component: TrainingDetail,
|
||||||
meta: { title: TEXT.modules.startupMeetingAuth.trainingDetailTitle, requiresStudy: true },
|
meta: { title: TEXT.modules.startupMeetingAuth.trainingDetailTitle, requiresStudy: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "startup/training/:recordId/edit",
|
|
||||||
name: "StartupTrainingEdit",
|
|
||||||
component: TrainingForm,
|
|
||||||
meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.trainingTab, requiresStudy: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "subjects",
|
path: "subjects",
|
||||||
name: "SubjectManagement",
|
name: "SubjectManagement",
|
||||||
@@ -447,7 +409,7 @@ const router = createRouter({
|
|||||||
|
|
||||||
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
|
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
|
||||||
audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
|
audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
|
||||||
project_members: { read: "project_members:list", write: "project_members:update" },
|
project_members: { read: "project_members:read", write: "project_members:update" },
|
||||||
sites: { read: "sites:read", write: "sites:update" },
|
sites: { read: "sites:read", write: "sites:update" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const useStudyStore = defineStore("study", () => {
|
|||||||
const currentStudy = ref<Study | null>(null);
|
const currentStudy = ref<Study | null>(null);
|
||||||
const currentStudyRole = ref<string | null>(null);
|
const currentStudyRole = ref<string | null>(null);
|
||||||
const currentSite = ref<any | null>(null);
|
const currentSite = ref<any | null>(null);
|
||||||
const viewContext = ref<{ siteName?: string } | null>(null);
|
const viewContext = ref<{ siteName?: string; pageTitle?: string } | null>(null);
|
||||||
const currentPermissions = ref<any | null>(null);
|
const currentPermissions = ref<any | null>(null);
|
||||||
const normalizeUserKey = (value: string) => value.trim().toLowerCase();
|
const normalizeUserKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ export const useStudyStore = defineStore("study", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setViewContext = (ctx: { siteName?: string } | null) => {
|
const setViewContext = (ctx: { siteName?: string; pageTitle?: string } | null) => {
|
||||||
viewContext.value = ctx;
|
viewContext.value = ctx;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./DrugShipmentEditorDrawer.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("DrugShipmentEditorDrawer attachments", () => {
|
||||||
|
it("uses a compact upload card instead of the full attachment table", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("AttachmentList");
|
||||||
|
expect(source).toContain('ref="attachmentPanelRef"');
|
||||||
|
expect(source).toContain('entity-type="drug_shipment"');
|
||||||
|
expect(source).toContain(':mode="\'upload\'"');
|
||||||
|
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||||
|
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)");
|
||||||
|
expect(source).toContain("uploadPending");
|
||||||
|
expect(source).not.toContain("uploadAttachment");
|
||||||
|
expect(source).not.toContain("pendingFiles");
|
||||||
|
expect(source).not.toContain("upload-card-label");
|
||||||
|
expect(source).not.toContain("<el-table");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DrugShipmentEditorDrawer shipment validation", () => {
|
||||||
|
it("keeps shipment execution fields optional while status is pending", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("requiresShipmentDetails");
|
||||||
|
expect(source).toContain('status !== "PENDING"');
|
||||||
|
expect(source).not.toContain('ship_date: [{ required: true');
|
||||||
|
expect(source).not.toContain('batch_no: [{ required: true');
|
||||||
|
expect(source).not.toContain('carrier: [{ required: true');
|
||||||
|
expect(source).not.toContain('tracking_no: [{ required: true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose the removed returned status", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).not.toContain("RETURNED");
|
||||||
|
expect(source).not.toContain("已回收");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires receive date when shipment is signed", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("requiresReceiveDate");
|
||||||
|
expect(source).toContain('status === "SIGNED"');
|
||||||
|
expect(source).not.toContain('receive_date: [{ required: true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps remark optional unless shipment is exceptional", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("requiresRemark");
|
||||||
|
expect(source).toContain('status === "EXCEPTION"');
|
||||||
|
expect(source).not.toContain('remark: [{ required: true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
v-if="modelValue"
|
||||||
|
:model-value="modelValue"
|
||||||
|
direction="rtl"
|
||||||
|
size="620px"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
:before-close="drawerDirtyGuard.beforeClose"
|
||||||
|
:show-close="false"
|
||||||
|
class="shipment-editor-drawer"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-title">{{ TEXT.modules.drugShipments.editTitle }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="shipment-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-basic"></span>
|
||||||
|
{{ TEXT.common.labels.basicInfo }}
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||||
|
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
|
||||||
|
<el-option
|
||||||
|
v-for="site in sites"
|
||||||
|
:key="site.id"
|
||||||
|
:label="site.name || TEXT.common.fallback"
|
||||||
|
:value="site.id"
|
||||||
|
:disabled="!site.is_active"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
||||||
|
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
|
||||||
|
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||||
|
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<div v-if="selectedSiteInactive" class="inactive-hint">
|
||||||
|
<span class="hint-icon">!</span>
|
||||||
|
<span>中心已停用,当前记录不可编辑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-record"></span>
|
||||||
|
{{ TEXT.modules.drugShipments.recordLabel }}
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date" :required="requiresReceiveDate(form.status)">
|
||||||
|
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-input-number v-model="form.quantity" :min="0" :controls="false" class="full-width" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-remark"></span>
|
||||||
|
{{ TEXT.common.fields.remark }}
|
||||||
|
</div>
|
||||||
|
<el-form-item prop="remark" :required="requiresRemark(form.status)">
|
||||||
|
<el-input v-model="form.remark" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.remark" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-attachment"></span>
|
||||||
|
{{ TEXT.common.labels.attachments }}
|
||||||
|
</div>
|
||||||
|
<AttachmentList
|
||||||
|
ref="attachmentPanelRef"
|
||||||
|
:study-id="studyId"
|
||||||
|
entity-type="drug_shipment"
|
||||||
|
:entity-id="shipmentId"
|
||||||
|
:mode="'upload'"
|
||||||
|
:readonly="isFormReadOnly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">{{ TEXT.common.actions.save }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||||
|
import { getDrugShipment, updateDrugShipment } from "../../api/drugShipments";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
|
import { useStudyStore } from "../../store/study";
|
||||||
|
import { TEXT } from "../../locales";
|
||||||
|
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||||
|
|
||||||
|
type ShipmentDirection = "SEND" | "RETURN";
|
||||||
|
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
|
||||||
|
|
||||||
|
type FormModel = {
|
||||||
|
center_id: string;
|
||||||
|
direction: ShipmentDirection;
|
||||||
|
ship_date: string;
|
||||||
|
receive_date: string;
|
||||||
|
quantity: number | null;
|
||||||
|
batch_no: string;
|
||||||
|
carrier: string;
|
||||||
|
tracking_no: string;
|
||||||
|
status: ShipmentStatus;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; shipmentId: string; readonly?: boolean }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "update:modelValue", value: boolean): void;
|
||||||
|
(event: "saved"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const study = useStudyStore();
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const saving = ref(false);
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||||
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
|
|
||||||
|
const form = reactive<FormModel>({
|
||||||
|
center_id: "",
|
||||||
|
direction: "SEND",
|
||||||
|
ship_date: "",
|
||||||
|
receive_date: "",
|
||||||
|
quantity: null,
|
||||||
|
batch_no: "",
|
||||||
|
carrier: "",
|
||||||
|
tracking_no: "",
|
||||||
|
status: "PENDING",
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||||
|
form,
|
||||||
|
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
|
||||||
|
}));
|
||||||
|
const siteActiveMap = computed(() =>
|
||||||
|
sites.value.reduce((acc: Record<string, boolean>, site: any) => {
|
||||||
|
if (site?.id) acc[site.id] = !!site.is_active;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
const selectedSiteInactive = computed(() => !!form.center_id && siteActiveMap.value[form.center_id] === false);
|
||||||
|
const isFormReadOnly = computed(() => !!props.readonly || selectedSiteInactive.value);
|
||||||
|
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
|
||||||
|
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
|
||||||
|
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
|
||||||
|
|
||||||
|
const validateShipmentDetail = (_rule: unknown, value: string | number | null, callback: (error?: Error) => void) => {
|
||||||
|
if (requiresShipmentDetails(form.status) && (value === null || value === undefined || value === "")) {
|
||||||
|
callback(new Error(TEXT.common.messages.required));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateShipmentTextDetail = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (requiresShipmentDetails(form.status) && !value?.trim()) {
|
||||||
|
callback(new Error(TEXT.common.messages.required));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateReceiveDate = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (requiresReceiveDate(form.status) && !value) {
|
||||||
|
callback(new Error(TEXT.common.messages.required));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateRemark = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (requiresRemark(form.status) && !value?.trim()) {
|
||||||
|
callback(new Error(TEXT.common.messages.required));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules: FormRules<FormModel> = {
|
||||||
|
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
||||||
|
direction: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
||||||
|
ship_date: [{ validator: validateShipmentDetail, trigger: "change" }],
|
||||||
|
receive_date: [{ validator: validateReceiveDate, trigger: "change" }],
|
||||||
|
quantity: [{ validator: validateShipmentDetail, trigger: "change" }],
|
||||||
|
batch_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
|
||||||
|
carrier: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
|
||||||
|
tracking_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
|
||||||
|
status: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
||||||
|
remark: [{ validator: validateRemark, trigger: "blur" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId.value) {
|
||||||
|
sites.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadShipment = async () => {
|
||||||
|
if (!studyId.value || !props.shipmentId) return;
|
||||||
|
try {
|
||||||
|
const { data } = await getDrugShipment(studyId.value, props.shipmentId);
|
||||||
|
Object.assign(form, {
|
||||||
|
center_id: data.center_id || "",
|
||||||
|
direction: data.direction || "SEND",
|
||||||
|
ship_date: data.ship_date || "",
|
||||||
|
receive_date: data.receive_date || "",
|
||||||
|
quantity: data.quantity ?? null,
|
||||||
|
batch_no: data.batch_no || "",
|
||||||
|
carrier: data.carrier || "",
|
||||||
|
tracking_no: data.tracking_no || "",
|
||||||
|
status: data.status || "PENDING",
|
||||||
|
remark: data.remark || "",
|
||||||
|
});
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
await loadSites();
|
||||||
|
await loadShipment();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
if (!studyId.value || !props.shipmentId) return;
|
||||||
|
if (isFormReadOnly.value) {
|
||||||
|
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false);
|
||||||
|
if (!valid) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await updateDrugShipment(studyId.value, props.shipmentId, {
|
||||||
|
center_id: form.center_id,
|
||||||
|
direction: form.direction,
|
||||||
|
ship_date: form.ship_date || null,
|
||||||
|
receive_date: form.receive_date || null,
|
||||||
|
quantity: form.quantity,
|
||||||
|
batch_no: form.batch_no.trim() || null,
|
||||||
|
carrier: form.carrier.trim() || null,
|
||||||
|
tracking_no: form.tracking_no.trim() || null,
|
||||||
|
status: form.status,
|
||||||
|
remark: form.remark.trim() || null,
|
||||||
|
});
|
||||||
|
await attachmentPanelRef.value?.uploadPending(props.shipmentId);
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
emit("saved");
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.modelValue, props.shipmentId, studyId.value] as const,
|
||||||
|
([visible]) => {
|
||||||
|
if (visible) load();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.shipment-editor-drawer > .el-drawer__header) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 22px 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.shipment-editor-drawer > .el-drawer__body) {
|
||||||
|
padding: 0 20px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-form {
|
||||||
|
padding: 0 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
border: 1px solid #e8eef6;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:hover {
|
||||||
|
border-color: #d0dced;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group + .form-group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: #1a3560;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-basic {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-record {
|
||||||
|
background: #f0ad2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-remark {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-attachment {
|
||||||
|
background: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff7d6;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #92400e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-form :deep(.el-form-item__label) {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a6283;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./ShipmentDetail.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("ShipmentDetail edit drawer", () => {
|
||||||
|
it("shows an edit action and opens the shipment editor drawer on the detail page", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("<DrugShipmentEditorDrawer");
|
||||||
|
expect(source).toContain('v-if="canUpdateShipment"');
|
||||||
|
expect(source).toContain("editorVisible.value = true");
|
||||||
|
expect(source).toContain("const handleEditorSaved = () =>");
|
||||||
|
expect(source).toContain('"drug_shipments:update"');
|
||||||
|
expect(source).toContain(":readonly=\"isReadOnly\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the upload button in the detail attachment table", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("<AttachmentList");
|
||||||
|
expect(source).toContain(":hide-uploader=\"true\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="unified-shell">
|
<div class="unified-shell">
|
||||||
<section class="unified-section">
|
<section v-loading="loading" class="unified-section">
|
||||||
<div class="unified-section-header">
|
<div class="unified-section-header">
|
||||||
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div>
|
<div>
|
||||||
<div class="ctms-section-actions">
|
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div>
|
||||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<div class="detail-subtitle">{{ TEXT.modules.drugShipments.detailSubtitle }}</div>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<el-button v-if="canUpdateShipment" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-action-btn">
|
||||||
|
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
|
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||||
<el-tag :type="detail.status === 'PENDING' ? 'info' : detail.status === 'SIGNED' ? 'success' : 'primary'">
|
<el-tag :type="statusType(detail.status)" effect="plain" round>
|
||||||
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
|
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||||
<el-descriptions-item :label="TEXT.common.fields.direction">
|
<el-descriptions-item :label="TEXT.common.fields.direction">
|
||||||
<el-tag :type="detail.direction === 'SEND' ? 'primary' : 'warning'" size="small">
|
<el-tag :class="['type-tag', detail.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
|
||||||
{{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}
|
{{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="unified-section">
|
<section class="unified-section">
|
||||||
<div class="ctms-section-title section-title-inline">{{ TEXT.modules.drugShipments.recordLabel }}</div>
|
<div class="ctms-section-title section-title-inline">{{ TEXT.modules.drugShipments.recordLabel }}</div>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
@@ -36,70 +43,162 @@
|
|||||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="unified-section attachment-section">
|
<section class="unified-section attachment-section">
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
v-if="shipmentId"
|
v-if="shipmentId && studyId"
|
||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="drug_shipment"
|
entity-type="drug_shipment"
|
||||||
:entity-id="shipmentId"
|
:entity-id="shipmentId"
|
||||||
|
:readonly="isReadOnly"
|
||||||
|
:hide-uploader="true"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<DrugShipmentEditorDrawer
|
||||||
|
v-model="editorVisible"
|
||||||
|
:shipment-id="shipmentId || ''"
|
||||||
|
:readonly="isReadOnly"
|
||||||
|
@saved="handleEditorSaved"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
|
import { Edit } from "@element-plus/icons-vue";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { getDrugShipment } from "../../api/drugShipments";
|
import { getDrugShipment } from "../../api/drugShipments";
|
||||||
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
|
import DrugShipmentEditorDrawer from "./DrugShipmentEditorDrawer.vue";
|
||||||
|
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||||
|
import { isSystemAdmin } from "../../utils/roles";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const shipmentId = route.params.shipmentId as string;
|
const editorVisible = ref(false);
|
||||||
const studyId = study.currentStudy?.id || "";
|
const shipmentId = computed(() => route.params.shipmentId as string | undefined);
|
||||||
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
|
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||||
|
const canUpdateShipment = computed(() => isSystemAdmin(auth.user) || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:update"]));
|
||||||
|
const siteActiveMap = computed(() =>
|
||||||
|
sites.value.reduce((acc: Record<string, boolean>, site: any) => {
|
||||||
|
if (site?.id) acc[site.id] = !!site.is_active;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
const detail = reactive<any>({
|
const detail = reactive({
|
||||||
|
center_id: "",
|
||||||
direction: "",
|
direction: "",
|
||||||
status: "",
|
status: "",
|
||||||
site_name: "",
|
site_name: "",
|
||||||
ship_date: "",
|
ship_date: "",
|
||||||
receive_date: "",
|
receive_date: "",
|
||||||
quantity: null,
|
quantity: null as number | null,
|
||||||
batch_no: "",
|
batch_no: "",
|
||||||
carrier: "",
|
carrier: "",
|
||||||
tracking_no: "",
|
tracking_no: "",
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
if (!studyId.value) {
|
||||||
|
sites.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||||
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
|
} catch {
|
||||||
|
sites.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!studyId || !shipmentId) return;
|
if (!studyId.value || !shipmentId.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await getDrugShipment(studyId, shipmentId);
|
const { data } = await getDrugShipment(studyId.value, shipmentId.value);
|
||||||
Object.assign(detail, data);
|
Object.assign(detail, {
|
||||||
|
center_id: data.center_id || "",
|
||||||
|
direction: data.direction || "",
|
||||||
|
status: data.status || "",
|
||||||
|
site_name: data.site_name || "",
|
||||||
|
ship_date: data.ship_date || "",
|
||||||
|
receive_date: data.receive_date || "",
|
||||||
|
quantity: data.quantity ?? null,
|
||||||
|
batch_no: data.batch_no || "",
|
||||||
|
carrier: data.carrier || "",
|
||||||
|
tracking_no: data.tracking_no || "",
|
||||||
|
remark: data.remark || "",
|
||||||
|
});
|
||||||
|
|
||||||
// 同步中心名称到面包屑
|
study.setViewContext({
|
||||||
if (detail.site_name) {
|
siteName: detail.site_name || TEXT.common.fallback,
|
||||||
study.setViewContext({ siteName: detail.site_name });
|
pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle,
|
||||||
}
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => router.push(`/drug/shipments/${shipmentId}/edit`);
|
const goEdit = () => {
|
||||||
const goBack = () => router.push("/drug/shipments");
|
if (!canUpdateShipment.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isReadOnly.value) {
|
||||||
|
ElMessage.warning("中心已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editorVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(load);
|
const handleEditorSaved = () => {
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusType = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "PENDING":
|
||||||
|
return "info";
|
||||||
|
case "IN_TRANSIT":
|
||||||
|
return "primary";
|
||||||
|
case "SIGNED":
|
||||||
|
return "success";
|
||||||
|
case "EXCEPTION":
|
||||||
|
return "danger";
|
||||||
|
default:
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [studyId.value, shipmentId.value],
|
||||||
|
async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -109,10 +208,27 @@ onMounted(load);
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.attachment-section {
|
.attachment-section {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-action-btn {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title-row {
|
.section-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -123,4 +239,19 @@ onMounted(load);
|
|||||||
.section-title-inline {
|
.section-title-inline {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-send {
|
||||||
|
background-color: #fff7ed !important;
|
||||||
|
color: #ea580c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-return {
|
||||||
|
background-color: #fefce8 !important;
|
||||||
|
color: #ca8a04 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<div class="unified-shell">
|
|
||||||
<section class="unified-section">
|
|
||||||
<div class="unified-section-header">
|
|
||||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</div>
|
|
||||||
<div class="ctms-section-actions">
|
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
|
||||||
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
|
|
||||||
<el-row :gutter="24">
|
|
||||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
|
||||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
|
||||||
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
|
||||||
<el-option
|
|
||||||
v-for="site in sites"
|
|
||||||
:key="site.id"
|
|
||||||
:label="site.name || TEXT.common.fallback"
|
|
||||||
:value="site.id"
|
|
||||||
:disabled="!site.is_active"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
|
||||||
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
|
||||||
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
|
||||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
|
||||||
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
|
||||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
|
|
||||||
<el-row :gutter="24">
|
|
||||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
|
||||||
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
|
|
||||||
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
|
||||||
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
|
|
||||||
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
|
||||||
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
|
|
||||||
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
|
||||||
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
|
|
||||||
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
|
||||||
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
|
|
||||||
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
|
||||||
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
|
|
||||||
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
|
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item>
|
|
||||||
<div class="actions">
|
|
||||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
|
|
||||||
{{ TEXT.common.actions.save }}
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</section>
|
|
||||||
<section class="unified-section">
|
|
||||||
<AttachmentList
|
|
||||||
v-if="isEdit && shipmentId"
|
|
||||||
:study-id="studyId"
|
|
||||||
entity-type="drug_shipment"
|
|
||||||
:entity-id="shipmentId"
|
|
||||||
/>
|
|
||||||
<StateEmpty v-else :description="TEXT.modules.drugShipments.uploadHint" />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
|
||||||
import { useRoute, useRouter } from "vue-router";
|
|
||||||
import { ElMessage } from "element-plus";
|
|
||||||
import { useStudyStore } from "../../store/study";
|
|
||||||
import { createDrugShipment, getDrugShipment, updateDrugShipment } from "../../api/drugShipments";
|
|
||||||
import { fetchSites } from "../../api/sites";
|
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
|
||||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const study = useStudyStore();
|
|
||||||
const saving = ref(false);
|
|
||||||
const formRef = ref();
|
|
||||||
const sites = ref<any[]>([]);
|
|
||||||
const siteActiveMap = computed(() => {
|
|
||||||
const map: Record<string, boolean> = {};
|
|
||||||
sites.value.forEach((site) => {
|
|
||||||
if (site?.id) map[site.id] = !!site.is_active;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
const shipmentId = computed(() => route.params.shipmentId as string | undefined);
|
|
||||||
const isEdit = computed(() => !!shipmentId.value);
|
|
||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
|
||||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
|
||||||
const canMutate = computed(() => {
|
|
||||||
const operationKey = isEdit.value ? "drug_shipments:update" : "drug_shipments:create";
|
|
||||||
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
|
|
||||||
});
|
|
||||||
const isReadOnly = computed(() => !canMutate.value || (isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false));
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
center_id: "",
|
|
||||||
direction: "SEND",
|
|
||||||
ship_date: "",
|
|
||||||
receive_date: "",
|
|
||||||
quantity: null as number | null,
|
|
||||||
batch_no: "",
|
|
||||||
carrier: "",
|
|
||||||
tracking_no: "",
|
|
||||||
status: "PENDING",
|
|
||||||
remark: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules = {
|
|
||||||
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
|
||||||
direction: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
|
||||||
ship_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
|
||||||
receive_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
|
||||||
quantity: [{ required: true, type: "number", message: TEXT.common.messages.required, trigger: "change" }],
|
|
||||||
batch_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
|
||||||
carrier: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
|
||||||
tracking_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
|
||||||
status: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
|
||||||
remark: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadSites = async () => {
|
|
||||||
const studyId = study.currentStudy?.id;
|
|
||||||
if (!studyId) return;
|
|
||||||
try {
|
|
||||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
|
||||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
|
||||||
} catch {
|
|
||||||
sites.value = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
if (!isEdit.value || !studyId.value || !shipmentId.value) return;
|
|
||||||
try {
|
|
||||||
const { data } = await getDrugShipment(studyId.value, shipmentId.value);
|
|
||||||
Object.assign(form, {
|
|
||||||
direction: data.direction || "SEND",
|
|
||||||
center_id: data.center_id || "",
|
|
||||||
ship_date: data.ship_date || "",
|
|
||||||
receive_date: data.receive_date || "",
|
|
||||||
quantity: data.quantity ?? null,
|
|
||||||
batch_no: data.batch_no || "",
|
|
||||||
carrier: data.carrier || "",
|
|
||||||
tracking_no: data.tracking_no || "",
|
|
||||||
status: data.status || "PENDING",
|
|
||||||
remark: data.remark || "",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!canMutate.value) {
|
|
||||||
ElMessage.warning("权限不足");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isReadOnly.value) {
|
|
||||||
ElMessage.warning("中心已停用");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!studyId.value) return;
|
|
||||||
const valid = await formRef.value?.validate().catch(() => false);
|
|
||||||
if (!valid) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
center_id: form.center_id,
|
|
||||||
direction: form.direction,
|
|
||||||
ship_date: form.ship_date || null,
|
|
||||||
receive_date: form.receive_date || null,
|
|
||||||
quantity: form.quantity,
|
|
||||||
batch_no: form.batch_no,
|
|
||||||
carrier: form.carrier || null,
|
|
||||||
tracking_no: form.tracking_no || null,
|
|
||||||
status: form.status,
|
|
||||||
remark: form.remark || null,
|
|
||||||
};
|
|
||||||
if (isEdit.value && shipmentId.value) {
|
|
||||||
await updateDrugShipment(studyId.value, shipmentId.value, payload);
|
|
||||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
|
||||||
router.push(`/drug/shipments/${shipmentId.value}`);
|
|
||||||
} else {
|
|
||||||
const { data } = await createDrugShipment(studyId.value, payload);
|
|
||||||
ElMessage.success(TEXT.common.messages.createSuccess);
|
|
||||||
router.push(`/drug/shipments/${data.id}`);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goBack = () => router.push("/drug/shipments");
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadSites();
|
|
||||||
load();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
margin: 0 0 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ctms-text-primary);
|
|
||||||
border-left: 4px solid var(--ctms-primary);
|
|
||||||
padding-left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./DrugShipments.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("DrugShipments project permissions", () => {
|
||||||
|
it("hides edit and delete actions when the role lacks matching backend operation permissions", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('["drug_shipments:create"]');
|
||||||
|
expect(source).toContain('["drug_shipments:update"]');
|
||||||
|
expect(source).toContain('["drug_shipments:delete"]');
|
||||||
|
expect(source).toContain('v-if="canUpdate"');
|
||||||
|
expect(source).toContain('v-if="canDelete"');
|
||||||
|
expect(source).toContain("if (!canUpdate.value)");
|
||||||
|
expect(source).toContain("if (!canDelete.value)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses flexible data columns so the table fills the available page width", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('class="ctms-table shipment-table"');
|
||||||
|
expect(source).toContain('style="width: 100%"');
|
||||||
|
expect(source).toContain('table-layout="fixed"');
|
||||||
|
expect(source).toContain(':label="TEXT.common.labels.actions" width="130"');
|
||||||
|
expect(source).not.toMatch(/prop="(?:site_name|direction|ship_date|receive_date|quantity|batch_no|status|remark)"[^>]+(?:min-width|width)=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps edit and delete actions on one line", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('class="shipment-actions"');
|
||||||
|
expect(source).toContain(".shipment-actions");
|
||||||
|
expect(source).toContain("white-space: nowrap;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the compact pending upload area in the create/edit drawer", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("AttachmentList");
|
||||||
|
expect(source).toContain('ref="attachmentPanelRef"');
|
||||||
|
expect(source).toContain('entity-type="drug_shipment"');
|
||||||
|
expect(source).toContain(':entity-id="editingId"');
|
||||||
|
expect(source).toContain(':mode="\'upload\'"');
|
||||||
|
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||||
|
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(shipmentId)");
|
||||||
|
expect(source).toContain("uploadPending");
|
||||||
|
expect(source).toContain("group-dot-attachment");
|
||||||
|
expect(source).not.toContain("uploadAttachment");
|
||||||
|
expect(source).not.toContain("pendingFiles");
|
||||||
|
expect(source).not.toContain("upload-card-label");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps shipment execution fields optional while status is pending", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("requiresShipmentDetails");
|
||||||
|
expect(source).toContain('status !== "PENDING"');
|
||||||
|
expect(source).not.toContain('ship_date: [{ required: true');
|
||||||
|
expect(source).not.toContain('batch_no: [{ required: true');
|
||||||
|
expect(source).not.toContain('carrier: [{ required: true');
|
||||||
|
expect(source).not.toContain('tracking_no: [{ required: true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose the removed returned status", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).not.toContain("RETURNED");
|
||||||
|
expect(source).not.toContain("已回收");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires receive date when shipment is signed", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("requiresReceiveDate");
|
||||||
|
expect(source).toContain('status === "SIGNED"');
|
||||||
|
expect(source).not.toContain('receive_date: [{ required: true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps remark optional unless shipment is exceptional", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("requiresRemark");
|
||||||
|
expect(source).toContain('status === "EXCEPTION"');
|
||||||
|
expect(source).not.toContain('remark: [{ required: true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,196 +1,571 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page page--flush">
|
<div class="page page--flush">
|
||||||
|
<div v-if="study.currentStudy" class="unified-shell">
|
||||||
|
<section class="unified-section section--flush">
|
||||||
|
<div class="filter-row">
|
||||||
|
<el-form :inline="true" :model="filters">
|
||||||
|
<el-form-item :label="TEXT.common.fields.site">
|
||||||
|
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
|
||||||
|
<el-option
|
||||||
|
v-for="site in sites"
|
||||||
|
:key="site.id"
|
||||||
|
:label="site.name || TEXT.common.fallback"
|
||||||
|
:value="site.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="TEXT.common.fields.direction">
|
||||||
|
<el-select v-model="filters.direction" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
|
||||||
|
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="TEXT.common.fields.status">
|
||||||
|
<el-select v-model="filters.status" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">{{ TEXT.common.actions.search }}</el-button>
|
||||||
|
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-button v-if="canCreate" type="primary" @click="openCreate">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="main-content-flat unified-shell">
|
|
||||||
<div class="filter-container unified-action-bar">
|
|
||||||
<el-form :inline="true" :model="filters" class="filter-form">
|
|
||||||
<div class="filter-item">
|
|
||||||
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
|
||||||
<template #prefix>
|
|
||||||
<el-icon><Location /></el-icon>
|
|
||||||
</template>
|
|
||||||
<el-option
|
|
||||||
v-for="site in sites"
|
|
||||||
:key="site.id"
|
|
||||||
:label="site.name || TEXT.common.fallback"
|
|
||||||
:value="site.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
<div class="filter-item">
|
|
||||||
<el-select v-model="filters.direction" clearable :placeholder="TEXT.common.fields.direction" class="filter-select">
|
|
||||||
<template #prefix>
|
|
||||||
<el-icon><Menu /></el-icon>
|
|
||||||
</template>
|
|
||||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
<div class="filter-item">
|
|
||||||
<el-select v-model="filters.status" clearable :placeholder="TEXT.common.fields.status" class="filter-select">
|
|
||||||
<template #prefix>
|
|
||||||
<el-icon><CircleCheck /></el-icon>
|
|
||||||
</template>
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
|
|
||||||
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
<div class="filter-actions">
|
|
||||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="filter-spacer"></div>
|
|
||||||
<el-button type="primary" @click="goNew">
|
|
||||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
|
||||||
{{ TEXT.modules.drugShipments.newTitle }}
|
|
||||||
</el-button>
|
|
||||||
</el-form>
|
|
||||||
</div>
|
|
||||||
<div class="unified-section table-section section--flush-x section--flush-top section--flush-bottom">
|
|
||||||
<el-table
|
<el-table
|
||||||
:data="sortedItems"
|
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
|
:data="sortedItems"
|
||||||
|
class="ctms-table shipment-table"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
class="shipment-table"
|
table-layout="fixed"
|
||||||
:row-class-name="shipmentRowClass"
|
:row-class-name="shipmentRowClass"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
table-layout="fixed"
|
|
||||||
>
|
>
|
||||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" width="150" show-overflow-tooltip>
|
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||||||
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
|
<template #default="{ row }">{{ row.site_name || TEXT.common.fallback }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120">
|
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<el-tag :class="['type-tag', scope.row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
|
<el-tag :class="['type-tag', row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
|
||||||
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}
|
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="165">
|
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
|
||||||
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
|
<template #default="{ row }">{{ displayDate(row.ship_date) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="145">
|
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
|
||||||
<template #default="scope">{{ displayDate(scope.row.receive_date) }}</template>
|
<template #default="{ row }">{{ displayDate(row.receive_date) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity" width="100" align="right">
|
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
|
||||||
<template #default="scope">{{ typeof scope.row.quantity === "number" ? scope.row.quantity : TEXT.common.fallback }}</template>
|
<template #default="{ row }">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" width="120" show-overflow-tooltip>
|
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
|
||||||
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template>
|
<template #default="{ row }">{{ row.batch_no || TEXT.common.fallback }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusType(scope.row.status)" effect="plain" round size="small" class="status-tag">
|
<el-tag :type="statusType(row.status)" effect="plain" round size="small" class="status-tag">
|
||||||
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
|
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" width="160" show-overflow-tooltip>
|
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
|
||||||
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
<template #default="{ row }">{{ row.remark || TEXT.common.fallback }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="110" align="center">
|
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" width="130">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<el-button
|
<div class="shipment-actions">
|
||||||
link
|
<el-button v-if="canUpdate" link type="primary" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||||
type="danger"
|
<el-button
|
||||||
size="small"
|
v-if="canDelete"
|
||||||
:disabled="isInactiveSite(scope.row.center_id)"
|
link
|
||||||
@click.stop="remove(scope.row)"
|
type="danger"
|
||||||
>
|
:disabled="isInactiveSite(row.center_id)"
|
||||||
{{ TEXT.common.actions.delete }}
|
@click.stop="remove(row)"
|
||||||
</el-button>
|
>
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<div v-if="!loading" class="table-empty">{{ TEXT.modules.drugShipments.empty }}</div>
|
<div v-if="!loading" class="table-empty">{{ TEXT.modules.drugShipments.empty }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-if="drawerVisible"
|
||||||
|
v-model="drawerVisible"
|
||||||
|
direction="rtl"
|
||||||
|
size="620px"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
:before-close="drawerDirtyGuard.beforeClose"
|
||||||
|
:show-close="false"
|
||||||
|
class="shipment-editor-drawer"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-title">{{ editingId ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="shipment-form">
|
||||||
|
<!-- 基本信息分组 -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-basic"></span>
|
||||||
|
{{ TEXT.common.labels.basicInfo }}
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||||
|
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
|
||||||
|
<el-option
|
||||||
|
v-for="site in sites"
|
||||||
|
:key="site.id"
|
||||||
|
:label="site.name || TEXT.common.fallback"
|
||||||
|
:value="site.id"
|
||||||
|
:disabled="!site.is_active"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
||||||
|
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
|
||||||
|
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||||
|
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
||||||
|
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<div v-if="selectedSiteInactive" class="inactive-hint">
|
||||||
|
<span class="hint-icon">!</span>
|
||||||
|
<span>中心已停用,当前记录不可编辑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 运输记录分组 -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-record"></span>
|
||||||
|
{{ TEXT.modules.drugShipments.recordLabel }}
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.ship_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
class="full-width"
|
||||||
|
:disabled="isFormReadOnly"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date" :required="requiresReceiveDate(form.status)">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.receive_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
class="full-width"
|
||||||
|
:disabled="isFormReadOnly"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-input-number v-model="form.quantity" :min="0" :controls="false" class="full-width" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no" :required="requiresShipmentDetails(form.status)">
|
||||||
|
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isFormReadOnly" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 备注分组 -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-remark"></span>
|
||||||
|
{{ TEXT.common.fields.remark }}
|
||||||
|
</div>
|
||||||
|
<el-form-item prop="remark" :required="requiresRemark(form.status)">
|
||||||
|
<el-input
|
||||||
|
v-model="form.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="TEXT.common.placeholders.input + TEXT.common.fields.remark"
|
||||||
|
:disabled="isFormReadOnly"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 附件分组 -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-attachment"></span>
|
||||||
|
{{ TEXT.common.labels.attachments }}
|
||||||
|
</div>
|
||||||
|
<AttachmentList
|
||||||
|
ref="attachmentPanelRef"
|
||||||
|
:study-id="study.currentStudy?.id || ''"
|
||||||
|
entity-type="drug_shipment"
|
||||||
|
:entity-id="editingId"
|
||||||
|
:mode="'upload'"
|
||||||
|
:readonly="isFormReadOnly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button @click="drawerVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">{{ TEXT.common.actions.save }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||||
import { Location, Menu, CircleCheck, Plus } from "@element-plus/icons-vue";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
|
import {
|
||||||
|
createDrugShipment,
|
||||||
|
deleteDrugShipment,
|
||||||
|
listDrugShipments,
|
||||||
|
updateDrugShipment,
|
||||||
|
} from "../../api/drugShipments";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
|
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||||
|
import { isSystemAdmin } from "../../utils/roles";
|
||||||
|
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||||
|
|
||||||
|
type ShipmentDirection = "SEND" | "RETURN";
|
||||||
|
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
|
||||||
|
|
||||||
|
interface ShipmentRow {
|
||||||
|
id: string;
|
||||||
|
center_id: string;
|
||||||
|
site_name: string;
|
||||||
|
direction: ShipmentDirection;
|
||||||
|
ship_date: string;
|
||||||
|
receive_date: string;
|
||||||
|
quantity: number | null;
|
||||||
|
batch_no: string;
|
||||||
|
carrier: string;
|
||||||
|
tracking_no: string;
|
||||||
|
status: ShipmentStatus;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormModel = Omit<ShipmentRow, "id" | "site_name">;
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const items = ref<any[]>([]);
|
const saving = ref(false);
|
||||||
|
const items = ref<ShipmentRow[]>([]);
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
const drawerVisible = ref(false);
|
||||||
|
const editingId = ref("");
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
center_id: study.currentSite?.id || "",
|
center_id: study.currentSite?.id || "",
|
||||||
direction: "",
|
direction: "" as "" | ShipmentDirection,
|
||||||
status: "",
|
status: "" as "" | ShipmentStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultForm: FormModel = {
|
||||||
|
center_id: "",
|
||||||
|
direction: "SEND",
|
||||||
|
ship_date: "",
|
||||||
|
receive_date: "",
|
||||||
|
quantity: null,
|
||||||
|
batch_no: "",
|
||||||
|
carrier: "",
|
||||||
|
tracking_no: "",
|
||||||
|
status: "PENDING",
|
||||||
|
remark: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = reactive<FormModel>({ ...defaultForm });
|
||||||
|
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||||
|
form,
|
||||||
|
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||||
|
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||||
|
const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:create"]));
|
||||||
|
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:update"]));
|
||||||
|
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:delete"]));
|
||||||
|
const siteActiveMap = computed(() =>
|
||||||
|
sites.value.reduce((acc: Record<string, boolean>, site: any) => {
|
||||||
|
if (site?.id) acc[site.id] = !!site.is_active;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
const selectedSiteInactive = computed(() => !!editingId.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
|
||||||
|
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value) || selectedSiteInactive.value);
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||||
|
);
|
||||||
|
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
|
||||||
|
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
|
||||||
|
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
|
||||||
|
|
||||||
|
const validateShipmentDetail = (_rule: unknown, value: string | number | null, callback: (error?: Error) => void) => {
|
||||||
|
if (requiresShipmentDetails(form.status) && (value === null || value === undefined || value === "")) {
|
||||||
|
callback(new Error(TEXT.common.messages.required));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateShipmentTextDetail = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (requiresShipmentDetails(form.status) && !value?.trim()) {
|
||||||
|
callback(new Error(TEXT.common.messages.required));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateReceiveDate = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (requiresReceiveDate(form.status) && !value) {
|
||||||
|
callback(new Error(TEXT.common.messages.required));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateRemark = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (requiresRemark(form.status) && !value?.trim()) {
|
||||||
|
callback(new Error(TEXT.common.messages.required));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules: FormRules<FormModel> = {
|
||||||
|
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
||||||
|
direction: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
||||||
|
ship_date: [{ validator: validateShipmentDetail, trigger: "change" }],
|
||||||
|
receive_date: [{ validator: validateReceiveDate, trigger: "change" }],
|
||||||
|
quantity: [{ validator: validateShipmentDetail, trigger: "change" }],
|
||||||
|
batch_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
|
||||||
|
carrier: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
|
||||||
|
tracking_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
|
||||||
|
status: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
||||||
|
remark: [{ validator: validateRemark, trigger: "blur" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeShipment = (item: any): ShipmentRow => ({
|
||||||
|
id: item.id,
|
||||||
|
center_id: item.center_id || "",
|
||||||
|
site_name: item.site_name || "",
|
||||||
|
direction: item.direction || "SEND",
|
||||||
|
ship_date: item.ship_date || "",
|
||||||
|
receive_date: item.receive_date || "",
|
||||||
|
quantity: item.quantity ?? null,
|
||||||
|
batch_no: item.batch_no || "",
|
||||||
|
carrier: item.carrier || "",
|
||||||
|
tracking_no: item.tracking_no || "",
|
||||||
|
status: item.status || "PENDING",
|
||||||
|
remark: item.remark || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadSites = async () => {
|
const loadSites = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) {
|
||||||
|
sites.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||||
siteActiveMap.value = sites.value.reduce((acc: Record<string, boolean>, site: any) => {
|
|
||||||
acc[site.id] = !!site.is_active;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
} catch {
|
} catch {
|
||||||
sites.value = [];
|
sites.value = [];
|
||||||
siteActiveMap.value = {};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) {
|
||||||
|
items.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await listDrugShipments(studyId, {});
|
const { data } = await listDrugShipments(studyId, {
|
||||||
items.value = Array.isArray(data) ? data : data?.items || [];
|
center_id: filters.center_id || undefined,
|
||||||
|
direction: filters.direction || undefined,
|
||||||
|
status: filters.status || undefined,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
const list = Array.isArray(data) ? data : data?.items || [];
|
||||||
|
items.value = list.map(normalizeShipment);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
items.value = [];
|
||||||
|
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, defaultForm);
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
if (!canCreate.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editingId.value = "";
|
||||||
|
resetForm();
|
||||||
|
if (study.currentSite?.id && siteActiveMap.value[study.currentSite.id] !== false) {
|
||||||
|
form.center_id = study.currentSite.id;
|
||||||
|
}
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
drawerVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (row: ShipmentRow) => {
|
||||||
|
if (!canUpdate.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editingId.value = row.id;
|
||||||
|
Object.assign(form, {
|
||||||
|
center_id: row.center_id,
|
||||||
|
direction: row.direction,
|
||||||
|
ship_date: row.ship_date,
|
||||||
|
receive_date: row.receive_date,
|
||||||
|
quantity: row.quantity,
|
||||||
|
batch_no: row.batch_no,
|
||||||
|
carrier: row.carrier,
|
||||||
|
tracking_no: row.tracking_no,
|
||||||
|
status: row.status,
|
||||||
|
remark: row.remark,
|
||||||
|
});
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
drawerVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
const studyId = study.currentStudy?.id;
|
||||||
|
if (!studyId) return;
|
||||||
|
if (isFormReadOnly.value) {
|
||||||
|
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false);
|
||||||
|
if (!valid) return;
|
||||||
|
const payload = {
|
||||||
|
center_id: form.center_id,
|
||||||
|
direction: form.direction,
|
||||||
|
ship_date: form.ship_date || null,
|
||||||
|
receive_date: form.receive_date || null,
|
||||||
|
quantity: form.quantity,
|
||||||
|
batch_no: form.batch_no.trim() || null,
|
||||||
|
carrier: form.carrier.trim() || null,
|
||||||
|
tracking_no: form.tracking_no.trim() || null,
|
||||||
|
status: form.status,
|
||||||
|
remark: form.remark.trim() || null,
|
||||||
|
};
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
let shipmentId = editingId.value;
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateDrugShipment(studyId, editingId.value, payload);
|
||||||
|
} else {
|
||||||
|
const { data } = (await createDrugShipment(studyId, payload)) as any;
|
||||||
|
shipmentId = data?.id || "";
|
||||||
|
if (shipmentId) editingId.value = shipmentId;
|
||||||
|
}
|
||||||
|
await attachmentPanelRef.value?.uploadPending(shipmentId);
|
||||||
|
await load();
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
drawerVisible.value = false;
|
||||||
|
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
filters.center_id = "";
|
filters.center_id = "";
|
||||||
filters.direction = "";
|
filters.direction = "";
|
||||||
filters.status = "";
|
filters.status = "";
|
||||||
|
study.setCurrentSite(null);
|
||||||
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const goNew = () => router.push("/drug/shipments/new");
|
const handleSearch = () => {
|
||||||
const goDetail = (id: string) => router.push(`/drug/shipments/${id}`);
|
load();
|
||||||
const onRowClick = (row: any) => {
|
|
||||||
if (!row?.id) return;
|
|
||||||
goDetail(row.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||||
const shipmentRowClass = ({ row }: { row: any }) =>
|
const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
|
||||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
||||||
const filteredItems = computed(() =>
|
const onRowClick = (row: ShipmentRow) => {
|
||||||
items.value.filter((item) => {
|
if (!row?.id) return;
|
||||||
if (filters.center_id && item?.center_id !== filters.center_id) return false;
|
router.push(`/drug/shipments/${row.id}`);
|
||||||
if (filters.direction && item?.direction !== filters.direction) return false;
|
};
|
||||||
if (filters.status && item?.status !== filters.status) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const sortedItems = computed(() =>
|
|
||||||
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusType = (status: string) => {
|
const statusType = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -200,8 +575,6 @@ const statusType = (status: string) => {
|
|||||||
return "primary";
|
return "primary";
|
||||||
case "SIGNED":
|
case "SIGNED":
|
||||||
return "success";
|
return "success";
|
||||||
case "RETURNED":
|
|
||||||
return "warning";
|
|
||||||
case "EXCEPTION":
|
case "EXCEPTION":
|
||||||
return "danger";
|
return "danger";
|
||||||
default:
|
default:
|
||||||
@@ -209,40 +582,62 @@ const statusType = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (row: any) => {
|
const remove = async (row: ShipmentRow) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
|
if (!canDelete.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isInactiveSite(row?.center_id)) {
|
if (isInactiveSite(row?.center_id)) {
|
||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips).catch(() => null);
|
const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips, { type: "warning" }).catch(() => null);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await deleteDrugShipment(studyId, row.id);
|
await deleteDrugShipment(studyId, row.id);
|
||||||
|
await load();
|
||||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||||
load();
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => filters.center_id, (val: string) => {
|
watch(
|
||||||
if (val) {
|
() => filters.center_id,
|
||||||
const matched = sites.value.find(s => s.id === val);
|
(val: string) => {
|
||||||
if (matched) study.setCurrentSite(matched);
|
if (val) {
|
||||||
} else {
|
const matched = sites.value.find((site) => site.id === val);
|
||||||
study.setCurrentSite(null);
|
if (matched) study.setCurrentSite(matched);
|
||||||
|
} else {
|
||||||
|
study.setCurrentSite(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
watch(() => study.currentSite, (newSite: any) => {
|
watch(
|
||||||
filters.center_id = newSite?.id || "";
|
() => study.currentSite,
|
||||||
});
|
(newSite: any) => {
|
||||||
|
filters.center_id = newSite?.id || "";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => study.currentStudy?.id,
|
||||||
|
async () => {
|
||||||
|
filters.center_id = study.currentSite?.id || "";
|
||||||
|
filters.direction = "";
|
||||||
|
filters.status = "";
|
||||||
|
drawerVisible.value = false;
|
||||||
|
await loadSites();
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSites();
|
await loadSites();
|
||||||
load();
|
await load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -252,46 +647,176 @@ onMounted(async () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-form {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
.filter-item {
|
gap: 12px;
|
||||||
margin-bottom: 0 !important;
|
|
||||||
margin-right: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-actions {
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
margin-right: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-actions :deep(.el-form-item__content) {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-actions :deep(.el-button) {
|
|
||||||
height: 32px;
|
|
||||||
line-height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-select {
|
.filter-select {
|
||||||
width: 140px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-spacer {
|
/* ========== 抽屉头部 ========== */
|
||||||
flex: 1;
|
:deep(.shipment-editor-drawer > .el-drawer__header) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 22px 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.shipment-editor-drawer > .el-drawer__body) {
|
||||||
|
padding: 0 20px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单整体 ========== */
|
||||||
|
.shipment-form {
|
||||||
|
padding: 0 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 分组卡片 ========== */
|
||||||
|
.form-group {
|
||||||
|
border: 1px solid #e8eef6;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:hover {
|
||||||
|
border-color: #d0dced;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group + .form-group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: #1a3560;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 基本信息 - 蓝色 */
|
||||||
|
.group-dot-basic {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 运输记录 - 琥珀色 */
|
||||||
|
.group-dot-record {
|
||||||
|
background: #f0ad2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 备注 - 绿色 */
|
||||||
|
.group-dot-remark {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 附件 - 紫色 */
|
||||||
|
.group-dot-attachment {
|
||||||
|
background: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff7d6;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #92400e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单元素细节 ========== */
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 底部按钮 ========== */
|
||||||
|
.drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 表单元素微调 ========== */
|
||||||
|
.shipment-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-form :deep(.el-form-item__label) {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a6283;
|
||||||
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shipment-table :deep(.el-table__inner-wrapper::before) {
|
.shipment-table :deep(.el-table__inner-wrapper::before) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== 表格居中 ========== */
|
||||||
|
.shipment-table :deep(th.el-table__cell .cell),
|
||||||
|
.shipment-table :deep(td.el-table__cell .cell) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-actions :deep(.el-button) {
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-actions :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.type-tag {
|
.type-tag {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -311,9 +836,6 @@ onMounted(async () => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: var(--ctms-text-placeholder);
|
|
||||||
}
|
|
||||||
.table-empty {
|
.table-empty {
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("MaterialEquipment project permissions", () => {
|
||||||
|
it("projects material equipment create update and delete permissions into all write actions", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("useAuthStore");
|
||||||
|
expect(source).toContain("isSystemAdmin");
|
||||||
|
expect(source).toContain("isApiPermissionAllowed");
|
||||||
|
expect(source).toContain('["material_equipments:create"]');
|
||||||
|
expect(source).toContain('["material_equipments:update"]');
|
||||||
|
expect(source).toContain('["material_equipments:delete"]');
|
||||||
|
expect(source).toContain('v-if="canCreate"');
|
||||||
|
expect(source).toContain('v-if="canUpdate"');
|
||||||
|
expect(source).toContain('v-if="canDelete"');
|
||||||
|
expect(source).toContain("if (!canDelete.value)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the visible table columns evenly distributed", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('class="ctms-table equipment-table"');
|
||||||
|
expect(source).toContain('style="width: 100%"');
|
||||||
|
expect(source).toContain('table-layout="fixed"');
|
||||||
|
expect(source).toContain('label="操作"');
|
||||||
|
expect(source).not.toMatch(/<el-table-column[^>]+label="操作"[^>]+(?:width|min-width)=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the detail page from row clicks instead of a detail action button", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('@row-click="onRowClick"');
|
||||||
|
expect(source).toContain(':row-class-name="equipmentRowClass"');
|
||||||
|
expect(source).toContain('name: "MaterialEquipmentDetail"');
|
||||||
|
expect(source).toContain("equipmentId: row.id");
|
||||||
|
expect(source).not.toContain(">详情</el-button>");
|
||||||
|
expect(source).toContain('@click.stop="openEdit(row)"');
|
||||||
|
expect(source).toContain('@click.stop="removeRow(row)"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads qualification files as equipment attachments after saving the drawer form", () => {
|
||||||
|
const source = readSource();
|
||||||
|
const calibrationIndex = source.indexOf("校准设置");
|
||||||
|
const qualificationIndex = source.indexOf("附件");
|
||||||
|
|
||||||
|
expect(calibrationIndex).toBeGreaterThan(-1);
|
||||||
|
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
||||||
|
expect(source).toContain("AttachmentList");
|
||||||
|
expect(source).toContain('ref="attachmentPanelRef"');
|
||||||
|
expect(source).toContain('entity-type="material_equipment"');
|
||||||
|
expect(source).toContain(':entity-id="editingId"');
|
||||||
|
expect(source).toContain(':mode="\'upload\'"');
|
||||||
|
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||||
|
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)");
|
||||||
|
expect(source).toContain("uploadPending");
|
||||||
|
expect(source).toContain("material_equipment");
|
||||||
|
expect(source).not.toContain("uploadAttachment");
|
||||||
|
expect(source).not.toContain("pendingFiles");
|
||||||
|
expect(source).not.toContain("qualificationRows");
|
||||||
|
expect(source).not.toContain("material_equipment_production_permit");
|
||||||
|
expect(source).not.toContain("material_equipment_tech_index");
|
||||||
|
expect(source).not.toContain("资质文件");
|
||||||
|
expect(source).not.toContain("生产许可证");
|
||||||
|
expect(source).not.toContain("技术指标");
|
||||||
|
expect(source).not.toContain("handleUploadChange");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,10 +12,18 @@
|
|||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-button type="primary" @click="openCreate">新建</el-button>
|
<el-button v-if="canCreate" type="primary" @click="openCreate">新建</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="rows" class="ctms-table equipment-table" style="width: 100%" table-layout="fixed">
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="rows"
|
||||||
|
class="ctms-table equipment-table"
|
||||||
|
style="width: 100%"
|
||||||
|
table-layout="fixed"
|
||||||
|
:row-class-name="equipmentRowClass"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
<el-table-column prop="name" label="设备名称" show-overflow-tooltip />
|
<el-table-column prop="name" label="设备名称" show-overflow-tooltip />
|
||||||
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip />
|
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip />
|
||||||
<el-table-column prop="unit" label="单位" show-overflow-tooltip />
|
<el-table-column prop="unit" label="单位" show-overflow-tooltip />
|
||||||
@@ -25,10 +33,10 @@
|
|||||||
{{ row.needCalibration ? "是" : "否" }}
|
{{ row.needCalibration ? "是" : "否" }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120">
|
<el-table-column label="操作">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
<el-button v-if="canUpdate" link type="primary" @click.stop="openEdit(row)">编辑</el-button>
|
||||||
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
|
<el-button v-if="canDelete" link type="danger" @click.stop="removeRow(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
@@ -44,14 +52,14 @@
|
|||||||
v-model="drawerVisible"
|
v-model="drawerVisible"
|
||||||
direction="rtl"
|
direction="rtl"
|
||||||
size="620px"
|
size="620px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="true"
|
||||||
|
:before-close="drawerDirtyGuard.beforeClose"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
class="equipment-editor-drawer"
|
class="equipment-editor-drawer"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<div class="editor-title">{{ editingId ? "编辑设备" : "新建设备" }}</div>
|
<div class="editor-title">{{ editingId ? "编辑设备" : "新建设备" }}</div>
|
||||||
<div class="editor-subtitle">{{ editingId ? "修改设备基本信息与校准配置" : "添加新的设备记录到设备台账" }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -93,49 +101,6 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 资质文件分组 -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="form-group-title">
|
|
||||||
<span class="group-dot group-dot-file"></span>
|
|
||||||
资质文件
|
|
||||||
</div>
|
|
||||||
<el-row :gutter="16">
|
|
||||||
<el-col :span="12">
|
|
||||||
<div class="upload-card">
|
|
||||||
<div class="upload-card-label">生产许可证</div>
|
|
||||||
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onProductionPermitChange">
|
|
||||||
<div v-if="!form.productionPermitFileName" class="upload-trigger">
|
|
||||||
<span class="upload-icon">📄</span>
|
|
||||||
<span class="upload-text">点击上传文件</span>
|
|
||||||
</div>
|
|
||||||
</el-upload>
|
|
||||||
<div v-if="form.productionPermitFileName" class="upload-result">
|
|
||||||
<span class="upload-result-icon">✅</span>
|
|
||||||
<span class="upload-result-name">{{ form.productionPermitFileName }}</span>
|
|
||||||
<el-button link type="danger" size="small" @click="form.productionPermitFileName = ''">移除</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
|
||||||
<div class="upload-card">
|
|
||||||
<div class="upload-card-label">技术指标</div>
|
|
||||||
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onTechIndexChange">
|
|
||||||
<div v-if="!form.techIndexFileName" class="upload-trigger">
|
|
||||||
<span class="upload-icon">📄</span>
|
|
||||||
<span class="upload-text">点击上传文件</span>
|
|
||||||
</div>
|
|
||||||
</el-upload>
|
|
||||||
<div v-if="form.techIndexFileName" class="upload-result">
|
|
||||||
<span class="upload-result-icon">✅</span>
|
|
||||||
<span class="upload-result-name">{{ form.techIndexFileName }}</span>
|
|
||||||
<el-button link type="danger" size="small" @click="form.techIndexFileName = ''">移除</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 校准设置分组 -->
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="form-group-title">
|
<div class="form-group-title">
|
||||||
<span class="group-dot group-dot-calibration"></span>
|
<span class="group-dot group-dot-calibration"></span>
|
||||||
@@ -161,12 +126,27 @@
|
|||||||
<span>该设备无需定期校准</span>
|
<span>该设备无需定期校准</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-file"></span>
|
||||||
|
附件
|
||||||
|
</div>
|
||||||
|
<AttachmentList
|
||||||
|
ref="attachmentPanelRef"
|
||||||
|
:study-id="study.currentStudy?.id || ''"
|
||||||
|
entity-type="material_equipment"
|
||||||
|
:entity-id="editingId"
|
||||||
|
:mode="'upload'"
|
||||||
|
:readonly="isFormReadOnly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="drawer-footer">
|
<div class="drawer-footer">
|
||||||
<el-button @click="drawerVisible = false">取消</el-button>
|
<el-button @click="drawerVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="saveForm">保存</el-button>
|
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">保存</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
@@ -174,16 +154,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, watch } from "vue";
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from "element-plus";
|
import { useRouter } from "vue-router";
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||||
import {
|
import {
|
||||||
createMaterialEquipment,
|
createMaterialEquipment,
|
||||||
deleteMaterialEquipment,
|
deleteMaterialEquipment,
|
||||||
listMaterialEquipments,
|
listMaterialEquipments,
|
||||||
updateMaterialEquipment,
|
updateMaterialEquipment,
|
||||||
} from "../../api/materialEquipments";
|
} from "../../api/materialEquipments";
|
||||||
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
|
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||||
|
import { isSystemAdmin } from "../../utils/roles";
|
||||||
|
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
interface EquipmentRow {
|
interface EquipmentRow {
|
||||||
@@ -193,8 +179,6 @@ interface EquipmentRow {
|
|||||||
unit: string;
|
unit: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
productionPermitFileName: string;
|
|
||||||
techIndexFileName: string;
|
|
||||||
needCalibration: boolean;
|
needCalibration: boolean;
|
||||||
calibrationCycleDays: number | null;
|
calibrationCycleDays: number | null;
|
||||||
}
|
}
|
||||||
@@ -202,12 +186,16 @@ interface EquipmentRow {
|
|||||||
type FormModel = Omit<EquipmentRow, "id">;
|
type FormModel = Omit<EquipmentRow, "id">;
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
const filters = reactive({ name: "" });
|
const filters = reactive({ name: "" });
|
||||||
const rows = ref<EquipmentRow[]>([]);
|
const rows = ref<EquipmentRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
const drawerVisible = ref(false);
|
const drawerVisible = ref(false);
|
||||||
const editingId = ref("");
|
const editingId = ref("");
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
|
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||||
|
|
||||||
const defaultForm: FormModel = {
|
const defaultForm: FormModel = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -215,13 +203,21 @@ const defaultForm: FormModel = {
|
|||||||
unit: "",
|
unit: "",
|
||||||
brand: "",
|
brand: "",
|
||||||
origin: "",
|
origin: "",
|
||||||
productionPermitFileName: "",
|
|
||||||
techIndexFileName: "",
|
|
||||||
needCalibration: true,
|
needCalibration: true,
|
||||||
calibrationCycleDays: 30,
|
calibrationCycleDays: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = reactive<FormModel>({ ...defaultForm });
|
const form = reactive<FormModel>({ ...defaultForm });
|
||||||
|
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||||
|
form,
|
||||||
|
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
|
||||||
|
}));
|
||||||
|
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||||
|
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||||
|
const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:create"]));
|
||||||
|
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
|
||||||
|
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"]));
|
||||||
|
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value));
|
||||||
|
|
||||||
const rules: FormRules<FormModel> = {
|
const rules: FormRules<FormModel> = {
|
||||||
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
|
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
|
||||||
@@ -262,8 +258,6 @@ const loadRows = async () => {
|
|||||||
unit: item.unit || "",
|
unit: item.unit || "",
|
||||||
brand: item.brand || "",
|
brand: item.brand || "",
|
||||||
origin: item.origin || "",
|
origin: item.origin || "",
|
||||||
productionPermitFileName: item.production_permit_file_name || "",
|
|
||||||
techIndexFileName: item.tech_index_file_name || "",
|
|
||||||
needCalibration: !!item.need_calibration,
|
needCalibration: !!item.need_calibration,
|
||||||
calibrationCycleDays: item.calibration_cycle_days ?? null,
|
calibrationCycleDays: item.calibration_cycle_days ?? null,
|
||||||
}));
|
}));
|
||||||
@@ -281,12 +275,21 @@ const resetForm = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
|
if (!canCreate.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
editingId.value = "";
|
editingId.value = "";
|
||||||
resetForm();
|
resetForm();
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
drawerVisible.value = true;
|
drawerVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (row: EquipmentRow) => {
|
const openEdit = (row: EquipmentRow) => {
|
||||||
|
if (!canUpdate.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
editingId.value = row.id;
|
editingId.value = row.id;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -294,18 +297,28 @@ const openEdit = (row: EquipmentRow) => {
|
|||||||
unit: row.unit,
|
unit: row.unit,
|
||||||
brand: row.brand,
|
brand: row.brand,
|
||||||
origin: row.origin,
|
origin: row.origin,
|
||||||
productionPermitFileName: row.productionPermitFileName,
|
|
||||||
techIndexFileName: row.techIndexFileName,
|
|
||||||
needCalibration: row.needCalibration,
|
needCalibration: row.needCalibration,
|
||||||
calibrationCycleDays: row.calibrationCycleDays,
|
calibrationCycleDays: row.calibrationCycleDays,
|
||||||
});
|
});
|
||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
drawerVisible.value = true;
|
drawerVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const equipmentRowClass = ({ row }: { row: EquipmentRow }) => (row?.id ? "clickable-row" : "");
|
||||||
|
|
||||||
|
const onRowClick = (row: EquipmentRow) => {
|
||||||
|
if (!row?.id) return;
|
||||||
|
router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: row.id } });
|
||||||
|
};
|
||||||
|
|
||||||
const saveForm = async () => {
|
const saveForm = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
|
if (isFormReadOnly.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ok = await formRef.value?.validate().catch(() => false);
|
const ok = await formRef.value?.validate().catch(() => false);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -314,28 +327,37 @@ const saveForm = async () => {
|
|||||||
unit: form.unit.trim() || null,
|
unit: form.unit.trim() || null,
|
||||||
brand: form.brand.trim(),
|
brand: form.brand.trim(),
|
||||||
origin: form.origin.trim() || null,
|
origin: form.origin.trim() || null,
|
||||||
production_permit_file_name: form.productionPermitFileName || null,
|
|
||||||
tech_index_file_name: form.techIndexFileName || null,
|
|
||||||
need_calibration: !!form.needCalibration,
|
need_calibration: !!form.needCalibration,
|
||||||
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
|
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
|
||||||
};
|
};
|
||||||
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
|
let equipmentId = editingId.value;
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await updateMaterialEquipment(studyId, editingId.value, payload);
|
await updateMaterialEquipment(studyId, editingId.value, payload);
|
||||||
} else {
|
} else {
|
||||||
await createMaterialEquipment(studyId, payload);
|
const { data } = (await createMaterialEquipment(studyId, payload)) as any;
|
||||||
|
equipmentId = data?.id || "";
|
||||||
}
|
}
|
||||||
|
await attachmentPanelRef.value?.uploadPending(equipmentId);
|
||||||
await loadRows();
|
await loadRows();
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
drawerVisible.value = false;
|
drawerVisible.value = false;
|
||||||
ElMessage.success("保存成功");
|
ElMessage.success("保存成功");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.saveFailed);
|
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeRow = async (row: EquipmentRow) => {
|
const removeRow = async (row: EquipmentRow) => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
|
if (!canDelete.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ok = await ElMessageBox.confirm("确认删除该条设备记录吗?", "提示", { type: "warning" }).catch(() => null);
|
const ok = await ElMessageBox.confirm("确认删除该条设备记录吗?", "提示", { type: "warning" }).catch(() => null);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
@@ -347,18 +369,6 @@ const removeRow = async (row: EquipmentRow) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadChange = (file: UploadFile, field: "productionPermitFileName" | "techIndexFileName") => {
|
|
||||||
form[field] = file.name;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProductionPermitChange = (file: UploadFile) => {
|
|
||||||
handleUploadChange(file, "productionPermitFileName");
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTechIndexChange = (file: UploadFile) => {
|
|
||||||
handleUploadChange(file, "techIndexFileName");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
loadRows();
|
loadRows();
|
||||||
};
|
};
|
||||||
@@ -406,10 +416,18 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 抽屉头部 ========== */
|
/* ========== 抽屉头部 ========== */
|
||||||
|
:deep(.equipment-editor-drawer > .el-drawer__header) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 22px 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.equipment-editor-drawer > .el-drawer__body) {
|
||||||
|
padding: 0 20px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-header {
|
.editor-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-title {
|
.editor-title {
|
||||||
@@ -419,15 +437,9 @@ watch(
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-subtitle {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ctms-text-secondary);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 表单整体 ========== */
|
/* ========== 表单整体 ========== */
|
||||||
.equipment-form {
|
.equipment-form {
|
||||||
padding: 4px 4px 0 0;
|
padding: 0 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 分组卡片 ========== */
|
/* ========== 分组卡片 ========== */
|
||||||
@@ -469,7 +481,7 @@ watch(
|
|||||||
background: #3b82f6;
|
background: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 资质文件 - 琥珀色 */
|
/* 附件 - 琥珀色 */
|
||||||
.group-dot-file {
|
.group-dot-file {
|
||||||
background: #f0ad2c;
|
background: #f0ad2c;
|
||||||
}
|
}
|
||||||
@@ -479,74 +491,6 @@ watch(
|
|||||||
background: #22c55e;
|
background: #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 上传卡片 ========== */
|
|
||||||
.upload-card {
|
|
||||||
border: 1px dashed #d0dced;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: #ffffff;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
min-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-card:hover {
|
|
||||||
border-color: var(--ctms-primary);
|
|
||||||
background: #f8faff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-card-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #4a6283;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-icon {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ctms-text-secondary);
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-trigger:hover .upload-text {
|
|
||||||
color: var(--ctms-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-result {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-result-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-result-name {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ctms-text-regular);
|
|
||||||
font-weight: 500;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 校准提示 ========== */
|
/* ========== 校准提示 ========== */
|
||||||
.calibration-hint {
|
.calibration-hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./StartupMeetingAuth.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("StartupMeetingAuth permissions", () => {
|
||||||
|
it("does not load project members unless the role can read project members", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('can("project.members.list")');
|
||||||
|
expect(source).toContain("if (canReadMembers.value)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create kickoff records from row click unless startup auth create permission is granted", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('can("startup.auth.create")');
|
||||||
|
expect(source).toContain("canCreateAuth");
|
||||||
|
expect(source).toContain("if (!canCreateAuth.value)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,17 +45,22 @@ import { fetchSites } from "../../api/sites";
|
|||||||
import { listMembers } from "../../api/members";
|
import { listMembers } from "../../api/members";
|
||||||
import { fetchUsers } from "../../api/users";
|
import { fetchUsers } from "../../api/users";
|
||||||
import { displayDate } from "../../utils/display";
|
import { displayDate } from "../../utils/display";
|
||||||
|
import { isSystemAdmin } from "../../utils/roles";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
import { usePermission } from "../../utils/permission";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const { can } = usePermission();
|
||||||
const kickoffItems = ref<any[]>([]);
|
const kickoffItems = ref<any[]>([]);
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
const users = ref<any[]>([]);
|
const users = ref<any[]>([]);
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const isAdmin = computed(() => auth.user?.is_admin);
|
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||||
|
const canReadMembers = computed(() => can("project.members.list"));
|
||||||
|
const canCreateAuth = computed(() => can("startup.auth.create"));
|
||||||
|
|
||||||
const memberNameMap = computed(() => {
|
const memberNameMap = computed(() => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
@@ -85,7 +90,7 @@ const contactLabel = (row: any) => {
|
|||||||
.split(",")
|
.split(",")
|
||||||
.map((c) => c.trim())
|
.map((c) => c.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((c) => memberNameMap.value[c] || TEXT.common.fallback)
|
.map((c) => memberNameMap.value[c] || c)
|
||||||
.join("、") || TEXT.common.fallback;
|
.join("、") || TEXT.common.fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,15 +123,19 @@ const loadKickoffs = async () => {
|
|||||||
const requests = [
|
const requests = [
|
||||||
fetchSites(studyId, { limit: 500 }),
|
fetchSites(studyId, { limit: 500 }),
|
||||||
listKickoffs(studyId),
|
listKickoffs(studyId),
|
||||||
listMembers(studyId, { limit: 500 }),
|
|
||||||
];
|
];
|
||||||
|
if (canReadMembers.value) {
|
||||||
|
requests.push(listMembers(studyId, { limit: 500 }));
|
||||||
|
}
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
requests.push(fetchUsers({ limit: 500 }));
|
requests.push(fetchUsers({ limit: 500 }));
|
||||||
}
|
}
|
||||||
const [sitesResp, kickoffResp, membersResp, usersResp] = await Promise.all(requests) as any[];
|
const [sitesResp, kickoffResp, ...optionalResponses] = await Promise.all(requests) as any[];
|
||||||
sites.value = Array.isArray(sitesResp.data) ? sitesResp.data : sitesResp.data.items || [];
|
sites.value = Array.isArray(sitesResp.data) ? sitesResp.data : sitesResp.data.items || [];
|
||||||
kickoffItems.value = Array.isArray(kickoffResp.data) ? kickoffResp.data : kickoffResp.data.items || [];
|
kickoffItems.value = Array.isArray(kickoffResp.data) ? kickoffResp.data : kickoffResp.data.items || [];
|
||||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
const membersResp = canReadMembers.value ? optionalResponses.shift() : null;
|
||||||
|
members.value = membersResp?.data ? (Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || []) : [];
|
||||||
|
const usersResp = isAdmin.value ? optionalResponses.shift() : null;
|
||||||
users.value = usersResp?.data?.items || [];
|
users.value = usersResp?.data?.items || [];
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
@@ -147,6 +156,10 @@ const onKickoffRowClick = async (row: any) => {
|
|||||||
goKickoffDetail(row.meeting_id);
|
goKickoffDetail(row.meeting_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canCreateAuth.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any;
|
const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any;
|
||||||
goKickoffDetail(data.id);
|
goKickoffDetail(data.id);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentDetail.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("MaterialEquipmentDetail edit drawer", () => {
|
||||||
|
it("shows an edit action and opens the equipment editor drawer on the detail page", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("<MaterialEquipmentEditorDrawer");
|
||||||
|
expect(source).toContain('v-if="canUpdateEquipment"');
|
||||||
|
expect(source).toContain("editorVisible.value = true");
|
||||||
|
expect(source).toContain("const handleEditorSaved = () =>");
|
||||||
|
expect(source).toContain('"material_equipments:update"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places attachments below calibration settings and renders them as one attachment table", () => {
|
||||||
|
const source = readSource();
|
||||||
|
const calibrationIndex = source.indexOf("校准设置");
|
||||||
|
const qualificationIndex = source.indexOf("TEXT.common.labels.attachments");
|
||||||
|
|
||||||
|
expect(calibrationIndex).toBeGreaterThan(-1);
|
||||||
|
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
||||||
|
expect(source).toContain("<AttachmentList");
|
||||||
|
expect(source).toContain('entity-type="material_equipment"');
|
||||||
|
expect(source).toContain(':hide-uploader="true"');
|
||||||
|
expect(source).toContain(':refresh-key="attachmentRefreshKey"');
|
||||||
|
expect(source).toContain("attachmentRefreshKey.value += 1");
|
||||||
|
expect(source).not.toContain("fetchAttachments");
|
||||||
|
expect(source).not.toContain("deleteAttachment");
|
||||||
|
expect(source).not.toContain("formatFileSize");
|
||||||
|
expect(source).not.toContain("qualificationAttachments");
|
||||||
|
expect(source).not.toContain('class="attachment-table qualification-table"');
|
||||||
|
expect(source).not.toContain("previewVisible");
|
||||||
|
expect(source).not.toContain("getDownloadUrl");
|
||||||
|
expect(source).not.toContain("removeAttachment");
|
||||||
|
expect(source).toContain("material_equipment");
|
||||||
|
expect(source).not.toContain("material_equipment_production_permit");
|
||||||
|
expect(source).not.toContain("material_equipment_tech_index");
|
||||||
|
expect(source).not.toContain("资质文件");
|
||||||
|
expect(source).not.toContain("生产许可证");
|
||||||
|
expect(source).not.toContain("技术指标");
|
||||||
|
expect(source).not.toContain('label="生产许可证">{{ displayValue(detail.production_permit_file_name) }}');
|
||||||
|
expect(source).not.toContain('label="技术指标">{{ displayValue(detail.tech_index_file_name) }}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div v-if="study.currentStudy" class="unified-shell">
|
||||||
|
<section v-loading="loading" class="unified-section">
|
||||||
|
<div class="unified-section-header">
|
||||||
|
<div>
|
||||||
|
<div class="ctms-section-title">{{ TEXT.modules.materialEquipment.detailTitle }}</div>
|
||||||
|
<div class="detail-subtitle">{{ TEXT.modules.materialEquipment.detailSubtitle }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<el-button v-if="canUpdateEquipment" type="primary" @click="goEdit" class="header-action-btn">
|
||||||
|
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||||
|
{{ TEXT.common.actions.edit }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title-row">
|
||||||
|
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||||
|
<el-tag :type="detail.need_calibration ? 'warning' : 'info'" effect="plain" round>
|
||||||
|
{{ detail.need_calibration ? "需要校准" : "无需校准" }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="设备名称">{{ displayValue(detail.name) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="规格型号">{{ displayValue(detail.spec_model) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="单位">{{ displayValue(detail.unit) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="品牌">{{ displayValue(detail.brand) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="产地">{{ displayValue(detail.origin) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="unified-section">
|
||||||
|
<div class="ctms-section-title section-title-inline">校准设置</div>
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="是否需要校准">
|
||||||
|
{{ detail.need_calibration ? TEXT.common.labels.yes : TEXT.common.labels.no }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="校准周期(天)">
|
||||||
|
{{ detail.need_calibration ? displayValue(detail.calibration_cycle_days) : TEXT.common.fallback }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="unified-section">
|
||||||
|
<div class="ctms-section-title section-title-inline">{{ TEXT.common.labels.attachments }}</div>
|
||||||
|
<AttachmentList
|
||||||
|
v-if="equipmentId && studyId"
|
||||||
|
:study-id="studyId"
|
||||||
|
entity-type="material_equipment"
|
||||||
|
:entity-id="equipmentId"
|
||||||
|
:hide-uploader="true"
|
||||||
|
:refresh-key="attachmentRefreshKey"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<MaterialEquipmentEditorDrawer
|
||||||
|
v-model="editorVisible"
|
||||||
|
:equipment-id="equipmentId || ''"
|
||||||
|
:readonly="!canUpdateEquipment"
|
||||||
|
@saved="handleEditorSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { Edit } from "@element-plus/icons-vue";
|
||||||
|
import { getMaterialEquipment } from "../../api/materialEquipments";
|
||||||
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
|
import MaterialEquipmentEditorDrawer from "./MaterialEquipmentEditorDrawer.vue";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
import { useStudyStore } from "../../store/study";
|
||||||
|
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||||
|
import { isSystemAdmin } from "../../utils/roles";
|
||||||
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const study = useStudyStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
const editorVisible = ref(false);
|
||||||
|
const attachmentRefreshKey = ref(0);
|
||||||
|
const equipmentId = computed(() => route.params.equipmentId as string | undefined);
|
||||||
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
|
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||||
|
const canUpdateEquipment = computed(() => isSystemAdmin(auth.user) || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
|
||||||
|
|
||||||
|
const detail = reactive({
|
||||||
|
name: "",
|
||||||
|
spec_model: "",
|
||||||
|
unit: "",
|
||||||
|
brand: "",
|
||||||
|
origin: "",
|
||||||
|
need_calibration: false,
|
||||||
|
calibration_cycle_days: null as number | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayValue = (value?: string | number | null) => {
|
||||||
|
if (value === null || value === undefined || value === "") return TEXT.common.fallback;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goEdit = () => {
|
||||||
|
if (!canUpdateEquipment.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editorVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorSaved = () => {
|
||||||
|
load();
|
||||||
|
attachmentRefreshKey.value += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!studyId.value || !equipmentId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await getMaterialEquipment(studyId.value, equipmentId.value);
|
||||||
|
Object.assign(detail, {
|
||||||
|
name: data.name || "",
|
||||||
|
spec_model: data.spec_model || "",
|
||||||
|
unit: data.unit || "",
|
||||||
|
brand: data.brand || "",
|
||||||
|
origin: data.origin || "",
|
||||||
|
need_calibration: !!data.need_calibration,
|
||||||
|
calibration_cycle_days: data.calibration_cycle_days ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
study.setViewContext({
|
||||||
|
pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [studyId.value, equipmentId.value],
|
||||||
|
() => {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-action-btn {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-inline {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentEditorDrawer.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("MaterialEquipmentEditorDrawer attachments", () => {
|
||||||
|
it("queues qualification files and uploads them as business attachments on save", () => {
|
||||||
|
const source = readSource();
|
||||||
|
const calibrationIndex = source.indexOf("校准设置");
|
||||||
|
const qualificationIndex = source.indexOf("附件");
|
||||||
|
|
||||||
|
expect(calibrationIndex).toBeGreaterThan(-1);
|
||||||
|
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
||||||
|
expect(source).toContain("AttachmentList");
|
||||||
|
expect(source).toContain('ref="attachmentPanelRef"');
|
||||||
|
expect(source).toContain('entity-type="material_equipment"');
|
||||||
|
expect(source).toContain(':mode="\'upload\'"');
|
||||||
|
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||||
|
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.equipmentId)");
|
||||||
|
expect(source).toContain("uploadPending");
|
||||||
|
expect(source).toContain("material_equipment");
|
||||||
|
expect(source).not.toContain("uploadAttachment");
|
||||||
|
expect(source).not.toContain("pendingFiles");
|
||||||
|
expect(source).not.toContain("qualificationRows");
|
||||||
|
expect(source).not.toContain("material_equipment_production_permit");
|
||||||
|
expect(source).not.toContain("material_equipment_tech_index");
|
||||||
|
expect(source).not.toContain("资质文件");
|
||||||
|
expect(source).not.toContain("生产许可证");
|
||||||
|
expect(source).not.toContain("技术指标");
|
||||||
|
expect(source).not.toContain("handleUploadChange");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
v-if="modelValue"
|
||||||
|
:model-value="modelValue"
|
||||||
|
direction="rtl"
|
||||||
|
size="620px"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
:before-close="drawerDirtyGuard.beforeClose"
|
||||||
|
:show-close="false"
|
||||||
|
class="equipment-editor-drawer"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-title">编辑设备</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="equipment-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-basic"></span>
|
||||||
|
基本信息
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="设备名称" prop="name">
|
||||||
|
<el-input v-model="form.name" :disabled="readonly" placeholder="请输入设备名称" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="规格型号" prop="specModel">
|
||||||
|
<el-input v-model="form.specModel" :disabled="readonly" placeholder="请输入规格型号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="单位" prop="unit">
|
||||||
|
<el-input v-model="form.unit" :disabled="readonly" placeholder="请输入" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="品牌" prop="brand">
|
||||||
|
<el-input v-model="form.brand" :disabled="readonly" placeholder="请输入品牌" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="产地" prop="origin">
|
||||||
|
<el-input v-model="form.origin" :disabled="readonly" placeholder="请输入产地" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-calibration"></span>
|
||||||
|
校准设置
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="是否需要校准" prop="needCalibration">
|
||||||
|
<el-select v-model="form.needCalibration" :disabled="readonly" placeholder="请选择" class="full-width">
|
||||||
|
<el-option label="是" :value="true" />
|
||||||
|
<el-option label="否" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col v-if="form.needCalibration" :span="12">
|
||||||
|
<el-form-item label="校准周期(天)" prop="calibrationCycleDays">
|
||||||
|
<el-input-number v-model="form.calibrationCycleDays" :disabled="readonly" :min="1" :precision="0" class="full-width" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<div v-if="!form.needCalibration" class="calibration-hint">该设备无需定期校准</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-file"></span>
|
||||||
|
附件
|
||||||
|
</div>
|
||||||
|
<AttachmentList
|
||||||
|
ref="attachmentPanelRef"
|
||||||
|
:study-id="studyId"
|
||||||
|
entity-type="material_equipment"
|
||||||
|
:entity-id="equipmentId"
|
||||||
|
:mode="'upload'"
|
||||||
|
:readonly="readonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="readonly" @click="saveForm">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||||
|
import { getMaterialEquipment, updateMaterialEquipment } from "../../api/materialEquipments";
|
||||||
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
|
import { useStudyStore } from "../../store/study";
|
||||||
|
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||||
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
|
type FormModel = {
|
||||||
|
name: string;
|
||||||
|
specModel: string;
|
||||||
|
unit: string;
|
||||||
|
brand: string;
|
||||||
|
origin: string;
|
||||||
|
needCalibration: boolean;
|
||||||
|
calibrationCycleDays: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; equipmentId: string; readonly?: boolean }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "update:modelValue", value: boolean): void;
|
||||||
|
(event: "saved"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const study = useStudyStore();
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const saving = ref(false);
|
||||||
|
const readonly = ref(false);
|
||||||
|
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||||
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
|
|
||||||
|
const form = reactive<FormModel>({
|
||||||
|
name: "",
|
||||||
|
specModel: "",
|
||||||
|
unit: "",
|
||||||
|
brand: "",
|
||||||
|
origin: "",
|
||||||
|
needCalibration: true,
|
||||||
|
calibrationCycleDays: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||||
|
form,
|
||||||
|
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rules: FormRules<FormModel> = {
|
||||||
|
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
|
||||||
|
specModel: [{ required: true, message: "请输入规格型号", trigger: "blur" }],
|
||||||
|
brand: [{ required: true, message: "请输入品牌", trigger: "blur" }],
|
||||||
|
needCalibration: [{ required: true, message: "请选择是否需要校准", trigger: "change" }],
|
||||||
|
calibrationCycleDays: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (form.needCalibration && (!value || value < 1)) {
|
||||||
|
callback(new Error("请填写校准周期"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: "change",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!studyId.value || !props.equipmentId) return;
|
||||||
|
readonly.value = !!props.readonly;
|
||||||
|
try {
|
||||||
|
const { data } = await getMaterialEquipment(studyId.value, props.equipmentId);
|
||||||
|
Object.assign(form, {
|
||||||
|
name: data.name || "",
|
||||||
|
specModel: data.spec_model || "",
|
||||||
|
unit: data.unit || "",
|
||||||
|
brand: data.brand || "",
|
||||||
|
origin: data.origin || "",
|
||||||
|
needCalibration: !!data.need_calibration,
|
||||||
|
calibrationCycleDays: data.calibration_cycle_days ?? null,
|
||||||
|
});
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
if (!studyId.value || !props.equipmentId) return;
|
||||||
|
if (readonly.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await formRef.value?.validate().catch(() => false);
|
||||||
|
if (!ok) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await updateMaterialEquipment(studyId.value, props.equipmentId, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
spec_model: form.specModel.trim(),
|
||||||
|
unit: form.unit.trim() || null,
|
||||||
|
brand: form.brand.trim(),
|
||||||
|
origin: form.origin.trim() || null,
|
||||||
|
need_calibration: !!form.needCalibration,
|
||||||
|
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
|
||||||
|
});
|
||||||
|
await attachmentPanelRef.value?.uploadPending(props.equipmentId);
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
emit("saved");
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.saveFailed);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.modelValue, props.equipmentId, props.readonly] as const,
|
||||||
|
([visible]) => {
|
||||||
|
readonly.value = !!props.readonly;
|
||||||
|
if (visible) load();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.needCalibration,
|
||||||
|
(need) => {
|
||||||
|
if (!need) form.calibrationCycleDays = null;
|
||||||
|
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.equipment-editor-drawer > .el-drawer__header) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 22px 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.equipment-editor-drawer > .el-drawer__body) {
|
||||||
|
padding: 0 20px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-form {
|
||||||
|
padding: 0 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
border: 1px solid #e8eef6;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:hover {
|
||||||
|
border-color: #d0dced;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group + .form-group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: #1a3560;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-basic {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-file {
|
||||||
|
background: #f0ad2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-calibration {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #166534;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-form :deep(.el-form-item__label) {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a6283;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,16 +6,13 @@
|
|||||||
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div>
|
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div>
|
||||||
<div class="ctms-section-actions">
|
<div class="ctms-section-actions">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!editMode"
|
v-if="canUpdateAuth"
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="loading || isReadOnly"
|
:disabled="loading || isReadOnly"
|
||||||
@click="startEdit"
|
@click="startEdit"
|
||||||
>
|
>
|
||||||
{{ TEXT.common.actions.edit }}
|
{{ TEXT.common.actions.edit }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
|
|
||||||
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
@@ -29,14 +26,7 @@
|
|||||||
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
|
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">
|
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">
|
||||||
<el-date-picker
|
{{ displayDate(detail.kickoff_date) }}
|
||||||
v-if="editMode"
|
|
||||||
v-model="draft.kickoff_date"
|
|
||||||
type="date"
|
|
||||||
value-format="YYYY-MM-DD"
|
|
||||||
:placeholder="TEXT.common.placeholders.select"
|
|
||||||
/>
|
|
||||||
<span v-else>{{ displayDate(detail.kickoff_date) }}</span>
|
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</section>
|
</section>
|
||||||
@@ -44,116 +34,96 @@
|
|||||||
<section class="unified-section training-section">
|
<section class="unified-section training-section">
|
||||||
<div class="training-header">
|
<div class="training-header">
|
||||||
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
|
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
|
||||||
|
<el-button v-if="canCreateAuth" type="primary" size="small" :disabled="isReadOnly" @click="startTrainingAdd">
|
||||||
|
{{ TEXT.common.actions.add }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="trainingRows" v-loading="loadingTraining" style="width: 100%" class="ctms-table" table-layout="fixed">
|
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%" class="ctms-table" table-layout="fixed">
|
||||||
<el-table-column prop="name" :label="TEXT.common.fields.name" show-overflow-tooltip>
|
<el-table-column prop="name" :label="TEXT.common.fields.name" show-overflow-tooltip>
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.name" size="small" />
|
{{ scope.row.name || TEXT.common.fallback }}
|
||||||
<span v-else>{{ scope.row.name || TEXT.common.fallback }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip>
|
<el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip>
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.role" size="small" />
|
{{ scope.row.role || TEXT.common.fallback }}
|
||||||
<span v-else>{{ scope.row.role || TEXT.common.fallback }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.fields.trained">
|
<el-table-column :label="TEXT.common.fields.trained">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-checkbox
|
<div class="status-with-date">
|
||||||
v-if="isTrainingEditing(scope.row)"
|
<span>{{ trainingStatusLabel(scope.row) }}</span>
|
||||||
v-model="trainingRowDraft.trained"
|
<span v-if="scope.row.trained && scope.row.trained_date" class="status-date-small">
|
||||||
:disabled="isReadOnly"
|
{{ displayDate(scope.row.trained_date) }}
|
||||||
@click.stop
|
</span>
|
||||||
/>
|
</div>
|
||||||
<el-checkbox
|
|
||||||
v-else
|
|
||||||
v-model="scope.row.trained"
|
|
||||||
:disabled="isReadOnly"
|
|
||||||
@change="toggleTraining(scope.row)"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.fields.authorized">
|
<el-table-column :label="TEXT.common.fields.authorized">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-checkbox
|
<div class="status-with-date">
|
||||||
v-if="isTrainingEditing(scope.row)"
|
<span>{{ authorizationStatusLabel(scope.row) }}</span>
|
||||||
v-model="trainingRowDraft.authorized"
|
<span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date-small">
|
||||||
:disabled="isReadOnly"
|
{{ displayDate(scope.row.authorized_date) }}
|
||||||
@click.stop
|
</span>
|
||||||
/>
|
</div>
|
||||||
<el-checkbox
|
|
||||||
v-else
|
|
||||||
v-model="scope.row.authorized"
|
|
||||||
:disabled="isReadOnly"
|
|
||||||
@change="toggleTraining(scope.row)"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="140">
|
<el-table-column v-if="canCreateAuth || canUpdateAuth || canDeleteAuth" :label="TEXT.common.labels.actions" width="140">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<template v-if="isTrainingEditing(scope.row)">
|
<el-button v-if="canUpdateAuth" link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
|
||||||
<el-button link type="primary" size="small" :loading="savingTraining" @click.stop="saveTraining(scope.row)">
|
{{ TEXT.common.actions.edit }}
|
||||||
{{ TEXT.common.actions.save }}
|
</el-button>
|
||||||
</el-button>
|
<el-button v-if="canDeleteAuth" link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
|
||||||
<el-button link size="small" :disabled="savingTraining" @click.stop="cancelTrainingEdit">
|
{{ TEXT.common.actions.delete }}
|
||||||
{{ TEXT.common.actions.cancel }}
|
</el-button>
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<el-button link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
|
|
||||||
{{ TEXT.common.actions.edit }}
|
|
||||||
</el-button>
|
|
||||||
<el-button link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
|
|
||||||
{{ TEXT.common.actions.delete }}
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div
|
|
||||||
class="training-add-row"
|
|
||||||
:class="{ disabled: newTrainingActive || savingTraining || isReadOnly }"
|
|
||||||
@click="startTrainingAdd"
|
|
||||||
>
|
|
||||||
<span class="training-add-icon">+</span>
|
|
||||||
</div>
|
|
||||||
<StateEmpty
|
|
||||||
v-if="!loadingTraining && trainingItems.length === 0 && !newTrainingActive"
|
|
||||||
:description="TEXT.modules.startupMeetingAuth.emptyTraining"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="meetingId" class="unified-section attachment-section">
|
<section v-if="meetingId" class="unified-section attachment-section">
|
||||||
<div class="attachment-grid">
|
<AttachmentList
|
||||||
<AttachmentList
|
:study-id="studyId"
|
||||||
v-for="group in kickoffAttachmentGroups"
|
entity-type="startup_kickoff"
|
||||||
:key="group.entityType"
|
:entity-id="meetingId"
|
||||||
:study-id="studyId"
|
:entity-groups="kickoffAttachmentGroups"
|
||||||
:entity-type="group.entityType"
|
:title="TEXT.common.labels.attachments"
|
||||||
:entity-id="meetingId"
|
:readonly="true"
|
||||||
:title="group.title"
|
:hide-uploader="true"
|
||||||
/>
|
:refresh-key="attachmentRefreshKey"
|
||||||
</div>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<KickoffEditorDrawer
|
||||||
|
v-model="editorVisible"
|
||||||
|
:meeting-id="meetingId"
|
||||||
|
:study-id="studyId"
|
||||||
|
:site-name="siteInfo.name"
|
||||||
|
:owner-label="ownerLabel"
|
||||||
|
:readonly="isReadOnly"
|
||||||
|
@saved="handleEditorSaved"
|
||||||
|
/>
|
||||||
|
<TrainingEditorDrawer
|
||||||
|
v-model="trainingEditorVisible"
|
||||||
|
:record-id="editingTrainingId"
|
||||||
|
:study-id="studyId"
|
||||||
|
:sites="sites"
|
||||||
|
:initial-site-name="siteInfo.name"
|
||||||
|
@saved="handleTrainingEditorSaved"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import {
|
import {
|
||||||
getKickoff,
|
getKickoff,
|
||||||
updateKickoff,
|
|
||||||
listTrainingAuthorizations,
|
listTrainingAuthorizations,
|
||||||
createTrainingAuthorization,
|
|
||||||
updateTrainingAuthorization,
|
|
||||||
deleteTrainingAuthorization,
|
deleteTrainingAuthorization,
|
||||||
} from "../../api/startup";
|
} from "../../api/startup";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
@@ -161,62 +131,43 @@ import { listMembers } from "../../api/members";
|
|||||||
import { fetchUsers } from "../../api/users";
|
import { fetchUsers } from "../../api/users";
|
||||||
import { displayDate } from "../../utils/display";
|
import { displayDate } from "../../utils/display";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import { TEXT } from "../../locales";
|
||||||
import { TEXT, requiredMessage } from "../../locales";
|
import { usePermission } from "../../utils/permission";
|
||||||
|
import KickoffEditorDrawer from "./KickoffEditorDrawer.vue";
|
||||||
|
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const { can } = usePermission();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loadingTraining = ref(false);
|
const loadingTraining = ref(false);
|
||||||
const saving = ref(false);
|
const editorVisible = ref(false);
|
||||||
const editMode = ref(false);
|
const trainingEditorVisible = ref(false);
|
||||||
|
const editingTrainingId = ref<string | undefined>();
|
||||||
|
const attachmentRefreshKey = ref(0);
|
||||||
const meetingId = route.params.meetingId as string;
|
const meetingId = route.params.meetingId as string;
|
||||||
const studyId = study.currentStudy?.id || "";
|
const studyId = study.currentStudy?.id || "";
|
||||||
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
|
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
|
||||||
|
const sites = ref<any[]>([]);
|
||||||
const users = ref<any[]>([]);
|
const users = ref<any[]>([]);
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
const isAdmin = computed(() => auth.user?.is_admin);
|
const isAdmin = computed(() => auth.user?.is_admin);
|
||||||
|
const canReadMembers = computed(() => can("project.members.list"));
|
||||||
|
const canCreateAuth = computed(() => can("startup.auth.create"));
|
||||||
|
const canUpdateAuth = computed(() => can("startup.auth.update"));
|
||||||
|
const canDeleteAuth = computed(() => can("startup.auth.delete"));
|
||||||
const kickoffAttachmentGroups = [
|
const kickoffAttachmentGroups = [
|
||||||
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
{ entityType: "startup_kickoff_minutes", label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
||||||
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
{ entityType: "startup_kickoff_signin", label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
||||||
{ entityType: "startup_kickoff_ppt", title: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
|
{ entityType: "startup_kickoff_ppt", label: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
|
||||||
{ entityType: "startup_kickoff", title: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
|
{ entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
|
||||||
];
|
];
|
||||||
|
|
||||||
const detail = reactive<any>({
|
const detail = reactive<any>({
|
||||||
kickoff_date: "",
|
kickoff_date: "",
|
||||||
attendees: [],
|
|
||||||
});
|
|
||||||
const draft = reactive({
|
|
||||||
kickoff_date: "",
|
|
||||||
});
|
});
|
||||||
const trainingItems = ref<any[]>([]);
|
const trainingItems = ref<any[]>([]);
|
||||||
const trainingEditingRowId = ref<string | null>(null);
|
|
||||||
const newTrainingActive = ref(false);
|
|
||||||
const savingTraining = ref(false);
|
|
||||||
const trainingRowDraft = reactive({
|
|
||||||
name: "",
|
|
||||||
role: "",
|
|
||||||
trained: false,
|
|
||||||
authorized: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const trainingRows = computed(() => {
|
|
||||||
if (!newTrainingActive.value) return trainingItems.value;
|
|
||||||
return [
|
|
||||||
...trainingItems.value,
|
|
||||||
{
|
|
||||||
id: "__new__",
|
|
||||||
__isNew: true,
|
|
||||||
name: "",
|
|
||||||
role: "",
|
|
||||||
trained: false,
|
|
||||||
authorized: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberNameMap = computed(() => {
|
const memberNameMap = computed(() => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
@@ -247,6 +198,8 @@ const statusLabel = computed(() =>
|
|||||||
);
|
);
|
||||||
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
|
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
|
||||||
const isReadOnly = computed(() => siteInfo.is_active === false);
|
const isReadOnly = computed(() => siteInfo.is_active === false);
|
||||||
|
const trainingStatusLabel = (row: any) => (row?.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
|
||||||
|
const authorizationStatusLabel = (row: any) => (row?.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!studyId || !meetingId) return;
|
if (!studyId || !meetingId) return;
|
||||||
@@ -254,17 +207,20 @@ const load = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await getKickoff(studyId, meetingId);
|
const { data } = await getKickoff(studyId, meetingId);
|
||||||
Object.assign(detail, data);
|
Object.assign(detail, data);
|
||||||
if (!editMode.value) {
|
|
||||||
draft.kickoff_date = data.kickoff_date || "";
|
|
||||||
}
|
|
||||||
if (data?.site_id) {
|
if (data?.site_id) {
|
||||||
const { data: sitesData } = await fetchSites(studyId, { limit: 500 });
|
const { data: sitesData } = await fetchSites(studyId, { limit: 500 });
|
||||||
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
|
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
|
||||||
|
sites.value = list;
|
||||||
const matched = list.find((site: any) => site.id === data.site_id);
|
const matched = list.find((site: any) => site.id === data.site_id);
|
||||||
Object.assign(siteInfo, matched || { name: "" });
|
Object.assign(siteInfo, matched || { name: "" });
|
||||||
} else {
|
} else {
|
||||||
Object.assign(siteInfo, { name: "" });
|
Object.assign(siteInfo, { name: "" });
|
||||||
}
|
}
|
||||||
|
const siteName = siteInfo.name || TEXT.common.fallback;
|
||||||
|
study.setViewContext({
|
||||||
|
siteName,
|
||||||
|
pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}` : TEXT.modules.startupMeetingAuth.kickoffDetailTitle,
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -273,10 +229,10 @@ const load = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadTraining = async () => {
|
const loadTraining = async () => {
|
||||||
if (!studyId) return;
|
if (!studyId || !siteInfo.name) return;
|
||||||
loadingTraining.value = true;
|
loadingTraining.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await listTrainingAuthorizations(studyId);
|
const { data } = await listTrainingAuthorizations(studyId, { site_name: siteInfo.name });
|
||||||
trainingItems.value = Array.isArray(data) ? data : data.items || [];
|
trainingItems.value = Array.isArray(data) ? data : data.items || [];
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
@@ -285,88 +241,24 @@ const loadTraining = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTraining = async (row: any) => {
|
const startTrainingEdit = (row: any) => {
|
||||||
if (isReadOnly.value) {
|
if (isReadOnly.value) return;
|
||||||
ElMessage.warning("中心已停用");
|
if (!canUpdateAuth.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!studyId) return;
|
editingTrainingId.value = row?.id || undefined;
|
||||||
try {
|
trainingEditorVisible.value = true;
|
||||||
await updateTrainingAuthorization(studyId, row.id, {
|
|
||||||
trained: row.trained,
|
|
||||||
authorized: row.authorized,
|
|
||||||
});
|
|
||||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
|
||||||
} catch (e: any) {
|
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
|
||||||
loadTraining();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetTrainingDraft = (row?: any) => {
|
|
||||||
trainingRowDraft.name = row?.name || "";
|
|
||||||
trainingRowDraft.role = row?.role || "";
|
|
||||||
trainingRowDraft.trained = !!row?.trained;
|
|
||||||
trainingRowDraft.authorized = !!row?.authorized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isTrainingEditing = (row: any) => {
|
|
||||||
if (row?.__isNew) return newTrainingActive.value;
|
|
||||||
return trainingEditingRowId.value === row?.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startTrainingEdit = (row: any) => {
|
|
||||||
if (savingTraining.value || isReadOnly.value) return;
|
|
||||||
newTrainingActive.value = false;
|
|
||||||
trainingEditingRowId.value = row?.id || null;
|
|
||||||
resetTrainingDraft(row);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTrainingAdd = () => {
|
const startTrainingAdd = () => {
|
||||||
if (newTrainingActive.value || savingTraining.value || isReadOnly.value) return;
|
if (isReadOnly.value) return;
|
||||||
trainingEditingRowId.value = null;
|
if (!canCreateAuth.value) {
|
||||||
newTrainingActive.value = true;
|
ElMessage.warning("权限不足");
|
||||||
resetTrainingDraft();
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelTrainingEdit = () => {
|
|
||||||
trainingEditingRowId.value = null;
|
|
||||||
newTrainingActive.value = false;
|
|
||||||
resetTrainingDraft();
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveTraining = async (row: any) => {
|
|
||||||
if (isReadOnly.value) {
|
|
||||||
ElMessage.warning("中心已停用");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!studyId) return;
|
editingTrainingId.value = undefined;
|
||||||
if (!trainingRowDraft.name.trim()) {
|
trainingEditorVisible.value = true;
|
||||||
ElMessage.error(requiredMessage(TEXT.common.fields.name));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
savingTraining.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
name: trainingRowDraft.name.trim(),
|
|
||||||
role: (trainingRowDraft.role || "").trim() || null,
|
|
||||||
trained: trainingRowDraft.trained,
|
|
||||||
authorized: trainingRowDraft.authorized,
|
|
||||||
};
|
|
||||||
if (row?.__isNew) {
|
|
||||||
await createTrainingAuthorization(studyId, payload);
|
|
||||||
ElMessage.success(TEXT.common.messages.createSuccess);
|
|
||||||
} else {
|
|
||||||
await updateTrainingAuthorization(studyId, row.id, payload);
|
|
||||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
|
||||||
}
|
|
||||||
cancelTrainingEdit();
|
|
||||||
loadTraining();
|
|
||||||
} catch (e: any) {
|
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
|
||||||
} finally {
|
|
||||||
savingTraining.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTraining = async (row: any) => {
|
const removeTraining = async (row: any) => {
|
||||||
@@ -374,6 +266,10 @@ const removeTraining = async (row: any) => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canDeleteAuth.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -391,43 +287,29 @@ const startEdit = () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editMode.value = true;
|
if (!canUpdateAuth.value) {
|
||||||
draft.kickoff_date = detail.kickoff_date || "";
|
ElMessage.warning("权限不足");
|
||||||
};
|
|
||||||
|
|
||||||
const cancelEdit = () => {
|
|
||||||
editMode.value = false;
|
|
||||||
draft.kickoff_date = detail.kickoff_date || "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEdit = async () => {
|
|
||||||
if (isReadOnly.value) {
|
|
||||||
ElMessage.warning("中心已停用");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!studyId || !meetingId) return;
|
editorVisible.value = true;
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
kickoff_date: draft.kickoff_date || null,
|
|
||||||
};
|
|
||||||
const { data } = await updateKickoff(studyId, meetingId, payload);
|
|
||||||
Object.assign(detail, data);
|
|
||||||
editMode.value = false;
|
|
||||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
|
||||||
} catch (e: any) {
|
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const goBack = () => router.push("/startup/meeting-auth");
|
const handleEditorSaved = () => {
|
||||||
|
load();
|
||||||
|
attachmentRefreshKey.value += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrainingEditorSaved = () => {
|
||||||
|
loadTraining();
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (studyId) {
|
if (studyId) {
|
||||||
const membersReq = listMembers(studyId, { limit: 500 });
|
|
||||||
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
|
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
|
||||||
|
let membersReq: Promise<any> = Promise.resolve(null);
|
||||||
|
if (canReadMembers.value) {
|
||||||
|
membersReq = listMembers(studyId, { limit: 500 });
|
||||||
|
}
|
||||||
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
|
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
|
||||||
if (membersResp?.data) {
|
if (membersResp?.data) {
|
||||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||||
@@ -437,7 +319,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await load();
|
await load();
|
||||||
loadTraining();
|
await loadTraining();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -461,6 +343,7 @@ onMounted(async () => {
|
|||||||
.training-header {
|
.training-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,34 +353,15 @@ onMounted(async () => {
|
|||||||
color: var(--ctms-text-main);
|
color: var(--ctms-text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.training-add-row {
|
.status-with-date {
|
||||||
border: 1px dashed var(--ctms-border-color);
|
display: inline-flex;
|
||||||
border-top: none;
|
|
||||||
height: 44px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--ctms-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.training-add-row:hover {
|
|
||||||
color: var(--ctms-text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.training-add-row.disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.training-add-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 2px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-date-small {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
v-if="modelValue"
|
||||||
|
:model-value="modelValue"
|
||||||
|
direction="rtl"
|
||||||
|
size="640px"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
:before-close="drawerDirtyGuard.beforeClose"
|
||||||
|
:show-close="false"
|
||||||
|
class="startup-editor-drawer"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-title">{{ TEXT.modules.startupMeetingAuth.kickoffEditTitle }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<StateError v-if="drawerErrorMessage" :description="drawerErrorMessage">
|
||||||
|
<template #action>
|
||||||
|
<el-button type="primary" @click="reloadEditingDetail">{{ TEXT.common.actions.retry }}</el-button>
|
||||||
|
</template>
|
||||||
|
</StateError>
|
||||||
|
|
||||||
|
<StateLoading v-else-if="drawerLoading" :rows="6" />
|
||||||
|
|
||||||
|
<el-form v-else ref="formRef" :model="form" label-position="top" class="startup-editor-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-basic"></span>
|
||||||
|
{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.common.fields.site">
|
||||||
|
<el-input :model-value="siteName || TEXT.common.fallback" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
|
||||||
|
<el-input :model-value="ownerLabel || TEXT.common.fallback" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item :label="TEXT.common.fields.kickoffDate">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.kickoff_date"
|
||||||
|
:disabled="isFormReadOnly"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
class="full-width"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<div v-if="readonly" class="inactive-hint">
|
||||||
|
<span class="hint-icon">!</span>
|
||||||
|
<span>中心已停用,当前记录不可编辑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="meetingId" class="form-group">
|
||||||
|
<div class="form-group-title">
|
||||||
|
<span class="group-dot group-dot-attachment"></span>
|
||||||
|
{{ TEXT.common.labels.attachments }}
|
||||||
|
</div>
|
||||||
|
<AttachmentList
|
||||||
|
ref="attachmentPanelRef"
|
||||||
|
:study-id="studyId"
|
||||||
|
entity-type="startup_kickoff"
|
||||||
|
:entity-id="meetingId"
|
||||||
|
:entity-groups="kickoffAttachmentGroups"
|
||||||
|
:mode="'upload'"
|
||||||
|
:readonly="isFormReadOnly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly || drawerLoading" @click="saveForm">
|
||||||
|
{{ TEXT.common.actions.save }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import { ElMessage, type FormInstance } from "element-plus";
|
||||||
|
import { getKickoff, updateKickoff } from "../../api/startup";
|
||||||
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
|
import StateError from "../../components/StateError.vue";
|
||||||
|
import StateLoading from "../../components/StateLoading.vue";
|
||||||
|
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||||
|
import { usePermission } from "../../utils/permission";
|
||||||
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
meetingId: string;
|
||||||
|
studyId: string;
|
||||||
|
siteName?: string;
|
||||||
|
ownerLabel?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: boolean];
|
||||||
|
saved: [meetingId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { can } = usePermission();
|
||||||
|
const canUpdateAuth = computed(() => can("startup.auth.update"));
|
||||||
|
const drawerLoading = ref(false);
|
||||||
|
const drawerErrorMessage = ref("");
|
||||||
|
const saving = ref(false);
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||||
|
const form = reactive({
|
||||||
|
kickoff_date: "",
|
||||||
|
});
|
||||||
|
const isFormReadOnly = computed(() => !canUpdateAuth.value || !!props.readonly);
|
||||||
|
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||||
|
form,
|
||||||
|
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const kickoffAttachmentGroups = [
|
||||||
|
{ label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel, entityType: "startup_kickoff_minutes" },
|
||||||
|
{ label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel, entityType: "startup_kickoff_signin" },
|
||||||
|
{ label: TEXT.modules.startupMeetingAuth.kickoffPptLabel, entityType: "startup_kickoff_ppt" },
|
||||||
|
{ label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel, entityType: "startup_kickoff" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.kickoff_date = "";
|
||||||
|
drawerErrorMessage.value = "";
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadEditingDetail = async () => {
|
||||||
|
if (!props.studyId || !props.meetingId) return;
|
||||||
|
drawerLoading.value = true;
|
||||||
|
drawerErrorMessage.value = "";
|
||||||
|
try {
|
||||||
|
const { data } = await getKickoff(props.studyId, props.meetingId);
|
||||||
|
form.kickoff_date = data.kickoff_date || "";
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
} catch (e: any) {
|
||||||
|
drawerErrorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
|
||||||
|
} finally {
|
||||||
|
drawerLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadEditingDetail = () => {
|
||||||
|
loadEditingDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
if (!props.studyId || !props.meetingId) return;
|
||||||
|
if (isFormReadOnly.value) {
|
||||||
|
ElMessage.warning(props.readonly ? "中心已停用" : "权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await updateKickoff(props.studyId, props.meetingId, {
|
||||||
|
kickoff_date: form.kickoff_date || null,
|
||||||
|
});
|
||||||
|
await attachmentPanelRef.value?.uploadPending(props.meetingId);
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
emit("saved", props.meetingId);
|
||||||
|
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async (visible) => {
|
||||||
|
if (!visible) return;
|
||||||
|
resetForm();
|
||||||
|
await loadEditingDetail();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.startup-editor-drawer > .el-drawer__header) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 22px 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.startup-editor-drawer > .el-drawer__body) {
|
||||||
|
padding: 0 20px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startup-editor-form {
|
||||||
|
padding: 0 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
border: 1px solid #e8eef6;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:hover {
|
||||||
|
border-color: #d0dced;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group + .form-group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: #1a3560;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-basic {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-attachment {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff7d6;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #92400e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readForm = () => readFileSync(resolve(__dirname, "./KickoffForm.vue"), "utf8");
|
||||||
|
const readDetail = () => readFileSync(resolve(__dirname, "./KickoffDetail.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("Kickoff member permissions", () => {
|
||||||
|
it("does not load project members in the form unless the role can read project members", () => {
|
||||||
|
const source = readForm();
|
||||||
|
|
||||||
|
expect(source).toContain('can("project.members.list")');
|
||||||
|
expect(source).toContain("if (canReadMembers.value)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not load project members in the detail page unless the role can read project members", () => {
|
||||||
|
const source = readDetail();
|
||||||
|
|
||||||
|
expect(source).toContain('can("project.members.list")');
|
||||||
|
expect(source).toContain("if (canReadMembers.value)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||||
|
|
||||||
|
describe("startup auth editor drawer attachments", () => {
|
||||||
|
it("uses upload cards for kickoff attachments instead of full attachment tables", () => {
|
||||||
|
const source = read("./KickoffEditorDrawer.vue");
|
||||||
|
|
||||||
|
expect(source).toContain("AttachmentList");
|
||||||
|
expect(source).toContain('ref="attachmentPanelRef"');
|
||||||
|
expect(source).toContain(':mode="\'upload\'"');
|
||||||
|
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
|
||||||
|
expect(source).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||||
|
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.meetingId)");
|
||||||
|
expect(source).toContain('entityType: "startup_kickoff_minutes"');
|
||||||
|
expect(source).toContain('entityType: "startup_kickoff_signin"');
|
||||||
|
expect(source).toContain('entityType: "startup_kickoff_ppt"');
|
||||||
|
expect(source).toContain('entityType: "startup_kickoff"');
|
||||||
|
expect(source).not.toContain("PendingAttachmentUpload");
|
||||||
|
expect(source).not.toContain("attendees");
|
||||||
|
expect(source).not.toContain("parseAttendees");
|
||||||
|
expect(source).not.toContain("attendeesPlaceholder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose attachments in the training authorization editor", () => {
|
||||||
|
const source = read("./TrainingEditorDrawer.vue");
|
||||||
|
|
||||||
|
expect(source).not.toContain("PendingAttachmentUpload");
|
||||||
|
expect(source).not.toContain("AttachmentList");
|
||||||
|
expect(source).not.toContain("training_authorization");
|
||||||
|
expect(source).not.toContain("attachments:");
|
||||||
|
expect(source).not.toContain("uploadPending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports batch creating and single-row updating training authorizations in the same drawer", () => {
|
||||||
|
const source = read("./TrainingEditorDrawer.vue");
|
||||||
|
|
||||||
|
expect(source).toContain("recordId?: string");
|
||||||
|
expect(source).toContain('editorTitle');
|
||||||
|
expect(source).toContain("createTrainingAuthorization");
|
||||||
|
expect(source).toContain("updateTrainingAuthorization");
|
||||||
|
expect(source).toContain("const isCreateMode = computed(() => !props.recordId)");
|
||||||
|
expect(source).toContain("const canCreateAuth = computed(() => can(\"startup.auth.create\"))");
|
||||||
|
expect(source).toContain("const canSaveAuth = computed(() => (isCreateMode.value ? canCreateAuth.value : canUpdateAuth.value))");
|
||||||
|
expect(source).toContain("batchRows");
|
||||||
|
expect(source).toContain("addBatchRow");
|
||||||
|
expect(source).toContain("removeBatchRow");
|
||||||
|
expect(source).toContain("for (const row of batchRows.value)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps training authorization editor compact and hides site input while preserving site context", () => {
|
||||||
|
const source = read("./TrainingEditorDrawer.vue");
|
||||||
|
|
||||||
|
expect(source).not.toContain(':label="TEXT.common.fields.site"');
|
||||||
|
expect(source).not.toContain('prop="site_name"');
|
||||||
|
expect(source).toContain("site_name: props.initialSiteName || form.site_name || null");
|
||||||
|
expect(source).toContain('class="compact-row-grid"');
|
||||||
|
expect(source).toContain('class="compact-field compact-field--name"');
|
||||||
|
expect(source).toContain('class="compact-field compact-field--status"');
|
||||||
|
expect(source).toContain('class="compact-field compact-field--remark"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a single-layer batch editor layout for training authorization creation", () => {
|
||||||
|
const source = read("./TrainingEditorDrawer.vue");
|
||||||
|
|
||||||
|
expect(source).toContain('class="form-section training-batch-editor"');
|
||||||
|
expect(source).toContain('class="batch-editor"');
|
||||||
|
expect(source).toContain('class="batch-entry"');
|
||||||
|
expect(source).toContain('class="status-inline"');
|
||||||
|
expect(source).toContain('class="status-date"');
|
||||||
|
expect(source).not.toContain('class="batch-row"');
|
||||||
|
expect(source).not.toContain(".batch-row {");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||||
|
|
||||||
|
describe("startup auth form/detail permissions", () => {
|
||||||
|
it("maps startup auth actions to backend operation permissions", () => {
|
||||||
|
const source = read("../../utils/permission.ts");
|
||||||
|
|
||||||
|
expect(source).toContain('"startup.auth.create": "startup_auth:create"');
|
||||||
|
expect(source).toContain('"startup.auth.update": "startup_auth:update"');
|
||||||
|
expect(source).toContain('"startup.auth.delete": "startup_auth:delete"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses create/update permissions in kickoff and training forms", () => {
|
||||||
|
const kickoffForm = read("./KickoffForm.vue");
|
||||||
|
const trainingForm = read("./TrainingForm.vue");
|
||||||
|
|
||||||
|
expect(kickoffForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
|
||||||
|
expect(kickoffForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
||||||
|
expect(kickoffForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
|
||||||
|
expect(kickoffForm).toContain("if (!canSaveAuth.value)");
|
||||||
|
expect(trainingForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
|
||||||
|
expect(trainingForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
||||||
|
expect(trainingForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
|
||||||
|
expect(trainingForm).toContain("if (!canSaveAuth.value)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens kickoff detail edits in a drawer and keeps training actions split by backend operations", () => {
|
||||||
|
const source = read("./KickoffDetail.vue");
|
||||||
|
|
||||||
|
expect(source).toContain("<KickoffEditorDrawer");
|
||||||
|
expect(source).toContain("<TrainingEditorDrawer");
|
||||||
|
expect(source).toContain("editorVisible.value = true");
|
||||||
|
expect(source).toContain("trainingEditorVisible.value = true");
|
||||||
|
expect(source).toContain("const handleEditorSaved = () =>");
|
||||||
|
expect(source).toContain(":refresh-key=\"attachmentRefreshKey\"");
|
||||||
|
expect(source).toContain("attachmentRefreshKey.value += 1");
|
||||||
|
expect(source).toContain("const handleTrainingEditorSaved = () =>");
|
||||||
|
expect(source).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
|
||||||
|
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
||||||
|
expect(source).toContain('const canDeleteAuth = computed(() => can("startup.auth.delete"))');
|
||||||
|
expect(source).toContain('v-if="canUpdateAuth"');
|
||||||
|
expect(source).toContain('v-if="canCreateAuth || canUpdateAuth || canDeleteAuth"');
|
||||||
|
expect(source).toContain("if (!canCreateAuth.value)");
|
||||||
|
expect(source).toContain("if (!canUpdateAuth.value)");
|
||||||
|
expect(source).toContain("if (!canDeleteAuth.value)");
|
||||||
|
expect(source).not.toContain("trainingRowDraft");
|
||||||
|
expect(source).not.toContain("isTrainingEditing");
|
||||||
|
expect(source).not.toContain("saveTraining");
|
||||||
|
expect(source).not.toContain("cancelTrainingEdit");
|
||||||
|
expect(source).not.toContain("editMode");
|
||||||
|
expect(source).not.toContain("saveEdit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens training detail edits in a drawer without navigating to the old edit page", () => {
|
||||||
|
const source = read("./TrainingDetail.vue");
|
||||||
|
|
||||||
|
expect(source).toContain("<TrainingEditorDrawer");
|
||||||
|
expect(source).toContain("editorVisible.value = true");
|
||||||
|
expect(source).toContain("const handleEditorSaved = () =>");
|
||||||
|
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
|
||||||
|
expect(source).toContain('v-if="canUpdateAuth"');
|
||||||
|
expect(source).toContain("if (!canUpdateAuth.value)");
|
||||||
|
expect(source).not.toContain("AttachmentList");
|
||||||
|
expect(source).not.toContain("training_authorization");
|
||||||
|
expect(source).not.toContain("attachmentRefreshKey");
|
||||||
|
expect(source).not.toContain("router.push(`/startup/training/${recordId}/edit`)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders kickoff attachments as one aggregated table with attachment type labels", () => {
|
||||||
|
const source = read("./KickoffDetail.vue");
|
||||||
|
|
||||||
|
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
|
||||||
|
expect(source).not.toContain("v-for=\"group in kickoffAttachmentGroups\"");
|
||||||
|
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel');
|
||||||
|
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel');
|
||||||
|
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffPptLabel');
|
||||||
|
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows legacy kickoff and training forms to queue attachments before saving", () => {
|
||||||
|
const kickoffForm = read("./KickoffForm.vue");
|
||||||
|
const trainingForm = read("./TrainingForm.vue");
|
||||||
|
|
||||||
|
expect(kickoffForm).toContain("<AttachmentList");
|
||||||
|
expect(kickoffForm).toContain('ref="attachmentPanelRef"');
|
||||||
|
expect(kickoffForm).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
|
||||||
|
expect(kickoffForm).toContain(':entity-id="meetingId || \'\'"');
|
||||||
|
expect(kickoffForm).toContain(':mode="\'upload\'"');
|
||||||
|
expect(kickoffForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
||||||
|
expect(kickoffForm).not.toContain('v-for="group in kickoffAttachmentGroups"');
|
||||||
|
expect(kickoffForm).not.toContain("StateEmpty");
|
||||||
|
|
||||||
|
expect(trainingForm).toContain("<AttachmentList");
|
||||||
|
expect(trainingForm).toContain('ref="attachmentPanelRef"');
|
||||||
|
expect(trainingForm).toContain('entity-type="training_authorization"');
|
||||||
|
expect(trainingForm).toContain(':entity-id="recordId || \'\'"');
|
||||||
|
expect(trainingForm).toContain(':mode="\'upload\'"');
|
||||||
|
expect(trainingForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
||||||
|
expect(trainingForm).not.toContain('v-if="isEdit && recordId"');
|
||||||
|
expect(trainingForm).not.toContain("StateEmpty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows training and authorization dates under positive status in the training table", () => {
|
||||||
|
const source = read("./KickoffDetail.vue");
|
||||||
|
|
||||||
|
expect(source).toContain("trainingStatusLabel(scope.row)");
|
||||||
|
expect(source).toContain("authorizationStatusLabel(scope.row)");
|
||||||
|
expect(source).toContain("scope.row.trained && scope.row.trained_date");
|
||||||
|
expect(source).toContain("scope.row.authorized && scope.row.authorized_date");
|
||||||
|
expect(source).toContain('class="status-date-small"');
|
||||||
|
expect(source).not.toContain("scope.row.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
|
||||||
|
expect(source).not.toContain("scope.row.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads training authorizations for the current kickoff site only", () => {
|
||||||
|
const source = read("./KickoffDetail.vue");
|
||||||
|
|
||||||
|
expect(source).toContain("if (!studyId || !siteInfo.name) return;");
|
||||||
|
expect(source).toContain("listTrainingAuthorizations(studyId, { site_name: siteInfo.name })");
|
||||||
|
expect(source).not.toContain("listTrainingAuthorizations(studyId);");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,8 +5,7 @@
|
|||||||
<div class="unified-section-header">
|
<div class="unified-section-header">
|
||||||
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
|
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
|
||||||
<div class="ctms-section-actions">
|
<div class="ctms-section-actions">
|
||||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button v-if="canUpdateAuth" type="primary" :disabled="isInactiveSite" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
@@ -20,34 +19,34 @@
|
|||||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</section>
|
</section>
|
||||||
<section class="unified-section">
|
|
||||||
<AttachmentList
|
|
||||||
v-if="recordId"
|
|
||||||
:study-id="studyId"
|
|
||||||
entity-type="training_authorization"
|
|
||||||
:entity-id="recordId"
|
|
||||||
:readonly="isReadOnly"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
<TrainingEditorDrawer
|
||||||
|
v-model="editorVisible"
|
||||||
|
:record-id="recordId"
|
||||||
|
:study-id="studyId"
|
||||||
|
:sites="sites"
|
||||||
|
@saved="handleEditorSaved"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { getTrainingAuthorization } from "../../api/startup";
|
import { getTrainingAuthorization } from "../../api/startup";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
import { usePermission } from "../../utils/permission";
|
||||||
|
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const { can } = usePermission();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const editorVisible = ref(false);
|
||||||
const recordId = route.params.recordId as string;
|
const recordId = route.params.recordId as string;
|
||||||
const studyId = study.currentStudy?.id || "";
|
const studyId = study.currentStudy?.id || "";
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
@@ -70,7 +69,9 @@ const siteActiveMap = computed(() => {
|
|||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
const canUpdateAuth = computed(() => can("startup.auth.update"));
|
||||||
|
const isInactiveSite = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||||
|
const isReadOnly = computed(() => !canUpdateAuth.value || isInactiveSite.value);
|
||||||
|
|
||||||
const loadSites = async () => {
|
const loadSites = async () => {
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
@@ -88,6 +89,10 @@ const load = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await getTrainingAuthorization(studyId, recordId);
|
const { data } = await getTrainingAuthorization(studyId, recordId);
|
||||||
Object.assign(detail, data);
|
Object.assign(detail, data);
|
||||||
|
study.setViewContext({
|
||||||
|
siteName: detail.site_name || TEXT.common.fallback,
|
||||||
|
pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle,
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,13 +101,20 @@ const load = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const goEdit = () => {
|
const goEdit = () => {
|
||||||
if (isReadOnly.value) {
|
if (!canUpdateAuth.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInactiveSite.value) {
|
||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/startup/training/${recordId}/edit`);
|
editorVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorSaved = () => {
|
||||||
|
load();
|
||||||
};
|
};
|
||||||
const goBack = () => router.push("/startup/meeting-auth");
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSites();
|
await loadSites();
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
v-if="modelValue"
|
||||||
|
:model-value="modelValue"
|
||||||
|
direction="rtl"
|
||||||
|
size="640px"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
:before-close="drawerDirtyGuard.beforeClose"
|
||||||
|
:show-close="false"
|
||||||
|
class="startup-editor-drawer"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-title">
|
||||||
|
{{ editorTitle }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<StateError v-if="drawerErrorMessage" :description="drawerErrorMessage">
|
||||||
|
<template #action>
|
||||||
|
<el-button type="primary" @click="reloadEditingDetail">{{ TEXT.common.actions.retry }}</el-button>
|
||||||
|
</template>
|
||||||
|
</StateError>
|
||||||
|
|
||||||
|
<StateLoading v-else-if="drawerLoading" :rows="6" />
|
||||||
|
|
||||||
|
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" class="startup-editor-form">
|
||||||
|
<div v-if="isCreateMode" class="form-section training-batch-editor">
|
||||||
|
<div class="section-title action-title">
|
||||||
|
<span>
|
||||||
|
<span class="group-dot group-dot-basic"></span>
|
||||||
|
{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}
|
||||||
|
</span>
|
||||||
|
<el-button type="primary" size="small" :disabled="isFormReadOnly" @click="addBatchRow">
|
||||||
|
{{ TEXT.common.actions.add }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="batch-editor">
|
||||||
|
<div v-for="(row, index) in batchRows" :key="row.tempId" class="batch-entry">
|
||||||
|
<div class="batch-entry-header">
|
||||||
|
<span>{{ index + 1 }}</span>
|
||||||
|
<el-button
|
||||||
|
v-if="batchRows.length > 1"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:disabled="isFormReadOnly"
|
||||||
|
@click="removeBatchRow(index)"
|
||||||
|
>
|
||||||
|
{{ TEXT.common.actions.remove }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="compact-row-grid">
|
||||||
|
<el-form-item class="compact-field compact-field--name" :label="TEXT.common.fields.name" :error="batchErrors[index]?.name">
|
||||||
|
<el-input v-model="row.name" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="compact-field" :label="TEXT.common.labels.role">
|
||||||
|
<el-input v-model="row.role" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.trained">
|
||||||
|
<div class="status-inline">
|
||||||
|
<el-switch v-model="row.trained" :disabled="isFormReadOnly" />
|
||||||
|
<el-date-picker
|
||||||
|
v-model="row.trained_date"
|
||||||
|
:disabled="isFormReadOnly || !row.trained"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
class="status-date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.authorized">
|
||||||
|
<div class="status-inline">
|
||||||
|
<el-switch v-model="row.authorized" :disabled="isFormReadOnly" />
|
||||||
|
<el-date-picker
|
||||||
|
v-model="row.authorized_date"
|
||||||
|
:disabled="isFormReadOnly || !row.authorized"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
class="status-date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="compact-field compact-field--remark" :label="TEXT.common.fields.remark">
|
||||||
|
<el-input v-model="row.remark" :disabled="isFormReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="form-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="group-dot group-dot-basic"></span>
|
||||||
|
{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}
|
||||||
|
</div>
|
||||||
|
<div class="compact-row-grid compact-row-grid--single">
|
||||||
|
<el-form-item class="compact-field compact-field--name" :label="TEXT.common.fields.name" prop="name">
|
||||||
|
<el-input v-model="form.name" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="compact-field" :label="TEXT.common.labels.role" prop="role">
|
||||||
|
<el-input v-model="form.role" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.trained">
|
||||||
|
<div class="status-inline">
|
||||||
|
<el-switch v-model="form.trained" :disabled="isFormReadOnly" />
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.trained_date"
|
||||||
|
:disabled="isFormReadOnly || !form.trained"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
class="status-date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.authorized">
|
||||||
|
<div class="status-inline">
|
||||||
|
<el-switch v-model="form.authorized" :disabled="isFormReadOnly" />
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.authorized_date"
|
||||||
|
:disabled="isFormReadOnly || !form.authorized"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="TEXT.common.placeholders.select"
|
||||||
|
class="status-date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="compact-field compact-field--remark" :label="TEXT.common.fields.remark" prop="remark">
|
||||||
|
<el-input v-model="form.remark" :disabled="isFormReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedSiteInactive" class="inactive-hint">
|
||||||
|
<span class="hint-icon">!</span>
|
||||||
|
<span>中心已停用,当前记录不可编辑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly || drawerLoading" @click="saveForm">
|
||||||
|
{{ TEXT.common.actions.save }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||||
|
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
|
||||||
|
import StateError from "../../components/StateError.vue";
|
||||||
|
import StateLoading from "../../components/StateLoading.vue";
|
||||||
|
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||||
|
import { usePermission } from "../../utils/permission";
|
||||||
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
|
type TrainingFormModel = {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
site_name: string;
|
||||||
|
trained: boolean;
|
||||||
|
authorized: boolean;
|
||||||
|
trained_date: string;
|
||||||
|
authorized_date: string;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchTrainingFormModel = TrainingFormModel & {
|
||||||
|
tempId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
recordId?: string;
|
||||||
|
studyId: string;
|
||||||
|
sites: any[];
|
||||||
|
initialSiteName?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: boolean];
|
||||||
|
saved: [recordId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { can } = usePermission();
|
||||||
|
const canCreateAuth = computed(() => can("startup.auth.create"));
|
||||||
|
const canUpdateAuth = computed(() => can("startup.auth.update"));
|
||||||
|
const isCreateMode = computed(() => !props.recordId);
|
||||||
|
const canSaveAuth = computed(() => (isCreateMode.value ? canCreateAuth.value : canUpdateAuth.value));
|
||||||
|
const drawerLoading = ref(false);
|
||||||
|
const drawerErrorMessage = ref("");
|
||||||
|
const saving = ref(false);
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
const form = reactive<TrainingFormModel>({
|
||||||
|
name: "",
|
||||||
|
role: "",
|
||||||
|
site_name: "",
|
||||||
|
trained: false,
|
||||||
|
authorized: false,
|
||||||
|
trained_date: "",
|
||||||
|
authorized_date: "",
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
const batchRows = ref<BatchTrainingFormModel[]>([]);
|
||||||
|
const batchErrors = ref<Record<string, string>[]>([]);
|
||||||
|
|
||||||
|
const siteActiveMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
props.sites.forEach((site) => {
|
||||||
|
if (site?.name) map[site.name] = !!site.is_active;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentSiteName = computed(() => props.initialSiteName || form.site_name || "");
|
||||||
|
const selectedSiteInactive = computed(() => !!currentSiteName.value && siteActiveMap.value[currentSiteName.value] === false);
|
||||||
|
const isFormReadOnly = computed(() => !canSaveAuth.value || selectedSiteInactive.value);
|
||||||
|
const editorTitle = computed(() => (isCreateMode.value ? TEXT.modules.startupMeetingAuth.trainingNewTitle : TEXT.modules.startupMeetingAuth.trainingEditTitle));
|
||||||
|
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||||
|
form,
|
||||||
|
batchRows: batchRows.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rules: FormRules<TrainingFormModel> = {
|
||||||
|
name: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEmptyBatchRow = (): BatchTrainingFormModel => ({
|
||||||
|
tempId: `${Date.now()}-${Math.random()}`,
|
||||||
|
name: "",
|
||||||
|
role: "",
|
||||||
|
site_name: props.initialSiteName || "",
|
||||||
|
trained: false,
|
||||||
|
authorized: false,
|
||||||
|
trained_date: "",
|
||||||
|
authorized_date: "",
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, {
|
||||||
|
name: "",
|
||||||
|
role: "",
|
||||||
|
site_name: props.initialSiteName || "",
|
||||||
|
trained: false,
|
||||||
|
authorized: false,
|
||||||
|
trained_date: "",
|
||||||
|
authorized_date: "",
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
batchRows.value = [createEmptyBatchRow()];
|
||||||
|
batchErrors.value = [];
|
||||||
|
drawerErrorMessage.value = "";
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadEditingDetail = async () => {
|
||||||
|
if (!props.studyId || !props.recordId) {
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawerLoading.value = true;
|
||||||
|
drawerErrorMessage.value = "";
|
||||||
|
try {
|
||||||
|
const { data } = await getTrainingAuthorization(props.studyId, props.recordId);
|
||||||
|
Object.assign(form, {
|
||||||
|
name: data.name || "",
|
||||||
|
role: data.role || "",
|
||||||
|
site_name: data.site_name || "",
|
||||||
|
trained: !!data.trained,
|
||||||
|
authorized: !!data.authorized,
|
||||||
|
trained_date: data.trained_date || "",
|
||||||
|
authorized_date: data.authorized_date || "",
|
||||||
|
remark: data.remark || "",
|
||||||
|
});
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
} catch (e: any) {
|
||||||
|
drawerErrorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
|
||||||
|
} finally {
|
||||||
|
drawerLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadEditingDetail = () => {
|
||||||
|
loadEditingDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addBatchRow = () => {
|
||||||
|
batchRows.value.push(createEmptyBatchRow());
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBatchRow = (index: number) => {
|
||||||
|
if (batchRows.value.length <= 1) return;
|
||||||
|
batchRows.value.splice(index, 1);
|
||||||
|
batchErrors.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePayload = (row: TrainingFormModel) => ({
|
||||||
|
name: row.name.trim(),
|
||||||
|
role: row.role || null,
|
||||||
|
site_name: props.initialSiteName || form.site_name || null,
|
||||||
|
trained: row.trained,
|
||||||
|
authorized: row.authorized,
|
||||||
|
trained_date: row.trained && row.trained_date ? row.trained_date : null,
|
||||||
|
authorized_date: row.authorized && row.authorized_date ? row.authorized_date : null,
|
||||||
|
remark: row.remark || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateBatchRows = () => {
|
||||||
|
const errors: Record<string, string>[] = [];
|
||||||
|
let ok = true;
|
||||||
|
batchRows.value.forEach((row, index) => {
|
||||||
|
const entry: Record<string, string> = {};
|
||||||
|
if (!row.name.trim()) {
|
||||||
|
entry.name = TEXT.common.messages.required;
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
errors[index] = entry;
|
||||||
|
});
|
||||||
|
batchErrors.value = errors;
|
||||||
|
return ok;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
if (!props.studyId) return;
|
||||||
|
if (isFormReadOnly.value) {
|
||||||
|
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formOk = await formRef.value?.validate?.().catch(() => false);
|
||||||
|
if (!formOk) return;
|
||||||
|
if (isCreateMode.value && !validateBatchRows()) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
let savedId = props.recordId || "";
|
||||||
|
if (props.recordId) {
|
||||||
|
await updateTrainingAuthorization(props.studyId, props.recordId, normalizePayload(form));
|
||||||
|
} else {
|
||||||
|
for (const row of batchRows.value) {
|
||||||
|
const { data } = await createTrainingAuthorization(props.studyId, normalizePayload(row));
|
||||||
|
savedId = data?.id || data?.data?.id || savedId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawerDirtyGuard.syncBaseline();
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
emit("saved", savedId);
|
||||||
|
ElMessage.success(isCreateMode.value ? TEXT.common.messages.createSuccess : TEXT.common.messages.saveSuccess);
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async (visible) => {
|
||||||
|
if (!visible) return;
|
||||||
|
resetForm();
|
||||||
|
await loadEditingDetail();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.startup-editor-drawer > .el-drawer__header) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 22px 24px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.startup-editor-drawer > .el-drawer__body) {
|
||||||
|
padding: 0 24px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startup-editor-form {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section + .form-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #1a3560;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-title {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-title > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dot-basic {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff7d6;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #92400e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-date {
|
||||||
|
width: min(156px, calc(100% - 52px));
|
||||||
|
flex: 1 1 128px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row-grid--single {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-field {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-field--name {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-field--status {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-field--remark {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
border-top: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-entry {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 18px 0 12px;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-entry-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.compact-field .el-form-item__label) {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #30415c;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.compact-field .el-input__wrapper),
|
||||||
|
:deep(.compact-field .el-textarea__inner) {
|
||||||
|
box-shadow: 0 0 0 1px #dce3ec inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.compact-field .el-input__wrapper:hover),
|
||||||
|
:deep(.compact-field .el-textarea__inner:hover) {
|
||||||
|
box-shadow: 0 0 0 1px #c8d3e1 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.status-date.is-disabled .el-input__wrapper) {
|
||||||
|
background: #f6f8fb;
|
||||||
|
box-shadow: 0 0 0 1px #e2e8f0 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user