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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreIdeaRequest;
|
||||||
|
use App\Http\Requests\UpdateIdeaRequest;
|
||||||
|
use App\Http\Resources\IdeaResource;
|
||||||
|
use App\Models\Idea;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class IdeaController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = Idea::query();
|
||||||
|
|
||||||
|
if ($request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreProjectRequest;
|
||||||
|
use App\Http\Requests\UpdateProjectRequest;
|
||||||
|
use App\Http\Resources\IdeaResource;
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Http\Resources\TaskResource;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ProjectController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = Project::query();
|
||||||
|
|
||||||
|
if ($request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreTaskCommentRequest;
|
||||||
|
use App\Http\Resources\TaskCommentResource;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\TaskComment;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TaskCommentController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get all comments for a task.
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Task $task)
|
||||||
|
{
|
||||||
|
$comments = $task->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreTaskRequest;
|
||||||
|
use App\Http\Requests\UpdateTaskRequest;
|
||||||
|
use App\Http\Resources\TaskResource;
|
||||||
|
use App\Models\Task;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TaskController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = Task::with(['project', 'comments']);
|
||||||
|
|
||||||
|
if ($request->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']));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Idea;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreIdeaRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => ['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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreProjectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'status' => ['required', 'string', Rule::in(Project::STATUSES)],
|
||||||
|
'due_at' => ['nullable', 'date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\TaskComment;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreTaskCommentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'body' => ['required', 'string'],
|
||||||
|
'author_type' => ['required', 'string', Rule::in(TaskComment::AUTHOR_TYPES)],
|
||||||
|
'author_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Task;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreTaskRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => ['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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Idea;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateIdeaRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => ['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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateProjectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['sometimes', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'status' => ['sometimes', 'string', Rule::in(Project::STATUSES)],
|
||||||
|
'due_at' => ['nullable', 'date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Task;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateTaskRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => ['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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class IdeaResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class ProjectResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $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()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TaskCommentResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TaskResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Ideas;
|
||||||
|
|
||||||
|
use App\Models\Idea;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public bool $showForm = false;
|
||||||
|
|
||||||
|
public ?int $editingId = null;
|
||||||
|
|
||||||
|
public string $title = '';
|
||||||
|
|
||||||
|
public ?string $description = null;
|
||||||
|
|
||||||
|
public string $status = 'new';
|
||||||
|
|
||||||
|
public ?int $projectId = null;
|
||||||
|
|
||||||
|
public int $priority = 0;
|
||||||
|
|
||||||
|
public ?string $category = null;
|
||||||
|
|
||||||
|
public ?string $tags = null;
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
public string $statusFilter = '';
|
||||||
|
|
||||||
|
public string $categoryFilter = '';
|
||||||
|
|
||||||
|
public ?int $projectFilter = null;
|
||||||
|
|
||||||
|
public bool $unlinkedFilter = false;
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$query = Idea::with(['project', 'user']);
|
||||||
|
|
||||||
|
if ($this->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public bool $showForm = false;
|
||||||
|
|
||||||
|
public ?int $editingId = null;
|
||||||
|
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
public ?string $description = null;
|
||||||
|
|
||||||
|
public string $status = 'active';
|
||||||
|
|
||||||
|
public ?string $dueAt = null;
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
public string $statusFilter = '';
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$query = Project::query();
|
||||||
|
|
||||||
|
if ($this->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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\TaskComments;
|
||||||
|
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\TaskComment;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public ?int $taskId = null;
|
||||||
|
|
||||||
|
public bool $showForm = false;
|
||||||
|
|
||||||
|
public ?int $editingId = null;
|
||||||
|
|
||||||
|
public string $body = '';
|
||||||
|
|
||||||
|
public string $authorType = 'user';
|
||||||
|
|
||||||
|
public string $authorName = '';
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
// Get task_id from query parameters
|
||||||
|
if (request()->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Tasks;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Task;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public bool $showForm = false;
|
||||||
|
|
||||||
|
public ?int $editingId = null;
|
||||||
|
|
||||||
|
public string $title = '';
|
||||||
|
|
||||||
|
public ?string $description = null;
|
||||||
|
|
||||||
|
public string $status = 'todo';
|
||||||
|
|
||||||
|
public ?int $projectId = null;
|
||||||
|
|
||||||
|
public int $priority = 0;
|
||||||
|
|
||||||
|
public ?string $dueAt = null;
|
||||||
|
|
||||||
|
public ?string $estimatedHours = null;
|
||||||
|
|
||||||
|
public ?string $actualHours = null;
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
public string $statusFilter = '';
|
||||||
|
|
||||||
|
public string $priorityFilter = '';
|
||||||
|
|
||||||
|
public ?int $projectFilter = null;
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
// If project_id is passed as a query parameter, filter by it
|
||||||
|
if (request()->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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Idea extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUSES = ['new', 'considered', 'planned', 'rejected', 'implemented'];
|
||||||
|
|
||||||
|
public const CATEGORIES = ['feature', 'improvement', 'bugfix', 'research', 'other'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'status',
|
||||||
|
'project_id',
|
||||||
|
'priority',
|
||||||
|
'category',
|
||||||
|
'tags',
|
||||||
|
'user_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'priority' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Project extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUSES = ['active', 'completed', 'archived', 'on_hold', 'planned'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'status',
|
||||||
|
'due_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'due_at' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Task extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUSES = ['todo', 'in_progress', 'done', 'blocked', 'waiting_review'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'status',
|
||||||
|
'project_id',
|
||||||
|
'priority',
|
||||||
|
'due_at',
|
||||||
|
'started_at',
|
||||||
|
'completed_at',
|
||||||
|
'estimated_hours',
|
||||||
|
'actual_hours',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'due_at' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class TaskComment extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
public const AUTHOR_TYPES = ['user', 'ai', 'system'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'task_id',
|
||||||
|
'body',
|
||||||
|
'author_type',
|
||||||
|
'author_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function task(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Idea;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class IdeaFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Idea::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class ProjectFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Project::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\TaskComment;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class TaskCommentFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = TaskComment::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'task_id' => 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Task;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class TaskFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Task::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('projects', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tasks', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('task_comments', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('ideas', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
<a href="{{ route('sites.index') }}" class="font-medium hover:text-blue-600">Sites</a>
|
<a href="{{ route('sites.index') }}" class="font-medium hover:text-blue-600">Sites</a>
|
||||||
<a href="{{ route('social-accounts.index') }}" class="font-medium hover:text-blue-600">Social Accounts</a>
|
<a href="{{ route('social-accounts.index') }}" class="font-medium hover:text-blue-600">Social Accounts</a>
|
||||||
<a href="{{ route('posts.index') }}" class="font-medium hover:text-blue-600">Posts</a>
|
<a href="{{ route('posts.index') }}" class="font-medium hover:text-blue-600">Posts</a>
|
||||||
|
<a href="{{ route('projects.index') }}" class="font-medium hover:text-blue-600">Projects</a>
|
||||||
|
<a href="{{ route('tasks.index') }}" class="font-medium hover:text-blue-600">Tasks</a>
|
||||||
|
<a href="{{ route('ideas.index') }}" class="font-medium hover:text-blue-600">Ideas</a>
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
<form method="POST" action="{{ route('logout') }}" class="ml-auto">
|
<form method="POST" action="{{ route('logout') }}" class="ml-auto">
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold">Ideas</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ $totalIdeas }} total |
|
||||||
|
{{ $newIdeas }} new |
|
||||||
|
{{ $consideredIdeas }} considered |
|
||||||
|
{{ $plannedIdeas }} planned |
|
||||||
|
{{ $implementedIdeas }} implemented
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Idea</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded p-4 mb-6">
|
||||||
|
<div class="flex gap-4 items-center flex-wrap">
|
||||||
|
<div class="flex-1 min-w-48">
|
||||||
|
<label class="block text-sm font-medium mb-1">Search</label>
|
||||||
|
<input type="text" wire:model.live="search" placeholder="Search ideas..."
|
||||||
|
class="w-full border rounded p-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select wire:model.live="statusFilter" class="border rounded p-2">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
@foreach ($statuses as $status)
|
||||||
|
<option value="{{ $status }}">{{ ucfirst($status) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Category</label>
|
||||||
|
<select wire:model.live="categoryFilter" class="border rounded p-2">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
@foreach ($categories as $category)
|
||||||
|
<option value="{{ $category }}">{{ ucfirst($category) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Project</label>
|
||||||
|
<select wire:model.live="projectFilter" class="border rounded p-2">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
@foreach ($projects as $project)
|
||||||
|
<option value="{{ $project->id }}">{{ $project->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" wire:model.live="unlinkedFilter" class="rounded">
|
||||||
|
<span class="text-sm">Unlinked only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button wire:click="clearFilters" class="text-sm text-gray-600 hover:text-gray-800">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
@forelse ($ideas as $idea)
|
||||||
|
<div wire:key="idea-{{ $idea->id }}" class="bg-white border border-gray-200 rounded p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold text-lg">{{ $idea->title }}</h3>
|
||||||
|
<div class="flex gap-2 mt-1 flex-wrap">
|
||||||
|
<span class="{{ $this->getStatusColor($idea->status) }} text-xs px-2 py-0.5 rounded">
|
||||||
|
{{ ucfirst($idea->status) }}
|
||||||
|
</span>
|
||||||
|
@if($idea->category)
|
||||||
|
<span class="{{ $this->getCategoryColor($idea->category) }} text-xs px-2 py-0.5 rounded">
|
||||||
|
{{ ucfirst($idea->category) }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@if($idea->priority > 0)
|
||||||
|
<span class="bg-gray-100 text-gray-800 text-xs px-2 py-0.5 rounded">
|
||||||
|
Priority: {{ $this->getPriorityLabel($idea->priority) }} ({{ $idea->priority }})
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button wire:click="edit({{ $idea->id }})" class="text-blue-600 hover:text-blue-800 text-sm">Edit</button>
|
||||||
|
<button wire:click="delete({{ $idea->id }})" wire:confirm="Delete this idea?" class="text-red-600 hover:text-red-800 text-sm">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($idea->description)
|
||||||
|
<p class="text-sm text-gray-600 mb-3">{{ $idea->description }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($idea->tags)
|
||||||
|
<div class="mb-3">
|
||||||
|
@foreach(explode(',', $idea->tags) as $tag)
|
||||||
|
<span class="inline-block bg-gray-100 text-gray-800 text-xs px-2 py-0.5 rounded-full mr-1 mb-1">{{ trim($tag) }}</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center text-xs text-gray-500 border-t border-gray-100 pt-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
@if($idea->project)
|
||||||
|
<span>Project: <a href="{{ route('projects.index') }}?project_id={{ $idea->project->id }}" class="text-blue-600 hover:underline">{{ $idea->project->name }}</a></span>
|
||||||
|
@else
|
||||||
|
<span>No project linked</span>
|
||||||
|
@endif
|
||||||
|
@if($idea->user)
|
||||||
|
<span>By: {{ $idea->user->name }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<span>{{ $idea->created_at->format('M d, Y') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<select wire:model="status" wire:change="changeStatus({{ $idea->id }}, $event.target.value)"
|
||||||
|
class="{{ $this->getStatusColor($idea->status) }} rounded p-1 text-sm">
|
||||||
|
@foreach ($statuses as $status)
|
||||||
|
<option value="{{ $status }}" {{ $idea->status === $status ? 'selected' : '' }}>
|
||||||
|
{{ ucfirst($status) }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
|
||||||
|
@if($idea->project_id)
|
||||||
|
<button wire:click="unlinkFromProject({{ $idea->id }})"
|
||||||
|
wire:confirm="Unlink this idea from its project?"
|
||||||
|
class="text-xs text-gray-600 hover:text-gray-800">
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<button wire:click="linkToProject({{ $idea->id }}, {{ $projectFilter ?? 0 }})"
|
||||||
|
wire:confirm="Link this idea to the selected project?"
|
||||||
|
class="text-xs text-green-600 hover:text-green-800">
|
||||||
|
Link to Project
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="col-span-full text-center py-8 text-gray-500">
|
||||||
|
No ideas found. Share your first idea!
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($showForm)
|
||||||
|
<div class="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white p-6 rounded w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Idea' : 'Add Idea' }}</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Title</label>
|
||||||
|
<input type="text" wire:model="title" class="w-full border rounded p-2">
|
||||||
|
@error('title') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Description</label>
|
||||||
|
<textarea wire:model="description" rows="4" class="w-full border rounded p-2"></textarea>
|
||||||
|
@error('description') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Status</label>
|
||||||
|
<select wire:model="status" class="w-full border rounded p-2">
|
||||||
|
@foreach ($statuses as $status)
|
||||||
|
<option value="{{ $status }}">{{ ucfirst($status) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('status') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Priority (0-10)</label>
|
||||||
|
<input type="number" wire:model="priority" min="0" max="10" class="w-full border rounded p-2">
|
||||||
|
@error('priority') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Category</label>
|
||||||
|
<select wire:model="category" class="w-full border rounded p-2">
|
||||||
|
<option value="">No category</option>
|
||||||
|
@foreach ($categories as $category)
|
||||||
|
<option value="{{ $category }}">{{ ucfirst($category) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('category') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Project (optional)</label>
|
||||||
|
<select wire:model="projectId" class="w-full border rounded p-2">
|
||||||
|
<option value="">No project</option>
|
||||||
|
@foreach ($projects as $project)
|
||||||
|
<option value="{{ $project->id }}">{{ $project->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('projectId') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Tags (comma separated)</label>
|
||||||
|
<input type="text" wire:model="tags" placeholder="e.g., ai, automation, feature" class="w-full border rounded p-2">
|
||||||
|
@error('tags') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
|
||||||
|
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold">Projects</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ $totalProjects }} total |
|
||||||
|
{{ $activeProjects }} active |
|
||||||
|
{{ $completedProjects }} completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Project</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded p-4 mb-6">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm font-medium mb-1">Search</label>
|
||||||
|
<input type="text" wire:model.live="search" placeholder="Search projects..."
|
||||||
|
class="w-full border rounded p-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select wire:model.live="statusFilter" class="border rounded p-2">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
@foreach ($statuses as $status)
|
||||||
|
<option value="{{ $status }}">{{ ucfirst(str_replace('_', ' ', $status)) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button wire:click="clearFilters" class="text-sm text-gray-600 hover:text-gray-800">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
@forelse ($projects as $project)
|
||||||
|
<div wire:key="project-{{ $project->id }}" class="bg-white border border-gray-200 rounded p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold text-lg">{{ $project->name }}</h3>
|
||||||
|
<div class="flex gap-2 mt-1">
|
||||||
|
<span class="{{ $this->getStatusColor($project->status) }} text-xs px-2 py-0.5 rounded">
|
||||||
|
{{ ucfirst(str_replace('_', ' ', $project->status)) }}
|
||||||
|
</span>
|
||||||
|
@if($project->due_at)
|
||||||
|
<span class="text-xs text-gray-600">
|
||||||
|
Due: {{ $project->due_at->format('M d, Y') }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button wire:click="edit({{ $project->id }})" class="text-blue-600 hover:text-blue-800 text-sm">Edit</button>
|
||||||
|
<button wire:click="delete({{ $project->id }})" wire:confirm="Delete this project?" class="text-red-600 hover:text-red-800 text-sm">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($project->description)
|
||||||
|
<p class="text-sm text-gray-600 mb-3">{{ $project->description }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center text-xs text-gray-500 border-t border-gray-100 pt-2">
|
||||||
|
<span>{{ $this->getTasksCount($project->id) }} tasks</span>
|
||||||
|
<span>{{ $project->created_at->format('M d, Y') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href="{{ route('tasks.index', ['project_id' => $project->id]) }}"
|
||||||
|
class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
||||||
|
View Tasks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="col-span-full text-center py-8 text-gray-500">
|
||||||
|
No projects found. Create your first project!
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($showForm)
|
||||||
|
<div class="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white p-6 rounded w-full max-w-md space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Project' : 'Add Project' }}</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Name</label>
|
||||||
|
<input type="text" wire:model="name" class="w-full border rounded p-2">
|
||||||
|
@error('name') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Description</label>
|
||||||
|
<textarea wire:model="description" rows="3" class="w-full border rounded p-2"></textarea>
|
||||||
|
@error('description') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Status</label>
|
||||||
|
<select wire:model="status" class="w-full border rounded p-2">
|
||||||
|
@foreach ($statuses as $status)
|
||||||
|
<option value="{{ $status }}">{{ ucfirst(str_replace('_', ' ', $status)) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('status') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Due Date</label>
|
||||||
|
<input type="datetime-local" wire:model="dueAt" class="w-full border rounded p-2">
|
||||||
|
@error('dueAt') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
|
||||||
|
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold">Task Comments</h1>
|
||||||
|
@if($task)
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Task: <span class="font-medium">{{ $task->title }}</span>
|
||||||
|
@if($task->project)
|
||||||
|
| Project: <span class="text-blue-600">{{ $task->project->name }}</span>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
@if($task)
|
||||||
|
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Comment</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($task)
|
||||||
|
<div class="bg-white border border-gray-200 rounded p-4 mb-6">
|
||||||
|
<h3 class="font-semibold mb-2">Task Details</h3>
|
||||||
|
<p class="text-gray-600 mb-2">{{ $task->description }}</p>
|
||||||
|
<div class="flex gap-4 text-sm text-gray-500">
|
||||||
|
<span>Status: {{ ucfirst(str_replace('_', ' ', $task->status)) }}</span>
|
||||||
|
@if($task->priority > 0)
|
||||||
|
<span>Priority: {{ $task->priority }}</span>
|
||||||
|
@endif
|
||||||
|
@if($task->due_at)
|
||||||
|
<span>Due: {{ $task->due_at->format('M d, Y') }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded p-4 mb-6">
|
||||||
|
<p class="text-yellow-800">No task selected. Please select a task to view its comments.</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
@forelse ($comments as $comment)
|
||||||
|
<div wire:key="comment-{{ $comment->id }}" class="bg-white border border-gray-200 rounded p-4">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="{{ $this->getAuthorColor($comment->author_type) }} text-xs px-2 py-0.5 rounded font-medium">
|
||||||
|
{{ ucfirst($comment->author_type) }}
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-gray-800">{{ $comment->author_name }}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ $comment->created_at->format('M d, Y H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="prose max-w-none text-gray-700">
|
||||||
|
{{ $comment->body }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button wire:click="edit({{ $comment->id }})" class="text-blue-600 hover:text-blue-800 text-sm">Edit</button>
|
||||||
|
<button wire:click="delete({{ $comment->id }})" wire:confirm="Delete this comment?" class="text-red-600 hover:text-red-800 text-sm">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
@if($task)
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded p-8 text-center text-gray-500">
|
||||||
|
No comments yet. Add your first comment!
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($showForm)
|
||||||
|
<div class="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white p-6 rounded w-full max-w-lg space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Comment' : 'Add Comment' }}</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Author Type</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button wire:click="setAuthorAsUser"
|
||||||
|
class="px-3 py-1 rounded {{ $authorType === 'user' ? 'bg-blue-600 text-white' : 'bg-gray-200' }}">
|
||||||
|
User
|
||||||
|
</button>
|
||||||
|
<button wire:click="setAuthorAsAI"
|
||||||
|
class="px-3 py-1 rounded {{ $authorType === 'ai' ? 'bg-purple-600 text-white' : 'bg-gray-200' }}">
|
||||||
|
AI
|
||||||
|
</button>
|
||||||
|
<button wire:click="setAuthorAsSystem"
|
||||||
|
class="px-3 py-1 rounded {{ $authorType === 'system' ? 'bg-gray-600 text-white' : 'bg-gray-200' }}">
|
||||||
|
System
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" wire:model="authorType">
|
||||||
|
@error('authorType') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Author Name</label>
|
||||||
|
<input type="text" wire:model="authorName" class="w-full border rounded p-2">
|
||||||
|
@error('authorName') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Comment</label>
|
||||||
|
<textarea wire:model="body" rows="6" class="w-full border rounded p-2"></textarea>
|
||||||
|
@error('body') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
|
||||||
|
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold">Tasks</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ $totalTasks }} total |
|
||||||
|
{{ $todoTasks }} todo |
|
||||||
|
{{ $inProgressTasks }} in progress |
|
||||||
|
{{ $doneTasks }} done |
|
||||||
|
{{ $blockedTasks }} blocked |
|
||||||
|
{{ $waitingReviewTasks }} waiting review
|
||||||
|
</p>
|
||||||
|
@if($dueSoonTasks > 0 || $overdueTasks > 0)
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
@if($dueSoonTasks > 0)
|
||||||
|
<span class="text-orange-600">{{ $dueSoonTasks }} due soon</span>
|
||||||
|
@endif
|
||||||
|
@if($overdueTasks > 0)
|
||||||
|
<span class="text-red-600">{{ $overdueTasks }} overdue</span>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Task</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded p-4 mb-6">
|
||||||
|
<div class="flex gap-4 items-center flex-wrap">
|
||||||
|
<div class="flex-1 min-w-48">
|
||||||
|
<label class="block text-sm font-medium mb-1">Search</label>
|
||||||
|
<input type="text" wire:model.live="search" placeholder="Search tasks..."
|
||||||
|
class="w-full border rounded p-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Project</label>
|
||||||
|
<select wire:model.live="projectFilter" class="border rounded p-2">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
@foreach ($projects as $project)
|
||||||
|
<option value="{{ $project->id }}">{{ $project->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select wire:model.live="statusFilter" class="border rounded p-2">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
@foreach ($statuses as $status)
|
||||||
|
<option value="{{ $status }}">{{ ucfirst(str_replace('_', ' ', $status)) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Priority</label>
|
||||||
|
<select wire:model.live="priorityFilter" class="border rounded p-2">
|
||||||
|
<option value="">All Priorities</option>
|
||||||
|
<option value="0">None (0)</option>
|
||||||
|
<option value="1">Low (1-3)</option>
|
||||||
|
<option value="4">Medium (4-6)</option>
|
||||||
|
<option value="7">High (7-8)</option>
|
||||||
|
<option value="9">Critical (9-10)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button wire:click="clearFilters" class="text-sm text-gray-600 hover:text-gray-800">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr class="text-left">
|
||||||
|
<th class="p-3">Task</th>
|
||||||
|
<th class="p-3">Project</th>
|
||||||
|
<th class="p-3">Status</th>
|
||||||
|
<th class="p-3">Priority</th>
|
||||||
|
<th class="p-3">Due</th>
|
||||||
|
<th class="p-3">Time</th>
|
||||||
|
<th class="p-3">Comments</th>
|
||||||
|
<th class="p-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($tasks as $task)
|
||||||
|
<tr wire:key="task-{{ $task->id }}" class="border-b border-gray-100 hover:bg-gray-50">
|
||||||
|
<td class="p-3">
|
||||||
|
<div class="font-medium">{{ $task->title }}</div>
|
||||||
|
@if($task->description)
|
||||||
|
<div class="text-xs text-gray-500 truncate max-w-48">{{ $task->description }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
@if($task->project)
|
||||||
|
<span class="text-blue-600 hover:underline cursor-pointer"
|
||||||
|
wire:click="$set('projectFilter', {{ $task->project->id }})">
|
||||||
|
{{ $task->project->name }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-400">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<select wire:model="status" wire:change="changeStatus({{ $task->id }}, $event.target.value)"
|
||||||
|
class="{{ $this->getStatusColor($task->status) }} rounded p-1 text-sm">
|
||||||
|
@foreach ($statuses as $status)
|
||||||
|
<option value="{{ $status }}" {{ $task->status === $status ? 'selected' : '' }}>
|
||||||
|
{{ ucfirst(str_replace('_', ' ', $status)) }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<span class="text-sm">
|
||||||
|
{{ $this->getPriorityLabel($task->priority) }}
|
||||||
|
@if($task->priority > 0)
|
||||||
|
({{ $task->priority }})
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
@if($task->due_at)
|
||||||
|
<span class="@if($task->due_at < now() && $task->status !== 'done') text-red-600 @endif">
|
||||||
|
{{ $task->due_at->format('M d') }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-400">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-sm">
|
||||||
|
@if($task->estimated_hours)
|
||||||
|
<span title="Estimated">{{ $task->estimated_hours }}h</span>
|
||||||
|
@endif
|
||||||
|
@if($task->actual_hours)
|
||||||
|
<span title="Actual" class="ml-1">{{ $task->actual_hours }}h</span>
|
||||||
|
@endif
|
||||||
|
@if(!$task->estimated_hours && !$task->actual_hours)
|
||||||
|
<span class="text-gray-400">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-center">
|
||||||
|
<span class="bg-gray-100 text-gray-800 text-xs px-2 py-0.5 rounded">
|
||||||
|
{{ $this->getCommentsCount($task->id) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-right space-x-1">
|
||||||
|
<button wire:click="edit({{ $task->id }})" class="text-blue-600 hover:text-blue-800 text-sm">Edit</button>
|
||||||
|
<button wire:click="delete({{ $task->id }})" wire:confirm="Delete this task?" class="text-red-600 hover:text-red-800 text-sm">Delete</button>
|
||||||
|
<a href="{{ route('task-comments.index', ['task_id' => $task->id]) }}"
|
||||||
|
class="text-purple-600 hover:text-purple-800 text-sm">Comments</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="p-6 text-center text-gray-500">
|
||||||
|
No tasks found. Create your first task!
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($showForm)
|
||||||
|
<div class="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white p-6 rounded w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Task' : 'Add Task' }}</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Title</label>
|
||||||
|
<input type="text" wire:model="title" class="w-full border rounded p-2">
|
||||||
|
@error('title') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Description</label>
|
||||||
|
<textarea wire:model="description" rows="4" class="w-full border rounded p-2"></textarea>
|
||||||
|
@error('description') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Project</label>
|
||||||
|
<select wire:model="projectId" class="w-full border rounded p-2">
|
||||||
|
<option value="">No Project</option>
|
||||||
|
@foreach ($projects as $project)
|
||||||
|
<option value="{{ $project->id }}">{{ $project->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('projectId') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Status</label>
|
||||||
|
<select wire:model="status" class="w-full border rounded p-2">
|
||||||
|
@foreach ($statuses as $status)
|
||||||
|
<option value="{{ $status }}">{{ ucfirst(str_replace('_', ' ', $status)) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('status') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Priority (0-10)</label>
|
||||||
|
<input type="number" wire:model="priority" min="0" max="10" class="w-full border rounded p-2">
|
||||||
|
@error('priority') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Due Date</label>
|
||||||
|
<input type="datetime-local" wire:model="dueAt" class="w-full border rounded p-2">
|
||||||
|
@error('dueAt') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Estimated Hours</label>
|
||||||
|
<input type="number" wire:model="estimatedHours" step="0.1" min="0" class="w-full border rounded p-2">
|
||||||
|
@error('estimatedHours') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Actual Hours</label>
|
||||||
|
<input type="number" wire:model="actualHours" step="0.1" min="0" class="w-full border rounded p-2">
|
||||||
|
@error('actualHours') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
|
||||||
|
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Api\PostController;
|
use App\Http\Controllers\Api\PostController;
|
||||||
|
use App\Http\Controllers\Api\ProjectController;
|
||||||
use App\Http\Controllers\Api\SiteController;
|
use App\Http\Controllers\Api\SiteController;
|
||||||
use App\Http\Controllers\Api\SocialAccountController;
|
use App\Http\Controllers\Api\SocialAccountController;
|
||||||
|
use App\Http\Controllers\Api\TaskCommentController;
|
||||||
|
use App\Http\Controllers\Api\TaskController;
|
||||||
use App\Http\Controllers\Api\WebhookController;
|
use App\Http\Controllers\Api\WebhookController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -14,6 +17,23 @@ Route::get('/accounts/{socialAccount}', [SocialAccountController::class, 'show']
|
|||||||
|
|
||||||
Route::apiResource('posts', PostController::class)->names('api.posts');
|
Route::apiResource('posts', PostController::class)->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)
|
// Webhook endpoints (require X-Muninn-Token header)
|
||||||
Route::prefix('webhooks')->group(function () {
|
Route::prefix('webhooks')->group(function () {
|
||||||
Route::post('/posts', [WebhookController::class, 'createPost']);
|
Route::post('/posts', [WebhookController::class, 'createPost']);
|
||||||
|
|||||||
@@ -27,4 +27,8 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index');
|
Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index');
|
||||||
Route::get('/posts', PostsIndex::class)->name('posts.index');
|
Route::get('/posts', PostsIndex::class)->name('posts.index');
|
||||||
Route::get('/templates', TemplatesIndex::class)->name('templates.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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Idea;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\TaskComment;
|
||||||
|
|
||||||
|
// ==================== PROJECTS API TESTS ====================
|
||||||
|
|
||||||
|
// Projects - Index
|
||||||
|
test('it lists all projects', function () {
|
||||||
|
Project::factory()->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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user