Initial commit: Complete工时统计系统 implementation

This commit is contained in:
2025-11-13 01:00:27 +08:00
commit b554c14ce6
47 changed files with 6040 additions and 0 deletions

26
src/App.jsx Normal file
View File

@@ -0,0 +1,26 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import TimesheetPage from './pages/TimesheetPage';
import RecordsPage from './pages/RecordsPage';
import ReportsPage from './pages/ReportsPage';
import SettingsPage from './pages/SettingsPage';
function App() {
return (
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/timesheet" element={<TimesheetPage />} />
<Route path="/records" element={<RecordsPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<LoginPage />} />
</Routes>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import Header from './Header';
import Sidebar from './Sidebar';
import Footer from './Footer';
const AppLayout = ({ children }) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Header />
<div style={{ display: 'flex', flex: 1 }}>
<Sidebar />
<main style={{ flex: 1, padding: '20px' }}>
{children}
</main>
</div>
<Footer />
</div>
);
};
export default AppLayout;

View File

0
src/components/Card.jsx Normal file
View File

View File

View File

View File

View File

View File

14
src/components/Footer.jsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
const Footer = () => {
return (
<Box sx={{ bgcolor: 'background.paper', p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
© 2025 IBM 工程部 | 版本 v1.0.0
</Typography>
</Box>
);
};
export default Footer;

57
src/components/Header.jsx Normal file
View File

@@ -0,0 +1,57 @@
import React from 'react';
import { AppBar, Toolbar, Typography, IconButton, Menu, MenuItem } from '@mui/material';
import AccountCircle from '@mui/icons-material/AccountCircle';
const Header = () => {
const [anchorEl, setAnchorEl] = React.useState(null);
const handleMenu = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
工时统计系统
</Typography>
<div>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={handleClose}>个人设置</MenuItem>
<MenuItem onClick={handleClose}>退出登录</MenuItem>
</Menu>
</div>
</Toolbar>
</AppBar>
);
};
export default Header;

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Drawer, List, ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import HomeIcon from '@mui/icons-material/Home';
import EditIcon from '@mui/icons-material/Edit';
import TableChartIcon from '@mui/icons-material/TableChart';
import AssessmentIcon from '@mui/icons-material/Assessment';
import SettingsIcon from '@mui/icons-material/Settings';
import { Link } from 'react-router-dom';
const Sidebar = () => {
const drawerWidth = 240;
const menuItems = [
{ label: '首页', icon: <HomeIcon />, route: '/dashboard' },
{ label: '工时录入', icon: <EditIcon />, route: '/timesheet' },
{ label: '工时查询', icon: <TableChartIcon />, route: '/records' },
{ label: '统计报表', icon: <AssessmentIcon />, route: '/reports' },
{ label: '个人设置', icon: <SettingsIcon />, route: '/settings' },
];
return (
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
},
}}
>
<List>
{menuItems.map((item) => (
<ListItem key={item.label} disablePadding>
<ListItemButton component={Link} to={item.route}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
))}
</List>
</Drawer>
);
};
export default Sidebar;

View File

View File

View File

@@ -0,0 +1,165 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
// 定义工时记录类型
export const timesheetEntryType = {
id: 'string',
date: 'Date',
project: 'string',
task: 'string',
hours: 'number',
taskType: 'string',
createdAt: 'Date',
};
// 创建上下文
const TimesheetContext = createContext();
// 上下文提供者组件
export const TimesheetProvider = ({ children }) => {
const [entries, setEntries] = useState([]);
// 加载本地存储的工时记录
useEffect(() => {
const savedEntries = localStorage.getItem('timesheetEntries');
if (savedEntries) {
setEntries(JSON.parse(savedEntries).map(entry => ({
...entry,
date: new Date(entry.date),
createdAt: new Date(entry.createdAt),
})));
}
}, []);
// 保存工时记录到本地存储
useEffect(() => {
if (entries.length > 0) {
localStorage.setItem('timesheetEntries', JSON.stringify(entries));
}
}, [entries]);
// 添加工时记录
const addEntry = (entry) => {
const newEntry = {
...entry,
id: Date.now().toString(),
createdAt: new Date(),
};
setEntries([...entries, newEntry]);
return newEntry;
};
// 删除工时记录
const deleteEntry = (id) => {
setEntries(entries.filter(entry => entry.id !== id));
};
// 更新工时记录
const updateEntry = (updatedEntry) => {
setEntries(entries.map(entry =>
entry.id === updatedEntry.id ? updatedEntry : entry
));
};
// 获取所有工时记录
const getAllEntries = () => entries;
// 根据日期范围获取工时记录
const getEntriesByDateRange = (startDate, endDate) => {
return entries.filter(entry => {
const entryDate = new Date(entry.date);
return entryDate >= startDate && entryDate <= endDate;
});
};
// 根据项目获取工时记录
const getEntriesByProject = (project) => {
return entries.filter(entry => entry.project === project);
};
// 根据任务类型获取工时记录
const getEntriesByTaskType = (taskType) => {
return entries.filter(entry => entry.taskType === taskType);
};
// 计算总工时
const calculateTotalHours = (entriesToCalculate = entries) => {
return entriesToCalculate.reduce((total, entry) => total + entry.hours, 0);
};
// 计算按项目分组的总工时
const calculateHoursByProject = () => {
const hoursByProject = {};
entries.forEach(entry => {
hoursByProject[entry.project] = (hoursByProject[entry.project] || 0) + entry.hours;
});
return Object.entries(hoursByProject).map(([project, hours]) => ({
project,
hours,
}));
};
// 计算按任务类型分组的总工时
const calculateHoursByTaskType = () => {
const hoursByTaskType = {};
entries.forEach(entry => {
hoursByTaskType[entry.taskType] = (hoursByTaskType[entry.taskType] || 0) + entry.hours;
});
return Object.entries(hoursByTaskType).map(([taskType, hours]) => ({
taskType,
hours,
}));
};
// 计算本周工时
const calculateThisWeekHours = () => {
const now = new Date();
const firstDayOfWeek = new Date(now.setDate(now.getDate() - now.getDay()));
firstDayOfWeek.setHours(0, 0, 0, 0);
const lastDayOfWeek = new Date(firstDayOfWeek);
lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6);
lastDayOfWeek.setHours(23, 59, 59, 999);
return calculateTotalHours(getEntriesByDateRange(firstDayOfWeek, lastDayOfWeek));
};
// 计算本月工时
const calculateThisMonthHours = () => {
const now = new Date();
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
return calculateTotalHours(getEntriesByDateRange(firstDayOfMonth, lastDayOfMonth));
};
const contextValue = {
entries,
addEntry,
deleteEntry,
updateEntry,
getAllEntries,
getEntriesByDateRange,
getEntriesByProject,
getEntriesByTaskType,
calculateTotalHours,
calculateHoursByProject,
calculateHoursByTaskType,
calculateThisWeekHours,
calculateThisMonthHours,
};
return (
<TimesheetContext.Provider value={contextValue}>
{children}
</TimesheetContext.Provider>
);
};
// 自定义钩子,用于访问上下文
export const useTimesheet = () => {
const context = useContext(TimesheetContext);
if (!context) {
throw new Error('useTimesheet must be used within a TimesheetProvider');
}
return context;
};

51
src/index.css Normal file
View File

@@ -0,0 +1,51 @@
/* Global Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Page Specific Styles */
.page-container {
min-height: calc(100vh - 128px); /* Subtract header and footer height */
padding: 2rem 0;
}
/* Form Styles */
.form-container {
max-width: 800px;
margin: 0 auto;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
@media (max-width: 600px) {
.form-grid {
grid-template-columns: 1fr;
}
}
/* Data Grid Styles */
.data-grid-container {
max-width: 1200px;
margin: 0 auto;
}
/* Chart Styles */
.chart-container {
max-width: 1000px;
margin: 0 auto;
}

13
src/index.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import { TimesheetProvider } from './context/TimesheetContext';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<TimesheetProvider>
<App />
</TimesheetProvider>
</React.StrictMode>
);

184
src/pages/DashboardPage.jsx Normal file
View File

@@ -0,0 +1,184 @@
import React from 'react';
import AppLayout from '../components/AppLayout';
import { Box, Container, Paper, Typography, Grid } from '@mui/material';
import { useTimesheet } from '../context/TimesheetContext';
const DashboardPage = () => {
const {
calculateThisWeekHours,
calculateThisMonthHours,
calculateTotalHours,
calculateHoursByProject,
entries
} = useTimesheet();
const thisWeekHours = calculateThisWeekHours();
const thisMonthHours = calculateThisMonthHours();
const totalHours = calculateTotalHours();
const hoursByProject = calculateHoursByProject();
const totalEntries = entries.length;
// Project labels and hours for chart
const projectLabels = hoursByProject.map(item => item.project.replace('project', '项目'));
const projectHours = hoursByProject.map(item => item.hours);
return (
<AppLayout>
<Container maxWidth="xl">
<Box sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" gutterBottom>
仪表盘
</Typography>
{/* Summary Cards */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} md={3}>
<Paper
elevation={3}
sx={{ p: 3, display: 'flex', flexDirection: 'column', height: 140 }}
>
<Typography variant="h6" gutterBottom color="textSecondary">
本周工时
</Typography>
<Typography variant="h4" gutterBottom>
{thisWeekHours.toFixed(1)}
</Typography>
<Typography variant="body2" color="textSecondary">
小时
</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Paper
elevation={3}
sx={{ p: 3, display: 'flex', flexDirection: 'column', height: 140 }}
>
<Typography variant="h6" gutterBottom color="textSecondary">
本月工时
</Typography>
<Typography variant="h4" gutterBottom>
{thisMonthHours.toFixed(1)}
</Typography>
<Typography variant="body2" color="textSecondary">
小时
</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Paper
elevation={3}
sx={{ p: 3, display: 'flex', flexDirection: 'column', height: 140 }}
>
<Typography variant="h6" gutterBottom color="textSecondary">
累计工时
</Typography>
<Typography variant="h4" gutterBottom>
{totalHours.toFixed(1)}
</Typography>
<Typography variant="body2" color="textSecondary">
小时
</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Paper
elevation={3}
sx={{ p: 3, display: 'flex', flexDirection: 'column', height: 140 }}
>
<Typography variant="h6" gutterBottom color="textSecondary">
记录总数
</Typography>
<Typography variant="h4" gutterBottom>
{totalEntries}
</Typography>
<Typography variant="body2" color="textSecondary">
条记录
</Typography>
</Paper>
</Grid>
</Grid>
{/* Project Hours Distribution */}
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Typography variant="h5" gutterBottom>
项目工时分布
</Typography>
<Grid container spacing={2}>
{hoursByProject.map((item, index) => (
<Grid item xs={12} sm={6} key={index}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body1">
{item.project.replace('project', '项目')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 100,
height: 20,
backgroundColor: '#e0e0e0',
borderRadius: 1,
overflow: 'hidden'
}}
>
<Box
sx={{
width: `${Math.min((item.hours / totalHours) * 100, 100)}%`,
height: '100%',
backgroundColor: index % 2 === 0 ? '#1976d2' : '#dc004e',
}}
/>
</Box>
<Typography variant="body2">
{item.hours.toFixed(1)} 小时
</Typography>
</Box>
</Box>
</Grid>
))}
</Grid>
</Paper>
{/* Recent Entries */}
<Paper elevation={3} sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>
最近记录
</Typography>
<Box sx={{ mt: 2 }}>
{entries.length === 0 ? (
<Typography variant="body1" color="textSecondary">
暂无工时记录
</Typography>
) : (
<Grid container spacing={2}>
{entries
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 5)
.map((entry) => (
<Grid item xs={12} key={entry.id}>
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
<Typography variant="body1" fontWeight="bold">
{entry.date.toLocaleDateString()} - {entry.taskType}
</Typography>
<Typography variant="body2" color="textSecondary">
项目: {entry.project.replace('project', '项目')} | 时长: {entry.hours} 小时
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{entry.task}
</Typography>
</Box>
</Grid>
))}
</Grid>
)}
</Box>
</Paper>
</Box>
</Container>
</AppLayout>
);
};
export default DashboardPage;

56
src/pages/LoginPage.jsx Normal file
View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Box, Button, Container, TextField, Typography } from '@mui/material';
import { Link } from 'react-router-dom';
const LoginPage = () => {
return (
<Container maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
登录
</Typography>
<Box component="form" noValidate sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="username"
label="用户名"
name="username"
autoComplete="username"
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="密码"
type="password"
id="password"
autoComplete="current-password"
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
component={Link}
to="/dashboard"
>
登录
</Button>
</Box>
</Box>
</Container>
);
};
export default LoginPage;

203
src/pages/RecordsPage.jsx Normal file
View File

@@ -0,0 +1,203 @@
import React, { useState } from 'react';
import AppLayout from '../components/AppLayout';
import {
Box,
Button,
Container,
FormControl,
InputLabel,
MenuItem,
Select,
Paper,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { DataGrid } from '@mui/x-data-grid';
import { useTimesheet } from '../context/TimesheetContext';
const RecordsPage = () => {
const { entries, deleteEntry } = useTimesheet();
const [dateRange, setDateRange] = useState('');
const [project, setProject] = useState('');
const [member, setMember] = useState('');
const [openModal, setOpenModal] = useState(false);
const [selectedRecord, setSelectedRecord] = useState(null);
// Convert entries to DataGrid format
const rows = entries.map((entry, index) => ({
id: entry.id,
date: entry.date.toLocaleDateString(),
member: '当前用户', // In a real app, this would be the actual user
project: entry.project.replace('project', '项目'),
hours: entry.hours,
status: '已提交', // In a real app, this would have statuses
note: entry.task,
original: entry,
}));
const columns = [
{ field: 'date', headerName: '日期', width: 150 },
{ field: 'member', headerName: '员工', width: 120 },
{ field: 'project', headerName: '项目', width: 180 },
{ field: 'hours', headerName: '工时', width: 100 },
{ field: 'status', headerName: '状态', width: 120 },
{ field: 'note', headerName: '备注', width: 250 },
{
field: 'action',
headerName: '操作',
width: 200,
renderCell: (params) => (
<>
<Button
variant="contained"
size="small"
onClick={() => handleViewDetail(params.row)}
sx={{ mr: 1 }}
>
查看详情
</Button>
<Button
variant="outlined"
size="small"
onClick={() => handleDelete(params.row.id)}
color="error"
>
删除
</Button>
</>
),
},
];
const handleViewDetail = (record) => {
setSelectedRecord(record);
setOpenModal(true);
};
const handleCloseModal = () => {
setOpenModal(false);
setSelectedRecord(null);
};
return (
<AppLayout>
<Container maxWidth="xl">
<Box sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" gutterBottom>
工时查询
</Typography>
{/* 筛选栏 */}
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap' }}>
<FormControl sx={{ minWidth: 180 }}>
<InputLabel>日期范围</InputLabel>
<Select
value={dateRange}
label="日期范围"
onChange={(e) => setDateRange(e.target.value)}
>
<MenuItem value="this-week">本周</MenuItem>
<MenuItem value="last-week">上周</MenuItem>
<MenuItem value="this-month">本月</MenuItem>
<MenuItem value="last-month">上月</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 180 }}>
<InputLabel>项目</InputLabel>
<Select
value={project}
label="项目"
onChange={(e) => setProject(e.target.value)}
>
<MenuItem value="project1">项目1</MenuItem>
<MenuItem value="project2">项目2</MenuItem>
<MenuItem value="project3">项目3</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 180 }}>
<InputLabel>员工</InputLabel>
<Select
value={member}
label="员工"
onChange={(e) => setMember(e.target.value)}
>
<MenuItem value="zhangsan">张三</MenuItem>
<MenuItem value="lisi">李四</MenuItem>
<MenuItem value="wangwu">王五</MenuItem>
</Select>
</FormControl>
<Button variant="contained" sx={{ mt: 1 }}>
查询
</Button>
<Button sx={{ mt: 1 }}>重置</Button>
</Box>
{/* 操作区 */}
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap' }}>
<Button variant="contained">添加记录</Button>
<Button variant="outlined">编辑选中</Button>
<Button variant="outlined">删除选中</Button>
<Button variant="outlined">导出 Excel</Button>
</Box>
{/* 数据表格 */}
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
<Box sx={{ height: 500, width: '100%' }}>
<DataGrid
rows={rows}
columns={columns}
pageSize={5}
rowsPerPageOptions={[5, 10, 20]}
checkboxSelection
disableSelectionOnClick
/>
</Box>
</Paper>
</Box>
</Container>
{/* 记录详情模态框 */}
<Dialog open={openModal} onClose={handleCloseModal} maxWidth="sm" fullWidth>
<DialogTitle>工时记录详情</DialogTitle>
<DialogContent>
{selectedRecord && (
<Box>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
日期: {selectedRecord.date}
</Typography>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
员工: {selectedRecord.member}
</Typography>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
项目: {selectedRecord.project}
</Typography>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
工时: {selectedRecord.hours} 小时
</Typography>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
状态: {selectedRecord.status}
</Typography>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
备注: {selectedRecord.note}
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseModal}>关闭</Button>
<Button variant="contained" onClick={handleCloseModal}>
编辑
</Button>
</DialogActions>
</Dialog>
</AppLayout>
);
};
export default RecordsPage;

238
src/pages/ReportsPage.jsx Normal file
View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import AppLayout from '../components/AppLayout';
import {
Box,
Button,
Container,
FormControl,
InputLabel,
MenuItem,
Select,
Typography,
Paper,
Grid,
} from '@mui/material';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { useTimesheet } from '../context/TimesheetContext';
const ReportsPage = () => {
const [reportType, setReportType] = useState('monthly');
const [dateRange, setDateRange] = useState('this-month');
const [filteredEntries, setFilteredEntries] = useState([]);
const [monthlyData, setMonthlyData] = useState([]);
const [projectData, setProjectData] = useState([]);
const { entries, getEntriesByDateRange } = useTimesheet();
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8'];
// Calculate date range based on selection
const calculateDateRange = () => {
const now = new Date();
let startDate, endDate;
switch (dateRange) {
case 'this-week':
startDate = new Date(now.setDate(now.getDate() - now.getDay()));
startDate.setHours(0, 0, 0, 0);
endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 6);
endDate.setHours(23, 59, 59, 999);
break;
case 'last-week':
startDate = new Date(now.setDate(now.getDate() - now.getDay() - 7));
startDate.setHours(0, 0, 0, 0);
endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 6);
endDate.setHours(23, 59, 59, 999);
break;
case 'this-month':
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
break;
case 'last-month':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
break;
case 'this-quarter':
const quarterStart = Math.floor(now.getMonth() / 3) * 3;
startDate = new Date(now.getFullYear(), quarterStart, 1);
endDate = new Date(now.getFullYear(), quarterStart + 3, 0, 23, 59, 59, 999);
break;
case 'last-quarter':
const lastQuarterStart = Math.floor(now.getMonth() / 3) * 3 - 3;
startDate = new Date(now.getFullYear(), lastQuarterStart, 1);
endDate = new Date(now.getFullYear(), lastQuarterStart + 3, 0, 23, 59, 59, 999);
break;
default:
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
}
return { startDate, endDate };
};
// Generate chart data from entries
const generateChartData = (entriesToProcess) => {
// Generate monthly trend data (group by week)
const weekDataMap = {};
entriesToProcess.forEach(entry => {
const date = new Date(entry.date);
const dayOfWeek = date.getDay() || 7; // Monday = 1, Sunday = 7
const weekStart = new Date(date.setDate(date.getDate() - dayOfWeek + 1));
const weekNumber = `${Math.ceil(weekStart.getDate() / 7)}`;
if (!weekDataMap[weekNumber]) {
weekDataMap[weekNumber] = { name: weekNumber };
}
const projectName = entry.project.replace('project', '项目');
weekDataMap[weekNumber][projectName] = (weekDataMap[weekNumber][projectName] || 0) + entry.hours;
});
const monthlyChartData = Object.values(weekDataMap).sort((a, b) => a.name.localeCompare(b.name));
// Generate project pie chart data
const projectHoursMap = {};
entriesToProcess.forEach(entry => {
const projectName = entry.project.replace('project', '项目');
projectHoursMap[projectName] = (projectHoursMap[projectName] || 0) + entry.hours;
});
const projectChartData = Object.entries(projectHoursMap).map(([name, value]) => ({
name,
value,
}));
return { monthlyChartData, projectChartData };
};
// Update filtered entries and chart data when dateRange or entries change
useEffect(() => {
const { startDate, endDate } = calculateDateRange();
const filtered = getEntriesByDateRange(startDate, endDate);
setFilteredEntries(filtered);
const { monthlyChartData, projectChartData } = generateChartData(filtered);
setMonthlyData(monthlyChartData);
setProjectData(projectChartData);
}, [dateRange, entries]);
return (
<AppLayout>
<Container maxWidth="xl">
<Box sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" gutterBottom>
统计报表
</Typography>
{/* 报表筛选栏 */}
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap' }}>
<FormControl sx={{ minWidth: 180 }}>
<InputLabel>报表类型</InputLabel>
<Select
value={reportType}
label="报表类型"
onChange={(e) => setReportType(e.target.value)}
>
<MenuItem value="monthly">月度趋势</MenuItem>
<MenuItem value="project">项目占比</MenuItem>
<MenuItem value="member">成员贡献</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 180 }}>
<InputLabel>日期范围</InputLabel>
<Select
value={dateRange}
label="日期范围"
onChange={(e) => setDateRange(e.target.value)}
>
<MenuItem value="this-month">本月</MenuItem>
<MenuItem value="last-month">上月</MenuItem>
<MenuItem value="this-quarter">本季度</MenuItem>
<MenuItem value="last-quarter">上季度</MenuItem>
</Select>
</FormControl>
<Button variant="contained" sx={{ mt: 1 }}>
生成报表
</Button>
</Box>
{/* 操作区 */}
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap' }}>
<Button variant="contained">导出 PDF</Button>
<Button variant="outlined">导出 Excel</Button>
<Button variant="outlined">返回首页</Button>
</Box>
{/* 图表区域 */}
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
月度趋势图
</Typography>
<Box sx={{ height: 400 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
{monthlyData.length > 0 && Object.keys(monthlyData[0])
.filter(key => key !== 'name')
.map((projectName, index) => (
<Bar
key={projectName}
dataKey={projectName}
fill={COLORS[index % COLORS.length]}
/>
))}
</BarChart>
</ResponsiveContainer>
</Box>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
项目占比环形图
</Typography>
<Box sx={{ height: 400 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={projectData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
fill="#8884d8"
paddingAngle={5}
dataKey="value"
>
{projectData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</Box>
</Paper>
</Grid>
</Grid>
</Box>
</Container>
</AppLayout>
);
};
export default ReportsPage;

179
src/pages/SettingsPage.jsx Normal file
View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react';
import AppLayout from '../components/AppLayout';
import {
Box,
Button,
Container,
Typography,
Paper,
TextField,
FormControlLabel,
Switch,
FormControl,
InputLabel,
MenuItem,
Select,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
const SettingsPage = () => {
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState('张三');
const [email, setEmail] = useState('zhangsan@example.com');
const [department, setDepartment] = useState('开发部');
const [language, setLanguage] = useState('zh-CN');
const [theme, setTheme] = useState('light');
const [timeFormat, setTimeFormat] = useState('24h');
const [emailNotifications, setEmailNotifications] = useState(true);
const handleEditToggle = () => {
setIsEditing(!isEditing);
};
const handleSave = () => {
// 在这里添加保存逻辑
setIsEditing(false);
};
return (
<AppLayout>
<Container maxWidth="md">
<Box sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" gutterBottom>
个人设置
</Typography>
{/* 个人信息 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" gutterBottom>
个人信息
</Typography>
<Button
variant="contained"
startIcon={isEditing ? <SaveIcon /> : <EditIcon />}
onClick={isEditing ? handleSave : handleEditToggle}
>
{isEditing ? '保存' : '编辑'}
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="姓名"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!isEditing}
fullWidth
/>
<TextField
label="邮箱"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={!isEditing}
fullWidth
/>
<TextField
label="部门"
value={department}
onChange={(e) => setDepartment(e.target.value)}
disabled={!isEditing}
fullWidth
/>
</Box>
</Paper>
{/* 系统偏好 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
系统偏好
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography>语言</Typography>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={language}
onChange={(e) => setLanguage(e.target.value)}
>
<MenuItem value="zh-CN">中文</MenuItem>
<MenuItem value="en-US">English</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography>主题</Typography>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={theme}
onChange={(e) => setTheme(e.target.value)}
>
<MenuItem value="light">亮色</MenuItem>
<MenuItem value="dark">暗色</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography>时间显示</Typography>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={timeFormat}
onChange={(e) => setTimeFormat(e.target.value)}
>
<MenuItem value="24h">24小时制</MenuItem>
<MenuItem value="12h">12小时制</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography>邮件通知</Typography>
<Switch
checked={emailNotifications}
onChange={(e) => setEmailNotifications(e.target.checked)}
/>
</Box>
</Box>
</Paper>
{/* 安全设置 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
安全设置
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="当前密码"
type="password"
fullWidth
/>
<TextField
label="新密码"
type="password"
fullWidth
/>
<TextField
label="确认新密码"
type="password"
fullWidth
/>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button variant="contained">修改密码</Button>
</Box>
</Paper>
{/* 保存按钮 */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
<Button variant="contained">保存所有设置</Button>
</Box>
</Box>
</Container>
</AppLayout>
);
};
export default SettingsPage;

198
src/pages/TimesheetPage.jsx Normal file
View File

@@ -0,0 +1,198 @@
import React, { useState } from 'react';
import AppLayout from '../components/AppLayout';
import {
Box,
Button,
Container,
FormControl,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
Snackbar,
Alert,
} from '@mui/material';
import { DatePicker } from '@mui/lab';
import AdapterDateFns from '@mui/lab/AdapterDateFns';
import LocalizationProvider from '@mui/lab/LocalizationProvider';
import { useTimesheet } from '../context/TimesheetContext';
const TimesheetPage = () => {
const { addEntry } = useTimesheet();
const [date, setDate] = useState(new Date());
const [project, setProject] = useState('');
const [task, setTask] = useState('');
const [hours, setHours] = useState('');
const [taskType, setTaskType] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarSeverity, setSnackbarSeverity] = useState('success');
const projects = [
{ value: 'project1', label: '项目1' },
{ value: 'project2', label: '项目2' },
{ value: 'project3', label: '项目3' },
];
const taskTypes = ['开发', '测试', '文档', '会议', '其他'];
const validateForm = () => {
if (!date) {
setSnackbarMessage('请选择日期');
setSnackbarSeverity('error');
setSnackbarOpen(true);
return false;
}
if (!project) {
setSnackbarMessage('请选择项目');
setSnackbarSeverity('error');
setSnackbarOpen(true);
return false;
}
if (!task.trim()) {
setSnackbarMessage('请输入任务内容');
setSnackbarSeverity('error');
setSnackbarOpen(true);
return false;
}
if (!hours || isNaN(parseFloat(hours)) || parseFloat(hours) <= 0 || parseFloat(hours) > 24) {
setSnackbarMessage('请输入有效的时长 (0-24小时)');
setSnackbarSeverity('error');
setSnackbarOpen(true);
return false;
}
if (!taskType) {
setSnackbarMessage('请选择任务类型');
setSnackbarSeverity('error');
setSnackbarOpen(true);
return false;
}
return true;
};
const handleSave = () => {
if (validateForm()) {
try {
addEntry({
date,
project,
task: task.trim(),
hours: parseFloat(hours),
taskType,
});
setSnackbarMessage('工时记录已保存');
setSnackbarSeverity('success');
setSnackbarOpen(true);
handleReset();
} catch (error) {
setSnackbarMessage('保存失败: ' + error.message);
setSnackbarSeverity('error');
setSnackbarOpen(true);
}
}
};
const handleReset = () => {
setDate(new Date());
setProject('');
setTask('');
setHours('');
setTaskType('');
};
return (
<AppLayout>
<Container maxWidth="md">
<Box sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" gutterBottom>
工时录入
</Typography>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Box sx={{ mt: 3, display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' } }}>
<DatePicker
label="日期"
value={date}
onChange={(newValue) => setDate(newValue)}
renderInput={(params) => <TextField {...params} fullWidth />}
/>
<FormControl fullWidth>
<InputLabel>项目</InputLabel>
<Select
value={project}
label="项目"
onChange={(e) => setProject(e.target.value)}
>
{projects.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
multiline
rows={3}
label="任务内容"
value={task}
onChange={(e) => setTask(e.target.value)}
helperText="请详细描述您完成的工作"
/>
<TextField
fullWidth
type="number"
label="时长 (小时)"
value={hours}
onChange={(e) => setHours(e.target.value)}
inputProps={{ min: 0, max: 24, step: 0.5 }}
helperText="请输入0-24之间的有效数字"
/>
<FormControl fullWidth>
<InputLabel>任务类型</InputLabel>
<Select
value={taskType}
label="任务类型"
onChange={(e) => setTaskType(e.target.value)}
>
{taskTypes.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</LocalizationProvider>
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button variant="outlined" onClick={handleReset}>
重置
</Button>
<Button variant="contained" onClick={handleSave}>
保存记录
</Button>
</Box>
</Box>
</Container>
<Snackbar
open={snackbarOpen}
autoHideDuration={3000}
onClose={() => setSnackbarOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert severity={snackbarSeverity} onClose={() => setSnackbarOpen(false)} sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
</AppLayout>
);
};
export default TimesheetPage;