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..780c222 100644 --- a/app/Filament/Pages/ProjectBoard.php +++ b/app/Filament/Pages/ProjectBoard.php @@ -5,14 +5,23 @@ use App\Exports\TicketsExport; use App\Filament\Actions\ExportTicketsAction; use App\Filament\Resources\Tickets\TicketResource; +use App\Models\Epic; use App\Models\Project; use App\Models\Ticket; +use App\Models\TicketPriority; +use App\Models\TicketStatus; use App\Models\User; use Exception; use Filament\Actions\Action; use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\RichEditor; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Filament\Schemas\Schema; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Livewire\Attributes\Computed; @@ -21,6 +30,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 +75,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 +156,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 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): void + 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 +291,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 +333,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 +363,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')] @@ -272,6 +417,11 @@ public function refreshBoard(): void { $this->loadTicketStatuses(); $this->dispatch('ticket-updated'); + + Notification::make() + ->title('Board Refreshed') + ->success() + ->send(); } public function showTicketDetails(int $ticketId): void @@ -287,7 +437,7 @@ public function showTicketDetails(int $ticketId): void return; } - $url = TicketResource::getUrl('view', ['record' => $ticketId]); + $url = $this->ticketViewUrl($ticketId); $this->js("window.open('{$url}', '_blank')"); } @@ -320,12 +470,25 @@ protected function getHeaderActions(): array ->name('ticket_on_board') ->label('New Ticket') ->icon('heroicon-m-plus') - ->visible(fn () => $this->selectedProject !== null && auth()->user()->can('create_ticket')) - ->schema(fn ($schema) => TicketResource::form($schema) - ->columns(3) - ) + ->visible(fn () => ! $this->isProjectIndex() && auth()->user()->can('create_ticket')) + ->schema(function ($schema) { + // When in global board mode, we need a project selector first + if ($this->isGlobalBoard()) { + return $this->globalBoardTicketForm($schema); + } + + return TicketResource::form($schema)->columns(3); + }) ->model(Ticket::class) ->fillForm(function () { + if ($this->isGlobalBoard()) { + return [ + 'project_id' => null, + 'ticket_status_id' => null, + 'assignees' => [], + ]; + } + $assignees = []; // Auto-assign current user if they're a project member @@ -339,36 +502,94 @@ protected function getHeaderActions(): array 'ticket_status_id' => $this->ticketStatuses?->first()?->id, 'assignees' => $assignees, ]; - }) ->action(function (array $data, $schema) { $data['created_by'] = auth()->id(); - $model = $schema->getModel(); + // Extract assignees — not a DB column, handle separately + $assigneeIds = $data['assignees'] ?? []; + unset($data['assignees']); + $model = $schema->getModel(); $record = $model::create($data); - $schema->model($record)->saveRelationships(); + // Sync assignees to pivot table + if (! empty($assigneeIds)) { + $record->assignees()->sync($assigneeIds); + } elseif (! empty($data['project_id'])) { + // Auto-assign current user if they're a project member + $project = Project::find($data['project_id']); + if ($project && $project->members()->where('users.id', auth()->id())->exists()) { + $record->assignees()->sync([auth()->id()]); + } + } Notification::make() ->title('Ticket Created') ->body('The ticket has been created successfully.') ->success() ->send(); + + $this->loadTicketStatuses(); + $this->dispatch('ticket-updated'); }), Action::make('refresh_board') ->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 +636,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 +656,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 +670,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 +700,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 +753,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 +794,568 @@ 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 ticketViewUrl(int $ticketId): string + { + $parameters = ['record' => $ticketId]; + + if ($from = $this->currentBoardRouteParameter()) { + $parameters['from'] = $from; + } + + return TicketResource::getUrl('view', $parameters); + } + + private function currentBoardRouteParameter(): ?string + { + if ($this->isGlobalBoard()) { + return str_replace(',', '-', $this->globalRouteParameter()); + } + + if ($this->selectedProjectId) { + return (string) $this->selectedProjectId; + } + + return null; + } + + 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; + } + + private function globalBoardTicketForm(Schema $schema): Schema + { + // Resolve which project IDs are visible based on current filter + $visibleProjectIds = $this->visibleGlobalProjectIds(); + + $projectOptions = $this->projects + ->when( + ! empty($visibleProjectIds), + fn (Collection $projects) => $projects->whereIn('id', $visibleProjectIds) + ) + ->pluck('name', 'id') + ->toArray(); + + return $schema + ->columns(3) + ->components([ + Select::make('project_id') + ->label('Project') + ->options($projectOptions) + ->required() + ->searchable() + ->live() + ->afterStateUpdated(function (callable $set) { + $set('ticket_status_id', null); + $set('assignees', []); + $set('epic_id', null); + }) + ->columnSpanFull(), + + Select::make('ticket_status_id') + ->label('Status') + ->options(function (callable $get) { + $projectId = $get('project_id'); + if (! $projectId) { + return []; + } + + return TicketStatus::where('project_id', $projectId) + ->pluck('name', 'id') + ->toArray(); + }) + ->required() + ->searchable() + ->hidden(fn (callable $get): bool => ! $get('project_id')), + + Select::make('priority_id') + ->label('Priority') + ->options(TicketPriority::pluck('name', 'id')->toArray()) + ->searchable() + ->nullable() + ->hidden(fn (callable $get): bool => ! $get('project_id')), + + Select::make('epic_id') + ->label('Epic') + ->options(function (callable $get) { + $projectId = $get('project_id'); + if (! $projectId) { + return []; + } + + return Epic::where('project_id', $projectId) + ->pluck('name', 'id') + ->toArray(); + }) + ->searchable() + ->nullable() + ->hidden(fn (callable $get): bool => ! $get('project_id')), + + TextInput::make('name') + ->label('Ticket Name') + ->required() + ->maxLength(255) + ->hidden(fn (callable $get): bool => ! $get('project_id')) + ->columnSpanFull(), + + Select::make('assignees') + ->label('Assigned to') + ->multiple() + ->options(function (callable $get) { + $projectId = $get('project_id'); + if (! $projectId) { + return []; + } + + $project = Project::find($projectId); + if (! $project) { + return []; + } + + return $project->members() + ->orderBy('name') + ->pluck('name', 'users.id') + ->toArray(); + }) + ->searchable() + ->live() + ->hidden(fn (callable $get): bool => ! $get('project_id')) + ->helperText('Only project members can be assigned.'), + + DatePicker::make('start_date') + ->label('Start Date') + ->default(now()) + ->nullable() + ->hidden(fn (callable $get): bool => ! $get('project_id')), + + DatePicker::make('due_date') + ->label('Due Date') + ->nullable() + ->hidden(fn (callable $get): bool => ! $get('project_id')), + + RichEditor::make('description') + ->label('Description') + ->fileAttachmentsDisk('public') + ->fileAttachmentsDirectory('attachments') + ->fileAttachmentsVisibility('public') + ->nullable() + ->hidden(fn (callable $get): bool => ! $get('project_id')) + ->columnSpanFull(), + ]); + } } diff --git a/app/Filament/Resources/Tickets/Pages/ViewTicket.php b/app/Filament/Resources/Tickets/Pages/ViewTicket.php index 2cca77b..264fb2b 100644 --- a/app/Filament/Resources/Tickets/Pages/ViewTicket.php +++ b/app/Filament/Resources/Tickets/Pages/ViewTicket.php @@ -6,6 +6,7 @@ use App\Filament\Resources\Tickets\TicketResource; use App\Models\Ticket; use App\Models\TicketComment; +use App\Models\TicketStatus; use Filament\Actions\Action; use Filament\Actions\EditAction; use Filament\Forms\Components\Hidden; @@ -16,7 +17,7 @@ use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; -use Illuminate\Support\HtmlString; +use Illuminate\Support\Str; class ViewTicket extends ViewRecord { @@ -24,6 +25,59 @@ class ViewTicket extends ViewRecord public ?int $editingCommentId = null; + public function canChangeStatus(): bool + { + $ticket = $this->getRecord(); + + return auth()->user()->hasRole(['super_admin']) + || $ticket->created_by === auth()->id() + || $ticket->assignees()->where('users.id', auth()->id())->exists(); + } + + public function updateStatus(int $statusId): void + { + $ticket = $this->getRecord(); + + if (! $this->canChangeStatus()) { + Notification::make() + ->title('You do not have permission to change this ticket status') + ->danger() + ->send(); + + return; + } + + if ($ticket->ticket_status_id === $statusId) { + return; + } + + $targetStatus = TicketStatus::query() + ->where('project_id', $ticket->project_id) + ->whereKey($statusId) + ->first(); + + if (! $targetStatus) { + Notification::make() + ->title('Status Not Found') + ->body("This ticket's project does not have that status.") + ->warning() + ->send(); + + return; + } + + $ticket->update([ + 'ticket_status_id' => $targetStatus->id, + ]); + + $ticket->load('status'); + + Notification::make() + ->title('Status changed to '.$targetStatus->name) + ->success() + ->send(); + } + public function editCommentAction(): Action { return Action::make('editComment') @@ -40,7 +94,7 @@ public function editCommentAction(): Action ->fillForm(function (array $arguments): array { $comment = TicketComment::find($arguments['commentId']); - if (!$comment) { + if (! $comment) { return []; } @@ -52,7 +106,7 @@ public function editCommentAction(): Action ->action(function (array $data): void { $comment = TicketComment::find($data['comment_id']); - if (!$comment) { + if (! $comment) { Notification::make() ->title('Comment not found') ->danger() @@ -61,7 +115,7 @@ public function editCommentAction(): Action return; } - if ($comment->user_id !== auth()->id() && !auth()->user()->hasRole(['super_admin'])) { + if ($comment->user_id !== auth()->id() && ! auth()->user()->hasRole(['super_admin'])) { Notification::make() ->title('You do not have permission to edit this comment') ->danger() @@ -98,7 +152,7 @@ public function deleteCommentAction(): Action ->action(function (array $arguments): void { $comment = TicketComment::find($arguments['commentId']); - if (!$comment) { + if (! $comment) { Notification::make() ->title('Comment not found') ->danger() @@ -107,7 +161,7 @@ public function deleteCommentAction(): Action return; } - if ($comment->user_id !== auth()->id() && !auth()->user()->hasRole(['super_admin'])) { + if ($comment->user_id !== auth()->id() && ! auth()->user()->hasRole(['super_admin'])) { Notification::make() ->title('You do not have permission to delete this comment') ->danger() @@ -134,8 +188,9 @@ protected function convertVideoImgsToVideoTags($html) return preg_replace_callback($pattern, function ($matches) { $videoUrl = $matches[1]; + return ''; }, $html); @@ -153,13 +208,75 @@ protected function getHeaderActions(): array || $ticket->assignees()->where('users.id', auth()->id())->exists(); }), + Action::make('copy_url') + ->label('Copy URL') + ->icon('heroicon-o-clipboard-document') + ->color('gray') + ->alpineClickHandler(fn () => $this->copyTicketUrlScript()), + Action::make('back') ->label('Back to Board') ->color('gray') - ->url(fn() => ProjectBoard::getUrl(['project_id' => $this->record->project_id])), + ->url(fn () => $this->getBackToBoardUrl()), ]; } + private function getBackToBoardUrl(): string + { + $from = request()->query('from'); + + if (is_string($from)) { + if ($this->isProjectBoardRouteParameter($from)) { + return ProjectBoard::getUrl(['project_id' => $from]); + } + + if ($this->isProjectBoardUrl($from)) { + return $from; + } + } + + return ProjectBoard::getUrl(['project_id' => $this->record->project_id]); + } + + private function getTicketUrl(): string + { + return TicketResource::getUrl('view', ['record' => $this->record]); + } + + private function copyTicketUrlScript(): string + { + $url = str_replace(['\\', "'"], ['\\\\', "\\'"], $this->getTicketUrl()); + + return "(() => { const url = '{$url}'; const notify = (copied) => window.FilamentNotification && new FilamentNotification().title(copied ? 'Ticket URL copied' : 'Unable to copy ticket URL')[copied ? 'success' : 'danger']().send(); const fallback = () => { const textarea = document.createElement('textarea'); textarea.value = url; textarea.setAttribute('readonly', ''); textarea.style.position = 'fixed'; textarea.style.top = '-1000px'; textarea.style.left = '-1000px'; document.body.appendChild(textarea); textarea.select(); const copied = document.execCommand('copy'); textarea.remove(); notify(copied); }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(url).then(() => notify(true)).catch(fallback); } else { fallback(); } })()"; + } + + private function isProjectBoardRouteParameter(string $from): bool + { + return $from === 'all-project' + || ctype_digit($from) + || preg_match('/^selected-projects-\d+(?:[,-]\d+)*$/', $from) === 1; + } + + private function isProjectBoardUrl(string $url): bool + { + $boardUrl = ProjectBoard::getUrl(); + $boardPath = parse_url($boardUrl, PHP_URL_PATH); + $fromPath = parse_url($url, PHP_URL_PATH); + + if (! is_string($boardPath) || ! is_string($fromPath)) { + return false; + } + + $fromHost = parse_url($url, PHP_URL_HOST); + $boardHost = parse_url($boardUrl, PHP_URL_HOST); + + if (is_string($fromHost) && $fromHost !== '' && $fromHost !== request()->getHost() && $fromHost !== $boardHost) { + return false; + } + + return $fromPath === $boardPath || Str::startsWith($fromPath, rtrim($boardPath, '/').'/'); + } + public function infolist(Schema $schema): Schema { return $schema @@ -192,16 +309,8 @@ public function infolist(Schema $schema): Schema ->schema([ TextEntry::make('status.name') ->label('Status') - ->formatStateUsing(function ($record) { - $color = e($record->status?->color ?? '#6B7280'); - $name = e($record->status?->name ?? 'Unknown'); - - return new HtmlString(<< - {$name} - - HTML); - }), + ->hiddenLabel() + ->view('filament.resources.ticket-resource.status-switcher'), TextEntry::make('assignees.name') ->label('Assigned To') @@ -219,7 +328,7 @@ public function infolist(Schema $schema): Schema ->label('Due Date') ->date('d M Y') ->icon('heroicon-o-calendar') - ->color(fn($record) => $record->due_date && $record->due_date->isPast() ? 'danger' : 'success'), + ->color(fn ($record) => $record->due_date && $record->due_date->isPast() ? 'danger' : 'success'), ]), ]), diff --git a/resources/css/app.css b/resources/css/app.css index ee7ef3a..2dd71a5 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -12,6 +12,25 @@ 'Segoe UI Symbol', 'Noto Color Emoji'; } +/* Prose link styles - ensure links in comments are visible */ +.prose a { + color: var(--color-primary-600, #4f46e5); + text-decoration: underline; + text-underline-offset: 2px; +} + +.dark .prose a { + color: var(--color-primary-400, #818cf8); +} + +.prose a:hover { + color: var(--color-primary-800, #3730a3); +} + +.dark .prose a:hover { + color: var(--color-primary-300, #a5b4fc); +} + /* View-only mode styles */ .view-only-mode .ticket-card { cursor: default !important; diff --git a/resources/views/filament/pages/project-board.blade.php b/resources/views/filament/pages/project-board.blade.php index c1da320..ab9399c 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 + +
+
+ + +