feat(c2): 排班派单页 + 服务对象计划/个人项目库

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-14 23:03:10 +08:00
parent ebbd46899c
commit 80e2a39190
7 changed files with 1072 additions and 1 deletions

38
src/api/elder-svc.ts Normal file
View File

@ -0,0 +1,38 @@
import { http } from '@/api/http'
/**
* 老人个人项目库(ltc_elder_svc)API。
* 字段锚点见 shared/data-model spec(ltc_elder_svc)。
* PUT 为整表覆盖:care_mode + svc_ids(落本机构 L4),旧行软删留档。
*/
/** 老人个人项目库项(含 svcNameSnapshot/svcCodeSnapshot 快照)。 */
export interface ElderSvc {
id: string
tenantCode?: string
elderId: string
svcId: string
svcNameSnapshot: string
svcCodeSnapshot?: string | null
careMode: string
isActive?: boolean
createdAt?: number
updatedAt?: number
}
/** 整表覆盖入参:care_mode 为整库统一护理方式,svc_ids 为本机构 L4 项目库 svc_org.id 列表。 */
export interface PutElderSvcData {
/** inst_medical/inst_nursing/home_org/home_individual */
careMode: string
svcIds: string[]
}
/** 当前(未软删)个人项目库。 */
export function getElderSvc(elderId: string): Promise<ElderSvc[]> {
return http.get<ElderSvc[]>(`/elder-svc/${elderId}`)
}
/** 整表覆盖保存个人项目库。 */
export function putElderSvc(elderId: string, data: PutElderSvcData): Promise<ElderSvc[]> {
return http.put<ElderSvc[]>(`/elder-svc/${elderId}`, data)
}

154
src/api/order.ts Normal file
View File

@ -0,0 +1,154 @@
import { http } from '@/api/http'
/**
* 工单(svc_order)API。
* 字段锚点见 shared/data-model spec(svc_order)。
* - 时间字段(timeSlotStart/End 为 datetime、serviceDate/assignTime 为日期)后端返回毫秒时间戳;
* - 派单冲突(2001)/无待遇(1001)/无计划(1002)/资质提示(1006)由后端以 ApiError.message 返回,前端直接展示。
*
* 注意:后端暂无「按日期列全部工单(管理员视角)」的列表接口,仅有 GET /order/today(当前登录
* 护理员本人当日工单)。排班派单页的「全部当日工单」数据源见 task-5-report(NEEDS_CONTEXT)。
*/
/** 工单八态(order_status)。 */
export const ORDER_STATUSES = [
'to_assign',
'assigned',
'serving',
'done',
'abnormal',
'cancelled',
'overdue',
'not_met',
] as const
export type OrderStatus = (typeof ORDER_STATUSES)[number]
/** 派单方式:manual(人工)/ batch_recommend(批量推荐确认)。 */
export const ASSIGN_MODES = ['manual', 'batch_recommend'] as const
/** 工单计划项快照(item_list 元素)。 */
export interface OrderItemSnapshot {
svcId: string
svcName: string
svcCode: string | null
freqType: string | null
freqValue: number | null
durationMin: number | null
photoTier: string
needCert: boolean
isMedical: boolean
}
/** 工单(svc_order,时间字段为毫秒时间戳)。 */
export interface Order {
id: string
tenantCode?: string
elderId: string
planId: string
serviceDate?: number
timeSlotStart?: number
timeSlotEnd?: number
assigneeId?: string | null
assignTime?: number | null
assignMode?: string | null
orderStatus: string
itemList: OrderItemSnapshot[]
needPhoto?: boolean
cancelReason?: string | null
rescheduleLog?: unknown
qualificationCheck?: string | null
billable?: boolean
createdAt?: number
updatedAt?: number
}
/** 生成工单入参(plan_id 须为 active)。 */
export interface GenerateOrderData {
elderId: string
planId: string
/** YYYY-MM-DD */
serviceDate: string
/** ISO datetime */
timeSlotStart: string
/** ISO datetime */
timeSlotEnd: string
}
/** 派单入参(工单态须为 to_assign)。 */
export interface AssignOrderData {
orderId: string
/** 护理员 staff.id */
assigneeId: string
assignMode?: string
}
/** 调单入参:改派护理员与/或改时段(均可选,缺省保留原值)。 */
export interface ReassignOrderData {
assigneeId?: string
/** ISO datetime */
timeSlotStart?: string
/** ISO datetime */
timeSlotEnd?: string
reason?: string
}
/** 取消工单入参。 */
export interface CancelOrderData {
cancelReason: string
}
/** 批量推荐结果(只读,不自动派)。 */
export interface BatchRecommendItem {
staffId: string
name: string
jobType: string
score: number
reasons: string[]
}
/** 批量推荐查询。 */
export interface BatchRecommendQuery {
elderId: string
/** YYYY-MM-DD */
serviceDate?: string
/** ISO datetime */
timeSlotStart?: string
/** ISO datetime */
timeSlotEnd?: string
}
/** 生成工单(to_assign)。 */
export function generateOrder(data: GenerateOrderData): Promise<Order> {
return http.post<Order>('/order/generate', data)
}
/** 派单(to_assign → assigned)。 */
export function assignOrder(data: AssignOrderData): Promise<Order> {
return http.post<Order>('/order/assign', data)
}
/** 调单(改派/改时段,写 reschedule_log)。 */
export function reassignOrder(id: string, data: ReassignOrderData): Promise<Order> {
return http.post<Order>(`/order/${id}/reassign`, data)
}
/** 取消工单(cancel_reason 必填)。 */
export function cancelOrder(id: string, data: CancelOrderData): Promise<Order> {
return http.post<Order>(`/order/${id}/cancel`, data)
}
/** 工单详情。 */
export function getOrder(id: string): Promise<Order> {
return http.get<Order>(`/order/${id}`)
}
/** 今日工单(当前登录护理员本人;serviceDate 缺省为今天)。 */
export function getTodayOrders(serviceDate?: string): Promise<Order[]> {
return http.get<Order[]>('/order/today', { params: serviceDate ? { serviceDate } : undefined })
}
/** 批量推荐(只读,返回推荐护理员 + 理由,不自动派)。 */
export function batchRecommend(query: BatchRecommendQuery): Promise<BatchRecommendItem[]> {
return http.get<BatchRecommendItem[]>('/order/batch-recommend', { params: query })
}

118
src/api/plan.ts Normal file
View File

@ -0,0 +1,118 @@
import { http } from '@/api/http'
/**
* 服务计划(svc_plan / svc_plan_item)API。
* 字段锚点见 shared/data-model spec(svc_plan / svc_plan_item)。
* - 日期字段(planPeriodStart/End、confirmTime)后端经 TransformInterceptor 序列化为毫秒时间戳;
* - 计划项 svcId 引用本机构 L4(svc_org.id),落库时快照 svcNameSnapshot/svcCodeSnapshot。
*/
/** 计划项入参(PlanItemInputDto)。freqType: daily/weekly_n/monthly_n/as_needed;photoTier: must/optional/free。 */
export interface PlanItemInput {
svcId: string
freqType?: string
freqValue?: number
durationMin?: number
photoTier?: string
cautionNote?: string
isMedical?: boolean
needCert?: boolean
}
/** 制定计划入参(CreatePlanDto)。plan_status 由服务端置 active,不接受前端传入。 */
export interface CreatePlanData {
elderId: string
/** YYYY-MM-DD */
planPeriodStart: string
/** YYYY-MM-DD */
planPeriodEnd: string
/** care_1/care_2/care_3 */
careLevel: string
/** inst_medical/inst_nursing/home_org/home_individual */
careMode: string
items: PlanItemInput[]
weekFreq?: number
monthHours?: number
extraItemCount?: number
planBasis?: unknown
}
/** 变更计划入参:change_from_plan_id 指向被变更的旧计划。 */
export interface ChangePlanData extends CreatePlanData {
changeFromPlanId: string
}
/** 计划确认入参(老人/家属确认)。 */
export interface ConfirmPlanData {
confirmElder: boolean
confirmGuardian?: boolean
}
/** 服务计划头(svc_plan,日期字段为毫秒时间戳)。 */
export interface Plan {
id: string
tenantCode?: string
elderId: string
planPeriodStart?: number
planPeriodEnd?: number
careLevel: string
careMode: string
itemCount: number
itemCountRule?: string | null
extraItemCount?: number
weekFreq?: number | null
monthHours?: number | null
planStatus: string
confirmElder?: boolean
confirmGuardian?: boolean
confirmTime?: number | null
planBasis?: unknown
changeFromPlanId?: string | null
bizLine?: string
createdAt?: number
updatedAt?: number
}
/** 服务计划项(svc_plan_item)。 */
export interface PlanItem {
id: string
planId: string
svcId: string
svcNameSnapshot: string
svcCodeSnapshot?: string | null
freqType?: string | null
freqValue?: number | null
durationMin?: number | null
photoTier?: string
cautionNote?: string | null
isMedical?: boolean
needCert?: boolean
}
/** 计划详情 = 计划头 + 计划项。 */
export type PlanDetail = Plan & { items: PlanItem[] }
/** 制定服务计划(服务端置 active)。 */
export function createPlan(data: CreatePlanData): Promise<Plan> {
return http.post<Plan>('/plan', data)
}
/** 变更服务计划(新建 active + 旧计划转 changed)。 */
export function changePlan(data: ChangePlanData): Promise<Plan> {
return http.post<Plan>('/plan/change', data)
}
/** 计划详情(含计划项)。 */
export function getPlan(id: string): Promise<PlanDetail> {
return http.get<PlanDetail>(`/plan/${id}`)
}
/** 计划确认(写 confirm_time + elder/guardian 布尔,不改 plan_status)。 */
export function confirmPlan(id: string, data: ConfirmPlanData): Promise<Plan> {
return http.post<Plan>(`/plan/${id}/confirm`, data)
}
/** 按老人查计划(新→旧)。 */
export function listPlansByElder(elderId: string): Promise<Plan[]> {
return http.get<Plan[]>(`/plan/by-elder/${elderId}`)
}

View File

@ -26,6 +26,30 @@
<el-descriptions-item label="备注" :span="2">{{ elder.note ?? '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">个人项目库</el-divider>
<div v-loading="svcLoading">
<el-form inline label-width="auto">
<el-form-item label="护理方式">
<el-select v-model="svcForm.careMode" placeholder="请选择" style="width: 200px">
<el-option v-for="o in CARE_MODE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button v-permission="'elder:svc-save'" type="primary" :loading="svcSaving" @click="saveSvcLibrary">保存项目库</el-button>
</el-form-item>
</el-form>
<el-checkbox-group v-model="svcForm.svcIds" class="elder-detail__svc">
<el-checkbox v-for="p in l4Projects" :key="p.id" :value="p.id" border class="elder-detail__svc-item">
{{ p.name }}
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="l4Projects.length === 0" description="本机构暂无已启用项目" :image-size="60" />
</div>
<div class="mt-sm">
<el-button v-permission="'plan:create'" type="primary" @click="openPlanForm">制定计划</el-button>
</div>
<div v-if="!isTerminal" v-permission="'elder:status-change'">
<el-divider content-position="left">状态变更</el-divider>
<el-form inline>
@ -58,10 +82,15 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/http'
import { changeElderStatus, getElder, getStatusLog, type Elder, type ElderStatusLog } from '@/api/elder'
import { listLocal, listNewNational, listOrg } from '@/api/catalog'
import { getElderSvc, putElderSvc } from '@/api/elder-svc'
import { showDialog } from '@/utils/dialog'
import {
ASSESS_LEVEL_OPTIONS,
CARE_LEVEL_OPTIONS,
CARE_MODE_OPTIONS,
GENDER_OPTIONS,
INSURE_TYPE_OPTIONS,
INSURE_VERIFY_STATUS_OPTIONS,
@ -71,6 +100,7 @@ import {
formatDateTime,
labelOf,
} from '@/pages/ltc/elder/constants'
import PlanForm from '@/components/plan-form.vue'
defineOptions({ name: 'ElderDetail' })
@ -83,6 +113,17 @@ const changing = ref(false)
const statusForm = reactive({ toStatus: '', changeReason: '' })
// ---- 个人项目库 ----
interface L4Project {
id: string
name: string
}
const svcLoading = ref(false)
const svcSaving = ref(false)
const l4Projects = ref<L4Project[]>([])
const svcForm = reactive({ careMode: '', svcIds: [] as string[] })
/** 终态(dead/terminated):后端 IRREVERSIBLE 源态守卫,禁止从终态再变更 → 隐藏整个状态变更区块。 */
const isTerminal = computed(() => {
const s = elder.value?.treatStatus
@ -104,6 +145,72 @@ async function load(): Promise<void> {
} finally {
loading.value = false
}
await loadSvcLibrary()
}
/** 个人项目库:当前勾选(快照)+ 本机构 L4 项目(名称 join L2/L1 目录)。 */
async function loadSvcLibrary(): Promise<void> {
svcLoading.value = true
try {
const [lib, orgs] = await Promise.all([getElderSvc(props.elderId), listOrg()])
const libraryNames = new Map<string, string>()
for (const i of lib) libraryNames.set(i.svcId, i.svcNameSnapshot)
svcForm.svcIds = lib.map((i) => i.svcId)
svcForm.careMode = lib[0]?.careMode ?? svcForm.careMode
// L4(svc_org)只有 srcType/srcId,无名称;按 srcId join 目录解析名称。
const locCode = elder.value?.locCode ?? ''
const [locals, nationals] = await Promise.all([listLocal(locCode), listNewNational()])
const localMap = new Map<string, string>()
const nationalMap = new Map<string, string>()
for (const it of locals) {
if ('localItemName' in it) localMap.set(it.id, it.localItemName)
else if ('natName' in it) nationalMap.set(it.id, it.natName)
}
for (const it of nationals) nationalMap.set(it.id, it.natName)
l4Projects.value = orgs
.filter((o) => o.isEnabled || svcForm.svcIds.includes(o.id))
.map((o) => {
const catalogName = o.srcType === 'local' ? localMap.get(o.srcId) : nationalMap.get(o.srcId)
return { id: o.id, name: libraryNames.get(o.id) ?? catalogName ?? o.srcId }
})
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
svcLoading.value = false
}
}
/** 整表覆盖保存个人项目库。 */
async function saveSvcLibrary(): Promise<void> {
if (!svcForm.careMode) {
ElMessage.warning('请选择护理方式')
return
}
svcSaving.value = true
try {
await putElderSvc(props.elderId, { careMode: svcForm.careMode, svcIds: svcForm.svcIds })
ElMessage.success('个人项目库已保存')
await loadSvcLibrary()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
svcSaving.value = false
}
}
/** 制定计划:弹窗,从个人项目库带入 → 生成 plan_item 初稿(可改)。 */
async function openPlanForm(): Promise<void> {
const plan = await showDialog({
component: PlanForm,
props: { elderId: props.elderId, careLevel: elder.value?.careLevel },
title: `制定服务计划(${elder.value?.name ?? ''})`,
width: '900px',
confirmText: '制定',
})
if (plan == null) return
ElMessage.success('服务计划已制定')
}
async function submitStatusChange(): Promise<void> {
@ -129,3 +236,13 @@ async function submitStatusChange(): Promise<void> {
onMounted(load)
</script>
<style scoped lang="scss">
.elder-detail__svc {
@apply flex flex-wrap gap-xs;
}
.elder-detail__svc-item {
@apply mb-0;
}
</style>

View File

@ -0,0 +1,212 @@
<template>
<div class="plan-form">
<el-form :model="form" label-width="110px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="护理等级" required>
<el-select v-model="form.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="form.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="12">
<el-form-item label="周期起" required>
<el-date-picker v-model="form.planPeriodStart" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="周期止" required>
<el-date-picker v-model="form.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 v-permission="'plan:create'" link type="primary" :loading="libLoading" @click="bringFromLibrary">
从个人项目库带入
</el-button>
<span class="ml-xs text-xs text-text-placeholder">带入后可修改每项频次/时长/拍照档位</span>
</div>
<el-table :data="form.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="removeItem($index)">移除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="form.items.length === 0" description="暂无计划项,请从个人项目库带入" :image-size="60" />
</el-form>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/http'
import { createPlan, type CreatePlanData } from '@/api/plan'
import { getElderSvc } from '@/api/elder-svc'
import { CARE_LEVEL_OPTIONS, CARE_MODE_OPTIONS } from '@/pages/ltc/elder/constants'
defineOptions({ name: 'PlanForm' })
const props = defineProps<{ elderId: string; careLevel?: string }>()
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' },
]
interface ItemDraft {
svcId: string
name: string
freqType?: string
freqValue?: number
durationMin?: number
photoTier?: string
cautionNote?: string
}
const form = reactive<{
careLevel: string
careMode: string
planPeriodStart: string
planPeriodEnd: string
items: ItemDraft[]
}>({
careLevel: props.careLevel ?? '',
careMode: '',
planPeriodStart: '',
planPeriodEnd: '',
items: [],
})
const libLoading = ref(false)
function removeItem(index: number): void {
form.items.splice(index, 1)
}
/** 从个人项目库一键带入:追加未纳入的计划项,并用项目库的 careMode 兜底。 */
async function bringFromLibrary(): Promise<void> {
libLoading.value = true
try {
const lib = await getElderSvc(props.elderId)
if (lib.length === 0) {
ElMessage.warning('该老人暂无个人项目库,请先在服务对象页维护')
return
}
if (!form.careMode && lib[0]?.careMode) form.careMode = lib[0].careMode
const existing = new Set(form.items.map((i) => i.svcId))
for (const it of lib) {
if (existing.has(it.svcId)) continue
form.items.push({ svcId: it.svcId, name: it.svcNameSnapshot, photoTier: 'free' })
}
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
libLoading.value = false
}
}
/** 提交制定计划:校验 → createPlan → 返回计划;失败抛错保持弹窗打开。 */
async function submit(): Promise<unknown> {
if (!form.careLevel) {
ElMessage.warning('请选择护理等级')
throw new Error('请选择护理等级')
}
if (!form.careMode) {
ElMessage.warning('请选择护理方式')
throw new Error('请选择护理方式')
}
if (!form.planPeriodStart || !form.planPeriodEnd) {
ElMessage.warning('请选择计划周期')
throw new Error('请选择计划周期')
}
if (form.planPeriodEnd <= form.planPeriodStart) {
ElMessage.warning('周期止须晚于周期起')
throw new Error('周期止须晚于周期起')
}
if (form.items.length === 0) {
ElMessage.warning('请先带入计划项')
throw new Error('请先带入计划项')
}
const data: CreatePlanData = {
elderId: props.elderId,
planPeriodStart: form.planPeriodStart,
planPeriodEnd: form.planPeriodEnd,
careLevel: form.careLevel,
careMode: form.careMode,
items: form.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,
})),
}
try {
return await createPlan(data)
} catch (e) {
// 项数校验(PLAN_ITEM_RULE)等业务错误 message 直接展示(如「当前护理方式/等级需 ≥18 项」)。
if (e instanceof ApiError) ElMessage.error(e.message)
throw e
}
}
defineExpose({ submit })
onMounted(() => {
if (!form.careLevel && props.careLevel) form.careLevel = props.careLevel
})
</script>

View File

@ -32,6 +32,14 @@ export const CARE_LEVEL_OPTIONS: LabeledValue[] = [
{ label: '三级护理', value: 'care_3' },
]
/** 护理方式(值唯一来源:后端 plan/elder-svc CARE_MODE)。 */
export const CARE_MODE_OPTIONS: LabeledValue[] = [
{ label: '机构医疗', value: 'inst_medical' },
{ label: '机构护理', value: 'inst_nursing' },
{ label: '居家机构上门', value: 'home_org' },
{ label: '居家个人', value: 'home_individual' },
]
/** 待遇状态十态(值唯一来源:后端 elder TREAT_STATUSES)。 */
export const TREAT_STATUS_OPTIONS: LabeledValue[] = [
{ label: '待建档', value: 'pending_file' },

View File

@ -1,7 +1,431 @@
<template>
<div>排班派单(C1–C4 实现)</div>
<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>