first commit

This commit is contained in:
2025-09-12 12:38:11 +02:00
commit 0db2fd0314
46 changed files with 23221 additions and 0 deletions

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

46
frontend/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

18405
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
frontend/package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@mui/x-data-grid": "^8.11.2",
"@mui/x-date-pickers": "^8.11.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.12.0",
"dayjs": "^1.11.18",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.2",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

38
frontend/src/App.css Normal file
View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

121
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,121 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import DashboardLayout from './components/layout/DashboardLayout';
import Login from './components/auth/Login';
import Register from './components/auth/Register';
import Dashboard from './pages/Dashboard';
import Projects from './pages/Projects';
const theme = createTheme({
palette: {
primary: {
main: '#3b82f6',
},
secondary: {
main: '#6b7280',
},
},
});
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
};
const AppRoutes: React.FC = () => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
return (
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/dashboard" /> : <Login />}
/>
<Route
path="/register"
element={isAuthenticated ? <Navigate to="/dashboard" /> : <Register />}
/>
<Route
path="/"
element={<Navigate to="/dashboard" />}
/>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardLayout>
<Dashboard />
</DashboardLayout>
</ProtectedRoute>
}
/>
<Route
path="/projects"
element={
<ProtectedRoute>
<DashboardLayout>
<Projects />
</DashboardLayout>
</ProtectedRoute>
}
/>
<Route
path="/tasks"
element={
<ProtectedRoute>
<DashboardLayout>
<div>Tasks Page - Coming Soon</div>
</DashboardLayout>
</ProtectedRoute>
}
/>
<Route
path="/team"
element={
<ProtectedRoute>
<DashboardLayout>
<div>Team Page - Coming Soon</div>
</DashboardLayout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<DashboardLayout>
<div>Settings Page - Coming Soon</div>
</DashboardLayout>
</ProtectedRoute>
}
/>
</Routes>
);
};
const App: React.FC = () => {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<AuthProvider>
<Router>
<AppRoutes />
</Router>
</AuthProvider>
</ThemeProvider>
);
};
export default App;

View File

@@ -0,0 +1,134 @@
import React, { useState } from 'react';
import {
Box,
TextField,
Button,
Typography,
Alert,
Link,
Container,
Paper,
} from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
const Login: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || err.message || 'Login failed');
} finally {
setLoading(false);
}
};
return (
<Container component="main" maxWidth="sm">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Paper
elevation={3}
sx={{
padding: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
<Typography component="h1" variant="h4" sx={{ mb: 2, color: 'primary.main' }}>
Project Dashboard
</Typography>
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
Sign In
</Typography>
{error && (
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
<TextField
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
>
{loading ? 'Signing In...' : 'Sign In'}
</Button>
<Box textAlign="center">
<Link component={RouterLink} to="/register" variant="body2">
Don't have an account? Sign Up
</Link>
</Box>
</Box>
</Paper>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Demo Credentials:
</Typography>
<Typography variant="body2" color="text.secondary">
Email: admin@example.com
</Typography>
<Typography variant="body2" color="text.secondary">
Password: admin123
</Typography>
</Box>
</Box>
</Container>
);
};
export default Login;

View File

@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import {
Box,
TextField,
Button,
Typography,
Alert,
Link,
Container,
Paper,
} from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
const Register: React.FC = () => {
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: '',
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
return;
}
if (formData.password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
setLoading(true);
try {
await register({
email: formData.email,
password: formData.password,
first_name: formData.firstName,
last_name: formData.lastName,
});
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || err.message || 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<Container component="main" maxWidth="sm">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Paper
elevation={3}
sx={{
padding: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
<Typography component="h1" variant="h4" sx={{ mb: 2, color: 'primary.main' }}>
Project Dashboard
</Typography>
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
Sign Up
</Typography>
{error && (
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
<TextField
margin="normal"
required
fullWidth
id="firstName"
label="First Name"
name="firstName"
autoComplete="given-name"
autoFocus
value={formData.firstName}
onChange={handleChange}
/>
<TextField
margin="normal"
required
fullWidth
id="lastName"
label="Last Name"
name="lastName"
autoComplete="family-name"
value={formData.lastName}
onChange={handleChange}
/>
<TextField
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="new-password"
value={formData.password}
onChange={handleChange}
/>
<TextField
margin="normal"
required
fullWidth
name="confirmPassword"
label="Confirm Password"
type="password"
id="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
>
{loading ? 'Creating Account...' : 'Sign Up'}
</Button>
<Box textAlign="center">
<Link component={RouterLink} to="/login" variant="body2">
Already have an account? Sign In
</Link>
</Box>
</Box>
</Paper>
</Box>
</Container>
);
};
export default Register;

View File

@@ -0,0 +1,222 @@
import React, { useState } from 'react';
import {
AppBar,
Box,
CssBaseline,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
Avatar,
Menu,
MenuItem,
Divider,
} from '@mui/material';
import {
Menu as MenuIcon,
Dashboard as DashboardIcon,
Folder as FolderIcon,
Assignment as AssignmentIcon,
People as PeopleIcon,
Settings as SettingsIcon,
Logout as LogoutIcon,
AccountCircle as AccountCircleIcon,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate, useLocation } from 'react-router-dom';
const drawerWidth = 240;
interface DashboardLayoutProps {
children: React.ReactNode;
}
const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleLogout = () => {
logout();
navigate('/login');
handleClose();
};
const menuItems = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/dashboard' },
{ text: 'Projects', icon: <FolderIcon />, path: '/projects' },
{ text: 'Tasks', icon: <AssignmentIcon />, path: '/tasks' },
{ text: 'Team', icon: <PeopleIcon />, path: '/team' },
{ text: 'Settings', icon: <SettingsIcon />, path: '/settings' },
];
const drawer = (
<div>
<Toolbar>
<Typography variant="h6" noWrap component="div" sx={{ color: 'primary.main' }}>
Project Dashboard
</Typography>
</Toolbar>
<Divider />
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => navigate(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</div>
);
return (
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{menuItems.find(item => item.path === location.pathname)?.text || 'Dashboard'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ display: { xs: 'none', sm: 'block' } }}>
{user?.first_name} {user?.last_name}
</Typography>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main' }}>
{user?.first_name?.[0]}{user?.last_name?.[0]}
</Avatar>
</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={() => { navigate('/profile'); handleClose(); }}>
<ListItemIcon>
<AccountCircleIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Profile</ListItemText>
</MenuItem>
<MenuItem onClick={() => { navigate('/settings'); handleClose(); }}>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<LogoutIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
</Box>
</Toolbar>
</AppBar>
<Box
component="nav"
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true,
}}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { sm: `calc(100% - ${drawerWidth}px)` },
}}
>
<Toolbar />
{children}
</Box>
</Box>
);
};
export default DashboardLayout;

View File

@@ -0,0 +1,498 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Grid,
Card,
CardContent,
Typography,
Chip,
Avatar,
IconButton,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
FormControl,
InputLabel,
Select,
MenuItem as SelectMenuItem,
} from '@mui/material';
import {
MoreVert as MoreVertIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Comment as CommentIcon,
AttachFile as AttachFileIcon,
Person as PersonIcon,
Schedule as ScheduleIcon,
Add as AddIcon,
} from '@mui/icons-material';
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, closestCorners } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Task, tasksAPI, User } from '../../services/api';
interface KanbanBoardProps {
projectId: number;
tasks: Task[];
onTaskUpdate: (task: Task) => void;
onTaskDelete: (taskId: number) => void;
users: User[];
}
interface TaskCardProps {
task: Task;
onEdit: (task: Task) => void;
onDelete: (task: Task) => void;
users: User[];
}
interface ColumnProps {
status: string;
tasks: Task[];
onTaskEdit: (task: Task) => void;
onTaskDelete: (task: Task) => void;
users: User[];
}
const statusConfig = {
todo: { label: 'To Do', color: 'default' },
in_progress: { label: 'In Progress', color: 'warning' },
review: { label: 'Review', color: 'info' },
done: { label: 'Done', color: 'success' },
};
const priorityConfig = {
low: { label: 'Low', color: 'success' },
medium: { label: 'Medium', color: 'warning' },
high: { label: 'High', color: 'error' },
urgent: { label: 'Urgent', color: 'error' },
};
const SortableTaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDelete, users }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const assignedUser = users.find(user => user.id === task.assigned_to_id);
return (
<Card
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
sx={{
mb: 2,
cursor: 'grab',
'&:active': {
cursor: 'grabbing',
},
'&:hover': {
boxShadow: 4,
},
}}
>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, flexGrow: 1 }}>
{task.title}
</Typography>
<IconButton size="small" onClick={handleMenuOpen}>
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
{task.description && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{task.description.length > 100
? `${task.description.substring(0, 100)}...`
: task.description
}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Chip
label={priorityConfig[task.priority as keyof typeof priorityConfig]?.label || task.priority}
size="small"
color={priorityConfig[task.priority as keyof typeof priorityConfig]?.color as any || 'default'}
variant="outlined"
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{assignedUser && (
<Avatar sx={{ width: 24, height: 24, fontSize: 12 }}>
{assignedUser.first_name[0]}{assignedUser.last_name[0]}
</Avatar>
)}
{task.comments && task.comments.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CommentIcon fontSize="small" color="action" />
<Typography variant="caption">{task.comments.length}</Typography>
</Box>
)}
{task.files && task.files.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<AttachFileIcon fontSize="small" color="action" />
<Typography variant="caption">{task.files.length}</Typography>
</Box>
)}
</Box>
{task.due_date && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ScheduleIcon fontSize="small" color="action" />
<Typography variant="caption">
{new Date(task.due_date).toLocaleDateString()}
</Typography>
</Box>
)}
</Box>
</CardContent>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={() => { onEdit(task); handleMenuClose(); }}>
<ListItemIcon>
<EditIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Edit</ListItemText>
</MenuItem>
<MenuItem onClick={() => { onDelete(task); handleMenuClose(); }} sx={{ color: 'error.main' }}>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
</Menu>
</Card>
);
};
const Column: React.FC<ColumnProps> = ({ status, tasks, onTaskEdit, onTaskDelete, users }) => {
const config = statusConfig[status as keyof typeof statusConfig];
return (
<Box sx={{ minHeight: 400 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{config?.label || status}
</Typography>
<Chip
label={tasks.length}
size="small"
color={config?.color as any || 'default'}
variant="outlined"
/>
</Box>
</Box>
<SortableContext items={tasks.map(task => task.id)} strategy={verticalListSortingStrategy}>
{tasks.map((task) => (
<SortableTaskCard
key={task.id}
task={task}
onEdit={onTaskEdit}
onDelete={onTaskDelete}
users={users}
/>
))}
</SortableContext>
</Box>
);
};
const KanbanBoard: React.FC<KanbanBoardProps> = ({
projectId,
tasks,
onTaskUpdate,
onTaskDelete,
users,
}) => {
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [formData, setFormData] = useState({
title: '',
description: '',
status: '',
priority: '',
assigned_to_id: '',
due_date: '',
});
const handleDragStart = (event: DragStartEvent) => {
const task = tasks.find(t => t.id === event.active.id);
setActiveTask(task || null);
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
setActiveTask(null);
if (!over) return;
const task = tasks.find(t => t.id === active.id);
if (!task) return;
const newStatus = over.id as string;
if (task.status === newStatus) return;
try {
const updatedTask = { ...task, status: newStatus as Task['status'] };
await tasksAPI.updateTask(projectId, task.id, { status: newStatus });
onTaskUpdate(updatedTask);
} catch (error) {
console.error('Failed to update task status:', error);
}
};
const handleTaskEdit = (task: Task) => {
setSelectedTask(task);
setFormData({
title: task.title,
description: task.description || '',
status: task.status,
priority: task.priority,
assigned_to_id: task.assigned_to_id?.toString() || '',
due_date: task.due_date ? new Date(task.due_date).toISOString().split('T')[0] : '',
});
setEditDialogOpen(true);
};
const handleTaskDelete = (task: Task) => {
setSelectedTask(task);
setDeleteDialogOpen(true);
};
const handleUpdateTask = async () => {
if (!selectedTask) return;
try {
const updateData = {
title: formData.title,
description: formData.description,
status: formData.status,
priority: formData.priority,
assigned_to_id: formData.assigned_to_id ? parseInt(formData.assigned_to_id) : undefined,
due_date: formData.due_date || undefined,
};
const response = await tasksAPI.updateTask(projectId, selectedTask.id, updateData);
if (response.success && response.data) {
onTaskUpdate(response.data);
setEditDialogOpen(false);
setSelectedTask(null);
}
} catch (error) {
console.error('Failed to update task:', error);
}
};
const handleConfirmDelete = async () => {
if (!selectedTask) return;
try {
await tasksAPI.deleteTask(projectId, selectedTask.id);
onTaskDelete(selectedTask.id);
setDeleteDialogOpen(false);
setSelectedTask(null);
} catch (error) {
console.error('Failed to delete task:', error);
}
};
const tasksByStatus = {
todo: tasks.filter(task => task.status === 'todo'),
in_progress: tasks.filter(task => task.status === 'in_progress'),
review: tasks.filter(task => task.status === 'review'),
done: tasks.filter(task => task.status === 'done'),
};
return (
<Box>
<DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
collisionDetection={closestCorners}
>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 3 }}>
{Object.entries(tasksByStatus).map(([status, statusTasks]) => (
<Column
key={status}
status={status}
tasks={statusTasks}
onTaskEdit={handleTaskEdit}
onTaskDelete={handleTaskDelete}
users={users}
/>
))}
</Box>
<DragOverlay>
{activeTask ? (
<SortableTaskCard
task={activeTask}
onEdit={handleTaskEdit}
onDelete={handleTaskDelete}
users={users}
/>
) : null}
</DragOverlay>
</DndContext>
{/* Edit Task Dialog */}
<Dialog open={editDialogOpen} onClose={() => setEditDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Task</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Title"
fullWidth
variant="outlined"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label="Description"
fullWidth
multiline
rows={3}
variant="outlined"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
sx={{ mb: 2 }}
/>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 2 }}>
<FormControl fullWidth>
<InputLabel>Status</InputLabel>
<Select
value={formData.status}
label="Status"
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
>
{Object.entries(statusConfig).map(([value, config]) => (
<SelectMenuItem key={value} value={value}>
{config.label}
</SelectMenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Priority</InputLabel>
<Select
value={formData.priority}
label="Priority"
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
>
{Object.entries(priorityConfig).map(([value, config]) => (
<SelectMenuItem key={value} value={value}>
{config.label}
</SelectMenuItem>
))}
</Select>
</FormControl>
</Box>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Assigned To</InputLabel>
<Select
value={formData.assigned_to_id}
label="Assigned To"
onChange={(e) => setFormData({ ...formData, assigned_to_id: e.target.value })}
>
<SelectMenuItem value="">Unassigned</SelectMenuItem>
{users.map((user) => (
<SelectMenuItem key={user.id} value={user.id.toString()}>
{user.first_name} {user.last_name}
</SelectMenuItem>
))}
</Select>
</FormControl>
<TextField
margin="dense"
label="Due Date"
type="date"
fullWidth
variant="outlined"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
InputLabelProps={{ shrink: true }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleUpdateTask}
variant="contained"
disabled={!formData.title.trim()}
>
Update Task
</Button>
</DialogActions>
</Dialog>
{/* Delete Task Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Delete Task</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete "{selectedTask?.title}"? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleConfirmDelete} color="error" variant="contained">
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default KanbanBoard;

View File

@@ -0,0 +1,114 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, authAPI } from '../services/api';
interface AuthContextType {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
register: (data: {
email: string;
password: string;
first_name: string;
last_name: string;
}) => Promise<void>;
logout: () => void;
loading: boolean;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const storedToken = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
}
setLoading(false);
}, []);
const login = async (email: string, password: string) => {
try {
const response = await authAPI.login(email, password);
if (response.success && response.data) {
const { token: authToken, user: authUser } = response.data;
localStorage.setItem('token', authToken);
localStorage.setItem('user', JSON.stringify(authUser));
setToken(authToken);
setUser(authUser);
} else {
throw new Error(response.error || 'Login failed');
}
} catch (error) {
throw error;
}
};
const register = async (data: {
email: string;
password: string;
first_name: string;
last_name: string;
}) => {
try {
const response = await authAPI.register(data);
if (response.success && response.data) {
const { token: authToken, user: authUser } = response.data;
localStorage.setItem('token', authToken);
localStorage.setItem('user', JSON.stringify(authUser));
setToken(authToken);
setUser(authUser);
} else {
throw new Error(response.error || 'Registration failed');
}
} catch (error) {
throw error;
}
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
};
const value: AuthContextType = {
user,
token,
login,
register,
logout,
loading,
isAuthenticated: !!user && !!token,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

13
frontend/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
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;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

19
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
frontend/src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Chip,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
LinearProgress,
} from '@mui/material';
import {
Add as AddIcon,
Assignment as AssignmentIcon,
Folder as FolderIcon,
People as PeopleIcon,
TrendingUp as TrendingUpIcon,
MoreVert as MoreVertIcon,
} from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import { Project, Task, projectsAPI } from '../services/api';
import { useNavigate } from 'react-router-dom';
const Dashboard: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const { user } = useAuth();
const navigate = useNavigate();
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
try {
const response = await projectsAPI.getProjects();
if (response.success && response.data) {
setProjects(response.data);
}
} catch (error) {
console.error('Failed to fetch projects:', error);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'success';
case 'completed': return 'default';
case 'on_hold': return 'warning';
default: return 'primary';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent': return 'error';
case 'high': return 'warning';
case 'medium': return 'info';
case 'low': return 'success';
default: return 'default';
}
};
if (loading) {
return <LinearProgress />;
}
return (
<Box>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Welcome back, {user?.first_name}!
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Here's what's happening with your projects today.
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 3, mb: 4 }}>
{/* Stats Cards */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<FolderIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">Projects</Typography>
</Box>
<Typography variant="h4">{projects.length}</Typography>
<Typography color="text.secondary">Active projects</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<AssignmentIcon color="secondary" sx={{ mr: 1 }} />
<Typography variant="h6">Tasks</Typography>
</Box>
<Typography variant="h4">
{projects.reduce((total, project) => total + (project.tasks?.length || 0), 0)}
</Typography>
<Typography color="text.secondary">Total tasks</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<PeopleIcon color="success" sx={{ mr: 1 }} />
<Typography variant="h6">Team</Typography>
</Box>
<Typography variant="h4">
{new Set(projects.flatMap(p => p.members?.map(m => m.id) || [])).size}
</Typography>
<Typography color="text.secondary">Team members</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<TrendingUpIcon color="info" sx={{ mr: 1 }} />
<Typography variant="h6">Progress</Typography>
</Box>
<Typography variant="h4">85%</Typography>
<Typography color="text.secondary">Overall progress</Typography>
</CardContent>
</Card>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '2fr 1fr' }, gap: 3 }}>
{/* Recent Projects */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Recent Projects</Typography>
<Button
startIcon={<AddIcon />}
onClick={() => navigate('/projects/new')}
variant="outlined"
size="small"
>
New Project
</Button>
</Box>
{projects.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<FolderIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
No projects yet
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Create your first project to get started
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => navigate('/projects/new')}
>
Create Project
</Button>
</Box>
) : (
<List>
{projects.slice(0, 5).map((project) => (
<ListItem
key={project.id}
component="div"
onClick={() => navigate(`/projects/${project.id}`)}
sx={{ cursor: 'pointer' }}
>
<Box sx={{ width: 12, height: 12, borderRadius: '50%', bgcolor: project.color, mr: 2 }} />
<ListItemText
primary={project.name}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
<Chip
label={project.status}
size="small"
color={getStatusColor(project.status) as any}
variant="outlined"
/>
<Typography variant="caption" color="text.secondary">
{project.tasks?.length || 0} tasks
</Typography>
</Box>
}
/>
<ListItemSecondaryAction>
<IconButton edge="end">
<MoreVertIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</CardContent>
</Card>
{/* Recent Tasks */}
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 2 }}>Recent Tasks</Typography>
{projects.flatMap(p => p.tasks || []).length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<AssignmentIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="body2" color="text.secondary">
No tasks yet
</Typography>
</Box>
) : (
<List dense>
{projects
.flatMap(p => p.tasks?.map((task: Task) => ({ ...task, projectName: p.name })) || [])
.slice(0, 5)
.map((task: any) => (
<ListItem key={task.id} dense>
<ListItemText
primary={
<Typography variant="body2" noWrap>
{task.title}
</Typography>
}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
<Chip
label={task.priority}
size="small"
color={getPriorityColor(task.priority) as any}
variant="outlined"
/>
<Typography variant="caption" color="text.secondary">
{task.projectName}
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
)}
</CardContent>
</Card>
</Box>
</Box>
);
};
export default Dashboard;

View File

@@ -0,0 +1,334 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
CardActions,
Typography,
Button,
Chip,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Avatar,
AvatarGroup,
} from '@mui/material';
import {
Add as AddIcon,
MoreVert as MoreVertIcon,
Edit as EditIcon,
Delete as DeleteIcon,
People as PeopleIcon,
Assignment as AssignmentIcon,
CalendarToday as CalendarIcon,
} from '@mui/icons-material';
import { Project, projectsAPI } from '../services/api';
import { useNavigate } from 'react-router-dom';
const Projects: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [openDialog, setOpenDialog] = useState(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
color: '#3b82f6',
});
const navigate = useNavigate();
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
try {
const response = await projectsAPI.getProjects();
if (response.success && response.data) {
setProjects(response.data);
}
} catch (error) {
console.error('Failed to fetch projects:', error);
} finally {
setLoading(false);
}
};
const handleCreateProject = async () => {
try {
const response = await projectsAPI.createProject(formData);
if (response.success && response.data) {
setProjects([response.data, ...projects]);
setOpenDialog(false);
setFormData({ name: '', description: '', color: '#3b82f6' });
}
} catch (error) {
console.error('Failed to create project:', error);
}
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, project: Project) => {
setAnchorEl(event.currentTarget);
setSelectedProject(project);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedProject(null);
};
const handleDeleteProject = async () => {
if (!selectedProject) return;
try {
await projectsAPI.deleteProject(selectedProject.id);
setProjects(projects.filter(p => p.id !== selectedProject.id));
handleMenuClose();
} catch (error) {
console.error('Failed to delete project:', error);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'success';
case 'completed': return 'default';
case 'on_hold': return 'warning';
default: return 'primary';
}
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'No date set';
return new Date(dateString).toLocaleDateString();
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1">
Projects
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
>
New Project
</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 3 }}>
{projects.map((project) => (
<Box key={project.id}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
transition: 'transform 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 4,
},
}}
onClick={() => navigate(`/projects/${project.id}`)}
>
<CardContent sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: project.color,
mr: 1,
}}
/>
<Typography variant="h6" component="h2">
{project.name}
</Typography>
</Box>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleMenuOpen(e, project);
}}
>
<MoreVertIcon />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{project.description || 'No description provided'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Chip
label={project.status}
size="small"
color={getStatusColor(project.status) as any}
variant="outlined"
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<AssignmentIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{project.tasks?.length || 0} tasks
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CalendarIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{formatDate(project.end_date)}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">
Owner: {project.owner.first_name} {project.owner.last_name}
</Typography>
<AvatarGroup max={3} sx={{ '& .MuiAvatar-root': { width: 24, height: 24, fontSize: 12 } }}>
{project.members?.slice(0, 3).map((member) => (
<Avatar key={member.id} sx={{ bgcolor: project.color }}>
{member.first_name[0]}{member.last_name[0]}
</Avatar>
))}
</AvatarGroup>
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'space-between', px: 2, pb: 2 }}>
<Button size="small" onClick={(e) => {
e.stopPropagation();
navigate(`/projects/${project.id}`);
}}>
View Details
</Button>
<Button size="small" onClick={(e) => {
e.stopPropagation();
navigate(`/projects/${project.id}/tasks`);
}}>
View Tasks
</Button>
</CardActions>
</Card>
</Box>
))}
</Box>
{projects.length === 0 && !loading && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h6" color="text.secondary" sx={{ mb: 2 }}>
No projects found
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Create your first project to get started with task management
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
>
Create Project
</Button>
</Box>
)}
{/* Create Project Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create New Project</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Project Name"
fullWidth
variant="outlined"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label="Description"
fullWidth
multiline
rows={3}
variant="outlined"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label="Color"
type="color"
fullWidth
variant="outlined"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
<Button
onClick={handleCreateProject}
variant="contained"
disabled={!formData.name.trim()}
>
Create Project
</Button>
</DialogActions>
</Dialog>
{/* Project Menu */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={() => {
navigate(`/projects/${selectedProject?.id}/edit`);
handleMenuClose();
}}>
<ListItemIcon>
<EditIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Edit</ListItemText>
</MenuItem>
<MenuItem onClick={() => {
navigate(`/projects/${selectedProject?.id}/members`);
handleMenuClose();
}}>
<ListItemIcon>
<PeopleIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Manage Members</ListItemText>
</MenuItem>
<MenuItem onClick={handleDeleteProject} sx={{ color: 'error.main' }}>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
</Menu>
</Box>
);
};
export default Projects;

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,294 @@
import axios from 'axios';
const API_BASE_URL = 'http://localhost:8080/api/v1';
// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Types
export interface User {
id: number;
email: string;
first_name: string;
last_name: string;
avatar?: string;
role: string;
created_at: string;
updated_at: string;
}
export interface Project {
id: number;
name: string;
description: string;
status: string;
color: string;
start_date?: string;
end_date?: string;
owner_id: number;
owner: User;
members: User[];
tasks?: Task[];
created_at: string;
updated_at: string;
}
export interface Task {
id: number;
title: string;
description: string;
status: 'todo' | 'in_progress' | 'review' | 'done';
priority: 'low' | 'medium' | 'high' | 'urgent';
due_date?: string;
completed_at?: string;
position: number;
project_id: number;
assigned_to_id?: number;
created_by_id: number;
assigned_to?: User;
created_by: User;
comments: Comment[];
files: FileUpload[];
subtasks: Subtask[];
labels: Label[];
created_at: string;
updated_at: string;
}
export interface Subtask {
id: number;
title: string;
description: string;
status: 'todo' | 'in_progress' | 'done';
position: number;
completed_at?: string;
parent_task_id: number;
assigned_to_id?: number;
assigned_to?: User;
created_at: string;
updated_at: string;
}
export interface Label {
id: number;
name: string;
color: string;
project_id: number;
created_at: string;
updated_at: string;
}
export interface Comment {
id: number;
content: string;
task_id: number;
user_id: number;
user: User;
created_at: string;
updated_at: string;
}
export interface FileUpload {
id: number;
file_name: string;
original_name: string;
file_path: string;
file_size: number;
mime_type: string;
description?: string;
uploaded_by_id: number;
project_id?: number;
task_id?: number;
uploaded_by: User;
project?: Project;
task?: Task;
created_at: string;
updated_at: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface APIResponse<T = any> {
success: boolean;
message: string;
data?: T;
error?: string;
}
// Auth API
export const authAPI = {
login: (email: string, password: string): Promise<APIResponse<AuthResponse>> =>
api.post('/auth/login', { email, password }).then(res => res.data),
register: (data: {
email: string;
password: string;
first_name: string;
last_name: string;
}): Promise<APIResponse<AuthResponse>> =>
api.post('/auth/register', data).then(res => res.data),
getProfile: (): Promise<APIResponse<User>> =>
api.get('/auth/profile').then(res => res.data),
updateProfile: (data: {
first_name?: string;
last_name?: string;
avatar?: string;
}): Promise<APIResponse<User>> =>
api.put('/auth/profile', data).then(res => res.data),
changePassword: (data: {
current_password: string;
new_password: string;
}): Promise<APIResponse> =>
api.put('/auth/change-password', data).then(res => res.data),
};
// Projects API
export const projectsAPI = {
getProjects: (): Promise<APIResponse<Project[]>> =>
api.get('/projects').then(res => res.data),
getProject: (id: number): Promise<APIResponse<Project>> =>
api.get(`/projects/${id}`).then(res => res.data),
createProject: (data: {
name: string;
description?: string;
color?: string;
}): Promise<APIResponse<Project>> =>
api.post('/projects', data).then(res => res.data),
updateProject: (id: number, data: {
name?: string;
description?: string;
status?: string;
color?: string;
}): Promise<APIResponse<Project>> =>
api.put(`/projects/${id}`, data).then(res => res.data),
deleteProject: (id: number): Promise<APIResponse> =>
api.delete(`/projects/${id}`).then(res => res.data),
addMember: (id: number, data: {
user_id: number;
role?: string;
}): Promise<APIResponse<Project>> =>
api.post(`/projects/${id}/members`, data).then(res => res.data),
removeMember: (projectId: number, userId: number): Promise<APIResponse<Project>> =>
api.delete(`/projects/${projectId}/members/${userId}`).then(res => res.data),
};
// Tasks API
export const tasksAPI = {
getTasks: (projectId: number): Promise<APIResponse<Task[]>> =>
api.get(`/projects/${projectId}/tasks`).then(res => res.data),
getTask: (projectId: number, taskId: number): Promise<APIResponse<Task>> =>
api.get(`/projects/${projectId}/tasks/${taskId}`).then(res => res.data),
createTask: (projectId: number, data: {
title: string;
description?: string;
priority?: string;
due_date?: string;
assigned_to_id?: number;
}): Promise<APIResponse<Task>> =>
api.post(`/projects/${projectId}/tasks`, data).then(res => res.data),
updateTask: (projectId: number, taskId: number, data: {
title?: string;
description?: string;
status?: string;
priority?: string;
due_date?: string;
assigned_to_id?: number;
position?: number;
}): Promise<APIResponse<Task>> =>
api.put(`/projects/${projectId}/tasks/${taskId}`, data).then(res => res.data),
deleteTask: (projectId: number, taskId: number): Promise<APIResponse> =>
api.delete(`/projects/${projectId}/tasks/${taskId}`).then(res => res.data),
addComment: (projectId: number, taskId: number, data: {
content: string;
}): Promise<APIResponse<Comment>> =>
api.post(`/projects/${projectId}/tasks/${taskId}/comments`, data).then(res => res.data),
createSubtask: (projectId: number, taskId: number, data: {
title: string;
description?: string;
assigned_to_id?: number;
}): Promise<APIResponse<Subtask>> =>
api.post(`/projects/${projectId}/tasks/${taskId}/subtasks`, data).then(res => res.data),
};
// Files API
export const filesAPI = {
getFiles: (projectId: number): Promise<APIResponse<FileUpload[]>> =>
api.get(`/projects/${projectId}/files`).then(res => res.data),
getTaskFiles: (projectId: number, taskId: number): Promise<APIResponse<FileUpload[]>> =>
api.get(`/projects/${projectId}/tasks/${taskId}/files`).then(res => res.data),
uploadFile: (projectId: number, file: File, taskId?: number): Promise<APIResponse<FileUpload>> => {
const formData = new FormData();
formData.append('file', file);
if (taskId) {
formData.append('task_id', taskId.toString());
}
return api.post(`/projects/${projectId}/files`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
}).then(res => res.data);
},
downloadFile: (projectId: number, fileId: number): Promise<Blob> =>
api.get(`/projects/${projectId}/files/${fileId}`, {
responseType: 'blob',
}).then(res => res.data),
deleteFile: (projectId: number, fileId: number): Promise<APIResponse> =>
api.delete(`/projects/${projectId}/files/${fileId}`).then(res => res.data),
};
export default api;

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}