feat: configure booking capacity
This commit is contained in:
parent
1dce8e94d0
commit
7e924fa79d
@ -43,7 +43,7 @@ export const updateStore = (data: Record<string, unknown>) => apiPut('/store/upd
|
||||
export const getScheduleDay = (date: string) =>
|
||||
apiGet('/schedule/day', { date })
|
||||
|
||||
export const createScheduleBlock = (data: { slotStart: string; blockType: string; note?: string }) =>
|
||||
export const createScheduleBlock = (data: { slotStart: string; durationMinutes: number; blockType: string; note?: string }) =>
|
||||
apiPost('/schedule/block', data)
|
||||
|
||||
export const deleteScheduleBlock = (id: number) =>
|
||||
@ -62,11 +62,11 @@ export const deleteStaff = (staffId: number) =>
|
||||
export const getServiceTypeList = (storeId?: number | null) =>
|
||||
apiGet('/service-type/list', storeId != null ? { storeId } : {})
|
||||
|
||||
export const createServiceType = (name: string) =>
|
||||
apiPost('/service-type/create', { name })
|
||||
export const createServiceType = (name: string, durationMinutes: number) =>
|
||||
apiPost('/service-type/create', { name, durationMinutes })
|
||||
|
||||
export const updateServiceType = (id: number, name: string) =>
|
||||
apiPut('/service-type/update', { id, name })
|
||||
export const updateServiceType = (id: number, name: string, durationMinutes: number) =>
|
||||
apiPut('/service-type/update', { id, name, durationMinutes })
|
||||
|
||||
export const deleteServiceType = (id: number) =>
|
||||
apiDelete('/service-type/delete', { id })
|
||||
|
||||
@ -26,7 +26,9 @@
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="appointmentTime" label="预约时间" min-width="160" />
|
||||
<el-table-column label="预约时间" min-width="210">
|
||||
<template #default="{ row }">{{ appointmentRange(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">{{ statusLabel(row.status) }}</template>
|
||||
</el-table-column>
|
||||
@ -62,7 +64,7 @@
|
||||
<el-drawer v-model="drawer" title="预约详情" size="420px">
|
||||
<template v-if="current">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="时间">{{ current.appointmentTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时间">{{ appointmentRange(current) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(current.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宠主">{{ current.customerName }} {{ current.customerPhoneMasked }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宠物">{{ current.petName }} / {{ current.petType }}</el-descriptions-item>
|
||||
@ -101,6 +103,8 @@ import {
|
||||
type Appt = {
|
||||
id: number
|
||||
appointmentTime?: string
|
||||
endTime?: string
|
||||
durationMinutes?: number
|
||||
status?: string
|
||||
petName?: string
|
||||
petType?: string
|
||||
@ -128,6 +132,13 @@ const dateFilter = ref((route.query.date as string) || '')
|
||||
const dateRange = ref<[string, string] | ''>('')
|
||||
const staffId = ref<number | undefined>()
|
||||
const missingReport = ref(route.query.missingReport === '1')
|
||||
|
||||
function appointmentRange(row: Appt): string {
|
||||
const start = String(row.appointmentTime || '').replace('T', ' ').slice(0, 16)
|
||||
const end = String(row.endTime || '').slice(11, 16)
|
||||
if (!start) return '—'
|
||||
return end ? `${start}–${end}` : start
|
||||
}
|
||||
const staffOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
const drawer = ref(false)
|
||||
const current = ref<Appt | null>(null)
|
||||
|
||||
@ -7,11 +7,12 @@
|
||||
<el-button @click="openCreate">新增占用</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="time" label="时段" width="100" />
|
||||
<el-table-column prop="time" label="容量档" width="100" />
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{ row }">{{ kindLabel(row.kind, row.blockType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="内容" min-width="240" />
|
||||
<el-table-column prop="capacityText" label="已用/总容量" width="130" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
@ -35,15 +36,25 @@
|
||||
<el-form-item label="时段">
|
||||
<el-select v-model="createForm.slotStart" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in emptySlots"
|
||||
v-for="opt in candidateSlots"
|
||||
:key="opt.slotStart"
|
||||
:label="opt.time"
|
||||
:value="opt.slotStart"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="占用时长">
|
||||
<el-input-number
|
||||
v-model="createForm.durationMinutes"
|
||||
:min="30"
|
||||
:max="480"
|
||||
:step="30"
|
||||
@change="onBlockTypeChange"
|
||||
/>
|
||||
<span style="margin-left: 8px">分钟</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-radio-group v-model="createForm.blockType">
|
||||
<el-radio-group v-model="createForm.blockType" @change="onBlockTypeChange">
|
||||
<el-radio value="walk_in">到店占用</el-radio>
|
||||
<el-radio value="blocked">暂停预约</el-radio>
|
||||
</el-radio-group>
|
||||
@ -73,6 +84,10 @@ type Row = {
|
||||
blockId?: number
|
||||
title: string
|
||||
past?: boolean
|
||||
usedCapacity: number
|
||||
totalCapacity: number
|
||||
remainingCapacity: number
|
||||
capacityText: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
@ -83,12 +98,21 @@ const dialogVisible = ref(false)
|
||||
const createForm = reactive({
|
||||
slotStart: '',
|
||||
blockType: 'blocked',
|
||||
durationMinutes: 30,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const emptySlots = computed(() =>
|
||||
rows.value.filter((r) => r.kind === 'empty' && !r.past),
|
||||
)
|
||||
const candidateSlots = computed(() => {
|
||||
const bucketCount = Math.max(1, Number(createForm.durationMinutes || 30) / 30)
|
||||
return rows.value.filter((_row, index) => {
|
||||
const covered = rows.value.slice(index, index + bucketCount)
|
||||
if (covered.length !== bucketCount || covered.some((r) => r.past)) return false
|
||||
if (createForm.blockType === 'blocked') {
|
||||
return covered.every((r) => r.usedCapacity === 0 && r.kind === 'empty')
|
||||
}
|
||||
return covered.every((r) => r.remainingCapacity > 0)
|
||||
})
|
||||
})
|
||||
|
||||
function kindLabel(kind: string, blockType?: string) {
|
||||
if (kind === 'appointment') return '预约'
|
||||
@ -111,6 +135,9 @@ async function load() {
|
||||
const kind = String(r.kind || 'empty')
|
||||
const appt = r.appointment as Record<string, unknown> | null | undefined
|
||||
const block = r.block as Record<string, unknown> | null | undefined
|
||||
const usedCapacity = Number(r.usedCapacity || 0)
|
||||
const totalCapacity = Number(r.totalCapacity || 1)
|
||||
const remainingCapacity = Number(r.remainingCapacity || 0)
|
||||
let title = '—'
|
||||
let blockType: string | undefined
|
||||
let blockId: number | undefined
|
||||
@ -131,6 +158,10 @@ async function load() {
|
||||
blockId,
|
||||
title,
|
||||
past: Boolean(r.past),
|
||||
usedCapacity,
|
||||
totalCapacity,
|
||||
remainingCapacity,
|
||||
capacityText: kind === 'block' && blockType === 'blocked' ? '暂停' : `${usedCapacity}/${totalCapacity}`,
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
@ -139,15 +170,23 @@ async function load() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.slotStart = emptySlots.value[0]?.slotStart || ''
|
||||
createForm.blockType = 'blocked'
|
||||
createForm.durationMinutes = 30
|
||||
createForm.slotStart = candidateSlots.value[0]?.slotStart || ''
|
||||
createForm.note = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onBlockTypeChange() {
|
||||
if (!candidateSlots.value.some((r) => r.slotStart === createForm.slotStart)) {
|
||||
createForm.slotStart = candidateSlots.value[0]?.slotStart || ''
|
||||
}
|
||||
}
|
||||
|
||||
function quickBlock(row: Row) {
|
||||
createForm.slotStart = row.slotStart
|
||||
createForm.blockType = 'blocked'
|
||||
createForm.durationMinutes = 30
|
||||
createForm.note = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@ -161,6 +200,7 @@ async function submitCreate() {
|
||||
try {
|
||||
const res = await createScheduleBlock({
|
||||
slotStart: createForm.slotStart,
|
||||
durationMinutes: createForm.durationMinutes,
|
||||
blockType: createForm.blockType,
|
||||
note: createForm.note || undefined,
|
||||
})
|
||||
|
||||
@ -34,6 +34,10 @@
|
||||
:disabled="!isBoss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="并发接待数">
|
||||
<el-input-number v-model="form.bookingCapacity" :min="1" :max="10" :disabled="!isBoss" />
|
||||
<span style="margin-left: 10px; color: var(--el-text-color-secondary)">同一时段最多同时服务的顾客数</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="邀请码">
|
||||
<div class="invite-row">
|
||||
<el-input v-model="form.inviteCode" readonly />
|
||||
@ -50,6 +54,8 @@
|
||||
<el-tab-pane label="服务类型" name="service">
|
||||
<div class="filter-row" v-if="isBoss">
|
||||
<el-input v-model="newServiceName" placeholder="新服务类型名称" style="width: 220px" />
|
||||
<el-input-number v-model="newServiceDuration" :min="30" :max="480" :step="30" />
|
||||
<span>分钟</span>
|
||||
<el-button type="primary" @click="addService">新增</el-button>
|
||||
</div>
|
||||
<el-table :data="serviceTypes" v-loading="loadingMeta" stripe>
|
||||
@ -57,14 +63,17 @@
|
||||
<el-table-column label="范围" width="120">
|
||||
<template #default="{ row }">{{ row.storeId == null ? '系统默认' : '本店' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预计时长" width="140">
|
||||
<template #default="{ row }">{{ row.durationMinutes }} 分钟</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isBoss" label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.storeId != null"
|
||||
link
|
||||
type="primary"
|
||||
@click="renameService(row)"
|
||||
>改名</el-button>
|
||||
@click="editService(row)"
|
||||
>编辑</el-button>
|
||||
<el-button
|
||||
v-if="row.storeId != null"
|
||||
link
|
||||
@ -134,11 +143,13 @@ const form = reactive({
|
||||
inviteCode: '',
|
||||
bookingDayStart: '09:00',
|
||||
bookingLastSlotStart: '21:30',
|
||||
bookingCapacity: 1,
|
||||
})
|
||||
|
||||
const serviceTypes = ref<Array<Record<string, unknown>>>([])
|
||||
const staff = ref<Array<Record<string, unknown>>>([])
|
||||
const newServiceName = ref('')
|
||||
const newServiceDuration = ref(60)
|
||||
const newStaffName = ref('')
|
||||
const newStaffPhone = ref('')
|
||||
|
||||
@ -165,6 +176,7 @@ async function loadStore() {
|
||||
form.inviteCode = String(d.inviteCode || '')
|
||||
form.bookingDayStart = normTime(d.bookingDayStart)
|
||||
form.bookingLastSlotStart = normTime(d.bookingLastSlotStart)
|
||||
form.bookingCapacity = Number(d.bookingCapacity || 1)
|
||||
const token = getToken()
|
||||
const u = getUser()
|
||||
if (token && u) {
|
||||
@ -204,6 +216,7 @@ async function saveStore() {
|
||||
intro: form.intro,
|
||||
bookingDayStart: form.bookingDayStart,
|
||||
bookingLastSlotStart: form.bookingLastSlotStart,
|
||||
bookingCapacity: form.bookingCapacity,
|
||||
})
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '保存失败')
|
||||
@ -225,19 +238,26 @@ async function copyInvite() {
|
||||
async function addService() {
|
||||
const name = newServiceName.value.trim()
|
||||
if (!name) return
|
||||
const res = await createServiceType(name)
|
||||
const duration = Number(newServiceDuration.value)
|
||||
const res = await createServiceType(name, duration)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '新增失败')
|
||||
return
|
||||
}
|
||||
newServiceName.value = ''
|
||||
newServiceDuration.value = 60
|
||||
ElMessage.success('已新增')
|
||||
await loadMeta()
|
||||
}
|
||||
|
||||
async function renameService(row: Record<string, unknown>) {
|
||||
async function editService(row: Record<string, unknown>) {
|
||||
const { value } = await ElMessageBox.prompt('新名称', '改名', { inputValue: String(row.name || '') })
|
||||
const res = await updateServiceType(Number(row.id), value)
|
||||
const durationPrompt = await ElMessageBox.prompt('请输入 30~480 分钟,且为 30 的倍数', '预计服务时长', {
|
||||
inputValue: String(row.durationMinutes || 60),
|
||||
inputPattern: /^(30|60|90|120|150|180|210|240|270|300|330|360|390|420|450|480)$/,
|
||||
inputErrorMessage: '请输入 30~480 的 30 分钟倍数',
|
||||
})
|
||||
const res = await updateServiceType(Number(row.id), value, Number(durationPrompt.value))
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '改名失败')
|
||||
return
|
||||
|
||||
Loading…
Reference in New Issue
Block a user