d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
1458 lines
43 KiB
Vue
1458 lines
43 KiB
Vue
<template>
|
||
<div class="page" :class="{ 'shipment-page--desktop': isDesktop }" @click="closeShipmentContextMenu">
|
||
<div v-if="study.currentStudy" class="page-inner">
|
||
<!-- ==================== 表格卡片(含筛选栏) ==================== -->
|
||
<div class="table-card">
|
||
<div class="table-card-toolbar">
|
||
<div class="toolbar-filters">
|
||
<div class="filter-item">
|
||
<span class="filter-label">{{ TEXT.common.fields.site }}</span>
|
||
<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>
|
||
</div>
|
||
<div class="filter-item">
|
||
<span class="filter-label">{{ TEXT.common.fields.direction }}</span>
|
||
<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>
|
||
</div>
|
||
<div class="filter-item">
|
||
<span class="filter-label">{{ TEXT.common.fields.status }}</span>
|
||
<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>
|
||
</div>
|
||
<div class="filter-actions">
|
||
<el-button type="primary" size="small" @click="handleSearch" class="filter-btn">{{ TEXT.common.actions.search }}</el-button>
|
||
<el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<div v-if="items.length" class="filter-summary">
|
||
{{ items.length }} 条记录
|
||
</div>
|
||
<el-button v-if="canCreate" type="primary" @click="openCreate" class="create-btn">
|
||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||
{{ TEXT.modules.drugShipments.newTitle }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="shipment-workbench" :class="{ 'is-desktop': isDesktop }">
|
||
<div
|
||
ref="shipmentTablePaneRef"
|
||
class="shipment-table-pane"
|
||
:tabindex="isDesktop ? 0 : undefined"
|
||
@keydown.enter.prevent="openSelectedShipment"
|
||
>
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="sortedItems"
|
||
class="shipment-table"
|
||
style="width: 100%"
|
||
table-layout="fixed"
|
||
highlight-current-row
|
||
:row-class-name="shipmentRowClass"
|
||
@row-click="handleShipmentRowClick"
|
||
@row-dblclick="openShipmentDetail"
|
||
@row-contextmenu="openShipmentContextMenu"
|
||
>
|
||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
|
||
<template #default="{ row }">
|
||
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
|
||
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
|
||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
|
||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
|
||
<template #default="{ row }">
|
||
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||
<template #default="{ row }">
|
||
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
|
||
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.common.labels.actions" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="cell-actions">
|
||
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
|
||
<el-button
|
||
v-if="canDelete"
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
:disabled="isInactiveSite(row.center_id)"
|
||
@click.stop="remove(row)"
|
||
>
|
||
{{ TEXT.common.actions.delete }}
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<template #empty>
|
||
<div v-if="!loading" class="table-empty">
|
||
<div class="empty-icon">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
</div>
|
||
<span>{{ TEXT.modules.drugShipments.empty }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table>
|
||
</div>
|
||
|
||
<aside v-if="isDesktop" class="shipment-preview-pane">
|
||
<template v-if="selectedShipment">
|
||
<div class="preview-head">
|
||
<div>
|
||
<div class="preview-kicker">当前发运</div>
|
||
<div class="preview-title">{{ selectedShipment.site_name || TEXT.common.fallback }}</div>
|
||
</div>
|
||
<el-button size="small" type="primary" @click="openSelectedShipment">打开详情</el-button>
|
||
</div>
|
||
<dl class="preview-list">
|
||
<dt>{{ TEXT.common.fields.direction }}</dt>
|
||
<dd>{{ displayEnum(TEXT.enums.shipmentDirection, selectedShipment.direction) }}</dd>
|
||
<dt>{{ TEXT.common.fields.status }}</dt>
|
||
<dd>{{ displayEnum(TEXT.enums.shipmentStatus, selectedShipment.status) }}</dd>
|
||
<dt>{{ TEXT.common.fields.shipDate }}</dt>
|
||
<dd>{{ displayDate(selectedShipment.ship_date) }}</dd>
|
||
<dt>{{ TEXT.common.fields.receiveDate }}</dt>
|
||
<dd>{{ displayDate(selectedShipment.receive_date) }}</dd>
|
||
<dt>{{ TEXT.common.fields.quantity }}</dt>
|
||
<dd>{{ typeof selectedShipment.quantity === "number" ? selectedShipment.quantity : TEXT.common.fallback }}</dd>
|
||
<dt>{{ TEXT.common.fields.batchNo }}</dt>
|
||
<dd>{{ selectedShipment.batch_no || TEXT.common.fallback }}</dd>
|
||
<dt>{{ TEXT.common.fields.carrier }}</dt>
|
||
<dd>{{ selectedShipment.carrier || TEXT.common.fallback }}</dd>
|
||
<dt>{{ TEXT.common.fields.trackingNo }}</dt>
|
||
<dd>{{ selectedShipment.tracking_no || TEXT.common.fallback }}</dd>
|
||
<dt>{{ TEXT.common.fields.remark }}</dt>
|
||
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
|
||
</dl>
|
||
<div class="preview-actions">
|
||
<el-button
|
||
v-if="canUpdate"
|
||
size="small"
|
||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||
@click="openSelectedShipmentEditor"
|
||
>
|
||
{{ TEXT.common.actions.edit }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="canDelete"
|
||
size="small"
|
||
type="danger"
|
||
plain
|
||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||
@click="removeSelectedShipment"
|
||
>
|
||
{{ TEXT.common.actions.delete }}
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<div v-else class="preview-empty">
|
||
<span>选择一行查看发运摘要</span>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||
|
||
<div
|
||
v-if="isDesktop && shipmentContextMenu.visible && selectedShipment"
|
||
class="shipment-context-menu"
|
||
:style="{ left: `${shipmentContextMenu.x}px`, top: `${shipmentContextMenu.y}px` }"
|
||
@click.stop
|
||
>
|
||
<button type="button" @click="openSelectedShipment">打开详情</button>
|
||
<button
|
||
v-if="canUpdate"
|
||
type="button"
|
||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||
@click="openSelectedShipmentEditor"
|
||
>
|
||
{{ TEXT.common.actions.edit }}
|
||
</button>
|
||
<button
|
||
v-if="canDelete"
|
||
type="button"
|
||
class="danger"
|
||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||
@click="removeSelectedShipment"
|
||
>
|
||
{{ TEXT.common.actions.delete }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ==================== 编辑抽屉 ==================== -->
|
||
<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-section">
|
||
<div class="form-section-title">
|
||
<span class="dot dot--blue" />
|
||
{{ TEXT.common.labels.basicInfo }}
|
||
</div>
|
||
<div class="field-grid field-grid--2">
|
||
<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-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>
|
||
</div>
|
||
<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>
|
||
<div v-if="selectedSiteInactive" class="inactive-hint">
|
||
<span class="hint-icon">!</span>
|
||
<span>中心已停用,当前记录不可编辑</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section">
|
||
<div class="form-section-title">
|
||
<span class="dot dot--amber" />
|
||
{{ TEXT.modules.drugShipments.recordLabel }}
|
||
</div>
|
||
<div class="field-grid field-grid--2">
|
||
<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-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>
|
||
</div>
|
||
<div class="field-grid field-grid--2">
|
||
<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-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>
|
||
</div>
|
||
<div class="field-grid field-grid--2">
|
||
<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-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>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section">
|
||
<div class="form-section-title">
|
||
<span class="dot dot--green" />
|
||
{{ 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-section">
|
||
<div class="form-section-title">
|
||
<span class="dot dot--purple" />
|
||
{{ 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" class="footer-btn">{{ TEXT.common.actions.cancel }}</el-button>
|
||
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm" class="footer-btn footer-btn--primary">{{ TEXT.common.actions.save }}</el-button>
|
||
</div>
|
||
</template>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||
import { Plus } from "@element-plus/icons-vue";
|
||
import { useAuthStore } from "../../store/auth";
|
||
import { useStudyStore } from "../../store/study";
|
||
import {
|
||
createDrugShipment,
|
||
deleteDrugShipment,
|
||
listDrugShipments,
|
||
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 { displayDate, displayEnum } from "../../utils/display";
|
||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||
import { isSystemAdmin } from "../../utils/roles";
|
||
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||
import { isTauriRuntime } from "../../runtime";
|
||
|
||
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 study = useStudyStore();
|
||
const auth = useAuthStore();
|
||
const router = useRouter();
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
const items = ref<ShipmentRow[]>([]);
|
||
const sites = ref<any[]>([]);
|
||
const drawerVisible = ref(false);
|
||
const editingId = ref("");
|
||
const formRef = ref<FormInstance>();
|
||
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||
const shipmentTablePaneRef = ref<HTMLElement | null>(null);
|
||
const isDesktop = isTauriRuntime();
|
||
const selectedShipmentId = ref("");
|
||
const shipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
|
||
const filters = reactive({
|
||
center_id: study.currentSite?.id || "",
|
||
direction: "" as "" | ShipmentDirection,
|
||
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 selectedShipment = computed(() =>
|
||
sortedItems.value.find((item) => item.id === selectedShipmentId.value) || null
|
||
);
|
||
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 studyId = study.currentStudy?.id;
|
||
if (!studyId) {
|
||
sites.value = [];
|
||
return;
|
||
}
|
||
try {
|
||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||
} catch {
|
||
sites.value = [];
|
||
}
|
||
};
|
||
|
||
const load = async () => {
|
||
const studyId = study.currentStudy?.id;
|
||
if (!studyId) {
|
||
items.value = [];
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
try {
|
||
const { data } = await listDrugShipments(studyId, {
|
||
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) {
|
||
items.value = [];
|
||
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||
} finally {
|
||
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 = () => {
|
||
filters.center_id = "";
|
||
filters.direction = "";
|
||
filters.status = "";
|
||
study.setCurrentSite(null);
|
||
load();
|
||
};
|
||
|
||
const handleSearch = () => {
|
||
load();
|
||
};
|
||
|
||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||
const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
|
||
[
|
||
row?.id ? "clickable-row" : "",
|
||
isInactiveSite(row?.center_id) ? "row-inactive" : "",
|
||
isDesktop && row?.id === selectedShipmentId.value ? "row-selected" : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
const selectShipment = (row: ShipmentRow) => {
|
||
if (!row?.id) return;
|
||
selectedShipmentId.value = row.id;
|
||
shipmentTablePaneRef.value?.focus({ preventScroll: true });
|
||
};
|
||
|
||
const handleShipmentRowClick = (row: ShipmentRow) => {
|
||
if (!row?.id) return;
|
||
if (isDesktop) {
|
||
selectShipment(row);
|
||
return;
|
||
}
|
||
router.push(`/drug/shipments/${row.id}`);
|
||
};
|
||
|
||
const openShipmentDetail = (row?: ShipmentRow | null) => {
|
||
const target = row?.id ? row : selectedShipment.value;
|
||
if (target?.id) router.push(`/drug/shipments/${target.id}`);
|
||
};
|
||
|
||
const openSelectedShipment = () => {
|
||
closeShipmentContextMenu();
|
||
openShipmentDetail(selectedShipment.value);
|
||
};
|
||
|
||
const openSelectedShipmentEditor = () => {
|
||
const target = selectedShipment.value;
|
||
closeShipmentContextMenu();
|
||
if (target) openEdit(target);
|
||
};
|
||
|
||
const removeSelectedShipment = () => {
|
||
const target = selectedShipment.value;
|
||
closeShipmentContextMenu();
|
||
if (target) {
|
||
void remove(target);
|
||
}
|
||
};
|
||
|
||
const closeShipmentContextMenu = () => {
|
||
if (!shipmentContextMenu.value.visible) return;
|
||
shipmentContextMenu.value = { visible: false, x: 0, y: 0 };
|
||
};
|
||
|
||
const openShipmentContextMenu = (row: ShipmentRow, _column: unknown, event: MouseEvent) => {
|
||
if (!isDesktop || !row?.id) return;
|
||
event.preventDefault();
|
||
selectShipment(row);
|
||
shipmentContextMenu.value = {
|
||
visible: true,
|
||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 184)),
|
||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 132)),
|
||
};
|
||
};
|
||
|
||
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";
|
||
}
|
||
};
|
||
|
||
const remove = async (row: ShipmentRow) => {
|
||
const studyId = study.currentStudy?.id;
|
||
if (!studyId) return;
|
||
if (!canDelete.value) {
|
||
ElMessage.warning("权限不足");
|
||
return;
|
||
}
|
||
if (isInactiveSite(row?.center_id)) {
|
||
ElMessage.warning("中心已停用");
|
||
return;
|
||
}
|
||
const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips, { type: "warning" }).catch(() => null);
|
||
if (!ok) return;
|
||
try {
|
||
await deleteDrugShipment(studyId, row.id);
|
||
await load();
|
||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||
}
|
||
};
|
||
|
||
watch(
|
||
() => filters.center_id,
|
||
(val: string) => {
|
||
if (val) {
|
||
const matched = sites.value.find((site) => site.id === val);
|
||
if (matched) study.setCurrentSite(matched);
|
||
} else {
|
||
study.setCurrentSite(null);
|
||
}
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => 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();
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => sortedItems.value.map((item) => item.id).join("|"),
|
||
() => {
|
||
if (!isDesktop) return;
|
||
const rows = sortedItems.value;
|
||
if (!rows.length) {
|
||
selectedShipmentId.value = "";
|
||
return;
|
||
}
|
||
if (!rows.some((item) => item.id === selectedShipmentId.value)) {
|
||
selectedShipmentId.value = rows[0].id;
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
onMounted(async () => {
|
||
await loadSites();
|
||
await load();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ==================== Base ==================== */
|
||
.page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 4px 0;
|
||
background: transparent;
|
||
}
|
||
|
||
/* 桌面端:撑满父容器高度 */
|
||
.shipment-page--desktop {
|
||
height: 100%;
|
||
min-height: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.page-inner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
/* 桌面端:page-inner 撑满 .page */
|
||
.shipment-page--desktop > .page-inner {
|
||
flex: 1;
|
||
min-height: 0;
|
||
gap: 0;
|
||
}
|
||
|
||
/* ==================== Page Header ==================== */
|
||
.create-btn {
|
||
height: 34px;
|
||
border-radius: 8px;
|
||
padding: 0 16px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ==================== Table Toolbar ==================== */
|
||
.table-card-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 14px 20px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
gap: 12px;
|
||
}
|
||
|
||
.toolbar-filters {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.filter-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.filter-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #8a8a8a;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
.filter-select {
|
||
width: 140px;
|
||
}
|
||
|
||
.filter-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: flex-end;
|
||
padding-bottom: 1px;
|
||
}
|
||
|
||
.filter-btn {
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.filter-summary {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: #a3a3a3;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ==================== Table Card ==================== */
|
||
.table-card {
|
||
background: #ffffff;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
box-shadow:
|
||
0 1px 2px rgba(0, 0, 0, 0.04),
|
||
0 2px 8px rgba(0, 0, 0, 0.02);
|
||
}
|
||
|
||
/* 桌面端:table-card 填满、无圆角 */
|
||
.shipment-page--desktop .table-card {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.shipment-workbench {
|
||
min-width: 0;
|
||
min-height: 0;
|
||
}
|
||
|
||
.shipment-workbench.is-desktop {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 316px;
|
||
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.shipment-table-pane {
|
||
min-width: 0;
|
||
outline: none;
|
||
}
|
||
|
||
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
|
||
.shipment-table-pane :deep(.el-scrollbar__wrap) {
|
||
overflow-x: hidden;
|
||
}
|
||
.shipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
|
||
display: none;
|
||
}
|
||
|
||
.shipment-workbench.is-desktop .shipment-table-pane {
|
||
border-right: 1px solid #e3e9f1;
|
||
}
|
||
|
||
.shipment-preview-pane {
|
||
display: flex;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
padding: 16px;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.preview-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.preview-kicker {
|
||
margin-bottom: 5px;
|
||
color: #6b7e95;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.preview-title {
|
||
display: -webkit-box;
|
||
overflow: hidden;
|
||
color: #142033;
|
||
font-size: 17px;
|
||
font-weight: 800;
|
||
line-height: 1.35;
|
||
-webkit-box-orient: vertical;
|
||
-webkit-line-clamp: 3;
|
||
}
|
||
|
||
.preview-list {
|
||
display: grid;
|
||
grid-template-columns: 78px minmax(0, 1fr);
|
||
gap: 10px 12px;
|
||
margin: 0;
|
||
}
|
||
|
||
.preview-list dt {
|
||
color: #7b8da3;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.preview-list dd {
|
||
min-width: 0;
|
||
margin: 0;
|
||
overflow-wrap: anywhere;
|
||
color: #223349;
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.preview-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: auto;
|
||
}
|
||
|
||
.preview-empty {
|
||
display: flex;
|
||
min-height: 180px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #7b8da3;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
text-align: center;
|
||
}
|
||
|
||
.shipment-context-menu {
|
||
position: fixed;
|
||
z-index: 2300;
|
||
display: flex;
|
||
width: 172px;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: 6px;
|
||
border: 1px solid #cbd7e5;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
|
||
}
|
||
|
||
.shipment-context-menu button {
|
||
appearance: none;
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
min-height: 30px;
|
||
padding: 0 9px;
|
||
border: 0;
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: #223349;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-align: left;
|
||
}
|
||
|
||
.shipment-context-menu button:hover:not(:disabled) {
|
||
background: #eef4fb;
|
||
color: #183756;
|
||
}
|
||
|
||
.shipment-context-menu button.danger {
|
||
color: #b42318;
|
||
}
|
||
|
||
.shipment-context-menu button:disabled {
|
||
cursor: not-allowed;
|
||
color: #9aaabd;
|
||
}
|
||
|
||
.shipment-table {
|
||
--el-table-border-color: transparent;
|
||
--el-table-row-hover-bg-color: #f8f9fb;
|
||
}
|
||
|
||
.shipment-table :deep(.el-table__inner-wrapper::before) {
|
||
display: none;
|
||
}
|
||
|
||
.shipment-table :deep(th.el-table__cell) {
|
||
background: #f4f5f7;
|
||
color: #2a2a2a;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
padding: 14px 14px;
|
||
border-bottom: 2px solid #e0e0e0 !important;
|
||
}
|
||
|
||
.shipment-table :deep(th.el-table__cell .cell) {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.shipment-table :deep(td.el-table__cell) {
|
||
padding: 12px 14px;
|
||
color: #0a0a0a;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.shipment-table :deep(.el-table__body tr) {
|
||
cursor: pointer;
|
||
transition: background 0.1s ease;
|
||
}
|
||
|
||
.shipment-table :deep(.el-table__body tr.row-selected > td) {
|
||
background: #eaf1f8 !important;
|
||
}
|
||
|
||
.cell-nowrap {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.cell-primary {
|
||
font-weight: 600;
|
||
color: #0a0a0a;
|
||
}
|
||
|
||
.cell-mono {
|
||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||
font-size: 13px;
|
||
color: #0a0a0a;
|
||
}
|
||
|
||
.cell-muted {
|
||
color: #737373;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.cell-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.cell-actions :deep(.el-button) {
|
||
height: auto;
|
||
padding: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.cell-actions :deep(.el-button + .el-button) {
|
||
margin-left: 0;
|
||
}
|
||
|
||
/* ==================== Direction Tags ==================== */
|
||
.dir-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 10px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
border-radius: 4px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.dir-tag--send {
|
||
background: #fff7ed;
|
||
color: #ea580c;
|
||
}
|
||
|
||
.dir-tag--return {
|
||
background: #f0fdf4;
|
||
color: #16a34a;
|
||
}
|
||
|
||
/* ==================== Status Pills ==================== */
|
||
.status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 12px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
border-radius: 20px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.status-pill--info {
|
||
background: #f5f5f5;
|
||
color: #737373;
|
||
}
|
||
|
||
.status-pill--primary {
|
||
background: #eff4ff;
|
||
color: #2563eb;
|
||
}
|
||
|
||
.status-pill--success {
|
||
background: #dcfce7;
|
||
color: #16a34a;
|
||
}
|
||
|
||
.status-pill--danger {
|
||
background: #fef2f2;
|
||
color: #dc2626;
|
||
}
|
||
|
||
/* ==================== Empty State ==================== */
|
||
.table-empty {
|
||
min-height: 200px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.empty-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
background: #f5f5f5;
|
||
color: #c4c4c4;
|
||
}
|
||
|
||
.table-empty span {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #a3a3a3;
|
||
}
|
||
|
||
/* ==================== Drawer Editor ==================== */
|
||
:deep(.shipment-editor-drawer > .el-drawer__header) {
|
||
margin-bottom: 0;
|
||
padding: 20px 24px 8px;
|
||
}
|
||
|
||
:deep(.shipment-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: #0a0a0a;
|
||
line-height: 1.2;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.shipment-form {
|
||
padding: 0;
|
||
}
|
||
|
||
/* ==================== Form Sections ==================== */
|
||
.form-section {
|
||
padding-bottom: 6px;
|
||
}
|
||
|
||
.form-section + .form-section {
|
||
margin-top: 20px;
|
||
padding-top: 20px;
|
||
border-top: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.form-section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #0a0a0a;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.dot--blue { background: #3b82f6; }
|
||
.dot--amber { background: #f59e0b; }
|
||
.dot--green { background: #22c55e; }
|
||
.dot--purple { background: #8b5cf6; }
|
||
|
||
.field-grid {
|
||
display: grid;
|
||
gap: 0 16px;
|
||
}
|
||
|
||
.field-grid--2 {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
|
||
.full-width {
|
||
width: 100%;
|
||
}
|
||
|
||
.inactive-hint {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
background: #fffbeb;
|
||
border: 1px solid #fde68a;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
color: #92400e;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.drawer-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
|
||
.footer-btn {
|
||
height: 36px;
|
||
border-radius: 8px;
|
||
padding: 0 20px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.footer-btn--primary {
|
||
padding: 0 24px;
|
||
}
|
||
|
||
.shipment-form :deep(.el-form-item) {
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.shipment-form :deep(.el-form-item__label) {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #525252;
|
||
padding-bottom: 4px;
|
||
}
|
||
|
||
.shipment-form :deep(.el-form-item__error) {
|
||
font-size: 11px;
|
||
}
|
||
|
||
/* ==================== Responsive ==================== */
|
||
@media (max-width: 960px) {
|
||
.field-grid--2 {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.toolbar-filters {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.filter-select {
|
||
width: 100%;
|
||
}
|
||
.table-card-toolbar {
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
}
|
||
.shipment-workbench.is-desktop {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
.shipment-workbench.is-desktop .shipment-table-pane {
|
||
border-right: 0;
|
||
}
|
||
.shipment-preview-pane {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.create-btn {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .table-card) {
|
||
background: #172033;
|
||
}
|
||
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-preview-pane) {
|
||
border-color: #26364a;
|
||
background: #111827;
|
||
}
|
||
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-title),
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dd) {
|
||
color: #f8fafc;
|
||
}
|
||
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-kicker),
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dt),
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-empty) {
|
||
color: #94a3b8;
|
||
}
|
||
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu) {
|
||
border-color: #334155;
|
||
background: #172033;
|
||
}
|
||
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button) {
|
||
color: #dbe5f1;
|
||
}
|
||
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button:hover:not(:disabled)) {
|
||
background: #243247;
|
||
color: #bfdbfe;
|
||
}
|
||
|
||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-table .el-table__body tr.row-selected > td) {
|
||
background: #243247 !important;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
.row-inactive td {
|
||
color: var(--ctms-text-secondary);
|
||
background-color: #fff7d6 !important;
|
||
}
|
||
|
||
.row-inactive .status-pill,
|
||
.row-inactive .dir-tag {
|
||
opacity: 0.6;
|
||
}
|
||
</style>
|