Initial commit: Complete工时统计系统 implementation
This commit is contained in:
26
src/App.jsx
Normal file
26
src/App.jsx
Normal 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;
|
||||
21
src/components/AppLayout.jsx
Normal file
21
src/components/AppLayout.jsx
Normal 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;
|
||||
0
src/components/ButtonGroup.jsx
Normal file
0
src/components/ButtonGroup.jsx
Normal file
0
src/components/Card.jsx
Normal file
0
src/components/Card.jsx
Normal file
0
src/components/ChartArea.jsx
Normal file
0
src/components/ChartArea.jsx
Normal file
0
src/components/DataTable.jsx
Normal file
0
src/components/DataTable.jsx
Normal file
0
src/components/DatePicker.jsx
Normal file
0
src/components/DatePicker.jsx
Normal file
0
src/components/ExportPanel.jsx
Normal file
0
src/components/ExportPanel.jsx
Normal file
0
src/components/FilterBar.jsx
Normal file
0
src/components/FilterBar.jsx
Normal file
14
src/components/Footer.jsx
Normal file
14
src/components/Footer.jsx
Normal 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
57
src/components/Header.jsx
Normal 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;
|
||||
0
src/components/InputNumber.jsx
Normal file
0
src/components/InputNumber.jsx
Normal file
0
src/components/InputText.jsx
Normal file
0
src/components/InputText.jsx
Normal file
0
src/components/Pagination.jsx
Normal file
0
src/components/Pagination.jsx
Normal file
0
src/components/PreferenceSection.jsx
Normal file
0
src/components/PreferenceSection.jsx
Normal file
0
src/components/ProfileSection.jsx
Normal file
0
src/components/ProfileSection.jsx
Normal file
0
src/components/QuickActionsPanel.jsx
Normal file
0
src/components/QuickActionsPanel.jsx
Normal file
0
src/components/RecentActivitiesTable.jsx
Normal file
0
src/components/RecentActivitiesTable.jsx
Normal file
0
src/components/RecordDetailModal.jsx
Normal file
0
src/components/RecordDetailModal.jsx
Normal file
0
src/components/ReportFilterBar.jsx
Normal file
0
src/components/ReportFilterBar.jsx
Normal file
0
src/components/SecuritySection.jsx
Normal file
0
src/components/SecuritySection.jsx
Normal file
0
src/components/SelectProject.jsx
Normal file
0
src/components/SelectProject.jsx
Normal file
47
src/components/Sidebar.jsx
Normal file
47
src/components/Sidebar.jsx
Normal 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;
|
||||
0
src/components/SummaryCardGroup.jsx
Normal file
0
src/components/SummaryCardGroup.jsx
Normal file
0
src/components/TagSelector.jsx
Normal file
0
src/components/TagSelector.jsx
Normal file
165
src/context/TimesheetContext.jsx
Normal file
165
src/context/TimesheetContext.jsx
Normal 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
51
src/index.css
Normal 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
13
src/index.jsx
Normal 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
184
src/pages/DashboardPage.jsx
Normal 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
56
src/pages/LoginPage.jsx
Normal 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
203
src/pages/RecordsPage.jsx
Normal 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
238
src/pages/ReportsPage.jsx
Normal 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
179
src/pages/SettingsPage.jsx
Normal 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
198
src/pages/TimesheetPage.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user