diff --git a/app/Http/Controllers/Api/IdeaController.php b/app/Http/Controllers/Api/IdeaController.php new file mode 100644 index 0000000..f81b11e --- /dev/null +++ b/app/Http/Controllers/Api/IdeaController.php @@ -0,0 +1,129 @@ +filled('status')) { + $query->where('status', $request->string('status')); + } + + if ($request->filled('category')) { + $query->where('category', $request->string('category')); + } + + if ($request->filled('project_id')) { + $query->where('project_id', $request->integer('project_id')); + } + + if ($request->filled('search')) { + $query->search($request->string('search')); + } + + if ($request->filled('unlinked')) { + $query->unlinked(); + } + + if ($request->filled('priority')) { + $query->where('priority', $request->integer('priority')); + } + + $ideas = $query->orderBy('priority', 'desc')->orderBy('created_at', 'desc')->get(); + + // Eager load relationships if requested + if ($request->boolean('with_project')) { + $ideas->load(['project']); + } + + if ($request->boolean('with_user')) { + $ideas->load(['user']); + } + + return IdeaResource::collection($ideas); + } + + public function store(StoreIdeaRequest $request) + { + $idea = Idea::create($request->validated()); + + return (new IdeaResource($idea)) + ->response() + ->setStatusCode(201); + } + + public function show(Request $request, Idea $idea) + { + if ($request->boolean('with_project')) { + $idea->load(['project']); + } + + if ($request->boolean('with_user')) { + $idea->load(['user']); + } + + return new IdeaResource($idea); + } + + public function update(UpdateIdeaRequest $request, Idea $idea) + { + $idea->update($request->validated()); + + return new IdeaResource($idea); + } + + public function destroy(Idea $idea) + { + $idea->delete(); + + return response()->noContent(); + } + + /** + * Change idea status. + */ + public function changeStatus(Request $request, Idea $idea) + { + $request->validate([ + 'status' => ['required', 'string', 'in:' . implode(',', Idea::STATUSES)], + ]); + + $idea->update(['status' => $request->string('status')]); + + return new IdeaResource($idea); + } + + /** + * Link an idea to a project. + */ + public function linkToProject(Request $request, Idea $idea) + { + $request->validate([ + 'project_id' => ['required', 'integer', 'exists:projects,id'], + ]); + + $idea->update(['project_id' => $request->integer('project_id')]); + + return new IdeaResource($idea->load(['project'])); + } + + /** + * Unlink an idea from a project. + */ + public function unlinkFromProject(Idea $idea) + { + $idea->update(['project_id' => null]); + + return new IdeaResource($idea); + } +} diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php new file mode 100644 index 0000000..d748fac --- /dev/null +++ b/app/Http/Controllers/Api/ProjectController.php @@ -0,0 +1,113 @@ +filled('status')) { + $query->where('status', $request->string('status')); + } + + if ($request->filled('name')) { + $query->where('name', 'like', '%' . $request->string('name') . '%'); + } + + $projects = $query->orderBy('name')->get(); + + // Eager load tasks if ?with_tasks=1 + if ($request->boolean('with_tasks')) { + $projects->load(['tasks.comments']); + } + + return ProjectResource::collection($projects); + } + + public function store(StoreProjectRequest $request) + { + $project = Project::create($request->validated()); + + return (new ProjectResource($project)) + ->response() + ->setStatusCode(201); + } + + public function show(Request $request, Project $project) + { + if ($request->boolean('with_tasks')) { + $project->load(['tasks.comments']); + } + + return new ProjectResource($project); + } + + public function update(UpdateProjectRequest $request, Project $project) + { + $project->update($request->validated()); + + return new ProjectResource($project); + } + + public function destroy(Project $project) + { + $project->delete(); + + return response()->noContent(); + } + + /** + * Get tasks for a project. + */ + public function tasks(Request $request, Project $project) + { + $query = $project->tasks()->with(['comments']); + + if ($request->filled('status')) { + $query->where('status', $request->string('status')); + } + + if ($request->filled('priority')) { + $query->where('priority', $request->integer('priority')); + } + + $tasks = $query->orderBy('priority', 'desc')->orderBy('created_at')->get(); + + return TaskResource::collection($tasks); + } + + /** + * Get ideas for a project. + */ + public function ideas(Request $request, Project $project) + { + $query = $project->ideas()->with(['user']); + + if ($request->filled('status')) { + $query->where('status', $request->string('status')); + } + + if ($request->filled('category')) { + $query->where('category', $request->string('category')); + } + + if ($request->filled('priority')) { + $query->where('priority', $request->integer('priority')); + } + + $ideas = $query->orderBy('priority', 'desc')->orderBy('created_at', 'desc')->get(); + + return IdeaResource::collection($ideas); + } +} diff --git a/app/Http/Controllers/Api/TaskCommentController.php b/app/Http/Controllers/Api/TaskCommentController.php new file mode 100644 index 0000000..9c31383 --- /dev/null +++ b/app/Http/Controllers/Api/TaskCommentController.php @@ -0,0 +1,69 @@ +comments()->orderBy('created_at')->get(); + + return TaskCommentResource::collection($comments); + } + + /** + * Create a new comment for a task. + */ + public function store(StoreTaskCommentRequest $request, Task $task) + { + $comment = $task->comments()->create($request->validated()); + + return (new TaskCommentResource($comment)) + ->response() + ->setStatusCode(201); + } + + /** + * Update a comment. + */ + public function update(Request $request, TaskComment $comment) + { + $request->validate([ + 'body' => ['required', 'string'], + ]); + + $comment->update([ + 'body' => $request->string('body'), + ]); + + return new TaskCommentResource($comment); + } + + /** + * Show a single comment. + */ + public function show(TaskComment $comment) + { + return new TaskCommentResource($comment); + } + + /** + * Delete a comment. + */ + public function destroy(TaskComment $comment) + { + $comment->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/Api/TaskController.php b/app/Http/Controllers/Api/TaskController.php new file mode 100644 index 0000000..02f9aea --- /dev/null +++ b/app/Http/Controllers/Api/TaskController.php @@ -0,0 +1,127 @@ +filled('status')) { + $query->where('status', $request->string('status')); + } + + if ($request->filled('project_id')) { + $query->where('project_id', $request->integer('project_id')); + } + + if ($request->filled('priority')) { + $query->where('priority', $request->integer('priority')); + } + + if ($request->boolean('due_soon')) { + $query->dueSoon($request->integer('days', 3)); + } + + if ($request->boolean('overdue')) { + $query->overdue(); + } + + $tasks = $query->orderBy('priority', 'desc')->orderBy('created_at')->get(); + + return TaskResource::collection($tasks); + } + + public function store(StoreTaskRequest $request) + { + $task = Task::create($request->validated()); + + return (new TaskResource($task)) + ->response() + ->setStatusCode(201); + } + + public function show(Request $request, Task $task) + { + $task->load(['project', 'comments']); + + return new TaskResource($task); + } + + public function update(UpdateTaskRequest $request, Task $task) + { + // Handle status transitions + $validated = $request->validated(); + + // Auto-set timestamps based on status + if (isset($validated['status'])) { + if ($validated['status'] === 'in_progress' && ! $task->started_at) { + $validated['started_at'] = now(); + } + if (in_array($validated['status'], ['done', 'completed']) && ! $task->completed_at) { + $validated['completed_at'] = now(); + } + if ($validated['status'] !== 'in_progress') { + $validated['started_at'] = null; + } + } + + $task->update($validated); + + return new TaskResource($task->fresh(['project', 'comments'])); + } + + public function destroy(Task $task) + { + $task->delete(); + + return response()->noContent(); + } + + /** + * Get comments for a task. + */ + public function comments(Request $request, Task $task) + { + $comments = $task->comments()->orderBy('created_at')->get(); + + return TaskCommentResource::collection($comments); + } + + /** + * Change task status. + */ + public function changeStatus(Request $request, Task $task) + { + $request->validate([ + 'status' => ['required', 'string', 'in:' . implode(',', Task::STATUSES)], + ]); + + $status = $request->string('status'); + + $updateData = ['status' => $status]; + + // Auto-set timestamps + if ($status === 'in_progress' && ! $task->started_at) { + $updateData['started_at'] = now(); + } + if (in_array($status, ['done', 'completed']) && ! $task->completed_at) { + $updateData['completed_at'] = now(); + } + if ($status !== 'in_progress') { + $updateData['started_at'] = null; + } + + $task->update($updateData); + + return new TaskResource($task->fresh(['project', 'comments'])); + } +} diff --git a/app/Http/Requests/StoreIdeaRequest.php b/app/Http/Requests/StoreIdeaRequest.php new file mode 100644 index 0000000..ea7342f --- /dev/null +++ b/app/Http/Requests/StoreIdeaRequest.php @@ -0,0 +1,29 @@ + ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'string', Rule::in(Idea::STATUSES)], + 'project_id' => ['nullable', 'integer', 'exists:projects,id'], + 'priority' => ['integer', 'min:0', 'max:10'], + 'category' => ['nullable', 'string', Rule::in(Idea::CATEGORIES)], + 'tags' => ['nullable', 'string', 'max:255'], + 'user_id' => ['nullable', 'integer', 'exists:users,id'], + ]; + } +} diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php new file mode 100644 index 0000000..5e922c1 --- /dev/null +++ b/app/Http/Requests/StoreProjectRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'string', Rule::in(Project::STATUSES)], + 'due_at' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/StoreTaskCommentRequest.php b/app/Http/Requests/StoreTaskCommentRequest.php new file mode 100644 index 0000000..23e09c1 --- /dev/null +++ b/app/Http/Requests/StoreTaskCommentRequest.php @@ -0,0 +1,24 @@ + ['required', 'string'], + 'author_type' => ['required', 'string', Rule::in(TaskComment::AUTHOR_TYPES)], + 'author_name' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/StoreTaskRequest.php b/app/Http/Requests/StoreTaskRequest.php new file mode 100644 index 0000000..c8ff7a7 --- /dev/null +++ b/app/Http/Requests/StoreTaskRequest.php @@ -0,0 +1,28 @@ + ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'string', Rule::in(Task::STATUSES)], + 'project_id' => ['required', 'integer', 'exists:projects,id'], + 'priority' => ['integer', 'min:0', 'max:10'], + 'due_at' => ['nullable', 'date'], + 'estimated_hours' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/UpdateIdeaRequest.php b/app/Http/Requests/UpdateIdeaRequest.php new file mode 100644 index 0000000..4c207f9 --- /dev/null +++ b/app/Http/Requests/UpdateIdeaRequest.php @@ -0,0 +1,29 @@ + ['sometimes', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['sometimes', 'string', Rule::in(Idea::STATUSES)], + 'project_id' => ['nullable', 'integer', 'exists:projects,id'], + 'priority' => ['integer', 'min:0', 'max:10'], + 'category' => ['nullable', 'string', Rule::in(Idea::CATEGORIES)], + 'tags' => ['nullable', 'string', 'max:255'], + 'user_id' => ['nullable', 'integer', 'exists:users,id'], + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectRequest.php b/app/Http/Requests/UpdateProjectRequest.php new file mode 100644 index 0000000..479928f --- /dev/null +++ b/app/Http/Requests/UpdateProjectRequest.php @@ -0,0 +1,25 @@ + ['sometimes', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['sometimes', 'string', Rule::in(Project::STATUSES)], + 'due_at' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaskRequest.php b/app/Http/Requests/UpdateTaskRequest.php new file mode 100644 index 0000000..ddcbcac --- /dev/null +++ b/app/Http/Requests/UpdateTaskRequest.php @@ -0,0 +1,31 @@ + ['sometimes', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['sometimes', 'string', Rule::in(Task::STATUSES)], + 'project_id' => ['sometimes', 'integer', 'exists:projects,id'], + 'priority' => ['sometimes', 'integer', 'min:0', 'max:10'], + 'due_at' => ['nullable', 'date'], + 'started_at' => ['nullable', 'date'], + 'completed_at' => ['nullable', 'date'], + 'estimated_hours' => ['nullable', 'integer', 'min:0'], + 'actual_hours' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Resources/IdeaResource.php b/app/Http/Resources/IdeaResource.php new file mode 100644 index 0000000..c9aae7e --- /dev/null +++ b/app/Http/Resources/IdeaResource.php @@ -0,0 +1,36 @@ + $this->id, + 'title' => $this->title, + 'description' => $this->description, + 'status' => $this->status, + 'priority' => $this->priority, + 'category' => $this->category, + 'tags' => $this->tags, + 'tags_array' => $this->tags_array, + 'project_id' => $this->project_id, + 'user_id' => $this->user_id, + 'project' => $this->whenLoaded('project', fn () => [ + 'id' => $this->project->id, + 'name' => $this->project->name, + ]), + 'user' => $this->whenLoaded('user', fn () => [ + 'id' => $this->user->id, + 'name' => $this->user->name, + ]), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + 'deleted_at' => $this->deleted_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php new file mode 100644 index 0000000..13b1642 --- /dev/null +++ b/app/Http/Resources/ProjectResource.php @@ -0,0 +1,26 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'status' => $this->status, + 'due_at' => $this->due_at?->toIso8601String(), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + 'deleted_at' => $this->deleted_at?->toIso8601String(), + 'task_count' => $this->whenLoaded('tasks', fn () => $this->tasks->count()), + 'active_task_count' => $this->whenLoaded('tasks', fn () => $this->activeTasks->count()), + 'completed_task_count' => $this->whenLoaded('tasks', fn () => $this->completedTasks->count()), + ]; + } +} diff --git a/app/Http/Resources/TaskCommentResource.php b/app/Http/Resources/TaskCommentResource.php new file mode 100644 index 0000000..a27544f --- /dev/null +++ b/app/Http/Resources/TaskCommentResource.php @@ -0,0 +1,23 @@ + $this->id, + 'body' => $this->body, + 'author_type' => $this->author_type, + 'author_name' => $this->author_name, + 'task_id' => $this->task_id, + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + 'deleted_at' => $this->deleted_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/TaskResource.php b/app/Http/Resources/TaskResource.php new file mode 100644 index 0000000..3874902 --- /dev/null +++ b/app/Http/Resources/TaskResource.php @@ -0,0 +1,35 @@ + $this->id, + 'title' => $this->title, + 'description' => $this->description, + 'status' => $this->status, + 'priority' => $this->priority, + 'due_at' => $this->due_at?->toIso8601String(), + 'started_at' => $this->started_at?->toIso8601String(), + 'completed_at' => $this->completed_at?->toIso8601String(), + 'estimated_hours' => $this->estimated_hours, + 'actual_hours' => $this->actual_hours, + 'project_id' => $this->project_id, + 'comment_count' => $this->whenLoaded('comments', fn () => $this->comments->count()), + 'project' => $this->whenLoaded('project', fn () => [ + 'id' => $this->project->id, + 'name' => $this->project->name, + ]), + 'comments' => $this->whenLoaded('comments', fn () => TaskCommentResource::collection($this->comments)), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + 'deleted_at' => $this->deleted_at?->toIso8601String(), + ]; + } +} diff --git a/app/Livewire/Ideas/Index.php b/app/Livewire/Ideas/Index.php new file mode 100644 index 0000000..6207dcc --- /dev/null +++ b/app/Livewire/Ideas/Index.php @@ -0,0 +1,209 @@ +search) { + $query->search($this->search); + } + + if ($this->statusFilter) { + $query->where('status', $this->statusFilter); + } + + if ($this->categoryFilter) { + $query->where('category', $this->categoryFilter); + } + + if ($this->projectFilter) { + $query->where('project_id', $this->projectFilter); + } + + if ($this->unlinkedFilter) { + $query->unlinked(); + } + + $ideas = $query->orderBy('priority', 'desc')->orderBy('created_at', 'desc')->get(); + + return view('livewire.ideas.index', [ + 'ideas' => $ideas, + 'projects' => Project::orderBy('name')->get(), + 'statuses' => Idea::STATUSES, + 'categories' => Idea::CATEGORIES, + 'totalIdeas' => Idea::count(), + 'newIdeas' => Idea::new()->count(), + 'consideredIdeas' => Idea::considered()->count(), + 'plannedIdeas' => Idea::planned()->count(), + 'implementedIdeas' => Idea::implemented()->count(), + ]); + } + + public function create(): void + { + $this->resetForm(); + // Pre-select the filtered project if one is selected + if ($this->projectFilter) { + $this->projectId = $this->projectFilter; + } + $this->showForm = true; + } + + public function edit(int $id): void + { + $idea = Idea::findOrFail($id); + $this->editingId = $idea->id; + $this->title = $idea->title; + $this->description = $idea->description; + $this->status = $idea->status; + $this->projectId = $idea->project_id; + $this->priority = $idea->priority; + $this->category = $idea->category; + $this->tags = $idea->tags; + $this->showForm = true; + } + + public function save(): void + { + $validated = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'string', 'in:' . implode(',', Idea::STATUSES)], + 'projectId' => ['nullable', 'integer', 'exists:projects,id'], + 'priority' => ['integer', 'min:0', 'max:10'], + 'category' => ['nullable', 'string', 'in:' . implode(',', Idea::CATEGORIES)], + 'tags' => ['nullable', 'string', 'max:255'], + ]); + + Idea::updateOrCreate(['id' => $this->editingId], [ + 'title' => $validated['title'], + 'description' => $validated['description'], + 'status' => $validated['status'], + 'project_id' => $validated['projectId'], + 'priority' => $validated['priority'], + 'category' => $validated['category'], + 'tags' => $validated['tags'], + 'user_id' => auth()->id(), + ]); + + $this->resetForm(); + $this->showForm = false; + } + + public function delete(int $id): void + { + Idea::findOrFail($id)->delete(); + } + + public function changeStatus(int $id, string $newStatus): void + { + if (!in_array($newStatus, Idea::STATUSES)) { + return; + } + + Idea::findOrFail($id)->update(['status' => $newStatus]); + } + + public function linkToProject(int $id, int $projectId): void + { + Idea::findOrFail($id)->update(['project_id' => $projectId]); + } + + public function unlinkFromProject(int $id): void + { + Idea::findOrFail($id)->update(['project_id' => null]); + } + + public function clearFilters(): void + { + $this->search = ''; + $this->statusFilter = ''; + $this->categoryFilter = ''; + $this->projectFilter = null; + $this->unlinkedFilter = false; + } + + public function resetForm(): void + { + $this->editingId = null; + $this->title = ''; + $this->description = null; + $this->status = 'new'; + $this->projectId = null; + $this->priority = 0; + $this->category = null; + $this->tags = null; + $this->resetValidation(); + } + + public function getStatusColor(string $status): string + { + return match ($status) { + 'new' => 'bg-blue-100 text-blue-800', + 'considered' => 'bg-yellow-100 text-yellow-800', + 'planned' => 'bg-purple-100 text-purple-800', + 'rejected' => 'bg-red-100 text-red-800', + 'implemented' => 'bg-green-100 text-green-800', + default => 'bg-gray-100 text-gray-800', + }; + } + + public function getCategoryColor(string $category): string + { + return match ($category) { + 'feature' => 'bg-blue-100 text-blue-800', + 'improvement' => 'bg-purple-100 text-purple-800', + 'bugfix' => 'bg-red-100 text-red-800', + 'research' => 'bg-yellow-100 text-yellow-800', + 'other' => 'bg-gray-100 text-gray-800', + default => 'bg-gray-100 text-gray-800', + }; + } + + public function getPriorityLabel(int $priority): string + { + if ($priority === 0) return 'None'; + if ($priority <= 3) return 'Low'; + if ($priority <= 6) return 'Medium'; + if ($priority <= 8) return 'High'; + return 'Critical'; + } +} diff --git a/app/Livewire/Projects/Index.php b/app/Livewire/Projects/Index.php new file mode 100644 index 0000000..e024140 --- /dev/null +++ b/app/Livewire/Projects/Index.php @@ -0,0 +1,126 @@ +search) { + $query->where('name', 'like', '%' . $this->search . '%') + ->orWhere('description', 'like', '%' . $this->search . '%'); + } + + if ($this->statusFilter) { + $query->where('status', $this->statusFilter); + } + + $projects = $query->orderBy('name')->get(); + + return view('livewire.projects.index', [ + 'projects' => $projects, + 'statuses' => Project::STATUSES, + 'totalProjects' => Project::count(), + 'activeProjects' => Project::active()->count(), + 'completedProjects' => Project::completed()->count(), + ]); + } + + public function create(): void + { + $this->resetForm(); + $this->showForm = true; + } + + public function edit(int $id): void + { + $project = Project::findOrFail($id); + $this->editingId = $project->id; + $this->name = $project->name; + $this->description = $project->description; + $this->status = $project->status; + $this->dueAt = $project->due_at?->format('Y-m-d\TH:i') ?? ''; + $this->showForm = true; + } + + public function save(): void + { + $validated = $this->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'string', 'in:' . implode(',', Project::STATUSES)], + 'dueAt' => ['nullable', 'date'], + ]); + + Project::updateOrCreate(['id' => $this->editingId], [ + 'name' => $validated['name'], + 'description' => $validated['description'], + 'status' => $validated['status'], + 'due_at' => $validated['dueAt'] ?: null, + ]); + + $this->resetForm(); + $this->showForm = false; + } + + public function delete(int $id): void + { + Project::findOrFail($id)->delete(); + } + + public function clearFilters(): void + { + $this->search = ''; + $this->statusFilter = ''; + } + + public function resetForm(): void + { + $this->editingId = null; + $this->name = ''; + $this->description = null; + $this->status = 'active'; + $this->dueAt = null; + $this->resetValidation(); + } + + public function getTasksCount(int $projectId): int + { + return \App\Models\Task::where('project_id', $projectId)->count(); + } + + public function getStatusColor(string $status): string + { + return match ($status) { + 'active' => 'bg-green-100 text-green-800', + 'completed' => 'bg-blue-100 text-blue-800', + 'archived' => 'bg-gray-100 text-gray-800', + 'on_hold' => 'bg-yellow-100 text-yellow-800', + 'planned' => 'bg-purple-100 text-purple-800', + default => 'bg-gray-100 text-gray-800', + }; + } +} diff --git a/app/Livewire/TaskComments/Index.php b/app/Livewire/TaskComments/Index.php new file mode 100644 index 0000000..5857c2c --- /dev/null +++ b/app/Livewire/TaskComments/Index.php @@ -0,0 +1,142 @@ +has('task_id')) { + $this->taskId = (int) request('task_id'); + } + + // Set default author name for user + if (auth()->check()) { + $this->authorName = auth()->user()->name ?? 'User'; + } else { + $this->authorName = 'User'; + } + } + + public function render() + { + $task = null; + $comments = collect(); + + if ($this->taskId) { + $task = Task::with(['project', 'comments'])->find($this->taskId); + $comments = $task ? $task->comments()->orderBy('created_at')->get() : collect(); + } + + return view('livewire.task-comments.index', [ + 'task' => $task, + 'comments' => $comments, + 'authorTypes' => TaskComment::AUTHOR_TYPES, + ]); + } + + public function create(): void + { + $this->resetForm(); + $this->showForm = true; + } + + public function edit(int $id): void + { + $comment = TaskComment::findOrFail($id); + $this->editingId = $comment->id; + $this->body = $comment->body; + $this->authorType = $comment->author_type; + $this->authorName = $comment->author_name; + $this->showForm = true; + } + + public function save(): void + { + if (!$this->taskId) { + $this->addError('taskId', 'Task ID is required'); + return; + } + + $validated = $this->validate([ + 'body' => ['required', 'string'], + 'authorType' => ['required', 'string', 'in:' . implode(',', TaskComment::AUTHOR_TYPES)], + 'authorName' => ['required', 'string', 'max:255'], + ]); + + TaskComment::updateOrCreate(['id' => $this->editingId], [ + 'task_id' => $this->taskId, + 'body' => $validated['body'], + 'author_type' => $validated['authorType'], + 'author_name' => $validated['authorName'], + ]); + + $this->resetForm(); + $this->showForm = false; + } + + public function delete(int $id): void + { + TaskComment::findOrFail($id)->delete(); + } + + public function resetForm(): void + { + $this->editingId = null; + $this->body = ''; + $this->authorType = 'user'; + // Keep the author name as it was + $this->resetValidation(); + } + + public function getAuthorColor(string $authorType): string + { + return match ($authorType) { + 'ai' => 'bg-purple-100 text-purple-800', + 'system' => 'bg-gray-100 text-gray-800', + 'user' => 'bg-blue-100 text-blue-800', + default => 'bg-gray-100 text-gray-800', + }; + } + + public function setAuthorAsAI(): void + { + $this->authorType = 'ai'; + $this->authorName = 'AI Assistant'; + } + + public function setAuthorAsUser(): void + { + $this->authorType = 'user'; + if (auth()->check()) { + $this->authorName = auth()->user()->name ?? 'User'; + } else { + $this->authorName = 'User'; + } + } + + public function setAuthorAsSystem(): void + { + $this->authorType = 'system'; + $this->authorName = 'System'; + } +} diff --git a/app/Livewire/Tasks/Index.php b/app/Livewire/Tasks/Index.php new file mode 100644 index 0000000..6046b26 --- /dev/null +++ b/app/Livewire/Tasks/Index.php @@ -0,0 +1,231 @@ +has('project_id')) { + $this->projectFilter = (int) request('project_id'); + } + } + + public function render() + { + $query = Task::with(['project', 'comments']); + + if ($this->search) { + $query->where('title', 'like', '%' . $this->search . '%') + ->orWhere('description', 'like', '%' . $this->search . '%'); + } + + if ($this->statusFilter) { + $query->where('status', $this->statusFilter); + } + + if ($this->priorityFilter) { + $query->where('priority', $this->priorityFilter); + } + + if ($this->projectFilter) { + $query->where('project_id', $this->projectFilter); + } + + $tasks = $query->orderBy('priority', 'desc')->orderBy('created_at')->get(); + + return view('livewire.tasks.index', [ + 'tasks' => $tasks, + 'projects' => Project::orderBy('name')->get(), + 'statuses' => Task::STATUSES, + 'totalTasks' => Task::count(), + 'todoTasks' => Task::todo()->count(), + 'inProgressTasks' => Task::inProgress()->count(), + 'doneTasks' => Task::done()->count(), + 'blockedTasks' => Task::blocked()->count(), + 'waitingReviewTasks' => Task::waitingReview()->count(), + 'dueSoonTasks' => Task::dueSoon()->count(), + 'overdueTasks' => Task::overdue()->count(), + ]); + } + + public function create(): void + { + $this->resetForm(); + // Pre-select the filtered project if one is selected + if ($this->projectFilter) { + $this->projectId = $this->projectFilter; + } + $this->showForm = true; + } + + public function edit(int $id): void + { + $task = Task::findOrFail($id); + $this->editingId = $task->id; + $this->title = $task->title; + $this->description = $task->description; + $this->status = $task->status; + $this->projectId = $task->project_id; + $this->priority = $task->priority; + $this->dueAt = $task->due_at?->format('Y-m-d\TH:i') ?? ''; + $this->estimatedHours = $task->estimated_hours; + $this->actualHours = $task->actual_hours; + $this->showForm = true; + } + + public function save(): void + { + $validated = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'string', 'in:' . implode(',', Task::STATUSES)], + 'projectId' => ['nullable', 'integer', 'exists:projects,id'], + 'priority' => ['integer', 'min:0', 'max:10'], + 'dueAt' => ['nullable', 'date'], + 'estimatedHours' => ['nullable', 'numeric', 'min:0'], + 'actualHours' => ['nullable', 'numeric', 'min:0'], + ]); + + Task::updateOrCreate(['id' => $this->editingId], [ + 'title' => $validated['title'], + 'description' => $validated['description'], + 'status' => $validated['status'], + 'project_id' => $validated['projectId'], + 'priority' => $validated['priority'], + 'due_at' => $validated['dueAt'] ?: null, + 'estimated_hours' => $validated['estimatedHours'] ?: null, + 'actual_hours' => $validated['actualHours'] ?: null, + ]); + + $this->resetForm(); + $this->showForm = false; + } + + public function delete(int $id): void + { + Task::findOrFail($id)->delete(); + } + + public function changeStatus(int $id, string $newStatus): void + { + if (!in_array($newStatus, Task::STATUSES)) { + return; + } + + $task = Task::findOrFail($id); + $task->update(['status' => $newStatus]); + + // Auto-set timestamps based on status + if ($newStatus === 'in_progress' && !$task->started_at) { + $task->update(['started_at' => now()]); + } + + if ($newStatus === 'done' && !$task->completed_at) { + $task->update(['completed_at' => now()]); + } + + if ($newStatus !== 'in_progress' && $newStatus !== 'done') { + $task->update(['started_at' => null, 'completed_at' => null]); + } + } + + public function clearFilters(): void + { + $this->search = ''; + $this->statusFilter = ''; + $this->priorityFilter = ''; + $this->projectFilter = null; + } + + public function resetForm(): void + { + $this->editingId = null; + $this->title = ''; + $this->description = null; + $this->status = 'todo'; + $this->projectId = null; + $this->priority = 0; + $this->dueAt = null; + $this->estimatedHours = null; + $this->actualHours = null; + $this->resetValidation(); + } + + public function getStatusColor(string $status): string + { + return match ($status) { + 'todo' => 'bg-gray-100 text-gray-800', + 'in_progress' => 'bg-blue-100 text-blue-800', + 'done' => 'bg-green-100 text-green-800', + 'blocked' => 'bg-red-100 text-red-800', + 'waiting_review' => 'bg-yellow-100 text-yellow-800', + default => 'bg-gray-100 text-gray-800', + }; + } + + public function getPriorityLabel(int $priority): string + { + if ($priority === 0) return 'None'; + if ($priority <= 3) return 'Low'; + if ($priority <= 6) return 'Medium'; + if ($priority <= 8) return 'High'; + return 'Critical'; + } + + public function getCommentsCount(int $taskId): int + { + return \App\Models\TaskComment::where('task_id', $taskId)->count(); + } + + public function startTracking(int $id): void + { + $task = Task::findOrFail($id); + if ($task->status !== 'in_progress') { + $task->update(['status' => 'in_progress', 'started_at' => now()]); + } + } + + public function stopTracking(int $id): void + { + // This would need more complex time tracking logic + // For now, just mark as done + $task = Task::findOrFail($id); + $task->update(['status' => 'done', 'completed_at' => now()]); + } +} diff --git a/app/Models/Idea.php b/app/Models/Idea.php new file mode 100644 index 0000000..6c942ed --- /dev/null +++ b/app/Models/Idea.php @@ -0,0 +1,103 @@ + 'integer', + ]; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeNew($query) + { + return $query->where('status', 'new'); + } + + public function scopeConsidered($query) + { + return $query->where('status', 'considered'); + } + + public function scopePlanned($query) + { + return $query->where('status', 'planned'); + } + + public function scopeRejected($query) + { + return $query->where('status', 'rejected'); + } + + public function scopeImplemented($query) + { + return $query->where('status', 'implemented'); + } + + public function scopeByCategory($query, string $category) + { + return $query->where('category', $category); + } + + public function scopeLinkedToProject($query) + { + return $query->whereNotNull('project_id'); + } + + public function scopeUnlinked($query) + { + return $query->whereNull('project_id'); + } + + public function scopeSearch($query, string $search) + { + return $query->where('title', 'like', '%' . $search . '%') + ->orWhere('description', 'like', '%' . $search . '%') + ->orWhere('tags', 'like', '%' . $search . '%'); + } + + public function getTagsArrayAttribute(): array + { + return $this->tags ? explode(',', $this->tags) : []; + } + + public function setTagsAttribute(array|string|null $value): void + { + if (is_array($value)) { + $this->attributes['tags'] = implode(',', $value); + } else { + $this->attributes['tags'] = $value; + } + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 0000000..be5d57b --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,56 @@ + 'datetime', + ]; + + public function tasks(): HasMany + { + return $this->hasMany(Task::class)->orderBy('priority', 'desc')->orderBy('created_at'); + } + + public function ideas(): HasMany + { + return $this->hasMany(Idea::class)->orderBy('priority', 'desc')->orderBy('created_at', 'desc'); + } + + public function activeTasks() + { + return $this->tasks()->whereIn('status', ['todo', 'in_progress', 'waiting_review']); + } + + public function completedTasks() + { + return $this->tasks()->where('status', 'done'); + } + + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + public function scopeCompleted($query) + { + return $query->where('status', 'completed'); + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php new file mode 100644 index 0000000..a05e956 --- /dev/null +++ b/app/Models/Task.php @@ -0,0 +1,88 @@ + 'datetime', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function comments(): HasMany + { + return $this->hasMany(TaskComment::class)->orderBy('created_at'); + } + + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + public function scopeTodo($query) + { + return $query->where('status', 'todo'); + } + + public function scopeInProgress($query) + { + return $query->where('status', 'in_progress'); + } + + public function scopeDone($query) + { + return $query->where('status', 'done'); + } + + public function scopeBlocked($query) + { + return $query->where('status', 'blocked'); + } + + public function scopeWaitingReview($query) + { + return $query->where('status', 'waiting_review'); + } + + public function scopeDueSoon($query, int $days = 3) + { + return $query->where('due_at', '>=', now()) + ->where('due_at', '<=', now()->addDays($days)) + ->where('status', '!=', 'done'); + } + + public function scopeOverdue($query) + { + return $query->where('due_at', '<', now()) + ->where('status', '!=', 'done'); + } +} diff --git a/app/Models/TaskComment.php b/app/Models/TaskComment.php new file mode 100644 index 0000000..c3c891d --- /dev/null +++ b/app/Models/TaskComment.php @@ -0,0 +1,42 @@ +belongsTo(Task::class); + } + + public function isFromAI(): bool + { + return $this->author_type === 'ai'; + } + + public function isFromUser(): bool + { + return $this->author_type === 'user'; + } + + public function isSystem(): bool + { + return $this->author_type === 'system'; + } +} diff --git a/database/factories/IdeaFactory.php b/database/factories/IdeaFactory.php new file mode 100644 index 0000000..8a18f30 --- /dev/null +++ b/database/factories/IdeaFactory.php @@ -0,0 +1,104 @@ + fake()->sentence(4), + 'description' => fake()->optional()->paragraph(), + 'status' => fake()->randomElement(Idea::STATUSES), + 'project_id' => null, // Optional link to project + 'priority' => fake()->numberBetween(0, 10), + 'category' => fake()->optional()->randomElement(Idea::CATEGORIES), + 'tags' => fake()->optional()->words(3, true), + 'user_id' => User::factory(), + ]; + } + + public function asNew(): static + { + return $this->state(fn () => [ + 'status' => 'new', + ]); + } + + public function considered(): static + { + return $this->state(fn () => [ + 'status' => 'considered', + ]); + } + + public function planned(): static + { + return $this->state(fn () => [ + 'status' => 'planned', + ]); + } + + public function rejected(): static + { + return $this->state(fn () => [ + 'status' => 'rejected', + ]); + } + + public function implemented(): static + { + return $this->state(fn () => [ + 'status' => 'implemented', + ]); + } + + public function feature(): static + { + return $this->state(fn () => [ + 'category' => 'feature', + ]); + } + + public function improvement(): static + { + return $this->state(fn () => [ + 'category' => 'improvement', + ]); + } + + public function bugfix(): static + { + return $this->state(fn () => [ + 'category' => 'bugfix', + ]); + } + + public function withProject(Project $project = null): static + { + return $this->state(fn () => [ + 'project_id' => $project ? $project->id : Project::factory()->create()->id, + ]); + } + + public function highPriority(): static + { + return $this->state(fn () => [ + 'priority' => fake()->numberBetween(7, 10), + ]); + } + + public function withTags(array $tags): static + { + return $this->state(fn () => [ + 'tags' => implode(',', $tags), + ]); + } +} diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php new file mode 100644 index 0000000..c1c9c9c --- /dev/null +++ b/database/factories/ProjectFactory.php @@ -0,0 +1,63 @@ + fake()->words(2, true), + 'description' => fake()->optional()->paragraph(), + 'status' => fake()->randomElement(Project::STATUSES), + 'due_at' => fake()->optional()->dateTimeBetween('now', '+1 year'), + ]; + } + + public function active(): static + { + return $this->state(fn () => [ + 'status' => 'active', + ]); + } + + public function completed(): static + { + return $this->state(fn () => [ + 'status' => 'completed', + ]); + } + + public function archived(): static + { + return $this->state(fn () => [ + 'status' => 'archived', + ]); + } + + public function onHold(): static + { + return $this->state(fn () => [ + 'status' => 'on_hold', + ]); + } + + public function planned(): static + { + return $this->state(fn () => [ + 'status' => 'planned', + ]); + } + + public function withDueDate(): static + { + return $this->state(fn () => [ + 'due_at' => fake()->dateTimeBetween('now', '+1 year'), + ]); + } +} diff --git a/database/factories/TaskCommentFactory.php b/database/factories/TaskCommentFactory.php new file mode 100644 index 0000000..50a3302 --- /dev/null +++ b/database/factories/TaskCommentFactory.php @@ -0,0 +1,53 @@ + Task::factory(), + 'body' => fake()->paragraph(), + 'author_type' => fake()->randomElement(TaskComment::AUTHOR_TYPES), + 'author_name' => fake()->name(), + ]; + } + + public function fromUser(): static + { + return $this->state(fn () => [ + 'author_type' => 'user', + 'author_name' => fake()->name(), + ]); + } + + public function fromAI(): static + { + return $this->state(fn () => [ + 'author_type' => 'ai', + 'author_name' => fake()->randomElement(['AI Assistant', 'ChatBot', 'Virtual Helper']), + ]); + } + + public function fromSystem(): static + { + return $this->state(fn () => [ + 'author_type' => 'system', + 'author_name' => fake()->randomElement(['System', 'Automation', 'Scheduler']), + ]); + } + + public function forTask(Task $task): static + { + return $this->state(fn () => [ + 'task_id' => $task->id, + ]); + } +} diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php new file mode 100644 index 0000000..4c25322 --- /dev/null +++ b/database/factories/TaskFactory.php @@ -0,0 +1,92 @@ + fake()->sentence(4), + 'description' => fake()->optional()->paragraph(), + 'status' => fake()->randomElement(Task::STATUSES), + 'project_id' => Project::factory(), + 'priority' => fake()->numberBetween(0, 10), + 'due_at' => fake()->optional()->dateTimeBetween('now', '+1 year'), + 'estimated_hours' => fake()->optional()->numberBetween(1, 40), + 'actual_hours' => null, + 'started_at' => null, + 'completed_at' => null, + ]; + } + + public function todo(): static + { + return $this->state(fn () => [ + 'status' => 'todo', + ]); + } + + public function inProgress(): static + { + return $this->state(fn () => [ + 'status' => 'in_progress', + ]); + } + + public function done(): static + { + return $this->state(fn () => [ + 'status' => 'done', + 'completed_at' => now(), + ]); + } + + public function blocked(): static + { + return $this->state(fn () => [ + 'status' => 'blocked', + ]); + } + + public function waitingReview(): static + { + return $this->state(fn () => [ + 'status' => 'waiting_review', + ]); + } + + public function highPriority(): static + { + return $this->state(fn () => [ + 'priority' => fake()->numberBetween(7, 10), + ]); + } + + public function lowPriority(): static + { + return $this->state(fn () => [ + 'priority' => fake()->numberBetween(0, 3), + ]); + } + + public function withEstimatedHours(int $hours): static + { + return $this->state(fn () => [ + 'estimated_hours' => $hours, + ]); + } + + public function forProject(Project $project): static + { + return $this->state(fn () => [ + 'project_id' => $project->id, + ]); + } +} diff --git a/database/migrations/2026_07_27_000006_create_projects_table.php b/database/migrations/2026_07_27_000006_create_projects_table.php new file mode 100644 index 0000000..24a72c4 --- /dev/null +++ b/database/migrations/2026_07_27_000006_create_projects_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('status')->default('active'); + $table->dateTime('due_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('projects'); + } +}; diff --git a/database/migrations/2026_07_27_000007_create_tasks_table.php b/database/migrations/2026_07_27_000007_create_tasks_table.php new file mode 100644 index 0000000..179c865 --- /dev/null +++ b/database/migrations/2026_07_27_000007_create_tasks_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('status')->default('todo'); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->integer('priority')->default(0); + $table->dateTime('due_at')->nullable(); + $table->dateTime('started_at')->nullable(); + $table->dateTime('completed_at')->nullable(); + $table->integer('estimated_hours')->nullable(); + $table->integer('actual_hours')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tasks'); + } +}; diff --git a/database/migrations/2026_07_27_000008_create_task_comments_table.php b/database/migrations/2026_07_27_000008_create_task_comments_table.php new file mode 100644 index 0000000..a8f2069 --- /dev/null +++ b/database/migrations/2026_07_27_000008_create_task_comments_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('task_id')->constrained()->cascadeOnDelete(); + $table->text('body'); + $table->string('author_type')->default('user'); // user, ai, system + $table->string('author_name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('task_comments'); + } +}; diff --git a/database/migrations/2026_08_03_000001_create_ideas_table.php b/database/migrations/2026_08_03_000001_create_ideas_table.php new file mode 100644 index 0000000..3575f09 --- /dev/null +++ b/database/migrations/2026_08_03_000001_create_ideas_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('status')->default('new'); // new, considered, planned, rejected, implemented + $table->foreignId('project_id')->nullable()->constrained()->nullOnDelete(); + $table->integer('priority')->default(0); + $table->string('category')->nullable(); // feature, improvement, bugfix, research, other + $table->string('tags')->nullable(); // comma-separated tags + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ideas'); + } +}; diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 1aa36cf..7c5e90b 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -12,6 +12,9 @@ Sites Social Accounts Posts + Projects + Tasks + Ideas @auth