This commit is contained in:
beabigegg
2025-08-29 16:25:46 +08:00
commit b0c86302ff
65 changed files with 19786 additions and 0 deletions

184
frontend/.env.example Normal file
View File

@@ -0,0 +1,184 @@
# Frontend Environment Configuration
# 複製此檔案為 .env.local 並填入實際值
# ===========================================
# 基本設定
# ===========================================
# Next.js 環境模式
NODE_ENV=development
NEXT_PUBLIC_APP_NAME="PANJIT Todo List"
NEXT_PUBLIC_APP_VERSION="1.0.0"
# ===========================================
# 後端 API 設定
# ===========================================
# 後端 API 基本網址
NEXT_PUBLIC_API_URL=http://localhost:5000
NEXT_PUBLIC_BACKEND_URL=http://localhost:5000
# API 版本
NEXT_PUBLIC_API_VERSION=v1
# ===========================================
# 認證設定
# ===========================================
# JWT Token 設定
NEXT_PUBLIC_JWT_EXPIRES_IN=7d
NEXT_PUBLIC_REFRESH_TOKEN_EXPIRES_IN=30d
# AD/LDAP 認證設定 (如果需要前端顯示)
NEXT_PUBLIC_AD_DOMAIN=panjit.com.tw
# ===========================================
# 主題與 UI 設定
# ===========================================
# 預設主題模式 (light | dark | system)
NEXT_PUBLIC_DEFAULT_THEME=system
# 主題顏色設定
NEXT_PUBLIC_PRIMARY_COLOR=#3b82f6
NEXT_PUBLIC_SECONDARY_COLOR=#8b5cf6
# UI 設定
NEXT_PUBLIC_SIDEBAR_DEFAULT_COLLAPSED=false
NEXT_PUBLIC_ANIMATION_ENABLED=true
# ===========================================
# 功能開關
# ===========================================
# 功能啟用設定
NEXT_PUBLIC_CALENDAR_VIEW_ENABLED=true
NEXT_PUBLIC_BATCH_OPERATIONS_ENABLED=true
NEXT_PUBLIC_SEARCH_ENABLED=true
NEXT_PUBLIC_ADVANCED_FILTERS_ENABLED=true
NEXT_PUBLIC_EXCEL_IMPORT_ENABLED=true
# 實驗性功能
NEXT_PUBLIC_EXPERIMENTAL_FEATURES=false
NEXT_PUBLIC_DEBUG_MODE=false
# ===========================================
# 分析與監控
# ===========================================
# Google Analytics (如果需要)
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
# Sentry 錯誤監控 (如果需要)
# NEXT_PUBLIC_SENTRY_DSN=https://your-sentry-dsn
# 效能監控
NEXT_PUBLIC_PERFORMANCE_MONITORING=false
# ===========================================
# 郵件與通知設定
# ===========================================
# 郵件服務設定 (顯示用)
NEXT_PUBLIC_SMTP_ENABLED=true
NEXT_PUBLIC_EMAIL_DOMAIN=panjit.com.tw
# 通知設定
NEXT_PUBLIC_PUSH_NOTIFICATIONS=true
NEXT_PUBLIC_EMAIL_NOTIFICATIONS=true
# ===========================================
# 檔案與媒體設定
# ===========================================
# 檔案上傳設定
NEXT_PUBLIC_MAX_FILE_SIZE=10485760 # 10MB
NEXT_PUBLIC_ALLOWED_FILE_TYPES=.xlsx,.xls,.csv
# 頭像設定
NEXT_PUBLIC_AVATAR_MAX_SIZE=2097152 # 2MB
NEXT_PUBLIC_AVATAR_ALLOWED_TYPES=.jpg,.jpeg,.png,.gif
# ===========================================
# 快取與效能
# ===========================================
# API 快取設定
NEXT_PUBLIC_API_CACHE_ENABLED=true
NEXT_PUBLIC_API_CACHE_DURATION=300000 # 5 minutes
# 靜態資源 CDN (生產環境)
# NEXT_PUBLIC_CDN_URL=https://cdn.example.com
# ===========================================
# 本地化設定
# ===========================================
# 語言設定
NEXT_PUBLIC_DEFAULT_LOCALE=zh-TW
NEXT_PUBLIC_SUPPORTED_LOCALES=zh-TW,zh-CN,en-US
# 時區設定
NEXT_PUBLIC_DEFAULT_TIMEZONE=Asia/Taipei
# 日期格式
NEXT_PUBLIC_DATE_FORMAT=YYYY-MM-DD
NEXT_PUBLIC_DATETIME_FORMAT=YYYY-MM-DD HH:mm
NEXT_PUBLIC_TIME_FORMAT=HH:mm
# ===========================================
# 開發工具設定
# ===========================================
# 開發模式設定
NEXT_PUBLIC_DEV_TOOLS=true
NEXT_PUBLIC_MOCK_API=false
# Redux DevTools
NEXT_PUBLIC_REDUX_DEVTOOLS=true
# React Query DevTools
NEXT_PUBLIC_REACT_QUERY_DEVTOOLS=true
# ===========================================
# 安全設定
# ===========================================
# CORS 設定 (僅供參考,實際由後端控制)
NEXT_PUBLIC_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5000
# CSP 設定提示
NEXT_PUBLIC_CSP_ENABLED=false
# ===========================================
# 部署環境特定設定
# ===========================================
# 生產環境設定
# NODE_ENV=production
# NEXT_PUBLIC_API_URL=https://api.yourdomain.com
# 測試環境設定
# NODE_ENV=staging
# NEXT_PUBLIC_API_URL=https://staging-api.yourdomain.com
# ===========================================
# 範例說明
# ===========================================
# 📝 設定指南:
# 1. 複製此檔案為 .env.local
# 2. 根據您的環境修改對應的值
# 3. 確保 .env.local 已加入 .gitignore
# 4. 生產環境使用不同的 API 網址和金鑰
# 🔒 安全提醒:
# - 請勿將包含敏感資訊的 .env.local 提交到版本控制
# - API 金鑰和密碼應該定期更換
# - 生產環境務必使用 HTTPS
# 🚀 效能優化:
# - 生產環境建議啟用 CDN
# - 根據需求調整快取設定
# - 監控和分析工具可選擇性啟用

184
frontend/.env.local Normal file
View File

@@ -0,0 +1,184 @@
# Frontend Environment Configuration
# 複製此檔案為 .env.local 並填入實際值
# ===========================================
# 基本設定
# ===========================================
# Next.js 環境模式
NODE_ENV=development
NEXT_PUBLIC_APP_NAME="PANJIT Todo List"
NEXT_PUBLIC_APP_VERSION="1.0.0"
# ===========================================
# 後端 API 設定
# ===========================================
# 後端 API 基本網址
NEXT_PUBLIC_API_URL=http://localhost:5000
NEXT_PUBLIC_BACKEND_URL=http://localhost:5000
# API 版本
NEXT_PUBLIC_API_VERSION=v1
# ===========================================
# 認證設定
# ===========================================
# JWT Token 設定
NEXT_PUBLIC_JWT_EXPIRES_IN=7d
NEXT_PUBLIC_REFRESH_TOKEN_EXPIRES_IN=30d
# AD/LDAP 認證設定 (如果需要前端顯示)
NEXT_PUBLIC_AD_DOMAIN=panjit.com.tw
# ===========================================
# 主題與 UI 設定
# ===========================================
# 預設主題模式 (light | dark | system)
NEXT_PUBLIC_DEFAULT_THEME=system
# 主題顏色設定
NEXT_PUBLIC_PRIMARY_COLOR=#3b82f6
NEXT_PUBLIC_SECONDARY_COLOR=#8b5cf6
# UI 設定
NEXT_PUBLIC_SIDEBAR_DEFAULT_COLLAPSED=false
NEXT_PUBLIC_ANIMATION_ENABLED=true
# ===========================================
# 功能開關
# ===========================================
# 功能啟用設定
NEXT_PUBLIC_CALENDAR_VIEW_ENABLED=true
NEXT_PUBLIC_BATCH_OPERATIONS_ENABLED=true
NEXT_PUBLIC_SEARCH_ENABLED=true
NEXT_PUBLIC_ADVANCED_FILTERS_ENABLED=true
NEXT_PUBLIC_EXCEL_IMPORT_ENABLED=true
# 實驗性功能
NEXT_PUBLIC_EXPERIMENTAL_FEATURES=false
NEXT_PUBLIC_DEBUG_MODE=false
# ===========================================
# 分析與監控
# ===========================================
# Google Analytics (如果需要)
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
# Sentry 錯誤監控 (如果需要)
# NEXT_PUBLIC_SENTRY_DSN=https://your-sentry-dsn
# 效能監控
NEXT_PUBLIC_PERFORMANCE_MONITORING=false
# ===========================================
# 郵件與通知設定
# ===========================================
# 郵件服務設定 (顯示用)
NEXT_PUBLIC_SMTP_ENABLED=true
NEXT_PUBLIC_EMAIL_DOMAIN=panjit.com.tw
# 通知設定
NEXT_PUBLIC_PUSH_NOTIFICATIONS=true
NEXT_PUBLIC_EMAIL_NOTIFICATIONS=true
# ===========================================
# 檔案與媒體設定
# ===========================================
# 檔案上傳設定
NEXT_PUBLIC_MAX_FILE_SIZE=10485760 # 10MB
NEXT_PUBLIC_ALLOWED_FILE_TYPES=.xlsx,.xls,.csv
# 頭像設定
NEXT_PUBLIC_AVATAR_MAX_SIZE=2097152 # 2MB
NEXT_PUBLIC_AVATAR_ALLOWED_TYPES=.jpg,.jpeg,.png,.gif
# ===========================================
# 快取與效能
# ===========================================
# API 快取設定
NEXT_PUBLIC_API_CACHE_ENABLED=true
NEXT_PUBLIC_API_CACHE_DURATION=300000 # 5 minutes
# 靜態資源 CDN (生產環境)
# NEXT_PUBLIC_CDN_URL=https://cdn.example.com
# ===========================================
# 本地化設定
# ===========================================
# 語言設定
NEXT_PUBLIC_DEFAULT_LOCALE=zh-TW
NEXT_PUBLIC_SUPPORTED_LOCALES=zh-TW,zh-CN,en-US
# 時區設定
NEXT_PUBLIC_DEFAULT_TIMEZONE=Asia/Taipei
# 日期格式
NEXT_PUBLIC_DATE_FORMAT=YYYY-MM-DD
NEXT_PUBLIC_DATETIME_FORMAT=YYYY-MM-DD HH:mm
NEXT_PUBLIC_TIME_FORMAT=HH:mm
# ===========================================
# 開發工具設定
# ===========================================
# 開發模式設定
NEXT_PUBLIC_DEV_TOOLS=true
NEXT_PUBLIC_MOCK_API=false
# Redux DevTools
NEXT_PUBLIC_REDUX_DEVTOOLS=true
# React Query DevTools
NEXT_PUBLIC_REACT_QUERY_DEVTOOLS=true
# ===========================================
# 安全設定
# ===========================================
# CORS 設定 (僅供參考,實際由後端控制)
NEXT_PUBLIC_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5000
# CSP 設定提示
NEXT_PUBLIC_CSP_ENABLED=false
# ===========================================
# 部署環境特定設定
# ===========================================
# 生產環境設定
# NODE_ENV=production
# NEXT_PUBLIC_API_URL=https://api.yourdomain.com
# 測試環境設定
# NODE_ENV=staging
# NEXT_PUBLIC_API_URL=https://staging-api.yourdomain.com
# ===========================================
# 範例說明
# ===========================================
# 📝 設定指南:
# 1. 複製此檔案為 .env.local
# 2. 根據您的環境修改對應的值
# 3. 確保 .env.local 已加入 .gitignore
# 4. 生產環境使用不同的 API 網址和金鑰
# 🔒 安全提醒:
# - 請勿將包含敏感資訊的 .env.local 提交到版本控制
# - API 金鑰和密碼應該定期更換
# - 生產環境務必使用 HTTPS
# 🚀 效能優化:
# - 生產環境建議啟用 CDN
# - 根據需求調整快取設定
# - 監控和分析工具可選擇性啟用

43
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

22
frontend/next.config.js Normal file
View File

@@ -0,0 +1,22 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: 'standalone',
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000',
},
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/:path*`,
},
]
},
images: {
domains: ['localhost'],
},
}
module.exports = nextConfig

7808
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
frontend/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "todo-system-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.3",
"@mui/material": "^5.15.3",
"@mui/x-date-pickers": "^6.19.0",
"@reduxjs/toolkit": "^2.0.1",
"@tanstack/react-query": "^5.17.9",
"axios": "^1.6.5",
"dayjs": "^1.11.10",
"framer-motion": "^10.18.0",
"next": "14.0.4",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-hot-toast": "^2.4.1",
"react-redux": "^9.0.4",
"recharts": "^2.10.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^20.10.6",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-next": "14.0.4",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,182 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Skeleton,
Alert,
} from '@mui/material';
import { motion } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import DashboardLayout from '@/components/layout/DashboardLayout';
import CalendarView from '@/components/todos/CalendarView';
import { Todo } from '@/types';
import { todosApi } from '@/lib/api';
const CalendarPage: React.FC = () => {
const { actualTheme } = useTheme();
const [todos, setTodos] = useState<Todo[]>([]);
const [selectedTodos, setSelectedTodos] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchTodos = async () => {
try {
setLoading(true);
setError(null);
const token = localStorage.getItem('access_token');
if (!token) {
setTodos([]);
setLoading(false);
return;
}
const response = await todosApi.getTodos({ view: 'all' });
setTodos(response.todos || []);
} catch (error) {
console.error('Failed to fetch todos:', error);
setError('無法載入待辦事項,請重新整理頁面');
setTodos([]);
} finally {
setLoading(false);
}
};
fetchTodos();
}, []);
const handleSelectionChange = (selected: string[]) => {
setSelectedTodos(selected);
};
const handleEditTodo = (todo: Todo) => {
// TODO: 實作編輯功能,可以開啟編輯對話框或導航到編輯頁面
console.log('Edit todo:', todo);
};
if (loading) {
return (
<DashboardLayout>
<Box>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Box sx={{ mb: 3 }}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 1,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</Box>
{/* Loading Skeleton */}
<Paper
sx={{
p: 3,
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
mb: 3,
}}
>
<Skeleton variant="rectangular" height={60} sx={{ mb: 2, borderRadius: 1 }} />
</Paper>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 1 }}>
{Array.from({ length: 35 }).map((_, index) => (
<Skeleton
key={index}
variant="rectangular"
height={120}
sx={{ borderRadius: 1 }}
/>
))}
</Box>
</motion.div>
</Box>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Box>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Box sx={{ mb: 3 }}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 1,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</Box>
{error && (
<Alert
severity="error"
sx={{ mb: 3 }}
onClose={() => setError(null)}
>
{error}
</Alert>
)}
{selectedTodos.length > 0 && (
<Alert
severity="info"
sx={{ mb: 3 }}
onClose={() => setSelectedTodos([])}
>
{selectedTodos.length}
</Alert>
)}
<CalendarView
todos={todos}
selectedTodos={selectedTodos}
onSelectionChange={handleSelectionChange}
onEditTodo={handleEditTodo}
/>
</motion.div>
</Box>
</DashboardLayout>
);
};
export default CalendarPage;

View File

@@ -0,0 +1,544 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Grid,
Card,
CardContent,
Typography,
Chip,
Button,
Avatar,
AvatarGroup,
IconButton,
} from '@mui/material';
import {
Assignment,
Schedule,
CheckCircle,
Warning,
Add,
CalendarToday,
Star,
People,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import DashboardLayout from '@/components/layout/DashboardLayout';
import TodoDialog from '@/components/todos/TodoDialog';
import { todosApi } from '@/lib/api';
import { Todo } from '@/types';
const DashboardPage = () => {
const { actualTheme } = useTheme();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [todoDialogOpen, setTodoDialogOpen] = useState(false);
// 從 API 獲取資料
useEffect(() => {
const fetchDashboardData = async () => {
try {
setLoading(true);
// 檢查是否有有效的 token
const token = localStorage.getItem('access_token');
if (!token) {
console.log('No access token found, skipping API call');
setTodos([]);
return;
}
const response = await todosApi.getTodos({ view: 'all' });
setTodos(response.todos || []);
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
// 如果是認證錯誤,清除 token 並跳轉到登入頁
if (error.response?.status === 401) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/login';
}
setTodos([]);
} finally {
setLoading(false);
}
};
fetchDashboardData();
}, []);
const handleTodoCreated = async () => {
setTodoDialogOpen(false);
// 重新載入待辦事項資料
try {
console.log('Refreshing dashboard data after todo creation...');
const token = localStorage.getItem('access_token');
if (token) {
const response = await todosApi.getTodos({ view: 'all' });
console.log('Updated todos:', response.todos?.length || 0, 'items');
setTodos(response.todos || []);
}
} catch (error) {
console.error('Failed to refresh dashboard data:', error);
}
};
// 計算統計數據
const stats = {
total: todos.length,
doing: todos.filter(todo => todo.status === 'DOING').length,
completed: todos.filter(todo => todo.status === 'DONE').length,
overdue: todos.filter(todo => {
if (!todo.due_date) return false;
const dueDate = new Date(todo.due_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
return dueDate < today && todo.status !== 'DONE';
}).length,
};
// 最近的待辦事項最多顯示3個
const recentTodos = todos
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 3)
.map(todo => ({
id: todo.id,
title: todo.title,
dueDate: todo.due_date ? new Date(todo.due_date).toLocaleDateString('zh-TW') : '無截止日期',
priority: todo.priority,
status: todo.status,
assignees: (todo.responsible_users_details || todo.responsible_users || []).map(user =>
typeof user === 'string'
? user.substring(0, 1).toUpperCase()
: (user.display_name || user.ad_account).substring(0, 1).toUpperCase()
),
}));
// 即將到期的項目
const upcomingDeadlines = todos
.filter(todo => {
if (!todo.due_date || todo.status === 'DONE') return false;
const dueDate = new Date(todo.due_date);
const today = new Date();
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(today.getDate() + 3);
return dueDate >= today && dueDate <= threeDaysFromNow;
})
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())
.slice(0, 3)
.map(todo => {
const dueDate = new Date(todo.due_date!);
const today = new Date();
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
let dateText = '';
if (diffDays === 0) dateText = '今天';
else if (diffDays === 1) dateText = '明天';
else dateText = dueDate.toLocaleDateString('zh-TW');
return {
title: todo.title,
date: dateText,
urgent: diffDays <= 1,
};
});
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
};
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 getStatusColor = (status: string) => {
switch (status) {
case 'NEW': return '#6b7280';
case 'DOING': return '#3b82f6';
case 'BLOCKED': return '#ef4444';
case 'DONE': return '#10b981';
default: return '#6b7280';
}
};
return (
<DashboardLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<Box sx={{ mb: 4 }}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 1,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</Box>
{/* 統計卡片 */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography color="text.secondary" gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{stats.total}
</Typography>
</Box>
<Assignment sx={{ fontSize: 40, color: '#6b7280', opacity: 0.8 }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%)'
: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.doing}
</Typography>
</Box>
<Schedule sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #065f46 0%, #10b981 100%)'
: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(16, 185, 129, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.completed}
</Typography>
</Box>
<CheckCircle sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #991b1b 0%, #ef4444 100%)'
: 'linear-gradient(135deg, #ef4444 0%, #f87171 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(239, 68, 68, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.overdue}
</Typography>
</Box>
<Warning sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
</Grid>
{/* 主要內容區域 */}
<Grid container spacing={3}>
{/* 最近待辦 */}
<Grid item xs={12} lg={8}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
<Button
startIcon={<Add />}
variant="contained"
size="small"
onClick={() => setTodoDialogOpen(true)}
sx={{
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
textTransform: 'none',
}}
>
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{recentTodos.map((todo, index) => (
<motion.div
key={todo.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
>
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
transition: 'all 0.2s ease',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.04)',
transform: 'translateX(4px)',
},
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, flex: 1 }}>
{todo.title}
</Typography>
<Box sx={{ display: 'flex', gap: 1, ml: 2 }}>
<Chip
label={todo.priority}
size="small"
sx={{
backgroundColor: `${getPriorityColor(todo.priority)}15`,
color: getPriorityColor(todo.priority),
fontWeight: 600,
fontSize: '0.75rem',
}}
/>
<Chip
label={todo.status}
size="small"
sx={{
backgroundColor: `${getStatusColor(todo.status)}15`,
color: getStatusColor(todo.status),
fontWeight: 600,
fontSize: '0.75rem',
}}
/>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CalendarToday sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography variant="body2" color="text.secondary">
{todo.dueDate}
</Typography>
</Box>
<AvatarGroup max={3} sx={{ '& .MuiAvatar-root': { width: 28, height: 28, fontSize: '0.75rem' } }}>
{todo.assignees.map((assignee, idx) => (
<Avatar key={idx} sx={{ backgroundColor: 'primary.main' }}>
{assignee}
</Avatar>
))}
</AvatarGroup>
</Box>
</Box>
</motion.div>
))}
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
{/* 右側面板 */}
<Grid item xs={12} lg={4}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* 即將到期 */}
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{upcomingDeadlines.map((item, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1.5,
borderRadius: 1.5,
backgroundColor: item.urgent
? (actualTheme === 'dark' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(239, 68, 68, 0.05)')
: (actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)'),
border: item.urgent
? `1px solid ${actualTheme === 'dark' ? 'rgba(239, 68, 68, 0.3)' : 'rgba(239, 68, 68, 0.2)'}`
: `1px solid transparent`,
}}
>
<Box sx={{ flex: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: item.urgent ? '#ef4444' : 'text.primary',
}}
>
{item.title}
</Typography>
<Typography
variant="caption"
sx={{
color: item.urgent ? '#ef4444' : 'text.secondary',
}}
>
{item.date}
</Typography>
</Box>
{item.urgent && (
<Warning sx={{ color: '#ef4444', fontSize: 20 }} />
)}
</Box>
))}
</Box>
</CardContent>
</Card>
</motion.div>
</Box>
</Grid>
</Grid>
</motion.div>
{/* 新增待辦對話框 */}
<TodoDialog
open={todoDialogOpen}
onClose={() => setTodoDialogOpen(false)}
onTodoCreated={handleTodoCreated}
/>
</DashboardLayout>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,207 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-400 dark:bg-gray-500 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-500 dark:bg-gray-400;
}
/* Loading animations */
.loading-skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
.dark .loading-skeleton {
background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
background-size: 200% 100%;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Focus rings */
.focus-ring {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-800;
}
/* Button variants */
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
.btn-ghost {
@apply hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
/* Card styles */
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6;
}
.card-compact {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4;
}
/* Input styles */
.input {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus-ring;
}
.input-error {
@apply border-red-500 focus-visible:ring-red-500;
}
/* Status colors */
.status-new {
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300;
}
.status-doing {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300;
}
.status-blocked {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
}
.status-done {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
}
/* Priority colors */
.priority-low {
@apply bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300;
}
.priority-medium {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300;
}
.priority-high {
@apply bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300;
}
.priority-urgent {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
}
/* Animations */
.slide-in-right {
animation: slideInRight 0.3s ease-out;
}
.slide-in-left {
animation: slideInLeft 0.3s ease-out;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Custom utilities */
.text-balance {
text-wrap: balance;
}
.truncate-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.truncate-3 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
body {
background: white !important;
color: black !important;
}
.card {
border: 1px solid #ccc !important;
box-shadow: none !important;
}
}

View File

@@ -0,0 +1,40 @@
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import { Providers } from '@/providers';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'PANJIT To-Do System',
description: '專業待辦事項管理系統,支援多負責人協作、智能提醒與進度追蹤',
keywords: ['待辦事項', '任務管理', 'PANJIT', '協作工具'],
authors: [{ name: 'PANJIT IT Team' }],
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#111827' },
],
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-TW" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
{children}
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,358 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Box,
Card,
CardContent,
TextField,
Button,
Typography,
InputAdornment,
IconButton,
Fade,
Container,
Alert,
CircularProgress,
} from '@mui/material';
import {
Visibility,
VisibilityOff,
Person,
Lock,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useAuth } from '@/providers/AuthProvider';
import { useTheme } from '@/providers/ThemeProvider';
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login, isAuthenticated } = useAuth();
const { actualTheme } = useTheme();
const router = useRouter();
// 如果已登入,重定向到儀表板
useEffect(() => {
if (isAuthenticated) {
router.push('/dashboard');
}
}, [isAuthenticated, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password.trim()) {
setError('請輸入帳號和密碼');
return;
}
setIsLoading(true);
setError('');
try {
const success = await login(username.trim(), password);
if (success) {
router.push('/dashboard');
}
} catch (err) {
setError('登入失敗,請檢查您的帳號密碼');
} finally {
setIsLoading(false);
}
};
const cardVariants = {
hidden: {
opacity: 0,
y: 50,
scale: 0.95
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.6,
ease: [0.6, -0.05, 0.01, 0.99]
}
}
};
const logoVariants = {
hidden: { opacity: 0, y: -20 },
visible: {
opacity: 1,
y: 0,
transition: {
delay: 0.2,
duration: 0.5
}
}
};
return (
<Box
sx={{
minHeight: '100vh',
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
position: 'relative',
overflow: 'hidden',
}}
>
{/* 背景裝飾 */}
<Box
sx={{
position: 'absolute',
top: '-50%',
right: '-50%',
width: '200%',
height: '200%',
background: actualTheme === 'dark'
? 'radial-gradient(circle, rgba(96, 165, 250, 0.05) 0%, transparent 70%)'
: 'radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%)',
animation: 'float 20s ease-in-out infinite',
}}
/>
<Container maxWidth="sm">
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
>
<Card
elevation={24}
sx={{
backdropFilter: 'blur(20px)',
backgroundColor: actualTheme === 'dark'
? 'rgba(31, 41, 55, 0.8)'
: 'rgba(255, 255, 255, 0.9)',
border: actualTheme === 'dark'
? '1px solid rgba(255, 255, 255, 0.1)'
: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: 4,
overflow: 'hidden',
}}
>
<CardContent sx={{ p: 6 }}>
{/* Logo 區域 */}
<motion.div
variants={logoVariants}
initial="hidden"
animate="visible"
>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box
component="img"
src="/panjit-logo.png"
alt="PANJIT Logo"
sx={{
width: 180,
height: 180,
mb: 2,
filter: 'drop-shadow(0 4px 8px rgba(59, 130, 246, 0.3))'
}}
/>
<Typography
variant="h4"
component="h1"
gutterBottom
sx={{
fontWeight: 700,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
To-Do
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{
opacity: 0.8,
fontSize: '1.1rem',
}}
>
</Typography>
</Box>
</motion.div>
{/* 登入表單 */}
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<Fade in={!!error} timeout={300}>
<Box sx={{ mb: 2 }}>
{error && (
<Alert
severity="error"
sx={{
borderRadius: 2,
'& .MuiAlert-message': {
fontSize: '0.9rem'
}
}}
>
{error}
</Alert>
)}
</Box>
</Fade>
<TextField
fullWidth
label="AD 帳號"
value={username}
onChange={(e) => setUsername(e.target.value)}
margin="normal"
disabled={isLoading}
autoComplete="username"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Person color="action" />
</InputAdornment>
),
}}
sx={{
mb: 3,
'& .MuiOutlinedInput-root': {
borderRadius: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
'&.Mui-focused': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.2)',
}
}
}}
/>
<TextField
fullWidth
label="密碼"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
disabled={isLoading}
autoComplete="current-password"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock color="action" />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
disabled={isLoading}
size="small"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
sx={{
mb: 4,
'& .MuiOutlinedInput-root': {
borderRadius: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
'&.Mui-focused': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.2)',
}
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={isLoading}
sx={{
py: 2,
fontSize: '1.1rem',
fontWeight: 600,
borderRadius: 2,
textTransform: 'none',
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.3)',
transition: 'all 0.3s ease',
'&:hover': {
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
boxShadow: '0 6px 25px rgba(59, 130, 246, 0.4)',
transform: 'translateY(-2px)',
},
'&:disabled': {
background: 'linear-gradient(45deg, #9ca3af 30%, #9ca3af 90%)',
}
}}
>
{isLoading ? (
<>
<CircularProgress size={24} sx={{ mr: 2, color: 'white' }} />
...
</>
) : (
'登入系統'
)}
</Button>
</Box>
{/* 底部資訊 */}
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
opacity: 0.7,
fontSize: '0.8rem'
}}
>
使 AD
</Typography>
</Box>
</CardContent>
</Card>
</motion.div>
</Container>
<style jsx global>{`
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
33% { transform: translateY(-30px) rotate(120deg); }
66% { transform: translateY(-20px) rotate(240deg); }
}
`}</style>
</Box>
);
};
export default LoginPage;

41
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Box, CircularProgress, Typography } from '@mui/material';
export default function HomePage() {
const router = useRouter();
useEffect(() => {
// 檢查是否已登入
const token = localStorage.getItem('access_token');
if (token) {
// 如果已登入,跳轉到 dashboard
router.replace('/dashboard');
} else {
// 如果未登入,跳轉到登入頁面
router.replace('/login');
}
}, [router]);
// 顯示載入中的畫面
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="text.secondary">
PANJIT Todo List...
</Typography>
</Box>
);
}

View File

@@ -0,0 +1,764 @@
'use client';
import React, { useState } from 'react';
import {
Box,
Typography,
Card,
CardContent,
Switch,
FormControlLabel,
Button,
Divider,
Avatar,
TextField,
IconButton,
Chip,
Grid,
Paper,
Alert,
Snackbar,
FormControl,
InputLabel,
Select,
MenuItem,
Slider,
} from '@mui/material';
import {
Person,
Palette,
Notifications,
Security,
Language,
Save,
Edit,
PhotoCamera,
DarkMode,
LightMode,
SettingsBrightness,
VolumeUp,
Email,
Sms,
Phone,
Schedule,
Visibility,
Lock,
Key,
Shield,
Refresh,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import { useSearchParams } from 'next/navigation';
import DashboardLayout from '@/components/layout/DashboardLayout';
const SettingsPage = () => {
const { themeMode, setThemeMode, actualTheme } = useTheme();
const searchParams = useSearchParams();
const [showSuccess, setShowSuccess] = useState(false);
const [activeTab, setActiveTab] = useState(() => {
const tabParam = searchParams.get('tab');
return tabParam || 'profile';
});
// 用戶設定
const [userSettings, setUserSettings] = useState(() => {
try {
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
return {
name: user.display_name || user.ad_account || '',
email: user.email || '',
department: '資訊部',
position: '員工',
phone: '',
bio: '',
avatar: (user.display_name || user.ad_account || 'U').charAt(0).toUpperCase(),
};
}
} catch (error) {
console.error('Failed to parse user from localStorage:', error);
}
return {
name: '',
email: '',
department: '資訊部',
position: '員工',
phone: '',
bio: '',
avatar: 'U',
};
});
// 通知設定
const [notificationSettings, setNotificationSettings] = useState({
emailNotifications: true,
pushNotifications: true,
smsNotifications: false,
todoReminders: true,
deadlineAlerts: true,
weeklyReports: true,
soundEnabled: true,
soundVolume: 70,
});
// 隱私設定
const [privacySettings, setPrivacySettings] = useState({
profileVisibility: 'team',
todoVisibility: 'responsible',
showOnlineStatus: true,
allowDirectMessages: true,
dataSharing: false,
});
// 工作設定
const [workSettings, setWorkSettings] = useState({
timeZone: 'Asia/Taipei',
dateFormat: 'YYYY-MM-DD',
timeFormat: '24h',
workingHours: {
start: '09:00',
end: '18:00',
},
autoRefresh: 30,
defaultView: 'list',
});
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
};
const handleSave = () => {
console.log('Settings saved:', {
user: userSettings,
notifications: notificationSettings,
privacy: privacySettings,
work: workSettings,
});
setShowSuccess(true);
};
const themeOptions = [
{ value: 'light', label: '亮色模式', icon: <LightMode /> },
{ value: 'dark', label: '深色模式', icon: <DarkMode /> },
{ value: 'system', label: '跟隨系統', icon: <SettingsBrightness /> },
];
const tabs = [
{ id: 'profile', label: '個人資料', icon: <Person /> },
{ id: 'appearance', label: '外觀主題', icon: <Palette /> },
{ id: 'notifications', label: '通知設定', icon: <Notifications /> },
{ id: 'privacy', label: '隱私安全', icon: <Security /> },
{ id: 'work', label: '工作偏好', icon: <Schedule /> },
];
const renderProfileSettings = () => (
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Person sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
{/* 頭像區域 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 4 }}>
<Box sx={{ position: 'relative' }}>
<Avatar
sx={{
width: 80,
height: 80,
fontSize: '2rem',
fontWeight: 700,
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
}}
>
{userSettings.avatar}
</Avatar>
<IconButton
sx={{
position: 'absolute',
bottom: -5,
right: -5,
backgroundColor: 'primary.main',
color: 'white',
width: 32,
height: 32,
'&:hover': {
backgroundColor: 'primary.dark',
},
}}
>
<PhotoCamera sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
{userSettings.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{userSettings.position} · {userSettings.department}
</Typography>
<Chip
label="已驗證"
color="success"
size="small"
icon={<Shield sx={{ fontSize: 14 }} />}
/>
</Box>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="姓名"
value={userSettings.name}
onChange={(e) => setUserSettings(prev => ({ ...prev, name: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="電子信箱"
value={userSettings.email}
onChange={(e) => setUserSettings(prev => ({ ...prev, email: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="部門"
value={userSettings.department}
onChange={(e) => setUserSettings(prev => ({ ...prev, department: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="職位"
value={userSettings.position}
onChange={(e) => setUserSettings(prev => ({ ...prev, position: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="電話號碼"
value={userSettings.phone}
onChange={(e) => setUserSettings(prev => ({ ...prev, phone: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
multiline
rows={3}
label="個人簡介"
placeholder="簡單介紹一下自己..."
value={userSettings.bio}
onChange={(e) => setUserSettings(prev => ({ ...prev, bio: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
);
const renderAppearanceSettings = () => (
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Palette sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
</Typography>
<Grid container spacing={3}>
{themeOptions.map((option) => (
<Grid item xs={12} sm={4} key={option.value}>
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Paper
onClick={() => setThemeMode(option.value as 'light' | 'dark' | 'auto')}
sx={{
p: 3,
cursor: 'pointer',
textAlign: 'center',
backgroundColor: themeMode === option.value
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(59, 130, 246, 0.1)')
: (actualTheme === 'dark' ? '#374151' : '#f9fafb'),
border: themeMode === option.value
? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}`
: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 3,
transition: 'all 0.3s ease',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.1)'
: 'rgba(59, 130, 246, 0.05)',
transform: 'translateY(-2px)',
},
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 2,
color: themeMode === option.value ? 'primary.main' : 'text.secondary',
'& svg': {
fontSize: 40,
},
}}
>
{option.icon}
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 600,
color: themeMode === option.value ? 'primary.main' : 'text.primary',
mb: 1,
}}
>
{option.label}
</Typography>
{themeMode === option.value && (
<Chip
label="已選擇"
color="primary"
size="small"
sx={{ fontWeight: 600 }}
/>
)}
</Paper>
</motion.div>
</Grid>
))}
</Grid>
{/* 預覽區域 */}
<Box sx={{ mt: 4 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
</Typography>
<Paper
sx={{
p: 3,
backgroundColor: actualTheme === 'dark' ? '#374151' : '#f8fafc',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
}}
>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Chip label="進行中" color="primary" size="small" />
<Chip label="高優先級" color="error" size="small" />
<Typography variant="body2" color="text.secondary">
2024-01-15
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{themeMode === 'light' ? '亮色' : themeMode === 'dark' ? '深色' : '系統'}
</Typography>
</Paper>
</Box>
</CardContent>
</Card>
</motion.div>
);
const renderNotificationSettings = () => (
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Notifications sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.emailNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Email sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.pushNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, pushNotifications: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Notifications sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.smsNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, smsNotifications: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Sms sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
</Box>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.todoReminders}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, todoReminders: e.target.checked }))}
color="primary"
/>
}
label="待辦事項提醒"
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.deadlineAlerts}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, deadlineAlerts: e.target.checked }))}
color="primary"
/>
}
label="截止日期警告"
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.weeklyReports}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, weeklyReports: e.target.checked }))}
color="primary"
/>
}
label="每週報告"
/>
</Box>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.soundEnabled}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, soundEnabled: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VolumeUp sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
</Box>
{notificationSettings.soundEnabled && (
<Box sx={{ px: 2, mb: 2 }}>
<Typography variant="caption" color="text.secondary" gutterBottom>
: {notificationSettings.soundVolume}%
</Typography>
<Slider
value={notificationSettings.soundVolume}
onChange={(_, value) => setNotificationSettings(prev => ({ ...prev, soundVolume: value as number }))}
min={0}
max={100}
step={10}
marks
sx={{ color: 'primary.main' }}
/>
</Box>
)}
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
);
return (
<DashboardLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 標題區域 */}
<Box sx={{ mb: 3 }}>
<motion.div variants={itemVariants}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 0.5,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</motion.div>
</Box>
<Grid container spacing={3}>
{/* 側邊欄 */}
<Grid item xs={12} md={3}>
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<Box sx={{ p: 2 }}>
{tabs.map((tab) => (
<motion.div
key={tab.id}
whileHover={{ x: 4 }}
whileTap={{ scale: 0.98 }}
>
<Button
fullWidth
onClick={() => setActiveTab(tab.id)}
startIcon={tab.icon}
sx={{
justifyContent: 'flex-start',
textTransform: 'none',
fontWeight: activeTab === tab.id ? 600 : 400,
color: activeTab === tab.id ? 'primary.main' : 'text.primary',
backgroundColor: activeTab === tab.id
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
: 'transparent',
borderRadius: 2,
mb: 1,
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.1)'
: 'rgba(59, 130, 246, 0.05)',
},
}}
>
{tab.label}
</Button>
</motion.div>
))}
</Box>
</Card>
</motion.div>
</Grid>
{/* 主要內容 */}
<Grid item xs={12} md={9}>
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'profile' && renderProfileSettings()}
{activeTab === 'appearance' && renderAppearanceSettings()}
{activeTab === 'notifications' && renderNotificationSettings()}
{/* 其他 tab 內容可以在這裡添加 */}
</motion.div>
</AnimatePresence>
{/* 儲存按鈕 */}
<motion.div variants={itemVariants} style={{ marginTop: 24 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button
variant="outlined"
startIcon={<Refresh />}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
}}
>
</Button>
<Button
variant="contained"
startIcon={<Save />}
onClick={handleSave}
sx={{
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
'&:hover': {
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
},
}}
>
</Button>
</Box>
</motion.div>
</Grid>
</Grid>
{/* 成功通知 */}
<Snackbar
open={showSuccess}
autoHideDuration={3000}
onClose={() => setShowSuccess(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="success"
onClose={() => setShowSuccess(false)}
sx={{
borderRadius: 2,
fontWeight: 600,
}}
>
</Alert>
</Snackbar>
</motion.div>
</DashboardLayout>
);
};
export default SettingsPage;

View File

@@ -0,0 +1,611 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
IconButton,
Toolbar,
Tooltip,
Fade,
Chip,
Card,
} from '@mui/material';
import {
Add,
ViewList,
CalendarViewMonth,
FilterList,
Sort,
Search,
SelectAll,
MoreVert,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import DashboardLayout from '@/components/layout/DashboardLayout';
import TodoList from '@/components/todos/TodoList';
import CalendarView from '@/components/todos/CalendarView';
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 { Todo } from '@/types';
import { todosApi } from '@/lib/api';
type ViewMode = 'list' | 'calendar';
type FilterMode = 'all' | 'created' | 'responsible' | 'following';
const TodosPage = () => {
const { actualTheme } = useTheme();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [showFilters, setShowFilters] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [selectedTodos, setSelectedTodos] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [showTodoDialog, setShowTodoDialog] = useState(false);
const [editingTodo, setEditingTodo] = useState<any>(null);
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [currentUser, setCurrentUser] = useState<any>(null);
// 從 API 獲取資料
useEffect(() => {
const fetchTodos = async () => {
try {
setLoading(true);
// 檢查是否有有效的 token
const token = localStorage.getItem('access_token');
if (!token) {
console.log('No access token found, skipping API call');
setTodos([]);
return;
}
// 獲取當前用戶信息
try {
const userResponse = await fetch('http://localhost:5000/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (userResponse.ok) {
const userData = await userResponse.json();
setCurrentUser(userData);
}
} catch (userError) {
console.warn('Failed to fetch user data:', userError);
}
// 獲取待辦事項
const response = await todosApi.getTodos({
view: filterMode === 'all' ? 'all' : filterMode
});
setTodos(response.todos || []);
} catch (error) {
console.error('Failed to fetch todos:', error);
// 如果是認證錯誤,清除 token 並跳轉到登入頁
if (error.response?.status === 401) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/login';
}
setTodos([]);
} finally {
setLoading(false);
}
};
fetchTodos();
}, [filterMode]);
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
};
const filteredTodos = todos.filter(todo => {
// 搜尋過濾
if (searchQuery) {
const query = searchQuery.toLowerCase();
if (!todo.title.toLowerCase().includes(query) &&
!todo.description?.toLowerCase().includes(query)) {
return false;
}
}
// 視圖過濾
if (currentUser) {
switch (filterMode) {
case 'created':
return todo.creator_ad === currentUser.ad_account;
case 'responsible':
return todo.responsible_users?.includes(currentUser.ad_account) || false;
case 'following':
return todo.followers?.includes(currentUser.ad_account) || false;
default:
return true;
}
}
return true;
});
const getFilterModeLabel = (mode: FilterMode) => {
switch (mode) {
case 'created': return '我建立的';
case 'responsible': return '指派給我';
case 'following': return '我追蹤的';
default: return '所有待辦';
}
};
const handleSelectAll = () => {
if (selectedTodos.length === filteredTodos.length) {
setSelectedTodos([]);
} else {
setSelectedTodos(filteredTodos.map(todo => todo.id));
}
};
const handleCreateTodo = () => {
setEditingTodo(null);
setShowTodoDialog(true);
};
const handleEditTodo = (todo: any) => {
setEditingTodo(todo);
setShowTodoDialog(true);
};
const handleSaveTodo = (todoData: any) => {
console.log('Saving todo:', todoData);
// 這裡會調用 API 來儲存待辦事項
// 儲存成功後可以更新 todos 列表
};
const handleCloseTodoDialog = () => {
setShowTodoDialog(false);
setEditingTodo(null);
};
const handleTodoCreated = async () => {
// 刷新待辦事項列表
try {
const response = await todosApi.getTodos({
view: filterMode === 'all' ? 'all' : filterMode
});
setTodos(response.todos || []);
} catch (error) {
console.error('Failed to refresh todos:', error);
}
};
// 批次操作處理函數
const handleBulkStatusChange = async (status: 'NEW' | 'DOING' | 'BLOCKED') => {
try {
if (selectedTodos.length === 0) return;
// 使用批次更新 API
await todosApi.batchUpdateTodos(selectedTodos, { status });
// 更新本地狀態
setTodos(prevTodos =>
prevTodos.map(todo =>
selectedTodos.includes(todo.id)
? { ...todo, status }
: todo
)
);
// 清除選擇
setSelectedTodos([]);
console.log(`批次更新 ${selectedTodos.length} 個待辦事項狀態為 ${status}`);
} catch (error) {
console.error('批次狀態更新失敗:', error);
}
};
const handleBulkComplete = async () => {
try {
if (selectedTodos.length === 0) return;
// 使用批次更新 API 設為完成
await todosApi.batchUpdateTodos(selectedTodos, { status: 'DONE' });
// 更新本地狀態
setTodos(prevTodos =>
prevTodos.map(todo =>
selectedTodos.includes(todo.id)
? { ...todo, status: 'DONE', completed_at: new Date().toISOString() }
: todo
)
);
// 清除選擇
setSelectedTodos([]);
console.log(`批次完成 ${selectedTodos.length} 個待辦事項`);
} catch (error) {
console.error('批次完成失敗:', error);
}
};
const handleBulkDelete = async () => {
try {
if (selectedTodos.length === 0) return;
if (!confirm(`確定要刪除 ${selectedTodos.length} 個待辦事項嗎?此操作無法復原。`)) {
return;
}
// 逐一刪除待辦事項(如果沒有批次刪除 API
for (const todoId of selectedTodos) {
await todosApi.deleteTodo(todoId);
}
// 從本地狀態中移除
setTodos(prevTodos =>
prevTodos.filter(todo => !selectedTodos.includes(todo.id))
);
// 清除選擇
setSelectedTodos([]);
console.log(`批次刪除 ${selectedTodos.length} 個待辦事項`);
} catch (error) {
console.error('批次刪除失敗:', error);
}
};
// 單個待辦事項狀態變更處理函數
const handleStatusChange = async (todoId: string, status: string) => {
try {
// 使用 API 更新單個待辦事項的狀態
await todosApi.updateTodo(todoId, { status });
// 更新本地狀態
setTodos(prevTodos =>
prevTodos.map(todo =>
todo.id === todoId
? {
...todo,
status,
completed_at: status === 'DONE' ? new Date().toISOString() : null
}
: todo
)
);
console.log(`待辦事項 ${todoId} 狀態已更新為 ${status}`);
} catch (error) {
console.error('狀態更新失敗:', error);
}
};
return (
<DashboardLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 標題區域 */}
<Box sx={{ mb: 3 }}>
<motion.div variants={itemVariants}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 0.5,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body1" color="text.secondary">
{getFilterModeLabel(filterMode)} · {filteredTodos.length}
</Typography>
{selectedTodos.length > 0 && (
<Chip
label={`已選擇 ${selectedTodos.length}`}
color="primary"
size="small"
sx={{ fontWeight: 600 }}
/>
)}
</Box>
</Box>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleCreateTodo}
sx={{
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>
</motion.div>
</Box>
{/* 工具列 */}
<motion.div variants={itemVariants}>
<Card
sx={{
mb: 3,
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<Toolbar
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 2,
px: { xs: 2, sm: 3 },
py: 1,
}}
>
{/* 左側工具 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* 視圖切換 */}
<Box
sx={{
display: 'flex',
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.04)',
borderRadius: 1.5,
p: 0.5,
}}
>
<Tooltip title="清單視圖">
<IconButton
size="small"
onClick={() => setViewMode('list')}
sx={{
backgroundColor: viewMode === 'list' ? 'primary.main' : 'transparent',
color: viewMode === 'list' ? 'white' : 'text.secondary',
'&:hover': {
backgroundColor: viewMode === 'list' ? 'primary.dark' : 'action.hover',
},
}}
>
<ViewList fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="日曆視圖">
<IconButton
size="small"
onClick={() => setViewMode('calendar')}
sx={{
backgroundColor: viewMode === 'calendar' ? 'primary.main' : 'transparent',
color: viewMode === 'calendar' ? 'white' : 'text.secondary',
'&:hover': {
backgroundColor: viewMode === 'calendar' ? 'primary.dark' : 'action.hover',
},
}}
>
<CalendarViewMonth fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* 篩選器切換 */}
<Box sx={{ display: 'flex', gap: 0.5 }}>
{(['all', 'created', 'responsible', 'following'] as FilterMode[]).map((mode) => (
<Chip
key={mode}
label={getFilterModeLabel(mode)}
variant={filterMode === mode ? 'filled' : 'outlined'}
color={filterMode === mode ? 'primary' : 'default'}
size="small"
clickable
onClick={() => setFilterMode(mode)}
sx={{
fontSize: '0.75rem',
fontWeight: filterMode === mode ? 600 : 400,
'&:hover': {
transform: 'translateY(-1px)',
},
}}
/>
))}
</Box>
</Box>
{/* 右側工具 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title="搜尋">
<IconButton
size="small"
onClick={() => setShowSearch(!showSearch)}
sx={{
color: showSearch ? 'primary.main' : 'text.secondary',
backgroundColor: showSearch
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
: 'transparent',
}}
>
<Search fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="篩選">
<IconButton
size="small"
onClick={() => setShowFilters(!showFilters)}
sx={{
color: showFilters ? 'primary.main' : 'text.secondary',
backgroundColor: showFilters
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
: 'transparent',
}}
>
<FilterList fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="排序">
<IconButton size="small" sx={{ color: 'text.secondary' }}>
<Sort fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="全選">
<IconButton
size="small"
onClick={handleSelectAll}
sx={{
color: selectedTodos.length > 0 ? 'primary.main' : 'text.secondary',
}}
>
<SelectAll fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="更多選項">
<IconButton size="small" sx={{ color: 'text.secondary' }}>
<MoreVert fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Toolbar>
</Card>
</motion.div>
{/* 搜尋列 */}
<AnimatePresence>
{showSearch && (
<motion.div
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
animate={{ opacity: 1, height: 'auto', marginBottom: 24 }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3 }}
>
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
onClose={() => setShowSearch(false)}
/>
</motion.div>
)}
</AnimatePresence>
{/* 進階篩選 */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
animate={{ opacity: 1, height: 'auto', marginBottom: 24 }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3 }}
>
<TodoFilters onClose={() => setShowFilters(false)} />
</motion.div>
)}
</AnimatePresence>
{/* 批次操作工具列 */}
<AnimatePresence>
{selectedTodos.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
>
<BatchActions
selectedCount={selectedTodos.length}
onClearSelection={() => setSelectedTodos([])}
onBulkStatusChange={handleBulkStatusChange}
onBulkComplete={handleBulkComplete}
onBulkDelete={handleBulkDelete}
/>
</motion.div>
)}
</AnimatePresence>
{/* 主要內容區域 */}
<motion.div variants={itemVariants}>
<Fade in={true} timeout={500}>
<Box>
{viewMode === 'list' ? (
<TodoList
todos={filteredTodos}
selectedTodos={selectedTodos}
onSelectionChange={setSelectedTodos}
viewMode={viewMode}
onEditTodo={handleEditTodo}
onStatusChange={handleStatusChange}
/>
) : (
<CalendarView
todos={filteredTodos}
selectedTodos={selectedTodos}
onSelectionChange={setSelectedTodos}
onEditTodo={handleEditTodo}
/>
)}
</Box>
</Fade>
</motion.div>
{/* 新增/編輯待辦對話框 */}
<TodoDialog
open={showTodoDialog}
onClose={handleCloseTodoDialog}
todo={editingTodo}
mode={editingTodo ? 'edit' : 'create'}
onSave={handleSaveTodo}
onTodoCreated={handleTodoCreated}
/>
</motion.div>
</DashboardLayout>
);
};
export default TodosPage;

316
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,316 @@
import axios, { AxiosResponse, AxiosError } from 'axios';
import { toast } from 'react-hot-toast';
import {
Todo,
TodoCreate,
TodoUpdate,
TodoFilter,
TodosResponse,
User,
UserPreferences,
LdapUser,
LoginRequest,
LoginResponse,
FireEmailRequest,
FireEmailQuota,
ImportJob,
} from '@/types';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
});
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as any;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
const response = await api.post('/api/auth/refresh', {}, {
headers: { Authorization: `Bearer ${refreshToken}` },
});
const { access_token } = response.data;
localStorage.setItem('access_token', access_token);
// Retry original request (mark it to skip toast on failure)
originalRequest._isRetry = true;
return api(originalRequest);
}
} catch (refreshError) {
// Refresh failed, redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
// Show error toast (skip for retry requests to avoid duplicates)
if (!originalRequest._isRetry) {
const errorData = (error as any).response?.data;
const status = (error as any).response?.status;
let errorMessage = 'An error occurred';
if (errorData?.message) {
errorMessage = errorData.message;
} else if (errorData?.error) {
errorMessage = errorData.error;
} else if ((error as any).message) {
errorMessage = (error as any).message;
}
// Special handling for database connection errors
if (status === 503) {
toast.error(errorMessage, {
duration: 5000,
style: {
backgroundColor: '#fef3c7',
color: '#92400e',
},
});
} else if (status === 504) {
toast.error(errorMessage, {
duration: 4000,
style: {
backgroundColor: '#fee2e2',
color: '#991b1b',
},
});
} else if (status !== 401) {
toast.error(errorMessage);
}
}
return Promise.reject(error);
}
);
// Auth API
export const authApi = {
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await api.post('/api/auth/login', credentials);
return response.data;
},
logout: async (): Promise<void> => {
await api.post('/api/auth/logout');
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
},
getCurrentUser: async (): Promise<User> => {
const response = await api.get('/api/auth/me');
return response.data;
},
validateToken: async (): Promise<boolean> => {
try {
await api.get('/api/auth/validate');
return true;
} catch {
return false;
}
},
};
// Todos API
export const todosApi = {
getTodos: async (filter: TodoFilter & { page?: number; per_page?: number }): Promise<TodosResponse> => {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.append(key, value.toString());
}
});
const response = await api.get(`/api/todos?${params.toString()}`);
return response.data;
},
getTodo: async (id: string): Promise<Todo> => {
const response = await api.get(`/api/todos/${id}`);
return response.data;
},
createTodo: async (todo: TodoCreate): Promise<Todo> => {
const response = await api.post('/api/todos', todo);
return response.data;
},
updateTodo: async (id: string, updates: Partial<TodoUpdate>): Promise<Todo> => {
const response = await api.patch(`/api/todos/${id}`, updates);
return response.data;
},
deleteTodo: async (id: string): Promise<void> => {
await api.delete(`/api/todos/${id}`);
},
batchUpdateTodos: async (todoIds: string[], updates: Partial<TodoUpdate>): Promise<{ updated: number; errors: any[] }> => {
const response = await api.patch('/api/todos/batch', {
todo_ids: todoIds,
updates,
});
return response.data;
},
fireEmail: async (request: FireEmailRequest): Promise<void> => {
await api.post('/api/notifications/fire-email', {
todo_id: request.todo_id,
message: request.note,
});
},
};
// Users API
export const usersApi = {
searchUsers: async (query: string): Promise<LdapUser[]> => {
const response = await api.get(`/api/users/search?q=${encodeURIComponent(query)}`);
return response.data.users;
},
getPreferences: async (): Promise<UserPreferences> => {
const response = await api.get('/api/users/preferences');
return response.data;
},
updatePreferences: async (preferences: Partial<UserPreferences>): Promise<UserPreferences> => {
const response = await api.patch('/api/users/preferences', preferences);
return response.data;
},
getFireEmailQuota: async (): Promise<FireEmailQuota> => {
const response = await api.get('/api/users/fire-email-quota');
return response.data;
},
};
// Import API
export const importApi = {
downloadTemplate: async (): Promise<Blob> => {
const response = await api.get('/api/imports/template', {
responseType: 'blob',
});
return response.data;
},
uploadFile: async (file: File): Promise<ImportJob> => {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/api/imports', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
getImportJob: async (jobId: string): Promise<ImportJob> => {
const response = await api.get(`/api/imports/${jobId}`);
return response.data;
},
downloadErrors: async (jobId: string): Promise<Blob> => {
const response = await api.get(`/api/imports/${jobId}/errors`, {
responseType: 'blob',
});
return response.data;
},
};
// Admin API (if needed)
export const adminApi = {
getStats: async (days: number = 30): Promise<any> => {
const response = await api.get(`/api/admin/stats?days=${days}`);
return response.data;
},
getAuditLogs: async (params: any): Promise<any> => {
const queryParams = new URLSearchParams(params).toString();
const response = await api.get(`/api/admin/audit-logs?${queryParams}`);
return response.data;
},
getMailLogs: async (params: any): Promise<any> => {
const queryParams = new URLSearchParams(params).toString();
const response = await api.get(`/api/admin/mail-logs?${queryParams}`);
return response.data;
},
};
// Notifications API
export const notificationsApi = {
getSettings: async (): Promise<any> => {
const response = await api.get('/api/notifications/settings');
return response.data;
},
updateSettings: async (settings: any): Promise<any> => {
const response = await api.patch('/api/notifications/settings', settings);
return response.data;
},
sendTestEmail: async (recipientEmail?: string): Promise<void> => {
await api.post('/api/notifications/test', recipientEmail ? { recipient_email: recipientEmail } : {});
},
sendDigest: async (type: 'weekly' | 'monthly' = 'weekly'): Promise<void> => {
await api.post('/api/notifications/digest', { type });
},
markNotificationRead: async (notificationId: string): Promise<void> => {
await api.post('/api/notifications/mark-read', { notification_id: notificationId });
},
markAllNotificationsRead: async (): Promise<void> => {
await api.post('/api/notifications/mark-all-read');
},
getNotifications: async (): Promise<any> => {
const response = await api.get('/api/notifications/');
return response.data;
},
};
// Health API
export const healthApi = {
check: async (): Promise<any> => {
const response = await api.get('/api/health/healthz');
return response.data;
},
readiness: async (): Promise<any> => {
const response = await api.get('/api/health/readiness');
return response.data;
},
};
export default api;

210
frontend/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,210 @@
import { createTheme, ThemeOptions } from '@mui/material/styles';
const getDesignTokens = (mode: 'light' | 'dark'): ThemeOptions => ({
palette: {
mode,
...(mode === 'light'
? {
// Light mode colors
primary: {
main: '#3b82f6',
light: '#60a5fa',
dark: '#2563eb',
contrastText: '#ffffff',
},
secondary: {
main: '#8b5cf6',
light: '#a78bfa',
dark: '#7c3aed',
contrastText: '#ffffff',
},
error: {
main: '#ef4444',
light: '#f87171',
dark: '#dc2626',
},
warning: {
main: '#f59e0b',
light: '#fbbf24',
dark: '#d97706',
},
info: {
main: '#06b6d4',
light: '#22d3ee',
dark: '#0891b2',
},
success: {
main: '#10b981',
light: '#34d399',
dark: '#059669',
},
background: {
default: '#ffffff',
paper: '#f9fafb',
},
text: {
primary: '#111827',
secondary: '#4b5563',
disabled: '#9ca3af',
},
divider: '#e5e7eb',
}
: {
// Dark mode colors
primary: {
main: '#60a5fa',
light: '#93c5fd',
dark: '#3b82f6',
contrastText: '#111827',
},
secondary: {
main: '#a78bfa',
light: '#c4b5fd',
dark: '#8b5cf6',
contrastText: '#111827',
},
error: {
main: '#f87171',
light: '#fca5a5',
dark: '#ef4444',
},
warning: {
main: '#fbbf24',
light: '#fcd34d',
dark: '#f59e0b',
},
info: {
main: '#22d3ee',
light: '#67e8f9',
dark: '#06b6d4',
},
success: {
main: '#34d399',
light: '#6ee7b7',
dark: '#10b981',
},
background: {
default: '#111827',
paper: '#1f2937',
},
text: {
primary: '#f3f4f6',
secondary: '#d1d5db',
disabled: '#6b7280',
},
divider: '#374151',
}),
},
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
].join(','),
h1: {
fontSize: '2.5rem',
fontWeight: 700,
lineHeight: 1.2,
},
h2: {
fontSize: '2rem',
fontWeight: 600,
lineHeight: 1.3,
},
h3: {
fontSize: '1.75rem',
fontWeight: 600,
lineHeight: 1.4,
},
h4: {
fontSize: '1.5rem',
fontWeight: 600,
lineHeight: 1.4,
},
h5: {
fontSize: '1.25rem',
fontWeight: 600,
lineHeight: 1.5,
},
h6: {
fontSize: '1rem',
fontWeight: 600,
lineHeight: 1.5,
},
},
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: '0.5rem',
fontWeight: 500,
},
contained: {
boxShadow: 'none',
'&:hover': {
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
},
rounded: {
borderRadius: '0.75rem',
},
elevation1: {
boxShadow: mode === 'light'
? '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)'
: '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)',
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '0.75rem',
boxShadow: mode === 'light'
? '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)'
: '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)',
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: '0.375rem',
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '0.5rem',
},
},
},
},
},
});
export const createAppTheme = (mode: 'light' | 'dark') => {
return createTheme(getDesignTokens(mode));
};
export const lightTheme = createAppTheme('light');
export const darkTheme = createAppTheme('dark');

View File

@@ -0,0 +1,180 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { authApi } from '@/lib/api';
import { User, AuthState } from '@/types';
import { toast } from 'react-hot-toast';
interface AuthContextType extends AuthState {
login: (username: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
refreshAuth: () => Promise<void>;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: React.ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
user: null,
token: null,
refreshToken: null,
});
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const pathname = usePathname();
// Public routes that don't require authentication
const publicRoutes = ['/login', '/'];
useEffect(() => {
initializeAuth();
}, []);
useEffect(() => {
// Redirect logic
if (!isLoading) {
if (!authState.isAuthenticated && !publicRoutes.includes(pathname)) {
router.push('/login');
} else if (authState.isAuthenticated && pathname === '/login') {
router.push('/dashboard');
}
}
}, [authState.isAuthenticated, pathname, isLoading, router]);
const initializeAuth = async () => {
try {
const token = localStorage.getItem('access_token');
const refreshToken = localStorage.getItem('refresh_token');
const userStr = localStorage.getItem('user');
if (token && refreshToken && userStr) {
const user = JSON.parse(userStr);
// Validate token
const isValid = await authApi.validateToken();
if (isValid) {
setAuthState({
isAuthenticated: true,
user,
token,
refreshToken,
});
} else {
// Token invalid, clear storage
clearAuthData();
}
}
} catch (error) {
console.error('Auth initialization error:', error);
clearAuthData();
} finally {
setIsLoading(false);
}
};
const login = async (username: string, password: string): Promise<boolean> => {
try {
setIsLoading(true);
const response = await authApi.login({ username, password });
// Store auth data
localStorage.setItem('access_token', response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
localStorage.setItem('user', JSON.stringify(response.user));
setAuthState({
isAuthenticated: true,
user: response.user,
token: response.access_token,
refreshToken: response.refresh_token,
});
toast.success(`歡迎,${response.user.display_name}`);
return true;
} catch (error: any) {
console.error('Login error:', error);
let errorMessage = '登入失敗';
if (error.response?.status === 401) {
errorMessage = '帳號或密碼錯誤';
} else if (error.response?.data?.error) {
errorMessage = error.response.data.error;
}
toast.error(errorMessage);
return false;
} finally {
setIsLoading(false);
}
};
const logout = async (): Promise<void> => {
try {
await authApi.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
clearAuthData();
toast.success('已登出');
}
};
const refreshAuth = async (): Promise<void> => {
try {
const user = await authApi.getCurrentUser();
setAuthState(prev => ({
...prev,
user,
}));
// Update user in localStorage
localStorage.setItem('user', JSON.stringify(user));
} catch (error) {
console.error('Refresh auth error:', error);
}
};
const clearAuthData = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
setAuthState({
isAuthenticated: false,
user: null,
token: null,
refreshToken: null,
});
};
const contextValue: AuthContextType = {
...authState,
login,
logout,
refreshAuth,
isLoading,
};
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,98 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
import { createAppTheme } from '@/lib/theme';
type ThemeMode = 'light' | 'dark' | 'auto';
interface ThemeContextType {
themeMode: ThemeMode;
actualTheme: 'light' | 'dark';
setThemeMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [themeMode, setThemeMode] = useState<ThemeMode>('auto');
const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
// Load saved theme preference
const savedTheme = localStorage.getItem('themeMode') as ThemeMode | null;
if (savedTheme) {
setThemeMode(savedTheme);
}
}, []);
useEffect(() => {
const updateActualTheme = () => {
if (themeMode === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setActualTheme(prefersDark ? 'dark' : 'light');
} else {
setActualTheme(themeMode as 'light' | 'dark');
}
};
updateActualTheme();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (themeMode === 'auto') {
updateActualTheme();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [themeMode]);
useEffect(() => {
// Update document class for Tailwind
if (actualTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [actualTheme]);
const handleSetThemeMode = (mode: ThemeMode) => {
setThemeMode(mode);
localStorage.setItem('themeMode', mode);
};
const theme = React.useMemo(
() => createAppTheme(actualTheme),
[actualTheme]
);
return (
<ThemeContext.Provider
value={{
themeMode,
actualTheme,
setThemeMode: handleSetThemeMode,
}}
>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,91 @@
'use client';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Provider as ReduxProvider } from 'react-redux';
import { Toaster } from 'react-hot-toast';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { ThemeProvider } from './ThemeProvider';
import { AuthProvider } from './AuthProvider';
import { store } from '@/store';
import 'dayjs/locale/zh-tw';
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
// Don't retry on 401/403 errors
if (error?.response?.status === 401 || error?.response?.status === 403) {
return false;
}
return failureCount < 3;
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
},
mutations: {
retry: 1,
},
},
});
interface ProvidersProps {
children: React.ReactNode;
}
export const Providers: React.FC<ProvidersProps> = ({ children }) => {
return (
<ReduxProvider store={store}>
<QueryClientProvider client={queryClient}>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-tw">
<ThemeProvider>
<AuthProvider>
{children}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
className: 'text-sm',
style: {
borderRadius: '0.5rem',
background: 'var(--toast-bg)',
color: 'var(--toast-text)',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
},
success: {
style: {
background: '#10b981',
color: '#ffffff',
},
iconTheme: {
primary: '#ffffff',
secondary: '#10b981',
},
},
error: {
style: {
background: '#ef4444',
color: '#ffffff',
},
iconTheme: {
primary: '#ffffff',
secondary: '#ef4444',
},
},
loading: {
style: {
background: '#3b82f6',
color: '#ffffff',
},
},
}}
/>
</AuthProvider>
</ThemeProvider>
</LocalizationProvider>
</QueryClientProvider>
</ReduxProvider>
);
};

View File

@@ -0,0 +1,21 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './slices/authSlice';
import todosReducer from './slices/todosSlice';
import uiReducer from './slices/uiSlice';
export const store = configureStore({
reducer: {
auth: authReducer,
todos: todosReducer,
ui: uiReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
},
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -0,0 +1,37 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { User } from '@/types';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
}
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setAuth: (state, action: PayloadAction<{ user: User; token: string }>) => {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
},
updateUser: (state, action: PayloadAction<User>) => {
state.user = action.payload;
},
clearAuth: (state) => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
},
},
});
export const { setAuth, updateUser, clearAuth } = authSlice.actions;
export default authSlice.reducer;

View File

@@ -0,0 +1,119 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Todo, TodoFilter, ViewType } from '@/types';
interface TodosState {
todos: Todo[];
selectedTodos: string[];
filter: TodoFilter;
viewType: ViewType;
sortField: 'created_at' | 'due_date' | 'priority' | 'title';
sortOrder: 'asc' | 'desc';
isLoading: boolean;
pagination: {
page: number;
per_page: number;
total: number;
pages: number;
};
}
const initialState: TodosState = {
todos: [],
selectedTodos: [],
filter: {
view: 'all',
},
viewType: 'list',
sortField: 'created_at',
sortOrder: 'desc',
isLoading: false,
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0,
},
};
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
setTodos: (state, action: PayloadAction<Todo[]>) => {
state.todos = action.payload;
},
addTodo: (state, action: PayloadAction<Todo>) => {
state.todos.unshift(action.payload);
},
updateTodo: (state, action: PayloadAction<Todo>) => {
const index = state.todos.findIndex(todo => todo.id === action.payload.id);
if (index !== -1) {
state.todos[index] = action.payload;
}
},
removeTodo: (state, action: PayloadAction<string>) => {
state.todos = state.todos.filter(todo => todo.id !== action.payload);
},
setSelectedTodos: (state, action: PayloadAction<string[]>) => {
state.selectedTodos = action.payload;
},
toggleTodoSelection: (state, action: PayloadAction<string>) => {
const todoId = action.payload;
if (state.selectedTodos.includes(todoId)) {
state.selectedTodos = state.selectedTodos.filter(id => id !== todoId);
} else {
state.selectedTodos.push(todoId);
}
},
selectAllTodos: (state) => {
state.selectedTodos = state.todos.map(todo => todo.id);
},
clearSelectedTodos: (state) => {
state.selectedTodos = [];
},
setFilter: (state, action: PayloadAction<TodoFilter>) => {
state.filter = { ...state.filter, ...action.payload };
state.pagination.page = 1; // Reset to first page when filter changes
},
clearFilter: (state) => {
state.filter = { view: 'all' };
state.pagination.page = 1;
},
setViewType: (state, action: PayloadAction<ViewType>) => {
state.viewType = action.payload;
},
setSorting: (state, action: PayloadAction<{ field: string; order: 'asc' | 'desc' }>) => {
state.sortField = action.payload.field as any;
state.sortOrder = action.payload.order;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setPagination: (state, action: PayloadAction<Partial<TodosState['pagination']>>) => {
state.pagination = { ...state.pagination, ...action.payload };
},
setPage: (state, action: PayloadAction<number>) => {
state.pagination.page = action.payload;
},
},
});
export const {
setTodos,
addTodo,
updateTodo,
removeTodo,
setSelectedTodos,
toggleTodoSelection,
selectAllTodos,
clearSelectedTodos,
setFilter,
clearFilter,
setViewType,
setSorting,
setLoading,
setPagination,
setPage,
} = todosSlice.actions;
export default todosSlice.reducer;

View File

@@ -0,0 +1,168 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface UIState {
sidebarOpen: boolean;
sidebarCollapsed: boolean;
searchOpen: boolean;
filterPanelOpen: boolean;
createTodoDialogOpen: boolean;
editTodoDialogOpen: boolean;
deleteTodoDialogOpen: boolean;
batchActionsOpen: boolean;
settingsDialogOpen: boolean;
aiChatOpen: boolean;
importDialogOpen: boolean;
currentEditingTodo: string | null;
currentDeletingTodos: string[];
notifications: Array<{
id: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
timestamp: number;
read: boolean;
}>;
isOnline: boolean;
lastSync: string | null;
}
const initialState: UIState = {
sidebarOpen: true,
sidebarCollapsed: false,
searchOpen: false,
filterPanelOpen: false,
createTodoDialogOpen: false,
editTodoDialogOpen: false,
deleteTodoDialogOpen: false,
batchActionsOpen: false,
settingsDialogOpen: false,
aiChatOpen: false,
importDialogOpen: false,
currentEditingTodo: null,
currentDeletingTodos: [],
notifications: [],
isOnline: true,
lastSync: null,
};
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
toggleSidebar: (state) => {
state.sidebarOpen = !state.sidebarOpen;
},
setSidebarOpen: (state, action: PayloadAction<boolean>) => {
state.sidebarOpen = action.payload;
},
toggleSidebarCollapsed: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
},
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
state.sidebarCollapsed = action.payload;
},
setSearchOpen: (state, action: PayloadAction<boolean>) => {
state.searchOpen = action.payload;
},
setFilterPanelOpen: (state, action: PayloadAction<boolean>) => {
state.filterPanelOpen = action.payload;
},
setCreateTodoDialogOpen: (state, action: PayloadAction<boolean>) => {
state.createTodoDialogOpen = action.payload;
},
setEditTodoDialogOpen: (state, action: PayloadAction<boolean>) => {
state.editTodoDialogOpen = action.payload;
},
setDeleteTodoDialogOpen: (state, action: PayloadAction<boolean>) => {
state.deleteTodoDialogOpen = action.payload;
},
setBatchActionsOpen: (state, action: PayloadAction<boolean>) => {
state.batchActionsOpen = action.payload;
},
setSettingsDialogOpen: (state, action: PayloadAction<boolean>) => {
state.settingsDialogOpen = action.payload;
},
setAiChatOpen: (state, action: PayloadAction<boolean>) => {
state.aiChatOpen = action.payload;
},
setImportDialogOpen: (state, action: PayloadAction<boolean>) => {
state.importDialogOpen = action.payload;
},
setCurrentEditingTodo: (state, action: PayloadAction<string | null>) => {
state.currentEditingTodo = action.payload;
},
setCurrentDeletingTodos: (state, action: PayloadAction<string[]>) => {
state.currentDeletingTodos = action.payload;
},
addNotification: (state, action: PayloadAction<Omit<UIState['notifications'][0], 'id' | 'timestamp' | 'read'>>) => {
const notification = {
...action.payload,
id: Date.now().toString(),
timestamp: Date.now(),
read: false,
};
state.notifications.unshift(notification);
},
markNotificationAsRead: (state, action: PayloadAction<string>) => {
const notification = state.notifications.find(n => n.id === action.payload);
if (notification) {
notification.read = true;
}
},
markAllNotificationsAsRead: (state) => {
state.notifications.forEach(n => n.read = true);
},
removeNotification: (state, action: PayloadAction<string>) => {
state.notifications = state.notifications.filter(n => n.id !== action.payload);
},
clearNotifications: (state) => {
state.notifications = [];
},
setOnlineStatus: (state, action: PayloadAction<boolean>) => {
state.isOnline = action.payload;
},
setLastSync: (state, action: PayloadAction<string>) => {
state.lastSync = action.payload;
},
closeAllDialogs: (state) => {
state.createTodoDialogOpen = false;
state.editTodoDialogOpen = false;
state.deleteTodoDialogOpen = false;
state.settingsDialogOpen = false;
state.aiChatOpen = false;
state.importDialogOpen = false;
state.filterPanelOpen = false;
state.searchOpen = false;
state.batchActionsOpen = false;
state.currentEditingTodo = null;
state.currentDeletingTodos = [];
},
},
});
export const {
toggleSidebar,
setSidebarOpen,
toggleSidebarCollapsed,
setSidebarCollapsed,
setSearchOpen,
setFilterPanelOpen,
setCreateTodoDialogOpen,
setEditTodoDialogOpen,
setDeleteTodoDialogOpen,
setBatchActionsOpen,
setSettingsDialogOpen,
setAiChatOpen,
setImportDialogOpen,
setCurrentEditingTodo,
setCurrentDeletingTodos,
addNotification,
markNotificationAsRead,
markAllNotificationsAsRead,
removeNotification,
clearNotifications,
setOnlineStatus,
setLastSync,
closeAllDialogs,
} = uiSlice.actions;
export default uiSlice.reducer;

181
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,181 @@
// User Detail Types
export interface UserDetail {
ad_account: string;
display_name: string;
email: string;
}
// Todo Types
export interface Todo {
id: string;
title: string;
description?: string;
status: 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
due_date?: string;
created_at: string;
completed_at?: string;
creator_ad: string;
creator_display_name?: string;
creator_email?: string;
starred: boolean;
responsible_users: string[];
followers: string[];
responsible_users_details?: UserDetail[];
followers_details?: UserDetail[];
}
export interface TodoCreate {
title: string;
description?: string;
status?: 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
due_date?: string;
starred?: boolean;
responsible_users?: string[];
followers?: string[];
}
export interface TodoUpdate extends Partial<TodoCreate> {
id: string;
}
export interface TodoFilter {
status?: string;
priority?: string;
starred?: boolean;
due_from?: string;
due_to?: string;
search?: string;
view?: 'all' | 'created' | 'responsible' | 'following';
}
// User Types
export interface User {
ad_account: string;
display_name: string;
email: string;
theme?: 'light' | 'dark' | 'auto';
language?: string;
}
export interface UserPreferences {
ad_account: string;
email: string;
display_name: string;
theme: 'light' | 'dark' | 'auto';
language: string;
timezone: string;
notification_enabled: boolean;
email_reminder_enabled: boolean;
weekly_summary_enabled: boolean;
}
export interface LdapUser {
ad_account: string;
display_name: string;
email: string;
}
// Auth Types
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
user: User;
}
export interface AuthState {
isAuthenticated: boolean;
user: User | null;
token: string | null;
refreshToken: string | null;
}
// API Response Types
export interface ApiResponse<T = any> {
data?: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
per_page: number;
pages: number;
}
export interface TodosResponse {
todos: Todo[];
total: number;
page: number;
per_page: number;
pages: number;
}
// Fire Email Types
export interface FireEmailRequest {
todo_id: string;
recipients?: string[];
note?: string;
}
export interface FireEmailQuota {
used: number;
limit: number;
remaining: number;
}
// Import Types
export interface ImportJob {
id: string;
actor_ad: string;
filename: string;
total_rows: number;
success_rows: number;
failed_rows: number;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
error_file_path?: string;
error_details?: any;
created_at: string;
completed_at?: string;
}
// Theme Types
export type ThemeMode = 'light' | 'dark' | 'auto';
// Utility Types
export type ViewType = 'list' | 'calendar';
export type SortField = 'created_at' | 'due_date' | 'priority' | 'title';
export type SortOrder = 'asc' | 'desc';
// Component Props Types
export interface BaseComponentProps {
className?: string;
children?: React.ReactNode;
}
// Status and Priority Options
export const TODO_STATUSES = ['NEW', 'DOING', 'BLOCKED', 'DONE'] as const;
export const TODO_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'URGENT'] as const;
export const STATUS_COLORS = {
NEW: '#6b7280',
DOING: '#3b82f6',
BLOCKED: '#ef4444',
DONE: '#10b981',
} as const;
export const PRIORITY_COLORS = {
LOW: '#6b7280',
MEDIUM: '#f59e0b',
HIGH: '#f97316',
URGENT: '#ef4444',
} as const;

View File

@@ -0,0 +1,69 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
dark: {
bg: '#111827',
card: '#1f2937',
hover: '#374151',
border: '#4b5563',
text: {
primary: '#f3f4f6',
secondary: '#d1d5db',
muted: '#9ca3af',
},
},
light: {
bg: '#ffffff',
card: '#f9fafb',
hover: '#f3f4f6',
border: '#e5e7eb',
text: {
primary: '#111827',
secondary: '#4b5563',
muted: '#6b7280',
},
},
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}

34
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/store/*": ["./src/store/*"],
"@/types/*": ["./src/types/*"],
"@/styles/*": ["./src/styles/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}