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 .= '
'.e(Str::limit($row->description, 60)).'
'; + } + $meta = []; + if ($row->reporter) $meta[] = 'Reportado por '.e($row->reporter->name); + if ($row->comments_count) $meta[] = '💬 '.$row->comments_count; + if ($row->media_count) $meta[] = '📷 '.$row->media_count; + if ($meta) { + $html .= '
'.implode(' · ', $meta).'
'; + } + if ($row->tasks_count) { + $pct = (int) round($row->tasks_done_count / $row->tasks_count * 100); + $html .= '
+ + '.$row->tasks_done_count.'/'.$row->tasks_count.' tareas +
'; + } + return $html; + }) + ->html(), + + Column::make('Feature') + ->label(fn ($row) => $row->feature + ? ''.e($row->feature->name).'' + : '') + ->html(), + + Column::make('Estado', 'status') + ->sortable() + ->format(function ($value, $row) { + $label = ['open' => 'Abierto', 'in_review' => 'En revisión', 'resolved' => 'Resuelto', 'closed' => 'Cerrado'][$value] ?? ucfirst($value); + return ''.$label.''; + }) + ->html(), + + Column::make('Asignado a') + ->label(fn ($row) => $row->assignee + ? ''.e($row->assignee->name).'' + : 'Sin asignar') + ->html(), + + Column::make('Fecha', 'created_at') + ->sortable() + ->format(function ($value, $row) { + $html = $row->created_at->format('d/m/Y'); + if ($row->resolved_at) { + $html .= '
Res. '.$row->resolved_at->format('d/m/Y').'
'; + } + return $html; + }) + ->html(), + + Column::make('Acciones') + ->label(function ($row) { + $user = Auth::user(); + $detail = route('projects.issues.show', [$this->projectId, $row->id]); + $edit = route('projects.issues.edit', [$this->projectId, $row->id]); + + $html = '
'; + $html .= ' + + '; + + if ($user->can('edit issues')) { + $html .= ' + + '; + if (in_array($row->status, ['open', 'in_review'])) { + $html .= ''; + } + if ($row->status !== 'closed') { + $html .= ''; + } + } + + if ($user->can('delete issues')) { + $html .= ''; + } + + $html .= '
'; + return $html; + }) + ->html(), + ]; + } + + public function filters(): array + { + return [ + SelectFilter::make('Estado', 'status') + ->options([ + '' => 'Estado: todos', + 'open' => 'Abierto', + 'in_review' => 'En revisión', + 'resolved' => 'Resuelto', + 'closed' => 'Cerrado', + ]) + ->filter(fn (Builder $query, string $value) => $query->where('issues.status', $value)), + + SelectFilter::make('Prioridad', 'priority') + ->options([ + '' => 'Prioridad: todas', + 'critical' => 'Crítica', + 'high' => 'Alta', + 'medium' => 'Media', + 'low' => 'Baja', + ]) + ->filter(fn (Builder $query, string $value) => $query->where('issues.priority', $value)), + ]; + } + + // ── Row actions ──────────────────────────────────────────────────────────────── + + private function findIssue(int $id): Issue + { + return Issue::where('project_id', $this->projectId)->findOrFail($id); + } + + public function resolve(int $id): void + { + abort_unless(Auth::user()->can('edit issues'), 403); + $issue = $this->findIssue($id); + $issue->update(['status' => 'resolved', 'resolved_at' => $issue->resolved_at ?? now()]); + $this->dispatch('issuesChanged'); + $this->dispatch('notify', 'Incidencia marcada como resuelta'); + } + + public function close(int $id): void + { + abort_unless(Auth::user()->can('edit issues'), 403); + $issue = $this->findIssue($id); + $issue->update(['status' => 'closed', 'resolved_at' => $issue->resolved_at ?? now()]); + $this->dispatch('issuesChanged'); + $this->dispatch('notify', 'Incidencia cerrada'); + } + + public function deleteIssue(int $id): void + { + abort_unless(Auth::user()->can('delete issues'), 403); + $this->findIssue($id)->delete(); + $this->dispatch('issuesChanged'); + $this->dispatch('notify', 'Incidencia eliminada'); + } +} diff --git a/app/Models/Issue.php b/app/Models/Issue.php index 7a774c9..75d3e82 100644 --- a/app/Models/Issue.php +++ b/app/Models/Issue.php @@ -28,10 +28,28 @@ class Issue extends Model public function reporter() { return $this->belongsTo(User::class, 'reported_by'); } public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); } public function media() { return $this->morphMany(Media::class, 'mediable'); } + public function tasks() { return $this->hasMany(IssueTask::class)->orderBy('order')->orderBy('id'); } + public function comments() { return $this->hasMany(IssueComment::class)->orderBy('created_at'); } public function scopeOpen($q) { return $q->where('status', 'open'); } public function scopeCritical($q) { return $q->where('priority', 'critical'); } + /** Resolution progress derived from the checklist: done tasks / total. */ + public function getProgressAttribute(): int + { + $total = $this->tasks->count(); + if ($total === 0) { + return in_array($this->status, ['resolved', 'closed'], true) ? 100 : 0; + } + return (int) round($this->tasks->where('is_done', true)->count() / $total * 100); + } + + /** True when there is at least one task and all of them are done. */ + public function getTasksCompleteAttribute(): bool + { + return $this->tasks->count() > 0 && $this->tasks->every(fn ($t) => $t->is_done); + } + public function getPriorityColorAttribute(): string { return match($this->priority) { diff --git a/app/Models/IssueComment.php b/app/Models/IssueComment.php new file mode 100644 index 0000000..0cb50eb --- /dev/null +++ b/app/Models/IssueComment.php @@ -0,0 +1,20 @@ +belongsTo(Issue::class); } + public function user() { return $this->belongsTo(User::class); } + public function media() { return $this->morphMany(Media::class, 'mediable'); } +} diff --git a/app/Models/IssueTask.php b/app/Models/IssueTask.php new file mode 100644 index 0000000..bb3f51c --- /dev/null +++ b/app/Models/IssueTask.php @@ -0,0 +1,34 @@ + 'boolean', + 'done_at' => 'datetime', + 'due_date' => 'date', + ]; + + public function issue() { return $this->belongsTo(Issue::class); } + public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); } + public function completer() { return $this->belongsTo(User::class, 'done_by'); } + public function media() { return $this->morphMany(Media::class, 'mediable'); } + + /** Overdue = has a due date in the past and not yet done. */ + public function getIsOverdueAttribute(): bool + { + return ! $this->is_done && $this->due_date && $this->due_date->isPast(); + } +} diff --git a/database/migrations/2026_06_18_120000_create_issue_tasks_table.php b/database/migrations/2026_06_18_120000_create_issue_tasks_table.php new file mode 100644 index 0000000..4aa50cc --- /dev/null +++ b/database/migrations/2026_06_18_120000_create_issue_tasks_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete(); + $table->string('title'); + $table->boolean('is_done')->default(false); + $table->timestamp('done_at')->nullable(); + $table->foreignId('done_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->date('due_date')->nullable(); + $table->integer('order')->default(0); + + // Offline sync + $table->uuid('uuid')->nullable()->unique(); + $table->timestamp('client_updated_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('issue_tasks'); + } +}; diff --git a/database/migrations/2026_06_18_120100_create_issue_comments_table.php b/database/migrations/2026_06_18_120100_create_issue_comments_table.php new file mode 100644 index 0000000..de7074b --- /dev/null +++ b/database/migrations/2026_06_18_120100_create_issue_comments_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users'); + $table->text('body'); + + // Offline sync + $table->uuid('uuid')->nullable()->unique(); + $table->timestamp('client_updated_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('issue_comments'); + } +}; diff --git a/docs/MOBILE_SYNC_PROTOCOL.md b/docs/MOBILE_SYNC_PROTOCOL.md index 45b755e..02afe20 100644 --- a/docs/MOBILE_SYNC_PROTOCOL.md +++ b/docs/MOBILE_SYNC_PROTOCOL.md @@ -110,3 +110,17 @@ Respuesta por operación: - **Fase C — PUSH:** `/sync` idempotente con validación/autorización/conflictos (recoge y endurece la lógica actual). - **Fase D — Media:** subida multipart + descarga. - **Fase E — Endurecimiento + Docs:** rate-limit, `sync_logs`, OpenAPI/Swagger como contrato para el equipo móvil. + +--- + +## Addendum (2026-06-18): Incidencias enriquecidas — tareas, comentarios y fotos + +El detalle de una incidencia incluye ahora un **checklist de tareas** y un **hilo de comentarios**, ambos con fotos. Todo es sincronizable offline: + +- **Nuevas entidades de PULL** en el `bundle` (y en `deleted`): `issue_tasks`, `issue_comments`. +- **Nuevas operaciones de PUSH** en `/sync` (idempotentes por `uuid`): + - `issue_task.create` — `data`: `issue_id`, `title`, `assigned_to?`, `due_date?`, `is_done?`. Requiere `edit issues`. + - `issue_task.update` — `data`: `id`, y cualquiera de `title`/`assigned_to`/`due_date`/`is_done`. Last-write-wins por `client_updated_at`. Requiere `edit issues`. + - `issue_comment.create` — `data`: `issue_id`, `body`. Requiere `view issues`. +- **Fotos**: `POST /media` admite `parent_entity` = `issue_task` y `issue_comment` (además de `issue`). Requiere `upload media`. +- El **% de avance** de la incidencia se deriva de las tareas completadas (no se almacena ni se sincroniza). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b2eee13..30e168f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -130,7 +130,7 @@ paths: required: [uuid, parent_entity, parent_id, file] properties: uuid: { type: string, format: uuid } - parent_entity: { type: string, enum: [feature, issue, project, phase, layer] } + parent_entity: { type: string, enum: [feature, issue, issue_task, issue_comment, project, phase, layer] } parent_id: { type: integer } file: { type: string, format: binary } category: { type: string, enum: [image, document, other] } @@ -156,7 +156,7 @@ components: type: object required: [entity, op, uuid, data] properties: - entity: { type: string, enum: [progress_update, inspection, issue, feature] } + entity: { type: string, enum: [progress_update, inspection, issue, issue_task, issue_comment, feature] } op: { type: string, enum: [create, update] } uuid: { type: string, format: uuid, description: client-generated idempotency key } client_updated_at: { type: string, format: date-time } @@ -185,6 +185,8 @@ components: features: { type: array, items: { type: object } } inspections: { type: array, items: { type: object } } issues: { type: array, items: { type: object } } + issue_tasks: { type: array, items: { type: object } } + issue_comments: { type: array, items: { type: object } } templates: { type: array, items: { type: object } } media: { type: array, items: { type: object } } deleted: @@ -196,3 +198,5 @@ components: features: { type: array, items: { type: integer } } inspections: { type: array, items: { type: integer } } issues: { type: array, items: { type: integer } } + issue_tasks: { type: array, items: { type: integer } } + issue_comments: { type: array, items: { type: integer } } diff --git a/public/images/.gitkeep b/public/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/images/logo-rte.png b/public/images/logo-rte.png new file mode 100644 index 0000000..f5d4744 Binary files /dev/null and b/public/images/logo-rte.png differ diff --git a/resources/views/components/application-logo.blade.php b/resources/views/components/application-logo.blade.php index 46579cf..459780a 100644 --- a/resources/views/components/application-logo.blade.php +++ b/resources/views/components/application-logo.blade.php @@ -1,3 +1 @@ - - - +RTE International — MAI Group 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 @@ +
+ + {{-- ================================================================ + BACK + HEADER + ================================================================ --}} + @php + $statusLabel = [ + 'open' => 'Abierto', 'in_review' => 'En revisión', + 'resolved' => 'Resuelto', 'closed' => 'Cerrado', + ][$issue->status] ?? $issue->status; + $priorityLabel = [ + 'low' => 'Baja', 'medium' => 'Media', 'high' => 'Alta', 'critical' => 'Crítica', + ][$issue->priority] ?? $issue->priority; + $doneCount = $issue->tasks->where('is_done', true)->count(); + $totalCount = $issue->tasks->count(); + @endphp + + + Volver a incidencias + + +
+
+

{{ $issue->title }}

+
+ {{ $statusLabel }} + Prioridad: {{ $priorityLabel }} + @if($issue->feature) + {{ $issue->feature->name }} + @endif +
+
+ Reportado por {{ $issue->reporter?->name ?? '—' }} + el {{ $issue->created_at->format('d/m/Y H:i') }} + @if($issue->assignee) · Asignado a {{ $issue->assignee->name }} @endif +
+
+ + {{-- Resolution progress --}} + @if($totalCount > 0) +
+
+ {{ $issue->progress }}% +
+
{{ $doneCount }}/{{ $totalCount }} tareas
+
+ @endif +
+ + @if($issue->description) +
{{ $issue->description }}
+ @endif + + {{-- ================================================================ + STATUS WORKFLOW BAR + ================================================================ --}} + @if($canEdit) +
+ Acciones: + + @if(in_array($issue->status, ['open'])) + + @if($totalCount > 0 && $doneCount < $totalCount) + Completa todas las tareas para enviar a revisión + @endif + @endif + + @if(in_array($issue->status, ['open', 'in_review'])) + + @endif + + @if($issue->status !== 'closed') + + @endif + + @if(in_array($issue->status, ['resolved', 'closed'])) + + @endif +
+ @endif + +
+ + {{-- ================================================================ + LEFT: CHECKLIST + COMMENTS + ================================================================ --}} +
+ + {{-- CHECKLIST --}} +
+
+

+ + Tareas para resolver + {{ $doneCount }}/{{ $totalCount }} +

+ + @if($totalCount > 0) + + @endif + +
    + @forelse($issue->tasks as $task) +
  • + is_done) + @disabled(! $canEdit) + wire:click="toggleTask({{ $task->id }})" /> +
    +
    {{ $task->title }}
    +
    + @if($task->assignee)👤 {{ $task->assignee->name }}@endif + @if($task->due_date) + 📅 {{ $task->due_date->format('d/m/Y') }} + @endif + @if($task->is_done && $task->completer) + ✓ {{ $task->completer->name }} · {{ $task->done_at?->format('d/m/Y') }} + @endif +
    +
    + @if($canEdit) + + @endif +
  • + @empty +
  • Aún no hay tareas. Añade la primera abajo.
  • + @endforelse +
+ + @if($canEdit) +
+
+ + @error('newTaskTitle'){{ $message }}@enderror +
+ + + +
+ @endif +
+
+ + {{-- COMMENTS / SEGUIMIENTO --}} +
+
+

+ + Seguimiento y comentarios + {{ $issue->comments->count() }} +

+ +
+ @forelse($issue->comments as $comment) +
+ + {{ strtoupper(substr($comment->user?->name ?? '?', 0, 1)) }} + +
+
+ {{ $comment->user?->name ?? 'Usuario' }} + · {{ $comment->created_at->diffForHumans() }} +
+
{{ $comment->body }}
+ @if($comment->media->count()) +
+ @foreach($comment->media as $m) + + + + @endforeach +
+ @endif +
+
+ @empty +

Sin comentarios todavía.

+ @endforelse +
+ + {{-- New comment --}} +
+ + @error('newComment'){{ $message }}@enderror +
+ + +
+ @error('commentPhoto'){{ $message }}@enderror +
+
+
+
+ + {{-- ================================================================ + RIGHT: PHOTOS + RESOLUTION + ================================================================ --}} +
+ + {{-- ISSUE PHOTOS --}} +
+
+

+ Fotos de la incidencia +

+ + @if($issue->media->count()) +
+ @foreach($issue->media as $m) +
+ + + + @can('delete media') + + @endcan +
+ @endforeach +
+ @else +

Sin fotos.

+ @endif + + @can('upload media') +
+ + @error('issuePhotos.*'){{ $message }}@enderror + @if($issuePhotos) + + @endif +
+ @endcan +
+
+ + {{-- RESOLUTION NOTES --}} + @if($canEdit) +
+
+

+ Notas de resolución +

+ + +
+
+ @elseif($issue->resolution_notes) +
+
+

Notas de resolución

+

{{ $issue->resolution_notes }}

+
+
+ @endif +
+
+
diff --git a/resources/views/livewire/issues/issue-form.blade.php b/resources/views/livewire/issues/issue-form.blade.php new file mode 100644 index 0000000..10f9af8 --- /dev/null +++ b/resources/views/livewire/issues/issue-form.blade.php @@ -0,0 +1,119 @@ +
+ + + Volver a incidencias + + +
+
+

+ {{ $issue ? 'Editar incidencia' : 'Nueva incidencia' }} +

+

{{ $project->name }}

+ +
+ + {{-- Título --}} +
+ + + @error('title') + + @enderror +
+ + {{-- Descripción --}} +
+ + + @error('description') + + @enderror +
+ + {{-- Prioridad + Estado --}} +
+
+ + + @error('priority') + + @enderror +
+ +
+ + + @error('status') + + @enderror +
+
+ + {{-- Asignado a --}} +
+ + + @error('assignedTo') + + @enderror +
+ + {{-- Notas de resolución (visible when status = resolved or closed) --}} + @if(in_array($status, ['resolved', 'closed'])) +
+ + + @error('resolutionNotes') + + @enderror +
+ @endif + + {{-- Footer --}} +
+ Cancelar + +
+
+
+
+
diff --git a/resources/views/livewire/issues/issue-manager.blade.php b/resources/views/livewire/issues/issue-manager.blade.php index a6388b2..4888334 100644 --- a/resources/views/livewire/issues/issue-manager.blade.php +++ b/resources/views/livewire/issues/issue-manager.blade.php @@ -4,28 +4,24 @@ ================================================================ --}}
-

Issues del proyecto

+

Incidencias del proyecto

Gestión de incidencias y problemas

- + @can('create issues') + + + Nueva incidencia + + @endcan
{{-- ================================================================ STATS BAR ================================================================ --}} - @php - $countOpen = $issues->where('status', 'open')->count(); - $countInReview = $issues->where('status', 'in_review')->count(); - $countResolved = $issues->where('status', 'resolved')->count(); - $countClosed = $issues->where('status', 'closed')->count(); - @endphp -
Abiertos
@@ -45,359 +41,12 @@
Total
-
{{ $issues->count() }}
+
{{ $countTotal }}
{{-- ================================================================ - ISSUES TABLE + ISSUES TABLE (Rappasoft) ================================================================ --}} - @if($issues->isEmpty()) -
- -

Sin issues registrados

-

Crea el primer issue con el botón "Nuevo Issue".

-
- @else -
- - - - - - - - - - - - - - @foreach($issues as $issue) - - {{-- Prioridad --}} - - - {{-- Título + descripción breve --}} - - - {{-- Feature --}} - - - {{-- Estado --}} - - - {{-- Asignado a --}} - - - {{-- Fecha --}} - - - {{-- Acciones --}} - - - @endforeach - -
PrioridadTítuloEstadoAcciones
- @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 }} - - -
{{ $issue->title }}
- @if($issue->description) -
{{ Str::limit($issue->description, 60) }}
- @endif - @if($issue->reporter) -
- Reportado por {{ $issue->reporter->name }} -
- @endif -
- @php - $sLabel = match($issue->status) { - 'open' => 'Abierto', - 'in_review' => 'En revisión', - 'resolved' => 'Resuelto', - 'closed' => 'Cerrado', - default => ucfirst($issue->status), - }; - @endphp - - {{ $sLabel }} - - -
- {{-- Editar --}} - - - {{-- Resolver --}} - @if(in_array($issue->status, ['open', 'in_review'])) - - @endif - - {{-- Cerrar --}} - @if($issue->status !== 'closed') - - @endif - - {{-- Eliminar --}} - -
-
-
- @endif - - {{-- ================================================================ - MODAL FORM (create / edit) - ================================================================ --}} - @if($showForm) - {{-- Overlay --}} -
- - {{-- Modal panel --}} - - @endif + diff --git a/resources/views/livewire/layout/navigation.blade.php b/resources/views/livewire/layout/navigation.blade.php index a96fa9e..3071626 100644 --- a/resources/views/livewire/layout/navigation.blade.php +++ b/resources/views/livewire/layout/navigation.blade.php @@ -26,7 +26,7 @@ new class extends Component
- +
diff --git a/routes/web.php b/routes/web.php index ffdc385..bd81dbc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -119,6 +119,9 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa // Issues del proyecto Route::get('/projects/{project}/issues', \App\Livewire\IssueManager::class)->name('projects.issues'); + Route::get('/projects/{project}/issues/create', \App\Livewire\IssueForm::class)->name('projects.issues.create'); + Route::get('/projects/{project}/issues/{issue}', \App\Livewire\IssueDetail::class)->name('projects.issues.show'); + Route::get('/projects/{project}/issues/{issue}/edit', \App\Livewire\IssueForm::class)->name('projects.issues.edit'); // Dashboard por proyecto Route::get('/projects/{project}/dashboard', \App\Livewire\ProjectDashboard::class)->name('projects.dashboard'); diff --git a/tests/Feature/Api/MobileApiTest.php b/tests/Feature/Api/MobileApiTest.php index a7825b5..4710d88 100644 --- a/tests/Feature/Api/MobileApiTest.php +++ b/tests/Feature/Api/MobileApiTest.php @@ -6,6 +6,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\Phase; use App\Models\Project; @@ -28,7 +30,7 @@ class MobileApiTest extends TestCase protected function setUp(): void { parent::setUp(); - foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues', 'upload media'] as $p) { + foreach (['update progress', 'manage all', 'create inspections', 'view issues', 'create issues', 'edit issues', 'upload media'] as $p) { Permission::findOrCreate($p); } } @@ -407,6 +409,126 @@ class MobileApiTest extends TestCase $this->assertEquals(1, \App\Models\Media::where('uuid', $uuid)->count()); } + // ── Issue tasks / comments (enriquecimiento incidencias) ───────────────────── + + private function makeIssue(Project $project, ?User $reporter = null): Issue + { + return Issue::create([ + 'project_id' => $project->id, + 'title' => 'Incidencia test', + 'status' => 'open', + 'priority' => 'medium', + 'reported_by' => $reporter?->id ?? $project->created_by, + ]); + } + + public function test_sync_creates_and_updates_an_issue_task(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('edit issues'); + $project = $this->makeProject($user); + $issue = $this->makeIssue($project, $user); + + Sanctum::actingAs($user, ['mobile-sync']); + + // create task + $uuid = (string) Str::uuid(); + $this->postJson('/api/v1/sync', ['operations' => [[ + 'entity' => 'issue_task', 'op' => 'create', 'uuid' => $uuid, + 'data' => ['issue_id' => $issue->id, 'title' => 'Sanear zona'], + ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); + + $task = IssueTask::where('uuid', $uuid)->firstOrFail(); + $this->assertFalse($task->is_done); + + // mark done via update + $this->postJson('/api/v1/sync', ['operations' => [[ + 'entity' => 'issue_task', 'op' => 'update', 'uuid' => (string) Str::uuid(), + 'data' => ['id' => $task->id, 'is_done' => true], + ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); + + $task->refresh(); + $this->assertTrue($task->is_done); + $this->assertEquals($user->id, $task->done_by); + $this->assertNotNull($task->done_at); + } + + public function test_sync_issue_task_is_forbidden_without_edit_permission(): void + { + $user = User::factory()->create(); // member but no 'edit issues' + $project = $this->makeProject($user); + $issue = $this->makeIssue($project, $user); + + Sanctum::actingAs($user, ['mobile-sync']); + $this->postJson('/api/v1/sync', ['operations' => [[ + 'entity' => 'issue_task', 'op' => 'create', 'uuid' => (string) Str::uuid(), + 'data' => ['issue_id' => $issue->id, 'title' => 'X'], + ]]])->assertOk()->assertJsonPath('results.0.status', 'error'); + + $this->assertDatabaseCount('issue_tasks', 0); + } + + public function test_sync_creates_an_issue_comment(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('view issues'); + $project = $this->makeProject($user); + $issue = $this->makeIssue($project, $user); + + Sanctum::actingAs($user, ['mobile-sync']); + $uuid = (string) Str::uuid(); + $this->postJson('/api/v1/sync', ['operations' => [[ + 'entity' => 'issue_comment', 'op' => 'create', 'uuid' => $uuid, + 'data' => ['issue_id' => $issue->id, 'body' => 'Revisado en obra'], + ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); + + $this->assertDatabaseHas('issue_comments', [ + 'uuid' => $uuid, 'issue_id' => $issue->id, 'user_id' => $user->id, 'body' => 'Revisado en obra', + ]); + } + + public function test_media_uploads_to_issue_task_and_comment(): void + { + Storage::fake('public'); + + $user = User::factory()->create(); + $user->givePermissionTo('upload media'); + $project = $this->makeProject($user); + $issue = $this->makeIssue($project, $user); + $task = $issue->tasks()->create(['title' => 'T', 'uuid' => (string) Str::uuid()]); + $comment = $issue->comments()->create(['user_id' => $user->id, 'body' => 'c', 'uuid' => (string) Str::uuid()]); + + Sanctum::actingAs($user, ['mobile-sync']); + + $this->post('/api/v1/media', [ + 'uuid' => (string) Str::uuid(), 'parent_entity' => 'issue_task', 'parent_id' => $task->id, + 'file' => UploadedFile::fake()->image('a.jpg'), + ])->assertOk()->assertJsonPath('status', 'applied'); + + $this->post('/api/v1/media', [ + 'uuid' => (string) Str::uuid(), 'parent_entity' => 'issue_comment', 'parent_id' => $comment->id, + 'file' => UploadedFile::fake()->image('b.jpg'), + ])->assertOk()->assertJsonPath('status', 'applied'); + + $this->assertDatabaseHas('media', ['mediable_type' => IssueTask::class, 'mediable_id' => $task->id]); + $this->assertDatabaseHas('media', ['mediable_type' => IssueComment::class, 'mediable_id' => $comment->id]); + } + + public function test_bundle_includes_issue_tasks_and_comments(): void + { + $user = User::factory()->create(); + $project = $this->makeProject($user); + $issue = $this->makeIssue($project, $user); + $task = $issue->tasks()->create(['title' => 'Tarea bundle', 'uuid' => (string) Str::uuid()]); + $comment = $issue->comments()->create(['user_id' => $user->id, 'body' => 'Comentario bundle', 'uuid' => (string) Str::uuid()]); + + Sanctum::actingAs($user, ['mobile-sync']); + $res = $this->getJson("/api/v1/projects/{$project->id}/bundle")->assertOk(); + + $this->assertTrue(collect($res->json('issue_tasks'))->pluck('id')->contains($task->id)); + $this->assertTrue(collect($res->json('issue_comments'))->pluck('id')->contains($comment->id)); + } + public function test_sync_operation_is_idempotent_via_sync_logs(): void { $user = User::factory()->create(); diff --git a/tests/Feature/IssuesTablePageTest.php b/tests/Feature/IssuesTablePageTest.php new file mode 100644 index 0000000..6891112 --- /dev/null +++ b/tests/Feature/IssuesTablePageTest.php @@ -0,0 +1,87 @@ +create(); + $user->givePermissionTo(['view issues', 'edit issues', 'delete issues']); + + $project = Project::create([ + 'reference' => 'TBL-1', + 'name' => 'Proyecto Tabla', + 'address' => 'Calle Falsa 123', + 'lat' => 40.0, + 'lng' => -3.0, + 'start_date' => now()->toDateString(), + 'end_date_estimated' => now()->addMonths(6)->toDateString(), + 'status' => 'in_progress', + 'created_by' => $user->id, + ]); + $project->users()->attach($user->id, ['role_in_project' => 'supervisor']); + + $issue = Issue::create([ + 'project_id' => $project->id, + 'title' => 'Grieta en muro', + 'status' => 'open', + 'priority' => 'high', + 'reported_by' => $user->id, + ]); + + return [$user, $project, $issue]; + } + + public function test_issue_manager_renders_stats_and_table(): void + { + [$user, $project, $issue] = $this->memberWithIssue(); + + Livewire::actingAs($user) + ->test(IssueManager::class, ['project' => $project]) + ->assertOk() + ->assertSee('Incidencias del proyecto') + ->assertSeeLivewire(IssueTable::class); + } + + public function test_issue_table_lists_and_resolves_an_issue(): void + { + [$user, $project, $issue] = $this->memberWithIssue(); + + Livewire::actingAs($user) + ->test(IssueTable::class, ['projectId' => $project->id]) + ->assertOk() + ->assertSee('Grieta en muro') + ->call('resolve', $issue->id); + + $this->assertEquals('resolved', $issue->fresh()->status); + } + + public function test_issue_table_forbidden_for_non_member(): void + { + [, $project] = $this->memberWithIssue(); + + $outsider = User::factory()->create(); + $outsider->givePermissionTo('view issues'); + + Livewire::actingAs($outsider) + ->test(IssueTable::class, ['projectId' => $project->id]) + ->assertForbidden(); + } +}