經過 Day 13 的企業級包管理和多租戶架構建立,我們的開發工作流程已經滿完善了。今天我們要深入實作課程管理系統的前端介面,這不只是簡單的 CRUD 操作,而是要建立一個視覺化的排課工具,讓健身房管理者能夠直觀地管理課程、教練和時間安排。
今天我們將設計:
在開始實作前,我們需要深入了解健身房排課的複雜性:
// 課程排課的複雜業務需求
interface CourseSchedulingComplexity {
// 時間維度的挑戰
timeComplexity: {
multiTimeZone: boolean; // 多時區支援
recurringPatterns: string[]; // 週期性課程模式
holidayHandling: boolean; // 假期處理
seasonalAdjustment: boolean; // 季節性調整
};
// 資源衝突管理
resourceConflicts: {
trainerAvailability: TrainerSchedule[];
roomCapacity: RoomConstraint[];
equipmentRequirement: EquipmentNeed[];
memberPreferences: MemberPreference[];
};
// 視覺化要求
visualRequirements: {
multiViewSupport: ['day', 'week', 'month', 'agenda'];
colorCoding: 'category' | 'trainer' | 'status' | 'popularity';
dragAndDrop: boolean;
realTimeUpdates: boolean;
conflictHighlight: boolean;
};
// 互動複雜度
interactionComplexity: {
batchOperations: boolean; // 批次操作
templateSupport: boolean; // 範本支援
undoRedoStack: boolean; // 撤銷/重做
collaborativeEditing: boolean; // 多人協作
};
}
我們使用組合式設計模式,將複雜的排課系統拆解為可重用的組件:
// apps/kyo-dashboard/src/components/CourseScheduler/types.ts
export interface ScheduleViewProps {
view: 'day' | 'week' | 'month' | 'agenda';
selectedDate: Date;
courses: Course[];
trainers: Trainer[];
rooms: Room[];
onCourseCreate: (courseData: CreateCourseRequest) => void;
onCourseUpdate: (courseId: string, updates: Partial<Course>) => void;
onCourseDelete: (courseId: string) => void;
onTimeSlotClick: (timeSlot: TimeSlot) => void;
onCourseMove: (courseId: string, newTimeSlot: TimeSlot) => void;
}
export interface TimeSlot {
start: Date;
end: Date;
roomId?: string;
trainerId?: string;
}
export interface CourseConflict {
type: 'trainer_conflict' | 'room_conflict' | 'capacity_overflow';
courseIds: string[];
severity: 'warning' | 'error';
suggestion?: string;
}
讓我們深入實作最複雜的週視圖組件:
// apps/kyo-dashboard/src/components/CourseScheduler/WeekView.tsx
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import {
Box,
Grid,
Text,
Paper,
Group,
ActionIcon,
Tooltip,
Modal,
Alert,
Indicator
} from '@mantine/core';
import { useHotkeys, useDisclosure } from '@mantine/hooks';
import { DndProvider, useDrop, useDrag } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import {
IconChevronLeft,
IconChevronRight,
IconPlus,
IconAlertTriangle,
IconUsers,
IconClock
} from '@tabler/icons-react';
import { format, addDays, startOfWeek, isSameDay, parseISO } from 'date-fns';
import { zhTW } from 'date-fns/locale';
interface WeekViewProps extends ScheduleViewProps {
conflicts: CourseConflict[];
isLoading: boolean;
}
export const WeekView: React.FC<WeekViewProps> = ({
selectedDate,
courses,
trainers,
rooms,
conflicts,
isLoading,
onCourseCreate,
onCourseUpdate,
onCourseMove,
onTimeSlotClick,
}) => {
// 狀態管理
const [draggedCourse, setDraggedCourse] = useState<Course | null>(null);
const [hoveredTimeSlot, setHoveredTimeSlot] = useState<TimeSlot | null>(null);
const [selectedTimeSlot, setSelectedTimeSlot] = useState<TimeSlot | null>(null);
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure();
// 快捷鍵支援
useHotkeys([
['mod+N', () => openCreateModal()],
['Escape', () => setSelectedTimeSlot(null)],
['ArrowLeft', () => onWeekChange(-1)],
['ArrowRight', () => onWeekChange(1)],
]);
// 週資料計算
const weekData = useMemo(() => {
const weekStart = startOfWeek(selectedDate, { weekStartsOn: 1 }); // 週一開始
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
return {
weekStart,
days,
weekLabel: `${format(weekStart, 'M月d日', { locale: zhTW })} - ${format(addDays(weekStart, 6), 'M月d日', { locale: zhTW })}`
};
}, [selectedDate]);
// 時間網格配置
const timeConfig = useMemo(() => {
const startHour = 6; // 早上6點開始
const endHour = 23; // 晚上11點結束
const slotDuration = 30; // 30分鐘一格
const timeSlots = [];
for (let hour = startHour; hour <= endHour; hour++) {
for (let minute = 0; minute < 60; minute += slotDuration) {
timeSlots.push({
hour,
minute,
label: `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`,
value: hour * 60 + minute, // 轉換為分鐘值,方便計算
});
}
}
return { startHour, endHour, slotDuration, timeSlots };
}, []);
// 課程定位計算
const positionedCourses = useMemo(() => {
return courses.map(course => {
const startTime = parseISO(course.startTime);
const endTime = parseISO(course.endTime);
// 計算在網格中的位置
const dayIndex = weekData.days.findIndex(day => isSameDay(day, startTime));
const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60); // 持續時間(分鐘)
// 計算網格位置
const gridRowStart = Math.floor((startMinutes - timeConfig.startHour * 60) / timeConfig.slotDuration) + 2; // +2 因為第一行是標題
const gridRowSpan = Math.ceil(duration / timeConfig.slotDuration);
// 檢查衝突
const courseConflicts = conflicts.filter(conflict =>
conflict.courseIds.includes(course.id)
);
return {
...course,
dayIndex,
gridRowStart,
gridRowSpan,
conflicts: courseConflicts,
isConflicted: courseConflicts.length > 0,
};
}).filter(course => course.dayIndex !== -1); // 過濾掉不在當週的課程
}, [courses, weekData.days, timeConfig, conflicts]);
// 週變更處理
const onWeekChange = useCallback((direction: number) => {
const newDate = addDays(selectedDate, direction * 7);
onDateChange?.(newDate);
}, [selectedDate]);
// 時間槽點擊處理
const handleTimeSlotClick = useCallback((day: Date, timeSlot: any) => {
const clickedTimeSlot: TimeSlot = {
start: new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
timeSlot.hour,
timeSlot.minute
),
end: new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
timeSlot.hour,
timeSlot.minute + timeConfig.slotDuration
),
};
setSelectedTimeSlot(clickedTimeSlot);
onTimeSlotClick(clickedTimeSlot);
}, [timeConfig.slotDuration, onTimeSlotClick]);
// 拖拽邏輯
const handleCourseDrop = useCallback((courseId: string, targetTimeSlot: TimeSlot) => {
onCourseMove(courseId, targetTimeSlot);
setDraggedCourse(null);
}, [onCourseMove]);
return (
<DndProvider backend={HTML5Backend}>
<Box>
{/* 週導航標題 */}
<Group position="apart" mb="md">
<Group>
<ActionIcon
variant="subtle"
onClick={() => onWeekChange(-1)}
aria-label="上一週"
>
<IconChevronLeft size={16} />
</ActionIcon>
<Text size="lg" weight={600}>
{weekData.weekLabel}
</Text>
<ActionIcon
variant="subtle"
onClick={() => onWeekChange(1)}
aria-label="下一週"
>
<IconChevronRight size={16} />
</ActionIcon>
</Group>
<Group>
<Tooltip label="快捷鍵: Ctrl+N">
<ActionIcon
variant="filled"
color="blue"
onClick={openCreateModal}
aria-label="新增課程"
>
<IconPlus size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{/* 衝突警告 */}
{conflicts.length > 0 && (
<Alert
icon={<IconAlertTriangle size={16} />}
color="yellow"
mb="md"
title={`發現 ${conflicts.length} 個排課衝突`}
>
請檢查教練時間衝突或教室重複預訂問題
</Alert>
)}
{/* 週課表網格 */}
<Paper p="md" withBorder style={{ overflowX: 'auto' }}>
<Box
style={{
display: 'grid',
gridTemplateColumns: '80px repeat(7, 1fr)',
gridTemplateRows: `40px repeat(${timeConfig.timeSlots.length}, 60px)`,
gap: '1px',
backgroundColor: '#f1f3f4',
minWidth: '800px',
}}
>
{/* 標題行 */}
<Box /> {/* 空的左上角 */}
{weekData.days.map((day, index) => (
<DayHeader
key={index}
day={day}
isToday={isSameDay(day, new Date())}
coursesCount={positionedCourses.filter(c => c.dayIndex === index).length}
/>
))}
{/* 時間軸和課程網格 */}
{timeConfig.timeSlots.map((timeSlot, timeIndex) => (
<React.Fragment key={timeSlot.value}>
{/* 時間標籤 */}
<TimeLabel timeSlot={timeSlot} />
{/* 每一天的時間槽 */}
{weekData.days.map((day, dayIndex) => (
<TimeSlotCell
key={`${dayIndex}-${timeIndex}`}
day={day}
timeSlot={timeSlot}
isSelected={selectedTimeSlot &&
isSameDay(selectedTimeSlot.start, day) &&
selectedTimeSlot.start.getHours() === timeSlot.hour &&
selectedTimeSlot.start.getMinutes() === timeSlot.minute
}
onClick={() => handleTimeSlotClick(day, timeSlot)}
onCourseMove={handleCourseDrop}
/>
))}
</React.Fragment>
))}
{/* 渲染課程 */}
{positionedCourses.map(course => (
<CourseBlock
key={course.id}
course={course}
onMove={handleCourseDrop}
onEdit={(courseId) => onCourseUpdate(courseId, {})}
style={{
gridColumn: course.dayIndex + 2, // +2 因為第一列是時間軸
gridRow: `${course.gridRowStart} / span ${course.gridRowSpan}`,
}}
/>
))}
</Box>
</Paper>
{/* 新增課程 Modal */}
<CourseCreateModal
opened={createModalOpened}
onClose={closeCreateModal}
initialTimeSlot={selectedTimeSlot}
trainers={trainers}
rooms={rooms}
onSubmit={onCourseCreate}
/>
</Box>
</DndProvider>
);
};
// 子組件:日期標題
const DayHeader: React.FC<{
day: Date;
isToday: boolean;
coursesCount: number;
}> = ({ day, isToday, coursesCount }) => (
<Box
p="xs"
style={{
backgroundColor: isToday ? '#e3f2fd' : 'white',
textAlign: 'center',
borderRadius: '4px',
border: isToday ? '2px solid #2196f3' : '1px solid #e0e0e0',
}}
>
<Text size="sm" weight={500} color={isToday ? 'blue' : 'dark'}>
{format(day, 'EEE', { locale: zhTW })}
</Text>
<Text size="lg" weight={600} color={isToday ? 'blue' : 'dark'}>
{format(day, 'd')}
</Text>
{coursesCount > 0 && (
<Indicator size={16} color="blue" position="top-end">
<Text size="xs" color="dimmed">
{coursesCount} 堂課
</Text>
</Indicator>
)}
</Box>
);
// 子組件:時間標籤
const TimeLabel: React.FC<{ timeSlot: any }> = ({ timeSlot }) => (
<Box
p="xs"
style={{
backgroundColor: '#f5f5f5',
textAlign: 'center',
fontSize: '12px',
color: '#666',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{timeSlot.label}
</Box>
);
// 子組件:時間槽儲存格
const TimeSlotCell: React.FC<{
day: Date;
timeSlot: any;
isSelected: boolean;
onClick: () => void;
onCourseMove: (courseId: string, timeSlot: TimeSlot) => void;
}> = ({ day, timeSlot, isSelected, onClick, onCourseMove }) => {
const [{ isOver }, drop] = useDrop({
accept: 'course',
drop: (item: { courseId: string }) => {
const targetTimeSlot: TimeSlot = {
start: new Date(day.getFullYear(), day.getMonth(), day.getDate(), timeSlot.hour, timeSlot.minute),
end: new Date(day.getFullYear(), day.getMonth(), day.getDate(), timeSlot.hour, timeSlot.minute + 30),
};
onCourseMove(item.courseId, targetTimeSlot);
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
}),
});
return (
<Box
ref={drop}
onClick={onClick}
style={{
backgroundColor: isSelected ? '#bbdefb' : isOver ? '#e8f5e8' : 'white',
border: '1px solid #e0e0e0',
cursor: 'pointer',
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: '#f5f5f5',
},
}}
/>
);
};
// 子組件:課程區塊
const CourseBlock: React.FC<{
course: any;
onMove: (courseId: string, timeSlot: TimeSlot) => void;
onEdit: (courseId: string) => void;
style: React.CSSProperties;
}> = ({ course, onMove, onEdit, style }) => {
const [{ isDragging }, drag] = useDrag({
type: 'course',
item: { courseId: course.id },
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
});
const trainer = course.trainer;
const conflictColor = course.isConflicted ? '#ffebee' : undefined;
return (
<Paper
ref={drag}
p="xs"
style={{
...style,
backgroundColor: conflictColor || course.color || '#e3f2fd',
opacity: isDragging ? 0.5 : 1,
cursor: 'move',
overflow: 'hidden',
border: course.isConflicted ? '2px solid #f44336' : '1px solid #ddd',
}}
onClick={() => onEdit(course.id)}
>
<Group spacing="xs" noWrap>
{course.isConflicted && (
<Tooltip label="有排課衝突">
<IconAlertTriangle size={12} color="#f44336" />
</Tooltip>
)}
<Text size="xs" weight={600} lineClamp={1}>
{course.name}
</Text>
</Group>
<Group spacing="xs" mt={2}>
<Group spacing={2}>
<IconClock size={10} />
<Text size="xs" color="dimmed">
{format(parseISO(course.startTime), 'HH:mm')}
</Text>
</Group>
{trainer && (
<Group spacing={2}>
<IconUsers size={10} />
<Text size="xs" color="dimmed" lineClamp={1}>
{trainer.name}
</Text>
</Group>
)}
</Group>
{course.currentParticipants !== undefined && (
<Text size="xs" color="dimmed" mt={2}>
{course.currentParticipants}/{course.maxParticipants} 人
</Text>
)}
</Paper>
);
};
課程建立/編輯的表單很複雜,我們需要好的狀態管理方案:
// apps/kyo-dashboard/src/components/CourseScheduler/CourseCreateModal.tsx
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Modal,
Stack,
TextInput,
Textarea,
Select,
NumberInput,
Group,
Button,
Switch,
Grid,
Divider,
Alert,
Tabs,
ColorInput,
MultiSelect,
} from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import { IconInfoCircle, IconSettings, IconUsers, IconClock } from '@tabler/icons-react';
// 表單驗證 Schema
const courseCreateSchema = z.object({
name: z.string().min(1, '課程名稱為必填').max(100, '課程名稱不能超過100字'),
description: z.string().optional(),
categoryId: z.string().min(1, '請選擇課程類別'),
trainerId: z.string().min(1, '請選擇教練'),
roomId: z.string().min(1, '請選擇教室'),
// 時間設定
startTime: z.date({
required_error: '請選擇開始時間',
}),
duration: z.number()
.min(15, '課程時間至少15分鐘')
.max(180, '課程時間不能超過3小時'),
// 容量設定
maxParticipants: z.number()
.min(1, '至少需要1個名額')
.max(100, '名額不能超過100個'),
minParticipants: z.number()
.min(0, '最少開班人數不能為負數')
.optional(),
allowWaitlist: z.boolean().default(false),
// 定價
pricePerPerson: z.number()
.min(0, '價格不能為負數')
.optional(),
memberDiscount: z.number()
.min(0, '折扣不能為負數')
.max(100, '折扣不能超過100%')
.optional(),
// 重複設定
isRecurring: z.boolean().default(false),
recurringPattern: z.enum(['weekly', 'monthly']).optional(),
recurringEndDate: z.date().optional(),
// 進階設定
requiresEquipment: z.array(z.string()).optional(),
skillLevel: z.enum(['beginner', 'intermediate', 'advanced']),
tags: z.array(z.string()).optional(),
color: z.string().optional(),
// 狀態
isPublic: z.boolean().default(true),
allowDropIn: z.boolean().default(true),
cancellationPolicy: z.string().optional(),
}).refine((data) => {
// 自訂驗證:重複課程需要結束日期
if (data.isRecurring && !data.recurringEndDate) {
return false;
}
return true;
}, {
message: '重複課程需要設定結束日期',
path: ['recurringEndDate'],
}).refine((data) => {
// 自訂驗證:最少開班人數不能超過最大人數
if (data.minParticipants && data.minParticipants > data.maxParticipants) {
return false;
}
return true;
}, {
message: '最少開班人數不能超過最大人數',
path: ['minParticipants'],
});
type CourseCreateFormData = z.infer<typeof courseCreateSchema>;
interface CourseCreateModalProps {
opened: boolean;
onClose: () => void;
initialTimeSlot?: TimeSlot | null;
trainers: Trainer[];
rooms: Room[];
categories: CourseCategory[];
onSubmit: (data: CreateCourseRequest) => Promise<void>;
editingCourse?: Course | null;
}
export const CourseCreateModal: React.FC<CourseCreateModalProps> = ({
opened,
onClose,
initialTimeSlot,
trainers,
rooms,
categories,
onSubmit,
editingCourse,
}) => {
// 表單狀態管理
const {
control,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<CourseCreateFormData>({
resolver: zodResolver(courseCreateSchema),
defaultValues: {
name: editingCourse?.name || '',
description: editingCourse?.description || '',
categoryId: editingCourse?.categoryId || '',
trainerId: editingCourse?.trainerId || '',
roomId: editingCourse?.roomId || '',
startTime: editingCourse
? new Date(editingCourse.startTime)
: initialTimeSlot?.start || new Date(),
duration: editingCourse?.duration || 60,
maxParticipants: editingCourse?.maxParticipants || 20,
minParticipants: editingCourse?.minParticipants || 5,
allowWaitlist: editingCourse?.allowWaitlist || false,
pricePerPerson: editingCourse?.pricePerPerson || 0,
memberDiscount: editingCourse?.memberDiscount || 0,
isRecurring: editingCourse?.isRecurring || false,
skillLevel: editingCourse?.skillLevel || 'beginner',
isPublic: editingCourse?.isPublic ?? true,
allowDropIn: editingCourse?.allowDropIn ?? true,
color: editingCourse?.color || '#2196f3',
},
});
// 監聽表單變化
const watchedValues = watch();
const isRecurring = watch('isRecurring');
const selectedTrainer = watch('trainerId');
const selectedRoom = watch('roomId');
const startTime = watch('startTime');
const duration = watch('duration');
// 衝突檢測
const [conflicts, setConflicts] = React.useState<CourseConflict[]>([]);
const [isCheckingConflicts, setIsCheckingConflicts] = React.useState(false);
// 即時衝突檢測
React.useEffect(() => {
if (selectedTrainer && selectedRoom && startTime && duration) {
const checkConflicts = async () => {
setIsCheckingConflicts(true);
try {
// 呼叫 API 檢查衝突
const response = await fetch('/api/courses/check-conflicts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trainerId: selectedTrainer,
roomId: selectedRoom,
startTime,
duration,
excludeCourseId: editingCourse?.id,
}),
});
const conflictData = await response.json();
setConflicts(conflictData.conflicts || []);
} catch (error) {
console.error('衝突檢測失敗:', error);
} finally {
setIsCheckingConflicts(false);
}
};
const debounceTimer = setTimeout(checkConflicts, 500);
return () => clearTimeout(debounceTimer);
}
}, [selectedTrainer, selectedRoom, startTime, duration, editingCourse?.id]);
// 表單提交
const onFormSubmit = async (data: CourseCreateFormData) => {
try {
const endTime = new Date(data.startTime.getTime() + data.duration * 60000);
const createRequest: CreateCourseRequest = {
...data,
endTime,
recurringPattern: data.isRecurring ? data.recurringPattern : undefined,
recurringEndDate: data.isRecurring ? data.recurringEndDate : undefined,
};
await onSubmit(createRequest);
reset();
onClose();
} catch (error) {
console.error('課程建立失敗:', error);
}
};
// 智能預設值設定
const handleTrainerChange = (trainerId: string) => {
setValue('trainerId', trainerId);
// 根據教練專長自動設定課程顏色和技能等級
const trainer = trainers.find(t => t.id === trainerId);
if (trainer) {
// 根據教練的主要專長設定顏色
const specialtyColors = {
yoga: '#4caf50',
strength: '#f44336',
cardio: '#ff9800',
pilates: '#9c27b0',
dance: '#e91e63',
};
const primarySpecialty = trainer.specializations[0]?.category;
if (primarySpecialty && specialtyColors[primarySpecialty]) {
setValue('color', specialtyColors[primarySpecialty]);
}
// 根據教練等級設定建議的技能等級
if (trainer.level === 'master' || trainer.level === 'lead') {
setValue('skillLevel', 'advanced');
} else if (trainer.level === 'senior') {
setValue('skillLevel', 'intermediate');
}
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={editingCourse ? '編輯課程' : '新增課程'}
size="xl"
closeOnClickOutside={!isDirty}
closeOnEscape={!isDirty}
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Tabs defaultValue="basic">
<Tabs.List>
<Tabs.Tab value="basic" icon={<IconInfoCircle size={14} />}>
基本資訊
</Tabs.Tab>
<Tabs.Tab value="schedule" icon={<IconClock size={14} />}>
時間設定
</Tabs.Tab>
<Tabs.Tab value="participants" icon={<IconUsers size={14} />}>
人數與定價
</Tabs.Tab>
<Tabs.Tab value="advanced" icon={<IconSettings size={14} />}>
進階設定
</Tabs.Tab>
</Tabs.List>
{/* 基本資訊頁籤 */}
<Tabs.Panel value="basic" pt="md">
<Stack spacing="md">
<Controller
name="name"
control={control}
render={({ field }) => (
<TextInput
{...field}
label="課程名稱"
placeholder="輸入課程名稱"
error={errors.name?.message}
required
/>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<Textarea
{...field}
label="課程描述"
placeholder="描述課程內容、目標和特色"
minRows={3}
error={errors.description?.message}
/>
)}
/>
<Grid>
<Grid.Col span={6}>
<Controller
name="categoryId"
control={control}
render={({ field }) => (
<Select
{...field}
label="課程類別"
placeholder="選擇類別"
data={categories.map(cat => ({
value: cat.id,
label: cat.name,
}))}
error={errors.categoryId?.message}
required
/>
)}
/>
</Grid.Col>
<Grid.Col span={6}>
<Controller
name="skillLevel"
control={control}
render={({ field }) => (
<Select
{...field}
label="技能等級"
data={[
{ value: 'beginner', label: '初學者' },
{ value: 'intermediate', label: '中級' },
{ value: 'advanced', label: '進階' },
]}
error={errors.skillLevel?.message}
required
/>
)}
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<Controller
name="trainerId"
control={control}
render={({ field }) => (
<Select
{...field}
label="指定教練"
placeholder="選擇教練"
data={trainers.map(trainer => ({
value: trainer.id,
label: `${trainer.name} (${trainer.specializations.map(s => s.category).join(', ')})`,
}))}
error={errors.trainerId?.message}
onChange={handleTrainerChange}
required
/>
)}
/>
</Grid.Col>
<Grid.Col span={6}>
<Controller
name="roomId"
control={control}
render={({ field }) => (
<Select
{...field}
label="使用教室"
placeholder="選擇教室"
data={rooms.map(room => ({
value: room.id,
label: `${room.name} (容量: ${room.capacity})`,
}))}
error={errors.roomId?.message}
required
/>
)}
/>
</Grid.Col>
</Grid>
<Controller
name="color"
control={control}
render={({ field }) => (
<ColorInput
{...field}
label="課程顏色"
placeholder="選擇代表色"
swatches={[
'#2196f3', '#4caf50', '#ff9800', '#f44336',
'#9c27b0', '#00bcd4', '#795548', '#607d8b',
]}
/>
)}
/>
</Stack>
</Tabs.Panel>
{/* 時間設定頁籤 */}
<Tabs.Panel value="schedule" pt="md">
<Stack spacing="md">
<Grid>
<Grid.Col span={8}>
<Controller
name="startTime"
control={control}
render={({ field }) => (
<DateTimePicker
{...field}
label="開始時間"
placeholder="選擇日期和時間"
error={errors.startTime?.message}
required
/>
)}
/>
</Grid.Col>
<Grid.Col span={4}>
<Controller
name="duration"
control={control}
render={({ field }) => (
<NumberInput
{...field}
label="課程時長"
placeholder="分鐘"
min={15}
max={180}
step={15}
error={errors.duration?.message}
required
/>
)}
/>
</Grid.Col>
</Grid>
{/* 衝突警告 */}
{conflicts.length > 0 && (
<Alert color="red" title="發現排課衝突">
<Stack spacing="xs">
{conflicts.map((conflict, index) => (
<Text key={index} size="sm">
• {conflict.type === 'trainer_conflict' ? '教練時間衝突' : '教室時間衝突'}
{conflict.suggestion && `: ${conflict.suggestion}`}
</Text>
))}
</Stack>
</Alert>
)}
<Divider label="重複課程設定" />
<Controller
name="isRecurring"
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
label="建立重複課程"
description="自動為後續週期建立相同課程"
/>
)}
/>
{isRecurring && (
<Grid>
<Grid.Col span={6}>
<Controller
name="recurringPattern"
control={control}
render={({ field }) => (
<Select
{...field}
label="重複週期"
data={[
{ value: 'weekly', label: '每週' },
{ value: 'monthly', label: '每月' },
]}
error={errors.recurringPattern?.message}
/>
)}
/>
</Grid.Col>
<Grid.Col span={6}>
<Controller
name="recurringEndDate"
control={control}
render={({ field }) => (
<DateTimePicker
{...field}
label="結束日期"
placeholder="選擇結束日期"
error={errors.recurringEndDate?.message}
/>
)}
/>
</Grid.Col>
</Grid>
)}
</Stack>
</Tabs.Panel>
{/* 其他頁籤... */}
</Tabs>
<Group position="right" mt="xl">
<Button variant="subtle" onClick={onClose} disabled={isSubmitting}>
取消
</Button>
<Button
type="submit"
loading={isSubmitting}
disabled={conflicts.some(c => c.severity === 'error')}
>
{editingCourse ? '更新課程' : '建立課程'}
</Button>
</Group>
</form>
</Modal>
);
};
當多個管理者同時排課時,我們需要即時同步機制:
// apps/kyo-dashboard/src/hooks/useRealtimeSchedule.ts
import { useState, useEffect, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { useTenant } from '@kyong/kyo-core/client';
import { notifications } from '@mantine/notifications';
interface ScheduleUpdate {
type: 'course_created' | 'course_updated' | 'course_deleted' | 'user_editing';
data: any;
userId: string;
userName: string;
timestamp: number;
}
export const useRealtimeSchedule = () => {
const { currentTenant } = useTenant();
const [socket, setSocket] = useState<Socket | null>(null);
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
const [editingUsers, setEditingUsers] = useState<Map<string, string>>(new Map()); // courseId -> userName
// 建立 WebSocket 連線
useEffect(() => {
if (!currentTenant?.id) return;
const newSocket = io('/schedule', {
auth: {
tenantId: currentTenant.id,
token: localStorage.getItem('auth_token'),
},
});
newSocket.on('connect', () => {
console.log('即時排課連線已建立');
newSocket.emit('join_schedule_room', currentTenant.id);
});
newSocket.on('schedule_update', (update: ScheduleUpdate) => {
handleScheduleUpdate(update);
});
newSocket.on('users_online', (users: string[]) => {
setOnlineUsers(users);
});
newSocket.on('user_editing', ({ courseId, userName, isEditing }) => {
setEditingUsers(prev => {
const newMap = new Map(prev);
if (isEditing) {
newMap.set(courseId, userName);
} else {
newMap.delete(courseId);
}
return newMap;
});
});
newSocket.on('disconnect', () => {
console.log('即時排課連線已斷開');
});
setSocket(newSocket);
return () => {
newSocket.disconnect();
};
}, [currentTenant?.id]);
// 處理排程更新
const handleScheduleUpdate = useCallback((update: ScheduleUpdate) => {
const { type, data, userName } = update;
switch (type) {
case 'course_created':
notifications.show({
title: '新課程建立',
message: `${userName} 建立了新課程「${data.name}」`,
color: 'green',
});
// 觸發重新載入課程列表
window.dispatchEvent(new CustomEvent('course_created', { detail: data }));
break;
case 'course_updated':
notifications.show({
title: '課程已更新',
message: `${userName} 更新了課程「${data.name}」`,
color: 'blue',
});
window.dispatchEvent(new CustomEvent('course_updated', { detail: data }));
break;
case 'course_deleted':
notifications.show({
title: '課程已刪除',
message: `${userName} 刪除了課程「${data.name}」`,
color: 'red',
});
window.dispatchEvent(new CustomEvent('course_deleted', { detail: data }));
break;
}
}, []);
// 廣播使用者正在編輯
const broadcastEditing = useCallback((courseId: string, isEditing: boolean) => {
if (socket) {
socket.emit('user_editing', { courseId, isEditing });
}
}, [socket]);
// 廣播課程更新
const broadcastUpdate = useCallback((type: ScheduleUpdate['type'], data: any) => {
if (socket) {
socket.emit('schedule_update', { type, data });
}
}, [socket]);
return {
onlineUsers,
editingUsers,
broadcastEditing,
broadcastUpdate,
isConnected: socket?.connected || false,
};
};
我們添加課程統計和數據視覺化功能:
// apps/kyo-dashboard/src/components/CourseScheduler/ScheduleAnalytics.tsx
import React, { useMemo } from 'react';
import {
Paper,
Text,
Group,
Stack,
Grid,
RingProgress,
BarChart,
LineChart,
SimpleGrid,
Card,
Badge,
} from '@mantine/core';
import { IconTrendingUp, IconUsers, IconClock, IconTarget } from '@tabler/icons-react';
interface ScheduleAnalyticsProps {
courses: Course[];
dateRange: { start: Date; end: Date };
}
export const ScheduleAnalytics: React.FC<ScheduleAnalyticsProps> = ({
courses,
dateRange,
}) => {
// 計算統計數據
const analytics = useMemo(() => {
const totalCourses = courses.length;
const totalCapacity = courses.reduce((sum, course) => sum + course.maxParticipants, 0);
const totalBookings = courses.reduce((sum, course) => sum + course.currentParticipants, 0);
const utilizationRate = totalCapacity > 0 ? (totalBookings / totalCapacity) * 100 : 0;
// 按類別統計
const categoryStats = courses.reduce((acc, course) => {
const category = course.category?.name || '未分類';
if (!acc[category]) {
acc[category] = { count: 0, bookings: 0, capacity: 0 };
}
acc[category].count++;
acc[category].bookings += course.currentParticipants;
acc[category].capacity += course.maxParticipants;
return acc;
}, {} as Record<string, { count: number; bookings: number; capacity: number }>);
// 按教練統計
const trainerStats = courses.reduce((acc, course) => {
const trainerName = course.trainer?.name || '未指定';
if (!acc[trainerName]) {
acc[trainerName] = { courses: 0, totalHours: 0, avgRating: 0 };
}
acc[trainerName].courses++;
acc[trainerName].totalHours += course.duration / 60;
return acc;
}, {} as Record<string, { courses: number; totalHours: number; avgRating: number }>);
// 時段熱度分析
const hourlyStats = Array.from({ length: 24 }, (_, hour) => {
const coursesInHour = courses.filter(course => {
const startHour = new Date(course.startTime).getHours();
return startHour === hour;
});
return {
hour,
courseCount: coursesInHour.length,
utilization: coursesInHour.reduce((sum, course) =>
sum + (course.currentParticipants / course.maxParticipants), 0
) / (coursesInHour.length || 1),
};
}).filter(stat => stat.courseCount > 0);
return {
totalCourses,
totalCapacity,
totalBookings,
utilizationRate,
categoryStats,
trainerStats,
hourlyStats,
};
}, [courses]);
return (
<Stack spacing="md">
{/* 核心指標卡片 */}
<SimpleGrid cols={4} spacing="md">
<Card withBorder>
<Group position="apart">
<div>
<Text size="xs" color="dimmed" transform="uppercase" weight={700}>
總課程數
</Text>
<Text size="xl" weight={700}>
{analytics.totalCourses}
</Text>
</div>
<IconClock size={24} color="#228be6" />
</Group>
</Card>
<Card withBorder>
<Group position="apart">
<div>
<Text size="xs" color="dimmed" transform="uppercase" weight={700}>
總容量
</Text>
<Text size="xl" weight={700}>
{analytics.totalCapacity}
</Text>
</div>
<IconUsers size={24} color="#40c057" />
</Group>
</Card>
<Card withBorder>
<Group position="apart">
<div>
<Text size="xs" color="dimmed" transform="uppercase" weight={700}>
已預約
</Text>
<Text size="xl" weight={700}>
{analytics.totalBookings}
</Text>
</div>
<IconTarget size={24} color="#fd7e14" />
</Group>
</Card>
<Card withBorder>
<Group position="apart">
<div>
<Text size="xs" color="dimmed" transform="uppercase" weight={700}>
使用率
</Text>
<Text size="xl" weight={700}>
{analytics.utilizationRate.toFixed(1)}%
</Text>
</div>
<IconTrendingUp size={24} color="#e03131" />
</Group>
</Card>
</SimpleGrid>
<Grid>
{/* 使用率環形圖 */}
<Grid.Col span={4}>
<Paper p="md" withBorder>
<Text size="lg" weight={600} mb="md">整體使用率</Text>
<Group position="center">
<RingProgress
size={160}
thickness={16}
sections={[
{ value: analytics.utilizationRate, color: 'blue' },
]}
label={
<Text size="xl" align="center" weight={700}>
{analytics.utilizationRate.toFixed(1)}%
</Text>
}
/>
</Group>
</Paper>
</Grid.Col>
{/* 類別統計 */}
<Grid.Col span={8}>
<Paper p="md" withBorder>
<Text size="lg" weight={600} mb="md">課程類別分析</Text>
<Stack spacing="xs">
{Object.entries(analytics.categoryStats).map(([category, stats]) => (
<Group key={category} position="apart">
<Group>
<Text weight={500}>{category}</Text>
<Badge size="sm">{stats.count} 堂課</Badge>
</Group>
<Group spacing="xs">
<Text size="sm" color="dimmed">
{stats.bookings}/{stats.capacity}
</Text>
<RingProgress
size={24}
thickness={4}
sections={[
{
value: (stats.bookings / stats.capacity) * 100,
color: 'blue'
},
]}
/>
</Group>
</Group>
))}
</Stack>
</Paper>
</Grid.Col>
</Grid>
{/* 時段熱度圖 */}
<Paper p="md" withBorder>
<Text size="lg" weight={600} mb="md">時段熱度分析</Text>
<BarChart
h={300}
data={analytics.hourlyStats.map(stat => ({
hour: `${stat.hour}:00`,
courses: stat.courseCount,
utilization: Math.round(stat.utilization * 100),
}))}
dataKey="hour"
series={[
{ name: 'courses', color: 'blue.6', label: '課程數量' },
{ name: 'utilization', color: 'green.6', label: '使用率 (%)' },
]}
tickLine="y"
/>
</Paper>
{/* 教練工作負荷 */}
<Paper p="md" withBorder>
<Text size="lg" weight={600} mb="md">教練工作負荷</Text>
<Stack spacing="xs">
{Object.entries(analytics.trainerStats)
.sort(([,a], [,b]) => b.totalHours - a.totalHours)
.slice(0, 10)
.map(([trainer, stats]) => (
<Group key={trainer} position="apart">
<Group>
<Text weight={500}>{trainer}</Text>
<Badge size="sm" variant="light">
{stats.courses} 堂課
</Badge>
</Group>
<Text size="sm" color="dimmed">
{stats.totalHours.toFixed(1)} 小時
</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
);
};
今天我們實作了課程管理系統的前端: