From 8185b609f7339f57e949d0445259ec3316d09da9 Mon Sep 17 00:00:00 2001 From: beabigegg Date: Mon, 1 Sep 2025 09:16:14 +0800 Subject: [PATCH] 4th --- backend/routes/excel.py | 26 +- backend/utils/ldap_utils.py | 46 +- frontend/src/app/todos/page.tsx | 68 ++- frontend/src/components/todos/ExcelImport.tsx | 444 ++++++++++++++++++ 4 files changed, 545 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/todos/ExcelImport.tsx diff --git a/backend/routes/excel.py b/backend/routes/excel.py index 5e56bd3..ae69d47 100644 --- a/backend/routes/excel.py +++ b/backend/routes/excel.py @@ -117,18 +117,18 @@ def upload_excel(): # 狀態 status_mapping = { - '新建': 'NEW', '進行中': 'IN_PROGRESS', '完成': 'DONE', - 'NEW': 'NEW', 'IN_PROGRESS': 'IN_PROGRESS', 'DONE': 'DONE', - '新': 'NEW', '進行': 'IN_PROGRESS', '完': 'DONE' + '新建': 'NEW', '進行中': 'DOING', '完成': 'DONE', '阻塞': 'BLOCKED', + 'NEW': 'NEW', 'DOING': 'DOING', 'DONE': 'DONE', 'BLOCKED': 'BLOCKED', + '新': 'NEW', '進行': 'DOING', '完': 'DONE', '阻': 'BLOCKED' } status_str = str(row.get('狀態', row.get('status', 'NEW'))).strip() status = status_mapping.get(status_str, 'NEW') # 優先級 priority_mapping = { - '高': 'HIGH', '中': 'MEDIUM', '低': 'LOW', - 'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW', - '高優先級': 'HIGH', '中優先級': 'MEDIUM', '低優先級': 'LOW' + '緊急': 'URGENT', '高': 'HIGH', '中': 'MEDIUM', '低': 'LOW', + 'URGENT': 'URGENT', 'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW', + '緊急優先級': 'URGENT', '高優先級': 'HIGH', '中優先級': 'MEDIUM', '低優先級': 'LOW' } priority_str = str(row.get('優先級', row.get('priority', 'MEDIUM'))).strip() priority = priority_mapping.get(priority_str, 'MEDIUM') @@ -356,8 +356,8 @@ def export_todos(): followers = [f.ad_account for f in todo.followers] # 狀態和優先級的中文對應 - status_mapping = {'NEW': '新建', 'IN_PROGRESS': '進行中', 'DONE': '完成'} - priority_mapping = {'HIGH': '高', 'MEDIUM': '中', 'LOW': '低'} + status_mapping = {'NEW': '新建', 'DOING': '進行中', 'DONE': '完成', 'BLOCKED': '阻塞'} + priority_mapping = {'URGENT': '緊急', 'HIGH': '高', 'MEDIUM': '中', 'LOW': '低'} data.append({ '編號': 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'], '追蹤人': ['user3@panjit.com.tw;user4@panjit.com.tw', 'user5@panjit.com.tw'] } @@ -455,8 +455,8 @@ def download_template(): '欄位說明': [ '標題 (必填)', '描述 (選填)', - '狀態: 新建/進行中/完成', - '優先級: 高/中/低', + '狀態: 新建/進行中/完成/阻塞', + '優先級: 緊急/高/中/低', '到期日: YYYY-MM-DD 格式', '負責人: AD帳號,多人用分號分隔', '追蹤人: AD帳號,多人用分號分隔' @@ -464,8 +464,8 @@ def download_template(): '說明': [ '請填入待辦事項的標題', '可選填詳細描述', - '可選填 NEW/IN_PROGRESS/DONE', - '可選填 HIGH/MEDIUM/LOW', + '可選填 NEW/DOING/DONE/BLOCKED', + '可選填 URGENT/HIGH/MEDIUM/LOW', '例如: 2024-12-31', '例如: john@panjit.com.tw', '例如: mary@panjit.com.tw;tom@panjit.com.tw' diff --git a/backend/utils/ldap_utils.py b/backend/utils/ldap_utils.py index d0ce5f1..54873f2 100644 --- a/backend/utils/ldap_utils.py +++ b/backend/utils/ldap_utils.py @@ -154,7 +154,23 @@ def get_user_info(ad_account): return None 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( config['LDAP_SEARCH_BASE'], @@ -170,7 +186,8 @@ def get_user_info(ad_account): return { 'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName 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: @@ -191,13 +208,28 @@ def validate_ad_accounts(ad_accounts): valid_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( config['LDAP_SEARCH_BASE'], search_filter, SUBTREE, - attributes=['sAMAccountName', 'displayName', 'mail'] + attributes=['sAMAccountName', 'displayName', 'mail', 'userPrincipalName'] ) if conn.entries: @@ -205,8 +237,12 @@ def validate_ad_accounts(ad_accounts): valid_accounts[account] = { 'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName 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 diff --git a/frontend/src/app/todos/page.tsx b/frontend/src/app/todos/page.tsx index f8ff8c2..4f92094 100644 --- a/frontend/src/app/todos/page.tsx +++ b/frontend/src/app/todos/page.tsx @@ -22,6 +22,7 @@ import { FilterList, Search, SelectAll, + CloudUpload, } from '@mui/icons-material'; import { motion, AnimatePresence } from 'framer-motion'; import { useTheme } from '@/providers/ThemeProvider'; @@ -32,6 +33,7 @@ import TodoFilters from '@/components/todos/TodoFilters'; import BatchActions from '@/components/todos/BatchActions'; import SearchBar from '@/components/todos/SearchBar'; import TodoDialog from '@/components/todos/TodoDialog'; +import ExcelImport from '@/components/todos/ExcelImport'; import { Todo } from '@/types'; import { todosApi } from '@/lib/api'; import { useSearchParams } from 'next/navigation'; @@ -60,6 +62,7 @@ const TodosPage = () => { const [searchQuery, setSearchQuery] = useState(''); const [showTodoDialog, setShowTodoDialog] = useState(false); const [editingTodo, setEditingTodo] = useState(null); + const [showExcelImport, setShowExcelImport] = useState(false); const [todos, setTodos] = useState([]); const [loading, setLoading] = useState(true); const [currentUser, setCurrentUser] = useState(null); @@ -508,27 +511,43 @@ const TodosPage = () => { - + + + + @@ -779,6 +798,13 @@ const TodosPage = () => { onSave={handleSaveTodo} onTodoCreated={handleTodoCreated} /> + + {/* Excel 匯入對話框 */} + setShowExcelImport(false)} + onImportComplete={handleTodoCreated} + /> ); diff --git a/frontend/src/components/todos/ExcelImport.tsx b/frontend/src/components/todos/ExcelImport.tsx new file mode 100644 index 0000000..2d7cee1 --- /dev/null +++ b/frontend/src/components/todos/ExcelImport.tsx @@ -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 = ({ open, onClose, onImportComplete }) => { + const { actualTheme } = useTheme(); + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [file, setFile] = useState(null); + const [parsedData, setParsedData] = useState([]); + const [parseErrors, setParseErrors] = useState([]); + const [importErrors, setImportErrors] = useState([]); + + 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) => { + 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 ( + + + Excel 檔案匯入 + + + 請下載模板,填入待辦事項資料後上傳 + + + + + + + document.getElementById('file-input')?.click()} + > + + + + 點擊上傳檔案 + + + 支援 .xlsx, .xls, .csv 格式 + + {file && ( + + 已選擇: {file.name} + + )} + + + ); + + case 1: + return ( + + + 預覽資料 ({parsedData.length} 筆) + + + {parseErrors.length > 0 && ( + + 解析警告: +
    + {parseErrors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ )} + + + + + + + 標題 + 狀態 + 優先級 + 到期日 + 負責人 + 追蹤人 + + + + {parsedData.map((todo, index) => ( + + {todo.row} + + + {todo.title} + + {todo.description && ( + + {todo.description.substring(0, 50)}{todo.description.length > 50 ? '...' : ''} + + )} + + + + + + + + + + {todo.due_date || '-'} + + + + + {todo.responsible_users.join(', ') || '-'} + + + + + {todo.followers.join(', ') || '-'} + + + + ))} + +
+
+
+ ); + + case 2: + return ( + + + + 匯入完成! + + + {importErrors.length > 0 && ( + + 部分資料匯入失敗: +
    + {importErrors.map((error, index) => ( +
  • 第 {error.row} 行: {error.error}
  • + ))} +
+
+ )} +
+ ); + + default: + return null; + } + }; + + return ( + + + + Excel 匯入 + + {steps.map((label) => ( + + {label} + + ))} + + + + + + {loading && } + + + {renderStepContent()} + + + + + + + {activeStep === 1 && ( + + )} + {activeStep === 2 && ( + + )} + + + ); +}; + +export default ExcelImport; \ No newline at end of file