first commit
This commit is contained in:
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal 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
46
frontend/README.md
Normal 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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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
18405
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
frontend/package.json
Normal file
57
frontend/package.json
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
43
frontend/public/index.html
Normal file
43
frontend/public/index.html
Normal 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
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
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal 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"
|
||||
}
|
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
38
frontend/src/App.css
Normal file
38
frontend/src/App.css
Normal 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);
|
||||
}
|
||||
}
|
9
frontend/src/App.test.tsx
Normal file
9
frontend/src/App.test.tsx
Normal 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
121
frontend/src/App.tsx
Normal 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;
|
134
frontend/src/components/auth/Login.tsx
Normal file
134
frontend/src/components/auth/Login.tsx
Normal 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;
|
187
frontend/src/components/auth/Register.tsx
Normal file
187
frontend/src/components/auth/Register.tsx
Normal 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;
|
222
frontend/src/components/layout/DashboardLayout.tsx
Normal file
222
frontend/src/components/layout/DashboardLayout.tsx
Normal 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;
|
498
frontend/src/components/tasks/KanbanBoard.tsx
Normal file
498
frontend/src/components/tasks/KanbanBoard.tsx
Normal 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;
|
114
frontend/src/contexts/AuthContext.tsx
Normal file
114
frontend/src/contexts/AuthContext.tsx
Normal 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
13
frontend/src/index.css
Normal 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
19
frontend/src/index.tsx
Normal 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
1
frontend/src/logo.svg
Normal 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 |
257
frontend/src/pages/Dashboard.tsx
Normal file
257
frontend/src/pages/Dashboard.tsx
Normal 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;
|
334
frontend/src/pages/Projects.tsx
Normal file
334
frontend/src/pages/Projects.tsx
Normal 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
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
15
frontend/src/reportWebVitals.ts
Normal file
15
frontend/src/reportWebVitals.ts
Normal 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;
|
294
frontend/src/services/api.ts
Normal file
294
frontend/src/services/api.ts
Normal 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;
|
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal 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
26
frontend/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user