iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 14

Day 14:30天打造SaaS產品前端篇-課程管理系統深度實作與視覺化排課工具

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 13 的企業級包管理和多租戶架構建立,我們的開發工作流程已經滿完善了。今天我們要深入實作課程管理系統的前端介面,這不只是簡單的 CRUD 操作,而是要建立一個視覺化的排課工具,讓健身房管理者能夠直觀地管理課程、教練和時間安排。

今天我們將設計:

  • 🗓️ 視覺化排課日曆組件設計
  • 🎨 複雜表單狀態管理最佳實務
  • ⚡ 即時協作功能實作
  • 🔄 拖拽排課的 UX 設計
  • 📊 課程數據可視化

🗓️ 視覺化排課系統設計

需求分析與 UX 挑戰

在開始實作前,我們需要深入了解健身房排課的複雜性:

// 課程排課的複雜業務需求
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>
  );
};

今日總結

今天我們實作了課程管理系統的前端:

核心功能

  1. 視覺化排課系統:完整的週視圖日曆組件
  2. 複雜表單管理:多步驟表單與即時驗證
  3. 即時協作功能:WebSocket 即時同步
  4. 數據可視化:課程統計和分析圖表
  5. 拖拽操作:直觀的排課體驗

技術特色

  • 組件化設計:可重用的日曆組件架構
  • 狀態管理:React Hook Form + Zod 驗證
  • 即時性:WebSocket 協作和衝突檢測
  • 使用者體驗:拖拽、快捷鍵、視覺回饋

https://ithelp.ithome.com.tw/upload/images/20250928/20140358Cho0xQ0CVV.pnghttps://ithelp.ithome.com.tw/upload/images/20250928/20140358hSQr4Pgng9.pnghttps://ithelp.ithome.com.tw/upload/images/20250928/20140358sjoUahaMXn.png


上一篇
Day 13:30天打造SaaS產品前端篇-企業級包管理 + 多租戶架構前端功能
下一篇
Day 15:30天打造SaaS產品前端篇-課程模板系統與批次操作實作
系列文
30 天製作工作室 SaaS 產品 (前端篇)17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言