| | |
| | | new Map<string, HrmEmployeeScheduleApi.ScheduleLeave>(), |
| | | ); |
| | | const loading = ref(false); |
| | | const saving = ref(false); |
| | | /** 是否已执行过查询 */ |
| | | const loaded = ref(false); |
| | | /** 最近一次「查询排班」对应的月份/部门,保存前校验,避免拿旧数据覆盖 */ |
| | |
| | | const autoOpen = ref(false); |
| | | const autoShiftId = ref<number>(); |
| | | const autoRestMode = ref<RestMode>(REST_MODE.DOUBLE); |
| | | /** 自动排班保存中 */ |
| | | const autoSaving = ref(false); |
| | | /** 正在保存的格子 key:`${employeeId}_${date}` */ |
| | | const cellSaving = ref(new Set<string>()); |
| | | |
| | | /** 自动排班班次选择选项 */ |
| | | const autoShiftOptions = computed(() => |
| | |
| | | })), |
| | | ); |
| | | |
| | | /** 按休息模式为当前员工生成整月排班(休息日清除,工作日填班次) */ |
| | | function applyAutoSchedule() { |
| | | /** 分批提交排班变更 */ |
| | | async function persistItems(items: HrmEmployeeScheduleApi.ScheduleItem[]) { |
| | | for (let i = 0; i < items.length; i += SAVE_BATCH_SIZE) { |
| | | await saveEmployeeSchedule(items.slice(i, i + SAVE_BATCH_SIZE)); |
| | | } |
| | | } |
| | | |
| | | /** 按休息模式为当前员工生成整月排班(休息日清除,工作日填班次),生成后自动保存 */ |
| | | async function applyAutoSchedule() { |
| | | if (!employees.value.length || !days.value.length) { |
| | | message.warning('请先查询出员工'); |
| | | return; |
| | | } |
| | | if (autoShiftId.value == null) { |
| | | message.warning('请选择排班班次'); |
| | | return; |
| | | } |
| | | // 未重新查询时网格里的空格不代表「休息」,自动排班会误清已有排班 |
| | | if (loadedMonth.value !== month.value || loadedDeptId.value !== deptId.value) { |
| | | message.warning('月份或部门已变更,请先点击「查询排班」加载最新数据'); |
| | | return; |
| | | } |
| | | // 勾选了员工则只对勾选人生效,未勾选则作用于全部员工 |
| | |
| | | return; |
| | | } |
| | | const map = new Map(scheduleMap.value); |
| | | const items: HrmEmployeeScheduleApi.ScheduleItem[] = []; |
| | | for (const emp of targets) { |
| | | if (emp.id == null) { |
| | | continue; |
| | |
| | | } else if (autoRestMode.value === REST_MODE.SINGLE) { |
| | | rest = week === 0; |
| | | } |
| | | map.set(`${emp.id}_${date}`, rest ? null : autoShiftId.value); |
| | | const key = `${emp.id}_${date}`; |
| | | const next = rest ? null : autoShiftId.value!; |
| | | if ((map.get(key) ?? null) !== next) { |
| | | items.push({ employeeId: emp.id, date, shiftId: next }); |
| | | } |
| | | map.set(key, next); |
| | | } |
| | | } |
| | | scheduleMap.value = map; |
| | | autoOpen.value = false; |
| | | message.success('自动排班已生成,可手动微调后保存'); |
| | | if (!items.length) { |
| | | message.info('自动排班结果与现有排班一致,无需保存'); |
| | | return; |
| | | } |
| | | autoSaving.value = true; |
| | | try { |
| | | await persistItems(items); |
| | | message.success(`自动排班已生成并保存(${items.length} 个格子)`); |
| | | } catch { |
| | | message.error('自动排班保存失败,已重新加载服务端排班'); |
| | | await loadData(); |
| | | } finally { |
| | | autoSaving.value = false; |
| | | } |
| | | } |
| | | |
| | | // ========== 按群体批量排班(一类人一次排完) ========== |
| | |
| | | ); |
| | | } |
| | | |
| | | function pickShift(shiftId: number | null) { |
| | | if (!pickerEmployee.value || !pickerDate.value) { |
| | | /** 某格是否正在保存 */ |
| | | function isCellSaving(employeeId: number, date: string): boolean { |
| | | return cellSaving.value.has(`${employeeId}_${date}`); |
| | | } |
| | | |
| | | /** |
| | | * 保存单个格子:先乐观更新网格,失败则回滚为原值 |
| | | */ |
| | | async function saveCell( |
| | | employeeId: number, |
| | | date: string, |
| | | next: number | null, |
| | | prev: number | null, |
| | | ) { |
| | | const key = `${employeeId}_${date}`; |
| | | cellSaving.value = new Set([...cellSaving.value, key]); |
| | | try { |
| | | await saveEmployeeSchedule([{ employeeId, date, shiftId: next }]); |
| | | } catch { |
| | | const map = new Map(scheduleMap.value); |
| | | if (prev == null) { |
| | | map.delete(key); |
| | | } else { |
| | | map.set(key, prev); |
| | | } |
| | | scheduleMap.value = map; |
| | | message.error('保存失败,已恢复原排班,请重试'); |
| | | } finally { |
| | | const saving = new Set(cellSaving.value); |
| | | saving.delete(key); |
| | | cellSaving.value = saving; |
| | | } |
| | | } |
| | | |
| | | /** 选班次后立即保存该格(选「休息」即清除该天排班) */ |
| | | async function pickShift(shiftId: number | null) { |
| | | const emp = pickerEmployee.value; |
| | | const date = pickerDate.value; |
| | | pickerOpen.value = false; |
| | | if (emp?.id == null || !date) { |
| | | return; |
| | | } |
| | | scheduleMap.value.set(`${pickerEmployee.value.id}_${pickerDate.value}`, shiftId); |
| | | pickerOpen.value = false; |
| | | const key = `${emp.id}_${date}`; |
| | | const prev = scheduleMap.value.get(key) ?? null; |
| | | const next = shiftId ?? null; |
| | | if (prev === next) { |
| | | return; |
| | | } |
| | | scheduleMap.value.set(key, next); |
| | | await saveCell(emp.id, date, next, prev); |
| | | } |
| | | |
| | | // ========== 加载 ========== |
| | |
| | | await loadEmployeePage(); |
| | | } |
| | | |
| | | // ========== 保存(整月全量) ========== |
| | | async function handleSave() { |
| | | if (!month.value || !employees.value.length) { |
| | | return; |
| | | } |
| | | // 月份/部门改过但没有重新查询时,页面上的空格不代表「休息」,保存会把已有排班清掉 |
| | | if (loadedMonth.value !== month.value || loadedDeptId.value !== deptId.value) { |
| | | message.warning( |
| | | '月份或部门已变更,请先点击「查询排班」加载最新数据后再保存,避免误清已有排班', |
| | | ); |
| | | return; |
| | | } |
| | | Modal.confirm({ |
| | | title: '确认保存', |
| | | content: `将保存当前页 ${employees.value.length} 名员工在 ${month.value} 的全部排班,确定继续吗?`, |
| | | okText: '确定', |
| | | cancelText: '取消', |
| | | onOk: async () => { |
| | | saving.value = true; |
| | | try { |
| | | const items: HrmEmployeeScheduleApi.ScheduleItem[] = []; |
| | | for (const emp of employees.value) { |
| | | if (emp.id == null) { |
| | | continue; |
| | | } |
| | | for (const day of days.value) { |
| | | const date = formatDate(month.value, day); |
| | | items.push({ |
| | | employeeId: emp.id, |
| | | date, |
| | | shiftId: scheduleMap.value.get(`${emp.id}_${date}`) ?? null, |
| | | }); |
| | | } |
| | | } |
| | | // 大批量时分批提交,避免单次请求过大 |
| | | for (let i = 0; i < items.length; i += SAVE_BATCH_SIZE) { |
| | | await saveEmployeeSchedule(items.slice(i, i + SAVE_BATCH_SIZE)); |
| | | } |
| | | message.success('排班保存成功'); |
| | | } finally { |
| | | saving.value = false; |
| | | } |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | onMounted(() => { |
| | | month.value = dayjs().format('YYYY-MM'); |
| | | loadDeptTree(); |
| | |
| | | <Button type="primary" ghost :disabled="!month" @click="openBatch"> |
| | | 批量排班(按人群) |
| | | </Button> |
| | | <Button |
| | | type="primary" |
| | | :loading="saving" |
| | | :disabled="!employees.length" |
| | | @click="handleSave" |
| | | > |
| | | 保存排班 |
| | | </Button> |
| | | <span class="text-sm text-gray-400">修改格子后自动保存</span> |
| | | <Segmented |
| | | v-model:value="viewMode" |
| | | :options="[ |
| | |
| | | vd.isWeekend ? 'bg-red-50/40' : '', |
| | | holidayClass(vd.date), |
| | | getLeaveByCell(emp.id!, vd.date) ? 'leave-cell' : '', |
| | | isCellSaving(emp.id!, vd.date) ? 'cell-saving' : '', |
| | | ]" |
| | | @click="openPicker(emp, vd.date)" |
| | | > |
| | |
| | | checkedEmployeeIds.size > 0 |
| | | ? '(仅勾选的员工)' |
| | | : '(未勾选时为当前页全部员工)' |
| | | }},生成后可再手动调整。请假日(审批通过)会自动跳过、不覆盖。 |
| | | }},生成后立即保存。请假日(审批通过)会自动跳过、不覆盖。 |
| | | </div> |
| | | <div class="flex justify-end gap-2"> |
| | | <Button @click="autoOpen = false">取消</Button> |
| | | <Button type="primary" @click="applyAutoSchedule">生成排班</Button> |
| | | <Button |
| | | type="primary" |
| | | :loading="autoSaving" |
| | | @click="applyAutoSchedule" |
| | | > |
| | | 生成并保存 |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | </Modal> |
| | |
| | | } |
| | | } |
| | | |
| | | /* 单元格保存中:半透明,提示正在落库 */ |
| | | .cell-saving { |
| | | cursor: progress; |
| | | opacity: 0.45; |
| | | } |
| | | |
| | | /* 节假日标记:休息日橙色、调休工作日绿色,优先于周末底色 */ |
| | | .holiday-rest { |
| | | background-color: #fff1e6 !important; |