feat: 完成员工排班模块并统一门店抽屉底部操作样式
This commit is contained in:
186
apps/web-antd/src/api/store-staff/index.ts
Normal file
186
apps/web-antd/src/api/store-staff/index.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 文件职责:员工排班模块 API 与 DTO 定义。
|
||||
* 1. 维护员工、班次模板、排班表类型。
|
||||
* 2. 提供查询、保存、删除、复制接口。
|
||||
*/
|
||||
import type { PaginatedResult } from '#/api/store';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 员工角色 */
|
||||
export type StaffRoleType = 'cashier' | 'chef' | 'courier' | 'manager';
|
||||
|
||||
/** 员工状态 */
|
||||
export type StaffStatus = 'active' | 'leave' | 'resigned';
|
||||
|
||||
/** 班次类型 */
|
||||
export type ShiftType = 'evening' | 'full' | 'morning' | 'off';
|
||||
|
||||
/** 员工档案 */
|
||||
export interface StoreStaffDto {
|
||||
avatarColor: string;
|
||||
email: string;
|
||||
hiredAt: string;
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
phone: string;
|
||||
roleType: StaffRoleType;
|
||||
status: StaffStatus;
|
||||
}
|
||||
|
||||
/** 班次模板条目 */
|
||||
export interface ShiftTemplateItemDto {
|
||||
endTime: string;
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
/** 门店班次模板 */
|
||||
export interface StoreShiftTemplatesDto {
|
||||
evening: ShiftTemplateItemDto;
|
||||
full: ShiftTemplateItemDto;
|
||||
morning: ShiftTemplateItemDto;
|
||||
}
|
||||
|
||||
/** 单日排班 */
|
||||
export interface StaffDayShiftDto {
|
||||
dayOfWeek: number;
|
||||
endTime: string;
|
||||
shiftType: ShiftType;
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
/** 员工排班 */
|
||||
export interface StaffScheduleDto {
|
||||
shifts: StaffDayShiftDto[];
|
||||
staffId: string;
|
||||
}
|
||||
|
||||
/** 门店排班聚合 */
|
||||
export interface StoreStaffScheduleDto {
|
||||
schedules: StaffScheduleDto[];
|
||||
storeId: string;
|
||||
templates: StoreShiftTemplatesDto;
|
||||
weekStartDate: string;
|
||||
}
|
||||
|
||||
/** 员工列表查询参数 */
|
||||
export interface StoreStaffListQuery {
|
||||
keyword?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
roleType?: StaffRoleType;
|
||||
status?: StaffStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存员工参数 */
|
||||
export interface SaveStoreStaffParams {
|
||||
email: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
phone: string;
|
||||
roleType: StaffRoleType;
|
||||
status: StaffStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 删除员工参数 */
|
||||
export interface DeleteStoreStaffParams {
|
||||
staffId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存班次模板参数 */
|
||||
export interface SaveStoreStaffTemplatesParams {
|
||||
storeId: string;
|
||||
templates: StoreShiftTemplatesDto;
|
||||
}
|
||||
|
||||
/** 保存个人排班参数 */
|
||||
export interface SaveStoreStaffPersonalScheduleParams {
|
||||
shifts: StaffDayShiftDto[];
|
||||
staffId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存周排班参数 */
|
||||
export interface SaveStoreStaffWeeklyScheduleParams {
|
||||
schedules: StaffScheduleDto[];
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 复制排班参数 */
|
||||
export interface CopyStoreStaffScheduleParams {
|
||||
copyScope: 'template_and_schedule';
|
||||
sourceStoreId: string;
|
||||
targetStoreIds: string[];
|
||||
}
|
||||
|
||||
/** 获取员工列表 */
|
||||
export async function getStoreStaffListApi(params: StoreStaffListQuery) {
|
||||
return requestClient.get<PaginatedResult<StoreStaffDto>>('/store/staff', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增/编辑员工 */
|
||||
export async function saveStoreStaffApi(data: SaveStoreStaffParams) {
|
||||
return requestClient.post<StoreStaffDto>('/store/staff/save', data);
|
||||
}
|
||||
|
||||
/** 删除员工 */
|
||||
export async function deleteStoreStaffApi(data: DeleteStoreStaffParams) {
|
||||
return requestClient.post('/store/staff/delete', data);
|
||||
}
|
||||
|
||||
/** 获取门店排班配置 */
|
||||
export async function getStoreStaffScheduleApi(
|
||||
storeId: string,
|
||||
weekStartDate?: string,
|
||||
) {
|
||||
return requestClient.get<StoreStaffScheduleDto>('/store/staff/schedule', {
|
||||
params: {
|
||||
storeId,
|
||||
weekStartDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存班次模板 */
|
||||
export async function saveStoreStaffTemplatesApi(
|
||||
data: SaveStoreStaffTemplatesParams,
|
||||
) {
|
||||
return requestClient.post<StoreShiftTemplatesDto>(
|
||||
'/store/staff/template/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存单员工排班 */
|
||||
export async function saveStoreStaffPersonalScheduleApi(
|
||||
data: SaveStoreStaffPersonalScheduleParams,
|
||||
) {
|
||||
return requestClient.post<StaffScheduleDto>(
|
||||
'/store/staff/schedule/personal/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存周排班 */
|
||||
export async function saveStoreStaffWeeklyScheduleApi(
|
||||
data: SaveStoreStaffWeeklyScheduleParams,
|
||||
) {
|
||||
return requestClient.post<StoreStaffScheduleDto>(
|
||||
'/store/staff/schedule/weekly/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 复制班次模板与排班 */
|
||||
export async function copyStoreStaffScheduleApi(
|
||||
data: CopyStoreStaffScheduleParams,
|
||||
) {
|
||||
return requestClient.post('/store/staff/copy', data);
|
||||
}
|
||||
@@ -4,5 +4,6 @@ import './store-dinein';
|
||||
import './store-fees';
|
||||
import './store-hours';
|
||||
import './store-pickup';
|
||||
import './store-staff';
|
||||
|
||||
console.warn('[Mock] Mock 数据已启用');
|
||||
|
||||
919
apps/web-antd/src/mock/store-staff.ts
Normal file
919
apps/web-antd/src/mock/store-staff.ts
Normal file
@@ -0,0 +1,919 @@
|
||||
import Mock from 'mockjs';
|
||||
|
||||
/** 文件职责:员工排班页面 Mock 接口。 */
|
||||
interface MockRequestOptions {
|
||||
body: null | string;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type StaffRoleType = 'cashier' | 'chef' | 'courier' | 'manager';
|
||||
type StaffStatus = 'active' | 'leave' | 'resigned';
|
||||
type ShiftType = 'evening' | 'full' | 'morning' | 'off';
|
||||
|
||||
interface StoreStaffMock {
|
||||
avatarColor: string;
|
||||
email: string;
|
||||
hiredAt: string;
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
phone: string;
|
||||
roleType: StaffRoleType;
|
||||
status: StaffStatus;
|
||||
}
|
||||
|
||||
interface ShiftTemplateItemMock {
|
||||
endTime: string;
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
interface StoreShiftTemplatesMock {
|
||||
evening: ShiftTemplateItemMock;
|
||||
full: ShiftTemplateItemMock;
|
||||
morning: ShiftTemplateItemMock;
|
||||
}
|
||||
|
||||
interface StaffDayShiftMock {
|
||||
dayOfWeek: number;
|
||||
endTime: string;
|
||||
shiftType: ShiftType;
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
interface StaffScheduleMock {
|
||||
shifts: StaffDayShiftMock[];
|
||||
staffId: string;
|
||||
}
|
||||
|
||||
interface StoreStaffState {
|
||||
schedules: StaffScheduleMock[];
|
||||
staffs: StoreStaffMock[];
|
||||
templates: StoreShiftTemplatesMock;
|
||||
weekStartDate: string;
|
||||
}
|
||||
|
||||
const ROLE_VALUES = new Set<StaffRoleType>([
|
||||
'cashier',
|
||||
'chef',
|
||||
'courier',
|
||||
'manager',
|
||||
]);
|
||||
const STATUS_VALUES = new Set<StaffStatus>(['active', 'leave', 'resigned']);
|
||||
const SHIFT_VALUES = new Set<ShiftType>(['evening', 'full', 'morning', 'off']);
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'#f56a00',
|
||||
'#7265e6',
|
||||
'#52c41a',
|
||||
'#fa8c16',
|
||||
'#1890ff',
|
||||
'#bfbfbf',
|
||||
'#13c2c2',
|
||||
'#eb2f96',
|
||||
];
|
||||
|
||||
const DEFAULT_TEMPLATES: StoreShiftTemplatesMock = {
|
||||
morning: {
|
||||
startTime: '09:00',
|
||||
endTime: '14:00',
|
||||
},
|
||||
evening: {
|
||||
startTime: '14:00',
|
||||
endTime: '21:00',
|
||||
},
|
||||
full: {
|
||||
startTime: '09:00',
|
||||
endTime: '21:00',
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_STAFFS: StoreStaffMock[] = [
|
||||
{
|
||||
id: 'staff-001',
|
||||
name: '张伟',
|
||||
phone: '13800008001',
|
||||
email: 'zhangwei@example.com',
|
||||
roleType: 'manager',
|
||||
status: 'active',
|
||||
permissions: ['全部权限'],
|
||||
hiredAt: '2024-01-15',
|
||||
avatarColor: '#f56a00',
|
||||
},
|
||||
{
|
||||
id: 'staff-002',
|
||||
name: '李娜',
|
||||
phone: '13800008002',
|
||||
email: 'lina@example.com',
|
||||
roleType: 'cashier',
|
||||
status: 'active',
|
||||
permissions: ['收银', '退款'],
|
||||
hiredAt: '2024-03-20',
|
||||
avatarColor: '#7265e6',
|
||||
},
|
||||
{
|
||||
id: 'staff-003',
|
||||
name: '王磊',
|
||||
phone: '13800008003',
|
||||
email: '',
|
||||
roleType: 'courier',
|
||||
status: 'active',
|
||||
permissions: ['配送管理'],
|
||||
hiredAt: '2024-06-01',
|
||||
avatarColor: '#52c41a',
|
||||
},
|
||||
{
|
||||
id: 'staff-004',
|
||||
name: '赵敏',
|
||||
phone: '13800008004',
|
||||
email: '',
|
||||
roleType: 'chef',
|
||||
status: 'active',
|
||||
permissions: ['订单查看'],
|
||||
hiredAt: '2024-08-10',
|
||||
avatarColor: '#fa8c16',
|
||||
},
|
||||
{
|
||||
id: 'staff-005',
|
||||
name: '刘洋',
|
||||
phone: '13800008005',
|
||||
email: 'liuyang@example.com',
|
||||
roleType: 'courier',
|
||||
status: 'leave',
|
||||
permissions: ['配送管理'],
|
||||
hiredAt: '2025-01-05',
|
||||
avatarColor: '#1890ff',
|
||||
},
|
||||
{
|
||||
id: 'staff-006',
|
||||
name: '陈静',
|
||||
phone: '13800008006',
|
||||
email: '',
|
||||
roleType: 'cashier',
|
||||
status: 'resigned',
|
||||
permissions: [],
|
||||
hiredAt: '2024-11-20',
|
||||
avatarColor: '#bfbfbf',
|
||||
},
|
||||
];
|
||||
|
||||
const storeStaffMap = new Map<string, StoreStaffState>();
|
||||
|
||||
/** 解析 URL 查询参数。 */
|
||||
function parseUrlParams(url: string) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const params: Record<string, string> = {};
|
||||
parsed.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
/** 解析请求体 JSON。 */
|
||||
function parseBody(options: MockRequestOptions) {
|
||||
if (!options.body) return {};
|
||||
try {
|
||||
return JSON.parse(options.body) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
console.error('[mock-store-staff] parseBody error:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前周一日期。 */
|
||||
function getCurrentWeekStartDate(baseDate = new Date()) {
|
||||
const date = new Date(baseDate);
|
||||
const weekDay = date.getDay();
|
||||
const diff = weekDay === 0 ? -6 : 1 - weekDay;
|
||||
date.setDate(date.getDate() + diff);
|
||||
return toDateOnly(date);
|
||||
}
|
||||
|
||||
/** 日期转 yyyy-MM-dd。 */
|
||||
function toDateOnly(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** 归一化 HH:mm 时间。 */
|
||||
function normalizeTime(value: unknown, fallback: string) {
|
||||
const input = typeof value === 'string' ? value : '';
|
||||
const matched = /^(\d{2}):(\d{2})$/.exec(input);
|
||||
if (!matched) return fallback;
|
||||
const hour = Number(matched[1]);
|
||||
const minute = Number(matched[2]);
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
return fallback;
|
||||
}
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 归一化角色。 */
|
||||
function normalizeRoleType(value: unknown, fallback: StaffRoleType) {
|
||||
return ROLE_VALUES.has(value as StaffRoleType)
|
||||
? (value as StaffRoleType)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** 归一化状态。 */
|
||||
function normalizeStatus(value: unknown, fallback: StaffStatus) {
|
||||
return STATUS_VALUES.has(value as StaffStatus)
|
||||
? (value as StaffStatus)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** 归一化班次类型。 */
|
||||
function normalizeShiftType(value: unknown, fallback: ShiftType) {
|
||||
return SHIFT_VALUES.has(value as ShiftType) ? (value as ShiftType) : fallback;
|
||||
}
|
||||
|
||||
/** 深拷贝员工列表。 */
|
||||
function cloneStaffs(source: StoreStaffMock[]) {
|
||||
return source.map((item) => ({
|
||||
...item,
|
||||
permissions: [...item.permissions],
|
||||
}));
|
||||
}
|
||||
|
||||
/** 深拷贝模板。 */
|
||||
function cloneTemplates(
|
||||
source: StoreShiftTemplatesMock,
|
||||
): StoreShiftTemplatesMock {
|
||||
return {
|
||||
morning: { ...source.morning },
|
||||
evening: { ...source.evening },
|
||||
full: { ...source.full },
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝排班。 */
|
||||
function cloneSchedules(source: StaffScheduleMock[]) {
|
||||
return source.map((item) => ({
|
||||
staffId: item.staffId,
|
||||
shifts: item.shifts.map((shift) => ({ ...shift })),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 按入职时间稳定排序员工。 */
|
||||
function sortStaffs(source: StoreStaffMock[]) {
|
||||
return cloneStaffs(source).toSorted((a, b) => {
|
||||
const dateDiff = a.hiredAt.localeCompare(b.hiredAt);
|
||||
if (dateDiff !== 0) return dateDiff;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/** 通过班次类型生成单日排班。 */
|
||||
function createDayShift(
|
||||
dayOfWeek: number,
|
||||
shiftType: ShiftType,
|
||||
templates: StoreShiftTemplatesMock,
|
||||
): StaffDayShiftMock {
|
||||
if (shiftType === 'off') {
|
||||
return {
|
||||
dayOfWeek,
|
||||
shiftType,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
};
|
||||
}
|
||||
|
||||
const template = templates[shiftType];
|
||||
return {
|
||||
dayOfWeek,
|
||||
shiftType,
|
||||
startTime: template.startTime,
|
||||
endTime: template.endTime,
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成默认 7 天排班。 */
|
||||
function createDefaultWeekByRole(
|
||||
roleType: StaffRoleType,
|
||||
templates: StoreShiftTemplatesMock,
|
||||
): StaffDayShiftMock[] {
|
||||
const rolePatternMap: Record<StaffRoleType, ShiftType[]> = {
|
||||
manager: ['full', 'full', 'full', 'full', 'full', 'morning', 'off'],
|
||||
cashier: [
|
||||
'morning',
|
||||
'morning',
|
||||
'off',
|
||||
'morning',
|
||||
'evening',
|
||||
'full',
|
||||
'full',
|
||||
],
|
||||
courier: [
|
||||
'morning',
|
||||
'evening',
|
||||
'morning',
|
||||
'evening',
|
||||
'morning',
|
||||
'evening',
|
||||
'off',
|
||||
],
|
||||
chef: ['full', 'full', 'evening', 'off', 'full', 'full', 'morning'],
|
||||
};
|
||||
|
||||
const pattern = rolePatternMap[roleType] ?? rolePatternMap.cashier;
|
||||
return Array.from({ length: 7 }).map((_, dayOfWeek) =>
|
||||
createDayShift(dayOfWeek, pattern[dayOfWeek] ?? 'off', templates),
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建默认门店状态。 */
|
||||
function createDefaultState(): StoreStaffState {
|
||||
const templates = cloneTemplates(DEFAULT_TEMPLATES);
|
||||
const staffs = sortStaffs(cloneStaffs(DEFAULT_STAFFS));
|
||||
const schedules = staffs.map((staff) => ({
|
||||
staffId: staff.id,
|
||||
shifts:
|
||||
staff.status === 'resigned'
|
||||
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
|
||||
createDayShift(dayOfWeek, 'off', templates),
|
||||
)
|
||||
: createDefaultWeekByRole(staff.roleType, templates),
|
||||
}));
|
||||
|
||||
return {
|
||||
staffs,
|
||||
templates,
|
||||
schedules,
|
||||
weekStartDate: getCurrentWeekStartDate(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 确保门店状态存在。 */
|
||||
function ensureStoreState(storeId = '') {
|
||||
const key = storeId || 'default';
|
||||
let state = storeStaffMap.get(key);
|
||||
if (!state) {
|
||||
state = createDefaultState();
|
||||
storeStaffMap.set(key, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 构建员工排班索引。 */
|
||||
function createScheduleMap(schedules: StaffScheduleMock[]) {
|
||||
const scheduleMap = new Map<string, StaffDayShiftMock[]>();
|
||||
for (const schedule of schedules) {
|
||||
scheduleMap.set(
|
||||
schedule.staffId,
|
||||
schedule.shifts.map((shift) => ({ ...shift })),
|
||||
);
|
||||
}
|
||||
return scheduleMap;
|
||||
}
|
||||
|
||||
/** 同步模板后刷新已有排班的时间段。 */
|
||||
function syncScheduleTimesWithTemplates(
|
||||
schedules: StaffScheduleMock[],
|
||||
templates: StoreShiftTemplatesMock,
|
||||
) {
|
||||
for (const schedule of schedules) {
|
||||
schedule.shifts = schedule.shifts
|
||||
.map((shift) => {
|
||||
const normalizedType = normalizeShiftType(shift.shiftType, 'off');
|
||||
if (normalizedType === 'off') {
|
||||
return {
|
||||
dayOfWeek: shift.dayOfWeek,
|
||||
shiftType: 'off' as const,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
dayOfWeek: shift.dayOfWeek,
|
||||
shiftType: normalizedType,
|
||||
startTime: templates[normalizedType].startTime,
|
||||
endTime: templates[normalizedType].endTime,
|
||||
};
|
||||
})
|
||||
.toSorted((a, b) => a.dayOfWeek - b.dayOfWeek);
|
||||
}
|
||||
}
|
||||
|
||||
/** 归一化模板。 */
|
||||
function normalizeTemplates(
|
||||
input: unknown,
|
||||
fallback: StoreShiftTemplatesMock,
|
||||
): StoreShiftTemplatesMock {
|
||||
const record = typeof input === 'object' && input ? input : {};
|
||||
const morning =
|
||||
typeof (record as { morning?: unknown }).morning === 'object' &&
|
||||
(record as { morning?: unknown }).morning
|
||||
? ((record as { morning: Record<string, unknown> }).morning as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {};
|
||||
const evening =
|
||||
typeof (record as { evening?: unknown }).evening === 'object' &&
|
||||
(record as { evening?: unknown }).evening
|
||||
? ((record as { evening: Record<string, unknown> }).evening as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {};
|
||||
const full =
|
||||
typeof (record as { full?: unknown }).full === 'object' &&
|
||||
(record as { full?: unknown }).full
|
||||
? ((record as { full: Record<string, unknown> }).full as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
morning: {
|
||||
startTime: normalizeTime(morning.startTime, fallback.morning.startTime),
|
||||
endTime: normalizeTime(morning.endTime, fallback.morning.endTime),
|
||||
},
|
||||
evening: {
|
||||
startTime: normalizeTime(evening.startTime, fallback.evening.startTime),
|
||||
endTime: normalizeTime(evening.endTime, fallback.evening.endTime),
|
||||
},
|
||||
full: {
|
||||
startTime: normalizeTime(full.startTime, fallback.full.startTime),
|
||||
endTime: normalizeTime(full.endTime, fallback.full.endTime),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 归一化单员工 7 天排班。 */
|
||||
function normalizeShifts(
|
||||
input: unknown,
|
||||
templates: StoreShiftTemplatesMock,
|
||||
fallback: StaffDayShiftMock[],
|
||||
): StaffDayShiftMock[] {
|
||||
const byDay = new Map<number, StaffDayShiftMock>();
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const rawShift of input) {
|
||||
const shiftRecord =
|
||||
typeof rawShift === 'object' && rawShift
|
||||
? (rawShift as Record<string, unknown>)
|
||||
: {};
|
||||
const dayOfWeek = Number(shiftRecord.dayOfWeek);
|
||||
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shiftType = normalizeShiftType(shiftRecord.shiftType, 'off');
|
||||
if (shiftType === 'off') {
|
||||
byDay.set(dayOfWeek, {
|
||||
dayOfWeek,
|
||||
shiftType,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
byDay.set(dayOfWeek, {
|
||||
dayOfWeek,
|
||||
shiftType,
|
||||
startTime: normalizeTime(
|
||||
shiftRecord.startTime,
|
||||
templates[shiftType].startTime,
|
||||
),
|
||||
endTime: normalizeTime(
|
||||
shiftRecord.endTime,
|
||||
templates[shiftType].endTime,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from({ length: 7 }).map((_, dayOfWeek) => {
|
||||
const normalized = byDay.get(dayOfWeek);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const fallbackShift = fallback.find((item) => item.dayOfWeek === dayOfWeek);
|
||||
if (fallbackShift) {
|
||||
return { ...fallbackShift };
|
||||
}
|
||||
|
||||
return createDayShift(dayOfWeek, 'off', templates);
|
||||
});
|
||||
}
|
||||
|
||||
/** 规范手机号格式。 */
|
||||
function normalizePhone(value: unknown) {
|
||||
return String(value || '')
|
||||
.replaceAll(/\D/g, '')
|
||||
.slice(0, 11);
|
||||
}
|
||||
|
||||
/** 规范权限列表。 */
|
||||
function normalizePermissions(value: unknown, roleType: StaffRoleType) {
|
||||
if (!Array.isArray(value)) {
|
||||
return roleType === 'manager' ? ['全部权限'] : [];
|
||||
}
|
||||
const unique = [
|
||||
...new Set(value.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||
];
|
||||
if (roleType === 'manager' && unique.length === 0) {
|
||||
return ['全部权限'];
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
/** 生成员工 ID。 */
|
||||
function createStaffId() {
|
||||
return `staff-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
/** 生成头像颜色。 */
|
||||
function resolveAvatarColor(seed: string) {
|
||||
let hash = 0;
|
||||
for (const char of seed) {
|
||||
hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
|
||||
}
|
||||
return AVATAR_COLORS[hash % AVATAR_COLORS.length] ?? '#1677ff';
|
||||
}
|
||||
|
||||
/** 获取员工列表。 */
|
||||
Mock.mock(/\/store\/staff(?:\?|$)/, 'get', (options: MockRequestOptions) => {
|
||||
const params = parseUrlParams(options.url);
|
||||
const storeId = String(params.storeId || '');
|
||||
const state = ensureStoreState(storeId);
|
||||
|
||||
const keyword = String(params.keyword || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const roleType = params.roleType
|
||||
? normalizeRoleType(params.roleType, 'cashier')
|
||||
: '';
|
||||
const status = params.status ? normalizeStatus(params.status, 'active') : '';
|
||||
|
||||
const page = Math.max(1, Number(params.page) || 1);
|
||||
const pageSize = Math.max(1, Math.min(200, Number(params.pageSize) || 10));
|
||||
|
||||
const filtered = state.staffs.filter((staff) => {
|
||||
if (keyword) {
|
||||
const hitKeyword =
|
||||
staff.name.toLowerCase().includes(keyword) ||
|
||||
staff.phone.includes(keyword) ||
|
||||
staff.email.toLowerCase().includes(keyword);
|
||||
if (!hitKeyword) return false;
|
||||
}
|
||||
|
||||
if (roleType && staff.roleType !== roleType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status && staff.status !== status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
items: cloneStaffs(items),
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/** 新增 / 编辑员工。 */
|
||||
Mock.mock(/\/store\/staff\/save/, 'post', (options: MockRequestOptions) => {
|
||||
const body = parseBody(options);
|
||||
const storeId = String(body.storeId || '');
|
||||
if (!storeId) {
|
||||
return { code: 200, data: null };
|
||||
}
|
||||
|
||||
const state = ensureStoreState(storeId);
|
||||
const id = String(body.id || '').trim();
|
||||
const existingIndex = state.staffs.findIndex((item) => item.id === id);
|
||||
const roleType = normalizeRoleType(body.roleType, 'cashier');
|
||||
const status = normalizeStatus(body.status, 'active');
|
||||
|
||||
const nextStaff: StoreStaffMock = {
|
||||
id: id || createStaffId(),
|
||||
name: String(body.name || '').trim(),
|
||||
phone: normalizePhone(body.phone),
|
||||
email: String(body.email || '').trim(),
|
||||
roleType,
|
||||
status,
|
||||
permissions: normalizePermissions(body.permissions, roleType),
|
||||
hiredAt:
|
||||
existingIndex === -1
|
||||
? toDateOnly(new Date())
|
||||
: state.staffs[existingIndex]?.hiredAt || toDateOnly(new Date()),
|
||||
avatarColor:
|
||||
existingIndex === -1
|
||||
? resolveAvatarColor(String(body.name || Date.now()))
|
||||
: state.staffs[existingIndex]?.avatarColor || '#1677ff',
|
||||
};
|
||||
|
||||
if (!nextStaff.name) {
|
||||
return {
|
||||
code: 400,
|
||||
data: null,
|
||||
message: '员工姓名不能为空',
|
||||
};
|
||||
}
|
||||
|
||||
if (!nextStaff.phone || nextStaff.phone.length < 6) {
|
||||
return {
|
||||
code: 400,
|
||||
data: null,
|
||||
message: '手机号格式不正确',
|
||||
};
|
||||
}
|
||||
|
||||
if (existingIndex === -1) {
|
||||
state.staffs.push(nextStaff);
|
||||
state.staffs = sortStaffs(state.staffs);
|
||||
state.schedules.push({
|
||||
staffId: nextStaff.id,
|
||||
shifts:
|
||||
status === 'resigned'
|
||||
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
|
||||
createDayShift(dayOfWeek, 'off', state.templates),
|
||||
)
|
||||
: createDefaultWeekByRole(roleType, state.templates),
|
||||
});
|
||||
} else {
|
||||
state.staffs[existingIndex] = nextStaff;
|
||||
state.staffs = sortStaffs(state.staffs);
|
||||
|
||||
const schedule = state.schedules.find(
|
||||
(item) => item.staffId === nextStaff.id,
|
||||
);
|
||||
if (schedule && nextStaff.status === 'resigned') {
|
||||
schedule.shifts = Array.from({ length: 7 }).map((_, dayOfWeek) =>
|
||||
createDayShift(dayOfWeek, 'off', state.templates),
|
||||
);
|
||||
}
|
||||
if (!schedule) {
|
||||
state.schedules.push({
|
||||
staffId: nextStaff.id,
|
||||
shifts: createDefaultWeekByRole(nextStaff.roleType, state.templates),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
...nextStaff,
|
||||
permissions: [...nextStaff.permissions],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/** 删除员工。 */
|
||||
Mock.mock(/\/store\/staff\/delete/, 'post', (options: MockRequestOptions) => {
|
||||
const body = parseBody(options);
|
||||
const storeId = String(body.storeId || '');
|
||||
const staffId = String(body.staffId || '');
|
||||
if (!storeId || !staffId) {
|
||||
return { code: 200, data: null };
|
||||
}
|
||||
|
||||
const state = ensureStoreState(storeId);
|
||||
state.staffs = state.staffs.filter((item) => item.id !== staffId);
|
||||
state.schedules = state.schedules.filter((item) => item.staffId !== staffId);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: null,
|
||||
};
|
||||
});
|
||||
|
||||
/** 获取门店排班配置。 */
|
||||
Mock.mock(
|
||||
/\/store\/staff\/schedule(?:\?|$)/,
|
||||
'get',
|
||||
(options: MockRequestOptions) => {
|
||||
const params = parseUrlParams(options.url);
|
||||
const storeId = String(params.storeId || '');
|
||||
const state = ensureStoreState(storeId);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
storeId,
|
||||
templates: cloneTemplates(state.templates),
|
||||
schedules: cloneSchedules(state.schedules),
|
||||
weekStartDate: String(params.weekStartDate || state.weekStartDate),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** 保存班次模板。 */
|
||||
Mock.mock(
|
||||
/\/store\/staff\/template\/save/,
|
||||
'post',
|
||||
(options: MockRequestOptions) => {
|
||||
const body = parseBody(options);
|
||||
const storeId = String(body.storeId || '');
|
||||
if (!storeId) {
|
||||
return { code: 200, data: cloneTemplates(DEFAULT_TEMPLATES) };
|
||||
}
|
||||
|
||||
const state = ensureStoreState(storeId);
|
||||
state.templates = normalizeTemplates(body.templates, state.templates);
|
||||
syncScheduleTimesWithTemplates(state.schedules, state.templates);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: cloneTemplates(state.templates),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** 保存员工个人排班。 */
|
||||
Mock.mock(
|
||||
/\/store\/staff\/schedule\/personal\/save/,
|
||||
'post',
|
||||
(options: MockRequestOptions) => {
|
||||
const body = parseBody(options);
|
||||
const storeId = String(body.storeId || '');
|
||||
const staffId = String(body.staffId || '');
|
||||
if (!storeId || !staffId) {
|
||||
return { code: 200, data: null };
|
||||
}
|
||||
|
||||
const state = ensureStoreState(storeId);
|
||||
const staff = state.staffs.find((item) => item.id === staffId);
|
||||
if (!staff) {
|
||||
return {
|
||||
code: 400,
|
||||
data: null,
|
||||
message: '员工不存在',
|
||||
};
|
||||
}
|
||||
|
||||
const schedule = state.schedules.find((item) => item.staffId === staffId);
|
||||
const fallbackShifts =
|
||||
schedule?.shifts ??
|
||||
createDefaultWeekByRole(staff.roleType, state.templates);
|
||||
|
||||
const nextShifts =
|
||||
staff.status === 'resigned'
|
||||
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
|
||||
createDayShift(dayOfWeek, 'off', state.templates),
|
||||
)
|
||||
: normalizeShifts(body.shifts, state.templates, fallbackShifts);
|
||||
|
||||
if (schedule) {
|
||||
schedule.shifts = nextShifts;
|
||||
} else {
|
||||
state.schedules.push({
|
||||
staffId,
|
||||
shifts: nextShifts,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
staffId,
|
||||
shifts: nextShifts.map((item) => ({ ...item })),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** 保存周排班。 */
|
||||
Mock.mock(
|
||||
/\/store\/staff\/schedule\/weekly\/save/,
|
||||
'post',
|
||||
(options: MockRequestOptions) => {
|
||||
const body = parseBody(options);
|
||||
const storeId = String(body.storeId || '');
|
||||
if (!storeId) {
|
||||
return { code: 200, data: null };
|
||||
}
|
||||
|
||||
const state = ensureStoreState(storeId);
|
||||
const incomingList = Array.isArray(body.schedules) ? body.schedules : [];
|
||||
const scheduleMap = createScheduleMap(state.schedules);
|
||||
|
||||
for (const incoming of incomingList) {
|
||||
const record =
|
||||
typeof incoming === 'object' && incoming
|
||||
? (incoming as Record<string, unknown>)
|
||||
: {};
|
||||
const staffId = String(record.staffId || '');
|
||||
if (!staffId) continue;
|
||||
|
||||
const staff = state.staffs.find((item) => item.id === staffId);
|
||||
if (!staff || staff.status === 'resigned') continue;
|
||||
|
||||
const fallbackShifts =
|
||||
scheduleMap.get(staffId) ??
|
||||
createDefaultWeekByRole(staff.roleType, state.templates);
|
||||
|
||||
const nextShifts = normalizeShifts(
|
||||
record.shifts,
|
||||
state.templates,
|
||||
fallbackShifts,
|
||||
);
|
||||
scheduleMap.set(staffId, nextShifts);
|
||||
}
|
||||
|
||||
state.schedules = state.staffs.map((staff) => {
|
||||
const baseShifts =
|
||||
scheduleMap.get(staff.id) ??
|
||||
createDefaultWeekByRole(staff.roleType, state.templates);
|
||||
return {
|
||||
staffId: staff.id,
|
||||
shifts:
|
||||
staff.status === 'resigned'
|
||||
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
|
||||
createDayShift(dayOfWeek, 'off', state.templates),
|
||||
)
|
||||
: baseShifts.map((shift) => ({ ...shift })),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
storeId,
|
||||
templates: cloneTemplates(state.templates),
|
||||
schedules: cloneSchedules(state.schedules),
|
||||
weekStartDate: state.weekStartDate,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** 复制门店模板与排班。 */
|
||||
Mock.mock(/\/store\/staff\/copy/, 'post', (options: MockRequestOptions) => {
|
||||
const body = parseBody(options);
|
||||
const sourceStoreId = String(body.sourceStoreId || '');
|
||||
const copyScope = String(body.copyScope || '');
|
||||
const targetStoreIds = Array.isArray(body.targetStoreIds)
|
||||
? body.targetStoreIds.map(String).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (
|
||||
!sourceStoreId ||
|
||||
copyScope !== 'template_and_schedule' ||
|
||||
targetStoreIds.length === 0
|
||||
) {
|
||||
return {
|
||||
code: 200,
|
||||
data: { copiedCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const sourceState = ensureStoreState(sourceStoreId);
|
||||
const sourceScheduleMap = createScheduleMap(sourceState.schedules);
|
||||
const uniqueTargets = [...new Set(targetStoreIds)].filter(
|
||||
(id) => id !== sourceStoreId,
|
||||
);
|
||||
|
||||
for (const targetStoreId of uniqueTargets) {
|
||||
const targetState = ensureStoreState(targetStoreId);
|
||||
targetState.templates = cloneTemplates(sourceState.templates);
|
||||
|
||||
targetState.schedules = targetState.staffs.map((staff) => {
|
||||
const targetShiftFallback =
|
||||
sourceScheduleMap.get(staff.id) ??
|
||||
createDefaultWeekByRole(staff.roleType, targetState.templates);
|
||||
|
||||
return {
|
||||
staffId: staff.id,
|
||||
shifts:
|
||||
staff.status === 'resigned'
|
||||
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
|
||||
createDayShift(dayOfWeek, 'off', targetState.templates),
|
||||
)
|
||||
: normalizeShifts(
|
||||
targetShiftFallback,
|
||||
targetState.templates,
|
||||
targetShiftFallback,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
copiedCount: uniqueTargets.length,
|
||||
copyScope: 'template_and_schedule',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -55,6 +55,15 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '费用设置',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'StoreStaff',
|
||||
path: '/store/staff',
|
||||
component: () => import('#/views/store/staff/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:users',
|
||||
title: '员工排班',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'StoreDineIn',
|
||||
path: '/store/dine-in',
|
||||
|
||||
@@ -154,7 +154,9 @@ function toNumber(value: null | number | string, fallback = 0) {
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button type="primary" @click="emit('submit')">确认</Button>
|
||||
<Button type="primary" @click="emit('submit')">
|
||||
{{ props.form.id ? '保存修改' : '新增并保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
|
||||
@@ -153,7 +153,9 @@ function readInputValue(event: Event) {
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button type="primary" @click="emit('submit')">确认</Button>
|
||||
<Button type="primary" @click="emit('submit')">
|
||||
{{ props.form.id ? '保存修改' : '新增并保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
|
||||
@@ -80,6 +80,14 @@
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-footer .ant-btn {
|
||||
min-width: 96px;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ function toNumber(value: null | number | string, fallback = 1) {
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.submitText }}
|
||||
{{ props.form.id ? '保存修改' : '新增并保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -161,7 +161,7 @@ function resolveBusinessStatusClass(status: DineInTableStatus) {
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.submitText }}
|
||||
{{ props.form.id ? '保存修改' : '新增并保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -120,10 +120,18 @@
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-footer .ant-btn {
|
||||
min-width: 96px;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dinein-batch-modal-wrap {
|
||||
.ant-modal-content {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -110,7 +110,7 @@ function toNumber(value: null | number | string, fallback = 0) {
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
确认
|
||||
{{ props.form.id ? '保存修改' : '新增并保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,6 +50,14 @@
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-footer .ant-btn {
|
||||
min-width: 96px;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ function readTimeValue(value: unknown) {
|
||||
:loading="props.isWeeklySubmitting"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
确认添加
|
||||
新增并保存
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -232,7 +232,7 @@ function readTimeValue(value: unknown) {
|
||||
:loading="props.isHolidaySubmitting"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.submitText }}
|
||||
{{ props.holidayForm.id ? '保存修改' : '新增并保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,17 +42,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.add-slot-drawer-wrap .drawer-footer {
|
||||
.add-slot-drawer-wrap .drawer-footer,
|
||||
.day-edit-drawer-wrap .drawer-footer,
|
||||
.holiday-drawer-wrap .drawer-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.add-slot-drawer-wrap .drawer-footer .ant-btn {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
.add-slot-drawer-wrap .drawer-footer .ant-btn,
|
||||
.day-edit-drawer-wrap .drawer-footer .ant-btn,
|
||||
.holiday-drawer-wrap .drawer-footer .ant-btn {
|
||||
min-width: 96px;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.add-slot-drawer-wrap .form-block {
|
||||
@@ -257,19 +262,6 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.day-edit-drawer-wrap .drawer-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.day-edit-drawer-wrap .drawer-footer .ant-btn {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.day-edit-drawer-wrap .day-open-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -499,19 +491,6 @@
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.holiday-drawer-wrap .drawer-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.holiday-drawer-wrap .drawer-footer .ant-btn {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.holiday-drawer-wrap .form-block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -758,7 +737,7 @@
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ function handleServiceTypesChange(value: unknown) {
|
||||
:loading="props.isSubmitting"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
确认
|
||||
{{ props.title.includes('编辑') ? '保存修改' : '新增并保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,20 @@
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.store-drawer-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.store-drawer-footer .ant-btn {
|
||||
min-width: 96px;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-store-list {
|
||||
@@ -41,10 +55,4 @@
|
||||
.store-drawer-form .store-drawer-section-title + .ant-form-item {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.store-drawer-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ function readTimeValue(value: unknown) {
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.form.id ? '保存修改' : '确认添加' }}
|
||||
{{ props.form.id ? '保存修改' : '新增并保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,6 +104,14 @@
|
||||
|
||||
.pickup-drawer-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pickup-drawer-footer .ant-btn {
|
||||
min-width: 96px;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:班次模板卡片。
|
||||
* 1. 提供早班/晚班/全天模板时间编辑。
|
||||
* 2. 透出保存与重置事件。
|
||||
*/
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { ShiftType, StoreShiftTemplatesDto } from '#/api/store-staff';
|
||||
|
||||
import { Button, Card, TimePicker } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Props {
|
||||
isSaving: boolean;
|
||||
onSetTemplateTime: (payload: {
|
||||
field: 'endTime' | 'startTime';
|
||||
shiftType: Exclude<ShiftType, 'off'>;
|
||||
value: string;
|
||||
}) => void;
|
||||
templates: StoreShiftTemplatesDto;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'reset'): void;
|
||||
(event: 'save'): void;
|
||||
}>();
|
||||
|
||||
const templateRows: Array<{
|
||||
colorClass: string;
|
||||
label: string;
|
||||
shiftType: Exclude<ShiftType, 'off'>;
|
||||
}> = [
|
||||
{ shiftType: 'morning', label: '早班', colorClass: 'template-dot-morning' },
|
||||
{ shiftType: 'evening', label: '晚班', colorClass: 'template-dot-evening' },
|
||||
{ shiftType: 'full', label: '全天', colorClass: 'template-dot-full' },
|
||||
];
|
||||
|
||||
/** 将 HH:mm 字符串转换为时间组件值。 */
|
||||
function toPickerValue(time: string) {
|
||||
if (!time) return null;
|
||||
return dayjs(`2000-01-01 ${time}`);
|
||||
}
|
||||
|
||||
/** 将时间组件值转换为 HH:mm 字符串。 */
|
||||
function toTimeText(value: Dayjs | null) {
|
||||
return value ? value.format('HH:mm') : '';
|
||||
}
|
||||
|
||||
/** 处理模板时间变更。 */
|
||||
function handleTemplateTimeChange(payload: {
|
||||
field: 'endTime' | 'startTime';
|
||||
shiftType: Exclude<ShiftType, 'off'>;
|
||||
value: Dayjs | null;
|
||||
}) {
|
||||
props.onSetTemplateTime({
|
||||
shiftType: payload.shiftType,
|
||||
field: payload.field,
|
||||
value: toTimeText(payload.value),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" class="staff-card">
|
||||
<template #title>
|
||||
<span class="section-title">班次模板</span>
|
||||
</template>
|
||||
|
||||
<div class="template-list">
|
||||
<div
|
||||
v-for="row in templateRows"
|
||||
:key="row.shiftType"
|
||||
class="template-row"
|
||||
>
|
||||
<span class="template-dot" :class="row.colorClass"></span>
|
||||
<span class="template-label">{{ row.label }}</span>
|
||||
|
||||
<div class="template-time-group">
|
||||
<TimePicker
|
||||
:value="toPickerValue(props.templates[row.shiftType].startTime)"
|
||||
format="HH:mm"
|
||||
:allow-clear="false"
|
||||
@update:value="
|
||||
(value) =>
|
||||
handleTemplateTimeChange({
|
||||
shiftType: row.shiftType,
|
||||
field: 'startTime',
|
||||
value,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span class="template-time-separator">~</span>
|
||||
<TimePicker
|
||||
:value="toPickerValue(props.templates[row.shiftType].endTime)"
|
||||
format="HH:mm"
|
||||
:allow-clear="false"
|
||||
@update:value="
|
||||
(value) =>
|
||||
handleTemplateTimeChange({
|
||||
shiftType: row.shiftType,
|
||||
field: 'endTime',
|
||||
value,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="template-tip">
|
||||
调整模板后,个人排班和周排班中的同类型班次会同步到新的时间段。
|
||||
</div>
|
||||
|
||||
<div class="staff-card-actions">
|
||||
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button>
|
||||
<Button type="primary" :loading="props.isSaving" @click="emit('save')">
|
||||
保存模板
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:员工新增/编辑抽屉。
|
||||
* 1. 展示员工基础信息与权限表单。
|
||||
* 2. 透出保存与关闭事件。
|
||||
*/
|
||||
import type { StaffRoleType, StaffStatus } from '#/api/store-staff';
|
||||
import type {
|
||||
StaffEditorFormState,
|
||||
StaffPermissionOption,
|
||||
StaffRoleOption,
|
||||
StaffStatusOption,
|
||||
} from '#/views/store/staff/types';
|
||||
|
||||
import { Button, Drawer, Select } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
form: StaffEditorFormState;
|
||||
isSaving: boolean;
|
||||
onSetEmail: (value: string) => void;
|
||||
onSetName: (value: string) => void;
|
||||
onSetPermissions: (value: string[]) => void;
|
||||
onSetPhone: (value: string) => void;
|
||||
onSetRoleType: (value: StaffRoleType) => void;
|
||||
onSetStatus: (value: StaffStatus) => void;
|
||||
open: boolean;
|
||||
permissionOptions: StaffPermissionOption[];
|
||||
roleOptions: StaffRoleOption[];
|
||||
statusOptions: StaffStatusOption[];
|
||||
submitText: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'submit'): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 判断是否已全选权限。 */
|
||||
function isAllPermissionChecked() {
|
||||
if (props.form.permissions.includes('全部权限')) return true;
|
||||
if (props.permissionOptions.length === 0) return false;
|
||||
return props.permissionOptions.every((item) =>
|
||||
props.form.permissions.includes(item.value),
|
||||
);
|
||||
}
|
||||
|
||||
/** 切换单个权限。 */
|
||||
function togglePermission(value: string, checked: boolean) {
|
||||
const basePermissions = props.form.permissions.filter(
|
||||
(item) => item !== '全部权限',
|
||||
);
|
||||
const next = checked
|
||||
? [...new Set([value, ...basePermissions])]
|
||||
: basePermissions.filter((item) => item !== value);
|
||||
props.onSetPermissions(next);
|
||||
}
|
||||
|
||||
/** 全选/取消全选权限。 */
|
||||
function toggleAllPermissions(checked: boolean) {
|
||||
props.onSetPermissions(
|
||||
checked
|
||||
? ['全部权限', ...props.permissionOptions.map((item) => item.value)]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** 统一处理角色更新。 */
|
||||
function handleRoleChange(value: unknown) {
|
||||
if (typeof value !== 'string') return;
|
||||
props.onSetRoleType(value as StaffRoleType);
|
||||
}
|
||||
|
||||
/** 统一处理状态更新。 */
|
||||
function handleStatusChange(value: unknown) {
|
||||
if (typeof value !== 'string') return;
|
||||
props.onSetStatus(value as StaffStatus);
|
||||
}
|
||||
|
||||
/** 处理姓名输入。 */
|
||||
function handleNameInput(event: Event) {
|
||||
props.onSetName((event.target as HTMLInputElement | null)?.value ?? '');
|
||||
}
|
||||
|
||||
/** 处理手机号输入。 */
|
||||
function handlePhoneInput(event: Event) {
|
||||
props.onSetPhone((event.target as HTMLInputElement | null)?.value ?? '');
|
||||
}
|
||||
|
||||
/** 处理邮箱输入。 */
|
||||
function handleEmailInput(event: Event) {
|
||||
props.onSetEmail((event.target as HTMLInputElement | null)?.value ?? '');
|
||||
}
|
||||
|
||||
/** 处理全选权限切换。 */
|
||||
function handlePermissionAllChange(event: Event) {
|
||||
toggleAllPermissions(
|
||||
Boolean((event.target as HTMLInputElement | null)?.checked),
|
||||
);
|
||||
}
|
||||
|
||||
/** 处理单项权限切换。 */
|
||||
function handlePermissionItemChange(event: Event, value: string) {
|
||||
togglePermission(
|
||||
value,
|
||||
Boolean((event.target as HTMLInputElement | null)?.checked),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="props.open"
|
||||
:title="props.title"
|
||||
:width="520"
|
||||
:mask-closable="true"
|
||||
:destroy-on-close="false"
|
||||
class="staff-editor-drawer"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div class="staff-drawer-form">
|
||||
<div class="g-form-group">
|
||||
<label class="g-form-label required">姓名</label>
|
||||
<input
|
||||
:value="props.form.name"
|
||||
class="g-input"
|
||||
maxlength="20"
|
||||
placeholder="请输入员工姓名"
|
||||
@input="handleNameInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="g-form-group">
|
||||
<label class="g-form-label required">手机号</label>
|
||||
<input
|
||||
:value="props.form.phone"
|
||||
class="g-input"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="g-form-group">
|
||||
<label class="g-form-label">邮箱</label>
|
||||
<input
|
||||
:value="props.form.email"
|
||||
class="g-input"
|
||||
maxlength="64"
|
||||
placeholder="可选"
|
||||
@input="handleEmailInput"
|
||||
/>
|
||||
<div class="g-hint">用于接收系统通知</div>
|
||||
</div>
|
||||
|
||||
<div class="g-form-grid">
|
||||
<div class="g-form-group">
|
||||
<label class="g-form-label required">角色</label>
|
||||
<Select
|
||||
:value="props.form.roleType"
|
||||
class="staff-form-select"
|
||||
:options="props.roleOptions"
|
||||
@update:value="(value) => handleRoleChange(value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="g-form-group">
|
||||
<label class="g-form-label">状态</label>
|
||||
<Select
|
||||
:value="props.form.status"
|
||||
class="staff-form-select"
|
||||
:options="props.statusOptions"
|
||||
@update:value="(value) => handleStatusChange(value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="g-form-group">
|
||||
<label class="g-form-label">权限</label>
|
||||
<div class="staff-permission-grid">
|
||||
<label class="staff-permission-item staff-permission-item-all">
|
||||
<input
|
||||
:checked="isAllPermissionChecked()"
|
||||
type="checkbox"
|
||||
@change="handlePermissionAllChange"
|
||||
/>
|
||||
<span>全部权限</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-for="item in props.permissionOptions"
|
||||
:key="item.value"
|
||||
class="staff-permission-item"
|
||||
>
|
||||
<input
|
||||
:checked="props.form.permissions.includes(item.value)"
|
||||
type="checkbox"
|
||||
@change="(event) => handlePermissionItemChange(event, item.value)"
|
||||
/>
|
||||
<span>{{ item.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="staff-drawer-footer">
|
||||
<Button @click="emit('update:open', false)"> 取消 </Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.form.id ? '保存修改' : '新增并保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:员工列表筛选条。
|
||||
* 1. 提供关键词、角色、状态筛选。
|
||||
* 2. 仅透出筛选事件,不处理请求。
|
||||
*/
|
||||
import type { StaffRoleType, StaffStatus } from '#/api/store-staff';
|
||||
|
||||
import { Button, Input, Select } from 'ant-design-vue';
|
||||
|
||||
interface RoleOption {
|
||||
label: string;
|
||||
value: StaffRoleType;
|
||||
}
|
||||
|
||||
interface StatusOption {
|
||||
label: string;
|
||||
value: StaffStatus;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
keyword: string;
|
||||
onSetKeyword: (value: string) => void;
|
||||
onSetRoleType: (value: '' | StaffRoleType) => void;
|
||||
onSetStatus: (value: '' | StaffStatus) => void;
|
||||
roleOptions: RoleOption[];
|
||||
roleType: '' | StaffRoleType;
|
||||
status: '' | StaffStatus;
|
||||
statusOptions: StatusOption[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'reset'): void;
|
||||
(event: 'search'): void;
|
||||
}>();
|
||||
|
||||
const roleSelectOptions = [
|
||||
{ label: '全部角色', value: '' },
|
||||
...props.roleOptions,
|
||||
];
|
||||
|
||||
const statusSelectOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
...props.statusOptions,
|
||||
];
|
||||
|
||||
/** 统一处理角色筛选值。 */
|
||||
function handleRoleChange(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
props.onSetRoleType('');
|
||||
return;
|
||||
}
|
||||
props.onSetRoleType(value as '' | StaffRoleType);
|
||||
}
|
||||
|
||||
/** 统一处理状态筛选值。 */
|
||||
function handleStatusChange(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
props.onSetStatus('');
|
||||
return;
|
||||
}
|
||||
props.onSetStatus(value as '' | StaffStatus);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="staff-filter-bar">
|
||||
<Input
|
||||
:value="props.keyword"
|
||||
class="staff-filter-input"
|
||||
allow-clear
|
||||
placeholder="搜索姓名/手机号/邮箱"
|
||||
@press-enter="emit('search')"
|
||||
@update:value="(value) => props.onSetKeyword(String(value || ''))"
|
||||
/>
|
||||
|
||||
<Select
|
||||
:value="props.roleType"
|
||||
class="staff-filter-select"
|
||||
:options="roleSelectOptions"
|
||||
@update:value="(value) => handleRoleChange(value)"
|
||||
/>
|
||||
|
||||
<Select
|
||||
:value="props.status"
|
||||
class="staff-filter-select"
|
||||
:options="statusSelectOptions"
|
||||
@update:value="(value) => handleStatusChange(value)"
|
||||
/>
|
||||
|
||||
<Button type="primary" :loading="props.isLoading" @click="emit('search')">
|
||||
查询
|
||||
</Button>
|
||||
<Button :disabled="props.isLoading" @click="emit('reset')">重置</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:员工个人排班抽屉。
|
||||
* 1. 按周编辑单个员工的每日班次。
|
||||
* 2. 支持班次切换与时间微调。
|
||||
*/
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { ShiftType } from '#/api/store-staff';
|
||||
import type {
|
||||
DayOption,
|
||||
ShiftOption,
|
||||
StaffPersonalScheduleFormState,
|
||||
} from '#/views/store/staff/types';
|
||||
|
||||
import { Button, Drawer, TimePicker } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Props {
|
||||
dayOptions: DayOption[];
|
||||
form: StaffPersonalScheduleFormState;
|
||||
isSaving: boolean;
|
||||
onSetShiftEnd: (dayOfWeek: number, endTime: string) => void;
|
||||
onSetShiftStart: (dayOfWeek: number, startTime: string) => void;
|
||||
onSetShiftType: (dayOfWeek: number, shiftType: ShiftType) => void;
|
||||
open: boolean;
|
||||
shiftOptions: ShiftOption[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'submit'): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 获取某日排班。 */
|
||||
function getDayShift(dayOfWeek: number) {
|
||||
return props.form.shifts.find((item) => item.dayOfWeek === dayOfWeek);
|
||||
}
|
||||
|
||||
/** 转换时间组件值。 */
|
||||
function toPickerValue(time: string) {
|
||||
if (!time) return null;
|
||||
return dayjs(`2000-01-01 ${time}`);
|
||||
}
|
||||
|
||||
/** 时间组件值转字符串。 */
|
||||
function toTimeText(value: Dayjs | null) {
|
||||
return value ? value.format('HH:mm') : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="props.open"
|
||||
:title="props.title"
|
||||
:width="620"
|
||||
:mask-closable="true"
|
||||
:destroy-on-close="false"
|
||||
class="staff-schedule-drawer"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div class="staff-schedule-hint">
|
||||
点击班次胶囊可自动填充时间,选择“休息”后该日不排班。
|
||||
</div>
|
||||
|
||||
<div class="staff-schedule-list">
|
||||
<div
|
||||
v-for="day in props.dayOptions"
|
||||
:key="day.dayOfWeek"
|
||||
class="staff-schedule-row"
|
||||
>
|
||||
<span class="staff-schedule-day">{{ day.label }}</span>
|
||||
|
||||
<div class="staff-schedule-shifts">
|
||||
<button
|
||||
v-for="shift in props.shiftOptions"
|
||||
:key="shift.value"
|
||||
type="button"
|
||||
class="staff-shift-pill"
|
||||
:class="[
|
||||
shift.className,
|
||||
{
|
||||
active: getDayShift(day.dayOfWeek)?.shiftType === shift.value,
|
||||
},
|
||||
]"
|
||||
@click="props.onSetShiftType(day.dayOfWeek, shift.value)"
|
||||
>
|
||||
{{ shift.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="staff-schedule-time"
|
||||
:class="{ hidden: getDayShift(day.dayOfWeek)?.shiftType === 'off' }"
|
||||
>
|
||||
<TimePicker
|
||||
:value="toPickerValue(getDayShift(day.dayOfWeek)?.startTime || '')"
|
||||
format="HH:mm"
|
||||
:allow-clear="false"
|
||||
@update:value="
|
||||
(value) => props.onSetShiftStart(day.dayOfWeek, toTimeText(value))
|
||||
"
|
||||
/>
|
||||
<span class="staff-schedule-separator">~</span>
|
||||
<TimePicker
|
||||
:value="toPickerValue(getDayShift(day.dayOfWeek)?.endTime || '')"
|
||||
format="HH:mm"
|
||||
:allow-clear="false"
|
||||
@update:value="
|
||||
(value) => props.onSetShiftEnd(day.dayOfWeek, toTimeText(value))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="staff-drawer-footer">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
保存排班
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:员工列表区块。
|
||||
* 1. 展示员工信息表格与分页。
|
||||
* 2. 透出添加、编辑、排班、删除动作。
|
||||
*/
|
||||
import type {
|
||||
StaffRoleType,
|
||||
StaffStatus,
|
||||
StoreStaffDto,
|
||||
} from '#/api/store-staff';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
deletingStaffId: string;
|
||||
formatRoleLabel: (value: StaffRoleType) => string;
|
||||
formatStatusLabel: (value: StaffStatus) => string;
|
||||
getRoleTagClass: (value: StaffRoleType) => string;
|
||||
getStatusDotClass: (value: StaffStatus) => string;
|
||||
isLoading: boolean;
|
||||
isScheduleDisabled: (staff: StoreStaffDto) => boolean;
|
||||
maskPhone: (phone: string) => string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageSizeOptions: number[];
|
||||
rows: StoreStaffDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'add'): void;
|
||||
(event: 'delete', staff: StoreStaffDto): void;
|
||||
(event: 'edit', staff: StoreStaffDto): void;
|
||||
(event: 'pageChange', payload: { page: number; pageSize: number }): void;
|
||||
(event: 'schedule', staff: StoreStaffDto): void;
|
||||
}>();
|
||||
|
||||
/** 分页切换时同步页码和页长。 */
|
||||
function handlePageChange(page: number, pageSize: number) {
|
||||
emit('pageChange', { page, pageSize });
|
||||
}
|
||||
|
||||
/** 点击排班按钮时拦截禁用态。 */
|
||||
function handleScheduleClick(staff: StoreStaffDto) {
|
||||
if (props.isScheduleDisabled(staff)) return;
|
||||
emit('schedule', staff);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" class="staff-card">
|
||||
<template #title>
|
||||
<span class="section-title">员工列表</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<Button type="primary" @click="emit('add')">+ 添加员工</Button>
|
||||
</template>
|
||||
|
||||
<Spin :spinning="props.isLoading">
|
||||
<slot name="filters"></slot>
|
||||
|
||||
<div class="staff-table-wrap">
|
||||
<table class="staff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>员工信息</th>
|
||||
<th>角色</th>
|
||||
<th>邮箱</th>
|
||||
<th>状态</th>
|
||||
<th>权限</th>
|
||||
<th>入职时间</th>
|
||||
<th class="staff-op-column">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="props.rows.length === 0">
|
||||
<td colspan="7">
|
||||
<Empty description="暂无员工" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
v-for="staff in props.rows"
|
||||
v-else
|
||||
:key="staff.id"
|
||||
:class="{ 'staff-row-resigned': staff.status === 'resigned' }"
|
||||
>
|
||||
<td>
|
||||
<div class="staff-info">
|
||||
<div
|
||||
class="staff-avatar"
|
||||
:style="{ background: staff.avatarColor || '#1677ff' }"
|
||||
>
|
||||
{{ staff.name.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="staff-main">
|
||||
<div class="staff-name">{{ staff.name }}</div>
|
||||
<div class="staff-phone">
|
||||
{{ props.maskPhone(staff.phone) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="staff-role-tag"
|
||||
:class="props.getRoleTagClass(staff.roleType)"
|
||||
>
|
||||
{{ props.formatRoleLabel(staff.roleType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ staff.email || '--' }}</td>
|
||||
<td>
|
||||
<span class="staff-status">
|
||||
<span
|
||||
class="staff-status-dot"
|
||||
:class="props.getStatusDotClass(staff.status)"
|
||||
></span>
|
||||
{{ props.formatStatusLabel(staff.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
v-if="staff.permissions.length > 0"
|
||||
class="staff-permissions"
|
||||
>
|
||||
<span
|
||||
v-for="item in staff.permissions"
|
||||
:key="item"
|
||||
class="staff-permission-pill"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="staff-empty">--</span>
|
||||
</td>
|
||||
<td>{{ staff.hiredAt }}</td>
|
||||
<td>
|
||||
<div class="staff-actions">
|
||||
<a class="staff-link" @click="emit('edit', staff)">编辑</a>
|
||||
<a
|
||||
class="staff-link"
|
||||
:class="{
|
||||
'staff-link-disabled': props.isScheduleDisabled(staff),
|
||||
}"
|
||||
@click="handleScheduleClick(staff)"
|
||||
>
|
||||
排班
|
||||
</a>
|
||||
<Popconfirm
|
||||
title="确认删除该员工吗?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="emit('delete', staff)"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
class="staff-delete-btn"
|
||||
:loading="props.deletingStaffId === staff.id"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="staff-pagination" v-if="props.total > 0">
|
||||
<Pagination
|
||||
:current="props.page"
|
||||
:page-size="props.pageSize"
|
||||
:total="props.total"
|
||||
:page-size-options="props.pageSizeOptions"
|
||||
show-size-changer
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
@change="handlePageChange"
|
||||
@show-size-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:全员周排班抽屉。
|
||||
* 1. 以矩阵方式展示全员排班。
|
||||
* 2. 点击单元格循环切换班次。
|
||||
*/
|
||||
import type { ShiftType, StaffDayShiftDto } from '#/api/store-staff';
|
||||
import type {
|
||||
DayOption,
|
||||
ShiftLegendItem,
|
||||
WeekEditorRow,
|
||||
} from '#/views/store/staff/types';
|
||||
|
||||
import { Button, Drawer, Empty } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
dayOptions: DayOption[];
|
||||
getShiftClass: (shiftType: ShiftType) => string;
|
||||
getShiftLabel: (shiftType: ShiftType) => string;
|
||||
getShiftTimeText: (shift: StaffDayShiftDto) => string;
|
||||
isSaving: boolean;
|
||||
legendItems: ShiftLegendItem[];
|
||||
open: boolean;
|
||||
roleLabelResolver: (roleType: WeekEditorRow['roleType']) => string;
|
||||
rows: WeekEditorRow[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'cycleShift', payload: { dayOfWeek: number; staffId: string }): void;
|
||||
(event: 'submit'): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 获取某行某天班次,缺失时兜底为休息。 */
|
||||
function resolveDayShift(
|
||||
row: WeekEditorRow,
|
||||
dayOfWeek: number,
|
||||
): StaffDayShiftDto {
|
||||
return (
|
||||
row.shifts.find((item) => item.dayOfWeek === dayOfWeek) ?? {
|
||||
dayOfWeek,
|
||||
shiftType: 'off',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="props.open"
|
||||
title="编辑本周排班表"
|
||||
:width="760"
|
||||
:mask-closable="true"
|
||||
:destroy-on-close="false"
|
||||
class="staff-week-drawer"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div class="staff-week-legend">
|
||||
<span
|
||||
v-for="item in props.legendItems"
|
||||
:key="item.type"
|
||||
class="staff-week-legend-item"
|
||||
>
|
||||
<i class="staff-week-legend-dot" :class="item.className"></i>
|
||||
{{ item.label }} {{ item.timeText }}
|
||||
</span>
|
||||
<span class="staff-week-legend-item">
|
||||
<i class="staff-week-legend-dot shift-off"></i>
|
||||
休息
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="props.rows.length === 0" class="staff-week-empty">
|
||||
<Empty description="暂无可编辑排班" />
|
||||
</div>
|
||||
|
||||
<div v-else class="staff-week-table-wrap">
|
||||
<table class="staff-week-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>员工</th>
|
||||
<th v-for="day in props.dayOptions" :key="day.dayOfWeek">
|
||||
{{ day.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in props.rows" :key="row.staffId">
|
||||
<td class="staff-week-name-cell">
|
||||
<div class="staff-week-name">{{ row.staffName }}</div>
|
||||
<div class="staff-week-role">
|
||||
{{ props.roleLabelResolver(row.roleType) }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td
|
||||
v-for="day in props.dayOptions"
|
||||
:key="`${row.staffId}-${day.dayOfWeek}`"
|
||||
class="staff-week-cell-wrap"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="staff-week-cell"
|
||||
:class="
|
||||
props.getShiftClass(
|
||||
resolveDayShift(row, day.dayOfWeek).shiftType,
|
||||
)
|
||||
"
|
||||
@click="
|
||||
emit('cycleShift', {
|
||||
staffId: row.staffId,
|
||||
dayOfWeek: day.dayOfWeek,
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="staff-week-cell-label">
|
||||
{{
|
||||
props.getShiftLabel(
|
||||
resolveDayShift(row, day.dayOfWeek).shiftType,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span class="staff-week-cell-time">
|
||||
{{
|
||||
props.getShiftTimeText(resolveDayShift(row, day.dayOfWeek))
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="staff-drawer-footer">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
保存排班
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:周排班看板。
|
||||
* 1. 展示各员工周排班只读视图。
|
||||
* 2. 提供“编辑排班”入口。
|
||||
*/
|
||||
import type { ShiftType, StaffDayShiftDto } from '#/api/store-staff';
|
||||
import type { DayOption, WeekEditorRow } from '#/views/store/staff/types';
|
||||
|
||||
import { Button, Card, Empty } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
dayOptions: DayOption[];
|
||||
getShiftClass: (shiftType: ShiftType) => string;
|
||||
getShiftLabel: (shiftType: ShiftType) => string;
|
||||
getShiftTimeText: (shift: StaffDayShiftDto) => string;
|
||||
roleLabelResolver: (roleType: WeekEditorRow['roleType']) => string;
|
||||
rows: WeekEditorRow[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'editWeek'): void;
|
||||
}>();
|
||||
|
||||
/** 读取员工某日排班,缺失时兜底为休息。 */
|
||||
function resolveDayShift(
|
||||
row: WeekEditorRow,
|
||||
dayOfWeek: number,
|
||||
): StaffDayShiftDto {
|
||||
return (
|
||||
row.shifts.find((item) => item.dayOfWeek === dayOfWeek) ?? {
|
||||
dayOfWeek,
|
||||
shiftType: 'off',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" class="staff-card">
|
||||
<template #title>
|
||||
<span class="section-title">本周排班</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<Button @click="emit('editWeek')">编辑排班</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="props.rows.length === 0" class="schedule-empty">
|
||||
<Empty description="暂无可排班员工" />
|
||||
</div>
|
||||
|
||||
<div v-else class="schedule-board-wrap">
|
||||
<table class="schedule-board-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>员工</th>
|
||||
<th v-for="day in props.dayOptions" :key="day.dayOfWeek">
|
||||
{{ day.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in props.rows" :key="row.staffId">
|
||||
<td class="schedule-staff-cell">
|
||||
<div class="schedule-staff-name">{{ row.staffName }}</div>
|
||||
<div class="schedule-staff-role">
|
||||
{{ props.roleLabelResolver(row.roleType) }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td
|
||||
v-for="day in props.dayOptions"
|
||||
:key="`${row.staffId}-${day.dayOfWeek}`"
|
||||
>
|
||||
<div
|
||||
class="schedule-cell"
|
||||
:class="
|
||||
props.getShiftClass(
|
||||
resolveDayShift(row, day.dayOfWeek).shiftType,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="schedule-cell-time">
|
||||
{{
|
||||
props.getShiftTimeText(resolveDayShift(row, day.dayOfWeek))
|
||||
}}
|
||||
</span>
|
||||
<span class="schedule-cell-label">
|
||||
{{
|
||||
props.getShiftLabel(
|
||||
resolveDayShift(row, day.dayOfWeek).shiftType,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 文件职责:员工排班页面常量定义。
|
||||
* 1. 提供角色/状态/班次等选项源。
|
||||
* 2. 提供页面默认值与分页配置。
|
||||
*/
|
||||
import type { StoreShiftTemplatesDto } from '#/api/store-staff';
|
||||
import type {
|
||||
DayOption,
|
||||
ShiftOption,
|
||||
StaffEditorFormState,
|
||||
StaffFilterState,
|
||||
StaffPermissionOption,
|
||||
StaffRoleOption,
|
||||
StaffStatusOption,
|
||||
} from '#/views/store/staff/types';
|
||||
|
||||
export const DAY_OPTIONS: DayOption[] = [
|
||||
{ dayOfWeek: 0, label: '周一', shortLabel: '一' },
|
||||
{ dayOfWeek: 1, label: '周二', shortLabel: '二' },
|
||||
{ dayOfWeek: 2, label: '周三', shortLabel: '三' },
|
||||
{ dayOfWeek: 3, label: '周四', shortLabel: '四' },
|
||||
{ dayOfWeek: 4, label: '周五', shortLabel: '五' },
|
||||
{ dayOfWeek: 5, label: '周六', shortLabel: '六' },
|
||||
{ dayOfWeek: 6, label: '周日', shortLabel: '日' },
|
||||
];
|
||||
|
||||
export const SHIFT_OPTIONS: ShiftOption[] = [
|
||||
{
|
||||
value: 'morning',
|
||||
label: '早班',
|
||||
className: 'shift-morning',
|
||||
},
|
||||
{
|
||||
value: 'evening',
|
||||
label: '晚班',
|
||||
className: 'shift-evening',
|
||||
},
|
||||
{
|
||||
value: 'full',
|
||||
label: '全天',
|
||||
className: 'shift-full',
|
||||
},
|
||||
{
|
||||
value: 'off',
|
||||
label: '休息',
|
||||
className: 'shift-off',
|
||||
},
|
||||
];
|
||||
|
||||
export const SHIFT_CYCLE = SHIFT_OPTIONS.map((item) => item.value);
|
||||
|
||||
export const STAFF_ROLE_OPTIONS: StaffRoleOption[] = [
|
||||
{
|
||||
value: 'manager',
|
||||
label: '店长',
|
||||
tagClassName: 'role-manager',
|
||||
},
|
||||
{
|
||||
value: 'cashier',
|
||||
label: '收银员',
|
||||
tagClassName: 'role-cashier',
|
||||
},
|
||||
{
|
||||
value: 'courier',
|
||||
label: '配送员',
|
||||
tagClassName: 'role-courier',
|
||||
},
|
||||
{
|
||||
value: 'chef',
|
||||
label: '厨师',
|
||||
tagClassName: 'role-chef',
|
||||
},
|
||||
];
|
||||
|
||||
export const STAFF_STATUS_OPTIONS: StaffStatusOption[] = [
|
||||
{
|
||||
value: 'active',
|
||||
label: '在职',
|
||||
dotClassName: 'status-dot-active',
|
||||
},
|
||||
{
|
||||
value: 'leave',
|
||||
label: '休假',
|
||||
dotClassName: 'status-dot-leave',
|
||||
},
|
||||
{
|
||||
value: 'resigned',
|
||||
label: '离职',
|
||||
dotClassName: 'status-dot-resigned',
|
||||
},
|
||||
];
|
||||
|
||||
export const STAFF_PERMISSION_OPTIONS: StaffPermissionOption[] = [
|
||||
{ value: '收银', label: '收银' },
|
||||
{ value: '退款', label: '退款' },
|
||||
{ value: '配送管理', label: '配送管理' },
|
||||
{ value: '订单查看', label: '订单查看' },
|
||||
{ value: '库存管理', label: '库存管理' },
|
||||
{ value: '数据统计', label: '数据统计' },
|
||||
];
|
||||
|
||||
export const DEFAULT_SHIFT_TEMPLATES: StoreShiftTemplatesDto = {
|
||||
morning: {
|
||||
startTime: '09:00',
|
||||
endTime: '14:00',
|
||||
},
|
||||
evening: {
|
||||
startTime: '14:00',
|
||||
endTime: '21:00',
|
||||
},
|
||||
full: {
|
||||
startTime: '09:00',
|
||||
endTime: '21:00',
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_FILTER_STATE: StaffFilterState = {
|
||||
keyword: '',
|
||||
roleType: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
export const DEFAULT_STAFF_FORM: StaffEditorFormState = {
|
||||
id: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
roleType: 'cashier',
|
||||
status: 'active',
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
export const PAGE_SIZE_OPTIONS = [10, 20, 50];
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 文件职责:员工排班复制动作。
|
||||
* 1. 管理复制弹窗选择状态。
|
||||
* 2. 提交“模板+排班”复制请求。
|
||||
*/
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { copyStoreStaffScheduleApi } from '#/api/store-staff';
|
||||
|
||||
interface CreateCopyActionsOptions {
|
||||
copyCandidates: ComputedRef<StoreListItemDto[]>;
|
||||
copyTargetStoreIds: Ref<string[]>;
|
||||
isCopyModalOpen: Ref<boolean>;
|
||||
isCopySubmitting: Ref<boolean>;
|
||||
selectedStoreId: Ref<string>;
|
||||
}
|
||||
|
||||
export function createCopyActions(options: CreateCopyActionsOptions) {
|
||||
/** 打开复制弹窗。 */
|
||||
function openCopyModal() {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
options.copyTargetStoreIds.value = [];
|
||||
options.isCopyModalOpen.value = true;
|
||||
}
|
||||
|
||||
/** 切换单个门店选中态。 */
|
||||
function toggleCopyStore(storeId: string, checked: boolean) {
|
||||
options.copyTargetStoreIds.value = checked
|
||||
? [...new Set([storeId, ...options.copyTargetStoreIds.value])]
|
||||
: options.copyTargetStoreIds.value.filter((id) => id !== storeId);
|
||||
}
|
||||
|
||||
/** 全选/取消全选。 */
|
||||
function handleCopyCheckAll(checked: boolean) {
|
||||
options.copyTargetStoreIds.value = checked
|
||||
? options.copyCandidates.value.map((item) => item.id)
|
||||
: [];
|
||||
}
|
||||
|
||||
/** 提交复制。 */
|
||||
async function handleCopySubmit() {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
if (options.copyTargetStoreIds.value.length === 0) {
|
||||
message.error('请至少选择一个目标门店');
|
||||
return;
|
||||
}
|
||||
|
||||
options.isCopySubmitting.value = true;
|
||||
try {
|
||||
await copyStoreStaffScheduleApi({
|
||||
sourceStoreId: options.selectedStoreId.value,
|
||||
targetStoreIds: options.copyTargetStoreIds.value,
|
||||
copyScope: 'template_and_schedule',
|
||||
});
|
||||
|
||||
message.success(
|
||||
`已复制班次模板与排班到 ${options.copyTargetStoreIds.value.length} 家门店`,
|
||||
);
|
||||
options.isCopyModalOpen.value = false;
|
||||
options.copyTargetStoreIds.value = [];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isCopySubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleCopyCheckAll,
|
||||
handleCopySubmit,
|
||||
openCopyModal,
|
||||
toggleCopyStore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 文件职责:员工排班数据动作。
|
||||
* 1. 加载门店列表、员工列表、班次模板与排班数据。
|
||||
* 2. 维护页面级数据快照。
|
||||
*/
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type {
|
||||
StaffDayShiftDto,
|
||||
StoreShiftTemplatesDto,
|
||||
StoreStaffDto,
|
||||
} from '#/api/store-staff';
|
||||
import type {
|
||||
StaffFilterState,
|
||||
StaffScheduleSnapshot,
|
||||
} from '#/views/store/staff/types';
|
||||
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
import {
|
||||
getStoreStaffListApi,
|
||||
getStoreStaffScheduleApi,
|
||||
} from '#/api/store-staff';
|
||||
|
||||
import {
|
||||
cloneScheduleMap,
|
||||
cloneShifts,
|
||||
cloneTemplates,
|
||||
createEmptyWeekShifts,
|
||||
normalizeWeekShifts,
|
||||
} from './helpers';
|
||||
|
||||
interface CreateDataActionsOptions {
|
||||
filters: StaffFilterState;
|
||||
isPageLoading: Ref<boolean>;
|
||||
isStoreLoading: Ref<boolean>;
|
||||
scheduleMap: Ref<Record<string, StaffDayShiftDto[]>>;
|
||||
scheduleSnapshot: Ref<null | StaffScheduleSnapshot>;
|
||||
selectedStoreId: Ref<string>;
|
||||
staffDirectory: Ref<StoreStaffDto[]>;
|
||||
staffRows: Ref<StoreStaffDto[]>;
|
||||
staffTotal: Ref<number>;
|
||||
stores: Ref<StoreListItemDto[]>;
|
||||
templates: Ref<StoreShiftTemplatesDto>;
|
||||
}
|
||||
|
||||
export function createDataActions(options: CreateDataActionsOptions) {
|
||||
/** 清空当前门店相关数据。 */
|
||||
function clearStoreData() {
|
||||
options.staffRows.value = [];
|
||||
options.staffDirectory.value = [];
|
||||
options.staffTotal.value = 0;
|
||||
options.scheduleMap.value = {};
|
||||
options.scheduleSnapshot.value = null;
|
||||
}
|
||||
|
||||
/** 加载员工分页列表(受筛选条件影响)。 */
|
||||
async function loadStaffPage() {
|
||||
if (!options.selectedStoreId.value) {
|
||||
options.staffRows.value = [];
|
||||
options.staffTotal.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getStoreStaffListApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
keyword: options.filters.keyword || undefined,
|
||||
roleType: options.filters.roleType || undefined,
|
||||
status: options.filters.status || undefined,
|
||||
page: options.filters.page,
|
||||
pageSize: options.filters.pageSize,
|
||||
});
|
||||
|
||||
options.staffRows.value = result.items ?? [];
|
||||
options.staffTotal.value = result.total ?? 0;
|
||||
}
|
||||
|
||||
/** 加载员工全量字典(用于周排班看板)。 */
|
||||
async function loadStaffDirectory() {
|
||||
if (!options.selectedStoreId.value) {
|
||||
options.staffDirectory.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getStoreStaffListApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
keyword: undefined,
|
||||
roleType: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
options.staffDirectory.value = result.items ?? [];
|
||||
}
|
||||
|
||||
/** 加载班次模板与排班配置。 */
|
||||
async function loadScheduleSettings() {
|
||||
if (!options.selectedStoreId.value) {
|
||||
options.scheduleMap.value = {};
|
||||
options.scheduleSnapshot.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getStoreStaffScheduleApi(
|
||||
options.selectedStoreId.value,
|
||||
);
|
||||
const templates = cloneTemplates(result.templates);
|
||||
|
||||
const scheduleMap: Record<string, StaffDayShiftDto[]> = {};
|
||||
for (const schedule of result.schedules ?? []) {
|
||||
scheduleMap[schedule.staffId] = normalizeWeekShifts({
|
||||
shifts: schedule.shifts,
|
||||
fallback: createEmptyWeekShifts(templates),
|
||||
templates,
|
||||
});
|
||||
}
|
||||
|
||||
options.templates.value = templates;
|
||||
options.scheduleMap.value = scheduleMap;
|
||||
options.scheduleSnapshot.value = {
|
||||
templates: cloneTemplates(templates),
|
||||
scheduleMap: cloneScheduleMap(scheduleMap),
|
||||
};
|
||||
}
|
||||
|
||||
/** 一次性刷新当前门店全部数据。 */
|
||||
async function reloadStoreData() {
|
||||
if (!options.selectedStoreId.value) {
|
||||
clearStoreData();
|
||||
return;
|
||||
}
|
||||
|
||||
options.isPageLoading.value = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
loadStaffPage(),
|
||||
loadStaffDirectory(),
|
||||
loadScheduleSettings(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isPageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载门店列表并初始化默认门店。 */
|
||||
async function loadStores() {
|
||||
options.isStoreLoading.value = true;
|
||||
try {
|
||||
const result = await getStoreListApi({
|
||||
keyword: undefined,
|
||||
businessStatus: undefined,
|
||||
auditStatus: undefined,
|
||||
serviceType: undefined,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
});
|
||||
|
||||
options.stores.value = result.items ?? [];
|
||||
|
||||
if (options.stores.value.length === 0) {
|
||||
options.selectedStoreId.value = '';
|
||||
clearStoreData();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSelected = options.stores.value.some(
|
||||
(item) => item.id === options.selectedStoreId.value,
|
||||
);
|
||||
if (!hasSelected) {
|
||||
const firstStore = options.stores.value[0];
|
||||
if (firstStore) {
|
||||
options.selectedStoreId.value = firstStore.id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await reloadStoreData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
options.stores.value = [];
|
||||
options.selectedStoreId.value = '';
|
||||
clearStoreData();
|
||||
} finally {
|
||||
options.isStoreLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 局部更新单员工排班映射。 */
|
||||
function patchStaffSchedule(staffId: string, shifts: StaffDayShiftDto[]) {
|
||||
const nextMap = {
|
||||
...options.scheduleMap.value,
|
||||
[staffId]: cloneShifts(shifts),
|
||||
};
|
||||
options.scheduleMap.value = nextMap;
|
||||
options.scheduleSnapshot.value = {
|
||||
templates: cloneTemplates(options.templates.value),
|
||||
scheduleMap: cloneScheduleMap(nextMap),
|
||||
};
|
||||
}
|
||||
|
||||
/** 批量更新排班映射。 */
|
||||
function patchScheduleMap(nextMap: Record<string, StaffDayShiftDto[]>) {
|
||||
options.scheduleMap.value = cloneScheduleMap(nextMap);
|
||||
options.scheduleSnapshot.value = {
|
||||
templates: cloneTemplates(options.templates.value),
|
||||
scheduleMap: cloneScheduleMap(nextMap),
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新模板并同步快照。 */
|
||||
function patchTemplates(nextTemplates: StoreShiftTemplatesDto) {
|
||||
options.templates.value = cloneTemplates(nextTemplates);
|
||||
options.scheduleSnapshot.value = {
|
||||
templates: cloneTemplates(nextTemplates),
|
||||
scheduleMap: cloneScheduleMap(options.scheduleMap.value),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
clearStoreData,
|
||||
loadScheduleSettings,
|
||||
loadStaffDirectory,
|
||||
loadStaffPage,
|
||||
loadStores,
|
||||
patchScheduleMap,
|
||||
patchStaffSchedule,
|
||||
patchTemplates,
|
||||
reloadStoreData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 文件职责:员工排班页面纯函数工具。
|
||||
* 1. 负责克隆、归一化、格式化等无副作用逻辑。
|
||||
* 2. 负责排班矩阵与周视图数据转换。
|
||||
*/
|
||||
import type {
|
||||
ShiftType,
|
||||
StaffDayShiftDto,
|
||||
StaffScheduleDto,
|
||||
StoreShiftTemplatesDto,
|
||||
} from '#/api/store-staff';
|
||||
import type {
|
||||
StaffEditorFormState,
|
||||
StaffFilterState,
|
||||
WeekEditorRow,
|
||||
} from '#/views/store/staff/types';
|
||||
|
||||
import { DAY_OPTIONS } from './constants';
|
||||
|
||||
/** 深拷贝模板。 */
|
||||
export function cloneTemplates(
|
||||
source: StoreShiftTemplatesDto,
|
||||
): StoreShiftTemplatesDto {
|
||||
return {
|
||||
morning: { ...source.morning },
|
||||
evening: { ...source.evening },
|
||||
full: { ...source.full },
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝单员工周排班。 */
|
||||
export function cloneShifts(source: StaffDayShiftDto[]) {
|
||||
return source.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
/** 深拷贝排班映射。 */
|
||||
export function cloneScheduleMap(source: Record<string, StaffDayShiftDto[]>) {
|
||||
const next: Record<string, StaffDayShiftDto[]> = {};
|
||||
for (const [staffId, shifts] of Object.entries(source)) {
|
||||
next[staffId] = cloneShifts(shifts);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 深拷贝筛选表单。 */
|
||||
export function cloneFilterState(source: StaffFilterState): StaffFilterState {
|
||||
return {
|
||||
keyword: source.keyword,
|
||||
roleType: source.roleType,
|
||||
status: source.status,
|
||||
page: source.page,
|
||||
pageSize: source.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝员工编辑表单。 */
|
||||
export function cloneStaffForm(
|
||||
source: StaffEditorFormState,
|
||||
): StaffEditorFormState {
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
phone: source.phone,
|
||||
email: source.email,
|
||||
roleType: source.roleType,
|
||||
status: source.status,
|
||||
permissions: [...source.permissions],
|
||||
};
|
||||
}
|
||||
|
||||
/** 按模板创建空白一周(默认休息)。 */
|
||||
export function createEmptyWeekShifts(
|
||||
templates: StoreShiftTemplatesDto,
|
||||
): StaffDayShiftDto[] {
|
||||
return DAY_OPTIONS.map((day) => {
|
||||
const fallbackShift = templates.morning;
|
||||
return {
|
||||
dayOfWeek: day.dayOfWeek,
|
||||
shiftType: 'off',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
...fallbackShift,
|
||||
};
|
||||
}).map((item) => ({
|
||||
dayOfWeek: item.dayOfWeek,
|
||||
shiftType: item.shiftType,
|
||||
startTime: item.shiftType === 'off' ? '' : item.startTime,
|
||||
endTime: item.shiftType === 'off' ? '' : item.endTime,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 生成指定班次默认时间。 */
|
||||
export function resolveShiftTimeByType(
|
||||
shiftType: ShiftType,
|
||||
templates: StoreShiftTemplatesDto,
|
||||
) {
|
||||
if (shiftType === 'off') {
|
||||
return {
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
};
|
||||
}
|
||||
const template = templates[shiftType];
|
||||
return {
|
||||
startTime: template.startTime,
|
||||
endTime: template.endTime,
|
||||
};
|
||||
}
|
||||
|
||||
/** 归一化单日排班。 */
|
||||
export function normalizeDayShift(payload: {
|
||||
dayOfWeek: number;
|
||||
fallback?: StaffDayShiftDto;
|
||||
shift: Partial<StaffDayShiftDto>;
|
||||
templates: StoreShiftTemplatesDto;
|
||||
}) {
|
||||
const fallbackShift = payload.fallback;
|
||||
const shiftType = normalizeShiftType(
|
||||
payload.shift.shiftType,
|
||||
fallbackShift?.shiftType ?? 'off',
|
||||
);
|
||||
|
||||
if (shiftType === 'off') {
|
||||
return {
|
||||
dayOfWeek: payload.dayOfWeek,
|
||||
shiftType,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
} satisfies StaffDayShiftDto;
|
||||
}
|
||||
|
||||
const templateTime = resolveShiftTimeByType(shiftType, payload.templates);
|
||||
return {
|
||||
dayOfWeek: payload.dayOfWeek,
|
||||
shiftType,
|
||||
startTime: normalizeTime(payload.shift.startTime, templateTime.startTime),
|
||||
endTime: normalizeTime(payload.shift.endTime, templateTime.endTime),
|
||||
} satisfies StaffDayShiftDto;
|
||||
}
|
||||
|
||||
/** 归一化周排班数组,确保包含 0-6 共七天。 */
|
||||
export function normalizeWeekShifts(payload: {
|
||||
fallback: StaffDayShiftDto[];
|
||||
shifts: Partial<StaffDayShiftDto>[];
|
||||
templates: StoreShiftTemplatesDto;
|
||||
}) {
|
||||
const shiftMap = new Map<number, Partial<StaffDayShiftDto>>();
|
||||
for (const shift of payload.shifts) {
|
||||
const dayOfWeek = Number(shift.dayOfWeek);
|
||||
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
|
||||
continue;
|
||||
}
|
||||
shiftMap.set(dayOfWeek, shift);
|
||||
}
|
||||
|
||||
return DAY_OPTIONS.map((day) => {
|
||||
const fallbackShift = payload.fallback.find(
|
||||
(item) => item.dayOfWeek === day.dayOfWeek,
|
||||
);
|
||||
return normalizeDayShift({
|
||||
dayOfWeek: day.dayOfWeek,
|
||||
shift: shiftMap.get(day.dayOfWeek) ?? fallbackShift ?? {},
|
||||
fallback: fallbackShift,
|
||||
templates: payload.templates,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 将接口排班数组转换为映射。 */
|
||||
export function createScheduleMap(schedules: StaffScheduleDto[]) {
|
||||
const scheduleMap: Record<string, StaffDayShiftDto[]> = {};
|
||||
for (const schedule of schedules) {
|
||||
scheduleMap[schedule.staffId] = normalizeWeekShifts({
|
||||
shifts: schedule.shifts,
|
||||
fallback: createEmptyWeekShifts({
|
||||
morning: { startTime: '09:00', endTime: '14:00' },
|
||||
evening: { startTime: '14:00', endTime: '21:00' },
|
||||
full: { startTime: '09:00', endTime: '21:00' },
|
||||
}),
|
||||
templates: {
|
||||
morning: { startTime: '09:00', endTime: '14:00' },
|
||||
evening: { startTime: '14:00', endTime: '21:00' },
|
||||
full: { startTime: '09:00', endTime: '21:00' },
|
||||
},
|
||||
});
|
||||
}
|
||||
return scheduleMap;
|
||||
}
|
||||
|
||||
/** 将员工排班矩阵转为可编辑周视图行。 */
|
||||
export function buildWeekEditorRows(payload: {
|
||||
scheduleMap: Record<string, StaffDayShiftDto[]>;
|
||||
staffs: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
roleType: WeekEditorRow['roleType'];
|
||||
status: WeekEditorRow['status'];
|
||||
}>;
|
||||
templates: StoreShiftTemplatesDto;
|
||||
}) {
|
||||
return payload.staffs.map((staff) => ({
|
||||
staffId: staff.id,
|
||||
staffName: staff.name,
|
||||
roleType: staff.roleType,
|
||||
status: staff.status,
|
||||
shifts: cloneShifts(
|
||||
payload.scheduleMap[staff.id] ?? createEmptyWeekShifts(payload.templates),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 更新周视图某员工某天班次。 */
|
||||
export function updateWeekRowShift(payload: {
|
||||
dayOfWeek: number;
|
||||
nextShiftType: ShiftType;
|
||||
row: WeekEditorRow;
|
||||
templates: StoreShiftTemplatesDto;
|
||||
}) {
|
||||
return {
|
||||
...payload.row,
|
||||
shifts: payload.row.shifts.map((item) => {
|
||||
if (item.dayOfWeek !== payload.dayOfWeek) return { ...item };
|
||||
const normalized = normalizeDayShift({
|
||||
dayOfWeek: payload.dayOfWeek,
|
||||
shift: {
|
||||
dayOfWeek: payload.dayOfWeek,
|
||||
shiftType: payload.nextShiftType,
|
||||
},
|
||||
fallback: item,
|
||||
templates: payload.templates,
|
||||
});
|
||||
return normalized;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 归一化班次类型。 */
|
||||
export function normalizeShiftType(
|
||||
value: unknown,
|
||||
fallback: ShiftType,
|
||||
): ShiftType {
|
||||
if (
|
||||
value === 'morning' ||
|
||||
value === 'evening' ||
|
||||
value === 'full' ||
|
||||
value === 'off'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 归一化时间字符串(HH:mm)。 */
|
||||
export function normalizeTime(value: unknown, fallback: string) {
|
||||
const input = typeof value === 'string' ? value : '';
|
||||
const matched = /^(\d{2}):(\d{2})$/.exec(input);
|
||||
if (!matched) {
|
||||
return fallback;
|
||||
}
|
||||
const hour = Number(matched[1]);
|
||||
const minute = Number(matched[2]);
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
return fallback;
|
||||
}
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 获取当前周一日期。 */
|
||||
export function getCurrentWeekStartDate(baseDate = new Date()) {
|
||||
const date = new Date(baseDate);
|
||||
const weekDay = date.getDay();
|
||||
const diff = weekDay === 0 ? -6 : 1 - weekDay;
|
||||
date.setDate(date.getDate() + diff);
|
||||
return toDateOnly(date);
|
||||
}
|
||||
|
||||
/** 日期转 yyyy-MM-dd。 */
|
||||
export function toDateOnly(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** 手机号脱敏展示。 */
|
||||
export function maskPhone(phone: string) {
|
||||
const normalized = String(phone || '').replaceAll(/\D/g, '');
|
||||
if (normalized.length < 7) return phone;
|
||||
return `${normalized.slice(0, 3)}****${normalized.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** 根据姓名提取头像字符。 */
|
||||
export function resolveAvatarText(name: string) {
|
||||
const normalized = String(name || '').trim();
|
||||
if (!normalized) return '?';
|
||||
return normalized.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
/** 获取班次展示时间。 */
|
||||
export function formatShiftTimeText(shift: StaffDayShiftDto) {
|
||||
if (shift.shiftType === 'off') return '休息';
|
||||
return `${shift.startTime}-${shift.endTime}`;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 文件职责:排班编辑动作。
|
||||
* 1. 管理个人排班抽屉与周排班抽屉。
|
||||
* 2. 提交个人排班与周排班保存请求。
|
||||
*/
|
||||
import type {
|
||||
ShiftType,
|
||||
StaffDayShiftDto,
|
||||
StoreShiftTemplatesDto,
|
||||
StoreStaffDto,
|
||||
} from '#/api/store-staff';
|
||||
import type {
|
||||
StaffPersonalScheduleFormState,
|
||||
WeekEditorRow,
|
||||
} from '#/views/store/staff/types';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
saveStoreStaffPersonalScheduleApi,
|
||||
saveStoreStaffWeeklyScheduleApi,
|
||||
} from '#/api/store-staff';
|
||||
|
||||
import { SHIFT_CYCLE } from './constants';
|
||||
import {
|
||||
buildWeekEditorRows,
|
||||
cloneScheduleMap,
|
||||
cloneShifts,
|
||||
createEmptyWeekShifts,
|
||||
normalizeDayShift,
|
||||
normalizeTime,
|
||||
updateWeekRowShift,
|
||||
} from './helpers';
|
||||
|
||||
interface CreateScheduleActionsOptions {
|
||||
isPersonalDrawerOpen: Ref<boolean>;
|
||||
isPersonalSaving: Ref<boolean>;
|
||||
isWeekDrawerOpen: Ref<boolean>;
|
||||
isWeekSaving: Ref<boolean>;
|
||||
loadScheduleSettings: () => Promise<void>;
|
||||
patchScheduleMap: (nextMap: Record<string, StaffDayShiftDto[]>) => void;
|
||||
patchStaffSchedule: (staffId: string, shifts: StaffDayShiftDto[]) => void;
|
||||
personalForm: StaffPersonalScheduleFormState;
|
||||
scheduleMap: Ref<Record<string, StaffDayShiftDto[]>>;
|
||||
selectedStoreId: Ref<string>;
|
||||
staffDirectory: Ref<StoreStaffDto[]>;
|
||||
templates: Ref<StoreShiftTemplatesDto>;
|
||||
weekRows: Ref<WeekEditorRow[]>;
|
||||
}
|
||||
|
||||
export function createScheduleActions(options: CreateScheduleActionsOptions) {
|
||||
/** 打开个人排班抽屉。 */
|
||||
function openPersonalScheduleDrawer(staff: StoreStaffDto) {
|
||||
options.personalForm.staffId = staff.id;
|
||||
options.personalForm.staffName = staff.name;
|
||||
options.personalForm.roleType = staff.roleType;
|
||||
options.personalForm.shifts = cloneShifts(
|
||||
options.scheduleMap.value[staff.id] ??
|
||||
createEmptyWeekShifts(options.templates.value),
|
||||
);
|
||||
options.isPersonalDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 更新个人排班班次类型。 */
|
||||
function setPersonalShiftType(dayOfWeek: number, shiftType: ShiftType) {
|
||||
options.personalForm.shifts = options.personalForm.shifts.map((item) => {
|
||||
if (item.dayOfWeek !== dayOfWeek) return { ...item };
|
||||
return normalizeDayShift({
|
||||
dayOfWeek,
|
||||
shift: {
|
||||
...item,
|
||||
shiftType,
|
||||
},
|
||||
fallback: item,
|
||||
templates: options.templates.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新个人排班开始时间。 */
|
||||
function setPersonalShiftStart(dayOfWeek: number, startTime: string) {
|
||||
options.personalForm.shifts = options.personalForm.shifts.map((item) => {
|
||||
if (item.dayOfWeek !== dayOfWeek) return { ...item };
|
||||
if (item.shiftType === 'off') return { ...item };
|
||||
return {
|
||||
...item,
|
||||
startTime: normalizeTime(startTime, item.startTime),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新个人排班结束时间。 */
|
||||
function setPersonalShiftEnd(dayOfWeek: number, endTime: string) {
|
||||
options.personalForm.shifts = options.personalForm.shifts.map((item) => {
|
||||
if (item.dayOfWeek !== dayOfWeek) return { ...item };
|
||||
if (item.shiftType === 'off') return { ...item };
|
||||
return {
|
||||
...item,
|
||||
endTime: normalizeTime(endTime, item.endTime),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 提交个人排班。 */
|
||||
async function handleSavePersonalSchedule() {
|
||||
if (!options.selectedStoreId.value || !options.personalForm.staffId) return;
|
||||
|
||||
options.isPersonalSaving.value = true;
|
||||
try {
|
||||
const result = await saveStoreStaffPersonalScheduleApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
staffId: options.personalForm.staffId,
|
||||
shifts: cloneShifts(options.personalForm.shifts),
|
||||
});
|
||||
|
||||
options.patchStaffSchedule(result.staffId, result.shifts);
|
||||
options.isPersonalDrawerOpen.value = false;
|
||||
message.success('员工排班已保存');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isPersonalSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开周排班抽屉。 */
|
||||
function openWeekScheduleDrawer() {
|
||||
const editableStaffs = options.staffDirectory.value.filter(
|
||||
(staff) => staff.status !== 'resigned',
|
||||
);
|
||||
options.weekRows.value = buildWeekEditorRows({
|
||||
staffs: editableStaffs,
|
||||
scheduleMap: options.scheduleMap.value,
|
||||
templates: options.templates.value,
|
||||
});
|
||||
options.isWeekDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 按循环顺序切换周视图单元格班次。 */
|
||||
function cycleWeekShift(payload: { dayOfWeek: number; staffId: string }) {
|
||||
options.weekRows.value = options.weekRows.value.map((row) => {
|
||||
if (row.staffId !== payload.staffId) return row;
|
||||
|
||||
const currentShift = row.shifts.find(
|
||||
(item) => item.dayOfWeek === payload.dayOfWeek,
|
||||
);
|
||||
const currentType = currentShift?.shiftType ?? 'off';
|
||||
const currentIndex = SHIFT_CYCLE.indexOf(currentType);
|
||||
const nextType =
|
||||
SHIFT_CYCLE[(currentIndex + 1) % SHIFT_CYCLE.length] ?? 'off';
|
||||
|
||||
return updateWeekRowShift({
|
||||
row,
|
||||
dayOfWeek: payload.dayOfWeek,
|
||||
nextShiftType: nextType,
|
||||
templates: options.templates.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 提交周排班。 */
|
||||
async function handleSaveWeekSchedule() {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
|
||||
options.isWeekSaving.value = true;
|
||||
try {
|
||||
const schedules = options.weekRows.value.map((row) => ({
|
||||
staffId: row.staffId,
|
||||
shifts: cloneShifts(row.shifts),
|
||||
}));
|
||||
|
||||
const result = await saveStoreStaffWeeklyScheduleApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
schedules,
|
||||
});
|
||||
|
||||
const nextMap = cloneScheduleMap(options.scheduleMap.value);
|
||||
for (const schedule of result.schedules ?? []) {
|
||||
nextMap[schedule.staffId] = cloneShifts(schedule.shifts);
|
||||
}
|
||||
options.patchScheduleMap(nextMap);
|
||||
|
||||
options.isWeekDrawerOpen.value = false;
|
||||
message.success('本周排班已保存');
|
||||
|
||||
// 1. 保守刷新一次后端快照,避免本地与服务端算法差异。
|
||||
await options.loadScheduleSettings();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isWeekSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cycleWeekShift,
|
||||
handleSavePersonalSchedule,
|
||||
handleSaveWeekSchedule,
|
||||
openPersonalScheduleDrawer,
|
||||
openWeekScheduleDrawer,
|
||||
setPersonalShiftEnd,
|
||||
setPersonalShiftStart,
|
||||
setPersonalShiftType,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 文件职责:员工管理动作。
|
||||
* 1. 管理员工抽屉开关与表单字段。
|
||||
* 2. 执行新增、编辑、删除员工请求。
|
||||
*/
|
||||
import type { StoreStaffDto } from '#/api/store-staff';
|
||||
import type {
|
||||
StaffDrawerMode,
|
||||
StaffEditorFormState,
|
||||
} from '#/views/store/staff/types';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { deleteStoreStaffApi, saveStoreStaffApi } from '#/api/store-staff';
|
||||
|
||||
import { DEFAULT_STAFF_FORM } from './constants';
|
||||
import { cloneStaffForm } from './helpers';
|
||||
|
||||
interface CreateStaffActionsOptions {
|
||||
deletingStaffId: Ref<string>;
|
||||
isStaffSaving: Ref<boolean>;
|
||||
isStaffDrawerOpen: Ref<boolean>;
|
||||
reloadStoreData: () => Promise<void>;
|
||||
selectedStoreId: Ref<string>;
|
||||
staffDrawerMode: Ref<StaffDrawerMode>;
|
||||
staffForm: StaffEditorFormState;
|
||||
}
|
||||
|
||||
export function createStaffActions(options: CreateStaffActionsOptions) {
|
||||
/** 重置员工表单。 */
|
||||
function resetStaffForm() {
|
||||
Object.assign(options.staffForm, cloneStaffForm(DEFAULT_STAFF_FORM));
|
||||
}
|
||||
|
||||
/** 打开员工抽屉。 */
|
||||
function openStaffDrawer(mode: StaffDrawerMode, staff?: StoreStaffDto) {
|
||||
options.staffDrawerMode.value = mode;
|
||||
if (mode === 'edit' && staff) {
|
||||
Object.assign(options.staffForm, {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
phone: staff.phone,
|
||||
email: staff.email,
|
||||
roleType: staff.roleType,
|
||||
status: staff.status,
|
||||
permissions: [...staff.permissions],
|
||||
} satisfies StaffEditorFormState);
|
||||
} else {
|
||||
resetStaffForm();
|
||||
}
|
||||
options.isStaffDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 设置员工姓名。 */
|
||||
function setStaffName(value: string) {
|
||||
options.staffForm.name = value;
|
||||
}
|
||||
|
||||
/** 设置员工手机号。 */
|
||||
function setStaffPhone(value: string) {
|
||||
options.staffForm.phone = value;
|
||||
}
|
||||
|
||||
/** 设置员工邮箱。 */
|
||||
function setStaffEmail(value: string) {
|
||||
options.staffForm.email = value;
|
||||
}
|
||||
|
||||
/** 设置员工角色。 */
|
||||
function setStaffRoleType(value: StaffEditorFormState['roleType']) {
|
||||
options.staffForm.roleType = value;
|
||||
|
||||
// 1. 店长默认拥有“全部权限”。
|
||||
if (value === 'manager' && options.staffForm.permissions.length === 0) {
|
||||
options.staffForm.permissions = ['全部权限'];
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置员工状态。 */
|
||||
function setStaffStatus(value: StaffEditorFormState['status']) {
|
||||
options.staffForm.status = value;
|
||||
}
|
||||
|
||||
/** 设置员工权限。 */
|
||||
function setStaffPermissions(value: string[]) {
|
||||
options.staffForm.permissions = [
|
||||
...new Set(value.map((item) => item.trim()).filter(Boolean)),
|
||||
];
|
||||
}
|
||||
|
||||
/** 提交新增/编辑员工。 */
|
||||
async function handleSubmitStaff() {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
|
||||
const name = options.staffForm.name.trim();
|
||||
const phone = options.staffForm.phone.replaceAll(/\D/g, '');
|
||||
|
||||
if (!name) {
|
||||
message.error('请输入员工姓名');
|
||||
return;
|
||||
}
|
||||
|
||||
if (phone.length !== 11) {
|
||||
message.error('请输入 11 位手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
options.isStaffSaving.value = true;
|
||||
try {
|
||||
await saveStoreStaffApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
id: options.staffForm.id || undefined,
|
||||
name,
|
||||
phone,
|
||||
email: options.staffForm.email.trim(),
|
||||
roleType: options.staffForm.roleType,
|
||||
status: options.staffForm.status,
|
||||
permissions: [...options.staffForm.permissions],
|
||||
});
|
||||
|
||||
message.success(
|
||||
options.staffDrawerMode.value === 'edit'
|
||||
? '员工信息已更新'
|
||||
: '员工已添加',
|
||||
);
|
||||
options.isStaffDrawerOpen.value = false;
|
||||
resetStaffForm();
|
||||
await options.reloadStoreData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isStaffSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除员工。 */
|
||||
async function handleDeleteStaff(staff: StoreStaffDto) {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
|
||||
options.deletingStaffId.value = staff.id;
|
||||
try {
|
||||
await deleteStoreStaffApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
staffId: staff.id,
|
||||
});
|
||||
message.success('员工已删除');
|
||||
await options.reloadStoreData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.deletingStaffId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleDeleteStaff,
|
||||
handleSubmitStaff,
|
||||
openStaffDrawer,
|
||||
resetStaffForm,
|
||||
setStaffEmail,
|
||||
setStaffName,
|
||||
setStaffPermissions,
|
||||
setStaffPhone,
|
||||
setStaffRoleType,
|
||||
setStaffStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 文件职责:班次模板动作。
|
||||
* 1. 管理模板时间输入与重置。
|
||||
* 2. 提交模板保存请求并刷新排班。
|
||||
*/
|
||||
import type { ShiftType, StoreShiftTemplatesDto } from '#/api/store-staff';
|
||||
import type { StaffScheduleSnapshot } from '#/views/store/staff/types';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { saveStoreStaffTemplatesApi } from '#/api/store-staff';
|
||||
|
||||
import { cloneTemplates, normalizeTime } from './helpers';
|
||||
|
||||
interface CreateTemplateActionsOptions {
|
||||
isTemplateSaving: Ref<boolean>;
|
||||
loadScheduleSettings: () => Promise<void>;
|
||||
patchTemplates: (nextTemplates: StoreShiftTemplatesDto) => void;
|
||||
scheduleSnapshot: Ref<null | StaffScheduleSnapshot>;
|
||||
selectedStoreId: Ref<string>;
|
||||
templates: Ref<StoreShiftTemplatesDto>;
|
||||
}
|
||||
|
||||
export function createTemplateActions(options: CreateTemplateActionsOptions) {
|
||||
/** 更新单个模板时间字段。 */
|
||||
function setTemplateTime(payload: {
|
||||
field: 'endTime' | 'startTime';
|
||||
shiftType: Exclude<ShiftType, 'off'>;
|
||||
value: string;
|
||||
}) {
|
||||
const currentTemplate = options.templates.value[payload.shiftType];
|
||||
options.templates.value = {
|
||||
...options.templates.value,
|
||||
[payload.shiftType]: {
|
||||
...currentTemplate,
|
||||
[payload.field]: normalizeTime(
|
||||
payload.value,
|
||||
currentTemplate[payload.field],
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 重置模板到最近快照。 */
|
||||
function resetTemplates() {
|
||||
if (!options.scheduleSnapshot.value) return;
|
||||
options.templates.value = cloneTemplates(
|
||||
options.scheduleSnapshot.value.templates,
|
||||
);
|
||||
message.success('班次模板已重置');
|
||||
}
|
||||
|
||||
/** 保存模板。 */
|
||||
async function handleSaveTemplates() {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
|
||||
options.isTemplateSaving.value = true;
|
||||
try {
|
||||
const nextTemplates = await saveStoreStaffTemplatesApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
templates: cloneTemplates(options.templates.value),
|
||||
});
|
||||
|
||||
options.patchTemplates(nextTemplates);
|
||||
await options.loadScheduleSettings();
|
||||
message.success('班次模板已保存');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isTemplateSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSaveTemplates,
|
||||
resetTemplates,
|
||||
setTemplateTime,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* 文件职责:员工排班页面主编排。
|
||||
* 1. 维护门店、员工、模板、排班、抽屉与复制弹窗状态。
|
||||
* 2. 组装数据加载、员工管理、模板编辑、排班编辑、复制动作。
|
||||
* 3. 对视图层暴露可直接绑定的状态与方法。
|
||||
*/
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type {
|
||||
ShiftType,
|
||||
StaffDayShiftDto,
|
||||
StaffRoleType,
|
||||
StaffStatus,
|
||||
StoreShiftTemplatesDto,
|
||||
StoreStaffDto,
|
||||
} from '#/api/store-staff';
|
||||
import type {
|
||||
ShiftLegendItem,
|
||||
StaffDrawerMode,
|
||||
StaffEditorFormState,
|
||||
StaffFilterState,
|
||||
StaffPersonalScheduleFormState,
|
||||
WeekEditorRow,
|
||||
} from '#/views/store/staff/types';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
DAY_OPTIONS,
|
||||
DEFAULT_FILTER_STATE,
|
||||
DEFAULT_SHIFT_TEMPLATES,
|
||||
DEFAULT_STAFF_FORM,
|
||||
PAGE_SIZE_OPTIONS,
|
||||
SHIFT_OPTIONS,
|
||||
STAFF_PERMISSION_OPTIONS,
|
||||
STAFF_ROLE_OPTIONS,
|
||||
STAFF_STATUS_OPTIONS,
|
||||
} from './staff-page/constants';
|
||||
import { createCopyActions } from './staff-page/copy-actions';
|
||||
import { createDataActions } from './staff-page/data-actions';
|
||||
import {
|
||||
cloneFilterState,
|
||||
cloneShifts,
|
||||
cloneStaffForm,
|
||||
cloneTemplates,
|
||||
createEmptyWeekShifts,
|
||||
formatShiftTimeText,
|
||||
getCurrentWeekStartDate,
|
||||
maskPhone,
|
||||
normalizeWeekShifts,
|
||||
resolveAvatarText,
|
||||
} from './staff-page/helpers';
|
||||
import { createScheduleActions } from './staff-page/schedule-actions';
|
||||
import { createStaffActions } from './staff-page/staff-actions';
|
||||
import { createTemplateActions } from './staff-page/template-actions';
|
||||
|
||||
export function useStoreStaffPage() {
|
||||
// 1. 页面 loading / submitting 状态。
|
||||
const isStoreLoading = ref(false);
|
||||
const isPageLoading = ref(false);
|
||||
const isStaffSaving = ref(false);
|
||||
const isTemplateSaving = ref(false);
|
||||
const isPersonalSaving = ref(false);
|
||||
const isWeekSaving = ref(false);
|
||||
const isCopySubmitting = ref(false);
|
||||
const deletingStaffId = ref('');
|
||||
|
||||
// 2. 页面核心业务数据。
|
||||
const stores = ref<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
|
||||
const filters = reactive<StaffFilterState>(
|
||||
cloneFilterState(DEFAULT_FILTER_STATE),
|
||||
);
|
||||
|
||||
const staffRows = ref<StoreStaffDto[]>([]);
|
||||
const staffTotal = ref(0);
|
||||
const staffDirectory = ref<StoreStaffDto[]>([]);
|
||||
|
||||
const templates = ref<StoreShiftTemplatesDto>(
|
||||
cloneTemplates(DEFAULT_SHIFT_TEMPLATES),
|
||||
);
|
||||
const scheduleMap = ref<Record<string, StaffDayShiftDto[]>>({});
|
||||
const scheduleSnapshot = ref<null | {
|
||||
scheduleMap: Record<string, StaffDayShiftDto[]>;
|
||||
templates: StoreShiftTemplatesDto;
|
||||
}>(null);
|
||||
|
||||
// 3. 抽屉与弹窗状态。
|
||||
const isStaffDrawerOpen = ref(false);
|
||||
const staffDrawerMode = ref<StaffDrawerMode>('create');
|
||||
const staffForm = reactive<StaffEditorFormState>(
|
||||
cloneStaffForm(DEFAULT_STAFF_FORM),
|
||||
);
|
||||
|
||||
const isPersonalDrawerOpen = ref(false);
|
||||
const personalScheduleForm = reactive<StaffPersonalScheduleFormState>({
|
||||
staffId: '',
|
||||
staffName: '',
|
||||
roleType: 'cashier',
|
||||
shifts: createEmptyWeekShifts(DEFAULT_SHIFT_TEMPLATES),
|
||||
});
|
||||
|
||||
const isWeekDrawerOpen = ref(false);
|
||||
const weekRows = ref<WeekEditorRow[]>([]);
|
||||
|
||||
const isCopyModalOpen = ref(false);
|
||||
const copyTargetStoreIds = ref<string[]>([]);
|
||||
|
||||
// 4. 页面衍生数据。
|
||||
const storeOptions = computed(() =>
|
||||
stores.value.map((item) => ({ label: item.name, value: item.id })),
|
||||
);
|
||||
|
||||
const selectedStoreName = computed(
|
||||
() =>
|
||||
stores.value.find((item) => item.id === selectedStoreId.value)?.name ??
|
||||
'',
|
||||
);
|
||||
|
||||
const copyCandidates = computed(() =>
|
||||
stores.value.filter((item) => item.id !== selectedStoreId.value),
|
||||
);
|
||||
|
||||
const isCopyAllChecked = computed(
|
||||
() =>
|
||||
copyCandidates.value.length > 0 &&
|
||||
copyTargetStoreIds.value.length === copyCandidates.value.length,
|
||||
);
|
||||
|
||||
const isCopyIndeterminate = computed(
|
||||
() =>
|
||||
copyTargetStoreIds.value.length > 0 &&
|
||||
copyTargetStoreIds.value.length < copyCandidates.value.length,
|
||||
);
|
||||
|
||||
const roleOptionMap = computed(() => {
|
||||
const map = new Map<StaffRoleType, (typeof STAFF_ROLE_OPTIONS)[number]>();
|
||||
for (const option of STAFF_ROLE_OPTIONS) {
|
||||
map.set(option.value, option);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const statusOptionMap = computed(() => {
|
||||
const map = new Map<StaffStatus, (typeof STAFF_STATUS_OPTIONS)[number]>();
|
||||
for (const option of STAFF_STATUS_OPTIONS) {
|
||||
map.set(option.value, option);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const shiftOptionMap = computed(() => {
|
||||
const map = new Map<ShiftType, (typeof SHIFT_OPTIONS)[number]>();
|
||||
for (const option of SHIFT_OPTIONS) {
|
||||
map.set(option.value, option);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const staffDrawerTitle = computed(() =>
|
||||
staffDrawerMode.value === 'edit'
|
||||
? `编辑员工${staffForm.name ? ` - ${staffForm.name}` : ''}`
|
||||
: '添加员工',
|
||||
);
|
||||
|
||||
const staffSubmitText = computed(() =>
|
||||
staffDrawerMode.value === 'edit' ? '保存修改' : '确认添加',
|
||||
);
|
||||
|
||||
const personalDrawerTitle = computed(() => {
|
||||
if (!personalScheduleForm.staffName) return '编辑排班';
|
||||
return `编辑排班 - ${personalScheduleForm.staffName}`;
|
||||
});
|
||||
|
||||
const boardRows = computed(() => {
|
||||
const visibleStaffs = staffDirectory.value.filter(
|
||||
(item) => item.status !== 'resigned',
|
||||
);
|
||||
|
||||
return visibleStaffs.map((staff) => ({
|
||||
staffId: staff.id,
|
||||
staffName: staff.name,
|
||||
roleType: staff.roleType,
|
||||
status: staff.status,
|
||||
shifts: cloneShifts(
|
||||
scheduleMap.value[staff.id] ?? createEmptyWeekShifts(templates.value),
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
const shiftLegendItems = computed<ShiftLegendItem[]>(() => {
|
||||
return SHIFT_OPTIONS.filter((item) => item.value !== 'off').map((item) => {
|
||||
const template = templates.value[item.value as Exclude<ShiftType, 'off'>];
|
||||
return {
|
||||
type: item.value as Exclude<ShiftType, 'off'>,
|
||||
label: item.label,
|
||||
className: item.className,
|
||||
timeText: `${template.startTime}-${template.endTime}`,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// 5. 数据动作装配。
|
||||
const {
|
||||
clearStoreData,
|
||||
loadScheduleSettings,
|
||||
loadStaffPage,
|
||||
loadStores,
|
||||
patchScheduleMap,
|
||||
patchStaffSchedule,
|
||||
patchTemplates,
|
||||
reloadStoreData,
|
||||
} = createDataActions({
|
||||
filters,
|
||||
isPageLoading,
|
||||
isStoreLoading,
|
||||
scheduleMap,
|
||||
scheduleSnapshot,
|
||||
selectedStoreId,
|
||||
staffDirectory,
|
||||
staffRows,
|
||||
staffTotal,
|
||||
stores,
|
||||
templates,
|
||||
});
|
||||
|
||||
const {
|
||||
handleDeleteStaff,
|
||||
handleSubmitStaff,
|
||||
openStaffDrawer,
|
||||
setStaffEmail,
|
||||
setStaffName,
|
||||
setStaffPermissions,
|
||||
setStaffPhone,
|
||||
setStaffRoleType,
|
||||
setStaffStatus,
|
||||
} = createStaffActions({
|
||||
deletingStaffId,
|
||||
isStaffSaving,
|
||||
isStaffDrawerOpen,
|
||||
reloadStoreData,
|
||||
selectedStoreId,
|
||||
staffDrawerMode,
|
||||
staffForm,
|
||||
});
|
||||
|
||||
const { handleSaveTemplates, resetTemplates, setTemplateTime } =
|
||||
createTemplateActions({
|
||||
isTemplateSaving,
|
||||
loadScheduleSettings,
|
||||
patchTemplates,
|
||||
scheduleSnapshot,
|
||||
selectedStoreId,
|
||||
templates,
|
||||
});
|
||||
|
||||
const {
|
||||
cycleWeekShift,
|
||||
handleSavePersonalSchedule,
|
||||
handleSaveWeekSchedule,
|
||||
openPersonalScheduleDrawer,
|
||||
openWeekScheduleDrawer,
|
||||
setPersonalShiftEnd,
|
||||
setPersonalShiftStart,
|
||||
setPersonalShiftType,
|
||||
} = createScheduleActions({
|
||||
isPersonalDrawerOpen,
|
||||
isPersonalSaving,
|
||||
isWeekDrawerOpen,
|
||||
isWeekSaving,
|
||||
loadScheduleSettings,
|
||||
patchScheduleMap,
|
||||
patchStaffSchedule,
|
||||
personalForm: personalScheduleForm,
|
||||
scheduleMap,
|
||||
selectedStoreId,
|
||||
staffDirectory,
|
||||
templates,
|
||||
weekRows,
|
||||
});
|
||||
|
||||
const {
|
||||
handleCopyCheckAll,
|
||||
handleCopySubmit,
|
||||
openCopyModal,
|
||||
toggleCopyStore,
|
||||
} = createCopyActions({
|
||||
copyCandidates,
|
||||
copyTargetStoreIds,
|
||||
isCopyModalOpen,
|
||||
isCopySubmitting,
|
||||
selectedStoreId,
|
||||
});
|
||||
|
||||
// 6. 页面交互方法。
|
||||
function setSelectedStoreId(value: string) {
|
||||
selectedStoreId.value = value;
|
||||
}
|
||||
|
||||
function setFilterKeyword(value: string) {
|
||||
filters.keyword = value;
|
||||
}
|
||||
|
||||
function setFilterRoleType(value: '' | StaffRoleType) {
|
||||
filters.roleType = value;
|
||||
}
|
||||
|
||||
function setFilterStatus(value: '' | StaffStatus) {
|
||||
filters.status = value;
|
||||
}
|
||||
|
||||
/** 应用筛选条件后重新查询列表。 */
|
||||
async function applyFilters() {
|
||||
filters.page = 1;
|
||||
await loadStaffPage();
|
||||
}
|
||||
|
||||
/** 重置筛选并重新查询列表。 */
|
||||
async function resetFilters() {
|
||||
filters.keyword = '';
|
||||
filters.roleType = '';
|
||||
filters.status = '';
|
||||
filters.page = 1;
|
||||
await loadStaffPage();
|
||||
}
|
||||
|
||||
/** 切换分页。 */
|
||||
async function handlePageChange(payload: { page: number; pageSize: number }) {
|
||||
filters.page = payload.page;
|
||||
filters.pageSize = payload.pageSize;
|
||||
await loadStaffPage();
|
||||
}
|
||||
|
||||
function setStaffDrawerOpen(value: boolean) {
|
||||
isStaffDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
function setPersonalDrawerOpen(value: boolean) {
|
||||
isPersonalDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
function setWeekDrawerOpen(value: boolean) {
|
||||
isWeekDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
function setCopyModalOpen(value: boolean) {
|
||||
isCopyModalOpen.value = value;
|
||||
}
|
||||
|
||||
/** 判断员工是否可进入排班。 */
|
||||
function isScheduleDisabled(staff: StoreStaffDto) {
|
||||
return staff.status === 'resigned';
|
||||
}
|
||||
|
||||
/** 打开个人排班抽屉。 */
|
||||
function handleOpenPersonalSchedule(staff: StoreStaffDto) {
|
||||
if (isScheduleDisabled(staff)) return;
|
||||
openPersonalScheduleDrawer(staff);
|
||||
}
|
||||
|
||||
/** 获取角色展示文案。 */
|
||||
function getRoleLabel(roleType: StaffRoleType) {
|
||||
return roleOptionMap.value.get(roleType)?.label ?? roleType;
|
||||
}
|
||||
|
||||
/** 获取角色标签样式类。 */
|
||||
function getRoleTagClass(roleType: StaffRoleType) {
|
||||
return roleOptionMap.value.get(roleType)?.tagClassName ?? 'role-cashier';
|
||||
}
|
||||
|
||||
/** 获取状态展示文案。 */
|
||||
function getStatusLabel(status: StaffStatus) {
|
||||
return statusOptionMap.value.get(status)?.label ?? status;
|
||||
}
|
||||
|
||||
/** 获取状态圆点样式类。 */
|
||||
function getStatusDotClass(status: StaffStatus) {
|
||||
return (
|
||||
statusOptionMap.value.get(status)?.dotClassName ?? 'status-dot-active'
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取班次文案。 */
|
||||
function getShiftLabel(shiftType: ShiftType) {
|
||||
return shiftOptionMap.value.get(shiftType)?.label ?? shiftType;
|
||||
}
|
||||
|
||||
/** 获取班次样式类。 */
|
||||
function getShiftClass(shiftType: ShiftType) {
|
||||
return shiftOptionMap.value.get(shiftType)?.className ?? 'shift-off';
|
||||
}
|
||||
|
||||
/** 获取员工某天排班。 */
|
||||
function getStaffShift(row: WeekEditorRow, dayOfWeek: number) {
|
||||
return (
|
||||
row.shifts.find((item) => item.dayOfWeek === dayOfWeek) ??
|
||||
normalizeWeekShifts({
|
||||
shifts: [],
|
||||
fallback: createEmptyWeekShifts(templates.value),
|
||||
templates: templates.value,
|
||||
}).find((item) => item.dayOfWeek === dayOfWeek) ?? {
|
||||
dayOfWeek,
|
||||
shiftType: 'off',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 7. 监听门店切换。
|
||||
watch(selectedStoreId, async (storeId) => {
|
||||
if (!storeId) {
|
||||
clearStoreData();
|
||||
return;
|
||||
}
|
||||
|
||||
filters.page = 1;
|
||||
await reloadStoreData();
|
||||
});
|
||||
|
||||
// 8. 页面首屏加载。
|
||||
onMounted(loadStores);
|
||||
|
||||
return {
|
||||
DAY_OPTIONS,
|
||||
PAGE_SIZE_OPTIONS,
|
||||
SHIFT_OPTIONS,
|
||||
STAFF_PERMISSION_OPTIONS,
|
||||
STAFF_ROLE_OPTIONS,
|
||||
STAFF_STATUS_OPTIONS,
|
||||
applyFilters,
|
||||
boardRows,
|
||||
copyCandidates,
|
||||
copyTargetStoreIds,
|
||||
deletingStaffId,
|
||||
filters,
|
||||
getCurrentWeekStartDate,
|
||||
getRoleLabel,
|
||||
getRoleTagClass,
|
||||
getShiftClass,
|
||||
getShiftLabel,
|
||||
getStaffShift,
|
||||
getStatusDotClass,
|
||||
getStatusLabel,
|
||||
handleCopyCheckAll,
|
||||
handleCopySubmit,
|
||||
handleDeleteStaff,
|
||||
handleOpenPersonalSchedule,
|
||||
handlePageChange,
|
||||
handleSavePersonalSchedule,
|
||||
handleSaveTemplates,
|
||||
handleSaveWeekSchedule,
|
||||
handleSubmitStaff,
|
||||
isCopyAllChecked,
|
||||
isCopyIndeterminate,
|
||||
isCopyModalOpen,
|
||||
isCopySubmitting,
|
||||
isPageLoading,
|
||||
isPersonalDrawerOpen,
|
||||
isPersonalSaving,
|
||||
isScheduleDisabled,
|
||||
isStaffDrawerOpen,
|
||||
isStaffSaving,
|
||||
isStoreLoading,
|
||||
isTemplateSaving,
|
||||
isWeekDrawerOpen,
|
||||
isWeekSaving,
|
||||
maskPhone,
|
||||
openCopyModal,
|
||||
openStaffDrawer,
|
||||
openWeekScheduleDrawer,
|
||||
personalDrawerTitle,
|
||||
personalScheduleForm,
|
||||
resetFilters,
|
||||
resetTemplates,
|
||||
resolveAvatarText,
|
||||
selectedStoreId,
|
||||
selectedStoreName,
|
||||
setCopyModalOpen,
|
||||
setFilterKeyword,
|
||||
setFilterRoleType,
|
||||
setFilterStatus,
|
||||
setPersonalDrawerOpen,
|
||||
setPersonalShiftEnd,
|
||||
setPersonalShiftStart,
|
||||
setPersonalShiftType,
|
||||
setSelectedStoreId,
|
||||
setStaffDrawerOpen,
|
||||
setStaffEmail,
|
||||
setStaffName,
|
||||
setStaffPermissions,
|
||||
setStaffPhone,
|
||||
setStaffRoleType,
|
||||
setStaffStatus,
|
||||
setTemplateTime,
|
||||
setWeekDrawerOpen,
|
||||
shiftLegendItems,
|
||||
staffDrawerTitle,
|
||||
staffDrawerMode,
|
||||
staffForm,
|
||||
staffRows,
|
||||
staffSubmitText,
|
||||
staffTotal,
|
||||
storeOptions,
|
||||
templates,
|
||||
toggleCopyStore,
|
||||
weekRows,
|
||||
cycleWeekShift,
|
||||
formatShiftTimeText,
|
||||
};
|
||||
}
|
||||
255
apps/web-antd/src/views/store/staff/index.vue
Normal file
255
apps/web-antd/src/views/store/staff/index.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:员工排班页面主视图。
|
||||
* 1. 组装员工列表、班次模板、周排班看板。
|
||||
* 2. 挂载员工抽屉、个人排班抽屉、周排班抽屉与复制弹窗。
|
||||
*/
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Card, Empty, Spin } from 'ant-design-vue';
|
||||
|
||||
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
|
||||
import ShiftTemplateCard from './components/ShiftTemplateCard.vue';
|
||||
import StaffEditorDrawer from './components/StaffEditorDrawer.vue';
|
||||
import StaffFilterBar from './components/StaffFilterBar.vue';
|
||||
import StaffScheduleDrawer from './components/StaffScheduleDrawer.vue';
|
||||
import StaffTableSection from './components/StaffTableSection.vue';
|
||||
import WeeklyScheduleBoard from './components/WeeklyScheduleBoard.vue';
|
||||
import WeekScheduleDrawer from './components/WeekScheduleDrawer.vue';
|
||||
import { useStoreStaffPage } from './composables/useStoreStaffPage';
|
||||
|
||||
const {
|
||||
DAY_OPTIONS,
|
||||
PAGE_SIZE_OPTIONS,
|
||||
SHIFT_OPTIONS,
|
||||
STAFF_PERMISSION_OPTIONS,
|
||||
STAFF_ROLE_OPTIONS,
|
||||
STAFF_STATUS_OPTIONS,
|
||||
applyFilters,
|
||||
boardRows,
|
||||
copyCandidates,
|
||||
copyTargetStoreIds,
|
||||
cycleWeekShift,
|
||||
deletingStaffId,
|
||||
filters,
|
||||
formatShiftTimeText,
|
||||
getRoleLabel,
|
||||
getRoleTagClass,
|
||||
getShiftClass,
|
||||
getShiftLabel,
|
||||
getStatusDotClass,
|
||||
getStatusLabel,
|
||||
handleCopyCheckAll,
|
||||
handleCopySubmit,
|
||||
handleDeleteStaff,
|
||||
handleOpenPersonalSchedule,
|
||||
handlePageChange,
|
||||
handleSavePersonalSchedule,
|
||||
handleSaveTemplates,
|
||||
handleSaveWeekSchedule,
|
||||
handleSubmitStaff,
|
||||
isCopyAllChecked,
|
||||
isCopyIndeterminate,
|
||||
isCopyModalOpen,
|
||||
isCopySubmitting,
|
||||
isPageLoading,
|
||||
isPersonalDrawerOpen,
|
||||
isPersonalSaving,
|
||||
isScheduleDisabled,
|
||||
isStaffDrawerOpen,
|
||||
isStaffSaving,
|
||||
isStoreLoading,
|
||||
isTemplateSaving,
|
||||
isWeekDrawerOpen,
|
||||
isWeekSaving,
|
||||
maskPhone,
|
||||
openCopyModal,
|
||||
openStaffDrawer,
|
||||
openWeekScheduleDrawer,
|
||||
personalDrawerTitle,
|
||||
personalScheduleForm,
|
||||
resetFilters,
|
||||
resetTemplates,
|
||||
selectedStoreId,
|
||||
selectedStoreName,
|
||||
setCopyModalOpen,
|
||||
setFilterKeyword,
|
||||
setFilterRoleType,
|
||||
setFilterStatus,
|
||||
setPersonalDrawerOpen,
|
||||
setPersonalShiftEnd,
|
||||
setPersonalShiftStart,
|
||||
setPersonalShiftType,
|
||||
setSelectedStoreId,
|
||||
setStaffDrawerOpen,
|
||||
setStaffEmail,
|
||||
setStaffName,
|
||||
setStaffPermissions,
|
||||
setStaffPhone,
|
||||
setStaffRoleType,
|
||||
setStaffStatus,
|
||||
setTemplateTime,
|
||||
setWeekDrawerOpen,
|
||||
shiftLegendItems,
|
||||
staffDrawerTitle,
|
||||
staffForm,
|
||||
staffRows,
|
||||
staffSubmitText,
|
||||
staffTotal,
|
||||
storeOptions,
|
||||
templates,
|
||||
toggleCopyStore,
|
||||
weekRows,
|
||||
} = useStoreStaffPage();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="员工排班" content-class="space-y-4 page-store-staff">
|
||||
<StoreScopeToolbar
|
||||
:selected-store-id="selectedStoreId"
|
||||
:store-options="storeOptions"
|
||||
:is-store-loading="isStoreLoading"
|
||||
:copy-disabled="!selectedStoreId || copyCandidates.length === 0"
|
||||
copy-button-text="复制员工排班到其他门店"
|
||||
@update:selected-store-id="setSelectedStoreId"
|
||||
@copy="openCopyModal"
|
||||
/>
|
||||
|
||||
<template v-if="storeOptions.length === 0">
|
||||
<Card :bordered="false">
|
||||
<Empty description="暂无门店,请先创建门店" />
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Spin :spinning="isPageLoading">
|
||||
<StaffTableSection
|
||||
:rows="staffRows"
|
||||
:total="staffTotal"
|
||||
:page="filters.page"
|
||||
:page-size="filters.pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
:is-loading="isPageLoading"
|
||||
:deleting-staff-id="deletingStaffId"
|
||||
:format-role-label="getRoleLabel"
|
||||
:format-status-label="getStatusLabel"
|
||||
:get-role-tag-class="getRoleTagClass"
|
||||
:get-status-dot-class="getStatusDotClass"
|
||||
:mask-phone="maskPhone"
|
||||
:is-schedule-disabled="isScheduleDisabled"
|
||||
@add="openStaffDrawer('create')"
|
||||
@edit="(staff) => openStaffDrawer('edit', staff)"
|
||||
@schedule="handleOpenPersonalSchedule"
|
||||
@delete="handleDeleteStaff"
|
||||
@page-change="handlePageChange"
|
||||
>
|
||||
<template #filters>
|
||||
<StaffFilterBar
|
||||
:keyword="filters.keyword"
|
||||
:role-type="filters.roleType"
|
||||
:status="filters.status"
|
||||
:is-loading="isPageLoading"
|
||||
:role-options="STAFF_ROLE_OPTIONS"
|
||||
:status-options="STAFF_STATUS_OPTIONS"
|
||||
:on-set-keyword="setFilterKeyword"
|
||||
:on-set-role-type="setFilterRoleType"
|
||||
:on-set-status="setFilterStatus"
|
||||
@search="applyFilters"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
</template>
|
||||
</StaffTableSection>
|
||||
|
||||
<ShiftTemplateCard
|
||||
:templates="templates"
|
||||
:is-saving="isTemplateSaving"
|
||||
:on-set-template-time="setTemplateTime"
|
||||
@save="handleSaveTemplates"
|
||||
@reset="resetTemplates"
|
||||
/>
|
||||
|
||||
<WeeklyScheduleBoard
|
||||
:rows="boardRows"
|
||||
:day-options="DAY_OPTIONS"
|
||||
:get-shift-class="getShiftClass"
|
||||
:get-shift-label="getShiftLabel"
|
||||
:get-shift-time-text="formatShiftTimeText"
|
||||
:role-label-resolver="getRoleLabel"
|
||||
@edit-week="openWeekScheduleDrawer"
|
||||
/>
|
||||
</Spin>
|
||||
</template>
|
||||
|
||||
<StaffEditorDrawer
|
||||
:open="isStaffDrawerOpen"
|
||||
:title="staffDrawerTitle"
|
||||
:submit-text="staffSubmitText"
|
||||
:form="staffForm"
|
||||
:is-saving="isStaffSaving"
|
||||
:role-options="STAFF_ROLE_OPTIONS"
|
||||
:status-options="STAFF_STATUS_OPTIONS"
|
||||
:permission-options="STAFF_PERMISSION_OPTIONS"
|
||||
:on-set-name="setStaffName"
|
||||
:on-set-phone="setStaffPhone"
|
||||
:on-set-email="setStaffEmail"
|
||||
:on-set-role-type="setStaffRoleType"
|
||||
:on-set-status="setStaffStatus"
|
||||
:on-set-permissions="setStaffPermissions"
|
||||
@update:open="setStaffDrawerOpen"
|
||||
@submit="handleSubmitStaff"
|
||||
/>
|
||||
|
||||
<StaffScheduleDrawer
|
||||
:open="isPersonalDrawerOpen"
|
||||
:title="personalDrawerTitle"
|
||||
:is-saving="isPersonalSaving"
|
||||
:form="personalScheduleForm"
|
||||
:day-options="DAY_OPTIONS"
|
||||
:shift-options="SHIFT_OPTIONS"
|
||||
:on-set-shift-type="setPersonalShiftType"
|
||||
:on-set-shift-start="setPersonalShiftStart"
|
||||
:on-set-shift-end="setPersonalShiftEnd"
|
||||
@update:open="setPersonalDrawerOpen"
|
||||
@submit="handleSavePersonalSchedule"
|
||||
/>
|
||||
|
||||
<WeekScheduleDrawer
|
||||
:open="isWeekDrawerOpen"
|
||||
:rows="weekRows"
|
||||
:is-saving="isWeekSaving"
|
||||
:day-options="DAY_OPTIONS"
|
||||
:legend-items="shiftLegendItems"
|
||||
:get-shift-class="getShiftClass"
|
||||
:get-shift-label="getShiftLabel"
|
||||
:get-shift-time-text="formatShiftTimeText"
|
||||
:role-label-resolver="getRoleLabel"
|
||||
@update:open="setWeekDrawerOpen"
|
||||
@cycle-shift="cycleWeekShift"
|
||||
@submit="handleSaveWeekSchedule"
|
||||
/>
|
||||
|
||||
<CopyToStoresModal
|
||||
:open="isCopyModalOpen"
|
||||
title="复制员工排班到其他门店"
|
||||
confirm-text="确认复制"
|
||||
warning-text="仅复制班次模板与排班安排,不会复制员工档案"
|
||||
:copy-candidates="copyCandidates"
|
||||
:target-store-ids="copyTargetStoreIds"
|
||||
:is-all-checked="isCopyAllChecked"
|
||||
:is-indeterminate="isCopyIndeterminate"
|
||||
:is-submitting="isCopySubmitting"
|
||||
:selected-store-name="selectedStoreName"
|
||||
@update:open="setCopyModalOpen"
|
||||
@check-all="handleCopyCheckAll"
|
||||
@submit="handleCopySubmit"
|
||||
@toggle-store="
|
||||
({ storeId, checked }) => toggleCopyStore(storeId, checked)
|
||||
"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
@import './styles/index.less';
|
||||
</style>
|
||||
94
apps/web-antd/src/views/store/staff/styles/base.less
Normal file
94
apps/web-antd/src/views/store/staff/styles/base.less
Normal file
@@ -0,0 +1,94 @@
|
||||
/* 文件职责:员工排班页面基础骨架与通用样式。 */
|
||||
.page-store-staff {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
|
||||
.staff-card {
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
|
||||
|
||||
.ant-card-head {
|
||||
min-height: 52px;
|
||||
padding: 0 18px;
|
||||
background: #f8f9fb;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.ant-card-head-title {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.ant-card-extra {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.staff-card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.staff-drawer-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.staff-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.staff-filter-input {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.staff-filter-select {
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
.staff-empty {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.shift-morning {
|
||||
color: #096dd9;
|
||||
background: #e6f7ff;
|
||||
border-color: #91d5ff;
|
||||
}
|
||||
|
||||
.shift-evening {
|
||||
color: #d46b08;
|
||||
background: #fff7e6;
|
||||
border-color: #ffd591;
|
||||
}
|
||||
|
||||
.shift-full {
|
||||
color: #389e0d;
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.shift-off {
|
||||
color: #8c8c8c;
|
||||
background: #f5f5f5;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
}
|
||||
300
apps/web-antd/src/views/store/staff/styles/drawer.less
Normal file
300
apps/web-antd/src/views/store/staff/styles/drawer.less
Normal file
@@ -0,0 +1,300 @@
|
||||
/* 文件职责:员工排班抽屉与表单样式。 */
|
||||
.staff-editor-drawer {
|
||||
.ant-drawer-header {
|
||||
min-height: 56px;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.ant-drawer-footer {
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.staff-drawer-form {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.g-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.g-form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.g-form-label.required::before {
|
||||
margin-right: 3px;
|
||||
color: #ff4d4f;
|
||||
content: '*';
|
||||
}
|
||||
|
||||
.g-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.g-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
font-size: 13px;
|
||||
color: #1a1a2e;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.g-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 3px rgb(22 119 255 / 10%);
|
||||
}
|
||||
|
||||
.g-input::placeholder {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.staff-form-select {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.staff-form-select .ant-select-selector {
|
||||
height: 34px !important;
|
||||
padding: 0 11px !important;
|
||||
background: #fff !important;
|
||||
border: 1px solid #d9d9d9 !important;
|
||||
border-radius: 6px !important;
|
||||
box-shadow: none !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.staff-form-select .ant-select-selection-item,
|
||||
.staff-form-select .ant-select-selection-placeholder {
|
||||
line-height: 32px !important;
|
||||
}
|
||||
|
||||
.staff-form-select .ant-select-selection-item {
|
||||
font-size: 13px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.staff-form-select .ant-select-selection-placeholder {
|
||||
font-size: 13px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.staff-form-select .ant-select-arrow {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.staff-form-select:not(.ant-select-disabled):hover .ant-select-selector,
|
||||
.staff-form-select.ant-select-focused .ant-select-selector {
|
||||
border-color: #1677ff !important;
|
||||
}
|
||||
|
||||
.staff-form-select.ant-select-focused .ant-select-selector {
|
||||
box-shadow: 0 0 0 3px rgb(22 119 255 / 10%) !important;
|
||||
}
|
||||
|
||||
.g-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.staff-permission-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.staff-permission-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: #1a1a2e;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.staff-permission-item:hover {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.staff-permission-item input[type='checkbox'] {
|
||||
margin: 0;
|
||||
accent-color: #1677ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.staff-permission-item-all {
|
||||
grid-column: 1 / -1;
|
||||
font-weight: 600;
|
||||
background: #f0f5ff;
|
||||
border-color: #d6e4ff;
|
||||
}
|
||||
}
|
||||
|
||||
.staff-editor-drawer .staff-drawer-footer,
|
||||
.staff-schedule-drawer .staff-drawer-footer,
|
||||
.staff-week-drawer .staff-drawer-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.staff-editor-drawer .staff-drawer-footer .ant-btn,
|
||||
.staff-schedule-drawer .staff-drawer-footer .ant-btn,
|
||||
.staff-week-drawer .staff-drawer-footer .ant-btn {
|
||||
min-width: 96px;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.staff-schedule-drawer,
|
||||
.staff-week-drawer {
|
||||
.ant-drawer-header {
|
||||
padding: 18px 20px 14px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.ant-drawer-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.staff-schedule-drawer {
|
||||
.staff-schedule-hint {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #1d39c4;
|
||||
background: #f0f5ff;
|
||||
border: 1px solid #adc6ff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.staff-schedule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.staff-schedule-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.staff-schedule-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.staff-schedule-day {
|
||||
width: 44px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.staff-schedule-shifts {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.staff-shift-pill {
|
||||
min-width: 46px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #667085;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.staff-shift-pill:hover {
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.staff-shift-pill.active.shift-morning {
|
||||
color: #096dd9;
|
||||
background: #e6f7ff;
|
||||
border-color: #91d5ff;
|
||||
}
|
||||
|
||||
.staff-shift-pill.active.shift-evening {
|
||||
color: #d46b08;
|
||||
background: #fff7e6;
|
||||
border-color: #ffd591;
|
||||
}
|
||||
|
||||
.staff-shift-pill.active.shift-full {
|
||||
color: #389e0d;
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.staff-shift-pill.active.shift-off {
|
||||
color: #8c8c8c;
|
||||
background: #f5f5f5;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.staff-schedule-time {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.staff-schedule-time.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.staff-schedule-separator {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
7
apps/web-antd/src/views/store/staff/styles/index.less
Normal file
7
apps/web-antd/src/views/store/staff/styles/index.less
Normal file
@@ -0,0 +1,7 @@
|
||||
/* 文件职责:员工排班页面样式聚合入口(仅负责分片导入)。 */
|
||||
@import './base.less';
|
||||
@import './list.less';
|
||||
@import './template.less';
|
||||
@import './schedule.less';
|
||||
@import './drawer.less';
|
||||
@import './responsive.less';
|
||||
196
apps/web-antd/src/views/store/staff/styles/list.less
Normal file
196
apps/web-antd/src/views/store/staff/styles/list.less
Normal file
@@ -0,0 +1,196 @@
|
||||
/* 文件职责:员工列表区样式。 */
|
||||
.page-store-staff {
|
||||
.staff-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #f0f2f5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.staff-table {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #667085;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.staff-op-column {
|
||||
width: 166px;
|
||||
}
|
||||
|
||||
.staff-row-resigned {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.staff-info {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.staff-avatar {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.staff-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.staff-name {
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.staff-phone {
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.staff-role-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.role-manager {
|
||||
color: #c41d7f;
|
||||
background: #fff0f6;
|
||||
border-color: #ffadd2;
|
||||
}
|
||||
|
||||
.role-cashier {
|
||||
color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.role-courier {
|
||||
color: #389e0d;
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.role-chef {
|
||||
color: #d46b08;
|
||||
background: #fff7e6;
|
||||
border-color: #ffd591;
|
||||
}
|
||||
|
||||
.staff-status {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.staff-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot-active {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.status-dot-leave {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.status-dot-resigned {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
.staff-permissions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.staff-permission-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #2f54eb;
|
||||
background: #f0f5ff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.staff-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.staff-link {
|
||||
font-size: 13px;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.staff-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.staff-link-disabled {
|
||||
color: #bfbfbf;
|
||||
text-decoration: none !important;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.staff-delete-btn {
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.staff-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
47
apps/web-antd/src/views/store/staff/styles/responsive.less
Normal file
47
apps/web-antd/src/views/store/staff/styles/responsive.less
Normal file
@@ -0,0 +1,47 @@
|
||||
/* 文件职责:员工排班页面响应式样式。 */
|
||||
@media (max-width: 992px) {
|
||||
.page-store-staff {
|
||||
.staff-filter-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.staff-filter-select {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.staff-form-grid,
|
||||
.staff-permission-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.staff-schedule-row {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.staff-schedule-time {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-store-staff {
|
||||
.staff-filter-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.staff-card {
|
||||
.ant-card-head,
|
||||
.ant-card-body {
|
||||
padding-right: 14px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.staff-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
135
apps/web-antd/src/views/store/staff/styles/schedule.less
Normal file
135
apps/web-antd/src/views/store/staff/styles/schedule.less
Normal file
@@ -0,0 +1,135 @@
|
||||
/* 文件职责:排班看板与周排班抽屉样式。 */
|
||||
.page-store-staff {
|
||||
.schedule-empty,
|
||||
.staff-week-empty {
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.schedule-board-wrap,
|
||||
.staff-week-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #f0f2f5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.schedule-board-table,
|
||||
.staff-week-table {
|
||||
width: 100%;
|
||||
min-width: 900px;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #667085;
|
||||
background: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-staff-cell,
|
||||
.staff-week-name-cell {
|
||||
min-width: 96px;
|
||||
text-align: left !important;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.schedule-staff-name,
|
||||
.staff-week-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.schedule-staff-role,
|
||||
.staff-week-role {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.schedule-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 4px 5px;
|
||||
border: 1px solid;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.schedule-cell-time {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.schedule-cell-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.staff-week-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.staff-week-legend-item {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.staff-week-legend-dot {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.staff-week-cell-wrap {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.staff-week-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
border-radius: 8px;
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.staff-week-cell:hover {
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.staff-week-cell-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.staff-week-cell-time {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
65
apps/web-antd/src/views/store/staff/styles/template.less
Normal file
65
apps/web-antd/src/views/store/staff/styles/template.less
Normal file
@@ -0,0 +1,65 @@
|
||||
/* 文件职责:班次模板区样式。 */
|
||||
.page-store-staff {
|
||||
.template-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.template-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.template-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.template-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.template-dot-morning {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.template-dot-evening {
|
||||
background: #fa8c16;
|
||||
}
|
||||
|
||||
.template-dot-full {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.template-label {
|
||||
width: 48px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.template-time-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.template-time-separator {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.template-tip {
|
||||
padding: 10px 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f2f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
88
apps/web-antd/src/views/store/staff/types.ts
Normal file
88
apps/web-antd/src/views/store/staff/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 文件职责:员工排班页面类型定义。
|
||||
* 1. 声明筛选、抽屉、周排班编辑等页面状态类型。
|
||||
* 2. 约束组件之间的数据结构与事件载荷。
|
||||
*/
|
||||
import type {
|
||||
ShiftType,
|
||||
StaffDayShiftDto,
|
||||
StaffRoleType,
|
||||
StaffStatus,
|
||||
StoreShiftTemplatesDto,
|
||||
} from '#/api/store-staff';
|
||||
|
||||
export type StaffDrawerMode = 'create' | 'edit';
|
||||
|
||||
export interface StaffFilterState {
|
||||
keyword: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
roleType: '' | StaffRoleType;
|
||||
status: '' | StaffStatus;
|
||||
}
|
||||
|
||||
export interface StaffEditorFormState {
|
||||
email: string;
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
phone: string;
|
||||
roleType: StaffRoleType;
|
||||
status: StaffStatus;
|
||||
}
|
||||
|
||||
export interface StaffPersonalScheduleFormState {
|
||||
roleType: StaffRoleType;
|
||||
shifts: StaffDayShiftDto[];
|
||||
staffId: string;
|
||||
staffName: string;
|
||||
}
|
||||
|
||||
export interface WeekEditorRow {
|
||||
roleType: StaffRoleType;
|
||||
shifts: StaffDayShiftDto[];
|
||||
staffId: string;
|
||||
staffName: string;
|
||||
status: StaffStatus;
|
||||
}
|
||||
|
||||
export interface ShiftOption {
|
||||
className: string;
|
||||
label: string;
|
||||
value: ShiftType;
|
||||
}
|
||||
|
||||
export interface DayOption {
|
||||
dayOfWeek: number;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
}
|
||||
|
||||
export interface StaffRoleOption {
|
||||
label: string;
|
||||
tagClassName: string;
|
||||
value: StaffRoleType;
|
||||
}
|
||||
|
||||
export interface StaffStatusOption {
|
||||
dotClassName: string;
|
||||
label: string;
|
||||
value: StaffStatus;
|
||||
}
|
||||
|
||||
export interface StaffPermissionOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ShiftLegendItem {
|
||||
className: string;
|
||||
label: string;
|
||||
timeText: string;
|
||||
type: Exclude<ShiftType, 'off'>;
|
||||
}
|
||||
|
||||
export interface StaffScheduleSnapshot {
|
||||
scheduleMap: Record<string, StaffDayShiftDto[]>;
|
||||
templates: StoreShiftTemplatesDto;
|
||||
}
|
||||
Reference in New Issue
Block a user