1ST
This commit is contained in:
184
frontend/.env.example
Normal file
184
frontend/.env.example
Normal 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
184
frontend/.env.local
Normal 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
43
frontend/Dockerfile
Normal 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
5
frontend/next-env.d.ts
vendored
Normal 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
22
frontend/next.config.js
Normal 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
7808
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/package.json
Normal file
46
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
BIN
frontend/public/panjit-logo.png
Normal file
BIN
frontend/public/panjit-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.8 KiB |
182
frontend/src/app/calendar/page.tsx
Normal file
182
frontend/src/app/calendar/page.tsx
Normal 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;
|
544
frontend/src/app/dashboard/page.tsx
Normal file
544
frontend/src/app/dashboard/page.tsx
Normal 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;
|
207
frontend/src/app/globals.css
Normal file
207
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
40
frontend/src/app/layout.tsx
Normal file
40
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
358
frontend/src/app/login/page.tsx
Normal file
358
frontend/src/app/login/page.tsx
Normal 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
41
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
764
frontend/src/app/settings/page.tsx
Normal file
764
frontend/src/app/settings/page.tsx
Normal 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;
|
611
frontend/src/app/todos/page.tsx
Normal file
611
frontend/src/app/todos/page.tsx
Normal 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
316
frontend/src/lib/api.ts
Normal 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
210
frontend/src/lib/theme.ts
Normal 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');
|
180
frontend/src/providers/AuthProvider.tsx
Normal file
180
frontend/src/providers/AuthProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
98
frontend/src/providers/ThemeProvider.tsx
Normal file
98
frontend/src/providers/ThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
91
frontend/src/providers/index.tsx
Normal file
91
frontend/src/providers/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
21
frontend/src/store/index.ts
Normal file
21
frontend/src/store/index.ts
Normal 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;
|
37
frontend/src/store/slices/authSlice.ts
Normal file
37
frontend/src/store/slices/authSlice.ts
Normal 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;
|
119
frontend/src/store/slices/todosSlice.ts
Normal file
119
frontend/src/store/slices/todosSlice.ts
Normal 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;
|
168
frontend/src/store/slices/uiSlice.ts
Normal file
168
frontend/src/store/slices/uiSlice.ts
Normal 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
181
frontend/src/types/index.ts
Normal 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;
|
69
frontend/tailwind.config.js
Normal file
69
frontend/tailwind.config.js
Normal 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
34
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
Reference in New Issue
Block a user