|
@@ -20,6 +20,7 @@
|
|
|
<span><i class="dot am" />早班</span>
|
|
<span><i class="dot am" />早班</span>
|
|
|
<span><i class="dot pm" />晚班</span>
|
|
<span><i class="dot pm" />晚班</span>
|
|
|
<span><i class="dot duty" />值班</span>
|
|
<span><i class="dot duty" />值班</span>
|
|
|
|
|
+ <span><i class="dot other" />其他</span>
|
|
|
<span class="muted">{{
|
|
<span class="muted">{{
|
|
|
viewMode === 'stats' ? '点击本月合计可返回日历' : '点击日期配置 / 复制链接'
|
|
viewMode === 'stats' ? '点击本月合计可返回日历' : '点击日期配置 / 复制链接'
|
|
|
}}</span>
|
|
}}</span>
|
|
@@ -52,6 +53,15 @@
|
|
|
>
|
|
>
|
|
|
<i class="dot duty" />值班 <em>{{ monthShiftStats.duty }}</em>
|
|
<i class="dot duty" />值班 <em>{{ monthShiftStats.duty }}</em>
|
|
|
</button>
|
|
</button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-if="monthShiftStats.other > 0"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="stat"
|
|
|
|
|
+ :class="{ active: viewMode === 'calendar' && filterShiftType === 'other' }"
|
|
|
|
|
+ @click="toggleShiftFilter('other')"
|
|
|
|
|
+ >
|
|
|
|
|
+ <i class="dot other" />其他 <em>{{ monthShiftStats.other }}</em>
|
|
|
|
|
+ </button>
|
|
|
<button
|
|
<button
|
|
|
type="button"
|
|
type="button"
|
|
|
class="stat total"
|
|
class="stat total"
|
|
@@ -137,7 +147,7 @@
|
|
|
<span>总次数</span>
|
|
<span>总次数</span>
|
|
|
</div>
|
|
</div>
|
|
|
</header>
|
|
</header>
|
|
|
- <div class="stats-breakdown">
|
|
|
|
|
|
|
+ <div class="stats-breakdown" :class="{ 'has-other': card.other > 0 }">
|
|
|
<div class="cell am">
|
|
<div class="cell am">
|
|
|
<span><i class="dot am" />早班</span>
|
|
<span><i class="dot am" />早班</span>
|
|
|
<strong>{{ card.morning }}</strong>
|
|
<strong>{{ card.morning }}</strong>
|
|
@@ -150,6 +160,10 @@
|
|
|
<span><i class="dot duty" />值班</span>
|
|
<span><i class="dot duty" />值班</span>
|
|
|
<strong>{{ card.duty }}</strong>
|
|
<strong>{{ card.duty }}</strong>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div v-if="card.other > 0" class="cell other">
|
|
|
|
|
+ <span><i class="dot other" />其他</span>
|
|
|
|
|
+ <strong>{{ card.other }}</strong>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="stats-bar">
|
|
<div class="stats-bar">
|
|
|
<i
|
|
<i
|
|
@@ -167,10 +181,13 @@
|
|
|
class="seg duty"
|
|
class="seg duty"
|
|
|
:style="{ width: barPct(card.duty, card.total) }"
|
|
:style="{ width: barPct(card.duty, card.total) }"
|
|
|
/>
|
|
/>
|
|
|
|
|
+ <i
|
|
|
|
|
+ v-if="card.other"
|
|
|
|
|
+ class="seg other"
|
|
|
|
|
+ :style="{ width: barPct(card.other, card.total) }"
|
|
|
|
|
+ />
|
|
|
</div>
|
|
</div>
|
|
|
- <footer class="stats-card-ft">
|
|
|
|
|
- {{ year }}年{{ pad(month) }}月 排班统计
|
|
|
|
|
- </footer>
|
|
|
|
|
|
|
+ <footer class="stats-card-ft"> {{ year }}年{{ pad(month) }}月 排班统计 </footer>
|
|
|
</article>
|
|
</article>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -193,40 +210,90 @@
|
|
|
<div v-for="slot in formSlots" :key="slot.shiftType" class="slot-card">
|
|
<div v-for="slot in formSlots" :key="slot.shiftType" class="slot-card">
|
|
|
<div class="slot-hd">
|
|
<div class="slot-hd">
|
|
|
<el-checkbox v-model="slot.enabled">{{ shiftLabel(slot.shiftType) }}</el-checkbox>
|
|
<el-checkbox v-model="slot.enabled">{{ shiftLabel(slot.shiftType) }}</el-checkbox>
|
|
|
- <div class="cap-field" v-if="slot.enabled">
|
|
|
|
|
|
|
+ <div v-if="slot.enabled" class="cap-field">
|
|
|
<span>名额</span>
|
|
<span>名额</span>
|
|
|
- <el-input-number v-model="slot.capacity" :min="1" :max="99" controls-position="right" />
|
|
|
|
|
|
|
+ <el-input-number
|
|
|
|
|
+ v-model="slot.capacity"
|
|
|
|
|
+ :min="1"
|
|
|
|
|
+ :max="99"
|
|
|
|
|
+ controls-position="right"
|
|
|
|
|
+ />
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
<template v-if="slot.enabled">
|
|
<template v-if="slot.enabled">
|
|
|
- <div class="form-grid-2 slot-assign-row">
|
|
|
|
|
- <el-form-item label="值班安排">
|
|
|
|
|
|
|
+ <div class="form-grid-2 slot-fields-row">
|
|
|
|
|
+ <el-form-item label="开始时间">
|
|
|
|
|
+ <el-time-picker
|
|
|
|
|
+ v-model="slot.startTime"
|
|
|
|
|
+ value-format="HH:mm"
|
|
|
|
|
+ format="HH:mm"
|
|
|
|
|
+ placeholder="可选"
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ @change="() => syncCrossDay(slot)"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="结束时间">
|
|
|
|
|
+ <el-time-picker
|
|
|
|
|
+ v-model="slot.endTime"
|
|
|
|
|
+ value-format="HH:mm"
|
|
|
|
|
+ format="HH:mm"
|
|
|
|
|
+ placeholder="可选"
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ @change="() => syncCrossDay(slot)"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="跨日">
|
|
|
|
|
+ <el-checkbox v-model="slot.crossDay">结束时间跨日</el-checkbox>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="工作安排" class="form-col-span-2">
|
|
|
<el-select
|
|
<el-select
|
|
|
- v-model="slot.pendingUserId"
|
|
|
|
|
|
|
+ :model-value="slot.workPlans"
|
|
|
|
|
+ multiple
|
|
|
filterable
|
|
filterable
|
|
|
|
|
+ allow-create
|
|
|
|
|
+ default-first-option
|
|
|
clearable
|
|
clearable
|
|
|
- placeholder="选择员工后添加"
|
|
|
|
|
|
|
+ placeholder="选择或输入工作安排"
|
|
|
style="width: 100%"
|
|
style="width: 100%"
|
|
|
- @change="(v) => addAssign(slot, String(v || ''))"
|
|
|
|
|
|
|
+ @change="(v) => onWorkPlansChange(slot, v as string[])"
|
|
|
>
|
|
>
|
|
|
<el-option
|
|
<el-option
|
|
|
- v-for="u in userOptions"
|
|
|
|
|
- :key="u.value"
|
|
|
|
|
- :label="u.label"
|
|
|
|
|
- :value="u.value"
|
|
|
|
|
|
|
+ v-for="o in workPlanOptions"
|
|
|
|
|
+ :key="o.value"
|
|
|
|
|
+ :label="o.label"
|
|
|
|
|
+ :value="o.value"
|
|
|
/>
|
|
/>
|
|
|
</el-select>
|
|
</el-select>
|
|
|
</el-form-item>
|
|
</el-form-item>
|
|
|
- <el-form-item label="工作安排">
|
|
|
|
|
|
|
+ <el-form-item label="备注" class="form-col-span-2">
|
|
|
<el-input
|
|
<el-input
|
|
|
v-model="slot.remark"
|
|
v-model="slot.remark"
|
|
|
type="textarea"
|
|
type="textarea"
|
|
|
:rows="2"
|
|
:rows="2"
|
|
|
maxlength="500"
|
|
maxlength="500"
|
|
|
show-word-limit
|
|
show-word-limit
|
|
|
- placeholder="可选,填写本班次工作安排"
|
|
|
|
|
|
|
+ placeholder="可选,填写备注(与工作安排分离)"
|
|
|
/>
|
|
/>
|
|
|
</el-form-item>
|
|
</el-form-item>
|
|
|
|
|
+ <el-form-item label="值班安排" class="form-col-span-2">
|
|
|
|
|
+ <el-select
|
|
|
|
|
+ v-model="slot.pendingUserId"
|
|
|
|
|
+ filterable
|
|
|
|
|
+ clearable
|
|
|
|
|
+ placeholder="选择员工后添加"
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ @change="(v) => addAssign(slot, String(v || ''))"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="u in userOptions"
|
|
|
|
|
+ :key="u.value"
|
|
|
|
|
+ :label="u.label"
|
|
|
|
|
+ :value="u.value"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="person-tags">
|
|
<div class="person-tags">
|
|
|
<el-tag
|
|
<el-tag
|
|
@@ -244,6 +311,29 @@
|
|
|
</template>
|
|
</template>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <div class="add-shift-row">
|
|
|
|
|
+ <el-select
|
|
|
|
|
+ v-model="addShiftDraft"
|
|
|
|
|
+ filterable
|
|
|
|
|
+ allow-create
|
|
|
|
|
+ default-first-option
|
|
|
|
|
+ clearable
|
|
|
|
|
+ placeholder="选择或输入班次类型"
|
|
|
|
|
+ style="flex: 1; min-width: 180px"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="o in availableShiftOptions"
|
|
|
|
|
+ :key="o.value"
|
|
|
|
|
+ :label="o.label"
|
|
|
|
|
+ :value="o.value"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ <el-button class="btn-ghost" :loading="addingShift" @click="confirmAddShift">
|
|
|
|
|
+ <el-icon><Plus /></el-icon>
|
|
|
|
|
+ 添加班次
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<div v-if="dayDetail?.publicUrl" class="link-box">
|
|
<div v-if="dayDetail?.publicUrl" class="link-box">
|
|
|
<span>公开链接</span>
|
|
<span>公开链接</span>
|
|
|
<code>{{ dayDetail.publicUrl }}</code>
|
|
<code>{{ dayDetail.publicUrl }}</code>
|
|
@@ -251,12 +341,7 @@
|
|
|
<el-icon><CopyDocument /></el-icon>
|
|
<el-icon><CopyDocument /></el-icon>
|
|
|
复制
|
|
复制
|
|
|
</el-button>
|
|
</el-button>
|
|
|
- <el-button
|
|
|
|
|
- v-if="dayDetail.id"
|
|
|
|
|
- class="btn-ghost"
|
|
|
|
|
- size="small"
|
|
|
|
|
- @click="toggleLink"
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <el-button v-if="dayDetail.id" class="btn-ghost" size="small" @click="toggleLink">
|
|
|
<el-icon><SwitchButton /></el-icon>
|
|
<el-icon><SwitchButton /></el-icon>
|
|
|
{{ dayDetail.linkStatus === 'closed' ? '打开链接' : '关闭链接' }}
|
|
{{ dayDetail.linkStatus === 'closed' ? '打开链接' : '关闭链接' }}
|
|
|
</el-button>
|
|
</el-button>
|
|
@@ -278,7 +363,7 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
- import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
|
|
+ import { computed, onMounted, ref } from 'vue'
|
|
|
import {
|
|
import {
|
|
|
ArrowLeft,
|
|
ArrowLeft,
|
|
|
ArrowRight,
|
|
ArrowRight,
|
|
@@ -286,30 +371,35 @@
|
|
|
Check,
|
|
Check,
|
|
|
Close,
|
|
Close,
|
|
|
CopyDocument,
|
|
CopyDocument,
|
|
|
|
|
+ Plus,
|
|
|
SwitchButton
|
|
SwitchButton
|
|
|
} from '@element-plus/icons-vue'
|
|
} from '@element-plus/icons-vue'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
+ import { fetchDictDataOptions } from '@/api/admin'
|
|
|
import {
|
|
import {
|
|
|
deleteDutyAssignment,
|
|
deleteDutyAssignment,
|
|
|
|
|
+ ensureDutyDict,
|
|
|
fetchDutyDay,
|
|
fetchDutyDay,
|
|
|
fetchDutyMonth,
|
|
fetchDutyMonth,
|
|
|
fetchDutyUserOptions,
|
|
fetchDutyUserOptions,
|
|
|
saveDutyDay,
|
|
saveDutyDay,
|
|
|
setDutyLinkStatus,
|
|
setDutyLinkStatus,
|
|
|
type DutyDayItem,
|
|
type DutyDayItem,
|
|
|
- type DutySlotItem,
|
|
|
|
|
- type ShiftType
|
|
|
|
|
|
|
+ type DutySlotItem
|
|
|
} from '@/api/duty'
|
|
} from '@/api/duty'
|
|
|
|
|
|
|
|
defineOptions({ name: 'DutySchedule' })
|
|
defineOptions({ name: 'DutySchedule' })
|
|
|
|
|
|
|
|
- const SHIFT_TYPES: ShiftType[] = ['morning', 'evening', 'duty']
|
|
|
|
|
- const SHIFT_LABELS: Record<ShiftType, string> = {
|
|
|
|
|
|
|
+ const PRESET_TYPES = ['morning', 'evening', 'duty'] as const
|
|
|
|
|
+ type PresetShift = (typeof PRESET_TYPES)[number]
|
|
|
|
|
+ type FilterShift = PresetShift | 'other' | ''
|
|
|
|
|
+
|
|
|
|
|
+ const SHIFT_LABELS: Record<string, string> = {
|
|
|
morning: '早班',
|
|
morning: '早班',
|
|
|
evening: '晚班',
|
|
evening: '晚班',
|
|
|
duty: '值班'
|
|
duty: '值班'
|
|
|
}
|
|
}
|
|
|
- const SHIFT_CLS: Record<ShiftType, string> = {
|
|
|
|
|
|
|
+ const SHIFT_CLS: Record<string, string> = {
|
|
|
morning: 'am',
|
|
morning: 'am',
|
|
|
evening: 'pm',
|
|
evening: 'pm',
|
|
|
duty: 'duty'
|
|
duty: 'duty'
|
|
@@ -320,37 +410,63 @@
|
|
|
const month = ref(now.getMonth() + 1)
|
|
const month = ref(now.getMonth() + 1)
|
|
|
const loading = ref(false)
|
|
const loading = ref(false)
|
|
|
const saving = ref(false)
|
|
const saving = ref(false)
|
|
|
|
|
+ const addingShift = ref(false)
|
|
|
const monthDays = ref<DutyDayItem[]>([])
|
|
const monthDays = ref<DutyDayItem[]>([])
|
|
|
const filterPersonKey = ref('')
|
|
const filterPersonKey = ref('')
|
|
|
- const filterShiftType = ref<ShiftType | ''>('')
|
|
|
|
|
|
|
+ const filterShiftType = ref<FilterShift>('')
|
|
|
const viewMode = ref<'calendar' | 'stats'>('calendar')
|
|
const viewMode = ref<'calendar' | 'stats'>('calendar')
|
|
|
const dialogVisible = ref(false)
|
|
const dialogVisible = ref(false)
|
|
|
const editingDate = ref('')
|
|
const editingDate = ref('')
|
|
|
const dayDetail = ref<DutyDayItem | null>(null)
|
|
const dayDetail = ref<DutyDayItem | null>(null)
|
|
|
const userOptions = ref<Array<{ label: string; value: string }>>([])
|
|
const userOptions = ref<Array<{ label: string; value: string }>>([])
|
|
|
|
|
+ const shiftTypeOptions = ref<Array<{ label: string; value: string }>>([])
|
|
|
|
|
+ const workPlanOptions = ref<Array<{ label: string; value: string }>>([])
|
|
|
|
|
+ const addShiftDraft = ref('')
|
|
|
|
|
|
|
|
type FormSlot = {
|
|
type FormSlot = {
|
|
|
- shiftType: ShiftType
|
|
|
|
|
|
|
+ shiftType: string
|
|
|
enabled: boolean
|
|
enabled: boolean
|
|
|
capacity: number
|
|
capacity: number
|
|
|
|
|
+ workPlans: string[]
|
|
|
remark: string
|
|
remark: string
|
|
|
|
|
+ startTime: string | null
|
|
|
|
|
+ endTime: string | null
|
|
|
|
|
+ crossDay: boolean
|
|
|
pendingUserId: string
|
|
pendingUserId: string
|
|
|
assignments: DutySlotItem['assignments']
|
|
assignments: DutySlotItem['assignments']
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const formSlots = reactive<FormSlot[]>(
|
|
|
|
|
- SHIFT_TYPES.map((t) => ({
|
|
|
|
|
|
|
+ function defaultTimesFor(shiftType: string) {
|
|
|
|
|
+ if (shiftType === 'morning') return { startTime: '08:00', endTime: '18:00', crossDay: false }
|
|
|
|
|
+ if (shiftType === 'evening') return { startTime: '18:00', endTime: '07:00', crossDay: true }
|
|
|
|
|
+ return { startTime: null as string | null, endTime: null as string | null, crossDay: false }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function blankFormSlots(): FormSlot[] {
|
|
|
|
|
+ return PRESET_TYPES.map((t) => ({
|
|
|
shiftType: t,
|
|
shiftType: t,
|
|
|
- enabled: false,
|
|
|
|
|
|
|
+ enabled: t === 'morning' || t === 'evening',
|
|
|
capacity: 1,
|
|
capacity: 1,
|
|
|
|
|
+ workPlans: [],
|
|
|
remark: '',
|
|
remark: '',
|
|
|
|
|
+ ...defaultTimesFor(t),
|
|
|
pendingUserId: '',
|
|
pendingUserId: '',
|
|
|
assignments: []
|
|
assignments: []
|
|
|
}))
|
|
}))
|
|
|
- )
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const formSlots = ref<FormSlot[]>(blankFormSlots())
|
|
|
|
|
+
|
|
|
|
|
+ function isPresetShift(t: string): t is PresetShift {
|
|
|
|
|
+ return (PRESET_TYPES as readonly string[]).includes(t)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- function shiftLabel(t: ShiftType) {
|
|
|
|
|
- return SHIFT_LABELS[t]
|
|
|
|
|
|
|
+ function shiftChipCls(t: string) {
|
|
|
|
|
+ return SHIFT_CLS[t] || 'other'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function shiftLabel(t: string) {
|
|
|
|
|
+ return shiftTypeOptions.value.find((o) => o.value === t)?.label || SHIFT_LABELS[t] || t
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function pad(n: number) {
|
|
function pad(n: number) {
|
|
@@ -361,7 +477,6 @@
|
|
|
return `${y}-${pad(m)}-${pad(d)}`
|
|
return `${y}-${pad(m)}-${pad(d)}`
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** 统计/过滤统一按展示姓名合并,避免同人早班有 userId、晚班无 userId 时拆成两条 */
|
|
|
|
|
function personDisplayName(a: { userId?: string | null; personName?: string }) {
|
|
function personDisplayName(a: { userId?: string | null; personName?: string }) {
|
|
|
return (a.personName || '').trim()
|
|
return (a.personName || '').trim()
|
|
|
}
|
|
}
|
|
@@ -374,12 +489,23 @@
|
|
|
return personDisplayName(a) === nameKey
|
|
return personDisplayName(a) === nameKey
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function matchesShiftFilter(shiftType: string, filter: FilterShift) {
|
|
|
|
|
+ if (!filter) return true
|
|
|
|
|
+ if (filter === 'other') return !isPresetShift(shiftType)
|
|
|
|
|
+ return shiftType === filter
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const availableShiftOptions = computed(() => {
|
|
|
|
|
+ const used = new Set(formSlots.value.map((s) => s.shiftType))
|
|
|
|
|
+ return shiftTypeOptions.value.filter((o) => !used.has(o.value))
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
const personStats = computed(() => {
|
|
const personStats = computed(() => {
|
|
|
const map = new Map<string, { key: string; name: string; count: number }>()
|
|
const map = new Map<string, { key: string; name: string; count: number }>()
|
|
|
const shiftFilter = filterShiftType.value
|
|
const shiftFilter = filterShiftType.value
|
|
|
for (const day of monthDays.value) {
|
|
for (const day of monthDays.value) {
|
|
|
for (const slot of day.slots || []) {
|
|
for (const slot of day.slots || []) {
|
|
|
- if (shiftFilter && slot.shiftType !== shiftFilter) continue
|
|
|
|
|
|
|
+ if (!matchesShiftFilter(slot.shiftType, shiftFilter)) continue
|
|
|
for (const a of slot.assignments || []) {
|
|
for (const a of slot.assignments || []) {
|
|
|
const name = personDisplayName(a)
|
|
const name = personDisplayName(a)
|
|
|
if (!name) continue
|
|
if (!name) continue
|
|
@@ -395,26 +521,32 @@
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- /** 当月各类班次占位次数(与人员统计同口径) */
|
|
|
|
|
const monthShiftStats = computed(() => {
|
|
const monthShiftStats = computed(() => {
|
|
|
- const counts = { morning: 0, evening: 0, duty: 0 }
|
|
|
|
|
|
|
+ const counts = { morning: 0, evening: 0, duty: 0, other: 0 }
|
|
|
for (const day of monthDays.value) {
|
|
for (const day of monthDays.value) {
|
|
|
for (const slot of day.slots || []) {
|
|
for (const slot of day.slots || []) {
|
|
|
const n = (slot.assignments || []).length
|
|
const n = (slot.assignments || []).length
|
|
|
if (slot.shiftType === 'morning') counts.morning += n
|
|
if (slot.shiftType === 'morning') counts.morning += n
|
|
|
else if (slot.shiftType === 'evening') counts.evening += n
|
|
else if (slot.shiftType === 'evening') counts.evening += n
|
|
|
else if (slot.shiftType === 'duty') counts.duty += n
|
|
else if (slot.shiftType === 'duty') counts.duty += n
|
|
|
|
|
+ else counts.other += n
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
return {
|
|
return {
|
|
|
...counts,
|
|
...counts,
|
|
|
- total: counts.morning + counts.evening + counts.duty
|
|
|
|
|
|
|
+ total: counts.morning + counts.evening + counts.duty + counts.other
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- /** 统计视图:每人各班次次数卡片 */
|
|
|
|
|
const personCards = computed(() => {
|
|
const personCards = computed(() => {
|
|
|
- type Acc = { key: string; name: string; morning: number; evening: number; duty: number }
|
|
|
|
|
|
|
+ type Acc = {
|
|
|
|
|
+ key: string
|
|
|
|
|
+ name: string
|
|
|
|
|
+ morning: number
|
|
|
|
|
+ evening: number
|
|
|
|
|
+ duty: number
|
|
|
|
|
+ other: number
|
|
|
|
|
+ }
|
|
|
const map = new Map<string, Acc>()
|
|
const map = new Map<string, Acc>()
|
|
|
for (const day of monthDays.value) {
|
|
for (const day of monthDays.value) {
|
|
|
for (const slot of day.slots || []) {
|
|
for (const slot of day.slots || []) {
|
|
@@ -423,19 +555,20 @@
|
|
|
if (!name) continue
|
|
if (!name) continue
|
|
|
let row = map.get(name)
|
|
let row = map.get(name)
|
|
|
if (!row) {
|
|
if (!row) {
|
|
|
- row = { key: name, name, morning: 0, evening: 0, duty: 0 }
|
|
|
|
|
|
|
+ row = { key: name, name, morning: 0, evening: 0, duty: 0, other: 0 }
|
|
|
map.set(name, row)
|
|
map.set(name, row)
|
|
|
}
|
|
}
|
|
|
if (slot.shiftType === 'morning') row.morning += 1
|
|
if (slot.shiftType === 'morning') row.morning += 1
|
|
|
else if (slot.shiftType === 'evening') row.evening += 1
|
|
else if (slot.shiftType === 'evening') row.evening += 1
|
|
|
else if (slot.shiftType === 'duty') row.duty += 1
|
|
else if (slot.shiftType === 'duty') row.duty += 1
|
|
|
|
|
+ else row.other += 1
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
return Array.from(map.values())
|
|
return Array.from(map.values())
|
|
|
.map((row) => ({
|
|
.map((row) => ({
|
|
|
...row,
|
|
...row,
|
|
|
- total: row.morning + row.evening + row.duty,
|
|
|
|
|
|
|
+ total: row.morning + row.evening + row.duty + row.other,
|
|
|
initial: row.name.slice(0, 1)
|
|
initial: row.name.slice(0, 1)
|
|
|
}))
|
|
}))
|
|
|
.sort((a, b) => {
|
|
.sort((a, b) => {
|
|
@@ -502,29 +635,28 @@
|
|
|
const chips: Array<{ text: string; cls: string; title?: string }> = []
|
|
const chips: Array<{ text: string; cls: string; title?: string }> = []
|
|
|
if (summary?.slots?.length) {
|
|
if (summary?.slots?.length) {
|
|
|
for (const s of summary.slots) {
|
|
for (const s of summary.slots) {
|
|
|
- if (shiftFilter && s.shiftType !== shiftFilter) continue
|
|
|
|
|
- const matched = (s.assignments || []).filter((a) =>
|
|
|
|
|
- assignmentMatchesFilter(a, filterKey)
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ if (!matchesShiftFilter(s.shiftType, shiftFilter)) continue
|
|
|
|
|
+ const matched = (s.assignments || []).filter((a) => assignmentMatchesFilter(a, filterKey))
|
|
|
if (filterKey && !matched.length) continue
|
|
if (filterKey && !matched.length) continue
|
|
|
const names = matched.map((a) => a.personName).filter(Boolean)
|
|
const names = matched.map((a) => a.personName).filter(Boolean)
|
|
|
const taken = s.taken ?? (s.assignments || []).length
|
|
const taken = s.taken ?? (s.assignments || []).length
|
|
|
- const remark = (s.remark || '').trim()
|
|
|
|
|
|
|
+ const label = s.shiftLabel || shiftLabel(s.shiftType)
|
|
|
|
|
+ const planText = (s.workPlanLabels || []).filter(Boolean).join('、')
|
|
|
let text: string
|
|
let text: string
|
|
|
if (filterKey) {
|
|
if (filterKey) {
|
|
|
- text = `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${names.join('、')}`
|
|
|
|
|
|
|
+ text = `${label} ${names.join('、')}`
|
|
|
} else if (names.length) {
|
|
} else if (names.length) {
|
|
|
- text = `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${names.slice(0, 2).join('、')}${names.length > 2 ? '…' : ''}`
|
|
|
|
|
|
|
+ text = `${label} ${names.slice(0, 2).join('、')}${names.length > 2 ? '…' : ''}`
|
|
|
} else {
|
|
} else {
|
|
|
- text = `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${taken}/${s.capacity}`
|
|
|
|
|
|
|
+ text = `${label} ${taken}/${s.capacity}`
|
|
|
}
|
|
}
|
|
|
- if (remark) {
|
|
|
|
|
- text = `${text} · ${remark}`
|
|
|
|
|
|
|
+ if (planText) {
|
|
|
|
|
+ text = `${text} · ${planText}`
|
|
|
}
|
|
}
|
|
|
chips.push({
|
|
chips.push({
|
|
|
text,
|
|
text,
|
|
|
- cls: SHIFT_CLS[s.shiftType] || 'am',
|
|
|
|
|
- title: remark || undefined
|
|
|
|
|
|
|
+ cls: shiftChipCls(s.shiftType),
|
|
|
|
|
+ title: planText || undefined
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
if (!hasFilter && !chips.length) {
|
|
if (!hasFilter && !chips.length) {
|
|
@@ -560,7 +692,7 @@
|
|
|
filterPersonKey.value = filterPersonKey.value === key ? '' : key
|
|
filterPersonKey.value = filterPersonKey.value === key ? '' : key
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function toggleShiftFilter(shift: ShiftType) {
|
|
|
|
|
|
|
+ function toggleShiftFilter(shift: Exclude<FilterShift, ''>) {
|
|
|
if (viewMode.value === 'stats') {
|
|
if (viewMode.value === 'stats') {
|
|
|
viewMode.value = 'calendar'
|
|
viewMode.value = 'calendar'
|
|
|
}
|
|
}
|
|
@@ -580,6 +712,26 @@
|
|
|
viewMode.value = 'stats'
|
|
viewMode.value = 'stats'
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ async function loadDictOptions() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const [shifts, plans] = await Promise.all([
|
|
|
|
|
+ fetchDictDataOptions('duty_shift_type'),
|
|
|
|
|
+ fetchDictDataOptions('duty_work_plan')
|
|
|
|
|
+ ])
|
|
|
|
|
+ shiftTypeOptions.value = (shifts || []).map((x) => ({
|
|
|
|
|
+ label: x.label,
|
|
|
|
|
+ value: x.value
|
|
|
|
|
+ }))
|
|
|
|
|
+ workPlanOptions.value = (plans || []).map((x) => ({
|
|
|
|
|
+ label: x.label,
|
|
|
|
|
+ value: x.value
|
|
|
|
|
+ }))
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ shiftTypeOptions.value = []
|
|
|
|
|
+ workPlanOptions.value = []
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async function loadMonth() {
|
|
async function loadMonth() {
|
|
|
loading.value = true
|
|
loading.value = true
|
|
|
filterPersonKey.value = ''
|
|
filterPersonKey.value = ''
|
|
@@ -616,21 +768,113 @@
|
|
|
void loadMonth()
|
|
void loadMonth()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function applyFoundToFormSlot(fs: FormSlot, found: DutySlotItem) {
|
|
|
|
|
+ fs.enabled = true
|
|
|
|
|
+ fs.capacity = found.capacity || 1
|
|
|
|
|
+ fs.workPlans = [...(found.workPlans || [])]
|
|
|
|
|
+ fs.remark = (found.remark || '').trim()
|
|
|
|
|
+ fs.startTime = found.startTime || null
|
|
|
|
|
+ fs.endTime = found.endTime || null
|
|
|
|
|
+ fs.crossDay = !!found.crossDay
|
|
|
|
|
+ fs.pendingUserId = ''
|
|
|
|
|
+ fs.assignments = found ? [...(found.assignments || [])] : []
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function resetFormSlots(detail: DutyDayItem | null) {
|
|
function resetFormSlots(detail: DutyDayItem | null) {
|
|
|
- for (const fs of formSlots) {
|
|
|
|
|
- const found = detail?.slots?.find((s) => s.shiftType === fs.shiftType)
|
|
|
|
|
- fs.enabled = !!found
|
|
|
|
|
- fs.capacity = found?.capacity || 1
|
|
|
|
|
- fs.remark = (found?.remark || '').trim()
|
|
|
|
|
- fs.pendingUserId = ''
|
|
|
|
|
- fs.assignments = found ? [...(found.assignments || [])] : []
|
|
|
|
|
- }
|
|
|
|
|
- if (!detail?.slots?.length) {
|
|
|
|
|
- // 默认启用早班晚班各 1
|
|
|
|
|
- formSlots[0].enabled = true
|
|
|
|
|
- formSlots[0].capacity = 1
|
|
|
|
|
- formSlots[1].enabled = true
|
|
|
|
|
- formSlots[1].capacity = 1
|
|
|
|
|
|
|
+ const slots = blankFormSlots()
|
|
|
|
|
+ if (detail?.slots?.length) {
|
|
|
|
|
+ for (const fs of slots) {
|
|
|
|
|
+ const found = detail.slots.find((s) => s.shiftType === fs.shiftType)
|
|
|
|
|
+ if (found) applyFoundToFormSlot(fs, found)
|
|
|
|
|
+ else fs.enabled = false
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const s of detail.slots) {
|
|
|
|
|
+ if (isPresetShift(s.shiftType)) continue
|
|
|
|
|
+ if (slots.some((x) => x.shiftType === s.shiftType)) continue
|
|
|
|
|
+ const custom: FormSlot = {
|
|
|
|
|
+ shiftType: s.shiftType,
|
|
|
|
|
+ enabled: true,
|
|
|
|
|
+ capacity: 1,
|
|
|
|
|
+ workPlans: [],
|
|
|
|
|
+ remark: '',
|
|
|
|
|
+ ...defaultTimesFor(s.shiftType),
|
|
|
|
|
+ pendingUserId: '',
|
|
|
|
|
+ assignments: []
|
|
|
|
|
+ }
|
|
|
|
|
+ applyFoundToFormSlot(custom, s)
|
|
|
|
|
+ slots.push(custom)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ formSlots.value = slots
|
|
|
|
|
+ addShiftDraft.value = ''
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function syncCrossDay(slot: FormSlot) {
|
|
|
|
|
+ const start = slot.startTime || ''
|
|
|
|
|
+ const end = slot.endTime || ''
|
|
|
|
|
+ if (start && end && end < start) {
|
|
|
|
|
+ slot.crossDay = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function resolveDictValue(
|
|
|
|
|
+ dictType: 'duty_shift_type' | 'duty_work_plan',
|
|
|
|
|
+ raw: string,
|
|
|
|
|
+ options: Array<{ label: string; value: string }>
|
|
|
|
|
+ ) {
|
|
|
|
|
+ const existing = options.find((o) => o.value === raw || o.label === raw)
|
|
|
|
|
+ if (existing) return existing.value
|
|
|
|
|
+ const res = await ensureDutyDict({ dictType, label: raw })
|
|
|
|
|
+ if (!options.some((o) => o.value === res.value)) {
|
|
|
|
|
+ options.push({ label: res.label || raw, value: res.value })
|
|
|
|
|
+ }
|
|
|
|
|
+ return res.value
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function onWorkPlansChange(slot: FormSlot, values: string[]) {
|
|
|
|
|
+ const next: string[] = []
|
|
|
|
|
+ for (const raw of values || []) {
|
|
|
|
|
+ const v = String(raw || '').trim()
|
|
|
|
|
+ if (!v) continue
|
|
|
|
|
+ try {
|
|
|
|
|
+ const value = await resolveDictValue('duty_work_plan', v, workPlanOptions.value)
|
|
|
|
|
+ if (!next.includes(value)) next.push(value)
|
|
|
|
|
+ } catch (e: any) {
|
|
|
|
|
+ ElMessage.error(e?.message || '创建工作安排失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ slot.workPlans = next
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function confirmAddShift() {
|
|
|
|
|
+ const raw = String(addShiftDraft.value || '').trim()
|
|
|
|
|
+ if (!raw) {
|
|
|
|
|
+ ElMessage.warning('请选择或输入班次类型')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ addingShift.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const shiftType = await resolveDictValue('duty_shift_type', raw, shiftTypeOptions.value)
|
|
|
|
|
+ if (formSlots.value.some((s) => s.shiftType === shiftType)) {
|
|
|
|
|
+ ElMessage.warning('该班次类型当日已存在,不可重复')
|
|
|
|
|
+ addShiftDraft.value = ''
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ formSlots.value.push({
|
|
|
|
|
+ shiftType,
|
|
|
|
|
+ enabled: true,
|
|
|
|
|
+ capacity: 1,
|
|
|
|
|
+ workPlans: [],
|
|
|
|
|
+ remark: '',
|
|
|
|
|
+ ...defaultTimesFor(shiftType),
|
|
|
|
|
+ pendingUserId: '',
|
|
|
|
|
+ assignments: []
|
|
|
|
|
+ })
|
|
|
|
|
+ addShiftDraft.value = ''
|
|
|
|
|
+ } catch (e: any) {
|
|
|
|
|
+ ElMessage.error(e?.message || '添加班次失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ addingShift.value = false
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -684,20 +928,28 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function handleSave() {
|
|
async function handleSave() {
|
|
|
- const slots = formSlots
|
|
|
|
|
- .filter((s) => s.enabled)
|
|
|
|
|
- .map((s) => ({
|
|
|
|
|
- shiftType: s.shiftType,
|
|
|
|
|
- capacity: s.capacity,
|
|
|
|
|
- remark: s.remark.trim() || null,
|
|
|
|
|
- assignments: s.assignments
|
|
|
|
|
- .filter((a) => a.source === 'assign')
|
|
|
|
|
- .map((a) => ({
|
|
|
|
|
- source: 'assign' as const,
|
|
|
|
|
- userId: a.userId || undefined,
|
|
|
|
|
- personName: a.personName
|
|
|
|
|
- }))
|
|
|
|
|
- }))
|
|
|
|
|
|
|
+ const enabled = formSlots.value.filter((s) => s.enabled)
|
|
|
|
|
+ const types = enabled.map((s) => s.shiftType)
|
|
|
|
|
+ if (new Set(types).size !== types.length) {
|
|
|
|
|
+ ElMessage.warning('同一天不能重复配置相同班次类型')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const slots = enabled.map((s) => ({
|
|
|
|
|
+ shiftType: s.shiftType,
|
|
|
|
|
+ capacity: s.capacity,
|
|
|
|
|
+ workPlans: s.workPlans || [],
|
|
|
|
|
+ remark: s.remark.trim() || null,
|
|
|
|
|
+ startTime: s.startTime || null,
|
|
|
|
|
+ endTime: s.endTime || null,
|
|
|
|
|
+ crossDay: !!s.crossDay,
|
|
|
|
|
+ assignments: s.assignments
|
|
|
|
|
+ .filter((a) => a.source === 'assign')
|
|
|
|
|
+ .map((a) => ({
|
|
|
|
|
+ source: 'assign' as const,
|
|
|
|
|
+ userId: a.userId || undefined,
|
|
|
|
|
+ personName: a.personName
|
|
|
|
|
+ }))
|
|
|
|
|
+ }))
|
|
|
if (!slots.length) {
|
|
if (!slots.length) {
|
|
|
ElMessage.warning('请至少启用一个班次')
|
|
ElMessage.warning('请至少启用一个班次')
|
|
|
return
|
|
return
|
|
@@ -744,6 +996,7 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
onMounted(async () => {
|
|
|
|
|
+ await loadDictOptions()
|
|
|
try {
|
|
try {
|
|
|
const opts = await fetchDutyUserOptions()
|
|
const opts = await fetchDutyUserOptions()
|
|
|
userOptions.value = (Array.isArray(opts) ? opts : []) as Array<{
|
|
userOptions.value = (Array.isArray(opts) ? opts : []) as Array<{
|
|
@@ -804,6 +1057,9 @@
|
|
|
.dot.duty {
|
|
.dot.duty {
|
|
|
background: #ef4444;
|
|
background: #ef4444;
|
|
|
}
|
|
}
|
|
|
|
|
+ .dot.other {
|
|
|
|
|
+ background: #64748b;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
.filter-row {
|
|
.filter-row {
|
|
|
display: flex;
|
|
display: flex;
|
|
@@ -957,6 +1213,9 @@
|
|
|
gap: 8px;
|
|
gap: 8px;
|
|
|
margin-bottom: 12px;
|
|
margin-bottom: 12px;
|
|
|
}
|
|
}
|
|
|
|
|
+ .stats-breakdown.has-other {
|
|
|
|
|
+ grid-template-columns: repeat(4, 1fr);
|
|
|
|
|
+ }
|
|
|
.stats-breakdown .cell {
|
|
.stats-breakdown .cell {
|
|
|
padding: 10px 8px;
|
|
padding: 10px 8px;
|
|
|
text-align: center;
|
|
text-align: center;
|
|
@@ -986,6 +1245,9 @@
|
|
|
.stats-breakdown .cell.duty {
|
|
.stats-breakdown .cell.duty {
|
|
|
background: #fef2f2;
|
|
background: #fef2f2;
|
|
|
}
|
|
}
|
|
|
|
|
+ .stats-breakdown .cell.other {
|
|
|
|
|
+ background: #f1f5f9;
|
|
|
|
|
+ }
|
|
|
.stats-bar {
|
|
.stats-bar {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
height: 8px;
|
|
height: 8px;
|
|
@@ -1006,6 +1268,9 @@
|
|
|
.stats-bar .seg.duty {
|
|
.stats-bar .seg.duty {
|
|
|
background: #ef4444;
|
|
background: #ef4444;
|
|
|
}
|
|
}
|
|
|
|
|
+ .stats-bar .seg.other {
|
|
|
|
|
+ background: #64748b;
|
|
|
|
|
+ }
|
|
|
.stats-card-ft {
|
|
.stats-card-ft {
|
|
|
margin-top: 10px;
|
|
margin-top: 10px;
|
|
|
font-size: 11px;
|
|
font-size: 11px;
|
|
@@ -1144,6 +1409,10 @@
|
|
|
color: #b91c1c;
|
|
color: #b91c1c;
|
|
|
background: #fef2f2;
|
|
background: #fef2f2;
|
|
|
}
|
|
}
|
|
|
|
|
+ .chip.other {
|
|
|
|
|
+ color: #475569;
|
|
|
|
|
+ background: #f1f5f9;
|
|
|
|
|
+ }
|
|
|
.chip.empty {
|
|
.chip.empty {
|
|
|
color: var(--mint-text-muted, #64748b);
|
|
color: var(--mint-text-muted, #64748b);
|
|
|
background: #f1f5f9;
|
|
background: #f1f5f9;
|
|
@@ -1172,7 +1441,7 @@
|
|
|
justify-content: space-between;
|
|
justify-content: space-between;
|
|
|
margin-bottom: 8px;
|
|
margin-bottom: 8px;
|
|
|
}
|
|
}
|
|
|
- .slot-assign-row {
|
|
|
|
|
|
|
+ .slot-fields-row {
|
|
|
margin-bottom: 4px;
|
|
margin-bottom: 4px;
|
|
|
:deep(.el-form-item) {
|
|
:deep(.el-form-item) {
|
|
|
margin-bottom: 0;
|
|
margin-bottom: 0;
|
|
@@ -1194,6 +1463,13 @@
|
|
|
font-size: 12px;
|
|
font-size: 12px;
|
|
|
color: var(--mint-text-muted, #94a3b8);
|
|
color: var(--mint-text-muted, #94a3b8);
|
|
|
}
|
|
}
|
|
|
|
|
+ .add-shift-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ margin: 4px 0 12px;
|
|
|
|
|
+ }
|
|
|
.link-box {
|
|
.link-box {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
flex-wrap: wrap;
|