From 30012220eafa0fc1d715e474abd27e69c6271a38 Mon Sep 17 00:00:00 2001 From: Thoriq Date: Fri, 15 May 2026 21:19:45 +0700 Subject: [PATCH 1/4] feat: add selectable all-project project board --- app/Exports/TicketsExport.php | 44 +- app/Filament/Actions/ExportTicketsAction.php | 15 +- app/Filament/Pages/ProjectBoard.php | 862 +++++++++++++++--- .../filament/pages/project-board.blade.php | 714 ++++++++++----- 4 files changed, 1269 insertions(+), 366 deletions(-) diff --git a/app/Exports/TicketsExport.php b/app/Exports/TicketsExport.php index 4747e25..5437f5a 100644 --- a/app/Exports/TicketsExport.php +++ b/app/Exports/TicketsExport.php @@ -2,26 +2,28 @@ namespace App\Exports; -use PhpOffice\PhpSpreadsheet\Style\Fill; -use App\Models\Ticket; +use Illuminate\Support\Collection; use Maatwebsite\Excel\Concerns\FromCollection; +use Maatwebsite\Excel\Concerns\ShouldAutoSize; use Maatwebsite\Excel\Concerns\WithHeadings; use Maatwebsite\Excel\Concerns\WithMapping; use Maatwebsite\Excel\Concerns\WithStyles; -use Maatwebsite\Excel\Concerns\ShouldAutoSize; +use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; -use Illuminate\Support\Collection; -class TicketsExport implements FromCollection, WithHeadings, WithMapping, WithStyles, ShouldAutoSize +class TicketsExport implements FromCollection, ShouldAutoSize, WithHeadings, WithMapping, WithStyles { protected $tickets; + protected $selectedColumns; + protected $availableColumns = [ 'uuid' => 'Ticket ID', 'name' => 'Title', 'description' => 'Description', 'status' => 'Status', 'assignee' => 'Assignee', + 'project_code' => 'Project Code', 'project' => 'Project', 'epic' => 'Epic', 'start_date' => 'Start Date', @@ -49,6 +51,7 @@ public function headings(): array $headings[] = $this->availableColumns[$column]; } } + return $headings; } @@ -71,7 +74,10 @@ public function map($ticket): array $row[] = $ticket->status?->name ?? 'No Status'; break; case 'assignee': - $row[] = $ticket->assignees->pluck('name')->implode(', ');; + $row[] = $ticket->assignees->pluck('name')->implode(', '); + break; + case 'project_code': + $row[] = $this->projectCode($ticket->project); break; case 'project': $row[] = $ticket->project?->name ?? 'No Project'; @@ -128,4 +134,30 @@ public function getAvailableColumns(): array { return $this->availableColumns; } + + private function projectCode($project): string + { + if (! $project) { + return 'PRJ'; + } + + if ($project->ticket_prefix) { + return $project->ticket_prefix; + } + + $words = str($project->name) + ->replaceMatches('/[^A-Za-z0-9\s]/', ' ') + ->squish() + ->explode(' ') + ->filter(); + + if ($words->count() <= 1) { + return str($project->name)->upper()->substr(0, 4)->toString(); + } + + return $words + ->take(4) + ->map(fn (string $word) => str($word)->substr(0, 1)->upper()->toString()) + ->implode(''); + } } diff --git a/app/Filament/Actions/ExportTicketsAction.php b/app/Filament/Actions/ExportTicketsAction.php index e56f20f..1d337f5 100644 --- a/app/Filament/Actions/ExportTicketsAction.php +++ b/app/Filament/Actions/ExportTicketsAction.php @@ -2,13 +2,9 @@ namespace App\Filament\Actions; -use Filament\Schemas\Components\Section; -use App\Exports\TicketsExport; use Filament\Actions\Action; use Filament\Forms\Components\CheckboxList; -use Filament\Notifications\Notification; -use Maatwebsite\Excel\Facades\Excel; -use Symfony\Component\HttpFoundation\StreamedResponse; +use Filament\Schemas\Components\Section; class ExportTicketsAction { @@ -30,21 +26,22 @@ public static function make(): Action 'description' => 'Description', 'status' => 'Status', 'assignee' => 'Assignee', + 'project_code' => 'Project Code', 'project' => 'Project', 'epic' => 'Epic', 'due_date' => 'Due Date', 'created_at' => 'Created At', 'updated_at' => 'Updated At', ]) - ->default(['uuid', 'name', 'status', 'assignee', 'due_date', 'created_at']) + ->default(['uuid', 'name', 'status', 'assignee', 'project_code', 'project', 'due_date', 'created_at']) ->required() ->minItems(1) ->columns(2) - ->gridDirection('row') - ]) + ->gridDirection('row'), + ]), ]) ->action(function (array $data, $livewire): void { $livewire->exportTickets($data['columns'] ?? []); }); } -} \ No newline at end of file +} diff --git a/app/Filament/Pages/ProjectBoard.php b/app/Filament/Pages/ProjectBoard.php index fa4cd87..c89e293 100644 --- a/app/Filament/Pages/ProjectBoard.php +++ b/app/Filament/Pages/ProjectBoard.php @@ -7,12 +7,14 @@ use App\Filament\Resources\Tickets\TicketResource; use App\Models\Project; use App\Models\Ticket; +use App\Models\TicketStatus; use App\Models\User; use Exception; use Filament\Actions\Action; use Filament\Forms\Components\CheckboxList; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Livewire\Attributes\Computed; @@ -21,6 +23,22 @@ class ProjectBoard extends Page { + private const MODE_GLOBAL = 'global'; + + private const MODE_INDEX = 'index'; + + private const MODE_PROJECT = 'project'; + + private const ALL_PROJECT_ROUTE = 'all-project'; + + private const SELECTED_PROJECTS_ROUTE_PREFIX = 'selected-projects-'; + + private const SESSION_SELECTED_GLOBAL_PROJECT_IDS = 'project_board.selected_global_project_ids'; + + private const DEFAULT_COLUMN_LIMIT = 20; + + private const COLUMN_LIMIT_INCREMENT = 20; + protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-view-columns'; protected string $view = 'filament.pages.project-board'; @@ -50,36 +68,70 @@ public function getSubheading(): ?string public ?int $selectedProjectId = null; + public string $boardMode = self::MODE_INDEX; + public array $sortOrders = []; + public array $columnLimits = []; + public array $selectedUserIds = []; + public array $selectedGlobalProjectIds = []; + public Collection $projectUsers; public string $searchProject = ''; public function mount($project_id = null): void { - if (auth()->user()->hasRole(['super_admin'])) { - $this->projects = Project::orderByRaw('pinned_date IS NULL') - ->orderBy('pinned_date', 'desc') - ->orderBy('name') - ->get(); - } else { - $this->projects = auth()->user()->projects() - ->orderByRaw('pinned_date IS NULL') - ->orderBy('pinned_date', 'desc') - ->orderBy('name') - ->get(); + $this->loadProjects(); + $this->projectUsers = collect(); + + if ($project_id === null || $project_id === '') { + if ($this->restorePersistedGlobalProjectFilter()) { + return; + } + + $this->showProjectIndex(navigate: false); + + return; } - if ($project_id) { - $this->selectedProjectId = (int) $project_id; - $this->selectedProject = Project::find($project_id); - $this->loadProjectUsers(); - } else { - $this->projectUsers = collect(); + $routeProjectId = (string) $project_id; + + if ($routeProjectId === self::ALL_PROJECT_ROUTE) { + $this->selectAllProjects(navigate: false); + + return; + } + + if (str_starts_with($routeProjectId, self::SELECTED_PROJECTS_ROUTE_PREFIX)) { + $this->selectSelectedProjects($this->selectedProjectIdsFromRoute($routeProjectId), navigate: false); + + return; + } + + if (is_numeric($routeProjectId)) { + $this->selectProject((int) $routeProjectId, navigate: false); + + return; } + + $this->showProjectIndex(navigate: false); + } + + private function loadProjects(): void + { + $query = auth()->user()->hasRole(['super_admin']) + ? Project::query() + : auth()->user()->projects(); + + $this->projects = $query + ->select('projects.id', 'projects.name', 'projects.ticket_prefix', 'projects.color', 'projects.pinned_date') + ->orderByRaw('pinned_date IS NULL') + ->orderBy('pinned_date', 'desc') + ->orderBy('name') + ->get(); } public function getFilteredProjectsProperty(): Collection @@ -97,71 +149,131 @@ public function getFilteredProjectsProperty(): Collection public function updatedSelectedProjectId($value): void { if ($value) { - $this->selectProject($value); + $this->selectProject((int) $value); } else { - $this->selectedProject = null; - $this->projectUsers = collect(); - $this->selectedUserIds = []; + $this->showProjectIndex(); + } + } - // Use wire:navigate for SPA-like navigation - $url = static::getUrl(); - $this->js("Livewire.navigate('{$url}')"); + public function showProjectIndex(bool $navigate = true): void + { + $this->clearPersistedGlobalProjectFilter(); + $this->boardMode = self::MODE_INDEX; + $this->selectedTicket = null; + $this->selectedProject = null; + $this->selectedProjectId = null; + $this->selectedGlobalProjectIds = []; + $this->projectUsers = collect(); + $this->selectedUserIds = []; + $this->columnLimits = []; + $this->loadTicketStatuses(); + + if ($navigate) { + $this->navigateToBoardUrl(); + } + } + + public function selectAllProjects(bool $navigate = true): void + { + $this->clearPersistedGlobalProjectFilter(); + $this->boardMode = self::MODE_GLOBAL; + $this->selectedTicket = null; + $this->selectedProject = null; + $this->selectedProjectId = null; + $this->selectedGlobalProjectIds = []; + $this->projectUsers = collect(); + $this->selectedUserIds = []; + $this->columnLimits = []; + $this->loadProjectUsers(); + $this->loadTicketStatuses(); + + if ($navigate) { + $this->navigateToBoardUrl(self::ALL_PROJECT_ROUTE); } } - public function selectProject(int $projectId): void + public function selectSelectedProjects(array $projectIds, bool $navigate = true): void { + $this->boardMode = self::MODE_GLOBAL; + $this->selectedTicket = null; + $this->selectedProject = null; + $this->selectedProjectId = null; + $this->selectedGlobalProjectIds = $this->normalizedGlobalProjectFilterIds($projectIds); + $this->projectUsers = collect(); + $this->selectedUserIds = []; + $this->columnLimits = []; + $this->loadProjectUsers(); + $this->loadTicketStatuses(); + $this->persistGlobalProjectFilter(); + + if ($navigate) { + $this->navigateToBoardUrl($this->globalRouteParameter()); + } + } + + public function selectProject(int $projectId, bool $navigate = true): void + { + $this->clearPersistedGlobalProjectFilter(); + $this->boardMode = self::MODE_PROJECT; $this->selectedTicket = null; - $this->ticketStatuses = collect(); $this->selectedProjectId = $projectId; - $this->selectedProject = Project::with('tickets')->find($projectId); + $this->selectedProject = $this->projects->firstWhere('id', $projectId); + $this->selectedGlobalProjectIds = []; $this->selectedUserIds = []; + $this->columnLimits = []; if ($this->selectedProject) { $this->loadProjectUsers(); + $this->loadTicketStatuses(); // Use wire:navigate for SPA-like navigation - $url = static::getUrl(['project_id' => $projectId]); - $this->js("Livewire.navigate('{$url}')"); + if ($navigate) { + $this->navigateToBoardUrl((string) $projectId); + } + + return; } + + $this->showProjectIndex(navigate: false); } #[Computed] public function ticketStatuses(): Collection { + if ($this->isGlobalBoard()) { + return $this->globalBoardColumns(); + } + if (! $this->selectedProject) { return collect(); } + return $this->projectBoardColumns(); + } + + private function projectBoardColumns(): Collection + { $statuses = $this->selectedProject->ticketStatuses() - ->with([ - 'tickets' => function ($query) { - $query->with([ - 'assignees:id,name', - 'status:id,name,color,is_completed', - 'priority:id,name,color', - 'creator:id,name', - ]) - ->select('id', 'project_id', 'ticket_status_id', 'priority_id', 'name', 'description', 'uuid', 'due_date', 'created_at', 'updated_at', 'created_by') - ->when(! empty($this->selectedUserIds), function ($query) { - $query->whereHas('assignees', function ($assigneeQuery) { - $assigneeQuery->whereIn('users.id', $this->selectedUserIds); - }); - }) - ->orderByDesc('created_at') - ->orderByDesc('id'); - }, - ]) ->select('id', 'project_id', 'name', 'color', 'sort_order', 'is_completed') ->orderBy('sort_order') ->get(); - $statuses->each(function ($status) { + return $statuses->map(function ($status) { $sortOrder = $this->sortOrders[$status->id] ?? 'date_created_newest'; - $status->tickets = $this->applySorting($status->tickets, $sortOrder); - }); + $baseQuery = $this->projectTicketsQuery($status->id); + + $status->tickets_count = (clone $baseQuery)->count(); + $status->tickets = $this->applyQuerySorting( + (clone $baseQuery) + ->with($this->ticketRelations()) + ->select($this->ticketSelectColumns()), + $sortOrder + ) + ->limit($this->getColumnLimit($status->id)) + ->get(); - return $statuses; + return $status; + }); } public function loadTicketStatuses(): void @@ -172,23 +284,27 @@ public function loadTicketStatuses(): void public function loadProjectUsers(): void { - if (! $this->selectedProject) { + if ($this->isProjectIndex()) { $this->projectUsers = collect(); return; } - // Get only users who are assigned to tickets in this project - $ticketAssigneeIds = $this->selectedProject->tickets() - ->with('assignees') - ->get() - ->flatMap(function ($ticket) { - return $ticket->assignees->pluck('id'); - }) - ->unique() - ->filter(); + $projectIds = $this->selectedProject + ? [$this->selectedProject->id] + : $this->visibleGlobalProjectIds(); - $this->projectUsers = User::whereIn('id', $ticketAssigneeIds) + if (empty($projectIds)) { + $this->projectUsers = collect(); + + return; + } + + $this->projectUsers = User::query() + ->select('users.id', 'users.name') + ->whereHas('assignedTickets', function (Builder $query) use ($projectIds) { + $query->whereIn('tickets.project_id', $projectIds); + }) ->orderBy('name') ->get(); } @@ -210,29 +326,28 @@ public function setSortOrder($statusId, $sortOrder) $this->loadTicketStatuses(); } - private function applySorting($tickets, $sortOrder) + public function loadMoreTickets(string $columnId): void + { + $this->columnLimits[$columnId] = $this->getColumnLimit($columnId) + self::COLUMN_LIMIT_INCREMENT; + $this->loadTicketStatuses(); + $this->dispatch('ticket-updated'); + } + + private function applyQuerySorting(Builder $query, string $sortOrder): Builder { switch ($sortOrder) { case 'date_created_newest': - // Query already ordered by created_at DESC, id DESC - just reset keys - return $tickets->values(); + return $query->orderByDesc('created_at')->orderByDesc('id'); case 'date_created_oldest': - return $tickets->sortBy(function ($ticket) { - return $ticket->created_at->timestamp . '_' . str_pad($ticket->id, 10, '0', STR_PAD_LEFT); - })->values(); + return $query->orderBy('created_at')->orderBy('id'); case 'card_name_alphabetical': - return $tickets->sortBy('name')->values(); + return $query->orderBy('name')->orderByDesc('created_at'); case 'due_date': - return $tickets->sortBy(function ($ticket) { - return $ticket->due_date ?? '9999-12-31'; - })->values(); + return $query->orderByRaw('due_date IS NULL')->orderBy('due_date')->orderByDesc('created_at'); case 'priority': - return $tickets->sortBy(function ($ticket) { - return $ticket->priority ? $ticket->priority->id : 999; - })->values(); + return $query->orderByRaw('priority_id IS NULL')->orderBy('priority_id')->orderByDesc('created_at'); default: - // Default is same as date_created_newest - query already sorted - return $tickets->values(); + return $query->orderByDesc('created_at')->orderByDesc('id'); } } @@ -241,30 +356,53 @@ public function moveTicket($ticketId, $newStatusId): void { $ticket = Ticket::find($ticketId); - if ($ticket && $ticket->project_id === $this->selectedProject?->id) { - if (! $this->canManageTicket($ticket)) { - Notification::make() - ->title('Permission Denied') - ->body('You do not have permission to move this ticket.') - ->danger() - ->send(); + if (! $ticket) { + Notification::make() + ->title('Ticket Not Found') + ->danger() + ->send(); - return; - } + return; + } - $ticket->update([ - 'ticket_status_id' => $newStatusId, - ]); + if (! $this->canManageTicket($ticket)) { + Notification::make() + ->title('Permission Denied') + ->body('You do not have permission to move this ticket.') + ->danger() + ->send(); - $this->loadTicketStatuses(); + return; + } - $this->dispatch('ticket-updated'); + $targetStatus = $this->resolveTargetStatus($ticket, (string) $newStatusId); + if (! $targetStatus) { Notification::make() - ->title('Ticket Updated') - ->success() + ->title('Status Not Found') + ->body("This ticket's project does not have that exact status.") + ->warning() ->send(); + + return; } + + if ($ticket->ticket_status_id === $targetStatus->id) { + return; + } + + $ticket->update([ + 'ticket_status_id' => $targetStatus->id, + ]); + + $this->loadTicketStatuses(); + + $this->dispatch('ticket-updated'); + + Notification::make() + ->title('Ticket Updated') + ->success() + ->send(); } #[On('refresh-board')] @@ -361,14 +499,58 @@ protected function getHeaderActions(): array ->label('Refresh Board') ->icon('heroicon-m-arrow-path') ->action('refreshBoard') + ->visible(fn () => ! $this->isProjectIndex()) ->color('warning'), + Action::make('filter_projects') + ->label(fn () => $this->projectFilterActionLabel()) + ->icon('heroicon-m-funnel') + ->visible(fn () => ($this->isProjectIndex() || $this->isGlobalBoard()) && $this->projects->isNotEmpty()) + ->schema([ + CheckboxList::make('selectedGlobalProjectIds') + ->label('Projects to Show') + ->helperText('Leave empty to show every accessible project.') + ->options(fn () => $this->projectFilterOptionLabels()) + ->allowHtml() + ->columns(2) + ->searchable() + ->bulkToggleable(), + ]) + ->action(function (array $data) { + $selectedProjectIds = $this->normalizedGlobalProjectFilterIds($data['selectedGlobalProjectIds'] ?? []); + + if (empty($selectedProjectIds)) { + $this->selectAllProjects(); + } else { + $this->selectSelectedProjects($selectedProjectIds); + } + + $projectCount = count($selectedProjectIds); + if ($projectCount > 0) { + Notification::make() + ->title('Project Filter Applied') + ->body("Showing tickets for {$projectCount} selected project(s).") + ->success() + ->send(); + } else { + Notification::make() + ->title('Project Filter Cleared') + ->body('Showing tickets from all accessible projects.') + ->info() + ->send(); + } + }) + ->fillForm(fn () => [ + 'selectedGlobalProjectIds' => $this->normalizedGlobalProjectFilterIds($this->selectedGlobalProjectIds), + ]) + ->modalWidth('lg') + ->color('gray'), ExportTicketsAction::make() - ->visible(fn () => $this->selectedProject !== null && auth()->user()->hasRole(['super_admin'])), + ->visible(fn () => ! $this->isProjectIndex() && auth()->user()->hasRole(['super_admin'])), Action::make('filter_users') ->label('Filter by User') ->icon('heroicon-m-user-group') - ->visible(fn () => $this->selectedProject !== null && $this->projectUsers->isNotEmpty()) + ->visible(fn () => ! $this->isProjectIndex() && $this->projectUsers->isNotEmpty()) ->schema([ CheckboxList::make('selectedUserIds') ->label('Select Users to Filter') @@ -415,8 +597,8 @@ private function canViewTicket(?Ticket $ticket): bool } return auth()->user()->hasRole(['super_admin']) - || $ticket->user_id === auth()->id() - || $ticket->assignees()->where('users.id', auth()->id())->exists(); + || $ticket->created_by === auth()->id() + || $this->userIsTicketAssignee($ticket); } private function canEditTicket(?Ticket $ticket): bool @@ -435,8 +617,8 @@ private function canEditTicket(?Ticket $ticket): bool // 2. The ticket creator // 3. Assigned to the ticket return auth()->user()->hasRole(['super_admin']) - || $ticket->user_id === auth()->id() - || $ticket->assignees()->where('users.id', auth()->id())->exists(); + || $ticket->created_by === auth()->id() + || $this->userIsTicketAssignee($ticket); } private function canManageTicket(?Ticket $ticket): bool @@ -449,8 +631,22 @@ private function canManageTicket(?Ticket $ticket): bool } return auth()->user()->hasRole(['super_admin']) - || $ticket->user_id === auth()->id() - || $ticket->assignees()->where('users.id', auth()->id())->exists(); + || $ticket->created_by === auth()->id() + || $this->userIsTicketAssignee($ticket); + } + + public function canMoveTicket(?Ticket $ticket): bool + { + return $this->canManageTicket($ticket); + } + + private function userIsTicketAssignee(Ticket $ticket): bool + { + if ($ticket->relationLoaded('assignees')) { + return $ticket->assignees->contains('id', auth()->id()); + } + + return $ticket->assignees()->where('users.id', auth()->id())->exists(); } public function exportTickets(array $selectedColumns): void @@ -465,21 +661,45 @@ public function exportTickets(array $selectedColumns): void return; } - $tickets = collect(); + if ($this->isGlobalBoard()) { + $insertAfter = array_search('assignee', $selectedColumns, true); + $insertAt = $insertAfter === false ? 0 : $insertAfter + 1; + $requiredProjectColumns = array_values(array_filter( + ['project_code', 'project'], + fn (string $column) => ! in_array($column, $selectedColumns, true) + )); + + if (! empty($requiredProjectColumns)) { + array_splice($selectedColumns, $insertAt, 0, $requiredProjectColumns); + } + } if ($this->selectedProject) { - $tickets = $this->selectedProject->tickets() + $tickets = Ticket::query() + ->where('project_id', $this->selectedProject->id) + ->when(! empty($this->selectedUserIds), function (Builder $query) { + $query->whereHas('assignees', function (Builder $assigneeQuery) { + $assigneeQuery->whereIn('users.id', $this->selectedUserIds); + }); + }) ->with(['assignees', 'status', 'project', 'epic']) ->orderBy('created_at', 'desc') ->get(); - } elseif ($this->ticketStatuses->isNotEmpty()) { - $ticketIds = $this->ticketStatuses->flatMap(function ($status) { - return $status->tickets->pluck('id'); - }); - - $tickets = Ticket::whereIn('id', $ticketIds) + } else { + $projectIds = $this->visibleGlobalProjectIds(); + $tickets = Ticket::query() + ->when( + empty($projectIds), + fn (Builder $query) => $query->whereRaw('1 = 0'), + fn (Builder $query) => $query->whereIn('project_id', $projectIds) + ) + ->when(! empty($this->selectedUserIds), function (Builder $query) { + $query->whereHas('assignees', function (Builder $assigneeQuery) { + $assigneeQuery->whereIn('users.id', $this->selectedUserIds); + }); + }) ->with(['assignees', 'status', 'project', 'epic']) - ->orderBy('created_at', 'asc') + ->orderBy('created_at', 'desc') ->get(); } @@ -494,7 +714,8 @@ public function exportTickets(array $selectedColumns): void } try { - $fileName = 'tickets_'.($this->selectedProject?->name ?? 'export').'_'.now()->format('Y-m-d_H-i-s').'.xlsx'; + $globalFileName = $this->hasGlobalProjectFilter() ? 'selected_projects' : 'all_projects'; + $fileName = 'tickets_'.($this->selectedProject?->name ?? $globalFileName).'_'.now()->format('Y-m-d_H-i-s').'.xlsx'; $fileName = Str::slug($fileName, '_').'.xlsx'; $export = new TicketsExport($tickets, $selectedColumns); Excel::store($export, 'exports/'.$fileName, 'public'); @@ -534,4 +755,423 @@ public function canMoveTickets(): bool { return auth()->user()->can('update_ticket'); } + + public function isGlobalBoard(): bool + { + return $this->boardMode === self::MODE_GLOBAL; + } + + public function isProjectIndex(): bool + { + return $this->boardMode === self::MODE_INDEX; + } + + public function boardTitle(): string + { + if ($this->selectedProject) { + return $this->selectedProject->name; + } + + if ($this->hasGlobalProjectFilter()) { + return 'Selected Projects'; + } + + return 'All Projects'; + } + + public function projectFilterActionLabel(): string + { + if ($this->isProjectIndex()) { + return 'Selected Projects'; + } + + if ($this->hasGlobalProjectFilter()) { + return 'Projects ('.$this->globalProjectFilterCount().')'; + } + + return 'Projects'; + } + + public function selectedGlobalProjectBadges(): Collection + { + $projectIds = $this->normalizedGlobalProjectFilterIds($this->selectedGlobalProjectIds); + + if (empty($projectIds)) { + return collect(); + } + + return $this->projects + ->whereIn('id', $projectIds) + ->map(fn (Project $project) => $this->projectBadge($project)) + ->values(); + } + + public function hasGlobalProjectFilter(): bool + { + return $this->isGlobalBoard() && $this->globalProjectFilterCount() > 0; + } + + public function globalProjectFilterCount(): int + { + return count($this->normalizedGlobalProjectFilterIds($this->selectedGlobalProjectIds)); + } + + public function getReadableTextColor(?string $color): string + { + $hex = ltrim($color ?: '#6B7280', '#'); + + if (strlen($hex) !== 6) { + return '#FFFFFF'; + } + + $red = hexdec(substr($hex, 0, 2)); + $green = hexdec(substr($hex, 2, 2)); + $blue = hexdec(substr($hex, 4, 2)); + $brightness = (($red * 299) + ($green * 587) + ($blue * 114)) / 1000; + + return $brightness > 155 ? '#1F2937' : '#FFFFFF'; + } + + private function globalBoardColumns(): Collection + { + $projectIds = $this->visibleGlobalProjectIds(); + + if (empty($projectIds)) { + return collect(); + } + + $statusGroups = TicketStatus::query() + ->whereIn('project_id', $projectIds) + ->select('id', 'project_id', 'name', 'color', 'sort_order', 'is_completed') + ->orderBy('sort_order') + ->orderBy('name') + ->get() + ->groupBy('name') + ->sortBy(function (Collection $statuses, string $name) { + return str_pad((string) ($statuses->min('sort_order') ?? 0), 10, '0', STR_PAD_LEFT).'_'.$name; + }); + + return $statusGroups->map(function (Collection $statuses, string $statusName) { + $columnId = $this->globalColumnId($statusName); + $sortOrder = $this->sortOrders[$columnId] ?? 'due_date'; + $statusIds = $statuses->pluck('id')->all(); + $baseQuery = $this->globalTicketsQuery($statusIds); + $uniqueColors = $statuses->pluck('color')->filter()->unique()->values(); + $projectBadges = $this->projects + ->whereIn('id', $statuses->pluck('project_id')->unique()) + ->map(fn (Project $project) => $this->projectBadge($project)) + ->values(); + + return (object) [ + 'id' => $columnId, + 'name' => $statusName, + 'color' => $uniqueColors->count() === 1 ? $uniqueColors->first() : '#4B5563', + 'is_completed' => $statuses->contains(fn (TicketStatus $status) => $status->is_completed), + 'project_ids' => $statuses->pluck('project_id')->unique()->values(), + 'project_badges' => $projectBadges, + 'project_count' => $statuses->pluck('project_id')->unique()->count(), + 'tickets_count' => (clone $baseQuery)->count(), + 'tickets' => $this->applyQuerySorting( + (clone $baseQuery) + ->with($this->ticketRelations()) + ->select($this->ticketSelectColumns()), + $sortOrder + ) + ->limit($this->getColumnLimit($columnId)) + ->get(), + ]; + })->values(); + } + + private function projectTicketsQuery(int $statusId): Builder + { + return Ticket::query() + ->where('project_id', $this->selectedProject->id) + ->where('ticket_status_id', $statusId) + ->when(! empty($this->selectedUserIds), function (Builder $query) { + $query->whereHas('assignees', function (Builder $assigneeQuery) { + $assigneeQuery->whereIn('users.id', $this->selectedUserIds); + }); + }); + } + + private function globalTicketsQuery(array $statusIds): Builder + { + return Ticket::query() + ->when( + empty($statusIds), + fn (Builder $query) => $query->whereRaw('1 = 0'), + fn (Builder $query) => $query->whereIn('ticket_status_id', $statusIds) + ) + ->when(! empty($this->selectedUserIds), function (Builder $query) { + $query->whereHas('assignees', function (Builder $assigneeQuery) { + $assigneeQuery->whereIn('users.id', $this->selectedUserIds); + }); + }); + } + + private function accessibleProjectIds(): array + { + return $this->projects->pluck('id')->all(); + } + + private function visibleGlobalProjectIds(): array + { + if (! $this->isGlobalBoard()) { + return []; + } + + $filteredProjectIds = $this->normalizedGlobalProjectFilterIds($this->selectedGlobalProjectIds); + + if (empty($filteredProjectIds)) { + return $this->accessibleProjectIds(); + } + + return $filteredProjectIds; + } + + private function projectFilterOptionLabels(): array + { + return $this->projects + ->mapWithKeys(fn (Project $project) => [ + $project->id => $this->projectFilterOptionLabel($project), + ]) + ->toArray(); + } + + private function projectFilterOptionLabel(Project $project): string + { + $badge = $this->projectBadge($project); + $pinnedIcon = $badge['is_pinned'] + ? sprintf( + '', + e($badge['color']), + ) + : ''; + + return sprintf( + '%s%s%s', + $pinnedIcon, + e($badge['color']), + e($badge['text_color']), + e($badge['code']), + e($badge['name']), + ); + } + + private function projectBadge(Project $project): array + { + $color = $this->safeProjectColor($project->color); + + return [ + 'id' => $project->id, + 'name' => $project->name, + 'code' => $this->projectCode($project), + 'color' => $color, + 'is_pinned' => $project->is_pinned, + 'text_color' => $this->getReadableTextColor($color), + ]; + } + + private function safeProjectColor(?string $color): string + { + return preg_match('/^#[0-9A-Fa-f]{6}$/', $color ?? '') ? $color : '#6B7280'; + } + + private function navigateToBoardUrl(?string $projectId = null): void + { + $url = $projectId === null + ? static::getUrl() + : static::getUrl(['project_id' => $projectId]); + + $this->js("Livewire.navigate('{$url}')"); + } + + private function globalRouteParameter(): string + { + $projectIds = $this->normalizedGlobalProjectFilterIds($this->selectedGlobalProjectIds); + + if (empty($projectIds)) { + return self::ALL_PROJECT_ROUTE; + } + + return self::SELECTED_PROJECTS_ROUTE_PREFIX.implode(',', $projectIds); + } + + private function selectedProjectIdsFromRoute(string $routeProjectId): array + { + $projectIds = Str::after($routeProjectId, self::SELECTED_PROJECTS_ROUTE_PREFIX); + + return collect(preg_split('/[,-]+/', $projectIds) ?: []) + ->filter(fn (string $projectId) => ctype_digit($projectId)) + ->map(fn (string $projectId) => (int) $projectId) + ->values() + ->all(); + } + + private function restorePersistedGlobalProjectFilter(): bool + { + $projectIds = session(self::SESSION_SELECTED_GLOBAL_PROJECT_IDS, []); + + if (! is_array($projectIds)) { + $this->clearPersistedGlobalProjectFilter(); + + return false; + } + + $projectIds = $this->normalizedGlobalProjectFilterIds($projectIds); + + if (empty($projectIds)) { + $this->clearPersistedGlobalProjectFilter(); + + return false; + } + + $this->selectSelectedProjects($projectIds, navigate: false); + + return true; + } + + private function persistGlobalProjectFilter(): void + { + $projectIds = $this->normalizedGlobalProjectFilterIds($this->selectedGlobalProjectIds); + + if (empty($projectIds)) { + $this->clearPersistedGlobalProjectFilter(); + + return; + } + + session([self::SESSION_SELECTED_GLOBAL_PROJECT_IDS => $projectIds]); + } + + private function clearPersistedGlobalProjectFilter(): void + { + session()->forget(self::SESSION_SELECTED_GLOBAL_PROJECT_IDS); + } + + private function normalizedGlobalProjectFilterIds(array $projectIds): array + { + $projectIds = $this->sanitizeProjectIds($projectIds); + + if (count($projectIds) === count($this->accessibleProjectIds())) { + return []; + } + + return $projectIds; + } + + private function sanitizeProjectIds(array $projectIds): array + { + $accessibleProjectIds = collect($this->accessibleProjectIds()) + ->map(fn ($projectId) => (int) $projectId) + ->values(); + + return collect($projectIds) + ->map(fn ($projectId) => (int) $projectId) + ->unique() + ->filter(fn (int $projectId) => $accessibleProjectIds->containsStrict($projectId)) + ->values() + ->all(); + } + + private function resolveTargetStatus(Ticket $ticket, string $targetStatusId): ?TicketStatus + { + if (is_numeric($targetStatusId)) { + return TicketStatus::query() + ->where('id', (int) $targetStatusId) + ->where('project_id', $ticket->project_id) + ->first(); + } + + $targetStatusName = $this->globalColumnName($targetStatusId); + + if ($targetStatusName === null) { + return null; + } + + return TicketStatus::query() + ->where('project_id', $ticket->project_id) + ->where('name', $targetStatusName) + ->orderBy('sort_order') + ->orderBy('id') + ->first(); + } + + private function globalColumnId(string $statusName): string + { + return 'status:'.rtrim(strtr(base64_encode($statusName), '+/', '-_'), '='); + } + + private function globalColumnName(string $columnId): ?string + { + if (! str_starts_with($columnId, 'status:')) { + return null; + } + + $encoded = strtr(substr($columnId, strlen('status:')), '-_', '+/'); + $encoded .= str_repeat('=', (4 - strlen($encoded) % 4) % 4); + $decoded = base64_decode($encoded, true); + + return $decoded === false ? null : $decoded; + } + + public function projectCode(?Project $project): string + { + if (! $project) { + return 'PRJ'; + } + + if ($project->ticket_prefix) { + return $project->ticket_prefix; + } + + $words = str($project->name) + ->replaceMatches('/[^A-Za-z0-9\s]/', ' ') + ->squish() + ->explode(' ') + ->filter(); + + if ($words->count() <= 1) { + return str($project->name)->upper()->substr(0, 4)->toString(); + } + + return $words + ->take(4) + ->map(fn (string $word) => str($word)->substr(0, 1)->upper()->toString()) + ->implode(''); + } + + private function ticketRelations(): array + { + return [ + 'assignees:id,name', + 'status:id,project_id,name,color,is_completed', + 'priority:id,name,color', + 'creator:id,name', + 'project:id,name,ticket_prefix,color', + ]; + } + + private function ticketSelectColumns(): array + { + return [ + 'id', + 'project_id', + 'ticket_status_id', + 'priority_id', + 'name', + 'description', + 'uuid', + 'due_date', + 'created_at', + 'updated_at', + 'created_by', + ]; + } + + private function getColumnLimit(int|string $columnId): int + { + return $this->columnLimits[(string) $columnId] ?? self::DEFAULT_COLUMN_LIMIT; + } } diff --git a/resources/views/filament/pages/project-board.blade.php b/resources/views/filament/pages/project-board.blade.php index c1da320..59c6a0d 100644 --- a/resources/views/filament/pages/project-board.blade.php +++ b/resources/views/filament/pages/project-board.blade.php @@ -1,216 +1,335 @@ + @if(! $this->isProjectIndex()) + @php + $selectedProjectBadges = $this->selectedGlobalProjectBadges(); + @endphp + +
+
+ + +