經過 Day 14 的排課功能畫面實作,我們已經有了基本的課程管理功能。但在實際的健身房營運中,管理者經常需要:
今天我們要實作課程模板系統與批次操作功能,這不只是簡單的 CRUD,而是要解決真實世界中複雜的健身房營運需求。
我們將探討:
在開始實作前,我們需要深入了解課程模板的複雜性:
// 課程模板的業務複雜度分析
interface CourseTemplateComplexity {
// 模板層次結構
templateHierarchy: {
masterTemplate: boolean; // 主模板
derivedTemplates: string[]; // 衍生模板
inheritanceRules: object; // 繼承規則
overrideCapabilities: string[]; // 可覆寫屬性
};
// 動態屬性配置
dynamicProperties: {
timeVariables: { // 時間變數
startTime: 'variable' | 'fixed';
duration: 'flexible' | 'strict';
recurrence: RecurrencePattern;
};
resourceVariables: { // 資源變數
trainer: 'auto' | 'manual' | 'preferred';
room: 'auto' | 'fixed' | 'category';
equipment: 'required' | 'optional' | 'none';
};
capacityVariables: { // 容量變數
baseCapacity: number;
scalingFactor: number;
overflowHandling: 'waitlist' | 'reject' | 'expand';
};
};
// 條件邏輯
conditionalLogic: {
seasonalAdjustments: boolean; // 季節性調整
membershipTierLimits: boolean; // 會員等級限制
equipmentAvailability: boolean; // 設備可用性檢查
trainerSpecialization: boolean; // 教練專長匹配
};
// 驗證規則
validationRules: {
businessRules: ValidationRule[];
conflictDetection: ConflictRule[];
complianceChecks: ComplianceRule[];
};
}
我們採用分層架構,確保靈活性與可維護性:
// apps/kyo-dashboard/src/components/CourseTemplates/types.ts
export interface CourseTemplate {
id: string;
name: string;
description?: string;
category: 'fitness' | 'yoga' | 'strength' | 'cardio' | 'dance' | 'custom';
// 基礎配置
baseConfig: {
duration: number; // 預設時長
maxParticipants: number; // 預設人數上限
minParticipants?: number; // 最少開班人數
pricePerPerson?: number; // 預設價格
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'mixed';
color: string; // 視覺識別色彩
};
// 資源偏好設定
resourcePreferences: {
preferredTrainers: string[]; // 偏好教練列表
requiredEquipment: string[]; // 必需設備
roomRequirements: { // 教室需求
minCapacity?: number;
features: string[]; // 必需設施
category?: 'studio' | 'gym' | 'outdoor' | 'pool';
};
};
// 重複模式設定
recurrenceSettings: {
enabled: boolean;
pattern: 'daily' | 'weekly' | 'monthly' | 'custom';
interval: number; // 間隔
daysOfWeek?: number[]; // 週幾
endCondition: {
type: 'date' | 'count' | 'never';
value?: string | number;
};
exceptions: Date[]; // 例外日期
};
// 動態規則
dynamicRules: {
autoAssignTrainer: boolean; // 自動分配教練
autoScheduling: boolean; // 自動排程
conflictResolution: 'manual' | 'auto' | 'skip';
waitlistEnabled: boolean; // 候補清單
cancellationPolicy: string; // 取消政策
};
// 使用統計
usage: {
totalCreated: number; // 使用此模板建立的課程總數
lastUsed: Date; // 最後使用時間
successRate: number; // 成功開班率
avgParticipation: number; // 平均參與率
};
// 元資料
metadata: {
createdBy: string;
createdAt: Date;
updatedAt: Date;
version: string; // 版本控制
isActive: boolean;
isDefault: boolean; // 是否為預設模板
tags: string[]; // 標籤分類
};
}
export interface TemplateApplication {
templateId: string;
targetTimeSlots: TimeSlot[];
overrides?: Partial<CourseTemplate['baseConfig']>;
resourceOverrides?: {
specificTrainer?: string;
specificRoom?: string;
};
validationResults?: ValidationResult[];
conflicts?: ConflictResult[];
}
讓我們實作完整的課程模板管理系統:
// apps/kyo-dashboard/src/components/CourseTemplates/TemplateManager.tsx
import React, { useState, useCallback, useMemo } from 'react';
import {
Container,
Title,
Text,
Stack,
Group,
Button,
Card,
Badge,
ActionIcon,
Modal,
Tabs,
Grid,
Alert,
Progress,
Tooltip,
Menu,
Divider,
Input,
Select,
MultiSelect,
} from '@mantine/core';
import { useDisclosure, useLocalStorage } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconTemplate,
IconPlus,
IconEdit,
IconCopy,
IconTrash,
IconStar,
IconStarFilled,
IconFilter,
IconSearch,
IconDots,
IconBolt,
IconUsers,
IconClock,
IconTrendingUp,
IconAlertTriangle,
} from '@tabler/icons-react';
import { format } from 'date-fns';
import { zhTW } from 'date-fns/locale';
interface TemplateManagerProps {
templates: CourseTemplate[];
onCreateTemplate: () => void;
onEditTemplate: (template: CourseTemplate) => void;
onDeleteTemplate: (templateId: string) => void;
onApplyTemplate: (templateId: string, timeSlots: TimeSlot[]) => void;
isLoading?: boolean;
}
export const TemplateManager: React.FC<TemplateManagerProps> = ({
templates,
onCreateTemplate,
onEditTemplate,
onDeleteTemplate,
onApplyTemplate,
isLoading = false,
}) => {
// 狀態管理
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'name' | 'usage' | 'date' | 'success'>('usage');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [favorites, setFavorites] = useLocalStorage<string[]>({
key: 'favorite-templates',
defaultValue: [],
});
// Modal 控制
const [deleteModalOpened, { open: openDeleteModal, close: closeDeleteModal }] = useDisclosure();
const [templateToDelete, setTemplateToDelete] = useState<CourseTemplate | null>(null);
const [batchSelectMode, setBatchSelectMode] = useState(false);
const [selectedTemplates, setSelectedTemplates] = useState<Set<string>>(new Set());
// 過濾和排序邏輯
const filteredAndSortedTemplates = useMemo(() => {
let filtered = templates.filter(template => {
const matchesSearch = template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesCategory = !selectedCategory || template.category === selectedCategory;
return matchesSearch && matchesCategory && template.metadata.isActive;
});
// 排序邏輯
filtered.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'usage':
return b.usage.totalCreated - a.usage.totalCreated;
case 'date':
return new Date(b.metadata.updatedAt).getTime() - new Date(a.metadata.updatedAt).getTime();
case 'success':
return b.usage.successRate - a.usage.successRate;
default:
return 0;
}
});
// 將最愛項目置頂
const favoriteTemplates = filtered.filter(t => favorites.includes(t.id));
const otherTemplates = filtered.filter(t => !favorites.includes(t.id));
return [...favoriteTemplates, ...otherTemplates];
}, [templates, searchTerm, selectedCategory, sortBy, favorites]);
// 事件處理器
const handleToggleFavorite = useCallback((templateId: string) => {
setFavorites(prev =>
prev.includes(templateId)
? prev.filter(id => id !== templateId)
: [...prev, templateId]
);
}, [setFavorites]);
const handleDeleteTemplate = useCallback((template: CourseTemplate) => {
setTemplateToDelete(template);
openDeleteModal();
}, [openDeleteModal]);
const confirmDelete = useCallback(() => {
if (templateToDelete) {
onDeleteTemplate(templateToDelete.id);
notifications.show({
title: '模板已刪除',
message: `課程模板「${templateToDelete.name}」已成功刪除`,
color: 'green',
});
}
closeDeleteModal();
setTemplateToDelete(null);
}, [templateToDelete, onDeleteTemplate, closeDeleteModal]);
const handleBatchAction = useCallback((action: 'delete' | 'export' | 'duplicate') => {
const selectedCount = selectedTemplates.size;
switch (action) {
case 'delete':
if (confirm(`確定要刪除選中的 ${selectedCount} 個模板嗎?`)) {
selectedTemplates.forEach(templateId => onDeleteTemplate(templateId));
notifications.show({
title: '批次刪除完成',
message: `已刪除 ${selectedCount} 個課程模板`,
color: 'green',
});
setSelectedTemplates(new Set());
setBatchSelectMode(false);
}
break;
case 'export':
notifications.show({
title: '匯出中...',
message: `正在匯出 ${selectedCount} 個模板`,
color: 'blue',
});
// TODO: 實際匯出邏輯
break;
case 'duplicate':
notifications.show({
title: '複製中...',
message: `正在複製 ${selectedCount} 個模板`,
color: 'blue',
});
// TODO: 實際複製邏輯
break;
}
}, [selectedTemplates, onDeleteTemplate]);
// 統計計算
const templateStats = useMemo(() => {
return {
total: templates.filter(t => t.metadata.isActive).length,
favorites: favorites.length,
highUsage: templates.filter(t => t.usage.totalCreated > 10).length,
recentlyUpdated: templates.filter(t => {
const daysSinceUpdate = (Date.now() - new Date(t.metadata.updatedAt).getTime()) / (1000 * 60 * 60 * 24);
return daysSinceUpdate <= 7;
}).length,
};
}, [templates, favorites]);
return (
<Container size="xl" py="md">
<Stack gap="lg">
{/* 標題和統計 */}
<div>
<Group justify="space-between" mb="md">
<div>
<Title order={2} mb="xs">課程模板管理</Title>
<Text c="dimmed">建立、管理和應用課程模板,提升排課效率</Text>
</div>
<Group>
<Button
leftSection={<IconPlus size={16} />}
onClick={onCreateTemplate}
color="blue"
>
新增模板
</Button>
</Group>
</Group>
{/* 快速統計 */}
<Grid>
<Grid.Col span={3}>
<Card withBorder p="md">
<Group justify="space-between">
<div>
<Text size="sm" c="dimmed" tt="uppercase" fw={700}>總模板數</Text>
<Text size="xl" fw={700} c="blue">{templateStats.total}</Text>
</div>
<IconTemplate size={24} color="var(--mantine-color-blue-6)" />
</Group>
</Card>
</Grid.Col>
<Grid.Col span={3}>
<Card withBorder p="md">
<Group justify="space-between">
<div>
<Text size="sm" c="dimmed" tt="uppercase" fw={700}>我的最愛</Text>
<Text size="xl" fw={700} c="pink">{templateStats.favorites}</Text>
</div>
<IconStarFilled size={24} color="var(--mantine-color-pink-6)" />
</Group>
</Card>
</Grid.Col>
<Grid.Col span={3}>
<Card withBorder p="md">
<Group justify="space-between">
<div>
<Text size="sm" c="dimmed" tt="uppercase" fw={700}>高使用率</Text>
<Text size="xl" fw={700} c="green">{templateStats.highUsage}</Text>
</div>
<IconTrendingUp size={24} color="var(--mantine-color-green-6)" />
</Group>
</Card>
</Grid.Col>
<Grid.Col span={3}>
<Card withBorder p="md">
<Group justify="space-between">
<div>
<Text size="sm" c="dimmed" tt="uppercase" fw={700}>最近更新</Text>
<Text size="xl" fw={700} c="orange">{templateStats.recentlyUpdated}</Text>
</div>
<IconBolt size={24} color="var(--mantine-color-orange-6)" />
</Group>
</Card>
</Grid.Col>
</Grid>
</div>
{/* 搜尋和篩選控制 */}
<Card withBorder p="md">
<Grid align="end">
<Grid.Col span={4}>
<Input
placeholder="搜尋模板名稱、描述或標籤..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
leftSection={<IconSearch size={16} />}
/>
</Grid.Col>
<Grid.Col span={2}>
<Select
placeholder="選擇類別"
value={selectedCategory}
onChange={setSelectedCategory}
data={[
{ value: 'fitness', label: '健身訓練' },
{ value: 'yoga', label: '瑜伽' },
{ value: 'strength', label: '重量訓練' },
{ value: 'cardio', label: '有氧運動' },
{ value: 'dance', label: '舞蹈' },
{ value: 'custom', label: '自訂' },
]}
clearable
/>
</Grid.Col>
<Grid.Col span={2}>
<Select
placeholder="排序方式"
value={sortBy}
onChange={(value) => setSortBy(value as any)}
data={[
{ value: 'usage', label: '使用次數' },
{ value: 'name', label: '名稱' },
{ value: 'date', label: '更新時間' },
{ value: 'success', label: '成功率' },
]}
/>
</Grid.Col>
<Grid.Col span={4}>
<Group justify="right">
<Button
variant={batchSelectMode ? 'filled' : 'light'}
color="gray"
onClick={() => setBatchSelectMode(!batchSelectMode)}
disabled={filteredAndSortedTemplates.length === 0}
>
批次選擇
</Button>
{batchSelectMode && selectedTemplates.size > 0 && (
<Menu shadow="md">
<Menu.Target>
<Button leftSection={<IconDots size={16} />}>
批次操作 ({selectedTemplates.size})
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={() => handleBatchAction('duplicate')}
>
複製模板
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => handleBatchAction('delete')}
>
刪除模板
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
</Grid.Col>
</Grid>
</Card>
{/* 模板列表 */}
{filteredAndSortedTemplates.length === 0 ? (
<Card withBorder p="xl">
<Stack align="center" gap="md">
<IconTemplate size={48} color="var(--mantine-color-gray-5)" />
<Text size="lg" c="dimmed">
{searchTerm || selectedCategory ? '沒有找到符合條件的模板' : '還沒有任何課程模板'}
</Text>
<Button
leftSection={<IconPlus size={16} />}
onClick={onCreateTemplate}
>
建立第一個模板
</Button>
</Stack>
</Card>
) : (
<Grid>
{filteredAndSortedTemplates.map((template) => (
<Grid.Col key={template.id} span={4}>
<TemplateCard
template={template}
isFavorite={favorites.includes(template.id)}
isSelected={selectedTemplates.has(template.id)}
showCheckbox={batchSelectMode}
onToggleFavorite={() => handleToggleFavorite(template.id)}
onEdit={() => onEditTemplate(template)}
onDelete={() => handleDeleteTemplate(template)}
onApply={(timeSlots) => onApplyTemplate(template.id, timeSlots)}
onToggleSelect={(selected) => {
const newSelected = new Set(selectedTemplates);
if (selected) {
newSelected.add(template.id);
} else {
newSelected.delete(template.id);
}
setSelectedTemplates(newSelected);
}}
/>
</Grid.Col>
))}
</Grid>
)}
</Stack>
{/* 刪除確認 Modal */}
<Modal
opened={deleteModalOpened}
onClose={closeDeleteModal}
title="確認刪除模板"
centered
>
{templateToDelete && (
<Stack gap="md">
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
您即將刪除課程模板「{templateToDelete.name}」。此操作無法復原。
</Alert>
<div>
<Text size="sm" c="dimmed" mb="xs">模板使用統計:</Text>
<Group gap="md">
<Text size="sm">已建立課程:{templateToDelete.usage.totalCreated} 堂</Text>
<Text size="sm">成功率:{templateToDelete.usage.successRate.toFixed(1)}%</Text>
</Group>
</div>
<Group justify="right" mt="md">
<Button variant="subtle" onClick={closeDeleteModal}>
取消
</Button>
<Button color="red" onClick={confirmDelete}>
確認刪除
</Button>
</Group>
</Stack>
)}
</Modal>
</Container>
);
};
// 模板卡片組件
const TemplateCard: React.FC<{
template: CourseTemplate;
isFavorite: boolean;
isSelected: boolean;
showCheckbox: boolean;
onToggleFavorite: () => void;
onEdit: () => void;
onDelete: () => void;
onApply: (timeSlots: TimeSlot[]) => void;
onToggleSelect: (selected: boolean) => void;
}> = ({
template,
isFavorite,
isSelected,
showCheckbox,
onToggleFavorite,
onEdit,
onDelete,
onApply,
onToggleSelect,
}) => {
const successColor = template.usage.successRate >= 80 ? 'green' :
template.usage.successRate >= 60 ? 'yellow' : 'red';
return (
<Card
withBorder
p="md"
h={280}
style={{
cursor: 'pointer',
transition: 'all 0.2s',
border: isSelected ? '2px solid var(--mantine-color-blue-5)' : undefined,
}}
sx={{
'&:hover': {
boxShadow: 'var(--mantine-shadow-md)',
transform: 'translateY(-2px)',
},
}}
>
<Stack gap="sm" h="100%">
{/* 標題行 */}
<Group justify="space-between" align="flex-start">
<div style={{ flex: 1 }}>
<Group gap="xs" wrap="nowrap">
{showCheckbox && (
<input
type="checkbox"
checked={isSelected}
onChange={(e) => onToggleSelect(e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
)}
<Text fw={600} size="lg" lineClamp={1} style={{ flex: 1 }}>
{template.name}
</Text>
</Group>
{template.description && (
<Text size="sm" c="dimmed" lineClamp={2} mt={4}>
{template.description}
</Text>
)}
</div>
<Group gap="xs">
<ActionIcon
variant="subtle"
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
>
{isFavorite ? (
<IconStarFilled size={16} color="var(--mantine-color-yellow-6)" />
) : (
<IconStar size={16} />
)}
</ActionIcon>
<Menu shadow="md">
<Menu.Target>
<ActionIcon
variant="subtle"
onClick={(e) => e.stopPropagation()}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={onEdit}
>
編輯模板
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={() => console.log('複製模板')}
>
複製模板
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={onDelete}
>
刪除模板
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
{/* 類別和標籤 */}
<Group gap="xs">
<Badge
variant="light"
color={template.baseConfig.color}
size="sm"
>
{getCategoryLabel(template.category)}
</Badge>
<Badge variant="dot" color={successColor} size="sm">
{template.usage.successRate.toFixed(0)}% 成功率
</Badge>
</Group>
{/* 統計資訊 */}
<Grid grow>
<Grid.Col span={4}>
<div style={{ textAlign: 'center' }}>
<Text size="lg" fw={700} c="blue">
{template.usage.totalCreated}
</Text>
<Text size="xs" c="dimmed">已建立</Text>
</div>
</Grid.Col>
<Grid.Col span={4}>
<div style={{ textAlign: 'center' }}>
<Text size="lg" fw={700} c="green">
{template.baseConfig.duration}m
</Text>
<Text size="xs" c="dimmed">時長</Text>
</div>
</Grid.Col>
<Grid.Col span={4}>
<div style={{ textAlign: 'center' }}>
<Text size="lg" fw={700} c="orange">
{template.baseConfig.maxParticipants}
</Text>
<Text size="xs" c="dimmed">人數</Text>
</div>
</Grid.Col>
</Grid>
{/* 最後使用時間 */}
<Text size="xs" c="dimmed" mt="auto">
最後使用:{format(template.usage.lastUsed, 'yyyy/MM/dd', { locale: zhTW })}
</Text>
{/* 應用按鈕 */}
<Button
fullWidth
leftSection={<IconBolt size={16} />}
onClick={(e) => {
e.stopPropagation();
onApply([]);
}}
>
應用模板
</Button>
</Stack>
</Card>
);
};
// 輔助函數
function getCategoryLabel(category: string): string {
const labels = {
fitness: '健身訓練',
yoga: '瑜伽',
strength: '重量訓練',
cardio: '有氧運動',
dance: '舞蹈',
custom: '自訂',
};
return labels[category] || category;
}
批次操作不只是簡單的多選執行,我們需要考慮更複雜的場景:
// apps/kyo-dashboard/src/components/CourseScheduler/BatchOperations.tsx
import React, { useState, useCallback, useEffect } from 'react';
import {
Modal,
Stack,
Group,
Button,
Text,
Alert,
Progress,
Checkbox,
Select,
NumberInput,
DateInput,
Stepper,
Card,
Badge,
Divider,
ActionIcon,
ScrollArea,
Timeline,
Notification,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import {
IconBulb,
IconAlertTriangle,
IconCheck,
IconX,
IconCalendar,
IconUsers,
IconClock,
IconRefresh,
IconPlaylistAdd,
IconEdit,
IconTrash,
} from '@tabler/icons-react';
interface BatchOperationModalProps {
opened: boolean;
onClose: () => void;
selectedCourses: Course[];
operation: 'edit' | 'delete' | 'duplicate' | 'reschedule';
onExecute: (operation: BatchOperation) => Promise<BatchOperationResult>;
}
interface BatchOperation {
type: 'edit' | 'delete' | 'duplicate' | 'reschedule';
courses: string[];
changes?: Partial<Course>;
options?: {
preserveEnrollments?: boolean;
notifyParticipants?: boolean;
createWaitlist?: boolean;
rescheduleRules?: RescheduleRule[];
};
}
interface BatchOperationResult {
success: boolean;
results: {
courseId: string;
status: 'success' | 'failed' | 'warning';
message?: string;
newCourseId?: string;
}[];
summary: {
total: number;
successful: number;
failed: number;
warnings: number;
};
}
export const BatchOperationModal: React.FC<BatchOperationModalProps> = ({
opened,
onClose,
selectedCourses,
operation,
onExecute,
}) => {
// 狀態管理
const [currentStep, setCurrentStep] = useState(0);
const [isExecuting, setIsExecuting] = useState(false);
const [executionResults, setExecutionResults] = useState<BatchOperationResult | null>(null);
const [previewResults, setPreviewResults] = useState<any[]>([]);
const [validationIssues, setValidationIssues] = useState<ValidationIssue[]>([]);
// 表單管理
const form = useForm({
initialValues: {
// 編輯操作的欄位
updateTrainer: false,
newTrainerId: '',
updateRoom: false,
newRoomId: '',
updateCapacity: false,
newCapacity: 20,
updatePrice: false,
newPrice: 0,
updateDuration: false,
newDuration: 60,
// 選項設定
preserveEnrollments: true,
notifyParticipants: true,
createWaitlist: false,
// 重新排程選項
rescheduleStartDate: new Date(),
reschedulePattern: 'same_time',
rescheduleInterval: 7,
},
});
// 重設狀態當 modal 開啟時
useEffect(() => {
if (opened) {
setCurrentStep(0);
setIsExecuting(false);
setExecutionResults(null);
setPreviewResults([]);
setValidationIssues([]);
form.reset();
}
}, [opened, form]);
// 驗證和預覽邏輯
const generatePreview = useCallback(async () => {
const changes = form.values;
const preview = [];
const issues = [];
for (const course of selectedCourses) {
const previewItem = {
courseId: course.id,
courseName: course.name,
originalValues: {},
newValues: {},
warnings: [],
conflicts: [],
};
// 根據操作類型生成預覽
switch (operation) {
case 'edit':
if (changes.updateTrainer) {
previewItem.originalValues.trainer = course.trainer?.name || '無';
previewItem.newValues.trainer = getTrainerName(changes.newTrainerId);
// 檢查教練衝突
if (await hasTrainerConflict(changes.newTrainerId, course.startTime)) {
previewItem.conflicts.push('教練在此時段已有其他課程');
}
}
if (changes.updateRoom) {
previewItem.originalValues.room = course.room?.name || '無';
previewItem.newValues.room = getRoomName(changes.newRoomId);
// 檢查教室衝突
if (await hasRoomConflict(changes.newRoomId, course.startTime)) {
previewItem.conflicts.push('教室在此時段已被預訂');
}
}
if (changes.updateCapacity) {
previewItem.originalValues.capacity = course.maxParticipants;
previewItem.newValues.capacity = changes.newCapacity;
// 檢查容量減少的影響
if (changes.newCapacity < course.currentParticipants) {
previewItem.warnings.push(`目前已有 ${course.currentParticipants} 人報名,縮減容量可能需要處理超額報名`);
}
}
break;
case 'duplicate':
previewItem.newValues = {
name: `${course.name} (副本)`,
startTime: '待指定',
trainer: course.trainer?.name,
room: course.room?.name,
};
break;
case 'delete':
if (course.currentParticipants > 0) {
previewItem.warnings.push(`有 ${course.currentParticipants} 人已報名此課程`);
}
break;
case 'reschedule':
const newStartTime = calculateNewStartTime(course, changes);
previewItem.originalValues.startTime = course.startTime;
previewItem.newValues.startTime = newStartTime;
// 檢查新時間的衝突
const conflicts = await checkRescheduleConflicts(course, newStartTime);
previewItem.conflicts.push(...conflicts);
break;
}
preview.push(previewItem);
// 收集驗證問題
if (previewItem.conflicts.length > 0) {
issues.push({
type: 'error',
courseId: course.id,
courseName: course.name,
message: previewItem.conflicts.join(', '),
});
}
if (previewItem.warnings.length > 0) {
issues.push({
type: 'warning',
courseId: course.id,
courseName: course.name,
message: previewItem.warnings.join(', '),
});
}
}
setPreviewResults(preview);
setValidationIssues(issues);
}, [form.values, selectedCourses, operation]);
// 執行批次操作
const executeOperation = useCallback(async () => {
setIsExecuting(true);
try {
const batchOperation: BatchOperation = {
type: operation,
courses: selectedCourses.map(c => c.id),
changes: form.values,
options: {
preserveEnrollments: form.values.preserveEnrollments,
notifyParticipants: form.values.notifyParticipants,
createWaitlist: form.values.createWaitlist,
},
};
const result = await onExecute(batchOperation);
setExecutionResults(result);
// 顯示結果通知
if (result.success) {
notifications.show({
title: '批次操作完成',
message: `成功處理 ${result.summary.successful}/${result.summary.total} 個課程`,
color: 'green',
});
} else {
notifications.show({
title: '批次操作部分失敗',
message: `${result.summary.failed} 個課程處理失敗`,
color: 'orange',
});
}
setCurrentStep(3); // 跳到結果步驟
} catch (error) {
notifications.show({
title: '批次操作失敗',
message: error.message,
color: 'red',
});
} finally {
setIsExecuting(false);
}
}, [form.values, selectedCourses, operation, onExecute]);
// 步驟導航
const nextStep = useCallback(async () => {
if (currentStep === 1) {
await generatePreview();
}
setCurrentStep(prev => Math.min(prev + 1, 3));
}, [currentStep, generatePreview]);
const prevStep = useCallback(() => {
setCurrentStep(prev => Math.max(prev - 1, 0));
}, []);
// 渲染不同步驟的內容
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<Stack gap="md">
<Alert icon={<IconBulb size={16} />} color="blue">
您即將對 {selectedCourses.length} 個課程執行 {getOperationLabel(operation)} 操作
</Alert>
{operation === 'edit' && (
<Stack gap="lg">
<Card withBorder p="md">
<Stack gap="md">
<Text fw={600}>教練設定</Text>
<Checkbox
label="更新教練"
{...form.getInputProps('updateTrainer', { type: 'checkbox' })}
/>
{form.values.updateTrainer && (
<Select
label="新教練"
placeholder="選擇教練"
data={mockTrainers}
{...form.getInputProps('newTrainerId')}
required
/>
)}
</Stack>
</Card>
<Card withBorder p="md">
<Stack gap="md">
<Text fw={600}>教室設定</Text>
<Checkbox
label="更新教室"
{...form.getInputProps('updateRoom', { type: 'checkbox' })}
/>
{form.values.updateRoom && (
<Select
label="新教室"
placeholder="選擇教室"
data={mockRooms}
{...form.getInputProps('newRoomId')}
required
/>
)}
</Stack>
</Card>
<Card withBorder p="md">
<Stack gap="md">
<Text fw={600}>課程屬性</Text>
<Group grow>
<div>
<Checkbox
label="更新容量"
{...form.getInputProps('updateCapacity', { type: 'checkbox' })}
/>
{form.values.updateCapacity && (
<NumberInput
label="新容量"
min={1}
max={100}
{...form.getInputProps('newCapacity')}
mt="xs"
/>
)}
</div>
<div>
<Checkbox
label="更新價格"
{...form.getInputProps('updatePrice', { type: 'checkbox' })}
/>
{form.values.updatePrice && (
<NumberInput
label="新價格"
min={0}
prefix="NT$ "
{...form.getInputProps('newPrice')}
mt="xs"
/>
)}
</div>
</Group>
</Stack>
</Card>
<Card withBorder p="md">
<Stack gap="md">
<Text fw={600}>操作選項</Text>
<Checkbox
label="保留現有報名"
description="保持學員的報名狀態不變"
{...form.getInputProps('preserveEnrollments', { type: 'checkbox' })}
/>
<Checkbox
label="通知參與者"
description="向已報名的學員發送變更通知"
{...form.getInputProps('notifyParticipants', { type: 'checkbox' })}
/>
</Stack>
</Card>
</Stack>
)}
{operation === 'reschedule' && (
<Card withBorder p="md">
<Stack gap="md">
<Text fw={600}>重新排程設定</Text>
<DateInput
label="新的開始日期"
{...form.getInputProps('rescheduleStartDate')}
required
/>
<Select
label="排程模式"
data={[
{ value: 'same_time', label: '保持相同時間' },
{ value: 'shift_days', label: '平移天數' },
{ value: 'new_pattern', label: '新的重複模式' },
]}
{...form.getInputProps('reschedulePattern')}
required
/>
</Stack>
</Card>
)}
{operation === 'delete' && (
<Alert icon={<IconAlertTriangle size={16} />} color="red">
警告:此操作將永久刪除選中的課程,且無法復原。
已報名的學員將收到課程取消通知。
</Alert>
)}
</Stack>
);
case 1:
return (
<Stack gap="md">
<Text fw={600} size="lg">操作預覽</Text>
{validationIssues.length > 0 && (
<Alert icon={<IconAlertTriangle size={16} />} color="yellow">
<Text fw={600} mb="xs">發現 {validationIssues.length} 個問題:</Text>
<Stack gap="xs">
{validationIssues.map((issue, index) => (
<Group key={index} gap="xs">
<Badge color={issue.type === 'error' ? 'red' : 'yellow'} size="sm">
{issue.type === 'error' ? '錯誤' : '警告'}
</Badge>
<Text size="sm">{issue.courseName}: {issue.message}</Text>
</Group>
))}
</Stack>
</Alert>
)}
<ScrollArea h={400}>
<Stack gap="sm">
{previewResults.map((item, index) => (
<Card key={index} withBorder p="md">
<Stack gap="xs">
<Text fw={600}>{item.courseName}</Text>
{Object.keys(item.newValues).length > 0 && (
<div>
<Text size="sm" c="dimmed" mb="xs">變更內容:</Text>
{Object.entries(item.newValues).map(([key, value]) => (
<Group key={key} justify="space-between" gap="md">
<Text size="sm">{getFieldLabel(key)}</Text>
<Group gap="xs">
<Text size="sm" c="dimmed">
{item.originalValues[key] || '無'}
</Text>
<Text>→</Text>
<Text size="sm" fw={600}>{value}</Text>
</Group>
</Group>
))}
</div>
)}
{item.conflicts.length > 0 && (
<Alert icon={<IconX size={14} />} color="red" size="sm">
{item.conflicts.join(', ')}
</Alert>
)}
{item.warnings.length > 0 && (
<Alert icon={<IconAlertTriangle size={14} />} color="yellow" size="sm">
{item.warnings.join(', ')}
</Alert>
)}
</Stack>
</Card>
))}
</Stack>
</ScrollArea>
</Stack>
);
case 2:
return (
<Stack gap="md" align="center">
<Text fw={600} size="lg">執行批次操作</Text>
<Text c="dimmed">正在處理 {selectedCourses.length} 個課程...</Text>
<Progress value={75} size="lg" style={{ width: '100%' }} />
<Text size="sm" c="dimmed">預計剩餘時間:30 秒</Text>
</Stack>
);
case 3:
return (
<Stack gap="md">
<Text fw={600} size="lg">操作結果</Text>
{executionResults && (
<>
<Group justify="space-between">
<Card withBorder p="md" style={{ flex: 1 }}>
<Group justify="center" gap="xs">
<IconCheck size={24} color="var(--mantine-color-green-6)" />
<div>
<Text fw={600} c="green">{executionResults.summary.successful}</Text>
<Text size="sm" c="dimmed">成功</Text>
</div>
</Group>
</Card>
{executionResults.summary.failed > 0 && (
<Card withBorder p="md" style={{ flex: 1 }}>
<Group justify="center" gap="xs">
<IconX size={24} color="var(--mantine-color-red-6)" />
<div>
<Text fw={600} c="red">{executionResults.summary.failed}</Text>
<Text size="sm" c="dimmed">失敗</Text>
</div>
</Group>
</Card>
)}
{executionResults.summary.warnings > 0 && (
<Card withBorder p="md" style={{ flex: 1 }}>
<Group justify="center" gap="xs">
<IconAlertTriangle size={24} color="var(--mantine-color-yellow-6)" />
<div>
<Text fw={600} c="yellow">{executionResults.summary.warnings}</Text>
<Text size="sm" c="dimmed">警告</Text>
</div>
</Group>
</Card>
)}
</Group>
<ScrollArea h={300}>
<Timeline>
{executionResults.results.map((result, index) => {
const course = selectedCourses.find(c => c.id === result.courseId);
const icon = result.status === 'success' ? IconCheck :
result.status === 'failed' ? IconX : IconAlertTriangle;
const color = result.status === 'success' ? 'green' :
result.status === 'failed' ? 'red' : 'yellow';
return (
<Timeline.Item
key={index}
bullet={React.createElement(icon, { size: 16 })}
color={color}
title={course?.name || result.courseId}
>
<Text size="sm" c="dimmed">
{result.message || `操作${result.status === 'success' ? '成功' : '失敗'}`}
</Text>
</Timeline.Item>
);
})}
</Timeline>
</ScrollArea>
</>
)}
</Stack>
);
default:
return null;
}
};
const canProceed = () => {
if (currentStep === 1) {
return validationIssues.filter(issue => issue.type === 'error').length === 0;
}
return true;
};
return (
<Modal
opened={opened}
onClose={onClose}
title={`批次${getOperationLabel(operation)}`}
size="xl"
centered
closeOnClickOutside={false}
>
<Stack gap="lg">
{/* 步驟指示器 */}
<Stepper active={currentStep} allowNextStepsSelect={false}>
<Stepper.Step
label="設定參數"
description="配置操作選項"
/>
<Stepper.Step
label="預覽變更"
description="檢查影響範圍"
/>
<Stepper.Step
label="執行操作"
description="處理中..."
loading={isExecuting}
/>
<Stepper.Step
label="完成"
description="查看結果"
/>
</Stepper>
{/* 步驟內容 */}
{renderStepContent()}
{/* 操作按鈕 */}
<Group justify="space-between">
<Button
variant="subtle"
onClick={currentStep === 0 ? onClose : prevStep}
disabled={isExecuting}
>
{currentStep === 0 ? '取消' : '上一步'}
</Button>
<Group>
{currentStep < 2 && (
<Button
onClick={nextStep}
disabled={!canProceed()}
loading={currentStep === 1 && previewResults.length === 0}
>
{currentStep === 1 ? '執行操作' : '下一步'}
</Button>
)}
{currentStep === 2 && (
<Button
onClick={executeOperation}
loading={isExecuting}
disabled={!canProceed()}
>
確認執行
</Button>
)}
{currentStep === 3 && (
<Button onClick={onClose}>
完成
</Button>
)}
</Group>
</Group>
</Stack>
</Modal>
);
};
// Mock 資料和輔助函數
const mockTrainers = [
{ value: 'trainer-1', label: '王小美 (瑜伽專家)' },
{ value: 'trainer-2', label: '李強 (重訓教練)' },
{ value: 'trainer-3', label: '張舞蹈 (有氧舞蹈)' },
];
const mockRooms = [
{ value: 'room-1', label: '瑜伽教室 (容量: 25)' },
{ value: 'room-2', label: '重訓區 (容量: 15)' },
{ value: 'room-3', label: '有氧教室 (容量: 30)' },
];
function getOperationLabel(operation: string): string {
const labels = {
edit: '編輯',
delete: '刪除',
duplicate: '複製',
reschedule: '重新排程',
};
return labels[operation] || operation;
}
function getFieldLabel(field: string): string {
const labels = {
trainer: '教練',
room: '教室',
capacity: '容量',
price: '價格',
duration: '時長',
startTime: '開始時間',
};
return labels[field] || field;
}
function getTrainerName(trainerId: string): string {
const trainer = mockTrainers.find(t => t.value === trainerId);
return trainer?.label.split(' ')[0] || '未知教練';
}
function getRoomName(roomId: string): string {
const room = mockRooms.find(r => r.value === roomId);
return room?.label.split(' ')[0] || '未知教室';
}
// 衝突檢測函數 (實際應用中會呼叫 API)
async function hasTrainerConflict(trainerId: string, startTime: string): Promise<boolean> {
// Mock 邏輯
return Math.random() > 0.8;
}
async function hasRoomConflict(roomId: string, startTime: string): Promise<boolean> {
// Mock 邏輯
return Math.random() > 0.7;
}
function calculateNewStartTime(course: Course, changes: any): string {
// Mock 邏輯
return '2024-01-20T10:00:00Z';
}
async function checkRescheduleConflicts(course: Course, newStartTime: string): Promise<string[]> {
// Mock 邏輯
const conflicts = [];
if (Math.random() > 0.6) {
conflicts.push('教練時間衝突');
}
if (Math.random() > 0.7) {
conflicts.push('教室已被預訂');
}
return conflicts;
}
今天我們實作了課程模板系統與批次操作功能: