liuyun 80e2a39190 feat(c2): 排班派单页 + 服务对象计划/个人项目库
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 23:03:10 +08:00

432 lines
15 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>
<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>