diff --git a/app/Http/Controllers/Api/V1/MediaController.php b/app/Http/Controllers/Api/V1/MediaController.php index 6c27fe1..77ecca5 100644 --- a/app/Http/Controllers/Api/V1/MediaController.php +++ b/app/Http/Controllers/Api/V1/MediaController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Models\Feature; use App\Models\Issue; +use App\Models\IssueComment; +use App\Models\IssueTask; use App\Models\Layer; use App\Models\Media; use App\Models\Phase; @@ -17,11 +19,13 @@ use Illuminate\Support\Str; class MediaController extends Controller { private array $map = [ - 'feature' => Feature::class, - 'issue' => Issue::class, - 'project' => Project::class, - 'phase' => Phase::class, - 'layer' => Layer::class, + 'feature' => Feature::class, + 'issue' => Issue::class, + 'issue_task' => IssueTask::class, + 'issue_comment' => IssueComment::class, + 'project' => Project::class, + 'phase' => Phase::class, + 'layer' => Layer::class, ]; /** Upload a file (multipart) and attach it to a parent record. Idempotent by uuid. */ @@ -73,12 +77,14 @@ class MediaController extends Controller private function projectOf(string $entity, $parent): ?Project { return match ($entity) { - 'project' => $parent, - 'phase' => $parent->project, - 'layer' => $parent->phase?->project, - 'feature' => $parent->layer?->phase?->project, - 'issue' => $parent->project, - default => null, + 'project' => $parent, + 'phase' => $parent->project, + 'layer' => $parent->phase?->project, + 'feature' => $parent->layer?->phase?->project, + 'issue' => $parent->project, + 'issue_task' => $parent->issue?->project, + 'issue_comment' => $parent->issue?->project, + default => null, }; } diff --git a/app/Http/Controllers/Api/V1/ProjectApiController.php b/app/Http/Controllers/Api/V1/ProjectApiController.php index fa80213..1559e98 100644 --- a/app/Http/Controllers/Api/V1/ProjectApiController.php +++ b/app/Http/Controllers/Api/V1/ProjectApiController.php @@ -7,6 +7,8 @@ use App\Models\Feature; use App\Models\Inspection; use App\Models\InspectionTemplate; use App\Models\Issue; +use App\Models\IssueComment; +use App\Models\IssueTask; use App\Models\Layer; use App\Models\Media; use App\Models\Phase; @@ -49,25 +51,35 @@ class ProjectApiController extends Controller $issues = $changed(Issue::where('project_id', $project->id))->get(); $templates = $changed(InspectionTemplate::where('project_id', $project->id))->get(); + $allIssueIds = Issue::withTrashed()->where('project_id', $project->id)->pluck('id'); + $issueTasks = $changed(IssueTask::whereIn('issue_id', $allIssueIds))->get(); + $issueComments = $changed(IssueComment::whereIn('issue_id', $allIssueIds))->get(); + $featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id'); $issueIds = Issue::where('project_id', $project->id)->pluck('id'); - $media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds) { + $taskIds = IssueTask::whereIn('issue_id', $allIssueIds)->pluck('id'); + $commentIds = IssueComment::whereIn('issue_id', $allIssueIds)->pluck('id'); + $media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds, $taskIds, $commentIds) { $q->where(fn ($w) => $w->where('mediable_type', Project::class)->where('mediable_id', $project->id)) ->orWhere(fn ($w) => $w->where('mediable_type', Feature::class)->whereIn('mediable_id', $featureIds)) - ->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds)); + ->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds)) + ->orWhere(fn ($w) => $w->where('mediable_type', IssueTask::class)->whereIn('mediable_id', $taskIds)) + ->orWhere(fn ($w) => $w->where('mediable_type', IssueComment::class)->whereIn('mediable_id', $commentIds)); }))->get(); return response()->json([ - 'server_time' => now()->toIso8601String(), - 'project' => $this->mapProject($project), - 'phases' => $phases->map(fn ($p) => $this->mapPhase($p))->values(), - 'layers' => $layers->map(fn ($l) => $this->mapLayer($l))->values(), - 'features' => $features->map(fn ($f) => $this->mapFeature($f))->values(), - 'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(), - 'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(), - 'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(), - 'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(), - 'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds) : (object) [], + 'server_time' => now()->toIso8601String(), + 'project' => $this->mapProject($project), + 'phases' => $phases->map(fn ($p) => $this->mapPhase($p))->values(), + 'layers' => $layers->map(fn ($l) => $this->mapLayer($l))->values(), + 'features' => $features->map(fn ($f) => $this->mapFeature($f))->values(), + 'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(), + 'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(), + 'issue_tasks' => $issueTasks->map(fn ($t) => $this->mapIssueTask($t))->values(), + 'issue_comments' => $issueComments->map(fn ($c) => $this->mapIssueComment($c))->values(), + 'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(), + 'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(), + 'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds, $allIssueIds) : (object) [], ]); } @@ -100,14 +112,16 @@ class ProjectApiController extends Controller ); } - private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds): array + private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds, $allIssueIds): array { return [ - 'phases' => Phase::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), - 'layers' => Layer::onlyTrashed()->whereIn('phase_id', $allPhaseIds)->where('deleted_at', '>', $since)->pluck('id')->values(), - 'features' => Feature::onlyTrashed()->whereIn('layer_id', $allLayerIds)->where('deleted_at', '>', $since)->pluck('id')->values(), - 'inspections' => Inspection::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), - 'issues' => Issue::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'phases' => Phase::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'layers' => Layer::onlyTrashed()->whereIn('phase_id', $allPhaseIds)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'features' => Feature::onlyTrashed()->whereIn('layer_id', $allLayerIds)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'inspections' => Inspection::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'issues' => Issue::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'issue_tasks' => IssueTask::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'issue_comments' => IssueComment::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(), ]; } @@ -166,6 +180,24 @@ class ProjectApiController extends Controller ]; } + private function mapIssueTask(IssueTask $t): array + { + return [ + 'id' => $t->id, 'issue_id' => $t->issue_id, 'title' => $t->title, + 'is_done' => $t->is_done, 'done_at' => $t->done_at?->toIso8601String(), 'done_by' => $t->done_by, + 'assigned_to' => $t->assigned_to, 'due_date' => $t->due_date?->toDateString(), + 'order' => $t->order, 'updated_at' => $t->updated_at?->toIso8601String(), + ]; + } + + private function mapIssueComment(IssueComment $c): array + { + return [ + 'id' => $c->id, 'issue_id' => $c->issue_id, 'user_id' => $c->user_id, 'body' => $c->body, + 'created_at' => $c->created_at?->toIso8601String(), 'updated_at' => $c->updated_at?->toIso8601String(), + ]; + } + private function mapTemplate(InspectionTemplate $t): array { return [ @@ -180,9 +212,11 @@ class ProjectApiController extends Controller private function mapMedia(Media $m): array { $entity = [ - Project::class => 'project', - Feature::class => 'feature', - Issue::class => 'issue', + Project::class => 'project', + Feature::class => 'feature', + Issue::class => 'issue', + IssueTask::class => 'issue_task', + IssueComment::class => 'issue_comment', ][$m->mediable_type] ?? class_basename($m->mediable_type); return [ diff --git a/app/Http/Controllers/Api/V1/SyncController.php b/app/Http/Controllers/Api/V1/SyncController.php index 716a1ff..91151ed 100644 --- a/app/Http/Controllers/Api/V1/SyncController.php +++ b/app/Http/Controllers/Api/V1/SyncController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Controller; use App\Models\Feature; use App\Models\Inspection; use App\Models\Issue; +use App\Models\IssueComment; +use App\Models\IssueTask; use App\Models\Phase; use App\Models\ProgressUpdate; use App\Models\Project; @@ -63,6 +65,9 @@ class SyncController extends Controller 'inspection.create' => $this->inspectionCreate($user, $uuid, $op), 'issue.create' => $this->issueCreate($user, $uuid, $op), 'issue.update' => $this->issueUpdate($user, $uuid, $op), + 'issue_task.create' => $this->issueTaskCreate($user, $uuid, $op), + 'issue_task.update' => $this->issueTaskUpdate($user, $uuid, $op), + 'issue_comment.create' => $this->issueCommentCreate($user, $uuid, $op), 'feature.update' => $this->featureUpdate($user, $uuid, $op), default => $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']), }; @@ -245,6 +250,116 @@ class SyncController extends Controller return $this->applied($uuid, $issue->id); } + // ── issue_task.create / issue_task.update ────────────────────────────────────── + + private function issueTaskCreate(User $user, string $uuid, array $op): array + { + if ($existing = IssueTask::where('uuid', $uuid)->first()) { + return $this->duplicate($uuid, $existing->id); + } + + $v = Validator::make($op['data'], [ + 'issue_id' => ['required', 'integer', 'exists:issues,id'], + 'title' => ['required', 'string', 'max:255'], + 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], + 'due_date' => ['nullable', 'date'], + 'is_done' => ['nullable', 'boolean'], + ]); + if ($v->fails()) { + return $this->error($uuid, 'validation: ' . $v->errors()->first()); + } + $d = $v->validated(); + + $issue = Issue::with('project')->findOrFail($d['issue_id']); + if (! $this->canAccess($user, $issue->project) || ! $user->can('edit issues')) { + return $this->error($uuid, 'forbidden'); + } + + $done = $d['is_done'] ?? false; + $task = IssueTask::create([ + 'uuid' => $uuid, + 'issue_id' => $issue->id, + 'title' => $d['title'], + 'assigned_to' => $d['assigned_to'] ?? null, + 'due_date' => $d['due_date'] ?? null, + 'is_done' => $done, + 'done_at' => $done ? now() : null, + 'done_by' => $done ? $user->id : null, + 'order' => ((int) $issue->tasks()->max('order')) + 1, + 'client_updated_at' => $op['client_updated_at'] ?? null, + ]); + + return $this->applied($uuid, $task->id); + } + + private function issueTaskUpdate(User $user, string $uuid, array $op): array + { + $v = Validator::make($op['data'], [ + 'id' => ['required', 'integer', 'exists:issue_tasks,id'], + 'title' => ['nullable', 'string', 'max:255'], + 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], + 'due_date' => ['nullable', 'date'], + 'is_done' => ['nullable', 'boolean'], + ]); + if ($v->fails()) { + return $this->error($uuid, 'validation: ' . $v->errors()->first()); + } + $d = $v->validated(); + + $task = IssueTask::with('issue.project')->findOrFail($d['id']); + if (! $this->canAccess($user, $task->issue?->project) || ! $user->can('edit issues')) { + return $this->error($uuid, 'forbidden'); + } + + if ($conflict = $this->conflict($uuid, $task, $op)) { + return $conflict; + } + + if (array_key_exists('is_done', $d)) { + $task->is_done = (bool) $d['is_done']; + $task->done_at = $d['is_done'] ? ($task->done_at ?? now()) : null; + $task->done_by = $d['is_done'] ? ($task->done_by ?? $user->id) : null; + } + $task->fill(collect($d)->only('title', 'assigned_to', 'due_date')->toArray()); + $task->client_updated_at = $op['client_updated_at'] ?? null; + $task->save(); + + return $this->applied($uuid, $task->id); + } + + // ── issue_comment.create ──────────────────────────────────────────────────────── + + private function issueCommentCreate(User $user, string $uuid, array $op): array + { + if ($existing = IssueComment::where('uuid', $uuid)->first()) { + return $this->duplicate($uuid, $existing->id); + } + + $v = Validator::make($op['data'], [ + 'issue_id' => ['required', 'integer', 'exists:issues,id'], + 'body' => ['required', 'string', 'max:5000'], + ]); + if ($v->fails()) { + return $this->error($uuid, 'validation: ' . $v->errors()->first()); + } + $d = $v->validated(); + + $issue = Issue::with('project')->findOrFail($d['issue_id']); + if (! $this->canAccess($user, $issue->project) || ! $user->can('view issues')) { + return $this->error($uuid, 'forbidden'); + } + + $comment = IssueComment::create([ + 'uuid' => $uuid, + 'issue_id' => $issue->id, + 'user_id' => $user->id, + 'body' => $d['body'], + 'client_updated_at' => $op['client_updated_at'] ?? null, + ]); + + return $this->applied($uuid, $comment->id); + } + // ── feature.update ──────────────────────────────────────────────────────────── private function featureUpdate(User $user, string $uuid, array $op): array diff --git a/app/Livewire/IssueDetail.php b/app/Livewire/IssueDetail.php new file mode 100644 index 0000000..08b38ca --- /dev/null +++ b/app/Livewire/IssueDetail.php @@ -0,0 +1,240 @@ +project_id === $project->id, 404); + $this->project = $project; + $this->issue = $issue; + abort_unless($this->canAccessProject() && Auth::user()->can('view issues'), 403); + + $this->resolutionNotes = $issue->resolution_notes ?? ''; + $this->projectUsers = $project->users()->orderBy('name')->get(); + $this->refreshIssue(); + } + + private function canAccessProject(): bool + { + $user = Auth::user(); + return $user->can('manage all') + || $this->project->users()->where('user_id', $user->id)->exists(); + } + + private function canEdit(): bool + { + return Auth::user()->can('edit issues'); + } + + public function refreshIssue(): void + { + $this->issue->load([ + 'reporter', 'assignee', 'feature', + 'tasks.assignee', 'tasks.completer', + 'comments.user', 'comments.media', + 'media.uploader', + ]); + } + + // ── Checklist ──────────────────────────────────────────────────────────────── + + public function addTask(): void + { + abort_unless($this->canEdit(), 403); + $this->validate([ + 'newTaskTitle' => 'required|string|max:255', + 'newTaskAssignee' => 'nullable|exists:users,id', + 'newTaskDue' => 'nullable|date', + ]); + + $this->issue->tasks()->create([ + 'title' => $this->newTaskTitle, + 'assigned_to' => $this->newTaskAssignee ?: null, + 'due_date' => $this->newTaskDue ?: null, + 'order' => ((int) $this->issue->tasks()->max('order')) + 1, + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + ]); + + $this->reset(['newTaskTitle', 'newTaskAssignee', 'newTaskDue']); + $this->refreshIssue(); + $this->dispatch('notify', 'Tarea añadida'); + } + + public function toggleTask($taskId): void + { + abort_unless($this->canEdit(), 403); + $task = $this->issue->tasks()->findOrFail($taskId); + $done = ! $task->is_done; + $task->update([ + 'is_done' => $done, + 'done_at' => $done ? now() : null, + 'done_by' => $done ? Auth::id() : null, + ]); + $this->refreshIssue(); + } + + public function deleteTask($taskId): void + { + abort_unless($this->canEdit(), 403); + $this->issue->tasks()->findOrFail($taskId)->delete(); + $this->refreshIssue(); + $this->dispatch('notify', 'Tarea eliminada'); + } + + // ── Comments + photos ────────────────────────────────────────────────────────── + + public function addComment(): void + { + // Anyone who can view the issue (and is a member) may comment / report progress. + $this->validate([ + 'newComment' => 'nullable|string|max:5000', + 'commentPhoto' => 'nullable|image|max:20480', // 20 MB + ]); + + if (trim($this->newComment) === '' && ! $this->commentPhoto) { + throw ValidationException::withMessages([ + 'newComment' => 'Escribe un comentario o adjunta una foto.', + ]); + } + + $comment = $this->issue->comments()->create([ + 'user_id' => Auth::id(), + 'body' => trim($this->newComment) ?: '(foto)', + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + ]); + + if ($this->commentPhoto) { + abort_unless(Auth::user()->can('upload media'), 403); + $this->storeUpload($this->commentPhoto, $comment, 'comment'); + } + + $this->reset(['newComment', 'commentPhoto']); + $this->refreshIssue(); + $this->dispatch('notify', 'Comentario añadido'); + } + + public function uploadIssuePhotos(): void + { + abort_unless(Auth::user()->can('upload media'), 403); + $this->validate(['issuePhotos.*' => 'required|image|max:20480']); + + foreach ($this->issuePhotos as $photo) { + $this->storeUpload($photo, $this->issue, 'issue'); + } + + $this->reset('issuePhotos'); + $this->refreshIssue(); + $this->dispatch('notify', 'Fotos subidas'); + } + + public function deleteMedia($mediaId): void + { + $media = \App\Models\Media::findOrFail($mediaId); + $user = Auth::user(); + abort_unless($user->can('delete media') || $media->uploaded_by === $user->id, 403); + $media->delete(); + $this->refreshIssue(); + $this->dispatch('notify', 'Foto eliminada'); + } + + /** Store an uploaded file on the public disk and attach it to a parent model. */ + private function storeUpload($file, $parent, string $entity): void + { + $mime = $file->getMimeType(); + $path = $file->store("uploads/issues/{$this->issue->id}/{$entity}", 'public'); + + $parent->media()->create([ + 'name' => $file->getClientOriginalName(), + 'file_path' => $path, + 'file_type' => $mime, + 'file_extension' => $file->getClientOriginalExtension(), + 'file_size' => $file->getSize(), + 'category' => str_starts_with($mime, 'image/') ? 'image' : 'document', + 'uploaded_by' => Auth::id(), + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + ]); + } + + // ── Status workflow (verification) ─────────────────────────────────────────────── + + public function sendToReview(): void + { + abort_unless($this->canEdit(), 403); + $this->issue->update(['status' => 'in_review']); + $this->refreshIssue(); + $this->dispatch('notify', 'Incidencia enviada a revisión'); + } + + public function verifyResolve(): void + { + abort_unless($this->canEdit(), 403); + $this->issue->update([ + 'status' => 'resolved', + 'resolved_at' => $this->issue->resolved_at ?? now(), + 'resolution_notes' => $this->resolutionNotes ?: null, + ]); + $this->refreshIssue(); + $this->dispatch('notify', 'Incidencia validada y resuelta'); + } + + public function closeIssue(): void + { + abort_unless($this->canEdit(), 403); + $this->issue->update([ + 'status' => 'closed', + 'resolved_at' => $this->issue->resolved_at ?? now(), + ]); + $this->refreshIssue(); + $this->dispatch('notify', 'Incidencia cerrada'); + } + + public function reopen(): void + { + abort_unless($this->canEdit(), 403); + $this->issue->update(['status' => 'open', 'resolved_at' => null]); + $this->refreshIssue(); + $this->dispatch('notify', 'Incidencia reabierta'); + } + + public function render() + { + return view('livewire.issues.issue-detail', [ + 'canEdit' => $this->canEdit(), + ]); + } +} diff --git a/app/Livewire/IssueForm.php b/app/Livewire/IssueForm.php new file mode 100644 index 0000000..e335423 --- /dev/null +++ b/app/Livewire/IssueForm.php @@ -0,0 +1,126 @@ +project = $project; + + if ($issue) { + abort_unless($issue->project_id === $project->id, 404); + } + abort_unless($this->canAccessProject() && Auth::user()->can($issue ? 'edit issues' : 'create issues'), 403); + + $this->projectUsers = $project->users()->orderBy('name')->get(); + + if ($issue) { + $this->issue = $issue; + $this->title = $issue->title; + $this->description = $issue->description ?? ''; + $this->status = $issue->status; + $this->priority = $issue->priority; + $this->assignedTo = $issue->assigned_to ?? ''; + $this->resolutionNotes = $issue->resolution_notes ?? ''; + $this->featureId = $issue->feature_id; + $this->inspectionId = $issue->inspection_id; + } + } + + private function canAccessProject(): bool + { + $user = Auth::user(); + return $user->can('manage all') + || $this->project->users()->where('user_id', $user->id)->exists(); + } + + protected function rules(): array + { + return [ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'status' => 'required|in:' . implode(',', Issue::STATUSES), + 'priority' => 'required|in:' . implode(',', Issue::PRIORITIES), + 'assignedTo' => 'nullable|exists:users,id', + 'resolutionNotes' => 'nullable|string', + ]; + } + + public function save() + { + abort_unless(Auth::user()->can($this->issue ? 'edit issues' : 'create issues'), 403); + $this->validate(); + + $payload = [ + 'title' => $this->title, + 'description' => $this->description, + 'status' => $this->status, + 'priority' => $this->priority, + 'feature_id' => $this->featureId, + 'inspection_id' => $this->inspectionId, + 'assigned_to' => $this->assignedTo ?: null, + 'resolution_notes' => $this->resolutionNotes ?: null, + ]; + + // Keep resolved_at in sync with the status + $payload['resolved_at'] = in_array($this->status, ['resolved', 'closed']) ? now() : null; + + if ($this->issue) { + // Don't overwrite an existing resolved date + if ($this->issue->resolved_at && in_array($this->status, ['resolved', 'closed'])) { + unset($payload['resolved_at']); + } + $this->issue->update($payload); + $issue = $this->issue; + } else { + $issue = Issue::create(array_merge($payload, [ + 'project_id' => $this->project->id, + 'reported_by' => Auth::id(), + ])); + + $issue->load(['feature', 'assignee']); + $creator = $this->project->creator; + if ($creator && $creator->id !== Auth::id()) { + $creator->notify(new IssueReportedNotification($issue)); + } + if ($issue->assignee && $issue->assignee->id !== Auth::id()) { + $issue->assignee->notify(new IssueReportedNotification($issue)); + } + } + + session()->flash('message', $this->issue ? 'Incidencia actualizada' : 'Incidencia creada'); + + return $this->redirectRoute('projects.issues.show', ['project' => $this->project, 'issue' => $issue], navigate: true); + } + + public function render() + { + return view('livewire.issues.issue-form'); + } +} diff --git a/app/Livewire/IssueManager.php b/app/Livewire/IssueManager.php index 5359224..730d133 100644 --- a/app/Livewire/IssueManager.php +++ b/app/Livewire/IssueManager.php @@ -4,188 +4,51 @@ namespace App\Livewire; use Livewire\Component; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Illuminate\Support\Facades\Auth; use App\Models\Project; use App\Models\Issue; -use App\Notifications\IssueReportedNotification; #[Layout('layouts.app')] class IssueManager extends Component { public Project $project; - public $issues = []; - public $projectUsers = []; - - // Form / modal state - public $showForm = false; - public $editingIssue = null; // issue id when editing, null when creating - - // Form fields - public $title = ''; - public $description = ''; - public $status = 'open'; - public $priority = 'medium'; - public $assignedTo = ''; - public $resolutionNotes = ''; - - // Optional context (e.g. when reporting from a map feature) - public $featureId = null; - public $inspectionId = null; - public function mount(Project $project) { $this->project = $project; - $this->loadProjectUsers(); - $this->loadIssues(); + abort_unless($this->canAccessProject() && Auth::user()->can('view issues'), 403); } - public function loadIssues() + /** The current user must be a project member (or super-admin) to touch issues. */ + private function canAccessProject(): bool { - $this->issues = Issue::where('project_id', $this->project->id) - ->with(['feature', 'reporter', 'assignee']) - ->orderBy('created_at', 'desc') - ->get(); + $user = Auth::user(); + return $user->can('manage all') + || $this->project->users()->where('user_id', $user->id)->exists(); } - public function loadProjectUsers() + /** Re-render the stats bar after the embedded table changes an issue. */ + #[On('issuesChanged')] + public function refreshStats(): void { - $this->projectUsers = $this->project->users()->orderBy('name')->get(); - } - - protected function rules(): array - { - return [ - 'title' => 'required|string|max:255', - 'description' => 'nullable|string', - 'status' => 'required|in:' . implode(',', Issue::STATUSES), - 'priority' => 'required|in:' . implode(',', Issue::PRIORITIES), - 'assignedTo' => 'nullable|exists:users,id', - 'resolutionNotes' => 'nullable|string', - ]; - } - - public function openForm($issueId = null) - { - $this->resetForm(); - - if ($issueId) { - $issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId); - $this->editingIssue = $issue->id; - $this->title = $issue->title; - $this->description = $issue->description ?? ''; - $this->status = $issue->status; - $this->priority = $issue->priority; - $this->assignedTo = $issue->assigned_to ?? ''; - $this->resolutionNotes = $issue->resolution_notes ?? ''; - $this->featureId = $issue->feature_id; - $this->inspectionId = $issue->inspection_id; - } - - $this->showForm = true; - } - - public function closeForm() - { - $this->showForm = false; - $this->resetForm(); - } - - private function resetForm(): void - { - $this->reset([ - 'title', 'description', 'assignedTo', 'resolutionNotes', - 'featureId', 'inspectionId', 'editingIssue', - ]); - $this->status = 'open'; - $this->priority = 'medium'; - $this->resetErrorBag(); - } - - public function save() - { - $this->validate(); - - $payload = [ - 'title' => $this->title, - 'description' => $this->description, - 'status' => $this->status, - 'priority' => $this->priority, - 'feature_id' => $this->featureId, - 'inspection_id' => $this->inspectionId, - 'assigned_to' => $this->assignedTo ?: null, - 'resolution_notes' => $this->resolutionNotes ?: null, - ]; - - // Keep resolved_at in sync with the status - if (in_array($this->status, ['resolved', 'closed'])) { - $payload['resolved_at'] = now(); - } else { - $payload['resolved_at'] = null; - } - - if ($this->editingIssue) { - $issue = Issue::where('project_id', $this->project->id)->findOrFail($this->editingIssue); - // Don't overwrite an existing resolved date if it was already resolved - if ($issue->resolved_at && in_array($this->status, ['resolved', 'closed'])) { - unset($payload['resolved_at']); - } - $issue->update($payload); - } else { - $issue = Issue::create(array_merge($payload, [ - 'project_id' => $this->project->id, - 'reported_by' => Auth::id(), - ])); - - if ($issue->wasRecentlyCreated) { - $issue->load(['feature', 'assignee']); - $creator = $this->project->creator; - if ($creator && $creator->id !== Auth::id()) { - $creator->notify(new IssueReportedNotification($issue)); - } - if ($issue->assignee && $issue->assignee->id !== Auth::id()) { - $issue->assignee->notify(new IssueReportedNotification($issue)); - } - } - } - - $this->closeForm(); - $this->loadIssues(); - $this->dispatch('notify', 'Issue guardado correctamente'); - } - - public function resolve($issueId) - { - $issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId); - $issue->update([ - 'status' => 'resolved', - 'resolved_at' => $issue->resolved_at ?? now(), - ]); - $this->loadIssues(); - $this->dispatch('notify', 'Issue marcado como resuelto'); - } - - public function close($issueId) - { - $issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId); - $issue->update([ - 'status' => 'closed', - 'resolved_at' => $issue->resolved_at ?? now(), - ]); - $this->loadIssues(); - $this->dispatch('notify', 'Issue cerrado'); - } - - public function delete($issueId) - { - $issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId); - $issue->delete(); - $this->loadIssues(); - $this->dispatch('notify', 'Issue eliminado'); + // No state to mutate — the listener simply triggers a re-render so the + // stat counters recompute from the database in render(). } public function render() { - return view('livewire.issues.issue-manager'); + $counts = Issue::where('project_id', $this->project->id) + ->selectRaw('status, count(*) as c') + ->groupBy('status') + ->pluck('c', 'status'); + + return view('livewire.issues.issue-manager', [ + 'countOpen' => (int) ($counts['open'] ?? 0), + 'countInReview' => (int) ($counts['in_review'] ?? 0), + 'countResolved' => (int) ($counts['resolved'] ?? 0), + 'countClosed' => (int) ($counts['closed'] ?? 0), + 'countTotal' => (int) $counts->sum(), + ]); } } diff --git a/app/Livewire/IssueTable.php b/app/Livewire/IssueTable.php new file mode 100644 index 0000000..22eb4d4 --- /dev/null +++ b/app/Livewire/IssueTable.php @@ -0,0 +1,217 @@ +setPrimaryKey('id') + ->setDefaultSort('created_at', 'desc') + ->setSortingPillsEnabled(false) + ->setAdditionalSelects(['issues.id as id', 'issues.created_at as created_at']); + } + + public function builder(): Builder + { + // Defence in depth: only members (or super-admin) may list a project's issues. + $user = Auth::user(); + abort_unless( + $user->can('view issues') && ( + $user->can('manage all') + || \App\Models\Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists() + ), + 403 + ); + + return Issue::where('issues.project_id', $this->projectId) + ->with(['feature', 'reporter', 'assignee']) + ->withCount([ + 'comments', + 'media', + 'tasks', + 'tasks as tasks_done_count' => fn (Builder $q) => $q->where('is_done', true), + ]); + } + + public function columns(): array + { + return [ + Column::make('Prioridad', 'priority') + ->sortable() + ->format(function ($value, $row) { + $label = ['low' => 'Bajo', 'medium' => 'Medio', 'high' => 'Alto', 'critical' => 'Crítico'][$value] ?? ucfirst($value); + $textColor = in_array($value, ['critical', 'high']) ? '#fff' : '#1f2937'; + return ''.$label.''; + }) + ->html(), + + Column::make('Título', 'title') + ->sortable() + ->searchable() + ->format(function ($value, $row) { + $url = route('projects.issues.show', [$this->projectId, $row->id]); + $html = ''.e($value).''; + if ($row->description) { + $html .= '
diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php
index b4aac35..ff92e42 100644
--- a/resources/views/layouts/guest.blade.php
+++ b/resources/views/layouts/guest.blade.php
@@ -21,7 +21,7 @@
diff --git a/resources/views/livewire/issues/issue-detail.blade.php b/resources/views/livewire/issues/issue-detail.blade.php
new file mode 100644
index 0000000..27ac511
--- /dev/null
+++ b/resources/views/livewire/issues/issue-detail.blade.php
@@ -0,0 +1,304 @@
+Sin comentarios todavía.
+ @endforelse +Sin fotos.
+ @endif + + @can('upload media') + + @endcan +{{ $issue->resolution_notes }}
+{{ $project->name }}
+ + +Gestión de incidencias y problemas
Sin issues registrados
-Crea el primer issue con el botón "Nuevo Issue".
-| Prioridad | -Título | -Feature | -Estado | -Asignado a | -Fecha | -Acciones | -
|---|---|---|---|---|---|---|
| - @php - $pClass = match($issue->priority) { - 'critical' => 'badge-purple', - 'high' => 'badge-error', - 'medium' => 'badge-warning', - 'low' => 'badge-ghost', - default => 'badge-ghost', - }; - $pLabel = match($issue->priority) { - 'critical' => 'Crítico', - 'high' => 'Alto', - 'medium' => 'Medio', - 'low' => 'Bajo', - default => ucfirst($issue->priority), - }; - @endphp - - {{ $pLabel }} - - | - - {{-- Título + descripción breve --}} -
- {{ $issue->title }}
- @if($issue->description)
- {{ Str::limit($issue->description, 60) }}
- @endif
- @if($issue->reporter)
-
- Reportado por {{ $issue->reporter->name }}
-
- @endif
- |
-
- {{-- Feature --}}
- - @if($issue->feature) - {{ $issue->feature->name }} - @else - — - @endif - | - - {{-- Estado --}} -- @php - $sLabel = match($issue->status) { - 'open' => 'Abierto', - 'in_review' => 'En revisión', - 'resolved' => 'Resuelto', - 'closed' => 'Cerrado', - default => ucfirst($issue->status), - }; - @endphp - - {{ $sLabel }} - - | - - {{-- Asignado a --}} -
- @if($issue->assignee)
-
-
- {{ strtoupper(substr($issue->assignee->name, 0, 1)) }}
-
- {{ $issue->assignee->name }}
-
- @else
- Sin asignar
- @endif
- |
-
- {{-- Fecha --}}
-
- {{ $issue->created_at->format('d/m/Y') }}
- @if($issue->resolved_at)
- Res. {{ $issue->resolved_at->format('d/m/Y') }}
- @endif
- |
-
- {{-- Acciones --}}
-
-
- {{-- Editar --}}
-
-
- {{-- Resolver --}}
- @if(in_array($issue->status, ['open', 'in_review']))
-
- @endif
-
- {{-- Cerrar --}}
- @if($issue->status !== 'closed')
-
- @endif
-
- {{-- Eliminar --}}
-
-
- |
-