first commit
This commit is contained in:
233
README.md
Normal file
233
README.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Dumbass Dashboard
|
||||
|
||||
*Because apparently I can't just download a fucking dashboard like a normal person...*
|
||||
|
||||
A comprehensive project and task management application built with Go backend and React frontend, featuring task boards, timelines, file uploads, and team collaboration. Because I wanted to build my own goddamn dashboard instead of using one of the million existing solutions. ¯\_(ツ)_/¯
|
||||
|
||||
## Why The Fuck Did I Build This?
|
||||
|
||||
Look, I know there are already like 47 different project management tools out there. Trello, Asana, Monday.com, Notion, Jira, Basecamp, and probably 40 more that I can't even remember. But here's the thing - I wanted to build my own fucking dashboard because:
|
||||
|
||||
1. **I'm stubborn as hell** - When someone tells me "just use an existing solution," my immediate response is "fuck that, I can do it better"
|
||||
2. **Learning experience** - I wanted to actually understand how this shit works under the hood instead of just clicking buttons
|
||||
3. **Full control** - I don't want to deal with someone else's bugs, limitations, or "premium features" that require a credit card
|
||||
4. **Customization** - I want to add features that make sense to me, not whatever some product manager thinks users want
|
||||
5. **Because I can** - Sometimes the best reason is just because you can
|
||||
|
||||
So yeah, I spent way too much time building this when I could have just downloaded Trello and been done with it in 5 minutes. But now I have a dashboard that actually does exactly what I want it to do, and I understand every single line of code in it. That's worth something, right?
|
||||
|
||||
## Features (Because I Had To Build This Shit Myself)
|
||||
|
||||
### Backend (Go)
|
||||
- **RESTful API** built with Gin framework (because Express.js is for plebs)
|
||||
- **Database Models** for users, projects, tasks, and files using GORM (SQLite because I'm not dealing with PostgreSQL bullshit)
|
||||
- **Authentication & Authorization** with JWT tokens and role-based access (security is important, apparently)
|
||||
- **File Upload & Storage** with support for various file types (because everyone needs to upload their memes)
|
||||
- **Task Management** with CRUD operations and status tracking (Kanban boards are overrated anyway)
|
||||
- **Team Collaboration** with project members and permissions (because working alone is for losers)
|
||||
|
||||
### Frontend (React + TypeScript)
|
||||
- **Modern UI** built with Material-UI components (Google's design system, not my fault it's everywhere)
|
||||
- **Responsive Design** that works on desktop and mobile (mobile-first my ass)
|
||||
- **Task Board (Kanban)** with drag-and-drop functionality (because clicking is too mainstream)
|
||||
- **Project Management** with team collaboration features (assigning blame has never been easier)
|
||||
- **Authentication Flow** with login/register pages (password security is for nerds)
|
||||
- **File Management** with upload and download capabilities (because email attachments are so 2010)
|
||||
|
||||
## Tech Stack (The Tools I Actually Used)
|
||||
|
||||
### Backend
|
||||
- **Go 1.22+** - Programming language (because Python is for data scientists and JavaScript is for frontend peasants)
|
||||
- **Gin** - Web framework (lightweight and fast, unlike Express.js bloatware)
|
||||
- **GORM** - ORM for database operations (because writing raw SQL is for masochists)
|
||||
- **SQLite** - Database (because I don't want to deal with Docker containers and database servers)
|
||||
- **JWT** - Authentication tokens (because sessions are so 2010)
|
||||
- **CORS** - Cross-origin resource sharing (because browsers are paranoid)
|
||||
|
||||
### Frontend
|
||||
- **React 19** - UI framework (because Vue.js is for hipsters and Angular is for enterprise drones)
|
||||
- **TypeScript** - Type safety (because JavaScript errors at runtime are fun, but not when you're debugging at 2 AM)
|
||||
- **Material-UI** - Component library (Google's design system, fight me)
|
||||
- **React Router** - Client-side routing (because page refreshes are for cavemen)
|
||||
- **Axios** - HTTP client (because fetch() API is too verbose)
|
||||
- **@dnd-kit** - Drag and drop functionality (because react-beautiful-dnd doesn't work with React 19)
|
||||
|
||||
## Prerequisites (Because You Need These To Run My Code)
|
||||
|
||||
- Go 1.22 or higher (because I'm not supporting legacy versions)
|
||||
- Node.js 18 or higher (npm comes with it, don't ask)
|
||||
- A functioning brain (optional but recommended)
|
||||
|
||||
## Installation (The Fun Part)
|
||||
|
||||
### Backend Setup (The Go Part)
|
||||
|
||||
1. Navigate to the backend directory (because I organized it this way):
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. Install dependencies (Go will figure out what you need):
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
3. Run the server (and pray it works):
|
||||
```bash
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
|
||||
The backend will start on `http://localhost:8080` (unless something's already using that port, then you're fucked)
|
||||
|
||||
### Frontend Setup (The JavaScript Hell)
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. Install dependencies (this might take a while, grab some coffee):
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server (and watch the magic happen):
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The frontend will start on `http://localhost:3000` (React will probably open it in your browser automatically)
|
||||
|
||||
## Usage (How To Actually Use This Thing)
|
||||
|
||||
### Default Admin Account (Because I'm Lazy)
|
||||
- **Email**: admin@example.com
|
||||
- **Password**: admin123 (yes, I know it's weak, don't judge me)
|
||||
|
||||
### Getting Started (The Not-So-Fun Part)
|
||||
|
||||
1. **Login** with the admin credentials or create a new account (because everyone loves registration forms)
|
||||
2. **Create a Project** from the projects page (because apparently you need projects to manage)
|
||||
3. **Add Team Members** to your project (because working alone is for introverts)
|
||||
4. **Create Tasks** and organize them in the Kanban board (because sticky notes are so analog)
|
||||
5. **Upload Files** and collaborate with your team (because email attachments are for boomers)
|
||||
|
||||
### Key Features (The Stuff That Actually Works)
|
||||
|
||||
#### Project Management (Because You Need Structure)
|
||||
- Create and manage multiple projects (because one project is never enough)
|
||||
- Assign team members with different roles (because hierarchy is important)
|
||||
- Track project progress and deadlines (because deadlines are fun)
|
||||
|
||||
#### Task Management (The Core Functionality)
|
||||
- Create tasks with priorities and due dates (because everything is urgent)
|
||||
- Organize tasks in Kanban board (To Do, In Progress, Review, Done - because 4 statuses are enough)
|
||||
- Assign tasks to team members (because delegation is key)
|
||||
- Add comments and subtasks (because complexity is beautiful)
|
||||
|
||||
#### File Management (Because We're Not In 1995)
|
||||
- Upload files to projects or specific tasks (because attachments are overrated)
|
||||
- Download and manage uploaded files (because local storage is so last decade)
|
||||
- Support for various file types (images, documents, etc. - because variety is the spice of life)
|
||||
|
||||
#### Team Collaboration (Because Misery Loves Company)
|
||||
- Add members to projects (because the more the merrier)
|
||||
- Role-based permissions (Owner, Manager, Member - because not everyone is equal)
|
||||
- Real-time task updates (because waiting is for peasants)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/login` - User login
|
||||
- `POST /api/v1/auth/register` - User registration
|
||||
- `GET /api/v1/auth/profile` - Get user profile
|
||||
- `PUT /api/v1/auth/profile` - Update user profile
|
||||
|
||||
### Projects
|
||||
- `GET /api/v1/projects` - Get user's projects
|
||||
- `POST /api/v1/projects` - Create new project
|
||||
- `GET /api/v1/projects/:id` - Get project details
|
||||
- `PUT /api/v1/projects/:id` - Update project
|
||||
- `DELETE /api/v1/projects/:id` - Delete project
|
||||
|
||||
### Tasks
|
||||
- `GET /api/v1/projects/:id/tasks` - Get project tasks
|
||||
- `POST /api/v1/projects/:id/tasks` - Create new task
|
||||
- `GET /api/v1/projects/:id/tasks/:taskId` - Get task details
|
||||
- `PUT /api/v1/projects/:id/tasks/:taskId` - Update task
|
||||
- `DELETE /api/v1/projects/:id/tasks/:taskId` - Delete task
|
||||
|
||||
### Files
|
||||
- `POST /api/v1/projects/:id/files` - Upload file
|
||||
- `GET /api/v1/projects/:id/files` - Get project files
|
||||
- `GET /api/v1/projects/:id/files/:fileId` - Download file
|
||||
- `DELETE /api/v1/projects/:id/files/:fileId` - Delete file
|
||||
|
||||
## Development
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
# Run with hot reload (requires air)
|
||||
go install github.com/cosmtrek/air@latest
|
||||
air
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
```bash
|
||||
# Start development server
|
||||
npm start
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application uses SQLite by default with the following main entities:
|
||||
|
||||
- **Users** - User accounts with roles and authentication
|
||||
- **Projects** - Project containers with members and settings
|
||||
- **Tasks** - Individual tasks with status, priority, and assignments
|
||||
- **Subtasks** - Sub-tasks that belong to parent tasks
|
||||
- **Comments** - Task comments for collaboration
|
||||
- **FileUploads** - File attachments for projects and tasks
|
||||
- **Labels** - Custom labels for task categorization
|
||||
|
||||
## Configuration
|
||||
|
||||
### Backend Configuration
|
||||
- Database connection can be modified in `models/database.go`
|
||||
- JWT secret should be changed in production (middleware/auth.go)
|
||||
- File upload directory is configurable in `api/files.go`
|
||||
|
||||
### Frontend Configuration
|
||||
- API base URL can be changed in `src/services/api.ts`
|
||||
- Theme customization in `src/App.tsx`
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## Support (Good Luck With That)
|
||||
|
||||
For support or questions, please open an issue in the repository. But honestly, if you can't figure out how to use this thing, maybe you should just go back to using Trello. I'm not your personal tech support.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. Do whatever the fuck you want with it, I don't care.
|
||||
|
||||
## Final Thoughts
|
||||
|
||||
Look, I built this thing because I wanted to, not because I had to. It's not perfect, it's not revolutionary, and it's definitely not going to replace Jira anytime soon. But it's mine, I understand it, and it does exactly what I need it to do.
|
||||
|
||||
If you find it useful, great. If you think it's shit, that's fine too. I'm not trying to impress anyone - I just wanted to build my own damn dashboard instead of paying monthly fees for someone else's half-baked solution.
|
||||
|
||||
Now stop reading this README and go actually use the thing. Or don't. I really don't care.
|
||||
|
||||
---
|
||||
|
||||
*Built with ❤️ and a lot of caffeine by someone who refuses to use existing solutions like a normal person.*
|
208
backend/api/auth.go
Normal file
208
backend/api/auth.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"project-dashboard/api/utils"
|
||||
"project-dashboard/middleware"
|
||||
"project-dashboard/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
FirstName string `json:"first_name" binding:"required"`
|
||||
LastName string `json:"last_name" binding:"required"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User models.User `json:"user"`
|
||||
}
|
||||
|
||||
// Register creates a new user account
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
var existingUser models.User
|
||||
if err := models.GetDB().Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
|
||||
utils.ErrorResponse(c, http.StatusConflict, "User with this email already exists")
|
||||
return
|
||||
}
|
||||
|
||||
// Create new user
|
||||
user := models.User{
|
||||
Email: req.Email,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Role: "member", // Default role
|
||||
}
|
||||
|
||||
// Hash password
|
||||
if err := user.HashPassword(req.Password); err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
// Save user to database
|
||||
if err := models.GetDB().Create(&user).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to create user")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate token
|
||||
token, err := middleware.GenerateToken(&user)
|
||||
if err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusCreated, "User created successfully", AuthResponse{
|
||||
Token: token,
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// Login authenticates a user
|
||||
func Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
var user models.User
|
||||
if err := models.GetDB().Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "Invalid email or password")
|
||||
return
|
||||
}
|
||||
|
||||
// Check password
|
||||
if !user.CheckPassword(req.Password) {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "Invalid email or password")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate token
|
||||
token, err := middleware.GenerateToken(&user)
|
||||
if err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Login successful", AuthResponse{
|
||||
Token: token,
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProfile returns the current user's profile
|
||||
func GetProfile(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Profile retrieved successfully", user)
|
||||
}
|
||||
|
||||
// UpdateProfile updates the current user's profile
|
||||
func UpdateProfile(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
|
||||
var updateData struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&updateData); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if updateData.FirstName != "" {
|
||||
userObj.FirstName = updateData.FirstName
|
||||
}
|
||||
if updateData.LastName != "" {
|
||||
userObj.LastName = updateData.LastName
|
||||
}
|
||||
if updateData.Avatar != "" {
|
||||
userObj.Avatar = updateData.Avatar
|
||||
}
|
||||
|
||||
if err := models.GetDB().Save(userObj).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to update profile")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Profile updated successfully", userObj)
|
||||
}
|
||||
|
||||
// ChangePassword changes the user's password
|
||||
func ChangePassword(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if !userObj.CheckPassword(req.CurrentPassword) {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "Current password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
if err := userObj.HashPassword(req.NewPassword); err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to hash new password")
|
||||
return
|
||||
}
|
||||
|
||||
if err := models.GetDB().Save(userObj).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to update password")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Password changed successfully", nil)
|
||||
}
|
243
backend/api/files.go
Normal file
243
backend/api/files.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"project-dashboard/api/utils"
|
||||
"project-dashboard/models"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// UploadFile handles file uploads
|
||||
func UploadFile(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
// Get task ID if provided
|
||||
taskIDStr := c.PostForm("task_id")
|
||||
var taskID *uint
|
||||
if taskIDStr != "" {
|
||||
if id, err := strconv.ParseUint(taskIDStr, 10, 32); err == nil {
|
||||
taskIDUint := uint(id)
|
||||
taskID = &taskIDUint
|
||||
}
|
||||
}
|
||||
|
||||
// Get file from form
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "No file provided")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate file size (10MB limit)
|
||||
const maxFileSize = 10 << 20 // 10MB
|
||||
if header.Size > maxFileSize {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "File size too large (max 10MB)")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
ext := filepath.Ext(header.Filename)
|
||||
fileName := uuid.New().String() + ext
|
||||
|
||||
// Create uploads directory if it doesn't exist
|
||||
uploadDir := "./uploads"
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to create upload directory")
|
||||
return
|
||||
}
|
||||
|
||||
// Save file
|
||||
filePath := filepath.Join(uploadDir, fileName)
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to create file")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to save file")
|
||||
return
|
||||
}
|
||||
|
||||
// Get MIME type
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
// Create file record
|
||||
fileUpload := models.FileUpload{
|
||||
FileName: fileName,
|
||||
OriginalName: header.Filename,
|
||||
FilePath: filePath,
|
||||
FileSize: header.Size,
|
||||
MimeType: mimeType,
|
||||
UploadedByID: userObj.ID,
|
||||
ProjectID: &projectObj.ID,
|
||||
TaskID: taskID,
|
||||
}
|
||||
|
||||
if err := models.GetDB().Create(&fileUpload).Error; err != nil {
|
||||
// Clean up file if database save fails
|
||||
os.Remove(filePath)
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to save file record")
|
||||
return
|
||||
}
|
||||
|
||||
// Load file with relationships
|
||||
models.GetDB().
|
||||
Preload("UploadedBy").
|
||||
Preload("Project").
|
||||
Preload("Task").
|
||||
First(&fileUpload, fileUpload.ID)
|
||||
|
||||
utils.SuccessResponse(c, http.StatusCreated, "File uploaded successfully", fileUpload)
|
||||
}
|
||||
|
||||
// GetFiles returns all files for a project
|
||||
func GetFiles(c *gin.Context) {
|
||||
project, exists := c.Get("project")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Project not found")
|
||||
return
|
||||
}
|
||||
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
var files []models.FileUpload
|
||||
|
||||
if err := models.GetDB().
|
||||
Preload("UploadedBy").
|
||||
Preload("Project").
|
||||
Preload("Task").
|
||||
Where("project_id = ?", projectObj.ID).
|
||||
Order("created_at DESC").
|
||||
Find(&files).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to fetch files")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Files retrieved successfully", files)
|
||||
}
|
||||
|
||||
// GetTaskFiles returns all files for a specific task
|
||||
func GetTaskFiles(c *gin.Context) {
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "Task ID required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify task exists and belongs to project
|
||||
var task models.Task
|
||||
if err := models.GetDB().
|
||||
Where("id = ? AND project_id = ?", taskID, projectObj.ID).
|
||||
First(&task).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Task not found")
|
||||
return
|
||||
}
|
||||
|
||||
var files []models.FileUpload
|
||||
|
||||
if err := models.GetDB().
|
||||
Preload("UploadedBy").
|
||||
Preload("Project").
|
||||
Preload("Task").
|
||||
Where("task_id = ?", taskID).
|
||||
Order("created_at DESC").
|
||||
Find(&files).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to fetch task files")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Task files retrieved successfully", files)
|
||||
}
|
||||
|
||||
// DownloadFile serves a file for download
|
||||
func DownloadFile(c *gin.Context) {
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
fileID := c.Param("file_id")
|
||||
if fileID == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "File ID required")
|
||||
return
|
||||
}
|
||||
|
||||
var fileUpload models.FileUpload
|
||||
if err := models.GetDB().
|
||||
Where("id = ? AND project_id = ?", fileID, projectObj.ID).
|
||||
First(&fileUpload).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "File not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists on disk
|
||||
if _, err := os.Stat(fileUpload.FilePath); os.IsNotExist(err) {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "File not found on disk")
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers for file download
|
||||
c.Header("Content-Description", "File Transfer")
|
||||
c.Header("Content-Transfer-Encoding", "binary")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileUpload.OriginalName))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
|
||||
// Serve file
|
||||
c.File(fileUpload.FilePath)
|
||||
}
|
||||
|
||||
// DeleteFile deletes a file
|
||||
func DeleteFile(c *gin.Context) {
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
fileID := c.Param("file_id")
|
||||
if fileID == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "File ID required")
|
||||
return
|
||||
}
|
||||
|
||||
var fileUpload models.FileUpload
|
||||
if err := models.GetDB().
|
||||
Where("id = ? AND project_id = ?", fileID, projectObj.ID).
|
||||
First(&fileUpload).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "File not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete file from disk
|
||||
if err := os.Remove(fileUpload.FilePath); err != nil {
|
||||
// Log error but continue with database deletion
|
||||
fmt.Printf("Failed to delete file from disk: %v\n", err)
|
||||
}
|
||||
|
||||
// Delete file record from database
|
||||
if err := models.GetDB().Delete(&fileUpload).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to delete file record")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "File deleted successfully", nil)
|
||||
}
|
312
backend/api/projects.go
Normal file
312
backend/api/projects.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"project-dashboard/api/utils"
|
||||
"project-dashboard/models"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type AddMemberRequest struct {
|
||||
UserID uint `json:"user_id" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// CreateProject creates a new project
|
||||
func CreateProject(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
|
||||
var req CreateProjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
project := models.Project{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Color: req.Color,
|
||||
OwnerID: userObj.ID,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if project.Color == "" {
|
||||
project.Color = "#3b82f6"
|
||||
}
|
||||
|
||||
if err := models.GetDB().Create(&project).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to create project")
|
||||
return
|
||||
}
|
||||
|
||||
// Add owner as project member
|
||||
member := models.ProjectMember{
|
||||
ProjectID: project.ID,
|
||||
UserID: userObj.ID,
|
||||
Role: "owner",
|
||||
}
|
||||
models.GetDB().Create(&member)
|
||||
|
||||
// Load project with relationships
|
||||
models.GetDB().Preload("Owner").Preload("Members").First(&project, project.ID)
|
||||
|
||||
utils.SuccessResponse(c, http.StatusCreated, "Project created successfully", project)
|
||||
}
|
||||
|
||||
// GetProjects returns all projects for the current user
|
||||
func GetProjects(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
|
||||
var projects []models.Project
|
||||
|
||||
// Get projects where user is owner or member
|
||||
if err := models.GetDB().
|
||||
Preload("Owner").
|
||||
Preload("Members").
|
||||
Where("owner_id = ? OR id IN (SELECT project_id FROM project_members WHERE user_id = ?)",
|
||||
userObj.ID, userObj.ID).
|
||||
Find(&projects).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to fetch projects")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Projects retrieved successfully", projects)
|
||||
}
|
||||
|
||||
// GetProject returns a specific project
|
||||
func GetProject(c *gin.Context) {
|
||||
project, exists := c.Get("project")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Project not found")
|
||||
return
|
||||
}
|
||||
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
// Load additional relationships
|
||||
models.GetDB().
|
||||
Preload("Owner").
|
||||
Preload("Members").
|
||||
Preload("Tasks").
|
||||
Preload("Files").
|
||||
First(projectObj, projectObj.ID)
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Project retrieved successfully", projectObj)
|
||||
}
|
||||
|
||||
// UpdateProject updates a project
|
||||
func UpdateProject(c *gin.Context) {
|
||||
project, exists := c.Get("project")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Project not found")
|
||||
return
|
||||
}
|
||||
|
||||
projectObj := project.(*models.Project)
|
||||
user, _ := c.Get("user")
|
||||
userObj := user.(*models.User)
|
||||
|
||||
// Check if user is owner or manager
|
||||
role := projectObj.GetMemberRole(userObj.ID)
|
||||
if role != "owner" && role != "manager" {
|
||||
utils.ErrorResponse(c, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Name != "" {
|
||||
projectObj.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
projectObj.Description = req.Description
|
||||
}
|
||||
if req.Status != "" {
|
||||
projectObj.Status = req.Status
|
||||
}
|
||||
if req.Color != "" {
|
||||
projectObj.Color = req.Color
|
||||
}
|
||||
|
||||
if err := models.GetDB().Save(projectObj).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to update project")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Project updated successfully", projectObj)
|
||||
}
|
||||
|
||||
// DeleteProject deletes a project
|
||||
func DeleteProject(c *gin.Context) {
|
||||
project, exists := c.Get("project")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Project not found")
|
||||
return
|
||||
}
|
||||
|
||||
projectObj := project.(*models.Project)
|
||||
user, _ := c.Get("user")
|
||||
userObj := user.(*models.User)
|
||||
|
||||
// Only owner can delete project
|
||||
if !projectObj.IsOwner(userObj.ID) {
|
||||
utils.ErrorResponse(c, http.StatusForbidden, "Only project owner can delete project")
|
||||
return
|
||||
}
|
||||
|
||||
if err := models.GetDB().Delete(projectObj).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to delete project")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Project deleted successfully", nil)
|
||||
}
|
||||
|
||||
// AddMember adds a member to the project
|
||||
func AddMember(c *gin.Context) {
|
||||
project, exists := c.Get("project")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Project not found")
|
||||
return
|
||||
}
|
||||
|
||||
projectObj := project.(*models.Project)
|
||||
user, _ := c.Get("user")
|
||||
userObj := user.(*models.User)
|
||||
|
||||
// Check if user has permission to add members
|
||||
role := projectObj.GetMemberRole(userObj.ID)
|
||||
if role != "owner" && role != "manager" {
|
||||
utils.ErrorResponse(c, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
var req AddMemberRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
var targetUser models.User
|
||||
if err := models.GetDB().First(&targetUser, req.UserID).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
if projectObj.HasMember(req.UserID) {
|
||||
utils.ErrorResponse(c, http.StatusConflict, "User is already a member of this project")
|
||||
return
|
||||
}
|
||||
|
||||
// Set default role
|
||||
if req.Role == "" {
|
||||
req.Role = "member"
|
||||
}
|
||||
|
||||
member := models.ProjectMember{
|
||||
ProjectID: projectObj.ID,
|
||||
UserID: req.UserID,
|
||||
Role: req.Role,
|
||||
}
|
||||
|
||||
if err := models.GetDB().Create(&member).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to add member")
|
||||
return
|
||||
}
|
||||
|
||||
// Load updated project
|
||||
models.GetDB().Preload("Members").First(projectObj, projectObj.ID)
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Member added successfully", projectObj)
|
||||
}
|
||||
|
||||
// RemoveMember removes a member from the project
|
||||
func RemoveMember(c *gin.Context) {
|
||||
project, exists := c.Get("project")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Project not found")
|
||||
return
|
||||
}
|
||||
|
||||
projectObj := project.(*models.Project)
|
||||
user, _ := c.Get("user")
|
||||
userObj := user.(*models.User)
|
||||
|
||||
// Check if user has permission to remove members
|
||||
role := projectObj.GetMemberRole(userObj.ID)
|
||||
if role != "owner" && role != "manager" {
|
||||
utils.ErrorResponse(c, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
userIDStr := c.Param("user_id")
|
||||
if userIDStr == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "User ID required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseUint(userIDStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is a member
|
||||
if !projectObj.HasMember(uint(userID)) {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "User is not a member of this project")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove member
|
||||
if err := models.GetDB().
|
||||
Where("project_id = ? AND user_id = ?", projectObj.ID, uint(userID)).
|
||||
Delete(&models.ProjectMember{}).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to remove member")
|
||||
return
|
||||
}
|
||||
|
||||
// Load updated project
|
||||
models.GetDB().Preload("Members").First(projectObj, projectObj.ID)
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Member removed successfully", projectObj)
|
||||
}
|
359
backend/api/tasks.go
Normal file
359
backend/api/tasks.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"project-dashboard/api/utils"
|
||||
"project-dashboard/models"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CreateTaskRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
DueDate string `json:"due_date"`
|
||||
AssignedToID *uint `json:"assigned_to_id"`
|
||||
}
|
||||
|
||||
type UpdateTaskRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
DueDate string `json:"due_date"`
|
||||
AssignedToID *uint `json:"assigned_to_id"`
|
||||
Position *int `json:"position"`
|
||||
}
|
||||
|
||||
type CreateSubtaskRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
AssignedToID *uint `json:"assigned_to_id"`
|
||||
}
|
||||
|
||||
type CreateCommentRequest struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateTask creates a new task
|
||||
func CreateTask(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
var req CreateTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
task := models.Task{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Priority: req.Priority,
|
||||
ProjectID: projectObj.ID,
|
||||
CreatedByID: userObj.ID,
|
||||
AssignedToID: req.AssignedToID,
|
||||
Status: "todo",
|
||||
}
|
||||
|
||||
if task.Priority == "" {
|
||||
task.Priority = "medium"
|
||||
}
|
||||
|
||||
// Parse due date if provided
|
||||
if req.DueDate != "" {
|
||||
if dueDate, err := time.Parse("2006-01-02T15:04:05Z07:00", req.DueDate); err == nil {
|
||||
task.DueDate = &dueDate
|
||||
}
|
||||
}
|
||||
|
||||
if err := models.GetDB().Create(&task).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to create task")
|
||||
return
|
||||
}
|
||||
|
||||
// Load task with relationships
|
||||
models.GetDB().
|
||||
Preload("Project").
|
||||
Preload("AssignedTo").
|
||||
Preload("CreatedBy").
|
||||
First(&task, task.ID)
|
||||
|
||||
utils.SuccessResponse(c, http.StatusCreated, "Task created successfully", task)
|
||||
}
|
||||
|
||||
// GetTasks returns all tasks for a project
|
||||
func GetTasks(c *gin.Context) {
|
||||
project, exists := c.Get("project")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Project not found")
|
||||
return
|
||||
}
|
||||
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
var tasks []models.Task
|
||||
|
||||
if err := models.GetDB().
|
||||
Preload("AssignedTo").
|
||||
Preload("CreatedBy").
|
||||
Preload("Comments").
|
||||
Preload("Files").
|
||||
Preload("Subtasks").
|
||||
Preload("Labels").
|
||||
Where("project_id = ?", projectObj.ID).
|
||||
Order("position ASC, created_at DESC").
|
||||
Find(&tasks).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to fetch tasks")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Tasks retrieved successfully", tasks)
|
||||
}
|
||||
|
||||
// GetTask returns a specific task
|
||||
func GetTask(c *gin.Context) {
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "Task ID required")
|
||||
return
|
||||
}
|
||||
|
||||
var task models.Task
|
||||
if err := models.GetDB().
|
||||
Preload("Project").
|
||||
Preload("AssignedTo").
|
||||
Preload("CreatedBy").
|
||||
Preload("Comments").
|
||||
Preload("Comments.User").
|
||||
Preload("Files").
|
||||
Preload("Subtasks").
|
||||
Preload("Subtasks.AssignedTo").
|
||||
Preload("Labels").
|
||||
Where("id = ? AND project_id = ?", taskID, projectObj.ID).
|
||||
First(&task).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Task not found")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Task retrieved successfully", task)
|
||||
}
|
||||
|
||||
// UpdateTask updates a task
|
||||
func UpdateTask(c *gin.Context) {
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "Task ID required")
|
||||
return
|
||||
}
|
||||
|
||||
var task models.Task
|
||||
if err := models.GetDB().
|
||||
Where("id = ? AND project_id = ?", taskID, projectObj.ID).
|
||||
First(&task).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Task not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Title != "" {
|
||||
task.Title = req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
task.Description = req.Description
|
||||
}
|
||||
if req.Status != "" {
|
||||
task.Status = req.Status
|
||||
if req.Status == "done" {
|
||||
task.MarkAsCompleted()
|
||||
}
|
||||
}
|
||||
if req.Priority != "" {
|
||||
task.Priority = req.Priority
|
||||
}
|
||||
if req.AssignedToID != nil {
|
||||
task.AssignedToID = req.AssignedToID
|
||||
}
|
||||
if req.Position != nil {
|
||||
task.Position = *req.Position
|
||||
}
|
||||
|
||||
// Parse due date if provided
|
||||
if req.DueDate != "" {
|
||||
if dueDate, err := time.Parse("2006-01-02T15:04:05Z07:00", req.DueDate); err == nil {
|
||||
task.DueDate = &dueDate
|
||||
}
|
||||
}
|
||||
|
||||
if err := models.GetDB().Save(&task).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to update task")
|
||||
return
|
||||
}
|
||||
|
||||
// Load updated task with relationships
|
||||
models.GetDB().
|
||||
Preload("AssignedTo").
|
||||
Preload("CreatedBy").
|
||||
Preload("Comments").
|
||||
Preload("Files").
|
||||
Preload("Subtasks").
|
||||
Preload("Labels").
|
||||
First(&task, task.ID)
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Task updated successfully", task)
|
||||
}
|
||||
|
||||
// DeleteTask deletes a task
|
||||
func DeleteTask(c *gin.Context) {
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "Task ID required")
|
||||
return
|
||||
}
|
||||
|
||||
var task models.Task
|
||||
if err := models.GetDB().
|
||||
Where("id = ? AND project_id = ?", taskID, projectObj.ID).
|
||||
First(&task).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Task not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := models.GetDB().Delete(&task).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to delete task")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessResponse(c, http.StatusOK, "Task deleted successfully", nil)
|
||||
}
|
||||
|
||||
// CreateSubtask creates a new subtask
|
||||
func CreateSubtask(c *gin.Context) {
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "Task ID required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify task exists and belongs to project
|
||||
var task models.Task
|
||||
if err := models.GetDB().
|
||||
Where("id = ? AND project_id = ?", taskID, projectObj.ID).
|
||||
First(&task).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Task not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateSubtaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
subtask := models.Subtask{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
ParentTaskID: task.ID,
|
||||
AssignedToID: req.AssignedToID,
|
||||
Status: "todo",
|
||||
}
|
||||
|
||||
if err := models.GetDB().Create(&subtask).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to create subtask")
|
||||
return
|
||||
}
|
||||
|
||||
// Load subtask with relationships
|
||||
models.GetDB().
|
||||
Preload("AssignedTo").
|
||||
First(&subtask, subtask.ID)
|
||||
|
||||
utils.SuccessResponse(c, http.StatusCreated, "Subtask created successfully", subtask)
|
||||
}
|
||||
|
||||
// AddComment adds a comment to a task
|
||||
func AddComment(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
project, _ := c.Get("project")
|
||||
projectObj := project.(*models.Project)
|
||||
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "Task ID required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify task exists and belongs to project
|
||||
var task models.Task
|
||||
if err := models.GetDB().
|
||||
Where("id = ? AND project_id = ?", taskID, projectObj.ID).
|
||||
First(&task).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Task not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationErrorResponse(c, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
comment := models.Comment{
|
||||
Content: req.Content,
|
||||
TaskID: task.ID,
|
||||
UserID: userObj.ID,
|
||||
}
|
||||
|
||||
if err := models.GetDB().Create(&comment).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to create comment")
|
||||
return
|
||||
}
|
||||
|
||||
// Load comment with user
|
||||
models.GetDB().
|
||||
Preload("User").
|
||||
First(&comment, comment.ID)
|
||||
|
||||
utils.SuccessResponse(c, http.StatusCreated, "Comment added successfully", comment)
|
||||
}
|
64
backend/api/utils/response.go
Normal file
64
backend/api/utils/response.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SuccessResponse sends a successful response
|
||||
func SuccessResponse(c *gin.Context, statusCode int, message string, data interface{}) {
|
||||
c.JSON(statusCode, APIResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrorResponse sends an error response
|
||||
func ErrorResponse(c *gin.Context, statusCode int, message string) {
|
||||
c.JSON(statusCode, APIResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
})
|
||||
}
|
||||
|
||||
// ValidationErrorResponse sends a validation error response
|
||||
func ValidationErrorResponse(c *gin.Context, errors interface{}) {
|
||||
c.JSON(http.StatusBadRequest, APIResponse{
|
||||
Success: false,
|
||||
Error: "Validation failed",
|
||||
Data: errors,
|
||||
})
|
||||
}
|
||||
|
||||
// PaginatedResponse sends a paginated response
|
||||
type PaginatedResponse struct {
|
||||
Data interface{} `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type Pagination struct {
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
Total int64 `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
func PaginatedSuccessResponse(c *gin.Context, message string, data interface{}, pagination Pagination) {
|
||||
c.JSON(http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
Data: PaginatedResponse{
|
||||
Data: data,
|
||||
Pagination: pagination,
|
||||
},
|
||||
})
|
||||
}
|
138
backend/cmd/server/main.go
Normal file
138
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"project-dashboard/api"
|
||||
"project-dashboard/middleware"
|
||||
"project-dashboard/models"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize database
|
||||
models.InitDB()
|
||||
defer models.CloseDB()
|
||||
|
||||
// Create default admin user if not exists
|
||||
createDefaultAdmin()
|
||||
|
||||
// Set Gin mode
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
// Create Gin router
|
||||
r := gin.Default()
|
||||
|
||||
// Configure CORS
|
||||
config := cors.DefaultConfig()
|
||||
config.AllowOrigins = []string{"http://localhost:3000", "http://localhost:3001"}
|
||||
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
|
||||
config.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"}
|
||||
config.AllowCredentials = true
|
||||
r.Use(cors.New(config))
|
||||
|
||||
// Serve static files
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
// Health check endpoint
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"message": "Project Dashboard API is running",
|
||||
})
|
||||
})
|
||||
|
||||
// API routes
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
// Authentication routes
|
||||
auth := v1.Group("/auth")
|
||||
{
|
||||
auth.POST("/register", api.Register)
|
||||
auth.POST("/login", api.Login)
|
||||
auth.GET("/profile", middleware.AuthMiddleware(), api.GetProfile)
|
||||
auth.PUT("/profile", middleware.AuthMiddleware(), api.UpdateProfile)
|
||||
auth.PUT("/change-password", middleware.AuthMiddleware(), api.ChangePassword)
|
||||
}
|
||||
|
||||
// Project routes
|
||||
projects := v1.Group("/projects")
|
||||
projects.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
projects.POST("", api.CreateProject)
|
||||
projects.GET("", api.GetProjects)
|
||||
|
||||
// Project-specific routes
|
||||
project := projects.Group("/:project_id")
|
||||
project.Use(middleware.ProjectAccessMiddleware())
|
||||
{
|
||||
project.GET("", api.GetProject)
|
||||
project.PUT("", api.UpdateProject)
|
||||
project.DELETE("", api.DeleteProject)
|
||||
|
||||
// Project members
|
||||
project.POST("/members", api.AddMember)
|
||||
project.DELETE("/members/:user_id", api.RemoveMember)
|
||||
|
||||
// Tasks
|
||||
project.POST("/tasks", api.CreateTask)
|
||||
project.GET("/tasks", api.GetTasks)
|
||||
|
||||
task := project.Group("/tasks/:task_id")
|
||||
{
|
||||
task.GET("", api.GetTask)
|
||||
task.PUT("", api.UpdateTask)
|
||||
task.DELETE("", api.DeleteTask)
|
||||
|
||||
// Task comments
|
||||
task.POST("/comments", api.AddComment)
|
||||
|
||||
// Task subtasks
|
||||
task.POST("/subtasks", api.CreateSubtask)
|
||||
}
|
||||
|
||||
// Files
|
||||
project.POST("/files", api.UploadFile)
|
||||
project.GET("/files", api.GetFiles)
|
||||
|
||||
file := project.Group("/files/:file_id")
|
||||
{
|
||||
file.GET("", api.DownloadFile)
|
||||
file.DELETE("", api.DeleteFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start server
|
||||
log.Println("Starting server on :8080")
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createDefaultAdmin() {
|
||||
var admin models.User
|
||||
if err := models.GetDB().Where("email = ?", "admin@example.com").First(&admin).Error; err != nil {
|
||||
// Create default admin user
|
||||
admin = models.User{
|
||||
Email: "admin@example.com",
|
||||
FirstName: "Admin",
|
||||
LastName: "User",
|
||||
Role: "admin",
|
||||
}
|
||||
|
||||
if err := admin.HashPassword("admin123"); err != nil {
|
||||
log.Printf("Failed to hash admin password: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := models.GetDB().Create(&admin).Error; err != nil {
|
||||
log.Printf("Failed to create admin user: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Default admin user created: admin@example.com / admin123")
|
||||
}
|
||||
}
|
43
backend/go.mod
Normal file
43
backend/go.mod
Normal file
@@ -0,0 +1,43 @@
|
||||
module project-dashboard
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.7
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/cors v1.7.6 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.10.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/gorm v1.9.16 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
golang.org/x/arch v0.18.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.0 // indirect
|
||||
)
|
111
backend/go.sum
Normal file
111
backend/go.sum
Normal file
@@ -0,0 +1,111 @@
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
|
||||
github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
BIN
backend/main
Executable file
BIN
backend/main
Executable file
Binary file not shown.
158
backend/middleware/auth.go
Normal file
158
backend/middleware/auth.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"project-dashboard/api/utils"
|
||||
"project-dashboard/models"
|
||||
"strings"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
var jwtSecret = []byte("your-secret-key") // In production, use environment variable
|
||||
|
||||
// GenerateToken generates a JWT token for the user
|
||||
func GenerateToken(user *models.User) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: 15000, // 24 hours
|
||||
Issuer: "project-dashboard",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
// AuthMiddleware validates JWT token
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "Authorization header required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "Invalid authorization header format")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "Invalid token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
var user models.User
|
||||
if err := models.GetDB().First(&user, claims.UserID).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not found")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", &user)
|
||||
c.Set("user_id", user.ID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMiddleware ensures user has admin role
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
if !userObj.IsAdmin() {
|
||||
utils.ErrorResponse(c, http.StatusForbidden, "Admin access required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ManagerMiddleware ensures user has manager role or higher
|
||||
func ManagerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
if !userObj.IsManager() {
|
||||
utils.ErrorResponse(c, http.StatusForbidden, "Manager access required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ProjectAccessMiddleware ensures user has access to the project
|
||||
func ProjectAccessMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userObj := user.(*models.User)
|
||||
projectID := c.Param("project_id")
|
||||
|
||||
if projectID == "" {
|
||||
utils.ErrorResponse(c, http.StatusBadRequest, "Project ID required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var project models.Project
|
||||
if err := models.GetDB().Preload("Members").First(&project, projectID).Error; err != nil {
|
||||
utils.ErrorResponse(c, http.StatusNotFound, "Project not found")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner or member
|
||||
if !project.IsOwner(userObj.ID) && !project.HasMember(userObj.ID) {
|
||||
utils.ErrorResponse(c, http.StatusForbidden, "Access denied to project")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("project", &project)
|
||||
c.Next()
|
||||
}
|
||||
}
|
51
backend/models/database.go
Normal file
51
backend/models/database.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
// InitDB initializes the database connection
|
||||
func InitDB() {
|
||||
var err error
|
||||
db, err = gorm.Open(sqlite.Open("project_dashboard.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
// Auto-migrate the schema
|
||||
err = db.AutoMigrate(
|
||||
&User{},
|
||||
&Project{},
|
||||
&ProjectMember{},
|
||||
&Task{},
|
||||
&Subtask{},
|
||||
&Label{},
|
||||
&Comment{},
|
||||
&FileUpload{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to migrate database:", err)
|
||||
}
|
||||
|
||||
log.Println("Database connected and migrated successfully")
|
||||
}
|
||||
|
||||
// GetDB returns the database instance
|
||||
func GetDB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection
|
||||
func CloseDB() {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
log.Printf("Error getting database instance: %v", err)
|
||||
return
|
||||
}
|
||||
sqlDB.Close()
|
||||
}
|
102
backend/models/file.go
Normal file
102
backend/models/file.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type FileUpload struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
FileName string `json:"file_name" gorm:"not null"`
|
||||
OriginalName string `json:"original_name" gorm:"not null"`
|
||||
FilePath string `json:"file_path" gorm:"not null"`
|
||||
FileSize int64 `json:"file_size" gorm:"not null"`
|
||||
MimeType string `json:"mime_type" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// Foreign Keys
|
||||
UploadedByID uint `json:"uploaded_by_id" gorm:"not null"`
|
||||
ProjectID *uint `json:"project_id"`
|
||||
TaskID *uint `json:"task_id"`
|
||||
|
||||
// Relationships
|
||||
UploadedBy User `json:"uploaded_by" gorm:"foreignKey:UploadedByID"`
|
||||
Project *Project `json:"project" gorm:"foreignKey:ProjectID"`
|
||||
Task *Task `json:"task" gorm:"foreignKey:TaskID"`
|
||||
}
|
||||
|
||||
// GetFileExtension returns the file extension
|
||||
func (f *FileUpload) GetFileExtension() string {
|
||||
if len(f.OriginalName) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lastDot := -1
|
||||
for i := len(f.OriginalName) - 1; i >= 0; i-- {
|
||||
if f.OriginalName[i] == '.' {
|
||||
lastDot = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if lastDot == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return f.OriginalName[lastDot:]
|
||||
}
|
||||
|
||||
// IsImage checks if the file is an image
|
||||
func (f *FileUpload) IsImage() bool {
|
||||
imageTypes := []string{
|
||||
"image/jpeg", "image/jpg", "image/png", "image/gif",
|
||||
"image/webp", "image/svg+xml", "image/bmp", "image/tiff",
|
||||
}
|
||||
|
||||
for _, imgType := range imageTypes {
|
||||
if f.MimeType == imgType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsDocument checks if the file is a document
|
||||
func (f *FileUpload) IsDocument() bool {
|
||||
docTypes := []string{
|
||||
"application/pdf", "application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"text/plain", "text/csv",
|
||||
}
|
||||
|
||||
for _, docType := range docTypes {
|
||||
if f.MimeType == docType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormatFileSize returns a human-readable file size
|
||||
func (f *FileUpload) FormatFileSize() string {
|
||||
const unit = 1024
|
||||
if f.FileSize < unit {
|
||||
return fmt.Sprintf("%d B", f.FileSize)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := f.FileSize / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(f.FileSize)/float64(div), "KMGTPE"[exp])
|
||||
}
|
79
backend/models/project.go
Normal file
79
backend/models/project.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Project struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status" gorm:"default:'active'"` // active, completed, archived, on_hold
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
Color string `json:"color" gorm:"default:'#3b82f6'"` // Hex color for UI
|
||||
|
||||
// Foreign Keys
|
||||
OwnerID uint `json:"owner_id" gorm:"not null"`
|
||||
|
||||
// Relationships
|
||||
Owner User `json:"owner" gorm:"foreignKey:OwnerID"`
|
||||
Members []User `json:"members" gorm:"many2many:project_members"`
|
||||
Tasks []Task `json:"tasks" gorm:"foreignKey:ProjectID"`
|
||||
Files []FileUpload `json:"files" gorm:"foreignKey:ProjectID"`
|
||||
}
|
||||
|
||||
type ProjectMember struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
ProjectID uint `json:"project_id" gorm:"not null"`
|
||||
UserID uint `json:"user_id" gorm:"not null"`
|
||||
Role string `json:"role" gorm:"default:'member'"` // owner, manager, member
|
||||
|
||||
// Relationships
|
||||
Project Project `json:"project" gorm:"foreignKey:ProjectID"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// IsOwner checks if the given user is the owner of this project
|
||||
func (p *Project) IsOwner(userID uint) bool {
|
||||
return p.OwnerID == userID
|
||||
}
|
||||
|
||||
// HasMember checks if the given user is a member of this project
|
||||
func (p *Project) HasMember(userID uint) bool {
|
||||
for _, member := range p.Members {
|
||||
if member.ID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetMemberRole returns the role of a member in this project
|
||||
func (p *Project) GetMemberRole(userID uint) string {
|
||||
if p.OwnerID == userID {
|
||||
return "owner"
|
||||
}
|
||||
|
||||
for _, member := range p.Members {
|
||||
if member.ID == userID {
|
||||
// Get role from ProjectMember table
|
||||
var pm ProjectMember
|
||||
if err := db.Where("project_id = ? AND user_id = ?", p.ID, userID).First(&pm).Error; err == nil {
|
||||
return pm.Role
|
||||
}
|
||||
return "member"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
125
backend/models/task.go
Normal file
125
backend/models/task.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status" gorm:"default:'todo'"` // todo, in_progress, review, done
|
||||
Priority string `json:"priority" gorm:"default:'medium'"` // low, medium, high, urgent
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
Position int `json:"position" gorm:"default:0"` // For ordering in kanban board
|
||||
|
||||
// Foreign Keys
|
||||
ProjectID uint `json:"project_id" gorm:"not null"`
|
||||
AssignedToID *uint `json:"assigned_to_id"`
|
||||
CreatedByID uint `json:"created_by_id" gorm:"not null"`
|
||||
|
||||
// Relationships
|
||||
Project Project `json:"project" gorm:"foreignKey:ProjectID"`
|
||||
AssignedTo *User `json:"assigned_to" gorm:"foreignKey:AssignedToID"`
|
||||
CreatedBy User `json:"created_by" gorm:"foreignKey:CreatedByID"`
|
||||
Comments []Comment `json:"comments" gorm:"foreignKey:TaskID"`
|
||||
Files []FileUpload `json:"files" gorm:"foreignKey:TaskID"`
|
||||
Subtasks []Subtask `json:"subtasks" gorm:"foreignKey:ParentTaskID"`
|
||||
Labels []Label `json:"labels" gorm:"many2many:task_labels"`
|
||||
}
|
||||
|
||||
type Subtask struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status" gorm:"default:'todo'"` // todo, in_progress, done
|
||||
Position int `json:"position" gorm:"default:0"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
|
||||
// Foreign Keys
|
||||
ParentTaskID uint `json:"parent_task_id" gorm:"not null"`
|
||||
AssignedToID *uint `json:"assigned_to_id"`
|
||||
|
||||
// Relationships
|
||||
ParentTask Task `json:"parent_task" gorm:"foreignKey:ParentTaskID"`
|
||||
AssignedTo *User `json:"assigned_to" gorm:"foreignKey:AssignedToID"`
|
||||
}
|
||||
|
||||
type Label struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Color string `json:"color" gorm:"default:'#6b7280'"` // Hex color
|
||||
|
||||
// Foreign Keys
|
||||
ProjectID uint `json:"project_id" gorm:"not null"`
|
||||
|
||||
// Relationships
|
||||
Project Project `json:"project" gorm:"foreignKey:ProjectID"`
|
||||
Tasks []Task `json:"tasks" gorm:"many2many:task_labels"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
Content string `json:"content" gorm:"not null"`
|
||||
|
||||
// Foreign Keys
|
||||
TaskID uint `json:"task_id" gorm:"not null"`
|
||||
UserID uint `json:"user_id" gorm:"not null"`
|
||||
|
||||
// Relationships
|
||||
Task Task `json:"task" gorm:"foreignKey:TaskID"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// IsOverdue checks if the task is overdue
|
||||
func (t *Task) IsOverdue() bool {
|
||||
if t.DueDate == nil {
|
||||
return false
|
||||
}
|
||||
return t.Status != "done" && t.DueDate.Before(time.Now())
|
||||
}
|
||||
|
||||
// GetCompletionPercentage returns the completion percentage based on subtasks
|
||||
func (t *Task) GetCompletionPercentage() int {
|
||||
if len(t.Subtasks) == 0 {
|
||||
if t.Status == "done" {
|
||||
return 100
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
completed := 0
|
||||
for _, subtask := range t.Subtasks {
|
||||
if subtask.Status == "done" {
|
||||
completed++
|
||||
}
|
||||
}
|
||||
|
||||
return (completed * 100) / len(t.Subtasks)
|
||||
}
|
||||
|
||||
// MarkAsCompleted marks the task as completed
|
||||
func (t *Task) MarkAsCompleted() {
|
||||
t.Status = "done"
|
||||
now := time.Now()
|
||||
t.CompletedAt = &now
|
||||
}
|
60
backend/models/user.go
Normal file
60
backend/models/user.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
Password string `json:"-" gorm:"not null"` // Hidden from JSON
|
||||
FirstName string `json:"first_name" gorm:"not null"`
|
||||
LastName string `json:"last_name" gorm:"not null"`
|
||||
Avatar string `json:"avatar"`
|
||||
Role string `json:"role" gorm:"default:'member'"` // admin, manager, member
|
||||
|
||||
// Relationships
|
||||
Projects []Project `json:"projects" gorm:"many2many:project_members"`
|
||||
OwnedProjects []Project `json:"owned_projects" gorm:"foreignKey:OwnerID"`
|
||||
Tasks []Task `json:"tasks" gorm:"foreignKey:AssignedToID"`
|
||||
Comments []Comment `json:"comments" gorm:"foreignKey:UserID"`
|
||||
FileUploads []FileUpload `json:"file_uploads" gorm:"foreignKey:UploadedByID"`
|
||||
}
|
||||
|
||||
// HashPassword hashes the user's password
|
||||
func (u *User) HashPassword(password string) error {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Password = string(hashedPassword)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword checks if the provided password matches the user's password
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetFullName returns the user's full name
|
||||
func (u *User) GetFullName() string {
|
||||
return u.FirstName + " " + u.LastName
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (u *User) IsAdmin() bool {
|
||||
return u.Role == "admin"
|
||||
}
|
||||
|
||||
// IsManager checks if the user has manager role or higher
|
||||
func (u *User) IsManager() bool {
|
||||
return u.Role == "manager" || u.Role == "admin"
|
||||
}
|
BIN
backend/project_dashboard.db
Normal file
BIN
backend/project_dashboard.db
Normal file
Binary file not shown.
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"
|
||||
]
|
||||
}
|
45
start.sh
Executable file
45
start.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Starting Project Management Dashboard..."
|
||||
|
||||
# Function to kill background processes on exit
|
||||
cleanup() {
|
||||
echo "Stopping servers..."
|
||||
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Set up trap to cleanup on script exit
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# Start backend server
|
||||
echo "Starting backend server on port 8080..."
|
||||
cd backend
|
||||
go run cmd/server/main.go &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Wait a moment for backend to start
|
||||
sleep 3
|
||||
|
||||
# Start frontend server
|
||||
echo "Starting frontend server on port 3000..."
|
||||
cd ../frontend
|
||||
npm start &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo "Project Management Dashboard is running!"
|
||||
echo "================================================"
|
||||
echo "Frontend: http://localhost:3000"
|
||||
echo "Backend: http://localhost:8080"
|
||||
echo ""
|
||||
echo "Default admin credentials:"
|
||||
echo "Email: admin@example.com"
|
||||
echo "Password: admin123"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop both servers"
|
||||
echo "================================================"
|
||||
|
||||
# Wait for user to stop the servers
|
||||
wait
|
Reference in New Issue
Block a user