Files
zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue
T
zlwuandCursor e7677d1661 feat: finish P0 per-sample payment and presign order integration
Wire finance transfer list, protocol view v2 readonly payment, and mark design/plan as P0 implemented.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 11:25:47 +08:00

3312 lines
106 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 采集协议单 — 只读查看(字段与新增/编辑弹窗一致) -->
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="1600px"
class="dialog-form-layout protocol-view-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="handleClosed"
>
<div
class="protocol-view-body"
:class="{ 'is-detail-loading': detailLoading && !bundle }"
v-loading="detailLoading && !bundle"
element-loading-text="加载中…"
>
<div class="detail-tabs">
<div
v-for="tab in formTabs"
:key="tab.key"
class="detail-tab"
:class="{ active: activeTab === tab.key, 'is-disabled': detailLoading && !bundle }"
@click="scrollToSection(tab.key)"
>
{{ tab.label }}
</div>
</div>
<template v-if="bundle">
<div>
<!-- 基本信息 -->
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>基本信息</h3></div>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">协议状态</div
><div class="data-value"
><el-tag
:type="protocolStatusTagType(String(protocol?.protocolStatus || ''))"
size="small"
>{{
protocolStatusDisplayLabel(
String(protocol?.protocolStatus || ''),
String(protocol?.voidType || '')
)
}}</el-tag
></div
></div
>
<div class="data-item"
><div class="data-label">登记日期</div
><div class="data-value">{{ formatYmd(protocol?.registerDate) }}</div></div
>
<div class="data-item"
><div class="data-label">采集专员</div
><div class="data-value">{{ collectorDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">创建时间</div
><div class="data-value">{{ dash(protocol?.createTime) }}</div></div
>
</div>
<!-- 协议状态变更记录(非储存证查看:仍在基本信息内) -->
<div v-if="!showStorageCert && hasAnyStatusChange" class="status-change-section">
<div class="status-change-title">状态变更记录</div>
<div class="data-grid data-grid--4">
<template v-if="protocol?.effectiveTime">
<div class="data-item"
><div class="data-label">协议生效时间</div
><div class="data-value">{{ dash(protocol.effectiveTime) }}</div></div
>
<div class="data-item"
><div class="data-label">协议生效描述</div
><div class="data-value data-value--muted">{{
dash(protocol.effectiveDesc)
}}</div></div
>
</template>
<template v-if="protocol?.expireTime">
<div class="data-item"
><div class="data-label">协议过期时间</div
><div class="data-value">{{ dash(protocol.expireTime) }}</div></div
>
<div class="data-item"
><div class="data-label">协议过期描述</div
><div class="data-value data-value--muted">{{
dash(protocol.expireDesc)
}}</div></div
>
</template>
<template v-if="protocol?.voidTime">
<div class="data-item"
><div class="data-label">作废类型</div
><div class="data-value">{{
voidTypeLabel(String(protocol?.voidType || '')) || '—'
}}</div></div
>
<div class="data-item"
><div class="data-label">协议作废时间</div
><div class="data-value">{{ dash(protocol.voidTime) }}</div></div
>
<div class="data-item"
><div class="data-label">协议作废描述</div
><div class="data-value data-value--muted">{{
dash(protocol.voidDesc)
}}</div></div
>
</template>
<template v-if="protocol?.toResearchTime">
<div class="data-item"
><div class="data-label">协议转科研时间</div
><div class="data-value">{{ dash(protocol.toResearchTime) }}</div></div
>
<div class="data-item"
><div class="data-label">协议转科研描述</div
><div class="data-value data-value--muted">{{
dash(protocol.toResearchDesc)
}}</div></div
>
</template>
</div>
</div>
<!-- 协议附件 / 知情同意书 / 孕妇健康调查表 / 备注 -->
<div
v-if="
protocolAttachments.length ||
consentAttachments.length ||
healthSurveyAttachments.length ||
(protocol?.remark && String(protocol.remark).trim())
"
class="protocol-basic-attachments-block"
>
<div class="data-item protocol-attachments-item">
<div class="data-label">协议附件</div>
<div class="data-value protocol-attachments-value">
<ProtocolAttachmentReadonly
:files="protocolAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
<div class="data-item protocol-attachments-item">
<div class="data-label">知情同意书</div>
<div class="data-value protocol-attachments-value">
<ProtocolAttachmentReadonly
:files="consentAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
<div class="data-item protocol-attachments-item">
<div class="data-label">孕妇健康调查表</div>
<div class="data-value protocol-attachments-value">
<ProtocolAttachmentReadonly
:files="healthSurveyAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
<div v-if="protocol?.remark && String(protocol.remark).trim()" class="data-item">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(protocol?.remark) }}</div>
</div>
</div>
<!-- 储存证:储存证附件、制作状态、制作日期同一行(在协议附件行下方) -->
<div v-if="showStorageCert" class="data-grid data-grid--3 storage-cert-info-row">
<div class="data-item storage-cert-attachments-item">
<div class="data-label">储存证附件</div>
<div class="data-value storage-cert-attachments-value">
<ProtocolAttachmentReadonly
:files="storageCertAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
<div class="data-item">
<div class="data-label">制作状态</div>
<div class="data-value">
<el-tag
:type="storageCertMakeStatusTagType(String(storageCertInfo?.makeStatus || '0'))"
size="small"
>
{{ storageCertMakeStatusLabel(String(storageCertInfo?.makeStatus || '0')) }}
</el-tag>
</div>
</div>
<div class="data-item">
<div class="data-label">制作日期</div>
<div class="data-value">{{ formatYmd(storageCertInfo?.makeDate) }}</div>
</div>
</div>
</div>
<!-- 状态变更记录(储存证查看:独立区域) -->
<div
v-if="showStorageCert"
id="view-section-status-change"
class="section-card section-card--white"
>
<div class="section-title"><h3>状态变更记录</h3></div>
<el-empty v-if="!hasAnyStatusChange" description="暂无状态变更记录" :image-size="64" />
<div v-else class="data-grid data-grid--4">
<template v-if="protocol?.effectiveTime">
<div class="data-item"
><div class="data-label">协议生效时间</div
><div class="data-value">{{ dash(protocol.effectiveTime) }}</div></div
>
<div class="data-item"
><div class="data-label">协议生效描述</div
><div class="data-value data-value--muted">{{
dash(protocol.effectiveDesc)
}}</div></div
>
</template>
<template v-if="protocol?.expireTime">
<div class="data-item"
><div class="data-label">协议过期时间</div
><div class="data-value">{{ dash(protocol.expireTime) }}</div></div
>
<div class="data-item"
><div class="data-label">协议过期描述</div
><div class="data-value data-value--muted">{{
dash(protocol.expireDesc)
}}</div></div
>
</template>
<template v-if="protocol?.voidTime">
<div class="data-item"
><div class="data-label">作废类型</div
><div class="data-value">{{
voidTypeLabel(String(protocol?.voidType || '')) || '—'
}}</div></div
>
<div class="data-item"
><div class="data-label">协议作废时间</div
><div class="data-value">{{ dash(protocol.voidTime) }}</div></div
>
<div class="data-item"
><div class="data-label">协议作废描述</div
><div class="data-value data-value--muted">{{
dash(protocol.voidDesc)
}}</div></div
>
</template>
<template v-if="protocol?.toResearchTime">
<div class="data-item"
><div class="data-label">协议转科研时间</div
><div class="data-value">{{ dash(protocol.toResearchTime) }}</div></div
>
<div class="data-item"
><div class="data-label">协议转科研描述</div
><div class="data-value data-value--muted">{{
dash(protocol.toResearchDesc)
}}</div></div
>
</template>
</div>
</div>
<!-- 采集信息 -->
<div id="view-section-collection" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>采集信息</h3></div>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">采集地区</div
><div class="data-value">{{ regionDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">采集医院</div
><div class="data-value">{{ hospitalDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">采集医护</div
><div class="data-value">{{ dash(collectionInfo?.medicalStaff) }}</div></div
>
<div class="data-item"
><div class="data-label">销售人员</div
><div class="data-value">{{ salesDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">样本采集时间</div
><div class="data-value">{{ dash(collectionInfo?.sampleCollectTime) }}</div></div
>
<div class="data-item"
><div class="data-label">母血采集时间</div
><div class="data-value">{{
dash(collectionInfo?.maternalBloodCollectTime)
}}</div></div
>
</div>
</div>
<!-- 样本及运输(字典异步补齐,不阻塞整弹窗) -->
<div
id="view-section-sample"
v-loading="lookupLoading"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>样本及运输信息</h3></div>
<div v-if="samples.length" class="sample-cards">
<div v-for="(row, sIdx) in samples" :key="row.id || sIdx" class="sample-card">
<div class="sample-card__header">
<div class="sample-card__title">
<span class="sample-card__seq">{{ sIdx + 1 }}</span>
{{ sampleTypeName(row.sampleTypeId) }}
</div>
<el-tag :type="sampleStatusTagType(String(row.sampleStatus || ''))" size="small">
{{ sampleStatusLabel(String(row.sampleStatus || '')) }}
</el-tag>
</div>
<div class="sample-card__body">
<div class="sample-card__section">
<div class="sample-card__section-label">样本信息</div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">样本编码</div>
<div class="data-value data-value--code">
<SampleCodeWithSpecialDisplay
:sample-code="row.sampleCode"
:is-special="!!row.isSpecial"
:special-remark="row.specialRemark"
monospace
/>
</div>
</div>
<div class="data-item">
<div class="data-label">原价格(元)</div>
<div class="data-value data-value--price">{{
row.originalPrice ? `¥${row.originalPrice}` : '—'
}}</div>
</div>
<div class="data-item">
<div class="data-label">优惠价格(元)</div>
<div class="data-value data-value--price">{{
row.price ? `¥${row.price}` : '—'
}}</div>
</div>
<div class="data-item">
<div class="data-label">存储年限(年)</div>
<div class="data-value">{{ dash(row.storageYears) }}</div>
</div>
<div v-if="row.remark" class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(row.remark) }}</div>
</div>
</div>
</div>
<div
v-if="row.transport?.transportType"
class="sample-card__section sample-card__section--transport"
>
<div class="sample-card__section-label">运输信息</div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">运输类型</div>
<div class="data-value">{{
transportTypeLabel(String(row.transport?.transportType || ''))
}}</div>
</div>
<div class="data-item">
<div class="data-label">目的站点</div>
<div class="data-value">{{
siteName(String(row.transport?.destSiteId || ''))
}}</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ dash(row.transport?.etaTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">快递/携带</div>
<div class="data-value">{{ sampleTransportExtra(row.transport) }}</div>
</div>
</div>
</div>
<div class="sample-card__section sample-card__section--reception">
<SampleReceptionDualReadonlyPanel
:qc-reception="row.sampleReception"
:production-reception="
row.productionReception || row.sampleReception?.productionReception
"
source-type="cell_storage"
:sample-type-name="row.sampleTypeName"
/>
<div
v-if="
row.sampleReception &&
sampleReceptionAttachmentFiles(row.sampleReception).length
"
class="attachment-row sample-card__attachments"
>
<div class="data-label">接收附件</div>
<ProtocolAttachmentReadonly
:files="sampleReceptionAttachmentFiles(row.sampleReception)"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
<SampleQcInfoGroupedPanel
:gate-qc-info="row.qcInfo"
:process-qc-info-list="row.processQcInfoList"
:sample-reception="row.sampleReception"
/>
<SampleProductionInfoCards :production-info="row.productionInfo" />
<SampleStorageReleaseInfo :storage-release-info="row.storageReleaseInfo" />
</div>
</div>
</div>
<el-empty v-else description="暂无样本" :image-size="48" />
</div>
<!-- 客户信息 -->
<div id="view-section-party" class="section-card section-card--green">
<div class="section-title section-title--banner"><h3>客户信息</h3></div>
<div class="client-sub-cards">
<!-- 母亲信息 -->
<div class="client-sub-card">
<div class="sub-card-header"
><span class="sub-card-icon">🤱</span><h4>母亲信息</h4></div
>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">关联客户</div
><div class="data-value">{{
partyCustomer?.customerId ? dash(partyCustomer?.motherName) : '—'
}}</div></div
>
<div class="data-item"
><div class="data-label">母亲姓名</div
><div class="data-value">{{ dash(partyCustomer?.motherName) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲联系电话</div
><div class="data-value">{{ dash(partyCustomer?.motherPhone) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲身份证号</div
><div class="data-value">{{ dash(partyCustomer?.motherIdCard) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲出生年月</div
><div class="data-value">{{ formatYm(partyCustomer?.motherBirthYm) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲民族</div
><div class="data-value">{{ dash(partyCustomer?.motherEthnic) }}</div></div
>
</div>
<div class="sub-card-full-row"
><div class="data-label">母亲邮箱</div
><div class="data-value">{{ dash(partyCustomer?.motherEmail) }}</div></div
>
<div class="sub-card-full-row"
><div class="data-label">母亲家庭住址</div
><div class="data-value data-value--muted">{{
dash(partyCustomer?.motherAddress)
}}</div></div
>
</div>
<!-- 父亲信息 -->
<div class="client-sub-card">
<div class="sub-card-header"
><span class="sub-card-icon">👨</span><h4>父亲信息</h4></div
>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">父亲姓名</div
><div class="data-value">{{ dash(partyCustomer?.fatherName) }}</div></div
>
<div class="data-item"
><div class="data-label">父亲电话</div
><div class="data-value">{{ dash(partyCustomer?.fatherPhone) }}</div></div
>
<div class="data-item data-item--full"
><div class="data-label">父亲身份证号</div
><div class="data-value">{{ dash(partyCustomer?.fatherIdCard) }}</div></div
>
</div>
</div>
<!-- 新生儿信息 -->
<div class="client-sub-card">
<div class="sub-card-header"
><span class="sub-card-icon">👶</span><h4>新生儿信息</h4></div
>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">新生儿姓名</div
><div class="data-value">{{ dash(partyCustomer?.newbornName) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿身份证</div
><div class="data-value">{{ dash(partyCustomer?.newbornIdCard) }}</div></div
>
<div class="data-item"
><div class="data-label">预产期</div
><div class="data-value">{{
dash(partyCustomer?.expectedDeliveryDate)
}}</div></div
>
<div class="data-item"
><div class="data-label">新生儿分娩时间</div
><div class="data-value">{{
dash(partyCustomer?.newbornDeliveryTime)
}}</div></div
>
<div class="data-item"
><div class="data-label">新生儿性别</div
><div class="data-value">{{
newbornSexLabel(String(partyCustomer?.newbornSex || ''))
}}</div></div
>
<div class="data-item"
><div class="data-label">分娩方式</div
><div class="data-value">{{
deliveryModeLabel(String(partyCustomer?.deliveryMode || ''))
}}</div></div
>
<div class="data-item"
><div class="data-label">胎次</div
><div class="data-value">{{ dash(partyCustomer?.birthOrder) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿体重(克)</div
><div class="data-value">{{ dash(partyCustomer?.newbornWeightGram) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿评分</div
><div class="data-value">{{ dash(partyCustomer?.apgar1min) }}</div></div
>
</div>
</div>
</div>
</div>
<!-- 新生儿保险信息(满月待办查看:客户信息下方独立区域) -->
<div
v-if="showFullmoonInsurance"
id="view-section-fullmoon-insurance"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>新生儿保险信息</h3></div>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">满月日期</div
><div class="data-value">{{ dash(fullmoonInsuranceInfo?.fullmoonDate) }}</div></div
>
<div class="data-item">
<div class="data-label">待办状态</div>
<div class="data-value">
<el-tag
:type="
fullmoonTodoStatusTagType(String(fullmoonInsuranceInfo?.todoStatus || '0'))
"
size="small"
>
{{ fullmoonTodoStatusLabel(String(fullmoonInsuranceInfo?.todoStatus || '0')) }}
</el-tag>
</div>
</div>
<div class="data-item">
<div class="data-label">是否已订购保险</div>
<div class="data-value">
<el-tag
:type="
insuranceSubscribedTagType(
String(fullmoonInsuranceInfo?.insuranceSubscribed || '0')
)
"
size="small"
>
{{
insuranceSubscribedLabel(
String(fullmoonInsuranceInfo?.insuranceSubscribed || '0')
)
}}
</el-tag>
</div>
</div>
<div class="data-item"
><div class="data-label">保险公司</div
><div class="data-value">{{
dash(fullmoonInsuranceInfo?.insuranceCompanyName)
}}</div></div
>
<div class="data-item"
><div class="data-label">保险险种</div
><div class="data-value">{{ dash(fullmoonInsuranceInfo?.insuranceType) }}</div></div
>
<div class="data-item"
><div class="data-label">保险单号</div
><div class="data-value">{{
dash(fullmoonInsuranceInfo?.insurancePolicyNo)
}}</div></div
>
<div class="data-item"
><div class="data-label">保单费用(元)</div
><div class="data-value">{{
fmtMoney(fullmoonInsuranceInfo?.insuranceFee)
}}</div></div
>
<div class="data-item"
><div class="data-label">保险单生效时间</div
><div class="data-value">{{
formatYmd(fullmoonInsuranceInfo?.insuranceEffectiveDate)
}}</div></div
>
<div class="data-item"
><div class="data-label">保险单失效时间</div
><div class="data-value">{{
formatYmd(fullmoonInsuranceInfo?.insuranceExpiryDate)
}}</div></div
>
<div class="data-item"
><div class="data-label">办理日期</div
><div class="data-value">{{
formatYmd(fullmoonInsuranceInfo?.handleDate)
}}</div></div
>
</div>
<div v-if="fullmoonInsuranceInfo?.handleRemark" class="remark-row">
<div class="data-item data-item--full">
<div class="data-label">办理备注</div>
<div class="data-value data-value--muted">{{
dash(fullmoonInsuranceInfo?.handleRemark)
}}</div>
</div>
</div>
<div class="attachment-row">
<div class="data-label">保险单附件</div>
<ProtocolAttachmentReadonly
v-if="fullmoonInsuranceAttachments.length"
:files="fullmoonInsuranceAttachments"
@preview="openPreview"
@download="downloadFile"
/>
<div v-else class="data-value data-value--muted">—</div>
</div>
</div>
<!-- 缴费信息 -->
<div id="view-section-payment" class="section-card">
<div class="section-title section-title--banner"><h3>缴费信息</h3></div>
<!-- v2: 按样本缴费只读 -->
<template v-if="paymentModelVersion >= 2">
<el-alert
v-if="presignDepositHint"
type="info"
:closable="false"
show-icon
style="margin-bottom: 12px"
:title="presignDepositHintText"
/>
<div class="payment-summary">
<div class="payment-metric">
<div class="pm-label">协议总金额(元)</div>
<div class="pm-value pm-value--amount">{{
fmtMoney(protocol?.protocolTotalAmount)
}}</div>
</div>
<div class="payment-metric">
<div class="pm-label">样本合同合计(元)</div>
<div class="pm-value pm-value--amount">{{ fmtMoney(sampleContractSum) }}</div>
</div>
<div class="payment-metric">
<div class="pm-label">已确认收款(元)</div>
<div class="pm-value pm-value--amount pm-value--received">{{
fmtMoney(v2ReceivedAmount)
}}</div>
</div>
</div>
<div class="section-subtitle">按样本缴费方案</div>
<div v-if="samplePayments.length" class="sample-pay-readonly">
<div
v-for="(sp, sIdx) in samplePayments"
:key="sp.id || sIdx"
class="sample-pay-readonly__card"
>
<div class="sample-pay-readonly__head">
<span
>{{ sp.sampleCode || '未编码' }} ·
{{ payModeLabel(String(sp.payMode || '')) }}</span
>
<span class="sample-pay-readonly__amt">{{ fmtMoney(sp.contractAmount) }}</span>
</div>
<div
v-for="(sch, schIdx) in sp.schedules || []"
:key="sch.id || schIdx"
class="sample-pay-readonly__sch"
>
<div class="sample-pay-readonly__sch-hd">
第 {{ sch.scheduleNo || schIdx + 1 }} 期 · 应缴
{{ fmtMoney(sch.dueAmount) }}
<template v-if="sch.dueDate"> · {{ formatYmd(sch.dueDate) }}</template>
· 已缴 {{ fmtMoney(sch.paidAmount) }} · 欠缴
{{ fmtMoney(sch.arrearsAmount) }}
</div>
<div
v-for="(tr, tIdx) in sch.transfers || []"
:key="tr.id || tIdx"
class="sample-pay-readonly__tr"
>
<el-tag :type="payStatusTagType(String(tr.payStatus || ''))" size="small">
{{ payStatusLabel(String(tr.payStatus || '')) }}
</el-tag>
<span>{{ transferTypeLabel(String(tr.transferType || '')) || '—' }}</span>
<span>{{ fmtMoney(tr.transferAmount) }}</span>
<span>{{ formatDisplayOrDash(tr.transferTime) }}</span>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无按样本缴费方案" :image-size="40" />
<div class="section-subtitle">退费登记</div>
<el-table
v-if="refundRecords.length"
:data="refundRecords"
class="list-table inner-table"
stripe
>
<el-table-column
prop="refundAmount"
label="退费金额"
min-width="120"
align="center"
>
<template #default="{ row }">{{ fmtMoney(row.refundAmount) }}</template>
</el-table-column>
<el-table-column prop="refundTime" label="退费时间" min-width="160" align="center">
<template #default="{ row }">{{ formatDisplayOrDash(row.refundTime) }}</template>
</el-table-column>
<el-table-column
prop="refundRemark"
label="备注"
min-width="180"
align="center"
show-overflow-tooltip
/>
</el-table>
<el-empty v-else description="暂无退费登记" :image-size="40" />
</template>
<!-- v1: 旧整单缴费只读 -->
<template v-else>
<!-- 缴费主表指标卡 -->
<div class="payment-summary">
<div class="payment-metric"
><div class="pm-label">缴费模式</div
><div class="pm-value">{{
payModeLabel(String(payment?.payMode || ''))
}}</div></div
>
<div class="payment-metric"
><div class="pm-label">合同金额(元)</div
><div class="pm-value pm-value--amount">{{
fmtMoney(payment?.contractAmount)
}}</div></div
>
<div class="payment-metric"
><div class="pm-label">已收金额(元)</div
><div class="pm-value pm-value--amount pm-value--received">{{
fmtMoney(receivedAmount)
}}</div></div
>
<template v-if="payment?.payMode === 'dj'">
<div class="payment-metric"
><div class="pm-label">趸交截止时间</div
><div class="pm-value">{{ formatYmd(payment?.lumpSumDeadline) }}</div></div
>
</template>
<template v-if="isInstallmentPayMode(String(payment?.payMode || ''))">
<div class="payment-metric"
><div class="pm-label">分期期数</div
><div class="pm-value">{{ dash(payment?.installmentCount) }}</div></div
>
<div class="payment-metric"
><div class="pm-label">每期间隔(月)</div
><div class="pm-value">{{ dash(payment?.installmentIntervalMonths) }}</div></div
>
<div class="payment-metric"
><div class="pm-label">分期开始时间</div
><div class="pm-value">{{ formatYmd(payment?.installmentStartDate) }}</div></div
>
<div class="payment-metric"
><div class="pm-label">应缴费提前(天)</div
><div class="pm-value">{{
dash(payment?.installmentAdvanceDays ?? 30)
}}</div></div
>
<div class="payment-metric"
><div class="pm-label">分期金额(元)</div
><div class="pm-value">{{
formatInstallmentAmounts(payment?.installmentRatios)
}}</div></div
>
</template>
</div>
<!-- 缴费明细 -->
<div class="section-subtitle">缴费明细</div>
<div v-if="paymentDetails.length" class="payment-details">
<div
v-for="(detail, dIdx) in paymentDetails"
:key="detail.id || dIdx"
class="payment-detail-card"
:class="{
'payment-detail-card--refunded': String(detail.payStatus) === 'ytf',
'payment-detail-card--refund-pending': String(detail.payStatus) === 'tfqrz'
}"
>
<!-- Header -->
<div class="pdc-header">
<div
class="pdc-header-left"
:class="payDetailColorClass(String(detail.payStatus || ''))"
>
缴费明细 #{{ dIdx + 1 }} ·
<span
class="pdc-header-amount"
:class="payDetailColorClass(String(detail.payStatus || ''))"
>
{{ fmtMoney(detail.dueAmount) }}
</span>
</div>
<el-tag :type="payStatusTagType(String(detail.payStatus || ''))" size="small">
{{ payStatusLabel(String(detail.payStatus || '')) }}
</el-tag>
</div>
<!-- Info row -->
<div class="pdc-info-row">
<div class="pdc-info-item">
<span class="info-label">应缴费金额(元)</span>
<span class="info-value">{{ fmtMoney(detail.dueAmount) }}</span>
</div>
<div class="pdc-info-item">
<span class="info-label">转账方式</span>
<span class="info-value">{{
transferTypeLabel(String(detail.transferType || ''))
}}</span>
</div>
<div class="pdc-info-item">
<span class="info-label">转账金额(元)</span>
<span class="info-value">{{ fmtMoney(detail.transferAmount) }}</span>
</div>
<div class="pdc-info-item">
<span class="info-label">转账时间</span>
<span class="info-value">{{ dash(detail.transferTime) }}</span>
</div>
<div
v-if="isInstallmentPayMode(String(payment?.payMode || ''))"
class="pdc-info-item"
>
<span class="info-label">应缴费时间</span>
<span class="info-value">{{ formatYmd(detail.installmentDueDate) }}</span>
</div>
<div
v-if="isInstallmentPayMode(String(payment?.payMode || ''))"
class="pdc-info-item"
>
<span class="info-label">分期期序号</span>
<span class="info-value">{{ dash(detail.installmentNo) }}</span>
</div>
</div>
<!-- Transfer attachments -->
<div v-if="detailAttachments(detail).length" class="pdc-attachments-row">
<div class="data-label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="detailAttachments(detail)"
@preview="openPreview"
@download="downloadFile"
/>
</div>
<!-- Mini flow: 正向横向进度条 + 下方详情卡片 -->
<template
v-for="(flowSteps, flowIdx) in [buildMiniFlowSteps(detail)]"
:key="`${detail.id || dIdx}-flow-${flowIdx}`"
>
<div v-if="flowSteps.length" class="flow-section">
<div class="mini-flow">
<template v-for="(step, sIdx) in flowSteps" :key="`c-${step.key}`">
<div
v-if="step.connectorToNext"
class="mini-flow-connector"
:class="step.connectorToNext"
:style="miniFlowConnectorStyle(flowSteps.length, sIdx)"
/>
</template>
<div v-for="step in flowSteps" :key="step.key" class="mini-flow-step">
<div class="mini-flow-dot" :class="step.dotClass">
{{ step.dotIcon }}
</div>
<div class="mini-flow-label" :class="step.labelClass">
{{ step.label }}
</div>
</div>
</div>
<div class="flow-detail-cards">
<template v-for="step in flowSteps" :key="`d-${step.key}`">
<div
v-if="step.hasDetailCard"
class="flow-detail-card"
:class="'flow-detail-card--' + step.dotClass"
>
<div class="flow-detail-card__header">
<span class="flow-detail-card__dot" :class="step.dotClass">{{
step.dotIcon
}}</span>
<span class="flow-detail-card__title" :class="step.labelClass">{{
step.label
}}</span>
</div>
<div class="flow-detail-card__body">
<template v-if="step.key === 'pay'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value"
><el-tag type="success" size="small">已缴费</el-tag></span
>
</div>
<div v-if="detail.transferTime" class="flow-detail-card__kv">
<span class="flow-detail-card__label">缴费时间</span>
<span class="flow-detail-card__value">{{
detail.transferTime
}}</span>
</div>
<div
v-if="detailAttachments(detail).length"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">转账凭证</span>
<ProtocolAttachmentReadonly
:files="detailAttachments(detail)"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</template>
<template v-else-if="step.key === 'receipt-finance'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag
v-if="isReceiptFinanceConfirmed(detail)"
type="success"
size="small"
>已确认</el-tag
>
<el-tag v-else type="warning" size="small">待确认</el-tag>
</span>
</div>
<div
v-if="isReceiptFinanceConfirmed(detail) && detail.updateTime"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">确认时间</span>
<span class="flow-detail-card__value">{{
formatDisplayOrDash(detail.updateTime)
}}</span>
</div>
<div
v-if="
isReceiptFinanceConfirmed(detail) &&
detail.financeRemark &&
String(detail.financeRemark).trim()
"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务备注</span>
<span class="flow-detail-card__value">{{
detail.financeRemark
}}</span>
</div>
<div
v-if="
isReceiptFinanceConfirmed(detail) &&
financeConfirmAttachmentFiles(detail).length
"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务附件</span>
<ProtocolAttachmentReadonly
:files="financeConfirmAttachmentFiles(detail)"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</template>
<template v-else-if="step.key === 'refund-apply'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费金额(元)</span>
<span
class="flow-detail-card__value"
:class="payDetailColorClass(String(detail.payStatus || ''))"
>{{ fmtMoney(detail.refundAmount) }}</span
>
</div>
<div
v-if="detail.refundRemark && String(detail.refundRemark).trim()"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">退费备注</span>
<span class="flow-detail-card__value">{{
detail.refundRemark
}}</span>
</div>
<div
v-if="refundAttachmentFiles(detail).length"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">退费附件</span>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles(detail)"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</template>
<template v-else-if="step.key === 'refund-finance'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value"
><el-tag type="danger" size="small">已退费</el-tag></span
>
</div>
<div v-if="detail.updateTime" class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认时间</span>
<span class="flow-detail-card__value">{{
formatDisplayOrDash(detail.updateTime)
}}</span>
</div>
<div
v-if="
detail.refundFinanceRemark &&
String(detail.refundFinanceRemark).trim()
"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务备注</span>
<span class="flow-detail-card__value">{{
detail.refundFinanceRemark
}}</span>
</div>
<div
v-if="refundFinanceConfirmAttachmentFiles(detail).length"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务附件</span>
<ProtocolAttachmentReadonly
:files="refundFinanceConfirmAttachmentFiles(detail)"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
<el-empty v-else description="暂无缴费明细" :image-size="48" />
<!-- 缴费总览 Timeline -->
<template
v-for="(overviewSteps, overviewIdx) in [buildOverviewTimelineSteps()]"
:key="`overview-timeline-${overviewIdx}`"
>
<div v-if="overviewSteps.length > 1" class="payment-timeline">
<div class="timeline-title">缴费总览</div>
<div class="timeline">
<!-- Connector lines -->
<template v-for="(step, sIdx) in overviewSteps" :key="`tc-${sIdx}`">
<div
v-if="sIdx < overviewSteps.length - 1"
class="timeline-connector"
:class="step.connectorToNext || ''"
:style="timelineConnectorStyle(overviewSteps.length, sIdx)"
/>
</template>
<div
v-for="(step, sIdx) in overviewSteps"
:key="`ts-${sIdx}`"
class="timeline-step"
>
<div class="timeline-dot" :class="step.dotClass">
{{ step.dotIcon }}
</div>
<div class="timeline-label" :class="step.labelClass">
{{ step.label }}
<br />
<span style="font-size: 10px; font-weight: 400">{{ step.subLabel }}</span>
</div>
</div>
</div>
</div>
</template>
</template>
</div>
<!-- 审核记录 -->
<div
v-if="showAuditHistory"
id="view-section-audit"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>审核记录</h3></div>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-loading="auditLoading"
class="list-table inner-table"
:data="auditRecords"
stripe
empty-text="暂无审核记录"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="审核时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
prop="auditStatus"
label="审核状态"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="String(row.auditStatus) === 'shtg' ? 'success' : 'warning'"
size="small"
>
{{ auditStatusLabel(String(row.auditStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="auditRemark"
label="审核备注"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="auditorUserName"
label="审核人"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ auditorDisplay(row) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
</div>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
alt="预览"
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
<div v-else class="preview-container">
<video
:src="previewUrl"
controls
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { computed, nextTick, ref, watch } from 'vue'
import { Close } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from './ProtocolAttachmentReadonly.vue'
import SampleQcInfoGroupedPanel from '@/components/business/sample-qc-info-grouped-panel/index.vue'
import SampleReceptionDualReadonlyPanel from '@/components/business/sample-reception-dual-readonly-panel/index.vue'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import SampleStorageReleaseInfo from '@/components/business/sample-storage-release-info/index.vue'
import {
fetchCollectionProtocolAuditList,
fetchCollectionProtocolDetail,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import {
fetchCellSampleTypeList,
fetchItemUnitList,
fetchExpressCompanyList,
fetchSiteList,
fetchCollectionRegionList,
fetchHospitalList,
fetchUserList
} from '@/api/admin'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
protocolStatusDisplayLabel,
protocolStatusTagType,
auditStatusLabel,
payModeLabel,
newbornSexLabel,
deliveryModeLabel,
sampleStatusLabel,
sampleStatusTagType,
transportTypeLabel,
payStatusLabel,
payStatusTagType
} from '@/utils/status-tag-registry'
import { loadTransferTypeDict, transferTypeLabel } from '@/utils/protocol-payment-labels'
import { loadVoidTypeDict, voidTypeLabel } from '@/utils/void-type-dict'
import { formatProtocolDocSubject } from '@/utils/protocol-doc-label'
import { isInstallmentPayMode } from '@/utils/protocol-payment-presets'
import { sumSampleContractAmounts } from '@/utils/protocol-sample-payment'
const props = withDefaults(
defineProps<{
modelValue: boolean
protocolId: string | null
listRow?: CollectionProtocolRow | null
/** 展示审核记录 Tab(采集协议单审核页) */
showAuditHistory?: boolean
/** 储存证管理查看:展示储存证附件,状态变更记录独立区域 */
showStorageCert?: boolean
/** 满月待办查看:客户信息下方独立展示新生儿保险信息 */
showFullmoonInsurance?: boolean
}>(),
{ showAuditHistory: false, showStorageCert: false, showFullmoonInsurance: false }
)
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
}>()
const baseFormTabs = [
{ key: 'basic', label: '基本信息' },
{ key: 'collection', label: '采集信息' },
{ key: 'sample', label: '样本及运输信息' },
{ key: 'party', label: '客户信息' },
{ key: 'payment', label: '缴费信息' }
] as const
const formTabs = computed(() => {
const tabs: Array<{ key: string; label: string }> = [...baseFormTabs]
if (props.showFullmoonInsurance) {
const partyIdx = tabs.findIndex((t) => t.key === 'party')
tabs.splice(partyIdx + 1, 0, { key: 'fullmoon-insurance', label: '新生儿保险' })
}
if (props.showAuditHistory) {
tabs.push({ key: 'audit', label: '审核记录' })
}
return tabs
})
const detailLoading = ref(false)
const lookupLoading = ref(false)
const auditLoading = ref(false)
const bundle = ref<Record<string, any> | null>(null)
const auditRecords = ref<Array<Record<string, unknown>>>([])
const activeTab = ref('basic')
const sampleTypeOptions = ref<Array<{ id: string; typeName: string }>>([])
const unitOptions = ref<Array<{ id: string; unitName: string }>>([])
const siteOptions = ref<Array<{ id: string; siteName: string }>>([])
const expressCompanyOptions = ref<Array<{ id: string; expressName: string }>>([])
const regionOptions = ref<Array<{ id: string; regionName: string }>>([])
const hospitalOptions = ref<Array<{ id: string; hospitalName: string }>>([])
const collectorUserOptions = ref<Array<{ id: string; nickName: string }>>([])
const salesUserOptions = ref<Array<{ id: string; nickName: string }>>([])
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const protocol = computed(() => bundle.value?.protocol as Record<string, any> | undefined)
const partyCustomer = computed(() => bundle.value?.partyCustomer as Record<string, any> | null)
const collectionInfo = computed(() => bundle.value?.collectionInfo as Record<string, any> | null)
const payment = computed(() => bundle.value?.payment as Record<string, any> | null)
const paymentModelVersion = computed(() =>
Number((bundle.value?.protocol as any)?.paymentModelVersion || 1)
)
const samplePayments = computed(
() => (bundle.value?.samplePayments as Array<Record<string, any>>) || []
)
const refundRecords = computed(
() => (bundle.value?.refundRecords as Array<Record<string, any>>) || []
)
const presignDepositHint = computed(
() => (bundle.value?.presignDepositHint as Record<string, any>) || null
)
const sampleContractSum = computed(() => sumSampleContractAmounts(samplePayments.value))
const presignDepositHintText = computed(() => {
const h = presignDepositHint.value
if (!h) return ''
const amt = Number(h.confirmedDepositAmount || 0)
const cnt = Number(h.confirmedTransferCount || 0)
return `来源预签约已确认意向金合计 ¥${formatMoneyDisplay(amt)}(${cnt} 笔),仅提示不自动计入样本缴费`
})
const v2ReceivedAmount = computed(() => {
let sum = 0
for (const sp of samplePayments.value) {
for (const sch of sp.schedules || []) {
for (const tr of sch.transfers || []) {
if (String(tr.payStatus || '') === 'yjf') {
sum += Number(tr.transferAmount || 0) || 0
}
}
}
}
return sum
})
const samples = computed(() => {
const rows = (bundle.value?.samples as Array<Record<string, any>>) || []
return [...rows].sort((a, b) => {
const oa = Number(a.orderNum ?? 0)
const ob = Number(b.orderNum ?? 0)
if (oa !== ob) return oa - ob
return String(a.createTime || '').localeCompare(String(b.createTime || ''))
})
})
/** 严格按接口返回的库内明细展示,不做期次补齐 */
const paymentDetails = computed(
() => (bundle.value?.paymentDetails as Array<Record<string, any>>) || []
)
/** 已收金额 = 已缴费明细的转账金额之和(排除待缴费、退费确认中、已退费) */
const receivedAmount = computed(() => {
return paymentDetails.value
.filter((d) => {
const s = String(d.payStatus || '')
return s === 'yjf' || s === 'jfqrz'
})
.reduce((sum, d) => {
const amt = Number(d.transferAmount || 0)
return sum + (isNaN(amt) ? 0 : amt)
}, 0)
})
const protocolAttachments = computed(() => {
const list = bundle.value?.attachments
return Array.isArray(list) ? list : []
})
const consentAttachments = computed(() => {
const list = bundle.value?.consentAttachmentFiles
return Array.isArray(list) ? list : []
})
const healthSurveyAttachments = computed(() => {
const list = bundle.value?.healthSurveyAttachmentFiles
return Array.isArray(list) ? list : []
})
const storageCertInfo = computed(() => {
const sc = bundle.value?.storageCert as Record<string, any> | undefined | null
if (sc) return sc
if (!props.showStorageCert || !props.listRow) return null
return {
makeStatus: props.listRow.makeStatus ?? '0',
certImageUrls: props.listRow.certImageUrls ?? '',
makeDate: props.listRow.makeDate ?? '',
remark: props.listRow.remark ?? ''
}
})
const storageCertAttachments = computed(() => {
const urls = String(storageCertInfo.value?.certImageUrls || '').trim()
if (!urls) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((url, index) => {
const rawName = url.split('/').pop() || ''
const fileName = decodeURIComponent(rawName) || `附件${index + 1}`
return { id: `cert_${index}`, fileName, fileUrl: url, fileType: '' }
})
})
const fullmoonInsuranceInfo = computed(() => {
const fm = bundle.value?.fullmoonTodo as Record<string, any> | undefined
if (fm) return fm
if (!props.showFullmoonInsurance || !props.listRow) return null
const row = props.listRow as Record<string, any>
return {
fullmoonDate: row.fullmoonDate ?? '',
todoStatus: row.todoStatus ?? '0',
insuranceSubscribed: row.insuranceSubscribed ?? '0',
insuranceCompanyName: row.insuranceCompanyName ?? '',
insuranceType: row.insuranceType ?? '',
insurancePolicyNo: row.insurancePolicyNo ?? '',
insuranceEffectiveDate: row.insuranceEffectiveDate ?? row.policyEffectiveDate ?? '',
insuranceExpiryDate: row.insuranceExpiryDate ?? row.policyExpiryDate ?? '',
insuranceFee: row.insuranceFee,
handleDate: row.handleDate ?? row.processDate ?? '',
handleRemark: row.handleRemark ?? row.remark ?? '',
insuranceAttachments: row.insuranceAttachments ?? ''
}
})
const fullmoonInsuranceAttachments = computed(() => {
const urls = String(fullmoonInsuranceInfo.value?.insuranceAttachments || '').trim()
if (!urls) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((url, index) => {
const rawName = url.split('/').pop() || ''
const fileName = decodeURIComponent(rawName) || `附件${index + 1}`
return { id: `ins_${index}`, fileName, fileUrl: url, fileType: '' }
})
})
function fullmoonTodoStatusLabel(status: string) {
return status === '1' ? '已办保险' : '未办保险'
}
function fullmoonTodoStatusTagType(status: string) {
return status === '1' ? 'success' : 'warning'
}
function insuranceSubscribedLabel(val: string) {
return val === '1' ? '是' : '否'
}
function insuranceSubscribedTagType(val: string) {
return val === '1' ? 'success' : 'info'
}
/** 金额只读展示:千分位 + ¥ 前缀,null 显示 — */
function fmtMoney(v: unknown): string {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
}
function storageCertMakeStatusLabel(makeStatus: string) {
return makeStatus === '1' ? '已制作' : '未制作'
}
function storageCertMakeStatusTagType(makeStatus: string) {
return makeStatus === '1' ? 'success' : 'warning'
}
const dialogTitle = computed(() => {
const name =
partyCustomer.value?.motherName || props.listRow?.motherName || protocol.value?.motherName
return `查看 — ${formatProtocolDocSubject('cell_storage', { motherName: name })}`
})
const hasAnyStatusChange = computed(() => {
if (!protocol.value) return false
return !!(
protocol.value.effectiveTime ||
protocol.value.expireTime ||
protocol.value.voidTime ||
protocol.value.toResearchTime
)
})
const collectorDisplay = computed(() => {
const fromList = props.listRow?.collectorUserName
if (fromList) return String(fromList)
const fromBundle = protocol.value?.collectorUserName
if (fromBundle) return String(fromBundle)
const id = protocol.value?.collectorUserId
const u = collectorUserOptions.value.find((x) => x.id === id)
return u?.nickName || dash(id)
})
const salesDisplay = computed(() => {
const fromList = props.listRow?.salesUserName
if (fromList) return String(fromList)
const fromBundle = collectionInfo.value?.salesUserName
if (fromBundle) return String(fromBundle)
const id = collectionInfo.value?.salesUserId
const u = salesUserOptions.value.find((x) => x.id === id)
return u?.nickName || dash(id)
})
const regionDisplay = computed(() => {
const fromList = props.listRow?.regionName
if (fromList) return String(fromList)
const fromBundle = collectionInfo.value?.regionName
if (fromBundle) return String(fromBundle)
const id = collectionInfo.value?.regionId
const r = regionOptions.value.find((x) => x.id === id)
return r?.regionName || dash(id)
})
const hospitalDisplay = computed(() => {
const fromList = props.listRow?.hospitalName
if (fromList) return String(fromList)
const fromBundle = collectionInfo.value?.hospitalName
if (fromBundle) return String(fromBundle)
const id = collectionInfo.value?.hospitalId
const h = hospitalOptions.value.find((x) => x.id === id)
return h?.hospitalName || dash(id)
})
function dash(val: unknown) {
return formatDisplayOrDash(val)
}
function formatYmd(val: unknown) {
const s = String(val || '').replace(/-/g, '')
if (s.length === 8) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
return dash(val)
}
function formatInstallmentAmounts(values?: number[] | null) {
if (!values?.length) return '—'
return values.map((v) => fmtMoney(v)).join(' / ')
}
function formatYm(val: unknown) {
const s = String(val || '')
if (s.length === 6) return `${s.slice(0, 4)}-${s.slice(4, 6)}`
return dash(val)
}
function payDetailColorClass(payStatus: string): string {
const s = String(payStatus || '')
if (s === 'ytf') return 'red'
if (s === 'tfqrz') return 'amber'
return 'green'
}
/** 缴费明细是否已完成财务收款确认(jfqrz 仅已提交待财务处理) */
function isReceiptFinanceConfirmed(detail: Record<string, any>) {
const s = String(detail.payStatus || '')
return s !== 'jfqrz' && s !== 'dfj'
}
interface MiniFlowStep {
key: string
label: string
dotClass: string
dotIcon: string
labelClass: string
connectorToNext?: string
hasDetailCard?: boolean
}
function buildMiniFlowSteps(detail: Record<string, any>): MiniFlowStep[] {
const s = String(detail.payStatus || '')
if (s === 'dfj') return []
const steps: MiniFlowStep[] = []
steps.push({
key: 'pay',
label: '缴费确认信息',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: 'done',
hasDetailCard: true
})
if (s === 'jfqrz') {
steps.push({
key: 'receipt-finance',
label: '缴费财务确认信息',
dotClass: 'current-amber',
dotIcon: '●',
labelClass: 'current-amber',
hasDetailCard: true
})
} else {
steps.push({
key: 'receipt-finance',
label: '缴费财务确认信息',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: s === 'yjf' ? undefined : 'done',
hasDetailCard: true
})
}
if (s === 'tfqrz' || s === 'ytf') {
steps.push({
key: 'refund-apply',
label: '退费确认信息',
dotClass: s === 'tfqrz' ? 'current-amber' : 'done',
dotIcon: s === 'tfqrz' ? '●' : '✓',
labelClass: s === 'tfqrz' ? 'current-amber' : 'done',
connectorToNext: s === 'ytf' ? 'done-red' : undefined,
hasDetailCard: true
})
}
if (s === 'ytf') {
steps.push({
key: 'refund-finance',
label: '退费财务确认信息',
dotClass: 'done-red',
dotIcon: '✓',
labelClass: 'done-red',
hasDetailCard: true
})
}
if (steps.length) {
delete steps[steps.length - 1].connectorToNext
}
return steps
}
function miniFlowConnectorStyle(total: number, index: number): string {
const leftPct = ((index + 0.5) / total) * 100
const widthPct = 100 / total
return `left: calc(${leftPct}% + 12px); width: calc(${widthPct}% - 12px);`
}
interface OverviewTimelineStep {
key: string
label: string
subLabel: string
dotClass: string
dotIcon: string
labelClass: string
connectorToNext?: string
}
/** 缴费总览 — 横向时间线节点(每个缴费明细对应一个节点) */
function buildOverviewTimelineSteps(): OverviewTimelineStep[] {
const details = paymentDetails.value
if (!details.length) return []
const steps: OverviewTimelineStep[] = details.map((d, i) => {
const s = String(d.payStatus || '')
const name = isInstallmentPayMode(String(payment.value?.payMode || ''))
? `第${d.installmentNo || i + 1}期`
: `缴费${i + 1}`
if (s === 'ytf') {
return {
key: `ov-${i}`,
label: name,
subLabel: '已退费',
dotClass: 'refund',
dotIcon: '✓',
labelClass: 'refund'
}
}
if (s === 'tfqrz') {
return {
key: `ov-${i}`,
label: name,
subLabel: '退费中',
dotClass: 'refund-current',
dotIcon: '●',
labelClass: 'refund-current',
connectorToNext: 'refund-current'
}
}
if (s === 'yjf') {
return {
key: `ov-${i}`,
label: name,
subLabel: '已缴费',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: 'done'
}
}
if (s === 'jfqrz') {
return {
key: `ov-${i}`,
label: name,
subLabel: '确认中',
dotClass: 'current',
dotIcon: '●',
labelClass: 'done',
connectorToNext: 'current'
}
}
return {
key: `ov-${i}`,
label: name,
subLabel: '待缴费',
dotClass: 'pending',
dotIcon: '○',
labelClass: ''
}
})
// 填充 connectorToNext:下一个节点的连线颜色由本节点的状态决定
for (let i = 0; i < steps.length - 1; i++) {
if (!steps[i].connectorToNext) {
steps[i].connectorToNext = steps[i].dotClass === 'pending' ? '' : 'done'
}
}
return steps
}
function timelineConnectorStyle(total: number, index: number): string {
const leftPct = ((index + 0.5) / total) * 100
const widthPct = 100 / total
return `left: calc(${leftPct}% + 14px); width: calc(${widthPct}% - 14px);`
}
function auditorDisplay(row: Record<string, unknown>) {
const name = String(row.auditorUserName || '').trim()
if (name) return name
return dash(row.auditorUserId)
}
function sampleTypeName(id: string) {
return sampleTypeOptions.value.find((x) => x.id === id)?.typeName || dash(id)
}
function ensureUnitsInOptions(samples: Array<Record<string, any>>) {
const merged = [...unitOptions.value]
for (const s of samples) {
const id = String(s.unitId || '')
if (!id) continue
const name = String(s.unitName || '').trim()
const existing = merged.find((x) => x.id === id)
if (existing) {
if (name && (!existing.unitName || existing.unitName === existing.id)) {
existing.unitName = name
}
continue
}
merged.push({ id, unitName: name || id })
}
unitOptions.value = merged
}
function siteName(id: string) {
return siteOptions.value.find((x) => x.id === id)?.siteName || dash(id)
}
function expressName(id: string) {
return expressCompanyOptions.value.find((x) => x.id === id)?.expressName || dash(id)
}
function sampleTransportExtra(transport: Record<string, any> | undefined) {
if (!transport?.transportType) return '—'
if (transport.transportType === 'kdys') {
const parts: string[] = []
const ec = expressName(String(transport.expressCompanyId || ''))
if (ec !== '—') parts.push(ec)
if (transport.waybillNo) parts.push(String(transport.waybillNo))
if (transport.expressFee != null && transport.expressFee !== '') {
parts.push(`¥${transport.expressFee}`)
}
return parts.length ? parts.join(' · ') : '—'
}
if (transport.transportType === 'ryxd') {
const parts: string[] = []
if (transport.carrierName) parts.push(String(transport.carrierName))
if (transport.carrierPhone) parts.push(String(transport.carrierPhone))
return parts.length ? parts.join(' · ') : '—'
}
return '—'
}
function sampleReceptionAttachmentFiles(reception: Record<string, any>) {
const list = reception?.receiveAttachmentFiles
if (Array.isArray(list) && list.length) return list
const urls = String(reception?.receiveAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `recv_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
}
function detailAttachments(detail: Record<string, any>) {
return Array.isArray(detail.attachments) ? detail.attachments : []
}
function financeConfirmAttachmentFiles(detail: Record<string, any>) {
if (Array.isArray(detail.financeConfirmAttachmentFiles)) {
return detail.financeConfirmAttachmentFiles
}
return urlsToAttachmentFiles(String(detail.financeConfirmAttachments || ''))
}
function refundAttachmentFiles(detail: Record<string, any>) {
if (Array.isArray(detail.refundAttachmentFiles)) {
return detail.refundAttachmentFiles
}
return urlsToAttachmentFiles(String(detail.refundAttachments || ''))
}
function refundFinanceConfirmAttachmentFiles(detail: Record<string, any>) {
if (Array.isArray(detail.refundFinanceConfirmAttachmentFiles)) {
return detail.refundFinanceConfirmAttachmentFiles
}
return urlsToAttachmentFiles(String(detail.refundFinanceConfirmAttachments || ''))
}
function urlsToAttachmentFiles(urls: string) {
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name, fileSize: 0 }
})
.filter(Boolean) as Array<Record<string, any>>
}
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
return suffix
return `${minioBaseUrl}/${suffix}`
}
function isVideoFile(file: { fileType?: string; fileName?: string }) {
const t = (file.fileType || '').toLowerCase()
if (t.startsWith('video/')) return true
return /\.(mp4|webm|ogg|mov)$/i.test(file.fileName || '')
}
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
function openPreview(file: Record<string, any>) {
if (!file.fileUrl) return
previewUrl.value = fullFileUrl(file.fileUrl)
previewType.value = isVideoFile(file) ? 'video' : 'image'
previewVisible.value = true
}
function downloadFile(file: Record<string, any>) {
downloadAttachmentFile(file, { fullFileUrl })
}
async function loadBaseLookups() {
const [stRes, uRes, ecRes, siteRes] = await Promise.all([
fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
fetchItemUnitList({ page: 1, pageSize: 100 }),
fetchExpressCompanyList({ page: 1, pageSize: 100 }),
fetchSiteList({ page: 1, pageSize: 100 })
])
sampleTypeOptions.value = (stRes.list || []) as any
unitOptions.value = (uRes.list || []) as any
expressCompanyOptions.value = ecRes.list || []
siteOptions.value = siteRes.list || []
}
async function loadConditionalLookups(data: Record<string, any>) {
const ci = data.collectionInfo as Record<string, any> | null
const p = data.protocol as Record<string, any> | undefined
const tasks: Promise<void>[] = []
if (ci?.regionId) {
tasks.push(
fetchCollectionRegionList({ page: 1, pageSize: 100 }).then((res) => {
regionOptions.value = res.list || []
})
)
tasks.push(
fetchHospitalList(String(ci.regionId), { page: 1, pageSize: 100 }).then((res) => {
hospitalOptions.value = res.list || []
})
)
}
if (p?.collectorUserId) {
tasks.push(
fetchUserList({ page: 1, pageSize: 50, status: '1' }).then((res) => {
collectorUserOptions.value = res.list || []
})
)
}
if (ci?.salesUserId) {
tasks.push(
fetchUserList({ page: 1, pageSize: 50, status: '1' }).then((res) => {
salesUserOptions.value = res.list || []
})
)
}
if (tasks.length) {
await Promise.all(tasks)
}
}
async function finishLookups(data: Record<string, any>, baseLookupsPromise: Promise<void>) {
lookupLoading.value = true
try {
await baseLookupsPromise
ensureUnitsInOptions((data.samples as Array<Record<string, any>>) || [])
await loadConditionalLookups(data)
} finally {
lookupLoading.value = false
}
}
async function loadAuditRecords() {
if (!props.showAuditHistory || !props.protocolId) {
auditRecords.value = []
return
}
auditLoading.value = true
try {
const res = await fetchCollectionProtocolAuditList({
page: 1,
pageSize: 100,
collectionProtocolId: props.protocolId
})
auditRecords.value = res.list || []
} catch {
auditRecords.value = []
} finally {
auditLoading.value = false
}
}
async function loadDetail() {
if (!props.protocolId) return
detailLoading.value = true
lookupLoading.value = true
bundle.value = null
auditRecords.value = []
const baseLookupsPromise = loadBaseLookups().catch(() => {})
const auditPromise = props.showAuditHistory ? loadAuditRecords() : null
let data: Record<string, any> | null = null
try {
data = (await fetchCollectionProtocolDetail(props.protocolId)) as Record<string, any>
bundle.value = data
} finally {
detailLoading.value = false
}
if (!data) {
lookupLoading.value = false
return
}
void finishLookups(data, baseLookupsPromise)
void auditPromise
}
function scrollToSection(key: string) {
if (detailLoading.value && !bundle.value) return
activeTab.value = key
nextTick(() => {
document
.getElementById(`view-section-${key}`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
function handleClosed() {
bundle.value = null
auditRecords.value = []
activeTab.value = 'basic'
detailLoading.value = false
lookupLoading.value = false
auditLoading.value = false
}
watch(
() => [props.modelValue, props.protocolId] as const,
([visible, id]) => {
if (visible) {
void loadTransferTypeDict()
void loadVoidTypeDict()
}
if (visible && id) loadDetail()
}
)
</script>
<style scoped lang="scss">
.protocol-view-body {
overflow: visible;
&.is-detail-loading {
min-height: min(680px, calc(100vh - 220px));
}
}
/* ---- KPI 摘要条(统一薄荷绿通栏 + 图标徽章) ---- */
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
margin-bottom: 24px;
padding: 6px;
background: linear-gradient(135deg, #e8f4ee 0%, #f8fafc 52%, #e8f4ee 100%);
border: 1px solid #a8d4c4;
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
}
.kpi-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
background: transparent;
border-radius: 8px;
transition: background 0.2s;
&:not(:last-child) {
border-right: 1px solid rgba(168, 212, 196, 0.75);
}
&:hover {
background: rgba(255, 255, 255, 0.55);
}
}
.kpi-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 10px;
background: #fff;
border: 1px solid #a8d4c4;
color: #065f46;
font-size: 18px;
}
.kpi-card__main {
min-width: 0;
flex: 1;
}
.kpi-label {
margin-bottom: 4px;
font-size: 11px;
font-weight: 500;
color: var(--mint-text-muted, #334155);
}
.kpi-value {
font-size: 15px;
font-weight: 600;
color: #0f172a;
display: flex;
align-items: center;
gap: 6px;
&--emphasis {
font-size: 16px;
font-weight: 700;
color: #047857;
letter-spacing: 0.02em;
}
}
/* ---- Section Card ---- */
.section-card {
border-radius: 12px;
padding: 22px 24px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition:
transform 0.2s,
box-shadow 0.2s;
position: relative;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&--white {
background: #fff;
}
&--green {
background: #f0fdf4;
}
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
&::before {
content: '';
width: 3px;
height: 18px;
background: #047857;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1e293b;
line-height: 1.4;
}
&--banner {
margin: -22px -24px 18px;
padding: 10px 24px;
border-bottom: 1px solid rgba(16, 185, 129, 0.12);
border-radius: 12px 12px 0 0;
background: var(--primary-bg, #ecfdf5);
h3 {
color: var(--primary-dark, #059669);
}
}
}
/* ---- Data Grid ---- */
.data-grid {
display: grid;
gap: 14px 20px;
&--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.data-item {
min-width: 0;
&--full {
grid-column: 1 / -1;
}
}
.data-label {
font-size: 12px;
color: var(--mint-text-muted, #334155);
margin-bottom: 3px;
}
.data-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--muted {
color: var(--mint-text-muted, #334155);
font-weight: 400;
}
}
/* 储存证:储存证附件 / 制作状态 / 制作日期同一行(在协议附件行下方) */
.storage-cert-info-row {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.storage-cert-attachments-item {
.storage-cert-attachments-value {
font-weight: 400;
}
:deep(.protocol-attachment-empty) {
font-size: 14px;
color: var(--mint-text-muted, #334155);
}
}
/* 协议附件 / 知情同意书 / 孕妇健康调查表 / 备注(三行堆叠) */
.protocol-basic-attachments-block {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.protocol-attachments-item {
.protocol-attachments-value {
font-weight: 400;
}
:deep(.protocol-attachment-empty) {
font-size: 14px;
color: var(--mint-text-muted, #334155);
}
}
/* ---- Attachment / Remark Row ---- */
.attachment-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 8px;
.data-label {
font-size: 12px;
color: var(--mint-text-muted, #334155);
}
}
.remark-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
/* ---- Status Change Section ---- */
.status-change-section {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.status-change-title {
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
margin-bottom: 12px;
}
/* ---- Client Sub Cards (🤱👨👶) ---- */
.client-sub-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.client-sub-card {
background: #fff;
border-radius: 10px;
padding: 18px 20px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.03);
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
}
}
.sub-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
}
.sub-card-icon {
font-size: 22px;
line-height: 1;
}
.sub-card-full-row {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 2px;
}
/* ---- 审核记录内嵌列表(对齐 OpenSpec 嵌套列表 14px 标准) ---- */
.view-inner-table-wrap.list-body.list-wrap {
margin-top: 4px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: var(--mint-radius, 10px);
overflow: hidden;
background: #fff;
:deep(.el-table) {
--el-table-header-bg-color: var(--mint-table-head, #f8fafc);
--el-table-border-color: var(--mint-border, #e2e8f0);
--el-table-row-hover-bg-color: var(--mint-row-hover, #f0fdf4);
--el-table-text-color: var(--mint-text, #0f172a);
--el-table-header-text-color: var(--mint-text-muted, #64748b);
font-size: 14px;
}
:deep(.el-table th.el-table__cell) {
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
}
:deep(.el-table td.el-table__cell) {
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #0f172a);
}
:deep(.el-empty) {
padding: 24px 0;
}
}
/* ---- Sample Cards ---- */
.sample-cards {
display: flex;
flex-direction: column;
gap: 14px;
}
.sample-card {
background: #fff;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.05);
overflow: hidden;
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
}
.sample-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #f8fafc;
border-bottom: 1px solid #f1f5f9;
}
.sample-card__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.sample-card__seq {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
background: #047857;
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.sample-card__body {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.sample-card__section {
&--transport,
&--reception {
margin-top: 2px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
}
:deep(.sample-card__section--qc) {
margin-top: 2px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
.sample-reception-empty {
font-size: 13px;
color: var(--mint-text-muted, #334155);
}
.check-list {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
}
.check-list-view {
pointer-events: none;
:deep(.el-checkbox.is-disabled) {
opacity: 1;
cursor: default;
.el-checkbox__label {
color: var(--el-text-color-regular);
}
.el-checkbox__input.is-disabled {
opacity: 1;
cursor: default;
.el-checkbox__inner {
background-color: var(--el-checkbox-bg-color);
border-color: var(--el-checkbox-input-border-color);
cursor: default;
}
&.is-checked .el-checkbox__inner {
background-color: var(--el-checkbox-checked-bg-color);
border-color: var(--el-checkbox-checked-input-border-color);
&::after {
border-color: var(--el-checkbox-checked-icon-color);
}
}
}
}
}
.sample-card__attachments {
margin-top: 12px;
}
.sample-card__section-label {
font-size: 11px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
margin-bottom: 10px;
letter-spacing: 0.3px;
}
.data-value--code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
font-size: 13px;
color: var(--mint-text-muted, #334155);
}
.data-value--price {
color: #065f46;
font-weight: 700;
}
/* ---- Payment Summary (Metrics) ---- */
.payment-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 18px;
}
.payment-metric {
background: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
}
.pm-label {
font-size: 11px;
color: var(--mint-text-muted, #334155);
margin-bottom: 4px;
}
.pm-value {
font-size: 16px;
font-weight: 700;
color: #1e293b;
&--amount {
color: #065f46;
}
&--received {
color: #047857;
}
}
/* ---- Payment Detail Cards ---- */
.payment-details {
display: flex;
flex-direction: column;
gap: 16px;
}
.payment-detail-card {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
&--refunded {
border-left: 3px solid #dc2626;
border-color: #e2e8f0;
border-left-color: #dc2626;
}
&--refund-pending {
border-left: 3px solid #d97706;
border-color: #e2e8f0;
border-left-color: #d97706;
}
}
/* ---- PDC Header ---- */
.pdc-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.pdc-header-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
&::before {
content: '';
width: 2px;
height: 14px;
border-radius: 1px;
flex-shrink: 0;
}
&.green::before {
background: #047857;
}
&.red::before {
background: #dc2626;
}
&.amber::before {
background: #d97706;
}
}
.pdc-header-amount {
font-size: 14px;
font-weight: 700;
margin-left: 4px;
&.green {
color: #065f46;
}
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
}
/* ---- PDC Info Row ---- */
.pdc-info-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px 20px;
margin-bottom: 12px;
}
.pdc-info-item {
display: flex;
flex-direction: column;
gap: 2px;
.info-label {
font-size: 11px;
color: var(--mint-text-muted, #334155);
}
.info-value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
}
}
.pdc-attachments-row {
margin-bottom: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 4px;
}
/* ---- Payment Section Subtitle ---- */
.section-subtitle {
margin: 0 0 14px;
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
}
.sample-pay-readonly {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.sample-pay-readonly__card {
padding: 12px 14px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
background: #fff;
}
.sample-pay-readonly__head {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
}
.sample-pay-readonly__amt {
color: #047857;
}
.sample-pay-readonly__sch {
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
}
.sample-pay-readonly__sch-hd {
margin-bottom: 6px;
font-size: 12px;
color: #475569;
}
.sample-pay-readonly__tr {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
padding: 4px 0;
font-size: 12px;
color: #334155;
}
/* ---- Mini Flow ---- */
.mini-flow {
display: flex;
align-items: center;
gap: 0;
margin: 12px 0;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
position: relative;
}
.mini-flow-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
z-index: 1;
}
.mini-flow-dot {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
z-index: 1;
&.done {
background: #047857;
color: #fff;
box-shadow: 0 0 0 3px rgba(4, 120, 87, 0.15);
}
&.done-red {
background: #dc2626;
color: #fff;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.15);
animation: pulseAmber 2s ease-in-out infinite;
}
&.pending-gray {
background: #f1f5f9;
color: #94a3b8;
border: 2px solid #e2e8f0;
}
}
.mini-flow-label {
font-size: 10px;
margin-top: 6px;
text-align: center;
max-width: 96px;
line-height: 1.3;
&.done {
color: #065f46;
font-weight: 600;
}
&.done-red {
color: #dc2626;
font-weight: 600;
}
&.current-amber {
color: #d97706;
font-weight: 600;
}
&.pending-gray {
color: #94a3b8;
}
}
/* ---- Flow Section: track + detail cards ---- */
.flow-section {
margin-top: 12px;
}
.flow-detail-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 14px;
}
.flow-detail-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
&--current-amber {
border-color: #fde68a;
background: #fffbeb;
}
&--done-red {
border-left: 3px solid #dc2626;
background: #fff;
}
&--pending-gray {
border-style: dashed;
background: #f8fafc;
}
}
.flow-detail-card__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
background: #f8fafc;
.flow-detail-card--done-red & {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.flow-detail-card--current-amber & {
background: #fffbeb;
border-bottom-color: #fde68a;
}
.flow-detail-card--pending-gray & {
background: #f1f5f9;
border-bottom-color: #e2e8f0;
}
}
.flow-detail-card__dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
&.done {
background: #047857;
color: #fff;
}
&.done-red {
background: #dc2626;
color: #fff;
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
}
&.pending-gray {
background: #f1f5f9;
border: 2px solid #e2e8f0;
color: #94a3b8;
}
}
.flow-detail-card__title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
&.done {
color: #065f46;
}
&.done-red {
color: #dc2626;
}
&.current-amber {
color: #d97706;
}
&.pending-gray {
color: #94a3b8;
}
}
.flow-detail-card__body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.flow-detail-card__kv {
display: flex;
flex-direction: column;
gap: 2px;
}
.flow-detail-card__label {
font-size: 11px;
color: var(--mint-text-muted, #334155);
}
.flow-detail-card__value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&.red {
color: #dc2626;
font-weight: 700;
}
&.amber {
color: #d97706;
font-weight: 700;
}
&.green {
color: #065f46;
font-weight: 700;
}
}
.flow-detail-card__empty {
font-size: 12px;
color: var(--mint-text-muted, #334155);
font-style: italic;
}
.mini-flow-connector {
position: absolute;
top: 22px;
height: 2px;
z-index: 0;
&.done {
background: #047857;
}
&.done-red {
background: #dc2626;
}
&.current-amber {
background: #d97706;
}
&.pending-gray {
background: #e2e8f0;
}
}
/* ---- Refund Area ---- */
.refund-area {
margin-top: 12px;
padding: 14px 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 10px;
&.red-tint {
background: #fef2f2;
border: 1px solid #fecaca;
}
&.amber-tint {
background: #fffbeb;
border: 1px solid #fde68a;
}
}
.refund-area-title {
font-size: 12px;
font-weight: 600;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 4px;
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
}
.refund-area-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px 20px;
.data-label {
font-size: 11px;
}
.data-value {
font-size: 13px;
&.red {
color: #dc2626;
font-weight: 700;
}
&.amber {
color: #d97706;
font-weight: 700;
}
}
.full-col {
grid-column: 1 / -1;
}
}
/* ---- Finance Area ---- */
.finance-area {
margin-top: 12px;
padding: 12px 16px;
border-radius: 8px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
display: flex;
flex-direction: column;
gap: 8px;
}
.finance-area-title {
font-size: 12px;
font-weight: 600;
color: #065f46;
margin-bottom: 2px;
}
.finance-area-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 20px;
.data-label {
font-size: 11px;
}
.data-value {
font-size: 13px;
}
}
@keyframes pulseAmber {
0%,
100% {
box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.15);
}
50% {
box-shadow: 0 0 0 6px rgba(217, 119, 6, 0.25);
}
}
/* ---- Payment Overview Timeline ---- */
.payment-timeline {
margin-top: 16px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.timeline-title {
font-size: 12px;
color: var(--mint-text-muted, #334155);
font-weight: 600;
margin-bottom: 12px;
}
.timeline {
display: flex;
align-items: center;
gap: 0;
position: relative;
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
}
.timeline-dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
z-index: 1;
transition: transform 0.2s;
&.done {
background: #047857;
color: #fff;
box-shadow: 0 0 0 4px rgba(4, 120, 87, 0.15);
}
&.current {
background: #fff;
border: 2px solid #047857;
color: #047857;
box-shadow: 0 0 0 4px rgba(4, 120, 87, 0.1);
}
&.pending {
background: #f1f5f9;
color: #94a3b8;
border: 2px solid #e2e8f0;
}
&.refund {
background: #dc2626;
color: #fff;
box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.15);
}
&.refund-current {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
box-shadow: 0 0 0 4px rgba(217, 119, 6, 0.15);
animation: pulseAmber 2s ease-in-out infinite;
}
}
.timeline-label {
font-size: 11px;
color: var(--mint-text-muted, #334155);
margin-top: 8px;
text-align: center;
max-width: 80px;
line-height: 1.4;
&.done {
color: #065f46;
font-weight: 600;
}
&.refund {
color: #dc2626;
font-weight: 600;
}
&.refund-current {
color: #d97706;
font-weight: 600;
}
}
.timeline-connector {
position: absolute;
top: 14px;
height: 2px;
background: #e2e8f0;
z-index: 0;
&.done {
background: #047857;
}
&.current {
background: #047857;
}
&.refund {
background: #dc2626;
}
&.refund-current {
background: #d97706;
}
}
/* ---- Preview ---- */
.preview-container {
text-align: center;
}
/* ---- Responsive ---- */
@media (max-width: 1200px) {
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
.kpi-strip .kpi-card {
border-right: none !important;
border-bottom: 1px solid rgba(168, 212, 196, 0.75);
&:nth-last-child(-n + 2) {
border-bottom: none;
}
}
.client-sub-cards {
grid-template-columns: 1fr;
}
.storage-cert-info-row.data-grid--3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
.storage-cert-attachments-item {
grid-column: 1 / -1;
}
}
.protocol-attach-remark-row.data-grid--2 {
grid-template-columns: 1fr;
}
.payment-summary {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 900px) {
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
.kpi-strip .kpi-card:nth-last-child(-n + 2) {
border-bottom: 1px solid rgba(168, 212, 196, 0.75);
}
.kpi-strip .kpi-card:last-child {
border-bottom: none;
}
.client-sub-cards {
grid-template-columns: 1fr;
}
.data-grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.storage-cert-info-row.data-grid--3 {
grid-template-columns: 1fr;
}
.payment-summary {
grid-template-columns: repeat(2, 1fr);
}
.pdc-info-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.refund-area-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.finance-area-grid {
grid-template-columns: 1fr;
}
}
</style>