diff --git a/app/Filament/Pages/JiraImport.php b/app/Filament/Pages/JiraImport.php index fe56abe56..c7367d0b4 100644 --- a/app/Filament/Pages/JiraImport.php +++ b/app/Filament/Pages/JiraImport.php @@ -19,6 +19,7 @@ use Illuminate\Contracts\Support\Htmlable; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; +use App\Settings\JiraSettings; class JiraImport extends Page implements HasForms { @@ -48,10 +49,17 @@ class JiraImport extends Page implements HasForms public $selected_tickets; public $data = []; public $ticketsDataApi; + public $epicKeys = []; + public $taskKeys = []; public function mount(): void { - $this->form->fill(); + $settings = app(JiraSettings::class); + $this->form->fill([ + 'host' => $settings->host, + 'username' => $settings->username, + 'token' => $settings->token, + ]); } public static function shouldRegisterNavigation(): bool @@ -87,7 +95,7 @@ public function getFormSchema(): array 'class' => 'bg-primary-500 rounded-lg border border-primary-600 text-white font-medium text-sm py-3 px-4' ]) ->hiddenLabel() - ->content(__('Important: Your jira credentials are only used to communicate with jira REST API, and will not be stored in this application')), + ->content(__('Your Jira credentials are used to communicate with the Jira REST API. They will be saved (with the API token encrypted) so you do not need to re-enter them each time.')), Grid::make() ->schema([ @@ -109,8 +117,14 @@ public function getFormSchema(): array ]), ]) ->afterValidation(function () { + $settings = app(JiraSettings::class); + $settings->host = $this->host; + $settings->username = $this->username; + $settings->token = $this->token; + $settings->save(); + $this->loadingProjects = true; - $this->emit('updateJiraProjects'); + $this->dispatch('updateJiraProjects'); }), Wizard\Step::make(__('Jira projects')) @@ -164,7 +178,7 @@ public function getFormSchema(): array ]) ->afterValidation(function () { $this->loadingTickets = true; - $this->emit('updateJiraTickets'); + $this->dispatch('updateJiraTickets'); }), Wizard\Step::make(__('Jira tickets')) @@ -187,6 +201,18 @@ public function getFormSchema(): array ->visible(fn() => $this->loadingTickets) ->content(__('Loading tickets, please wait...')); + if (!$this->loadingTickets && $this->tickets) { + $fields[] = Placeholder::make('selection_buttons') + ->hiddenLabel() + ->content(new HtmlString( + "
" + . "" + . "" + . "" + . "
" + )); + } + if (!$this->loadingTickets) { if ($this->tickets) { foreach ($this->tickets as $projectKey => $ticket) { @@ -201,9 +227,13 @@ public function getFormSchema(): array foreach ($ticket['issues'] as $issue) { $fields[] = Checkbox::make('data.' . Str::slug($projectKey) . '_' . Str::slug($issue['code'])) ->label(function () use ($issue) { + $epicBadge = !empty($issue['isEpic']) + ? "EPIC " + : ''; return new HtmlString( "
" . "
" + . $epicBadge . "
" . $issue['code'] . " " . $issue['name'] . "
" . "
" . "
" @@ -239,8 +269,13 @@ public function import(): void if ($this->data && sizeof($this->data)) { $tickets = []; foreach (array_keys($this->data) as $item) { - $url = $this->ticketsDataApi[$item]; - $tickets[] = $this->getJiraTicketDetails($this->host, $this->username, $this->token, $url); + $url = $this->ticketsDataApi[$item] ?? null; + if ($url) { + $ticket = $this->getJiraTicketDetails($this->host, $this->username, $this->token, $url); + if ($ticket) { + $tickets[] = $ticket; + } + } } dispatch(new ImportJiraTicketsJob($tickets, auth()->user())); Notification::make() @@ -266,13 +301,42 @@ public function updateJiraProjects(): void public function updateJiraTickets(): void { $this->ticketsDataApi = []; + $this->epicKeys = []; + $this->taskKeys = []; $client = $this->connectToJira($this->host, $this->username, $this->token); $this->tickets = $this->getJiraTicketsByProject($client, $this->selected_projects); - foreach ($this->tickets as $projectKey => $ticket) { - foreach ($ticket['issues'] as $issue) { - $this->ticketsDataApi[Str::slug($projectKey) . '_' . Str::slug($issue['code'])] = $issue['data']->self; + if ($this->tickets) { + foreach ($this->tickets as $projectKey => $ticket) { + foreach ($ticket['issues'] as $issue) { + $key = Str::slug($projectKey) . '_' . Str::slug($issue['code']); + $this->ticketsDataApi[$key] = $issue['data']->self; + if (!empty($issue['isEpic'])) { + $this->epicKeys[] = $key; + } else { + $this->taskKeys[] = $key; + } + } } } $this->loadingTickets = false; } + + public function selectAllEpics(): void + { + foreach ($this->epicKeys as $key) { + $this->data[$key] = true; + } + } + + public function selectAllTasks(): void + { + foreach ($this->taskKeys as $key) { + $this->data[$key] = true; + } + } + + public function deselectAll(): void + { + $this->data = []; + } } diff --git a/app/Filament/Pages/ManageJiraSettings.php b/app/Filament/Pages/ManageJiraSettings.php new file mode 100644 index 000000000..71f5cfb3c --- /dev/null +++ b/app/Filament/Pages/ManageJiraSettings.php @@ -0,0 +1,77 @@ +user()->can('Import from Jira'); + } + + public function getHeading(): string|Htmlable + { + return __('Manage Jira settings'); + } + + public static function getNavigationLabel(): string + { + return __('Jira'); + } + + public static function getNavigationGroup(): ?string + { + return __('Settings'); + } + + public function form(Schema $schema): Schema + { + return $schema->columns(1)->components([ + Section::make(__('Jira credentials')) + ->description(__('Configure your Jira connection credentials. The API token is stored encrypted.')) + ->columnSpanFull() + ->schema([ + Grid::make(2) + ->schema([ + TextInput::make('host') + ->label(__('Host')) + ->helperText(__('The URL used to access your Jira account (e.g. https://yourcompany.atlassian.net)')) + ->required(), + + TextInput::make('username') + ->label(__('Username')) + ->helperText(__('Your Jira account username (email)')) + ->required(), + + TextInput::make('token') + ->label(__('API Token')) + ->helperText(__('Your Jira account API token')) + ->password() + ->required(), + ]), + ]), + ]); + } + + public function getSaveFormAction(): Action + { + return parent::getSaveFormAction()->label(__('Save')); + } +} diff --git a/app/Filament/Pages/TimesheetDashboard.php b/app/Filament/Pages/TimesheetDashboard.php index 2c21f8d8b..e526c9b88 100644 --- a/app/Filament/Pages/TimesheetDashboard.php +++ b/app/Filament/Pages/TimesheetDashboard.php @@ -5,16 +5,14 @@ use App\Filament\Widgets\Timesheet\ActivitiesReport; use App\Filament\Widgets\Timesheet\MonthlyReport; use App\Filament\Widgets\Timesheet\WeeklyReport; -use Filament\Pages\Page; +use Filament\Pages\Dashboard; -class TimesheetDashboard extends Page +class TimesheetDashboard extends Dashboard { protected static ?string $slug = 'timesheet-dashboard'; protected static ?int $navigationSort = 2; - protected string $view = 'filament::pages.dashboard'; - public function getColumns(): int | array { return 6; diff --git a/app/Filament/Resources/TicketResource/Pages/ViewTicket.php b/app/Filament/Resources/TicketResource/Pages/ViewTicket.php index f7e0c89e6..fcb19a163 100644 --- a/app/Filament/Resources/TicketResource/Pages/ViewTicket.php +++ b/app/Filament/Resources/TicketResource/Pages/ViewTicket.php @@ -108,14 +108,14 @@ public function getHeaderActions(): array ->label(__('Activity')) ->searchable() ->live() - ->options(function (\Filament\Forms\Get $get, \Filament\Forms\Set $set) { + ->options(function (\Filament\Schemas\Components\Utilities\Get $get, \Filament\Schemas\Components\Utilities\Set $set) { return Activity::all()->pluck('name', 'id')->toArray(); }), Textarea::make('comment') ->label(__('Comment')) ->rows(3), ]) - ->action(function (Collection $records, array $data): void { + ->action(function (array $data): void { $value = $data['time']; $comment = $data['comment']; TicketHour::create([ diff --git a/app/Filament/Widgets/Timesheet/ActivitiesReport.php b/app/Filament/Widgets/Timesheet/ActivitiesReport.php index d45319496..269082940 100644 --- a/app/Filament/Widgets/Timesheet/ActivitiesReport.php +++ b/app/Filament/Widgets/Timesheet/ActivitiesReport.php @@ -19,7 +19,13 @@ class ActivitiesReport extends ChartWidget 'lg' => 3 ]; - public ?string $filter = '2023'; + public ?string $filter = null; + + public function mount(): void + { + $this->filter = (string) Carbon::now()->year; + parent::mount(); + } public function getHeading(): string { @@ -33,16 +39,23 @@ public function getType(): string public function getFilters(): ?array { - return [ - 2022 => 2022, - 2023 => 2023 - ]; + $currentYear = (int) Carbon::now()->year; + $firstYear = (int) (TicketHour::min('created_at') + ? Carbon::parse(TicketHour::min('created_at'))->year + : $currentYear); + + $years = []; + for ($year = $firstYear; $year <= $currentYear; $year++) { + $years[(string) $year] = (string) $year; + } + + return $years; } public function getData(): array { $collection = $this->filter(auth()->user(), [ - 'year' => $this->filter + 'year' => $this->filter ?: Carbon::now()->year ]); $datasets = $this->getDatasets($collection); diff --git a/app/Filament/Widgets/Timesheet/MonthlyReport.php b/app/Filament/Widgets/Timesheet/MonthlyReport.php index f9e13eb8a..c2831dc44 100644 --- a/app/Filament/Widgets/Timesheet/MonthlyReport.php +++ b/app/Filament/Widgets/Timesheet/MonthlyReport.php @@ -18,7 +18,13 @@ public function getHeading(): string return __('Logged time monthly'); } - public ?string $filter = '2023'; + public ?string $filter = null; + + public function mount(): void + { + $this->filter = (string) Carbon::now()->year; + parent::mount(); + } public function getType(): string { @@ -52,10 +58,17 @@ public function getData(): array public function getFilters(): ?array { - return [ - 2022 => 2022, - 2023 => 2023 - ]; + $currentYear = (int) Carbon::now()->year; + $firstYear = (int) (TicketHour::min('created_at') + ? Carbon::parse(TicketHour::min('created_at'))->year + : $currentYear); + + $years = []; + for ($year = $firstYear; $year <= $currentYear; $year++) { + $years[(string) $year] = (string) $year; + } + + return $years; } protected ?array $options = [ diff --git a/app/Filament/Widgets/Timesheet/WeeklyReport.php b/app/Filament/Widgets/Timesheet/WeeklyReport.php index 1785982c0..5509d6180 100644 --- a/app/Filament/Widgets/Timesheet/WeeklyReport.php +++ b/app/Filament/Widgets/Timesheet/WeeklyReport.php @@ -20,13 +20,12 @@ class WeeklyReport extends ChartWidget 'lg' => 3 ]; - public function __construct($id = null) + public function mount(): void { - $weekDaysData = $this->getWeekStartAndFinishDays(); + parent::mount(); + $weekDaysData = $this->getWeekStartAndFinishDays(); $this->filter = $weekDaysData['weekStartDate'] . ' - ' . $weekDaysData['weekEndDate']; - - parent::__construct($id); } public function getHeading(): string @@ -41,6 +40,11 @@ public function getType(): string public function getData(): array { + if (empty($this->filter) || !str_contains($this->filter, ' - ')) { + $weekDefaults = $this->getWeekStartAndFinishDays(); + $this->filter = $weekDefaults['weekStartDate'] . ' - ' . $weekDefaults['weekEndDate']; + } + $weekDaysData = explode(' - ', $this->filter); $collection = $this->filter(auth()->user(), [ diff --git a/app/Helpers/JiraHelper.php b/app/Helpers/JiraHelper.php index 804f10ede..4f45f932b 100644 --- a/app/Helpers/JiraHelper.php +++ b/app/Helpers/JiraHelper.php @@ -24,7 +24,7 @@ public function connectToJira($host, $username, $token): Client|null public function getJiraProjects(Client $client): array|null { try { - $response = $client->get('/rest/api/2/project'); + $response = $client->get('/rest/api/3/project'); return json_decode($response->getBody()->getContents()); } catch (GuzzleException $e) { Log::error($e->getTraceAsString()); @@ -41,6 +41,7 @@ public function getJiraTicketsByProject(Client $client, $projectKeys): array|nul $results[] = [ 'code' => $issue->key, 'name' => $issue->fields->summary, + 'isEpic' => isset($issue->fields->issuetype) && strtolower($issue->fields->issuetype->name) === 'epic', 'data' => $issue ]; } @@ -48,16 +49,21 @@ public function getJiraTicketsByProject(Client $client, $projectKeys): array|nul }; $results = []; foreach ($projectKeys as $projectKey) { - $response = $client->get('/rest/api/2/search?jql=project=' . $projectKey); + $response = $client->get('/rest/api/3/search/jql?jql=project=' . $projectKey . '&fields=*navigable'); $data = json_decode($response->getBody()->getContents()); + $issues = $data->issues ?? []; $results[$projectKey] = [ - 'total' => $data->total, - 'issues' => $formatIssues($data->issues) + 'total' => count($issues), + 'issues' => $formatIssues($issues) ]; } return $results; + } catch (\GuzzleHttp\Exception\RequestException $e) { + $response = $e->hasResponse() ? $e->getResponse()->getBody()->getContents() : 'No response'; + Log::error('Jira API error: ' . $e->getMessage() . ' | Response: ' . $response); + return null; } catch (GuzzleException $e) { - Log::error($e->getTraceAsString()); + Log::error('Jira connection error: ' . $e->getMessage()); return null; } } @@ -67,7 +73,7 @@ public function getJiraTicketDetails($host, $username, $token, $url) try { $client = $this->connectToJira($host, $username, $token); $url = explode('/', $url); - $response = $client->get('/rest/api/2/issue/' . $url[sizeof($url) - 1]); + $response = $client->get('/rest/api/3/issue/' . $url[sizeof($url) - 1]); return json_decode($response->getBody()->getContents()); } catch (GuzzleException $e) { Log::error($e->getTraceAsString()); diff --git a/app/Jobs/ImportJiraTicketsJob.php b/app/Jobs/ImportJiraTicketsJob.php index f603dd1e2..4a8abc2e4 100644 --- a/app/Jobs/ImportJiraTicketsJob.php +++ b/app/Jobs/ImportJiraTicketsJob.php @@ -44,6 +44,9 @@ public function handle() { if ($this->tickets && sizeof($this->tickets)) { foreach ($this->tickets as $ticket) { + if (!$ticket || !isset($ticket->fields)) { + continue; + } $projectDetails = $ticket->fields->project; $ticketData = $ticket->fields; @@ -52,7 +55,7 @@ public function handle() $project = Project::create([ 'name' => $projectDetails->name, 'description' => __('Project imported from Jira, project key:') . $projectDetails->key, - 'status_id' => ProjectStatus::where('is_default', true)->first()->id, + 'status_id' => ProjectStatus::where('is_default', true)->first()?->id ?? ProjectStatus::first()->id, 'owner_id' => $this->user->id, 'ticket_prefix' => $projectDetails->key ]); @@ -66,12 +69,14 @@ public function handle() Ticket::create([ 'name' => $ticketData->summary, - 'content' => $ticketData->description ?? __('No content found in jira ticket'), + 'content' => is_string($ticketData->description ?? null) + ? $ticketData->description + : (isset($ticketData->description) ? json_encode($ticketData->description) : __('No content found in jira ticket')), 'owner_id' => $this->user->id, - 'status_id' => TicketStatus::where('is_default', true)->first()->id, + 'status_id' => TicketStatus::where('is_default', true)->first()?->id ?? TicketStatus::first()->id, 'project_id' => $project->id, - 'type_id' => TicketType::where('is_default', true)->first()->id, - 'priority_id' => TicketPriority::where('is_default', true)->first()->id, + 'type_id' => TicketType::where('is_default', true)->first()?->id ?? TicketType::first()->id, + 'priority_id' => TicketPriority::where('is_default', true)->first()?->id ?? TicketPriority::first()->id, ]); } FilamentNotification::make() diff --git a/app/Settings/JiraSettings.php b/app/Settings/JiraSettings.php new file mode 100644 index 000000000..ccbd8b961 --- /dev/null +++ b/app/Settings/JiraSettings.php @@ -0,0 +1,22 @@ +migrator->add('jira.host', ''); + $this->migrator->add('jira.username', ''); + $this->migrator->addEncrypted('jira.token', ''); + } +}