4th
This commit is contained in:
@@ -117,18 +117,18 @@ def upload_excel():
|
|||||||
|
|
||||||
# 狀態
|
# 狀態
|
||||||
status_mapping = {
|
status_mapping = {
|
||||||
'新建': 'NEW', '進行中': 'IN_PROGRESS', '完成': 'DONE',
|
'新建': 'NEW', '進行中': 'DOING', '完成': 'DONE', '阻塞': 'BLOCKED',
|
||||||
'NEW': 'NEW', 'IN_PROGRESS': 'IN_PROGRESS', 'DONE': 'DONE',
|
'NEW': 'NEW', 'DOING': 'DOING', 'DONE': 'DONE', 'BLOCKED': 'BLOCKED',
|
||||||
'新': 'NEW', '進行': 'IN_PROGRESS', '完': 'DONE'
|
'新': 'NEW', '進行': 'DOING', '完': 'DONE', '阻': 'BLOCKED'
|
||||||
}
|
}
|
||||||
status_str = str(row.get('狀態', row.get('status', 'NEW'))).strip()
|
status_str = str(row.get('狀態', row.get('status', 'NEW'))).strip()
|
||||||
status = status_mapping.get(status_str, 'NEW')
|
status = status_mapping.get(status_str, 'NEW')
|
||||||
|
|
||||||
# 優先級
|
# 優先級
|
||||||
priority_mapping = {
|
priority_mapping = {
|
||||||
'高': 'HIGH', '中': 'MEDIUM', '低': 'LOW',
|
'緊急': 'URGENT', '高': 'HIGH', '中': 'MEDIUM', '低': 'LOW',
|
||||||
'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW',
|
'URGENT': 'URGENT', 'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW',
|
||||||
'高優先級': 'HIGH', '中優先級': 'MEDIUM', '低優先級': 'LOW'
|
'緊急優先級': 'URGENT', '高優先級': 'HIGH', '中優先級': 'MEDIUM', '低優先級': 'LOW'
|
||||||
}
|
}
|
||||||
priority_str = str(row.get('優先級', row.get('priority', 'MEDIUM'))).strip()
|
priority_str = str(row.get('優先級', row.get('priority', 'MEDIUM'))).strip()
|
||||||
priority = priority_mapping.get(priority_str, 'MEDIUM')
|
priority = priority_mapping.get(priority_str, 'MEDIUM')
|
||||||
@@ -356,8 +356,8 @@ def export_todos():
|
|||||||
followers = [f.ad_account for f in todo.followers]
|
followers = [f.ad_account for f in todo.followers]
|
||||||
|
|
||||||
# 狀態和優先級的中文對應
|
# 狀態和優先級的中文對應
|
||||||
status_mapping = {'NEW': '新建', 'IN_PROGRESS': '進行中', 'DONE': '完成'}
|
status_mapping = {'NEW': '新建', 'DOING': '進行中', 'DONE': '完成', 'BLOCKED': '阻塞'}
|
||||||
priority_mapping = {'HIGH': '高', 'MEDIUM': '中', 'LOW': '低'}
|
priority_mapping = {'URGENT': '緊急', 'HIGH': '高', 'MEDIUM': '中', 'LOW': '低'}
|
||||||
|
|
||||||
data.append({
|
data.append({
|
||||||
'編號': todo.id,
|
'編號': todo.id,
|
||||||
@@ -445,7 +445,7 @@ def download_template():
|
|||||||
'描述': ['這是第一個範例的詳細描述', '這是第二個範例的詳細描述'],
|
'描述': ['這是第一個範例的詳細描述', '這是第二個範例的詳細描述'],
|
||||||
'狀態': ['新建', '進行中'],
|
'狀態': ['新建', '進行中'],
|
||||||
'優先級': ['高', '中'],
|
'優先級': ['高', '中'],
|
||||||
'到期日': ['2024-12-31', '2025-01-15'],
|
'到期日': ['2025-12-31', '2026-01-15'],
|
||||||
'負責人': ['user1@panjit.com.tw', 'user2@panjit.com.tw'],
|
'負責人': ['user1@panjit.com.tw', 'user2@panjit.com.tw'],
|
||||||
'追蹤人': ['user3@panjit.com.tw;user4@panjit.com.tw', 'user5@panjit.com.tw']
|
'追蹤人': ['user3@panjit.com.tw;user4@panjit.com.tw', 'user5@panjit.com.tw']
|
||||||
}
|
}
|
||||||
@@ -455,8 +455,8 @@ def download_template():
|
|||||||
'欄位說明': [
|
'欄位說明': [
|
||||||
'標題 (必填)',
|
'標題 (必填)',
|
||||||
'描述 (選填)',
|
'描述 (選填)',
|
||||||
'狀態: 新建/進行中/完成',
|
'狀態: 新建/進行中/完成/阻塞',
|
||||||
'優先級: 高/中/低',
|
'優先級: 緊急/高/中/低',
|
||||||
'到期日: YYYY-MM-DD 格式',
|
'到期日: YYYY-MM-DD 格式',
|
||||||
'負責人: AD帳號,多人用分號分隔',
|
'負責人: AD帳號,多人用分號分隔',
|
||||||
'追蹤人: AD帳號,多人用分號分隔'
|
'追蹤人: AD帳號,多人用分號分隔'
|
||||||
@@ -464,8 +464,8 @@ def download_template():
|
|||||||
'說明': [
|
'說明': [
|
||||||
'請填入待辦事項的標題',
|
'請填入待辦事項的標題',
|
||||||
'可選填詳細描述',
|
'可選填詳細描述',
|
||||||
'可選填 NEW/IN_PROGRESS/DONE',
|
'可選填 NEW/DOING/DONE/BLOCKED',
|
||||||
'可選填 HIGH/MEDIUM/LOW',
|
'可選填 URGENT/HIGH/MEDIUM/LOW',
|
||||||
'例如: 2024-12-31',
|
'例如: 2024-12-31',
|
||||||
'例如: john@panjit.com.tw',
|
'例如: john@panjit.com.tw',
|
||||||
'例如: mary@panjit.com.tw;tom@panjit.com.tw'
|
'例如: mary@panjit.com.tw;tom@panjit.com.tw'
|
||||||
|
@@ -154,7 +154,23 @@ def get_user_info(ad_account):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
config = current_app.config
|
config = current_app.config
|
||||||
search_filter = f"(&(objectClass=person)(sAMAccountName={ad_account}))"
|
|
||||||
|
# 支援 sAMAccountName 和 userPrincipalName 格式
|
||||||
|
if '@' in ad_account:
|
||||||
|
# Email 格式,使用 userPrincipalName 或 mail 搜尋
|
||||||
|
search_filter = f"""(&
|
||||||
|
(objectClass=person)
|
||||||
|
(|
|
||||||
|
(userPrincipalName={ad_account})
|
||||||
|
(mail={ad_account})
|
||||||
|
)
|
||||||
|
)"""
|
||||||
|
else:
|
||||||
|
# 純帳號名稱,使用 sAMAccountName 搜尋
|
||||||
|
search_filter = f"(&(objectClass=person)(sAMAccountName={ad_account}))"
|
||||||
|
|
||||||
|
# 移除多餘的空白
|
||||||
|
search_filter = ' '.join(search_filter.split())
|
||||||
|
|
||||||
conn.search(
|
conn.search(
|
||||||
config['LDAP_SEARCH_BASE'],
|
config['LDAP_SEARCH_BASE'],
|
||||||
@@ -170,7 +186,8 @@ def get_user_info(ad_account):
|
|||||||
return {
|
return {
|
||||||
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else ad_account,
|
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else ad_account,
|
||||||
'display_name': str(entry.displayName) if entry.displayName else ad_account,
|
'display_name': str(entry.displayName) if entry.displayName else ad_account,
|
||||||
'email': str(entry.mail) if entry.mail else ''
|
'email': str(entry.mail) if entry.mail else '',
|
||||||
|
'user_principal_name': str(entry.userPrincipalName) if entry.userPrincipalName else ''
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -191,13 +208,28 @@ def validate_ad_accounts(ad_accounts):
|
|||||||
valid_accounts = {}
|
valid_accounts = {}
|
||||||
|
|
||||||
for account in ad_accounts:
|
for account in ad_accounts:
|
||||||
search_filter = f"(&(objectClass=person)(sAMAccountName={account}))"
|
# 支援 sAMAccountName 和 userPrincipalName 格式
|
||||||
|
if '@' in account:
|
||||||
|
# Email 格式,使用 userPrincipalName 或 mail 搜尋
|
||||||
|
search_filter = f"""(&
|
||||||
|
(objectClass=person)
|
||||||
|
(|
|
||||||
|
(userPrincipalName={account})
|
||||||
|
(mail={account})
|
||||||
|
)
|
||||||
|
)"""
|
||||||
|
else:
|
||||||
|
# 純帳號名稱,使用 sAMAccountName 搜尋
|
||||||
|
search_filter = f"(&(objectClass=person)(sAMAccountName={account}))"
|
||||||
|
|
||||||
|
# 移除多餘的空白
|
||||||
|
search_filter = ' '.join(search_filter.split())
|
||||||
|
|
||||||
conn.search(
|
conn.search(
|
||||||
config['LDAP_SEARCH_BASE'],
|
config['LDAP_SEARCH_BASE'],
|
||||||
search_filter,
|
search_filter,
|
||||||
SUBTREE,
|
SUBTREE,
|
||||||
attributes=['sAMAccountName', 'displayName', 'mail']
|
attributes=['sAMAccountName', 'displayName', 'mail', 'userPrincipalName']
|
||||||
)
|
)
|
||||||
|
|
||||||
if conn.entries:
|
if conn.entries:
|
||||||
@@ -205,8 +237,12 @@ def validate_ad_accounts(ad_accounts):
|
|||||||
valid_accounts[account] = {
|
valid_accounts[account] = {
|
||||||
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else account,
|
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else account,
|
||||||
'display_name': str(entry.displayName) if entry.displayName else account,
|
'display_name': str(entry.displayName) if entry.displayName else account,
|
||||||
'email': str(entry.mail) if entry.mail else ''
|
'email': str(entry.mail) if entry.mail else '',
|
||||||
|
'user_principal_name': str(entry.userPrincipalName) if entry.userPrincipalName else ''
|
||||||
}
|
}
|
||||||
|
logger.info(f"Validated AD account: {account} -> {entry.sAMAccountName}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"AD account not found: {account}")
|
||||||
|
|
||||||
return valid_accounts
|
return valid_accounts
|
||||||
|
|
||||||
|
@@ -22,6 +22,7 @@ import {
|
|||||||
FilterList,
|
FilterList,
|
||||||
Search,
|
Search,
|
||||||
SelectAll,
|
SelectAll,
|
||||||
|
CloudUpload,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useTheme } from '@/providers/ThemeProvider';
|
import { useTheme } from '@/providers/ThemeProvider';
|
||||||
@@ -32,6 +33,7 @@ import TodoFilters from '@/components/todos/TodoFilters';
|
|||||||
import BatchActions from '@/components/todos/BatchActions';
|
import BatchActions from '@/components/todos/BatchActions';
|
||||||
import SearchBar from '@/components/todos/SearchBar';
|
import SearchBar from '@/components/todos/SearchBar';
|
||||||
import TodoDialog from '@/components/todos/TodoDialog';
|
import TodoDialog from '@/components/todos/TodoDialog';
|
||||||
|
import ExcelImport from '@/components/todos/ExcelImport';
|
||||||
import { Todo } from '@/types';
|
import { Todo } from '@/types';
|
||||||
import { todosApi } from '@/lib/api';
|
import { todosApi } from '@/lib/api';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
@@ -60,6 +62,7 @@ const TodosPage = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showTodoDialog, setShowTodoDialog] = useState(false);
|
const [showTodoDialog, setShowTodoDialog] = useState(false);
|
||||||
const [editingTodo, setEditingTodo] = useState<any>(null);
|
const [editingTodo, setEditingTodo] = useState<any>(null);
|
||||||
|
const [showExcelImport, setShowExcelImport] = useState(false);
|
||||||
const [todos, setTodos] = useState<Todo[]>([]);
|
const [todos, setTodos] = useState<Todo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||||
@@ -508,27 +511,43 @@ const TodosPage = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
variant="contained"
|
<Button
|
||||||
startIcon={<Add />}
|
variant="outlined"
|
||||||
onClick={handleCreateTodo}
|
startIcon={<CloudUpload />}
|
||||||
sx={{
|
onClick={() => setShowExcelImport(true)}
|
||||||
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
sx={{
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
px: 3,
|
px: 3,
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
}}
|
||||||
'&:hover': {
|
>
|
||||||
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
|
Excel 匯入
|
||||||
boxShadow: '0 6px 16px rgba(59, 130, 246, 0.4)',
|
</Button>
|
||||||
transform: 'translateY(-1px)',
|
<Button
|
||||||
},
|
variant="contained"
|
||||||
}}
|
startIcon={<Add />}
|
||||||
>
|
onClick={handleCreateTodo}
|
||||||
新增待辦
|
sx={{
|
||||||
</Button>
|
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
px: 3,
|
||||||
|
py: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
|
||||||
|
boxShadow: '0 6px 16px rgba(59, 130, 246, 0.4)',
|
||||||
|
transform: 'translateY(-1px)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增待辦
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -779,6 +798,13 @@ const TodosPage = () => {
|
|||||||
onSave={handleSaveTodo}
|
onSave={handleSaveTodo}
|
||||||
onTodoCreated={handleTodoCreated}
|
onTodoCreated={handleTodoCreated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Excel 匯入對話框 */}
|
||||||
|
<ExcelImport
|
||||||
|
open={showExcelImport}
|
||||||
|
onClose={() => setShowExcelImport(false)}
|
||||||
|
onImportComplete={handleTodoCreated}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
444
frontend/src/components/todos/ExcelImport.tsx
Normal file
444
frontend/src/components/todos/ExcelImport.tsx
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
LinearProgress,
|
||||||
|
Alert,
|
||||||
|
Stepper,
|
||||||
|
Step,
|
||||||
|
StepLabel,
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
Paper,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
CloudUpload,
|
||||||
|
Download,
|
||||||
|
CheckCircle,
|
||||||
|
Error,
|
||||||
|
Edit,
|
||||||
|
Delete,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useTheme } from '@/providers/ThemeProvider';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface ExcelImportProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onImportComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TodoImportData {
|
||||||
|
row: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
due_date: string | null;
|
||||||
|
responsible_users: string[];
|
||||||
|
followers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExcelImport: React.FC<ExcelImportProps> = ({ open, onClose, onImportComplete }) => {
|
||||||
|
const { actualTheme } = useTheme();
|
||||||
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [parsedData, setParsedData] = useState<TodoImportData[]>([]);
|
||||||
|
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||||
|
const [importErrors, setImportErrors] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const steps = ['上傳檔案', '預覽資料', '確認匯入'];
|
||||||
|
|
||||||
|
const handleDownloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const response = await fetch('http://localhost:5000/api/excel/template', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'todo_import_template.xlsx';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
toast.success('模板下載成功!');
|
||||||
|
} else {
|
||||||
|
toast.error('模板下載失敗');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download template error:', error);
|
||||||
|
toast.error('模板下載失敗');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = event.target.files?.[0];
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
setFile(selectedFile);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const response = await fetch('http://localhost:5000/api/excel/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setParsedData(result.data || []);
|
||||||
|
setParseErrors(result.errors || []);
|
||||||
|
setActiveStep(1);
|
||||||
|
toast.success(`成功解析 ${result.total} 筆資料`);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '檔案解析失敗');
|
||||||
|
setParseErrors([result.error || '檔案解析失敗']);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File upload error:', error);
|
||||||
|
toast.error('檔案上傳失敗');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (parsedData.length === 0) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const response = await fetch('http://localhost:5000/api/excel/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
todos: parsedData
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setImportErrors(result.errors || []);
|
||||||
|
setActiveStep(2);
|
||||||
|
toast.success(`成功匯入 ${result.imported} 筆待辦事項`);
|
||||||
|
if (onImportComplete) {
|
||||||
|
onImportComplete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '匯入失敗');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Import error:', error);
|
||||||
|
toast.error('匯入失敗');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setActiveStep(0);
|
||||||
|
setFile(null);
|
||||||
|
setParsedData([]);
|
||||||
|
setParseErrors([]);
|
||||||
|
setImportErrors([]);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'NEW': return '#6b7280';
|
||||||
|
case 'DOING': return '#3b82f6';
|
||||||
|
case 'BLOCKED': return '#ef4444';
|
||||||
|
case 'DONE': return '#10b981';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'URGENT': return '#ef4444';
|
||||||
|
case 'HIGH': return '#f97316';
|
||||||
|
case 'MEDIUM': return '#f59e0b';
|
||||||
|
case 'LOW': return '#6b7280';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (activeStep) {
|
||||||
|
case 0:
|
||||||
|
return (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Excel 檔案匯入
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
請下載模板,填入待辦事項資料後上傳
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', mb: 4 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Download />}
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
sx={{ px: 3 }}
|
||||||
|
>
|
||||||
|
下載模板
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: actualTheme === 'dark' ? '#374151' : '#d1d5db',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
backgroundColor: actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.05)' : 'rgba(59, 130, 246, 0.02)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => document.getElementById('file-input')?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
<CloudUpload sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
點擊上傳檔案
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
支援 .xlsx, .xls, .csv 格式
|
||||||
|
</Typography>
|
||||||
|
{file && (
|
||||||
|
<Typography variant="body2" sx={{ mt: 2, fontWeight: 600 }}>
|
||||||
|
已選擇: {file.name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
預覽資料 ({parsedData.length} 筆)
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{parseErrors.length > 0 && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2">解析警告:</Typography>
|
||||||
|
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||||
|
{parseErrors.map((error, index) => (
|
||||||
|
<li key={index}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} sx={{ maxHeight: 400, mb: 2 }}>
|
||||||
|
<Table stickyHeader size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>行</TableCell>
|
||||||
|
<TableCell>標題</TableCell>
|
||||||
|
<TableCell>狀態</TableCell>
|
||||||
|
<TableCell>優先級</TableCell>
|
||||||
|
<TableCell>到期日</TableCell>
|
||||||
|
<TableCell>負責人</TableCell>
|
||||||
|
<TableCell>追蹤人</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{parsedData.map((todo, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>{todo.row}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{todo.title}
|
||||||
|
</Typography>
|
||||||
|
{todo.description && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{todo.description.substring(0, 50)}{todo.description.length > 50 ? '...' : ''}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={todo.status}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: `${getStatusColor(todo.status)}15`,
|
||||||
|
color: getStatusColor(todo.status),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={todo.priority}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: `${getPriorityColor(todo.priority)}15`,
|
||||||
|
color: getPriorityColor(todo.priority),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{todo.due_date || '-'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{todo.responsible_users.join(', ') || '-'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{todo.followers.join(', ') || '-'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<CheckCircle sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
匯入完成!
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{importErrors.length > 0 && (
|
||||||
|
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="subtitle2">部分資料匯入失敗:</Typography>
|
||||||
|
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||||
|
{importErrors.map((error, index) => (
|
||||||
|
<li key={index}>第 {error.row} 行: {error.error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="lg"
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
'& .MuiDialog-paper': {
|
||||||
|
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="h6">Excel 匯入</Typography>
|
||||||
|
<Stepper activeStep={activeStep} sx={{ flex: 1, mx: 4 }}>
|
||||||
|
{steps.map((label) => (
|
||||||
|
<Step key={label}>
|
||||||
|
<StepLabel>{label}</StepLabel>
|
||||||
|
</Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={activeStep}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{renderStepContent()}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>取消</Button>
|
||||||
|
{activeStep === 1 && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={loading || parsedData.length === 0}
|
||||||
|
startIcon={<CloudUpload />}
|
||||||
|
>
|
||||||
|
匯入資料
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{activeStep === 2 && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleClose}
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExcelImport;
|
Reference in New Issue
Block a user