432 lines
15 KiB
Vue
432 lines
15 KiB
Vue
<template>
|
||
<div class="schedule-page">
|
||
<el-card shadow="never">
|
||
<div class="flex items-center gap-sm">
|
||
<el-date-picker v-model="date" type="date" value-format="YYYY-MM-DD" :clearable="false" style="width: 160px" />
|
||
<el-button type="primary" :loading="loading" @click="refresh">刷新</el-button>
|
||
<el-button v-permission="'order:assign'" @click="recommendVisible = true">批量推荐</el-button>
|
||
<span class="ml-auto text-xs text-text-placeholder">横轴时段 08:00–18:00 · 纵轴护理员 · 冲突红框 · 空缺橙框</span>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-alert
|
||
v-if="dataSourceMissing"
|
||
class="mt-sm"
|
||
type="warning"
|
||
:closable="false"
|
||
title="后端暂缺「按日期列全部工单(管理员视角)」接口,日视图暂无法加载当日工单"
|
||
description="后端仅有 GET /order/today(当前登录护理员本人,管理员视角为空)。待补 GET /order?serviceDate= 后接入 loadOrders()。"
|
||
/>
|
||
|
||
<el-card shadow="never" class="mt-sm schedule-page__grid">
|
||
<div v-loading="loading" class="schedule-grid">
|
||
<div class="schedule-grid__corner">护理员 \ 时段</div>
|
||
<div v-for="s in slots" :key="s" class="schedule-grid__head">{{ s }}</div>
|
||
|
||
<template v-for="staff in staffList" :key="staff.id">
|
||
<div class="schedule-grid__staff">{{ staff.name }}</div>
|
||
<div
|
||
v-for="s in slots"
|
||
:key="s"
|
||
class="schedule-grid__cell"
|
||
:class="{ 'schedule-grid__cell--vacant': ordersOf(staff.id, s).length === 0 }"
|
||
>
|
||
<div
|
||
v-for="o in ordersOf(staff.id, s)"
|
||
:key="o.id"
|
||
class="order-card"
|
||
:class="{ 'order-card--conflict': conflictedOrderIds.has(o.id) }"
|
||
@click="openReassign(o)"
|
||
>
|
||
<div class="order-card__title">{{ elderName(o.elderId) }}</div>
|
||
<div class="order-card__time">{{ fmtTime(o.timeSlotStart) }}–{{ fmtTime(o.timeSlotEnd) }}</div>
|
||
<div class="order-card__svc">{{ firstSvcName(o) }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-dialog v-model="reassignVisible" title="调单" width="520px">
|
||
<el-alert
|
||
v-if="reassignCurrent"
|
||
class="mb-sm"
|
||
type="info"
|
||
:closable="false"
|
||
:title="`原护理员:${staffName(reassignCurrent.assigneeId)} · 原时段:${fmtTime(reassignCurrent.timeSlotStart)}–${fmtTime(reassignCurrent.timeSlotEnd)}`"
|
||
/>
|
||
<el-form :model="reassignForm" label-width="90px">
|
||
<el-form-item label="目标护理员">
|
||
<el-select v-model="reassignForm.assigneeId" placeholder="缺省保留原护理员" clearable style="width: 100%">
|
||
<el-option v-for="s in staffList" :key="s.id" :label="s.name" :value="s.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="新时段起">
|
||
<el-date-picker v-model="reassignForm.timeSlotStart" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="缺省保留原时段" style="width: 100%" />
|
||
</el-form-item>
|
||
<el-form-item label="新时段止">
|
||
<el-date-picker v-model="reassignForm.timeSlotEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="缺省保留原时段" style="width: 100%" />
|
||
</el-form-item>
|
||
<el-form-item label="调单原因">
|
||
<el-input v-model="reassignForm.reason" type="textarea" :rows="2" placeholder="选填" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="reassignVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="reassigning" @click="confirmReassign">确认调单</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="recommendVisible" title="批量推荐(逐条人工确认后才派,不自动派)" width="780px">
|
||
<el-form inline>
|
||
<el-form-item label="老人">
|
||
<el-select v-model="recommendForm.elderId" filterable placeholder="选择老人" style="width: 220px">
|
||
<el-option v-for="e in elders" :key="e.id" :label="`${e.name}(${e.idCardNo})`" :value="e.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="时段起">
|
||
<el-date-picker v-model="recommendForm.timeSlotStart" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width: 190px" />
|
||
</el-form-item>
|
||
<el-form-item label="时段止">
|
||
<el-date-picker v-model="recommendForm.timeSlotEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width: 190px" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :loading="recommending" @click="loadRecommend">推荐</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<el-table :data="recommendations" border size="small">
|
||
<el-table-column prop="name" label="护理员" min-width="90" />
|
||
<el-table-column prop="jobType" label="岗位" width="100" />
|
||
<el-table-column prop="score" label="评分" width="70" align="center" />
|
||
<el-table-column label="推荐理由" min-width="200">
|
||
<template #default="{ row }">{{ row.reasons.join('、') || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="110" align="center">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" :disabled="!recommendForm.orderId" @click="confirmRecommend(row.staffId, row.name)">确认派单</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="mt-sm flex items-center">
|
||
<el-form-item label="待派工单ID" class="mb-0">
|
||
<el-input v-model="recommendForm.orderId" placeholder="to_assign 工单 ID" style="width: 320px" />
|
||
</el-form-item>
|
||
<span class="ml-xs text-xs text-text-placeholder">确认派单需指定 to_assign 工单(日视图接入后可自动带入)</span>
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { ApiError } from '@/api/http'
|
||
import { listElders, type Elder } from '@/api/elder'
|
||
import { listStaff, type Staff } from '@/api/staff'
|
||
import {
|
||
assignOrder,
|
||
batchRecommend,
|
||
reassignOrder,
|
||
type BatchRecommendItem,
|
||
type Order,
|
||
} from '@/api/order'
|
||
|
||
defineOptions({ name: 'schedule' })
|
||
|
||
/** 横轴时段:08:00–18:00 每小时一列。 */
|
||
const SLOTS = ['08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00']
|
||
|
||
const slots = SLOTS
|
||
|
||
const date = ref(todayStr())
|
||
const loading = ref(false)
|
||
const staffList = ref<Staff[]>([])
|
||
const elders = ref<Elder[]>([])
|
||
const orders = ref<Order[]>([])
|
||
const conflictedOrderIds = ref<Set<string>>(new Set())
|
||
/** 后端缺「按日期列全部工单」接口时为 true(日视图数据源待补)。 */
|
||
const dataSourceMissing = ref(false)
|
||
|
||
const elderNameMap = ref<Map<string, string>>(new Map())
|
||
const staffNameMap = ref<Map<string, string>>(new Map())
|
||
|
||
// ---- 调单 ----
|
||
const reassignVisible = ref(false)
|
||
const reassigning = ref(false)
|
||
const reassignOrderId = ref('')
|
||
const reassignCurrent = ref<Order | null>(null)
|
||
const reassignForm = reactive({ assigneeId: '', timeSlotStart: '', timeSlotEnd: '', reason: '' })
|
||
|
||
// ---- 批量推荐 ----
|
||
const recommendVisible = ref(false)
|
||
const recommending = ref(false)
|
||
const recommendations = ref<BatchRecommendItem[]>([])
|
||
const recommendForm = reactive({ elderId: '', timeSlotStart: '', timeSlotEnd: '', orderId: '' })
|
||
|
||
function pad(n: number): string {
|
||
return String(n).padStart(2, '0')
|
||
}
|
||
|
||
function todayStr(): string {
|
||
const d = new Date()
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||
}
|
||
|
||
function fmtTime(ms?: number | null): string {
|
||
if (ms == null) return '-'
|
||
const d = new Date(ms)
|
||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
}
|
||
|
||
/** 工单起始小时落到的时段列 key(超出 08:00–18:00 返回 null)。 */
|
||
function slotKeyOf(o: Order): string | null {
|
||
if (o.timeSlotStart == null) return null
|
||
const key = `${pad(new Date(o.timeSlotStart).getHours())}:00`
|
||
return SLOTS.includes(key) ? key : null
|
||
}
|
||
|
||
function ordersOf(staffId: string, slotKey: string): Order[] {
|
||
return orders.value.filter(
|
||
(o) => o.assigneeId === staffId && o.orderStatus !== 'cancelled' && slotKeyOf(o) === slotKey,
|
||
)
|
||
}
|
||
|
||
function firstSvcName(o: Order): string {
|
||
return o.itemList?.[0]?.svcName ?? '工单'
|
||
}
|
||
|
||
function elderName(id: string): string {
|
||
return elderNameMap.value.get(id) ?? id
|
||
}
|
||
|
||
function staffName(id?: string | null): string {
|
||
if (!id) return '未派'
|
||
return staffNameMap.value.get(id) ?? id
|
||
}
|
||
|
||
/** 严格 < 时段重叠判定(与后端 isTimeOverlap 一致,首尾相接不算重叠)。 */
|
||
function isOverlap(aStart?: number, aEnd?: number, bStart?: number, bEnd?: number): boolean {
|
||
if (aStart == null || aEnd == null || bStart == null || bEnd == null) return false
|
||
return aStart < bEnd && bStart < aEnd
|
||
}
|
||
|
||
function computeConflicts(): void {
|
||
const conflicted = new Set<string>()
|
||
const byStaff = new Map<string, Order[]>()
|
||
for (const o of orders.value) {
|
||
if (!o.assigneeId || o.orderStatus === 'cancelled') continue
|
||
const list = byStaff.get(o.assigneeId) ?? []
|
||
list.push(o)
|
||
byStaff.set(o.assigneeId, list)
|
||
}
|
||
for (const list of byStaff.values()) {
|
||
for (let i = 0; i < list.length; i++) {
|
||
for (let j = i + 1; j < list.length; j++) {
|
||
const a = list[i]
|
||
const b = list[j]
|
||
if (isOverlap(a.timeSlotStart, a.timeSlotEnd, b.timeSlotStart, b.timeSlotEnd)) {
|
||
conflicted.add(a.id)
|
||
conflicted.add(b.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
conflictedOrderIds.value = conflicted
|
||
}
|
||
|
||
async function loadStaff(): Promise<void> {
|
||
const result = await listStaff({ page: 1, pageSize: 500 })
|
||
staffList.value = result.list
|
||
staffNameMap.value = new Map(result.list.map((s) => [s.id, s.name]))
|
||
}
|
||
|
||
async function loadElders(): Promise<void> {
|
||
const result = await listElders({ page: 1, pageSize: 200 })
|
||
elders.value = result.list
|
||
elderNameMap.value = new Map(result.list.map((e) => [e.id, e.name]))
|
||
}
|
||
|
||
/** 加载当日工单。后端暂缺「按日期列全部工单(管理员视角)」接口 → NEEDS_CONTEXT。 */
|
||
async function loadOrders(): Promise<void> {
|
||
// 待后端补 GET /order?serviceDate=...(管理员视角全部工单)后,替换为:
|
||
// orders.value = await listOrdersByDate(date.value)
|
||
// 当前仅有 GET /order/today(当前登录护理员本人,管理员视角返回 []),不可用于日视图。
|
||
orders.value = []
|
||
dataSourceMissing.value = true
|
||
computeConflicts()
|
||
}
|
||
|
||
async function refresh(): Promise<void> {
|
||
loading.value = true
|
||
try {
|
||
await Promise.all([loadStaff(), loadElders(), loadOrders()])
|
||
} catch (e) {
|
||
if (e instanceof ApiError) ElMessage.error(e.message)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// ---- 调单 ----
|
||
function openReassign(order: Order): void {
|
||
reassignOrderId.value = order.id
|
||
reassignCurrent.value = order
|
||
reassignForm.assigneeId = order.assigneeId ?? ''
|
||
reassignForm.timeSlotStart = ''
|
||
reassignForm.timeSlotEnd = ''
|
||
reassignForm.reason = ''
|
||
reassignVisible.value = true
|
||
}
|
||
|
||
async function confirmReassign(): Promise<void> {
|
||
if (!reassignForm.assigneeId && !reassignForm.timeSlotStart && !reassignForm.timeSlotEnd) {
|
||
ElMessage.warning('请选择目标护理员或新时段')
|
||
return
|
||
}
|
||
reassigning.value = true
|
||
try {
|
||
await reassignOrder(reassignOrderId.value, {
|
||
assigneeId: reassignForm.assigneeId || undefined,
|
||
timeSlotStart: reassignForm.timeSlotStart || undefined,
|
||
timeSlotEnd: reassignForm.timeSlotEnd || undefined,
|
||
reason: reassignForm.reason.trim() || undefined,
|
||
})
|
||
ElMessage.success('调单成功')
|
||
reassignVisible.value = false
|
||
await refresh()
|
||
} catch (e) {
|
||
if (e instanceof ApiError) ElMessage.error(e.message)
|
||
} finally {
|
||
reassigning.value = false
|
||
}
|
||
}
|
||
|
||
// ---- 批量推荐 ----
|
||
async function loadRecommend(): Promise<void> {
|
||
if (!recommendForm.elderId) {
|
||
ElMessage.warning('请选择老人')
|
||
return
|
||
}
|
||
if (!recommendForm.timeSlotStart || !recommendForm.timeSlotEnd) {
|
||
ElMessage.warning('请选择时段')
|
||
return
|
||
}
|
||
recommending.value = true
|
||
try {
|
||
recommendations.value = await batchRecommend({
|
||
elderId: recommendForm.elderId,
|
||
serviceDate: date.value,
|
||
timeSlotStart: recommendForm.timeSlotStart,
|
||
timeSlotEnd: recommendForm.timeSlotEnd,
|
||
})
|
||
if (recommendations.value.length === 0) ElMessage.info('暂无推荐护理员')
|
||
} catch (e) {
|
||
if (e instanceof ApiError) ElMessage.error(e.message)
|
||
} finally {
|
||
recommending.value = false
|
||
}
|
||
}
|
||
|
||
/** 逐条人工确认后才派(batch_recommend),不自动派。 */
|
||
async function confirmRecommend(staffId: string, name: string): Promise<void> {
|
||
if (!recommendForm.orderId) {
|
||
ElMessage.warning('请先填写待派工单 ID')
|
||
return
|
||
}
|
||
try {
|
||
await assignOrder({ orderId: recommendForm.orderId, assigneeId: staffId, assignMode: 'batch_recommend' })
|
||
ElMessage.success(`已派单给 ${name}`)
|
||
recommendations.value = recommendations.value.filter((r) => r.staffId !== staffId)
|
||
await refresh()
|
||
} catch (e) {
|
||
if (e instanceof ApiError) ElMessage.error(e.message)
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void refresh()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.schedule-page {
|
||
@apply flex h-full flex-col;
|
||
}
|
||
|
||
.schedule-page__grid {
|
||
@apply flex min-h-0 flex-1 flex-col;
|
||
|
||
:deep(.el-card__body) {
|
||
@apply flex min-h-0 flex-1 flex-col;
|
||
}
|
||
}
|
||
|
||
.schedule-grid {
|
||
@apply min-h-0 flex-1 overflow-auto;
|
||
display: grid;
|
||
grid-template-columns: 120px repeat(10, minmax(96px, 1fr));
|
||
grid-auto-rows: auto;
|
||
gap: 2px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.schedule-grid__corner,
|
||
.schedule-grid__head,
|
||
.schedule-grid__staff {
|
||
@apply flex items-center justify-center bg-bg-page px-xs py-xs font-medium text-text-regular;
|
||
position: sticky;
|
||
}
|
||
|
||
.schedule-grid__head {
|
||
top: 0;
|
||
z-index: 2;
|
||
}
|
||
|
||
.schedule-grid__corner {
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 3;
|
||
}
|
||
|
||
.schedule-grid__staff {
|
||
left: 0;
|
||
z-index: 1;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.schedule-grid__cell {
|
||
@apply min-h-14 rounded-sm border border-transparent p-xs;
|
||
background: #fff;
|
||
}
|
||
|
||
.schedule-grid__cell--vacant {
|
||
border: 1px dashed #f0a04b;
|
||
background: #fdf6ec;
|
||
}
|
||
|
||
.order-card {
|
||
@apply mb-xs cursor-pointer rounded-sm border border-solid border-primary p-xs text-text-primary;
|
||
border-left-width: 3px;
|
||
}
|
||
|
||
.order-card--conflict {
|
||
border-color: #f56c6c;
|
||
background: #fef0f0;
|
||
}
|
||
|
||
.order-card__title {
|
||
@apply font-medium;
|
||
}
|
||
|
||
.order-card__time {
|
||
@apply text-xs text-text-regular;
|
||
}
|
||
|
||
.order-card__svc {
|
||
@apply truncate text-xs;
|
||
color: var(--wm-color-text-placeholder);
|
||
}
|
||
</style>
|