first commit

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

233
README.md Normal file
View 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
View 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
View 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
View 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
View 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)
}

View 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
View 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
View 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
View 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

Binary file not shown.

158
backend/middleware/auth.go Normal file
View 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()
}
}

View 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
View 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
View 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
View 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
View 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"
}

Binary file not shown.

23
frontend/.gitignore vendored Normal file
View File

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

46
frontend/README.md Normal file
View File

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

18405
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
frontend/package.json Normal file
View File

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

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

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

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

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

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

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

26
frontend/tsconfig.json Normal file
View File

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

45
start.sh Executable file
View 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