From a4ff6c0ccb8070ccf8c4e1123e520a589648a93a Mon Sep 17 00:00:00 2001 From: m105 Date: Thu, 6 Aug 2026 20:41:57 +0300 Subject: [PATCH] Commit the Projects/Tasks/Ideas/TaskComments feature This was already implemented, migrated, and running live, but had never been committed. Bringing it into git: Project/Task/Idea/ TaskComment models, their JSON API (CRUD + status transitions, task comments, idea<->project linking), Livewire UI pages, and the nav/route wiring for them. All 42 of its own tests pass. Co-Authored-By: Claude Sonnet 5 --- app/Http/Controllers/Api/IdeaController.php | 129 +++++ .../Controllers/Api/ProjectController.php | 113 ++++ .../Controllers/Api/TaskCommentController.php | 69 +++ app/Http/Controllers/Api/TaskController.php | 127 +++++ app/Http/Requests/StoreIdeaRequest.php | 29 + app/Http/Requests/StoreProjectRequest.php | 25 + app/Http/Requests/StoreTaskCommentRequest.php | 24 + app/Http/Requests/StoreTaskRequest.php | 28 + app/Http/Requests/UpdateIdeaRequest.php | 29 + app/Http/Requests/UpdateProjectRequest.php | 25 + app/Http/Requests/UpdateTaskRequest.php | 31 ++ app/Http/Resources/IdeaResource.php | 36 ++ app/Http/Resources/ProjectResource.php | 26 + app/Http/Resources/TaskCommentResource.php | 23 + app/Http/Resources/TaskResource.php | 35 ++ app/Livewire/Ideas/Index.php | 209 +++++++ app/Livewire/Projects/Index.php | 126 +++++ app/Livewire/TaskComments/Index.php | 142 +++++ app/Livewire/Tasks/Index.php | 231 ++++++++ app/Models/Idea.php | 103 ++++ app/Models/Project.php | 56 ++ app/Models/Task.php | 88 +++ app/Models/TaskComment.php | 42 ++ database/factories/IdeaFactory.php | 104 ++++ database/factories/ProjectFactory.php | 63 +++ database/factories/TaskCommentFactory.php | 53 ++ database/factories/TaskFactory.php | 92 ++++ ...026_07_27_000006_create_projects_table.php | 26 + .../2026_07_27_000007_create_tasks_table.php | 32 ++ ...7_27_000008_create_task_comments_table.php | 26 + .../2026_08_03_000001_create_ideas_table.php | 30 + .../views/components/layouts/app.blade.php | 3 + .../views/livewire/ideas/index.blade.php | 220 ++++++++ .../views/livewire/projects/index.blade.php | 124 +++++ .../livewire/task-comments/index.blade.php | 116 ++++ .../views/livewire/tasks/index.blade.php | 236 ++++++++ routes/api.php | 20 + routes/web.php | 4 + .../Feature/Api/ProjectManagementApiTest.php | 516 ++++++++++++++++++ 39 files changed, 3411 insertions(+) create mode 100644 app/Http/Controllers/Api/IdeaController.php create mode 100644 app/Http/Controllers/Api/ProjectController.php create mode 100644 app/Http/Controllers/Api/TaskCommentController.php create mode 100644 app/Http/Controllers/Api/TaskController.php create mode 100644 app/Http/Requests/StoreIdeaRequest.php create mode 100644 app/Http/Requests/StoreProjectRequest.php create mode 100644 app/Http/Requests/StoreTaskCommentRequest.php create mode 100644 app/Http/Requests/StoreTaskRequest.php create mode 100644 app/Http/Requests/UpdateIdeaRequest.php create mode 100644 app/Http/Requests/UpdateProjectRequest.php create mode 100644 app/Http/Requests/UpdateTaskRequest.php create mode 100644 app/Http/Resources/IdeaResource.php create mode 100644 app/Http/Resources/ProjectResource.php create mode 100644 app/Http/Resources/TaskCommentResource.php create mode 100644 app/Http/Resources/TaskResource.php create mode 100644 app/Livewire/Ideas/Index.php create mode 100644 app/Livewire/Projects/Index.php create mode 100644 app/Livewire/TaskComments/Index.php create mode 100644 app/Livewire/Tasks/Index.php create mode 100644 app/Models/Idea.php create mode 100644 app/Models/Project.php create mode 100644 app/Models/Task.php create mode 100644 app/Models/TaskComment.php create mode 100644 database/factories/IdeaFactory.php create mode 100644 database/factories/ProjectFactory.php create mode 100644 database/factories/TaskCommentFactory.php create mode 100644 database/factories/TaskFactory.php create mode 100644 database/migrations/2026_07_27_000006_create_projects_table.php create mode 100644 database/migrations/2026_07_27_000007_create_tasks_table.php create mode 100644 database/migrations/2026_07_27_000008_create_task_comments_table.php create mode 100644 database/migrations/2026_08_03_000001_create_ideas_table.php create mode 100644 resources/views/livewire/ideas/index.blade.php create mode 100644 resources/views/livewire/projects/index.blade.php create mode 100644 resources/views/livewire/task-comments/index.blade.php create mode 100644 resources/views/livewire/tasks/index.blade.php create mode 100644 tests/Feature/Api/ProjectManagementApiTest.php 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
diff --git a/resources/views/livewire/ideas/index.blade.php b/resources/views/livewire/ideas/index.blade.php new file mode 100644 index 0000000..a0a6566 --- /dev/null +++ b/resources/views/livewire/ideas/index.blade.php @@ -0,0 +1,220 @@ +
+
+
+

Ideas

+

+ {{ $totalIdeas }} total | + {{ $newIdeas }} new | + {{ $consideredIdeas }} considered | + {{ $plannedIdeas }} planned | + {{ $implementedIdeas }} implemented +

+
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ +
+ @forelse ($ideas as $idea) +
+
+
+

{{ $idea->title }}

+
+ + {{ ucfirst($idea->status) }} + + @if($idea->category) + + {{ ucfirst($idea->category) }} + + @endif + @if($idea->priority > 0) + + Priority: {{ $this->getPriorityLabel($idea->priority) }} ({{ $idea->priority }}) + + @endif +
+
+
+ + +
+
+ + @if($idea->description) +

{{ $idea->description }}

+ @endif + + @if($idea->tags) +
+ @foreach(explode(',', $idea->tags) as $tag) + {{ trim($tag) }} + @endforeach +
+ @endif + +
+
+ @if($idea->project) + Project: {{ $idea->project->name }} + @else + No project linked + @endif + @if($idea->user) + By: {{ $idea->user->name }} + @endif +
+ {{ $idea->created_at->format('M d, Y') }} +
+ +
+ + + @if($idea->project_id) + + @else + + @endif +
+
+ @empty +
+ No ideas found. Share your first idea! +
+ @endforelse +
+ + @if ($showForm) +
+
+

{{ $editingId ? 'Edit Idea' : 'Add Idea' }}

+ +
+ + + @error('title') {{ $message }} @enderror +
+ +
+ + + @error('description') {{ $message }} @enderror +
+ +
+
+ + + @error('status') {{ $message }} @enderror +
+
+ + + @error('priority') {{ $message }} @enderror +
+
+ +
+
+ + + @error('category') {{ $message }} @enderror +
+
+ + + @error('projectId') {{ $message }} @enderror +
+
+ +
+ + + @error('tags') {{ $message }} @enderror +
+ +
+ + +
+
+
+ @endif +
diff --git a/resources/views/livewire/projects/index.blade.php b/resources/views/livewire/projects/index.blade.php new file mode 100644 index 0000000..9ac20d7 --- /dev/null +++ b/resources/views/livewire/projects/index.blade.php @@ -0,0 +1,124 @@ +
+
+
+

Projects

+

+ {{ $totalProjects }} total | + {{ $activeProjects }} active | + {{ $completedProjects }} completed +

+
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ @forelse ($projects as $project) +
+
+
+

{{ $project->name }}

+
+ + {{ ucfirst(str_replace('_', ' ', $project->status)) }} + + @if($project->due_at) + + Due: {{ $project->due_at->format('M d, Y') }} + + @endif +
+
+
+ + +
+
+ + @if($project->description) +

{{ $project->description }}

+ @endif + +
+ {{ $this->getTasksCount($project->id) }} tasks + {{ $project->created_at->format('M d, Y') }} +
+ + +
+ @empty +
+ No projects found. Create your first project! +
+ @endforelse +
+ + @if ($showForm) +
+
+

{{ $editingId ? 'Edit Project' : 'Add Project' }}

+ +
+ + + @error('name') {{ $message }} @enderror +
+ +
+ + + @error('description') {{ $message }} @enderror +
+ +
+ + + @error('status') {{ $message }} @enderror +
+ +
+ + + @error('dueAt') {{ $message }} @enderror +
+ +
+ + +
+
+
+ @endif +
diff --git a/resources/views/livewire/task-comments/index.blade.php b/resources/views/livewire/task-comments/index.blade.php new file mode 100644 index 0000000..cf9e2c2 --- /dev/null +++ b/resources/views/livewire/task-comments/index.blade.php @@ -0,0 +1,116 @@ +
+
+
+

Task Comments

+ @if($task) +

+ Task: {{ $task->title }} + @if($task->project) + | Project: {{ $task->project->name }} + @endif +

+ @endif +
+
+ @if($task) + + @endif +
+
+ + @if($task) +
+

Task Details

+

{{ $task->description }}

+
+ Status: {{ ucfirst(str_replace('_', ' ', $task->status)) }} + @if($task->priority > 0) + Priority: {{ $task->priority }} + @endif + @if($task->due_at) + Due: {{ $task->due_at->format('M d, Y') }} + @endif +
+
+ @else +
+

No task selected. Please select a task to view its comments.

+
+ @endif + +
+ @forelse ($comments as $comment) +
+
+
+
+ + {{ ucfirst($comment->author_type) }} + + {{ $comment->author_name }} + {{ $comment->created_at->format('M d, Y H:i') }} +
+
+ {{ $comment->body }} +
+
+
+ + +
+
+
+ @empty + @if($task) +
+ No comments yet. Add your first comment! +
+ @endif + @endforelse +
+ + @if ($showForm) +
+
+

{{ $editingId ? 'Edit Comment' : 'Add Comment' }}

+ +
+ +
+ + + +
+ + @error('authorType') {{ $message }} @enderror +
+ +
+ + + @error('authorName') {{ $message }} @enderror +
+ +
+ + + @error('body') {{ $message }} @enderror +
+ +
+ + +
+
+
+ @endif +
diff --git a/resources/views/livewire/tasks/index.blade.php b/resources/views/livewire/tasks/index.blade.php new file mode 100644 index 0000000..6ba4b2e --- /dev/null +++ b/resources/views/livewire/tasks/index.blade.php @@ -0,0 +1,236 @@ +
+
+
+

Tasks

+

+ {{ $totalTasks }} total | + {{ $todoTasks }} todo | + {{ $inProgressTasks }} in progress | + {{ $doneTasks }} done | + {{ $blockedTasks }} blocked | + {{ $waitingReviewTasks }} waiting review +

+ @if($dueSoonTasks > 0 || $overdueTasks > 0) +

+ @if($dueSoonTasks > 0) + {{ $dueSoonTasks }} due soon + @endif + @if($overdueTasks > 0) + {{ $overdueTasks }} overdue + @endif +

+ @endif +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + @forelse ($tasks as $task) + + + + + + + + + + + @empty + + + + @endforelse + +
TaskProjectStatusPriorityDueTimeCommentsActions
+
{{ $task->title }}
+ @if($task->description) +
{{ $task->description }}
+ @endif +
+ @if($task->project) + + {{ $task->project->name }} + + @else + — + @endif + + + + + {{ $this->getPriorityLabel($task->priority) }} + @if($task->priority > 0) + ({{ $task->priority }}) + @endif + + + @if($task->due_at) + + {{ $task->due_at->format('M d') }} + + @else + — + @endif + + @if($task->estimated_hours) + {{ $task->estimated_hours }}h + @endif + @if($task->actual_hours) + {{ $task->actual_hours }}h + @endif + @if(!$task->estimated_hours && !$task->actual_hours) + — + @endif + + + {{ $this->getCommentsCount($task->id) }} + + + + + Comments +
+ No tasks found. Create your first task! +
+
+ + @if ($showForm) +
+
+

{{ $editingId ? 'Edit Task' : 'Add Task' }}

+ +
+ + + @error('title') {{ $message }} @enderror +
+ +
+ + + @error('description') {{ $message }} @enderror +
+ +
+ + + @error('projectId') {{ $message }} @enderror +
+ +
+
+ + + @error('status') {{ $message }} @enderror +
+
+ + + @error('priority') {{ $message }} @enderror +
+
+ +
+
+ + + @error('dueAt') {{ $message }} @enderror +
+
+ + + @error('estimatedHours') {{ $message }} @enderror +
+
+ +
+ + + @error('actualHours') {{ $message }} @enderror +
+ +
+ + +
+
+
+ @endif +
diff --git a/routes/api.php b/routes/api.php index 6125525..c6b20cd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,8 +1,11 @@ names('api.posts'); +// Project Management API +Route::apiResource('projects', ProjectController::class)->names('api.projects'); +Route::get('/projects/{project}/tasks', [ProjectController::class, 'tasks'])->name('api.projects.tasks'); +Route::get('/projects/{project}/ideas', [ProjectController::class, 'ideas'])->name('api.projects.ideas'); + +Route::apiResource('tasks', TaskController::class)->names('api.tasks'); +Route::get('/tasks/{task}/comments', [TaskController::class, 'comments'])->name('api.tasks.comments'); +Route::post('/tasks/{task}/status', [TaskController::class, 'changeStatus'])->name('api.tasks.status'); + +Route::apiResource('tasks.comments', TaskCommentController::class)->shallow()->names('api.task_comments'); + +// Ideas API +Route::apiResource('ideas', \App\Http\Controllers\Api\IdeaController::class)->names('api.ideas'); +Route::post('/ideas/{idea}/status', [\App\Http\Controllers\Api\IdeaController::class, 'changeStatus'])->name('api.ideas.status'); +Route::post('/ideas/{idea}/link-project', [\App\Http\Controllers\Api\IdeaController::class, 'linkToProject'])->name('api.ideas.link_project'); +Route::post('/ideas/{idea}/unlink-project', [\App\Http\Controllers\Api\IdeaController::class, 'unlinkFromProject'])->name('api.ideas.unlink_project'); + // Webhook endpoints (require X-Muninn-Token header) Route::prefix('webhooks')->group(function () { Route::post('/posts', [WebhookController::class, 'createPost']); diff --git a/routes/web.php b/routes/web.php index 76fd64c..93d8911 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,4 +27,8 @@ Route::middleware('auth')->group(function () { Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index'); Route::get('/posts', PostsIndex::class)->name('posts.index'); Route::get('/templates', TemplatesIndex::class)->name('templates.index'); + Route::get('/projects', \App\Livewire\Projects\Index::class)->name('projects.index'); + Route::get('/tasks', \App\Livewire\Tasks\Index::class)->name('tasks.index'); + Route::get('/task-comments', \App\Livewire\TaskComments\Index::class)->name('task-comments.index'); + Route::get('/ideas', \App\Livewire\Ideas\Index::class)->name('ideas.index'); }); diff --git a/tests/Feature/Api/ProjectManagementApiTest.php b/tests/Feature/Api/ProjectManagementApiTest.php new file mode 100644 index 0000000..f8c74ac --- /dev/null +++ b/tests/Feature/Api/ProjectManagementApiTest.php @@ -0,0 +1,516 @@ +count(3)->create(); + + $response = $this->getJson('/api/projects'); + + $response->assertOk()->assertJsonCount(3, 'data'); +}); + +test('it filters projects by status', function () { + Project::factory()->create(['status' => 'active']); + Project::factory()->create(['status' => 'completed']); + Project::factory()->create(['status' => 'archived']); + + $response = $this->getJson('/api/projects?status=active'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.status', 'active'); +}); + +test('it filters projects by name', function () { + Project::factory()->create(['name' => 'Alpha Project']); + Project::factory()->create(['name' => 'Beta Project']); + Project::factory()->create(['name' => 'Gamma Project']); + + $response = $this->getJson('/api/projects?name=Alpha'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.name', 'Alpha Project'); +}); + +test('it includes tasks when requested', function () { + $project = Project::factory()->create(); + Task::factory()->count(2)->create(['project_id' => $project->id]); + + $response = $this->getJson('/api/projects?with_tasks=1'); + + $response->assertOk() + ->assertJsonPath('data.0.task_count', 2); +}); + +test('it shows a single project', function () { + $project = Project::factory()->create(['name' => 'Test Project']); + + $response = $this->getJson("/api/projects/{$project->id}"); + + $response->assertOk()->assertJsonPath('data.name', 'Test Project'); +}); + +test('it returns 404 for a missing project', function () { + $this->getJson('/api/projects/999')->assertNotFound(); +}); + +// Projects - Store +test('it creates a project', function () { + $data = [ + 'name' => 'New Project', + 'description' => 'A new test project', + 'status' => 'planned', + 'due_at' => now()->addDays(30)->toDateTimeString(), + ]; + + $response = $this->postJson('/api/projects', $data); + + $response->assertCreated() + ->assertJsonPath('data.name', 'New Project') + ->assertJsonPath('data.status', 'planned'); + + $this->assertDatabaseHas('projects', ['name' => 'New Project']); +}); + +// Projects - Update +test('it updates a project', function () { + $project = Project::factory()->create(['name' => 'Original Name']); + + $data = [ + 'name' => 'Updated Name', + 'description' => 'Updated description', + 'status' => 'active', + ]; + + $response = $this->putJson("/api/projects/{$project->id}", $data); + + $response->assertOk() + ->assertJsonPath('data.name', 'Updated Name') + ->assertJsonPath('data.description', 'Updated description'); + + $this->assertDatabaseHas('projects', ['name' => 'Updated Name']); +}); + +// Projects - Destroy +test('it deletes a project', function () { + $project = Project::factory()->create(); + + $response = $this->deleteJson("/api/projects/{$project->id}"); + + $response->assertNoContent(); + + $this->assertSoftDeleted($project); +}); + +// Projects - Tasks Endpoint +test('it lists tasks for a project', function () { + $project = Project::factory()->create(); + Task::factory()->count(3)->create(['project_id' => $project->id]); + Task::factory()->count(2)->create(); // Tasks not in this project + + $response = $this->getJson("/api/projects/{$project->id}/tasks"); + + $response->assertOk()->assertJsonCount(3, 'data'); +}); + +// ==================== TASKS API TESTS ==================== + +// Tasks - Index +test('it lists all tasks', function () { + Task::factory()->count(5)->create(); + + $response = $this->getJson('/api/tasks'); + + $response->assertOk()->assertJsonCount(5, 'data'); +}); + +test('it filters tasks by status', function () { + Task::factory()->create(['status' => 'todo']); + Task::factory()->create(['status' => 'in_progress']); + Task::factory()->create(['status' => 'done']); + + $response = $this->getJson('/api/tasks?status=in_progress'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.status', 'in_progress'); +}); + +test('it filters tasks by priority', function () { + Task::factory()->create(['priority' => 5]); + Task::factory()->create(['priority' => 3]); + Task::factory()->create(['priority' => 5]); + + $response = $this->getJson('/api/tasks?priority=5'); + + $response->assertOk()->assertJsonCount(2, 'data'); +}); + +test('it includes comments when requested', function () { + $task = Task::factory()->create(); + TaskComment::factory()->count(2)->create(['task_id' => $task->id]); + + $response = $this->getJson('/api/tasks?with_comments=1'); + + $response->assertOk() + ->assertJsonPath('data.0.comment_count', 2); +}); + +// Tasks - Show +test('it shows a single task', function () { + $task = Task::factory()->create(['title' => 'Test Task']); + + $response = $this->getJson("/api/tasks/{$task->id}"); + + $response->assertOk()->assertJsonPath('data.title', 'Test Task'); +}); + +// Tasks - Store +test('it creates a task', function () { + $project = Project::factory()->create(); + + $data = [ + 'title' => 'New Task', + 'description' => 'A new test task', + 'status' => 'todo', + 'project_id' => $project->id, + 'priority' => 5, + 'due_at' => now()->addDays(7)->toDateTimeString(), + 'estimated_hours' => 4, + ]; + + $response = $this->postJson('/api/tasks', $data); + + $response->assertCreated() + ->assertJsonPath('data.title', 'New Task') + ->assertJsonPath('data.priority', 5); + + $this->assertDatabaseHas('tasks', ['title' => 'New Task']); +}); + +// Tasks - Update +test('it updates a task', function () { + $task = Task::factory()->create(['title' => 'Original Title']); + + $data = [ + 'title' => 'Updated Title', + 'status' => 'in_progress', + 'priority' => 8, + ]; + + $response = $this->putJson("/api/tasks/{$task->id}", $data); + + $response->assertOk() + ->assertJsonPath('data.title', 'Updated Title') + ->assertJsonPath('data.status', 'in_progress'); + + $this->assertDatabaseHas('tasks', ['title' => 'Updated Title']); +}); + +// Tasks - Destroy +test('it deletes a task', function () { + $task = Task::factory()->create(); + + $response = $this->deleteJson("/api/tasks/{$task->id}"); + + $response->assertNoContent(); + + $this->assertSoftDeleted($task); +}); + +// Tasks - Comments Endpoint +test('it lists comments for a task', function () { + $task = Task::factory()->create(); + TaskComment::factory()->count(3)->create(['task_id' => $task->id]); + + $response = $this->getJson("/api/tasks/{$task->id}/comments"); + + $response->assertOk()->assertJsonCount(3, 'data'); +}); + +// Tasks - Change Status +test('it changes task status', function () { + $task = Task::factory()->create(['status' => 'todo']); + + $response = $this->postJson("/api/tasks/{$task->id}/status", [ + 'status' => 'in_progress', + ]); + + $response->assertOk(); + + $task->refresh(); + $this->assertEquals('in_progress', $task->status); +}); + +// ==================== TASK COMMENTS API TESTS ==================== + +// Task Comments - Index +test('it lists all task comments for a task', function () { + $task = Task::factory()->create(); + TaskComment::factory()->count(4)->create(['task_id' => $task->id]); + + $response = $this->getJson("/api/tasks/{$task->id}/comments"); + + $response->assertOk()->assertJsonCount(4, 'data'); +}); + +test('it shows a single task comment', function () { + $comment = TaskComment::factory()->create(['body' => 'Test Comment']); + + $response = $this->getJson("/api/comments/{$comment->id}"); + + $response->assertOk()->assertJsonPath('data.body', 'Test Comment'); +}); + +// Task Comments - Store +test('it creates a task comment', function () { + $task = Task::factory()->create(); + + $data = [ + 'body' => 'New comment from AI', + 'author_type' => 'ai', + 'author_name' => 'AI Assistant', + ]; + + $response = $this->postJson("/api/tasks/{$task->id}/comments", $data); + + $response->assertCreated() + ->assertJsonPath('data.body', 'New comment from AI') + ->assertJsonPath('data.author_type', 'ai'); + + $this->assertDatabaseHas('task_comments', ['body' => 'New comment from AI']); +}); + +// Task Comments - Update +test('it updates a task comment', function () { + $comment = TaskComment::factory()->create(['body' => 'Original Comment']); + + $data = [ + 'body' => 'Updated Comment', + ]; + + $response = $this->putJson("/api/comments/{$comment->id}", $data); + + $response->assertOk() + ->assertJsonPath('data.body', 'Updated Comment'); + + $this->assertDatabaseHas('task_comments', ['body' => 'Updated Comment']); +}); + +// Task Comments - Destroy +test('it deletes a task comment', function () { + $comment = TaskComment::factory()->create(); + + $response = $this->deleteJson("/api/comments/{$comment->id}"); + + $response->assertNoContent(); + + $this->assertSoftDeleted($comment); +}); + +// ==================== IDEAS API TESTS ==================== + +// Ideas - Index +test('it lists all ideas', function () { + Idea::factory()->count(5)->create(); + + $response = $this->getJson('/api/ideas'); + + $response->assertOk()->assertJsonCount(5, 'data'); +}); + +test('it filters ideas by status', function () { + Idea::factory()->create(['status' => 'new']); + Idea::factory()->create(['status' => 'considered']); + Idea::factory()->create(['status' => 'planned']); + + $response = $this->getJson('/api/ideas?status=considered'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.status', 'considered'); +}); + +test('it filters ideas by category', function () { + Idea::factory()->feature()->create(); + Idea::factory()->improvement()->create(); + Idea::factory()->bugfix()->create(); + + $response = $this->getJson('/api/ideas?category=feature'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.category', 'feature'); +}); + +test('it filters unlinked ideas', function () { + Idea::factory()->create(['project_id' => null]); + Idea::factory()->withProject()->create(); + + $response = $this->getJson('/api/ideas?unlinked=1'); + + $response->assertOk()->assertJsonCount(1, 'data'); +}); + +test('it filters ideas by project', function () { + $project = Project::factory()->create(); + Idea::factory()->count(2)->withProject($project)->create(); + Idea::factory()->count(3)->create(); + + $response = $this->getJson("/api/ideas?project_id={$project->id}"); + + $response->assertOk()->assertJsonCount(2, 'data'); +}); + +test('it searches ideas', function () { + Idea::factory()->create(['title' => 'AI Integration']); + Idea::factory()->create(['title' => 'Database Optimization']); + + $response = $this->getJson('/api/ideas?search=AI'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.title', 'AI Integration'); +}); + +// Ideas - Show +test('it shows a single idea', function () { + $idea = Idea::factory()->create(['title' => 'Test Idea']); + + $response = $this->getJson("/api/ideas/{$idea->id}"); + + $response->assertOk()->assertJsonPath('data.title', 'Test Idea'); +}); + +// Ideas - Store +test('it creates an idea', function () { + $data = [ + 'title' => 'New Idea', + 'description' => 'A great new idea', + 'status' => 'new', + 'category' => 'feature', + 'priority' => 5, + 'tags' => 'ai,automation,innovation', + ]; + + $response = $this->postJson('/api/ideas', $data); + + $response->assertCreated() + ->assertJsonPath('data.title', 'New Idea') + ->assertJsonPath('data.category', 'feature'); + + $this->assertDatabaseHas('ideas', ['title' => 'New Idea']); +}); + +// Ideas - Update +test('it updates an idea', function () { + $idea = Idea::factory()->create(['title' => 'Original Title']); + + $data = [ + 'title' => 'Updated Title', + 'status' => 'considered', + 'category' => 'improvement', + ]; + + $response = $this->putJson("/api/ideas/{$idea->id}", $data); + + $response->assertOk() + ->assertJsonPath('data.title', 'Updated Title') + ->assertJsonPath('data.status', 'considered'); + + $this->assertDatabaseHas('ideas', ['title' => 'Updated Title']); +}); + +// Ideas - Destroy +test('it deletes an idea', function () { + $idea = Idea::factory()->create(); + + $response = $this->deleteJson("/api/ideas/{$idea->id}"); + + $response->assertNoContent(); + + $this->assertSoftDeleted($idea); +}); + +// Ideas - Change Status +test('it changes idea status', function () { + $idea = Idea::factory()->create(['status' => 'new']); + + $response = $this->postJson("/api/ideas/{$idea->id}/status", [ + 'status' => 'considered', + ]); + + $response->assertOk(); + + $idea->refresh(); + $this->assertEquals('considered', $idea->status); +}); + +// Ideas - Link to Project +test('it links idea to project', function () { + $idea = Idea::factory()->create(['project_id' => null]); + $project = Project::factory()->create(); + + $response = $this->postJson("/api/ideas/{$idea->id}/link-project", [ + 'project_id' => $project->id, + ]); + + $response->assertOk(); + + $idea->refresh(); + $this->assertEquals($project->id, $idea->project_id); +}); + +// Ideas - Unlink from Project +test('it unlinks idea from project', function () { + $project = Project::factory()->create(); + $idea = Idea::factory()->withProject($project)->create(); + + $response = $this->postJson("/api/ideas/{$idea->id}/unlink-project"); + + $response->assertOk(); + + $idea->refresh(); + $this->assertNull($idea->project_id); +}); + +// ==================== DATA FACTORIES TESTS ==================== + +// Test that factories work correctly +test('project factory creates valid projects', function () { + $project = Project::factory()->create(); + + $this->assertNotNull($project->name); + $this->assertContains($project->status, Project::STATUSES); +}); + +test('task factory creates valid tasks', function () { + $task = Task::factory()->create(); + + $this->assertNotNull($task->title); + $this->assertContains($task->status, Task::STATUSES); + $this->assertNotNull($task->project_id); +}); + +test('task comment factory creates valid comments', function () { + $comment = TaskComment::factory()->create(); + + $this->assertNotNull($comment->body); + $this->assertContains($comment->author_type, TaskComment::AUTHOR_TYPES); + $this->assertNotNull($comment->task_id); +}); + +test('idea factory creates valid ideas', function () { + $idea = Idea::factory()->create(); + + $this->assertNotNull($idea->title); + $this->assertContains($idea->status, Idea::STATUSES); +}); \ No newline at end of file