feat(c4): 质控中心页
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
08a9182352
commit
44386e90ae
153
src/api/qc.ts
Normal file
153
src/api/qc.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { http } from '@/api/http'
|
||||||
|
import type { Elder } from '@/api/elder'
|
||||||
|
import type { Order } from '@/api/order'
|
||||||
|
import type { ChangePlanData, Plan } from '@/api/plan'
|
||||||
|
import type { ServiceRecordDetail } from '@/api/record'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 质控闸门(qc / ho_record_correction)API。
|
||||||
|
* 字段锚点见 shared/data-model spec(qc / ho_record_correction)。
|
||||||
|
* - 时间字段(qcTime/passTime/correctedAt/createdAt/updatedAt)后端经 TransformInterceptor 序列化为毫秒时间戳;
|
||||||
|
* - 待放行队列项含 record 摘要 + correctionCount + focus(补正≥3)+ waitMinutes(已等待分钟数)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 质控结果(qc_result)。 */
|
||||||
|
export const QC_RESULTS = ['pending', 'passed', 'returned'] as const
|
||||||
|
|
||||||
|
/** 质控类型(qc_type)。 */
|
||||||
|
export const QC_TYPES = ['normal', 'recheck'] as const
|
||||||
|
|
||||||
|
export const QC_RESULT_TAG_MAP: Record<string, { label: string; type: 'success' | 'info' | 'warning' | 'danger' | 'primary' }> = {
|
||||||
|
pending: { label: '待质控', type: 'warning' },
|
||||||
|
passed: { label: '已放行', type: 'success' },
|
||||||
|
returned: { label: '已退回', type: 'danger' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 质控记录(qc)。 */
|
||||||
|
export interface QcRecord {
|
||||||
|
id: string
|
||||||
|
tenantCode: string
|
||||||
|
recordId: string
|
||||||
|
qcType: string
|
||||||
|
qcResult: string
|
||||||
|
qcNote: string | null
|
||||||
|
qcStaffId: string | null
|
||||||
|
qcTime: number | null
|
||||||
|
passTime: number | null
|
||||||
|
firstQcId: string | null
|
||||||
|
createdAt: number
|
||||||
|
createdBy: string | null
|
||||||
|
updatedAt: number
|
||||||
|
deletedAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待放行队列项的留痕摘要。 */
|
||||||
|
export interface PendingQcRecordSummary {
|
||||||
|
id: string
|
||||||
|
elderId: string
|
||||||
|
assigneeId: string
|
||||||
|
checkinTime: number | null
|
||||||
|
recordStatus: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待放行队列项:qc + 留痕摘要 + 补正计数/质控重点/已等待。 */
|
||||||
|
export interface PendingQcItem extends QcRecord {
|
||||||
|
record: PendingQcRecordSummary | null
|
||||||
|
correctionCount: number
|
||||||
|
focus: boolean
|
||||||
|
waitMinutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单类待办计数 + 消除条件。 */
|
||||||
|
export interface CountWithCondition {
|
||||||
|
count: number
|
||||||
|
clearCondition: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 住院仍有派单:老人 + 活跃工单。 */
|
||||||
|
export interface HospActiveItem {
|
||||||
|
elder: Elder
|
||||||
|
order: Order
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 五类主动推送聚合结果。 */
|
||||||
|
export interface PendingListResult {
|
||||||
|
abnormalOpen: CountWithCondition
|
||||||
|
overdue: CountWithCondition & { list: Order[] }
|
||||||
|
hospActive: CountWithCondition & { list: HospActiveItem[] }
|
||||||
|
missingPlan: CountWithCondition & { list: Elder[] }
|
||||||
|
pendingQc: CountWithCondition & { list: PendingQcItem[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 补正说明(ho_record_correction)。 */
|
||||||
|
export interface QcCorrection {
|
||||||
|
id: string
|
||||||
|
tenantCode: string
|
||||||
|
recordId: string
|
||||||
|
qcId: string | null
|
||||||
|
correctionNote: string
|
||||||
|
correctedBy: string
|
||||||
|
correctedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 质控详情:qc + 关联留痕全文 + 补正历史。 */
|
||||||
|
export interface QcDetail extends QcRecord {
|
||||||
|
record: ServiceRecordDetail
|
||||||
|
corrections: QcCorrection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退回入参(qc_note 必填)。 */
|
||||||
|
export interface ReturnQcData {
|
||||||
|
qcNote: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 补正入参。 */
|
||||||
|
export interface CorrectData {
|
||||||
|
recordId: string
|
||||||
|
correctionNote: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量放行入参。 */
|
||||||
|
export interface BatchPassData {
|
||||||
|
recordIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常联动改计划入参:复用 plan.change 字段 + abnormalId。 */
|
||||||
|
export interface ExceptionLinkData extends ChangePlanData {
|
||||||
|
abnormalId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 五类待办聚合。 */
|
||||||
|
export function pendingList(signal?: AbortSignal): Promise<PendingListResult> {
|
||||||
|
return http.get<PendingListResult>('/qc/pending/list', { signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 质控详情(留痕全文/项目/照片/异常/定位/补正历史)。 */
|
||||||
|
export function getQcDetail(id: string): Promise<QcDetail> {
|
||||||
|
return http.get<QcDetail>(`/qc/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 放行(后端自检 1004/3001/4001,错误 ApiError.message 直接显示)。 */
|
||||||
|
export function passQc(id: string): Promise<QcRecord> {
|
||||||
|
return http.post<QcRecord>(`/qc/${id}/pass`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退回(qc_note 必填)。 */
|
||||||
|
export function returnQc(id: string, data: ReturnQcData): Promise<QcRecord> {
|
||||||
|
return http.post<QcRecord>(`/qc/${id}/return`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量放行(后端拦不可批量项)。 */
|
||||||
|
export function batchPassQc(data: BatchPassData): Promise<{ passed: number }> {
|
||||||
|
return http.post<{ passed: number }>('/qc/batch-pass', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常联动改计划(复用 plan.change + 回填 link_plan_change)。 */
|
||||||
|
export function exceptionLink(data: ExceptionLinkData): Promise<Plan> {
|
||||||
|
return http.post<Plan>('/qc/exception/link', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 补正(写更正说明 + 生成 recheck qc)。 */
|
||||||
|
export function correctQc(data: CorrectData): Promise<QcRecord> {
|
||||||
|
return http.post<QcRecord>('/qc/correct', data)
|
||||||
|
}
|
||||||
@ -1,28 +1,391 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="qc-page">
|
<div class="qc-page">
|
||||||
|
<!-- ① 待我处理:五类主动推送聚合 -->
|
||||||
<el-card class="mb-md" shadow="never">
|
<el-card class="mb-md" shadow="never">
|
||||||
<ty-search-form :schema="searchSchema" :model-value="query" @search="handleSearch" @reset="handleReset" />
|
<template #header>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span>待我处理</span>
|
||||||
|
<el-button link type="primary" class="ml-xs" :loading="pendingLoading" @click="fetchPending">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="qc-page__cards">
|
||||||
|
<div v-for="card in todoCards" :key="card.key" class="todo-card" @click="card.onClick">
|
||||||
|
<div class="todo-card__count" :class="{ 'todo-card__count--zero': card.count === 0 }">{{ card.count }}</div>
|
||||||
|
<div class="todo-card__label">{{ card.label }}</div>
|
||||||
|
<div class="todo-card__hint">{{ card.clearCondition || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="qc-page__table" shadow="never">
|
<!-- ② 待放行队列 -->
|
||||||
<ty-table
|
<el-card id="qc-pending-queue" class="mb-md" shadow="never">
|
||||||
:columns="columns"
|
<template #header>
|
||||||
:data="list"
|
<div class="flex items-center">
|
||||||
:loading="loading"
|
<span>待放行队列</span>
|
||||||
:total="total"
|
<el-button
|
||||||
v-model:page="page"
|
v-permission="'qc:batch-pass'"
|
||||||
v-model:page-size="pageSize"
|
type="primary"
|
||||||
/>
|
size="small"
|
||||||
|
class="ml-sm"
|
||||||
|
:disabled="selectedRecordIds.length === 0"
|
||||||
|
:loading="batchPassing"
|
||||||
|
@click="confirmBatchPass"
|
||||||
|
>
|
||||||
|
批量放行{{ selectedRecordIds.length ? `(${selectedRecordIds.length})` : '' }}
|
||||||
|
</el-button>
|
||||||
|
<span class="ml-auto text-xs text-text-placeholder">质控重点(补正 ≥3)行高亮;批量放行会拦截异常/未执行/加项/时长过短/已补正记录</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table
|
||||||
|
v-loading="pendingLoading"
|
||||||
|
:data="pendingQcs"
|
||||||
|
border
|
||||||
|
:row-class-name="pendingRowClass"
|
||||||
|
@selection-change="onPendingSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" :selectable="isPendingSelectable" />
|
||||||
|
<el-table-column label="老人" min-width="100">
|
||||||
|
<template #default="{ row }">{{ elderName(row.record?.elderId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="护理员" min-width="100">
|
||||||
|
<template #default="{ row }">{{ staffName(row.record?.assigneeId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="签到时间" width="150">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.record?.checkinTime) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="留痕状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="RECORD_STATUS_TAG_MAP[row.record?.recordStatus]?.type ?? 'info'" size="small">
|
||||||
|
{{ RECORD_STATUS_TAG_MAP[row.record?.recordStatus]?.label ?? row.record?.recordStatus ?? '—' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="补正次数" width="90" align="center">
|
||||||
|
<template #default="{ row }">{{ row.correctionCount }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="质控重点" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.focus" type="danger" size="small">质控重点</el-tag>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已等待" width="110" align="center">
|
||||||
|
<template #default="{ row }">{{ waitLabel(row.waitMinutes) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="220" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openQcDetail(row)">查看</el-button>
|
||||||
|
<el-button v-permission="'qc:pass'" link type="success" :loading="passingId === row.id" @click="doPass(row)">放行</el-button>
|
||||||
|
<el-button v-permission="'qc:return'" link type="warning" @click="openReturn(row)">退回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- ③ 异常处理台 -->
|
||||||
|
<el-card id="qc-abnormal" class="qc-page__abnormal" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span>异常处理台</span>
|
||||||
|
<span class="ml-xs text-xs text-text-placeholder">判断异常需加专项时,可联动变更服务计划</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ty-search-form :schema="abnormalSearchSchema" :model-value="query" @search="handleAbnormalSearch" @reset="handleAbnormalReset" />
|
||||||
|
<ty-table
|
||||||
|
class="mt-sm"
|
||||||
|
:columns="abnormalColumns"
|
||||||
|
:data="abnormalList"
|
||||||
|
:loading="abnormalLoading"
|
||||||
|
:total="abnormalTotal"
|
||||||
|
v-model:page="abnormalPage"
|
||||||
|
v-model:page-size="abnormalPageSize"
|
||||||
|
:actions-width="120"
|
||||||
|
>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<el-button v-permission="'qc:exception-link'" link type="primary" :disabled="row.linkPlanChange" @click="openLink(row)">
|
||||||
|
{{ row.linkPlanChange ? '已联动' : '联动改计划' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</ty-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 质控详情 -->
|
||||||
|
<el-dialog v-model="detailVisible" title="质控详情" width="860px">
|
||||||
|
<div v-loading="detailLoading" class="qc-page__detail">
|
||||||
|
<template v-if="detail">
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="质控结果">
|
||||||
|
<el-tag :type="QC_RESULT_TAG_MAP[detail.qcResult]?.type ?? 'info'" size="small">
|
||||||
|
{{ QC_RESULT_TAG_MAP[detail.qcResult]?.label ?? detail.qcResult }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="退回原因">{{ detail.qcNote ?? '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="老人">{{ elderName(detail.record.elderId) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="护理员">{{ staffName(detail.record.assigneeId) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="签到时间">{{ formatDateTime(detail.record.checkinTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="签退时间">{{ formatDateTime(detail.record.checkoutTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务时长">{{ durationLabel(detail.record.durationMin) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="垫付标记">
|
||||||
|
<el-tag v-if="detail.record.isPrepaid" type="warning" size="small">垫付</el-tag>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="是否计费">{{ detail.record.billable ? '是' : '否' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="离线补传">{{ detail.record.offlineFlag ? '是' : '否' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="提交时间">{{ formatDateTime(detail.record.submitTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="定位">
|
||||||
|
{{ LOCATE_STATUS_TAG_MAP[detail.record.locateStatus]?.label ?? detail.record.locateStatus }}
|
||||||
|
<span class="text-xs text-text-placeholder">({{ fmtCoord(detail.record.checkinLat) }}, {{ fmtCoord(detail.record.checkinLng) }})</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="工单 ID">{{ detail.record.orderId }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-divider content-position="left">项目执行</el-divider>
|
||||||
|
<el-table v-if="detail.record.items.length > 0" :data="detail.record.items" border size="small">
|
||||||
|
<el-table-column prop="svcNameSnapshot" label="项目" min-width="160" />
|
||||||
|
<el-table-column label="执行结果" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="EXEC_RESULT_TAG_MAP[row.execResult]?.type ?? 'info'" size="small">
|
||||||
|
{{ EXEC_RESULT_TAG_MAP[row.execResult]?.label ?? row.execResult }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="未执行原因" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.undoReason ?? '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="附加项" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ row.extraFlag ? '是' : '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="必拍" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ row.photoRequired ? '是' : '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="拒拍" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.isRefusedPhoto">是({{ row.refuseReason ?? '-' }})</template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无项目执行明细" :image-size="60" />
|
||||||
|
|
||||||
|
<el-divider content-position="left">照片</el-divider>
|
||||||
|
<el-table v-if="detail.record.photos.length > 0" :data="detail.record.photos" border size="small">
|
||||||
|
<el-table-column prop="filePath" label="文件 Key(MinIO)" min-width="280" />
|
||||||
|
<el-table-column prop="fileSize" label="大小(字节)" width="120" align="right" />
|
||||||
|
<el-table-column label="上传时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无照片" :image-size="60" />
|
||||||
|
|
||||||
|
<el-divider content-position="left">异常上报</el-divider>
|
||||||
|
<el-table v-if="detail.record.abnormals.length > 0" :data="detail.record.abnormals" border size="small">
|
||||||
|
<el-table-column label="分类" width="130">
|
||||||
|
<template #default="{ row }">{{ ABNORMAL_TYPE_LABEL_MAP[row.abnormalType] ?? row.abnormalType }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="描述" min-width="180">
|
||||||
|
<template #default="{ row }">{{ row.abnormalDesc ?? '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="照片" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ row.hasPhoto ? '有' : '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="上报时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无异常上报" :image-size="60" />
|
||||||
|
|
||||||
|
<el-divider content-position="left">补正历史</el-divider>
|
||||||
|
<el-table v-if="detail.corrections.length > 0" :data="detail.corrections" border size="small">
|
||||||
|
<el-table-column label="补正说明" min-width="240">
|
||||||
|
<template #default="{ row }">{{ row.correctionNote }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="补正时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.correctedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无补正" :image-size="60" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="detailVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 退回留痕 -->
|
||||||
|
<el-dialog v-model="returnVisible" title="退回留痕" width="480px">
|
||||||
|
<el-form :model="returnForm" label-width="90px">
|
||||||
|
<el-form-item label="退回原因" required>
|
||||||
|
<el-select v-model="returnForm.qcNote" filterable allow-create default-first-option placeholder="选择或输入原因" style="width: 100%">
|
||||||
|
<el-option v-for="r in RETURN_REASON_OPTIONS" :key="r" :label="r" :value="r" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="补充说明">
|
||||||
|
<el-input v-model="returnForm.extra" type="textarea" :rows="3" placeholder="选填,附在原因后一并写回" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="returnVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="returning" @click="confirmReturn">确认退回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 异常联动改计划 -->
|
||||||
|
<el-dialog v-model="linkVisible" title="异常联动改计划" width="860px">
|
||||||
|
<el-alert v-if="linkAbnormal" class="mb-sm" type="info" :closable="false" :title="`关联老人:${elderName(linkAbnormal.elderId)} · 异常分类:${ABNORMAL_TYPE_LABEL_MAP[linkAbnormal.abnormalType] ?? linkAbnormal.abnormalType}`" />
|
||||||
|
<el-form :model="linkForm" label-width="110px">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="原计划" required>
|
||||||
|
<el-select v-model="linkForm.changeFromPlanId" filterable placeholder="选择被变更的旧计划" style="width: 100%" :loading="linkPlansLoading" @change="onLinkPlanChange">
|
||||||
|
<el-option v-for="p in linkPlans" :key="p.id" :label="linkPlanLabel(p)" :value="p.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="护理等级" required>
|
||||||
|
<el-select v-model="linkForm.careLevel" placeholder="请选择" style="width: 100%">
|
||||||
|
<el-option v-for="o in CARE_LEVEL_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="护理方式" required>
|
||||||
|
<el-select v-model="linkForm.careMode" placeholder="请选择" style="width: 100%">
|
||||||
|
<el-option v-for="o in CARE_MODE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="周期起" required>
|
||||||
|
<el-date-picker v-model="linkForm.planPeriodStart" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="周期止" required>
|
||||||
|
<el-date-picker v-model="linkForm.planPeriodEnd" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<div class="mb-sm flex items-center">
|
||||||
|
<el-button link type="primary" :loading="linkLibLoading" @click="linkBringFromLibrary">从个人项目库带入</el-button>
|
||||||
|
<span class="ml-xs text-xs text-text-placeholder">带入后可修改每项频次/时长/拍照档位</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="linkForm.items" border size="small">
|
||||||
|
<el-table-column label="项目名称" min-width="180">
|
||||||
|
<template #default="{ row }">{{ row.name }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="频次类型" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select v-model="row.freqType" placeholder="按需" clearable size="small">
|
||||||
|
<el-option v-for="o in FREQ_TYPE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="频次值" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input-number v-model="row.freqValue" :min="0" :controls="false" size="small" placeholder="次数" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时长(分)" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input-number v-model="row.durationMin" :min="0" :controls="false" size="small" placeholder="分钟" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="拍照档位" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select v-model="row.photoTier" size="small">
|
||||||
|
<el-option v-for="o in PHOTO_TIER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="注意事项" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-model="row.cautionNote" size="small" placeholder="选填" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="70" align="center">
|
||||||
|
<template #default="{ $index }">
|
||||||
|
<el-button link type="danger" @click="linkForm.items.splice($index, 1)">移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="linkForm.items.length === 0" description="暂无计划项,请从个人项目库带入" :image-size="60" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="linkVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="linkSubmitting" @click="submitLink">联动改计划</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 待办明细(逾期/住院/计划) -->
|
||||||
|
<el-dialog v-model="todoListVisible" :title="todoTitle" width="720px">
|
||||||
|
<template v-if="todoActive === 'overdue'">
|
||||||
|
<el-table :data="todoListData" border size="small">
|
||||||
|
<el-table-column label="老人" min-width="100">
|
||||||
|
<template #default="{ row }">{{ elderName(row.elderId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时段" min-width="160">
|
||||||
|
<template #default="{ row }">{{ timeRangeLabel(row.timeSlotStart, row.timeSlotEnd) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="工单状态" width="110" align="center">
|
||||||
|
<template #default="{ row }">{{ row.orderStatus }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="todoActive === 'hospActive'">
|
||||||
|
<el-table :data="todoListData" border size="small">
|
||||||
|
<el-table-column label="老人" min-width="100">
|
||||||
|
<template #default="{ row }">{{ row.elder?.name ?? elderName(row.elder?.id) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="护理员" min-width="100">
|
||||||
|
<template #default="{ row }">{{ staffName(row.order?.assigneeId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="工单状态" width="110" align="center">
|
||||||
|
<template #default="{ row }">{{ row.order?.orderStatus }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-table :data="todoListData" border size="small">
|
||||||
|
<el-table-column label="老人" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.name }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="待遇生效" width="120">
|
||||||
|
<template #default="{ row }">{{ formatDate(row.benefitStart) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="待遇状态" width="110" align="center">
|
||||||
|
<template #default="{ row }">{{ row.treatStatus }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<el-empty v-if="todoListData.length === 0" description="暂无待办" :image-size="60" />
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="todoListVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { ApiError } from '@/api/http'
|
||||||
import type { JsonSchema, TableColumn } from '@/types/crud'
|
import type { JsonSchema, TableColumn } from '@/types/crud'
|
||||||
import { useRequest } from '@/composables/useRequest'
|
import { useRequest } from '@/composables/useRequest'
|
||||||
import { listElders, type Elder } from '@/api/elder'
|
import { listElders, type Elder } from '@/api/elder'
|
||||||
|
import { listStaff, type Staff } from '@/api/staff'
|
||||||
|
import { getElderSvc } from '@/api/elder-svc'
|
||||||
|
import { getPlan, listPlansByElder, type Plan } from '@/api/plan'
|
||||||
import {
|
import {
|
||||||
|
EXEC_RESULT_TAG_MAP,
|
||||||
|
LOCATE_STATUS_TAG_MAP,
|
||||||
|
RECORD_STATUS_TAG_MAP,
|
||||||
|
} from '@/api/record'
|
||||||
|
import {
|
||||||
|
ABNORMAL_TYPE_LABEL_MAP,
|
||||||
|
ABNORMAL_TYPE_OPTIONS,
|
||||||
HANDLE_STATUS_OPTIONS,
|
HANDLE_STATUS_OPTIONS,
|
||||||
HANDLE_STATUS_TAG_MAP,
|
HANDLE_STATUS_TAG_MAP,
|
||||||
NOTIFY_STATUS_OPTIONS,
|
NOTIFY_STATUS_OPTIONS,
|
||||||
@ -31,30 +394,273 @@ import {
|
|||||||
type ListAbnormalQuery,
|
type ListAbnormalQuery,
|
||||||
type RecordAbnormal,
|
type RecordAbnormal,
|
||||||
} from '@/api/abnormal'
|
} from '@/api/abnormal'
|
||||||
|
import {
|
||||||
|
QC_RESULT_TAG_MAP,
|
||||||
|
batchPassQc,
|
||||||
|
exceptionLink,
|
||||||
|
getQcDetail,
|
||||||
|
passQc,
|
||||||
|
pendingList,
|
||||||
|
returnQc,
|
||||||
|
type PendingQcItem,
|
||||||
|
type QcDetail,
|
||||||
|
} from '@/api/qc'
|
||||||
|
import { CARE_LEVEL_OPTIONS, CARE_MODE_OPTIONS, formatDate, formatDateTime } from '@/pages/ltc/elder/constants'
|
||||||
|
|
||||||
defineOptions({ name: 'qc' })
|
defineOptions({ name: 'qc' })
|
||||||
|
|
||||||
const columns: TableColumn[] = [
|
const RETURN_REASON_OPTIONS = ['项目不符', '时长异常', '照片缺失', '定位异常', '信息不全', '其他']
|
||||||
{ prop: 'abnormalType', label: '分类', width: 120, align: 'center', type: 'tag', dictCode: 'abnormal_type' },
|
|
||||||
|
const FREQ_TYPE_OPTIONS = [
|
||||||
|
{ label: '每日', value: 'daily' },
|
||||||
|
{ label: '每周N次', value: 'weekly_n' },
|
||||||
|
{ label: '每月N次', value: 'monthly_n' },
|
||||||
|
{ label: '按需', value: 'as_needed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PHOTO_TIER_OPTIONS = [
|
||||||
|
{ label: '必须拍照', value: 'must' },
|
||||||
|
{ label: '可选拍照', value: 'optional' },
|
||||||
|
{ label: '免拍照', value: 'free' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---- 名称映射 ----
|
||||||
|
const elders = ref<Elder[]>([])
|
||||||
|
const staffList = ref<Staff[]>([])
|
||||||
|
const elderNameMap = ref<Map<string, string>>(new Map())
|
||||||
|
const staffNameMap = ref<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
function elderName(id?: string | null): string {
|
||||||
|
if (!id) return '—'
|
||||||
|
return elderNameMap.value.get(id) ?? id
|
||||||
|
}
|
||||||
|
|
||||||
|
function staffName(id?: string | null): string {
|
||||||
|
if (!id) return '—'
|
||||||
|
return staffNameMap.value.get(id) ?? id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMaps(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [elderRes, staffRes] = await Promise.all([
|
||||||
|
listElders({ page: 1, pageSize: 200 }),
|
||||||
|
listStaff({ page: 1, pageSize: 200 }),
|
||||||
|
])
|
||||||
|
elders.value = elderRes.list
|
||||||
|
staffList.value = staffRes.list
|
||||||
|
elderNameMap.value = new Map(elderRes.list.map((e) => [e.id, e.name]))
|
||||||
|
staffNameMap.value = new Map(staffRes.list.map((s) => [s.id, s.name]))
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ① 待我处理 ----
|
||||||
|
const { data: pendingData, loading: pendingLoading, run: fetchPending } = useRequest(
|
||||||
|
(signal: AbortSignal) => pendingList(signal),
|
||||||
|
{ manual: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const pendingQcs = computed(() => pendingData.value?.pendingQc.list ?? [])
|
||||||
|
|
||||||
|
type TodoListKey = 'overdue' | 'hospActive' | 'missingPlan'
|
||||||
|
|
||||||
|
const todoListVisible = ref(false)
|
||||||
|
const todoActive = ref<TodoListKey | null>(null)
|
||||||
|
|
||||||
|
const todoTitle = computed(() => {
|
||||||
|
const map: Record<TodoListKey, string> = { overdue: '逾期未做', hospActive: '住院仍有派单', missingPlan: '计划未制定' }
|
||||||
|
return todoActive.value ? map[todoActive.value] : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const todoListData = computed<Record<string, any>[]>(() => {
|
||||||
|
const d = pendingData.value
|
||||||
|
if (!d || !todoActive.value) return []
|
||||||
|
if (todoActive.value === 'overdue') return d.overdue.list
|
||||||
|
if (todoActive.value === 'hospActive') return d.hospActive.list
|
||||||
|
return d.missingPlan.list
|
||||||
|
})
|
||||||
|
|
||||||
|
function scrollTo(id: string): void {
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goAbnormal(): void {
|
||||||
|
if (query.handleStatus !== 'open') {
|
||||||
|
query.handleStatus = 'open'
|
||||||
|
if (abnormalPage.value === 1) fetchAbnormal()
|
||||||
|
else abnormalPage.value = 1
|
||||||
|
}
|
||||||
|
scrollTo('qc-abnormal')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTodoList(key: TodoListKey): void {
|
||||||
|
todoActive.value = key
|
||||||
|
todoListVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoCards = computed(() => {
|
||||||
|
const d = pendingData.value
|
||||||
|
return [
|
||||||
|
{ key: 'abnormalOpen', label: '异常待处理', count: d?.abnormalOpen.count ?? 0, clearCondition: d?.abnormalOpen.clearCondition ?? '', onClick: goAbnormal },
|
||||||
|
{ key: 'overdue', label: '逾期未做', count: d?.overdue.count ?? 0, clearCondition: d?.overdue.clearCondition ?? '', onClick: () => openTodoList('overdue') },
|
||||||
|
{ key: 'hospActive', label: '住院仍有派单', count: d?.hospActive.count ?? 0, clearCondition: d?.hospActive.clearCondition ?? '', onClick: () => openTodoList('hospActive') },
|
||||||
|
{ key: 'missingPlan', label: '计划未制定', count: d?.missingPlan.count ?? 0, clearCondition: d?.missingPlan.clearCondition ?? '', onClick: () => openTodoList('missingPlan') },
|
||||||
|
{ key: 'pendingQc', label: '留痕待放行', count: d?.pendingQc.count ?? 0, clearCondition: d?.pendingQc.clearCondition ?? '', onClick: () => scrollTo('qc-pending-queue') },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- ② 待放行队列 ----
|
||||||
|
const selectedPending = ref<PendingQcItem[]>([])
|
||||||
|
const selectedRecordIds = computed(() => selectedPending.value.map((r) => r.record?.id).filter((id): id is string => !!id))
|
||||||
|
const batchPassing = ref(false)
|
||||||
|
const passingId = ref('')
|
||||||
|
|
||||||
|
function pendingRowClass({ row }: { row: PendingQcItem; rowIndex: number }): string {
|
||||||
|
return row.focus ? 'qc-page__row--focus' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPendingSelectable(row: PendingQcItem): boolean {
|
||||||
|
return row.record != null && row.correctionCount === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPendingSelectionChange(rows: PendingQcItem[]): void {
|
||||||
|
selectedPending.value = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitLabel(min: number): string {
|
||||||
|
if (min < 60) return `${min} 分钟`
|
||||||
|
const h = Math.floor(min / 60)
|
||||||
|
const m = min % 60
|
||||||
|
return m > 0 ? `${h} 小时 ${m} 分` : `${h} 小时`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doPass(row: Record<string, any>): Promise<void> {
|
||||||
|
const item = row as unknown as PendingQcItem
|
||||||
|
passingId.value = item.id
|
||||||
|
try {
|
||||||
|
await passQc(item.id)
|
||||||
|
ElMessage.success('已放行')
|
||||||
|
void fetchPending()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
passingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBatchPass(): Promise<void> {
|
||||||
|
if (selectedRecordIds.value.length === 0) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认批量放行 ${selectedRecordIds.value.length} 条留痕?含异常/未执行/临时加项/时长过短/已补正的记录将被后端拦截,须逐条审核。`,
|
||||||
|
'批量放行',
|
||||||
|
{ type: 'warning' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchPassing.value = true
|
||||||
|
try {
|
||||||
|
const res = await batchPassQc({ recordIds: selectedRecordIds.value })
|
||||||
|
ElMessage.success(`已放行 ${res.passed} 条`)
|
||||||
|
void fetchPending()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
batchPassing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 质控详情 ----
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detail = ref<QcDetail | null>(null)
|
||||||
|
|
||||||
|
function durationLabel(min: number | null): string {
|
||||||
|
return min == null ? '-' : `${min} 分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCoord(v: unknown): string {
|
||||||
|
if (v == null) return '-'
|
||||||
|
if (typeof v === 'number') return String(v)
|
||||||
|
if (typeof v === 'string') return v === '' ? '-' : v
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openQcDetail(row: Record<string, any>): Promise<void> {
|
||||||
|
const item = row as unknown as PendingQcItem
|
||||||
|
detailVisible.value = true
|
||||||
|
detail.value = null
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
detail.value = await getQcDetail(item.id)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 退回 ----
|
||||||
|
const returnVisible = ref(false)
|
||||||
|
const returning = ref(false)
|
||||||
|
const returnQcId = ref('')
|
||||||
|
const returnForm = reactive({ qcNote: '', extra: '' })
|
||||||
|
|
||||||
|
function openReturn(row: Record<string, any>): void {
|
||||||
|
const item = row as unknown as PendingQcItem
|
||||||
|
returnQcId.value = item.id
|
||||||
|
returnForm.qcNote = ''
|
||||||
|
returnForm.extra = ''
|
||||||
|
returnVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReturn(): Promise<void> {
|
||||||
|
const note = returnForm.qcNote.trim()
|
||||||
|
if (!note) {
|
||||||
|
ElMessage.warning('请选择或填写退回原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const extra = returnForm.extra.trim()
|
||||||
|
const qcNote = extra ? `${note}:${extra}` : note
|
||||||
|
returning.value = true
|
||||||
|
try {
|
||||||
|
await returnQc(returnQcId.value, { qcNote: qcNote.slice(0, 200) })
|
||||||
|
ElMessage.success('已退回')
|
||||||
|
returnVisible.value = false
|
||||||
|
void fetchPending()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
returning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ③ 异常处理台 ----
|
||||||
|
const abnormalColumns: TableColumn[] = [
|
||||||
|
{ prop: 'abnormalType', label: '分类', width: 120, align: 'center', formatter: (v) => ABNORMAL_TYPE_LABEL_MAP[String(v ?? '')] ?? String(v ?? '') },
|
||||||
{ prop: 'elderId', label: '关联老人', minWidth: 100, formatter: (v) => elderName(String(v)) },
|
{ prop: 'elderId', label: '关联老人', minWidth: 100, formatter: (v) => elderName(String(v)) },
|
||||||
{ prop: 'abnormalDesc', label: '描述', minWidth: 180, formatter: (v) => (v ? String(v) : '—') },
|
{ prop: 'abnormalDesc', label: '描述', minWidth: 180, formatter: (v) => (v ? String(v) : '—') },
|
||||||
{ prop: 'hasPhoto', label: '照片', width: 70, align: 'center', formatter: (v) => (v ? '有' : '—') },
|
{ prop: 'hasPhoto', label: '照片', width: 70, align: 'center', formatter: (v) => (v ? '有' : '—') },
|
||||||
{ prop: 'voiceFile', label: '语音', width: 70, align: 'center', formatter: (v) => (v ? '有' : '—') },
|
{ prop: 'voiceFile', label: '语音', width: 70, align: 'center', formatter: (v) => (v ? '有' : '—') },
|
||||||
{ prop: 'notifyStatus', label: '通知状态', width: 100, align: 'center', type: 'tag', tagMap: NOTIFY_STATUS_TAG_MAP },
|
{ prop: 'notifyStatus', label: '通知状态', width: 100, align: 'center', type: 'tag', tagMap: NOTIFY_STATUS_TAG_MAP },
|
||||||
{ prop: 'handleStatus', label: '处理状态', width: 100, align: 'center', type: 'tag', tagMap: HANDLE_STATUS_TAG_MAP },
|
{ prop: 'handleStatus', label: '处理状态', width: 100, align: 'center', type: 'tag', tagMap: HANDLE_STATUS_TAG_MAP },
|
||||||
|
{ prop: 'linkPlanChange', label: '联动', width: 80, align: 'center', formatter: (v) => (v ? '已联动' : '—') },
|
||||||
{ prop: 'visitTime', label: '到访时间', width: 150, type: 'datetime' },
|
{ prop: 'visitTime', label: '到访时间', width: 150, type: 'datetime' },
|
||||||
{ prop: 'createdAt', label: '上报时间', width: 150, type: 'datetime' },
|
{ prop: 'createdAt', label: '上报时间', width: 150, type: 'datetime' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const elders = ref<Elder[]>([])
|
const abnormalSearchSchema = computed<JsonSchema>(() => ({
|
||||||
const elderNameMap = ref<Map<string, string>>(new Map())
|
|
||||||
|
|
||||||
const elderOptions = computed(() => elders.value.map((e) => ({ label: e.name, value: e.id })))
|
|
||||||
|
|
||||||
const searchSchema = computed<JsonSchema>(() => ({
|
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
abnormalType: { type: 'string', title: '异常分类', placeholder: '全部', dictCode: 'abnormal_type' },
|
abnormalType: {
|
||||||
|
type: 'string',
|
||||||
|
title: '异常分类',
|
||||||
|
placeholder: '全部',
|
||||||
|
enum: ABNORMAL_TYPE_OPTIONS.map((o) => o.value),
|
||||||
|
enumNames: ABNORMAL_TYPE_OPTIONS.map((o) => o.label),
|
||||||
|
},
|
||||||
notifyStatus: {
|
notifyStatus: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
title: '通知状态',
|
title: '通知状态',
|
||||||
@ -73,24 +679,24 @@ const searchSchema = computed<JsonSchema>(() => ({
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
title: '老人',
|
title: '老人',
|
||||||
placeholder: '全部',
|
placeholder: '全部',
|
||||||
enum: elderOptions.value.map((o) => o.value),
|
enum: elders.value.map((e) => e.id),
|
||||||
enumNames: elderOptions.value.map((o) => o.label),
|
enumNames: elders.value.map((e) => e.name),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const query = reactive<Record<string, any>>({ abnormalType: '', notifyStatus: '', handleStatus: '', elderId: '' })
|
const query = reactive<Record<string, any>>({ abnormalType: '', notifyStatus: '', handleStatus: 'open', elderId: '' })
|
||||||
const list = ref<RecordAbnormal[]>([])
|
const abnormalList = ref<RecordAbnormal[]>([])
|
||||||
const total = ref(0)
|
const abnormalTotal = ref(0)
|
||||||
const page = ref(1)
|
const abnormalPage = ref(1)
|
||||||
const pageSize = ref(10)
|
const abnormalPageSize = ref(10)
|
||||||
|
|
||||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
|
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
|
||||||
|
|
||||||
function toListQuery(): ListAbnormalQuery {
|
function toAbnormalQuery(): ListAbnormalQuery {
|
||||||
return {
|
return {
|
||||||
page: page.value,
|
page: abnormalPage.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: abnormalPageSize.value,
|
||||||
abnormalType: str(query.abnormalType),
|
abnormalType: str(query.abnormalType),
|
||||||
notifyStatus: str(query.notifyStatus),
|
notifyStatus: str(query.notifyStatus),
|
||||||
handleStatus: str(query.handleStatus),
|
handleStatus: str(query.handleStatus),
|
||||||
@ -98,52 +704,258 @@ function toListQuery(): ListAbnormalQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { loading, run: fetchList } = useRequest((signal: AbortSignal) => listAbnormals(toListQuery(), signal), {
|
const { loading: abnormalLoading, run: fetchAbnormal } = useRequest(
|
||||||
|
(signal: AbortSignal) => listAbnormals(toAbnormalQuery(), signal),
|
||||||
|
{
|
||||||
manual: true,
|
manual: true,
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
list.value = result.list
|
abnormalList.value = result.list
|
||||||
total.value = result.total
|
abnormalTotal.value = result.total
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch([abnormalPage, abnormalPageSize], () => fetchAbnormal())
|
||||||
|
|
||||||
|
function handleAbnormalSearch(): void {
|
||||||
|
if (abnormalPage.value === 1) fetchAbnormal()
|
||||||
|
else abnormalPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAbnormalReset(): void {
|
||||||
|
if (abnormalPage.value === 1) fetchAbnormal()
|
||||||
|
else abnormalPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 异常联动改计划 ----
|
||||||
|
interface LinkItemDraft {
|
||||||
|
svcId: string
|
||||||
|
name: string
|
||||||
|
freqType?: string
|
||||||
|
freqValue?: number
|
||||||
|
durationMin?: number
|
||||||
|
photoTier?: string
|
||||||
|
cautionNote?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkVisible = ref(false)
|
||||||
|
const linkSubmitting = ref(false)
|
||||||
|
const linkLibLoading = ref(false)
|
||||||
|
const linkPlansLoading = ref(false)
|
||||||
|
const linkAbnormal = ref<RecordAbnormal | null>(null)
|
||||||
|
const linkPlans = ref<Plan[]>([])
|
||||||
|
const linkForm = reactive<{
|
||||||
|
changeFromPlanId: string
|
||||||
|
careLevel: string
|
||||||
|
careMode: string
|
||||||
|
planPeriodStart: string
|
||||||
|
planPeriodEnd: string
|
||||||
|
items: LinkItemDraft[]
|
||||||
|
}>({
|
||||||
|
changeFromPlanId: '',
|
||||||
|
careLevel: '',
|
||||||
|
careMode: '',
|
||||||
|
planPeriodStart: '',
|
||||||
|
planPeriodEnd: '',
|
||||||
|
items: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([page, pageSize], () => fetchList())
|
function toDateStr(ms?: number | null): string {
|
||||||
|
if (ms == null) return ''
|
||||||
function handleSearch(): void {
|
return formatDate(ms)
|
||||||
if (page.value === 1) fetchList()
|
|
||||||
else page.value = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset(): void {
|
function linkPlanLabel(p: Plan): string {
|
||||||
if (page.value === 1) fetchList()
|
const start = formatDate(p.planPeriodStart)
|
||||||
else page.value = 1
|
const end = formatDate(p.planPeriodEnd)
|
||||||
|
return `${start} ~ ${end}(${p.planStatus})`
|
||||||
}
|
}
|
||||||
|
|
||||||
function elderName(id: string): string {
|
async function loadLinkPlans(elderId: string): Promise<void> {
|
||||||
return elderNameMap.value.get(id) ?? id
|
linkPlansLoading.value = true
|
||||||
|
try {
|
||||||
|
linkPlans.value = await listPlansByElder(elderId)
|
||||||
|
if (linkPlans.value.length === 1) await onLinkPlanChange(linkPlans.value[0].id)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
linkPlansLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadElders(): Promise<void> {
|
async function onLinkPlanChange(planId: string): Promise<void> {
|
||||||
const result = await listElders({ page: 1, pageSize: 200 })
|
if (!planId) return
|
||||||
elders.value = result.list
|
try {
|
||||||
elderNameMap.value = new Map(result.list.map((e) => [e.id, e.name]))
|
const plan = await getPlan(planId)
|
||||||
|
linkForm.careLevel = plan.careLevel
|
||||||
|
linkForm.careMode = plan.careMode
|
||||||
|
linkForm.planPeriodStart = toDateStr(plan.planPeriodStart)
|
||||||
|
linkForm.planPeriodEnd = toDateStr(plan.planPeriodEnd)
|
||||||
|
linkForm.items = plan.items.map((it) => ({
|
||||||
|
svcId: it.svcId,
|
||||||
|
name: it.svcNameSnapshot,
|
||||||
|
freqType: it.freqType ?? undefined,
|
||||||
|
freqValue: it.freqValue ?? undefined,
|
||||||
|
durationMin: it.durationMin ?? undefined,
|
||||||
|
photoTier: it.photoTier || 'free',
|
||||||
|
cautionNote: it.cautionNote ?? undefined,
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink(row: Record<string, any>): void {
|
||||||
|
const ab = row as unknown as RecordAbnormal
|
||||||
|
linkAbnormal.value = ab
|
||||||
|
linkForm.changeFromPlanId = ''
|
||||||
|
linkForm.careLevel = ''
|
||||||
|
linkForm.careMode = ''
|
||||||
|
linkForm.planPeriodStart = ''
|
||||||
|
linkForm.planPeriodEnd = ''
|
||||||
|
linkForm.items = []
|
||||||
|
linkVisible.value = true
|
||||||
|
void loadLinkPlans(ab.elderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkBringFromLibrary(): Promise<void> {
|
||||||
|
const ab = linkAbnormal.value
|
||||||
|
if (!ab) return
|
||||||
|
linkLibLoading.value = true
|
||||||
|
try {
|
||||||
|
const lib = await getElderSvc(ab.elderId)
|
||||||
|
if (lib.length === 0) {
|
||||||
|
ElMessage.warning('该老人暂无个人项目库,请先在服务对象页维护')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!linkForm.careMode && lib[0]?.careMode) linkForm.careMode = lib[0].careMode
|
||||||
|
const existing = new Set(linkForm.items.map((i) => i.svcId))
|
||||||
|
for (const it of lib) {
|
||||||
|
if (existing.has(it.svcId)) continue
|
||||||
|
linkForm.items.push({ svcId: it.svcId, name: it.svcNameSnapshot, photoTier: 'free' })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
linkLibLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLink(): Promise<void> {
|
||||||
|
const ab = linkAbnormal.value
|
||||||
|
if (!ab) return
|
||||||
|
if (!linkForm.changeFromPlanId) {
|
||||||
|
ElMessage.warning('请选择原计划')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!linkForm.careLevel) {
|
||||||
|
ElMessage.warning('请选择护理等级')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!linkForm.careMode) {
|
||||||
|
ElMessage.warning('请选择护理方式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!linkForm.planPeriodStart || !linkForm.planPeriodEnd) {
|
||||||
|
ElMessage.warning('请选择计划周期')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (linkForm.planPeriodEnd <= linkForm.planPeriodStart) {
|
||||||
|
ElMessage.warning('周期止须晚于周期起')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (linkForm.items.length === 0) {
|
||||||
|
ElMessage.warning('请先带入计划项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
linkSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await exceptionLink({
|
||||||
|
abnormalId: ab.id,
|
||||||
|
changeFromPlanId: linkForm.changeFromPlanId,
|
||||||
|
elderId: ab.elderId,
|
||||||
|
planPeriodStart: linkForm.planPeriodStart,
|
||||||
|
planPeriodEnd: linkForm.planPeriodEnd,
|
||||||
|
careLevel: linkForm.careLevel,
|
||||||
|
careMode: linkForm.careMode,
|
||||||
|
items: linkForm.items.map((i) => ({
|
||||||
|
svcId: i.svcId,
|
||||||
|
freqType: i.freqType || undefined,
|
||||||
|
freqValue: i.freqValue,
|
||||||
|
durationMin: i.durationMin,
|
||||||
|
photoTier: i.photoTier || 'free',
|
||||||
|
cautionNote: i.cautionNote?.trim() || undefined,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
ElMessage.success('联动改计划成功')
|
||||||
|
linkVisible.value = false
|
||||||
|
void fetchAbnormal()
|
||||||
|
void fetchPending()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
linkSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 展示辅助 ----
|
||||||
|
function timeRangeLabel(start?: number | null, end?: number | null): string {
|
||||||
|
return `${formatDateTime(start)} ~ ${formatDateTime(end)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadElders().catch(() => {})
|
void loadMaps()
|
||||||
fetchList()
|
void fetchPending()
|
||||||
|
void fetchAbnormal()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.qc-page {
|
.qc-page {
|
||||||
@apply flex h-full flex-col;
|
@apply h-full overflow-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qc-page__table {
|
.qc-page__cards {
|
||||||
@apply flex min-h-0 flex-1 flex-col;
|
@apply grid grid-cols-5 gap-sm;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-card__body) {
|
.todo-card {
|
||||||
@apply flex min-h-0 flex-1 flex-col;
|
@apply cursor-pointer rounded-base border border-solid p-md;
|
||||||
|
border-color: var(--wm-border-color-light);
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
@apply text-3xl font-semibold;
|
||||||
|
color: var(--wm-color-primary);
|
||||||
|
|
||||||
|
&--zero {
|
||||||
|
color: var(--wm-color-text-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
@apply mt-xs font-medium;
|
||||||
|
color: var(--wm-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
@apply mt-xs text-xs;
|
||||||
|
color: var(--wm-color-text-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qc-page__detail {
|
||||||
|
@apply max-h-[60vh] overflow-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qc-page__abnormal {
|
||||||
|
@apply mb-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.qc-page__row--focus) {
|
||||||
|
background: #fef0f0;
|
||||||
|
|
||||||
|
& > td {
|
||||||
|
background: #fef0f0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user