diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php index ef2a338..65d1e02 100644 --- a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -2,6 +2,8 @@ namespace App\Actions\Notifications; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ResolveProjectTemplateState; use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; @@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload { public function __construct( private readonly ListProjectNotificationActions $listProjectNotificationActions, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, ) {} /** @@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload )->resolve(), 'project' => ProjectResource::make($project)->resolve(), 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS, + ]), 'templateVariables' => $this->templateVariables(), ]; } diff --git a/app/Actions/Projects/BuildProjectBacklogPayload.php b/app/Actions/Projects/BuildProjectBacklogPayload.php index bff6315..2d2a422 100644 --- a/app/Actions/Projects/BuildProjectBacklogPayload.php +++ b/app/Actions/Projects/BuildProjectBacklogPayload.php @@ -35,6 +35,7 @@ class BuildProjectBacklogPayload )->resolve(), 'canCreateTickets' => $canManageTickets, 'canEditTickets' => $canManageTickets, + 'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 'project' => ProjectResource::make($project)->resolve(), 'status' => $status, 'ticketStatusOptions' => TicketStatusResource::collection( diff --git a/app/Actions/Projects/BuildProjectBoardPayload.php b/app/Actions/Projects/BuildProjectBoardPayload.php index 1afb503..db72f5a 100644 --- a/app/Actions/Projects/BuildProjectBoardPayload.php +++ b/app/Actions/Projects/BuildProjectBoardPayload.php @@ -29,6 +29,7 @@ class BuildProjectBoardPayload 'ticketStatuses', 'tickets' => fn ($query) => $query ->with([ + 'assignee', 'ticketPriority', 'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.boardColumn', @@ -39,6 +40,7 @@ class BuildProjectBoardPayload )->resolve(), 'canCreateTickets' => $canManageTickets, 'canEditTickets' => $canManageTickets, + 'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 'project' => ProjectResource::make($project)->resolve(), 'status' => $status, ]; diff --git a/app/Actions/Projects/BuildProjectBoardSettingsPayload.php b/app/Actions/Projects/BuildProjectBoardSettingsPayload.php index a1992e6..72a5449 100644 --- a/app/Actions/Projects/BuildProjectBoardSettingsPayload.php +++ b/app/Actions/Projects/BuildProjectBoardSettingsPayload.php @@ -9,6 +9,10 @@ use App\Models\Project; class BuildProjectBoardSettingsPayload { + public function __construct( + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, + ) {} + /** * @return array */ @@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload )->resolve(), 'project' => ProjectResource::make($project)->resolve(), 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW, + ]), 'ticketStatuses' => TicketStatusResource::collection( $project->ticketStatuses() ->with(['allowedTargetStatuses.boardColumn', 'boardColumn']) diff --git a/app/Actions/Projects/BuildProjectSettingsPayload.php b/app/Actions/Projects/BuildProjectSettingsPayload.php new file mode 100644 index 0000000..8bc7f11 --- /dev/null +++ b/app/Actions/Projects/BuildProjectSettingsPayload.php @@ -0,0 +1,111 @@ + + */ + public function handle( + Project $project, + User $user, + ProjectSettingsSection $section, + mixed $status = null, + mixed $error = null, + ): array { + return [ + 'activeSection' => $section->value, + 'error' => $error, + 'project' => ProjectResource::make($project)->resolve(), + 'sectionData' => $this->sectionData($project, $user, $section), + 'status' => $status, + ]; + } + + /** + * @return array + */ + private function sectionData( + Project $project, + User $user, + ProjectSettingsSection $section, + ): array { + $payload = match ($section) { + ProjectSettingsSection::General => $this->generalData($project), + ProjectSettingsSection::Board => $this->buildProjectBoardSettingsPayload->handle($project), + ProjectSettingsSection::Metadata => $this->buildProjectTicketMetadataSettingsPayload->handle($project), + ProjectSettingsSection::Repositories => $this->buildProjectRepositorySettingsPayload->handle($project, $user), + ProjectSettingsSection::Notifications => $this->buildProjectNotificationSettingsPayload->handle($project), + ProjectSettingsSection::Access => $this->accessData($project), + }; + + return Arr::except($payload, ['error', 'project', 'status']); + } + + /** + * @return array + */ + private function generalData(Project $project): array + { + return [ + 'sourceTemplates' => ProjectTemplateResource::collection( + ProjectTemplate::query() + ->published() + ->with('sourceProject') + ->orderBy('name') + ->get(), + )->resolve(), + 'templateState' => $this->resolveProjectTemplateState->handle($project), + ]; + } + + /** + * @return array + */ + private function accessData(Project $project): array + { + return [ + 'directPermissions' => $this->permissionService->assignmentMap(User::class, $project), + 'permissionOptions' => PermissionOptionResource::collection( + $this->permissionService->permissionOptions(User::class, Project::class), + )->resolve(), + 'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project), + 'teams' => TeamResource::collection( + Team::query() + ->with('users') + ->orderBy('name') + ->get(), + )->resolve(), + 'users' => UserResource::collection( + User::query() + ->orderBy('name') + ->get(), + )->resolve(), + ]; + } +} diff --git a/app/Actions/Projects/BuildProjectTicketDetailPayload.php b/app/Actions/Projects/BuildProjectTicketDetailPayload.php index 25257cb..e973fb3 100644 --- a/app/Actions/Projects/BuildProjectTicketDetailPayload.php +++ b/app/Actions/Projects/BuildProjectTicketDetailPayload.php @@ -47,6 +47,7 @@ class BuildProjectTicketDetailPayload 'fieldValues.userValue', 'parentTicket', 'ticketPriority', + 'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.boardColumn', 'ticketType', 'worklogEntries.actor', diff --git a/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php b/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php index 1cc9c2c..3ab6fcf 100644 --- a/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php +++ b/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php @@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition; class BuildProjectTicketMetadataSettingsPayload { + public function __construct( + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, + ) {} + /** * @return array */ @@ -23,6 +27,13 @@ class BuildProjectTicketMetadataSettingsPayload 'project' => ProjectResource::make($project)->resolve(), 'error' => $error, 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES, + DuplicateProjectConfiguration::COPY_TICKET_FIELDS, + DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, + ]), 'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(), 'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(), 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), diff --git a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php index bd4bfc1..ca1b928 100644 --- a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php +++ b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php @@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource; use App\Http\Resources\TicketReferenceResource; use App\Http\Resources\TicketStatusResource; use App\Http\Resources\TicketTypeResource; -use App\Models\Ticket; use App\Http\Resources\UserResource; use App\Models\Project; +use App\Models\Ticket; use App\Models\User; class BuildProjectTicketOptionsPayload diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php index 4baf52a..221e3d9 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -6,25 +6,80 @@ use App\Actions\Permissions\PermissionService; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Enums\Permission; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\User; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; class CreateProject { public function __construct( + private readonly CreateProjectBaseTemplate $createProjectBaseTemplate, + private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly PermissionService $permissionService, ) {} /** - * @param array{key: string, name: string} $attributes + * @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, source_template_id?: int|string|null, copy_areas?: array|null} $attributes */ public function handle(array $attributes, User $actor): Project { - $project = Project::query()->create($attributes); + return DB::transaction(function () use ($attributes, $actor): Project { + $project = Project::query()->create(Arr::only($attributes, ['key', 'name'])); + $creationMode = Arr::get($attributes, 'creation_mode', 'scratch'); + $sourceTemplateId = Arr::get($attributes, 'source_template_id'); + $baseTemplate = null; + $sourceTemplate = null; - $this->initializeProjectTicketMetadata->handle($project); - $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); + if ($creationMode === 'template' && $sourceTemplateId !== null) { + $sourceTemplate = ProjectTemplate::query() + ->published() + ->findOrFail((int) $sourceTemplateId); - return $project; + $project->forceFill([ + 'project_template_id' => $sourceTemplate->id, + ])->save(); + } else { + $baseTemplate = $this->createProjectBaseTemplate->handle($project); + + $project->forceFill([ + 'project_template_id' => $baseTemplate->id, + ])->save(); + } + + $project = $project->fresh() ?? $project; + + $this->initializeProjectTicketMetadata->handle($project); + $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); + + if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) { + $sourceProject = Project::query()->findOrFail((int) Arr::get($attributes, 'source_project_id')); + + $this->duplicateProjectConfiguration->handle( + $sourceProject, + $project, + array_values(Arr::get($attributes, 'copy_areas', [])), + ); + } + + if ($creationMode === 'template' && $sourceTemplate !== null) { + $this->duplicateProjectConfiguration->apply( + $project->fresh() ?? $project, + $sourceTemplate->configuration_snapshot ?? [], + ); + } + + if ($baseTemplate !== null) { + $baseTemplate->forceFill([ + 'configuration_snapshot' => $this->duplicateProjectConfiguration->export( + $project->fresh() ?? $project, + DuplicateProjectConfiguration::copyableAreas(), + ), + ])->save(); + } + + return $project->fresh() ?? $project; + }); } } diff --git a/app/Actions/Projects/CreateProjectBaseTemplate.php b/app/Actions/Projects/CreateProjectBaseTemplate.php new file mode 100644 index 0000000..29623e3 --- /dev/null +++ b/app/Actions/Projects/CreateProjectBaseTemplate.php @@ -0,0 +1,28 @@ +create([ + 'project_id' => $project->id, + 'source_project_id' => $project->id, + 'name' => "{$project->name} base template", + 'slug' => strtolower($project->key).'-base-template', + 'is_published' => false, + 'copy_areas' => $copyAreas, + 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas), + ]); + } +} diff --git a/app/Actions/Projects/CreateProjectTemplate.php b/app/Actions/Projects/CreateProjectTemplate.php new file mode 100644 index 0000000..463267e --- /dev/null +++ b/app/Actions/Projects/CreateProjectTemplate.php @@ -0,0 +1,32 @@ +} $attributes + */ + public function handle(Project $sourceProject, array $attributes): ProjectTemplate + { + $copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); + + return ProjectTemplate::query()->create([ + 'project_id' => null, + 'source_project_id' => $sourceProject->id, + 'name' => $attributes['name'], + 'slug' => $attributes['slug'], + 'is_published' => true, + 'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas), + 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas), + ]); + } +} diff --git a/app/Actions/Projects/DuplicateProjectConfiguration.php b/app/Actions/Projects/DuplicateProjectConfiguration.php new file mode 100644 index 0000000..01e2eed --- /dev/null +++ b/app/Actions/Projects/DuplicateProjectConfiguration.php @@ -0,0 +1,400 @@ + + */ + public static function copyableAreas(): array + { + return [ + self::COPY_PROJECT_SETTINGS, + self::COPY_BOARD_WORKFLOW, + self::COPY_TICKET_TYPES, + self::COPY_TICKET_PRIORITIES, + self::COPY_TICKET_FIELDS, + self::COPY_TICKET_LINK_TYPES, + self::COPY_REPOSITORY_CONNECTIONS, + self::COPY_NOTIFICATION_ACTIONS, + ]; + } + + /** + * @return list + */ + public static function copyAreaOptions(): array + { + return [ + [ + 'value' => self::COPY_PROJECT_SETTINGS, + 'label' => 'Project settings', + 'description' => 'Copy general project preferences like story points and author visibility.', + ], + [ + 'value' => self::COPY_BOARD_WORKFLOW, + 'label' => 'Board workflow', + 'description' => 'Copy board columns, ticket statuses, and allowed status transitions.', + ], + [ + 'value' => self::COPY_TICKET_TYPES, + 'label' => 'Ticket types', + 'description' => 'Copy available ticket types, including parent-ticket types.', + ], + [ + 'value' => self::COPY_TICKET_PRIORITIES, + 'label' => 'Ticket priorities', + 'description' => 'Copy the project-specific ticket priorities.', + ], + [ + 'value' => self::COPY_TICKET_FIELDS, + 'label' => 'Custom ticket fields', + 'description' => 'Copy custom ticket field definitions without any ticket values.', + ], + [ + 'value' => self::COPY_TICKET_LINK_TYPES, + 'label' => 'Ticket link types', + 'description' => 'Copy available relationship labels between tickets.', + ], + [ + 'value' => self::COPY_REPOSITORY_CONNECTIONS, + 'label' => 'Repository connections', + 'description' => 'Copy linked repositories without changing the new project identity.', + ], + [ + 'value' => self::COPY_NOTIFICATION_ACTIONS, + 'label' => 'Notification actions', + 'description' => 'Copy notification rules and delivery settings.', + ], + ]; + } + + /** + * @param list $copyAreas + * @return list + */ + public function normalizeCopyAreas(array $copyAreas): array + { + return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas))); + } + + /** + * @param list $copyAreas + * @return array + */ + public function export(Project $sourceProject, array $copyAreas): array + { + $selectedAreas = $this->normalizeCopyAreas($copyAreas); + + if ($selectedAreas === []) { + return []; + } + + $sourceProject->loadMissing([ + 'boardColumns', + 'notificationActions', + 'repositoryConnections', + 'ticketFieldDefinitions', + 'ticketLinkTypes', + 'ticketPriorities', + 'ticketStatuses.allowedTargetStatuses', + 'ticketTypes', + ]); + + $configuration = []; + + if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) { + $configuration[self::COPY_PROJECT_SETTINGS] = [ + 'show_author' => $sourceProject->show_author, + 'show_story_points' => $sourceProject->show_story_points, + ]; + } + + if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) { + $boardColumnIndexes = []; + $boardColumns = []; + + foreach ($sourceProject->boardColumns as $index => $boardColumn) { + $boardColumnIndexes[$boardColumn->id] = $index; + $boardColumns[] = [ + 'name' => $boardColumn->name, + 'position' => $boardColumn->position, + ]; + } + + $ticketStatusIndexes = []; + $ticketStatuses = []; + + foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { + $ticketStatusIndexes[$ticketStatus->id] = $index; + $ticketStatuses[] = [ + 'board_column_index' => $ticketStatus->board_column_id === null + ? null + : ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null), + 'name' => $ticketStatus->name, + 'position' => $ticketStatus->position, + 'allowed_target_indexes' => [], + ]; + } + + foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { + $ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses + ->pluck('id') + ->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null) + ->filter(static fn (?int $statusIndex): bool => $statusIndex !== null) + ->values() + ->all(); + } + + $configuration[self::COPY_BOARD_WORKFLOW] = [ + 'board_columns' => $boardColumns, + 'ticket_statuses' => $ticketStatuses, + ]; + } + + if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) { + $configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes + ->map(fn ($ticketType): array => [ + 'name' => $ticketType->name, + 'position' => $ticketType->position, + 'is_parent' => $ticketType->is_parent, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) { + $configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities + ->map(fn ($ticketPriority): array => [ + 'name' => $ticketPriority->name, + 'position' => $ticketPriority->position, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) { + $configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions + ->map(fn ($ticketFieldDefinition): array => [ + 'field_type' => $ticketFieldDefinition->field_type, + 'label' => $ticketFieldDefinition->label, + 'position' => $ticketFieldDefinition->position, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) { + $configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes + ->map(fn ($ticketLinkType): array => [ + 'label' => $ticketLinkType->label, + 'counterpart_label' => $ticketLinkType->counterpart_label, + 'position' => $ticketLinkType->position, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) { + $configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections + ->map(fn ($repositoryConnection): array => [ + 'provider' => $repositoryConnection->provider->value, + 'base_url' => $repositoryConnection->base_url, + 'provider_repository_id' => $repositoryConnection->provider_repository_id, + 'owner_name' => $repositoryConnection->owner_name, + 'repository_name' => $repositoryConnection->repository_name, + 'repository_full_name' => $repositoryConnection->repository_full_name, + 'repository_url' => $repositoryConnection->repository_url, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) { + $configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions + ->map(fn ($notificationAction): array => [ + 'name' => $notificationAction->name, + 'enabled' => $notificationAction->enabled, + 'event_type' => $notificationAction->event_type->value, + 'channel' => $notificationAction->channel->value, + 'channel_configuration' => $notificationAction->channel_configuration ?? [], + 'message_template' => $notificationAction->message_template, + 'delivery_url' => $notificationAction->delivery_url, + 'filters' => $notificationAction->filters ?? [], + ]) + ->values() + ->all(); + } + + return $configuration; + } + + /** + * @param array $configuration + */ + public function apply(Project $targetProject, array $configuration): void + { + if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) { + $projectSettings = $configuration[self::COPY_PROJECT_SETTINGS]; + + $targetProject->forceFill([ + 'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author), + 'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points), + ])->save(); + } + + if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) { + $targetProject->ticketStatuses()->delete(); + $targetProject->boardColumns()->delete(); + + $boardColumnIds = []; + + foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) { + $newBoardColumn = $targetProject->boardColumns()->create([ + 'name' => $boardColumn['name'], + 'position' => $boardColumn['position'], + ]); + + $boardColumnIds[] = $newBoardColumn->id; + } + + $ticketStatusIds = []; + + foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) { + $boardColumnIndex = $ticketStatus['board_column_index'] ?? null; + $newTicketStatus = $targetProject->ticketStatuses()->create([ + 'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null), + 'name' => $ticketStatus['name'], + 'position' => $ticketStatus['position'], + ]); + + $ticketStatusIds[] = $newTicketStatus->id; + } + + foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) { + $newTicketStatusId = $ticketStatusIds[$index] ?? null; + + if ($newTicketStatusId === null) { + continue; + } + + $targetProject->ticketStatuses()->findOrFail($newTicketStatusId) + ->allowedTargetStatuses() + ->sync( + collect($ticketStatus['allowed_target_indexes'] ?? []) + ->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null) + ->filter(static fn (?int $statusId): bool => $statusId !== null) + ->values() + ->all(), + ); + } + } + + if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) { + $targetProject->ticketTypes()->delete(); + + foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) { + $targetProject->ticketTypes()->create([ + 'name' => $ticketType['name'], + 'position' => $ticketType['position'], + 'is_parent' => $ticketType['is_parent'], + ]); + } + } + + if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) { + $targetProject->ticketPriorities()->delete(); + + foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) { + $targetProject->ticketPriorities()->create([ + 'name' => $ticketPriority['name'], + 'position' => $ticketPriority['position'], + ]); + } + } + + if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) { + $targetProject->ticketFieldDefinitions()->delete(); + + foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) { + $targetProject->ticketFieldDefinitions()->create([ + 'field_type' => $ticketFieldDefinition['field_type'], + 'label' => $ticketFieldDefinition['label'], + 'position' => $ticketFieldDefinition['position'], + ]); + } + } + + if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) { + $targetProject->ticketLinkTypes()->delete(); + + foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) { + $targetProject->ticketLinkTypes()->create([ + 'label' => $ticketLinkType['label'], + 'counterpart_label' => $ticketLinkType['counterpart_label'], + 'position' => $ticketLinkType['position'], + ]); + } + } + + if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) { + $targetProject->repositoryConnections()->delete(); + + foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) { + $targetProject->repositoryConnections()->create([ + 'provider' => $repositoryConnection['provider'], + 'base_url' => $repositoryConnection['base_url'], + 'provider_repository_id' => $repositoryConnection['provider_repository_id'], + 'owner_name' => $repositoryConnection['owner_name'], + 'repository_name' => $repositoryConnection['repository_name'], + 'repository_full_name' => $repositoryConnection['repository_full_name'], + 'repository_url' => $repositoryConnection['repository_url'], + ]); + } + } + + if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) { + $targetProject->notificationActions()->delete(); + + foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) { + $targetProject->notificationActions()->create([ + 'name' => $notificationAction['name'], + 'enabled' => $notificationAction['enabled'], + 'event_type' => $notificationAction['event_type'], + 'channel' => $notificationAction['channel'], + 'channel_configuration' => $notificationAction['channel_configuration'] ?? [], + 'message_template' => $notificationAction['message_template'] ?? null, + 'delivery_url' => $notificationAction['delivery_url'], + 'filters' => $notificationAction['filters'] ?? [], + ]); + } + } + } + + /** + * @param list $copyAreas + */ + public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void + { + $this->apply($targetProject, $this->export($sourceProject, $copyAreas)); + } +} diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php new file mode 100644 index 0000000..8b3c8a0 --- /dev/null +++ b/app/Actions/Projects/ResolveProjectTemplateState.php @@ -0,0 +1,92 @@ +|null $areas + * @return array|null + */ + public function handle(Project $project, ?array $areas = null): ?array + { + $project->loadMissing('projectTemplate'); + + /** @var ProjectTemplate|null $projectTemplate */ + $projectTemplate = $project->projectTemplate; + + if ($projectTemplate === null) { + return null; + } + + $selectedAreas = $areas === null + ? DuplicateProjectConfiguration::copyableAreas() + : $this->duplicateProjectConfiguration->normalizeCopyAreas($areas); + + $templateConfiguration = collect($projectTemplate->configuration_snapshot ?? []) + ->only($selectedAreas) + ->all(); + $currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas); + + $overriddenAreas = []; + $inheritedAreas = []; + + foreach ($selectedAreas as $area) { + $label = $this->labelForArea($area); + $hasTemplateValue = array_key_exists($area, $templateConfiguration); + $hasCurrentValue = array_key_exists($area, $currentConfiguration); + + if (! $hasTemplateValue && ! $hasCurrentValue) { + continue; + } + + if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) { + $inheritedAreas[] = [ + 'label' => $label, + 'value' => $area, + ]; + + continue; + } + + $overriddenAreas[] = [ + 'label' => $label, + 'value' => $area, + ]; + } + + $overriddenAreaValues = array_flip(array_column($overriddenAreas, 'value')); + $inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value')); + + return [ + 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)), + 'inheritedAreas' => $inheritedAreas, + 'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues), + 'inheritsFromTemplate' => $projectTemplate->is_published, + 'overriddenAreas' => $overriddenAreas, + 'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues), + 'templateConfiguration' => $templateConfiguration, + 'templateId' => $projectTemplate->id, + 'templateName' => $projectTemplate->name, + 'templateSlug' => $projectTemplate->slug, + ]; + } + + private function labelForArea(string $area): string + { + foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) { + if ($option['value'] === $area) { + return $option['label']; + } + } + + return $area; + } +} diff --git a/app/Actions/Projects/UpdateProjectTemplate.php b/app/Actions/Projects/UpdateProjectTemplate.php new file mode 100644 index 0000000..f0d5528 --- /dev/null +++ b/app/Actions/Projects/UpdateProjectTemplate.php @@ -0,0 +1,66 @@ +} $attributes + */ + public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate + { + $projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate'); + + $sourceProject = $projectTemplate->sourceProject; + abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.'); + + $copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); + $normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas); + $previousConfiguration = $projectTemplate->configuration_snapshot ?? []; + $nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas); + $affectedAreas = array_values(array_unique([ + ...array_keys($previousConfiguration), + ...array_keys($nextConfiguration), + ])); + + DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void { + $projectTemplate->forceFill([ + 'name' => $attributes['name'], + 'copy_areas' => $normalizedCopyAreas, + 'configuration_snapshot' => $nextConfiguration, + ])->save(); + + foreach ($projectTemplate->linkedProjects as $linkedProject) { + $currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject->fresh(), $affectedAreas); + $replacementConfiguration = []; + + foreach ($nextConfiguration as $area => $configuration) { + $isNewTemplateArea = ! array_key_exists($area, $previousConfiguration); + $matchesPreviousTemplate = array_key_exists($area, $previousConfiguration) + && array_key_exists($area, $currentConfiguration) + && $currentConfiguration[$area] === $previousConfiguration[$area]; + + if ($isNewTemplateArea || $matchesPreviousTemplate) { + $replacementConfiguration[$area] = $configuration; + } + } + + if ($replacementConfiguration === []) { + continue; + } + + $this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration); + } + }); + + return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects'); + } +} diff --git a/app/Actions/Projects/UpdateProjectTemplateLink.php b/app/Actions/Projects/UpdateProjectTemplateLink.php new file mode 100644 index 0000000..a7bcc39 --- /dev/null +++ b/app/Actions/Projects/UpdateProjectTemplateLink.php @@ -0,0 +1,44 @@ +forceFill([ + 'project_template_id' => $template->id, + ])->save(); + + $this->duplicateProjectConfiguration->apply( + $project->fresh(), + $template->configuration_snapshot ?? [], + ); + }); + } + + public function reset(Project $project): void + { + $template = $project->projectTemplate; + + if ($template === null) { + return; + } + + DB::transaction(function () use ($project, $template): void { + $this->duplicateProjectConfiguration->apply( + $project->fresh(), + $template->configuration_snapshot ?? [], + ); + }); + } +} diff --git a/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php b/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php index e49b10f..ce22d98 100644 --- a/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php +++ b/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php @@ -4,6 +4,8 @@ namespace App\Actions\Repositories; use App\Actions\Git\Data\GitRepository; use App\Actions\Git\GitProviderManager; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ResolveProjectTemplateState; use App\Http\Resources\AvailableGitRepositorySourceResource; use App\Http\Resources\ProjectResource; use App\Http\Resources\RepositoryConnectionResource; @@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload { public function __construct( private readonly GitProviderManager $gitProviderManager, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, ) {} /** @@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload 'project' => ProjectResource::make($project)->resolve(), 'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(), 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS, + ]), ]; } diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index b2be15a..985a410 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -47,9 +47,13 @@ class CreateProjectTicket $nextSequenceNumber = (int) Ticket::query() ->where('project_id', $project->id) ->max('sequence_number') + 1; - $ticketStatus = TicketStatus::query() - ->where('project_id', $project->id) - ->findOrFail(Arr::get($attributes, 'ticket_status_id')); + $ticketStatusQuery = TicketStatus::query() + ->where( + $project->project_template_id === null ? 'project_id' : 'project_template_id', + $project->project_template_id ?? $project->id, + ); + + $ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id')); $ticket = Ticket::query()->create([ 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), diff --git a/app/Enums/ProjectSettingsSection.php b/app/Enums/ProjectSettingsSection.php new file mode 100644 index 0000000..e8d0b0f --- /dev/null +++ b/app/Enums/ProjectSettingsSection.php @@ -0,0 +1,13 @@ +user(); abort_unless( @@ -56,13 +58,32 @@ class ProjectBoardController extends Controller : 'projects.board.index'; $result = $this->moveProjectTicket->handle($project, $ticket, $user, $validated); + $statusMessage = $result['moved'] + ? "{$ticket->ticket_key} moved to {$result['destination_label']}." + : "{$ticket->ticket_key} already belongs to {$result['destination_label']}."; + + if ($request->expectsJson()) { + $ticket->refresh()->load([ + 'ticketStatus.allowedTargetStatuses.boardColumn', + 'ticketStatus.boardColumn', + ]); + + return response()->json([ + 'message' => $statusMessage, + 'moved' => $result['moved'], + 'ticket_status' => $ticket->ticketStatus === null + ? null + : TicketStatusResource::make($ticket->ticketStatus)->resolve(), + 'ticket_status_id' => $ticket->ticket_status_id, + ]); + } if (! $result['moved']) { return to_route($redirectRoute, $project) - ->with('status', "{$ticket->ticket_key} already belongs to {$result['destination_label']}."); + ->with('status', $statusMessage); } return to_route($redirectRoute, $project) - ->with('status', "{$ticket->ticket_key} moved to {$result['destination_label']}."); + ->with('status', $statusMessage); } } diff --git a/app/Http/Controllers/ProjectBoardSettingsController.php b/app/Http/Controllers/ProjectBoardSettingsController.php index bd65152..957dbab 100644 --- a/app/Http/Controllers/ProjectBoardSettingsController.php +++ b/app/Http/Controllers/ProjectBoardSettingsController.php @@ -10,8 +10,8 @@ use App\Actions\ProjectBoard\StoreTicketStatus; use App\Actions\ProjectBoard\UpdateBoardColumn; use App\Actions\ProjectBoard\UpdateTicketStatus; use App\Actions\ProjectBoard\UpdateTicketStatusTransitions; -use App\Actions\Projects\BuildProjectBoardSettingsPayload; use App\Enums\Permission; +use App\Enums\ProjectSettingsSection; use App\Http\Requests\StoreBoardColumnRequest; use App\Http\Requests\StoreTicketStatusRequest; use App\Http\Requests\UpdateBoardColumnRequest; @@ -22,13 +22,10 @@ use App\Models\Project; use App\Models\TicketStatus; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Inertia\Inertia; -use Inertia\Response; class ProjectBoardSettingsController extends Controller { public function __construct( - private readonly BuildProjectBoardSettingsPayload $buildProjectBoardSettingsPayload, private readonly DeleteBoardColumn $deleteBoardColumn, private readonly DeleteTicketStatus $deleteTicketStatus, private readonly PermissionService $permissionService, @@ -39,19 +36,6 @@ class ProjectBoardSettingsController extends Controller private readonly UpdateTicketStatusTransitions $updateTicketStatusTransitions, ) {} - public function show(Request $request, Project $project): Response - { - abort_unless( - $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), - 403, - ); - - return Inertia::render( - 'projects/settings/Board', - $this->buildProjectBoardSettingsPayload->handle($project, session('status')), - ); - } - public function store( StoreBoardColumnRequest $request, Project $project, @@ -62,7 +46,7 @@ class ProjectBoardSettingsController extends Controller ); $boardColumn = $this->storeBoardColumn->handle($project, $request->validated()); - return to_route('projects.settings.board', $project) + return $this->settingsRedirect($project) ->with('status', "{$boardColumn->name} was added to the board workflow."); } @@ -77,7 +61,7 @@ class ProjectBoardSettingsController extends Controller ); $boardColumn = $this->updateBoardColumn->handle($project, $boardColumn, $request->validated()); - return to_route('projects.settings.board', $project) + return $this->settingsRedirect($project) ->with('status', "{$boardColumn->fresh()->name} was updated."); } @@ -93,7 +77,7 @@ class ProjectBoardSettingsController extends Controller $deletedName = $this->deleteBoardColumn->handle($project, $boardColumn); - return to_route('projects.settings.board', $project) + return $this->settingsRedirect($project) ->with('status', "{$deletedName} was removed from the board workflow."); } @@ -107,7 +91,7 @@ class ProjectBoardSettingsController extends Controller ); $ticketStatus = $this->storeTicketStatus->handle($project, $request->validated()); - return to_route('projects.settings.board', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketStatus->name} was added to {$this->statusContextLabel($ticketStatus)}."); } @@ -122,7 +106,7 @@ class ProjectBoardSettingsController extends Controller ); $ticketStatus = $this->updateTicketStatus->handle($ticketStatus, $request->validated()); - return to_route('projects.settings.board', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketStatus->fresh()->name} was updated."); } @@ -138,7 +122,7 @@ class ProjectBoardSettingsController extends Controller $result = $this->deleteTicketStatus->handle($project, $ticketStatus); - return to_route('projects.settings.board', $project) + return $this->settingsRedirect($project) ->with('status', "{$result['deleted_name']} was removed from {$result['context_label']}."); } @@ -153,7 +137,7 @@ class ProjectBoardSettingsController extends Controller ); $this->updateTicketStatusTransitions->handle($ticketStatus, $request->validated()); - return to_route('projects.settings.board', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketStatus->name} move targets were updated."); } @@ -161,4 +145,12 @@ class ProjectBoardSettingsController extends Controller { return $ticketStatus->boardColumn?->name ?? 'Backlog'; } + + private function settingsRedirect(Project $project): RedirectResponse + { + return to_route('projects.settings.index', [ + 'project' => $project, + 'section' => ProjectSettingsSection::Board->value, + ]); + } } diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index d6405bb..d6b17e3 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -6,16 +6,20 @@ use App\Actions\Permissions\PermissionService; use App\Actions\Projects\BuildProjectBacklogPayload; use App\Actions\Projects\CreateProject; use App\Actions\Projects\DeleteProject; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\ListVisibleProjects; +use App\Actions\Projects\UpdateProjectTemplateLink; use App\Enums\Permission; +use App\Enums\ProjectSettingsSection; use App\Http\Requests\DeleteProjectRequest; +use App\Http\Requests\ResetProjectTemplateRequest; use App\Http\Requests\StoreProjectRequest; +use App\Http\Requests\SwitchProjectTemplateRequest; use App\Http\Requests\SyncProjectPermissionsRequest; -use App\Http\Resources\PermissionOptionResource; use App\Http\Resources\ProjectResource; -use App\Http\Resources\TeamResource; -use App\Http\Resources\UserResource; +use App\Http\Resources\ProjectTemplateResource; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\Team; use App\Models\User; use Illuminate\Http\RedirectResponse; @@ -32,6 +36,7 @@ class ProjectController extends Controller private readonly DeleteProject $deleteProject, private readonly ListVisibleProjects $listVisibleProjects, private readonly PermissionService $permissionService, + private readonly UpdateProjectTemplateLink $updateProjectTemplateLink, ) {} public function index(Request $request): Response @@ -51,17 +56,33 @@ class ProjectController extends Controller 403, ); - return Inertia::render('projects/Create'); + $sourceTemplates = ProjectTemplate::query() + ->published() + ->with('sourceProject') + ->orderBy('name') + ->get(); + $selectedTemplateId = $request->integer('template'); + + return Inertia::render('projects/Create', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'sourceProjects' => ProjectResource::collection( + $this->listVisibleProjects->handle($request->user()), + )->resolve(), + 'sourceTemplates' => ProjectTemplateResource::collection($sourceTemplates)->resolve(), + 'selectedSourceTemplateId' => $sourceTemplates->contains('id', $selectedTemplateId) + ? $selectedTemplateId + : null, + ]); } public function store(StoreProjectRequest $request): RedirectResponse { $project = $this->createProject->handle($request->validated(), $request->user()); - return to_route('projects.show', $project); + return to_route('projects.board.index', $project); } - public function show(Request $request, Project $project): Response + public function show(Request $request, Project $project): RedirectResponse { $user = $request->user(); @@ -70,10 +91,7 @@ class ProjectController extends Controller 403, ); - return Inertia::render('projects/Show', [ - 'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project), - 'project' => ProjectResource::make($project)->resolve(), - ]); + return to_route('projects.board.index', $project); } public function backlog(Request $request, Project $project): Response @@ -91,42 +109,37 @@ class ProjectController extends Controller ); } - public function general(Request $request, Project $project): Response + public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse { - return Inertia::render('projects/settings/General', [ - 'project' => ProjectResource::make($project)->resolve(), - 'status' => session('status'), - ]); + $validated = $request->validated(); + $template = ProjectTemplate::query() + ->published() + ->findOrFail((int) Arr::get($validated, 'source_template_id')); + + $this->updateProjectTemplateLink->switch($project, $template); + + return to_route('projects.settings.index', [ + 'project' => $project, + 'section' => ProjectSettingsSection::General->value, + ]) + ->with('status', "{$project->name} now uses the {$template->name} template."); } - public function access(Request $request, Project $project): Response + public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse { - $user = $request->user(); - abort_unless( - $this->permissionService->allows($user, Permission::ProjectAdmin, $project), - 403, - ); + $request->validated(); - return Inertia::render('projects/Access', [ - 'directPermissions' => $this->permissionService->assignmentMap(User::class, $project), - 'permissionOptions' => PermissionOptionResource::collection( - $this->permissionService->permissionOptions(User::class, Project::class), - )->resolve(), - 'project' => ProjectResource::make($project)->resolve(), - 'status' => session('status'), - 'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project), - 'teams' => TeamResource::collection( - Team::query() - ->with('users') - ->orderBy('name') - ->get(), - )->resolve(), - 'users' => UserResource::collection( - User::query() - ->orderBy('name') - ->get(), - )->resolve(), - ]); + $template = $project->projectTemplate; + + abort_if($template === null, 422, 'This project has no linked template to reset.'); + + $this->updateProjectTemplateLink->reset($project->load('projectTemplate')); + + return to_route('projects.settings.index', [ + 'project' => $project, + 'section' => ProjectSettingsSection::General->value, + ]) + ->with('status', "{$project->name} was reset to the {$template->name} template baseline."); } public function updateUserPermissions( @@ -145,7 +158,10 @@ class ProjectController extends Controller $this->permissionService->sync($user, $project, $permissions); - return to_route('projects.access', $project) + return to_route('projects.settings.index', [ + 'project' => $project, + 'section' => ProjectSettingsSection::Access->value, + ]) ->with('status', "{$user->name} permissions were updated."); } @@ -164,7 +180,10 @@ class ProjectController extends Controller $this->permissionService->sync($team, $project, $permissions); - return to_route('projects.access', $project) + return to_route('projects.settings.index', [ + 'project' => $project, + 'section' => ProjectSettingsSection::Access->value, + ]) ->with('status', "{$team->name} permissions were updated."); } diff --git a/app/Http/Controllers/ProjectNotificationSettingsController.php b/app/Http/Controllers/ProjectNotificationSettingsController.php index 8abb088..e78665d 100644 --- a/app/Http/Controllers/ProjectNotificationSettingsController.php +++ b/app/Http/Controllers/ProjectNotificationSettingsController.php @@ -2,41 +2,28 @@ namespace App\Http\Controllers; -use App\Actions\Notifications\BuildProjectNotificationSettingsPayload; use App\Actions\Notifications\DeleteProjectNotificationAction; use App\Actions\Notifications\StoreProjectNotificationAction; use App\Actions\Notifications\UpdateProjectNotificationAction; use App\Actions\Permissions\PermissionService; use App\Enums\Permission; +use App\Enums\ProjectSettingsSection; use App\Http\Requests\StoreProjectNotificationActionRequest; use App\Http\Requests\UpdateProjectNotificationActionRequest; use App\Models\NotificationAction; use App\Models\Project; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Inertia\Inertia; -use Inertia\Response; class ProjectNotificationSettingsController extends Controller { public function __construct( - private readonly BuildProjectNotificationSettingsPayload $buildProjectNotificationSettingsPayload, private readonly DeleteProjectNotificationAction $deleteProjectNotificationAction, private readonly PermissionService $permissionService, private readonly StoreProjectNotificationAction $storeProjectNotificationAction, private readonly UpdateProjectNotificationAction $updateProjectNotificationAction, ) {} - public function show(Request $request, Project $project): Response - { - $this->abortUnlessCanManage($request, $project); - - return Inertia::render( - 'projects/settings/Notifications', - $this->buildProjectNotificationSettingsPayload->handle($project, session('status')), - ); - } - public function store( StoreProjectNotificationActionRequest $request, Project $project, @@ -45,7 +32,7 @@ class ProjectNotificationSettingsController extends Controller $notificationAction = $this->storeProjectNotificationAction->handle($project, $request->validated()); - return to_route('projects.settings.notifications', $project) + return $this->settingsRedirect($project) ->with('status', "{$notificationAction->name} notification was added."); } @@ -62,7 +49,7 @@ class ProjectNotificationSettingsController extends Controller $request->validated(), ); - return to_route('projects.settings.notifications', $project) + return $this->settingsRedirect($project) ->with('status', "{$notificationAction->name} notification was updated."); } @@ -80,7 +67,7 @@ class ProjectNotificationSettingsController extends Controller $this->deleteProjectNotificationAction->handle($project, $notificationAction); - return to_route('projects.settings.notifications', $project) + return $this->settingsRedirect($project) ->with('status', "{$deletedName} notification was deleted."); } @@ -91,4 +78,12 @@ class ProjectNotificationSettingsController extends Controller 403, ); } + + private function settingsRedirect(Project $project): RedirectResponse + { + return to_route('projects.settings.index', [ + 'project' => $project, + 'section' => ProjectSettingsSection::Notifications->value, + ]); + } } diff --git a/app/Http/Controllers/ProjectRepositorySettingsController.php b/app/Http/Controllers/ProjectRepositorySettingsController.php index c95537c..e58c177 100644 --- a/app/Http/Controllers/ProjectRepositorySettingsController.php +++ b/app/Http/Controllers/ProjectRepositorySettingsController.php @@ -3,44 +3,27 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; -use App\Actions\Repositories\BuildProjectRepositorySettingsPayload; use App\Actions\Repositories\DeleteProjectRepositoryConnection; use App\Actions\Repositories\StoreProjectRepositoryConnection; use App\Actions\Repositories\UpdateProjectRepositoryConnection; use App\Enums\Permission; +use App\Enums\ProjectSettingsSection; use App\Http\Requests\StoreRepositoryConnectionRequest; use App\Http\Requests\UpdateRepositoryConnectionRequest; use App\Models\Project; use App\Models\RepositoryConnection; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Inertia\Inertia; -use Inertia\Response; class ProjectRepositorySettingsController extends Controller { public function __construct( - private readonly BuildProjectRepositorySettingsPayload $buildProjectRepositorySettingsPayload, private readonly DeleteProjectRepositoryConnection $deleteProjectRepositoryConnection, private readonly PermissionService $permissionService, private readonly StoreProjectRepositoryConnection $storeProjectRepositoryConnection, private readonly UpdateProjectRepositoryConnection $updateProjectRepositoryConnection, ) {} - public function show(Request $request, Project $project): Response - { - $user = $request->user(); - abort_unless( - $this->permissionService->allows($user, Permission::ProjectAdmin, $project), - 403, - ); - - return Inertia::render( - 'projects/settings/Repositories', - $this->buildProjectRepositorySettingsPayload->handle($project, $user, session('status')), - ); - } - public function store( StoreRepositoryConnectionRequest $request, Project $project, @@ -53,7 +36,7 @@ class ProjectRepositorySettingsController extends Controller $repositoryConnection = $this->storeProjectRepositoryConnection->handle($project, $user, $request->validated()); - return to_route('projects.settings.repositories', $project) + return $this->settingsRedirect($project) ->with('status', "{$repositoryConnection->repository_full_name} was linked to {$project->name}."); } @@ -76,7 +59,7 @@ class ProjectRepositorySettingsController extends Controller $request->validated(), ); - return to_route('projects.settings.repositories', $project) + return $this->settingsRedirect($project) ->with('status', "{$repositoryConnection->fresh()->repository_full_name} connection was updated."); } @@ -92,7 +75,15 @@ class ProjectRepositorySettingsController extends Controller $repositoryLabel = $this->deleteProjectRepositoryConnection->handle($repositoryConnection); - return to_route('projects.settings.repositories', $project) + return $this->settingsRedirect($project) ->with('status', "{$repositoryLabel} was removed from the project."); } + + private function settingsRedirect(Project $project): RedirectResponse + { + return to_route('projects.settings.index', [ + 'project' => $project, + 'section' => ProjectSettingsSection::Repositories->value, + ]); + } } diff --git a/app/Http/Controllers/ProjectSettingsController.php b/app/Http/Controllers/ProjectSettingsController.php new file mode 100644 index 0000000..ca7aa54 --- /dev/null +++ b/app/Http/Controllers/ProjectSettingsController.php @@ -0,0 +1,95 @@ +user(); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), + 403, + ); + + $validated = $request->validated(); + $section = ProjectSettingsSection::from( + (string) Arr::get($validated, 'section', ProjectSettingsSection::General->value), + ); + + return Inertia::render( + 'projects/Settings', + $this->buildProjectSettingsPayload->handle( + $project, + $user, + $section, + session('status'), + session('error'), + ), + ); + } + + public function general(Request $request, Project $project): RedirectResponse + { + return $this->redirectToSection($request, $project, ProjectSettingsSection::General); + } + + public function board(Request $request, Project $project): RedirectResponse + { + return $this->redirectToSection($request, $project, ProjectSettingsSection::Board); + } + + public function metadata(Request $request, Project $project): RedirectResponse + { + return $this->redirectToSection($request, $project, ProjectSettingsSection::Metadata); + } + + public function repositories(Request $request, Project $project): RedirectResponse + { + return $this->redirectToSection($request, $project, ProjectSettingsSection::Repositories); + } + + public function notifications(Request $request, Project $project): RedirectResponse + { + return $this->redirectToSection($request, $project, ProjectSettingsSection::Notifications); + } + + public function access(Request $request, Project $project): RedirectResponse + { + return $this->redirectToSection($request, $project, ProjectSettingsSection::Access); + } + + private function redirectToSection( + Request $request, + Project $project, + ProjectSettingsSection $section, + ): RedirectResponse { + abort_unless( + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), + 403, + ); + + return to_route('projects.settings.index', [ + 'project' => $project, + 'section' => $section->value, + ]); + } +} diff --git a/app/Http/Controllers/ProjectTemplateController.php b/app/Http/Controllers/ProjectTemplateController.php new file mode 100644 index 0000000..e9c1b6a --- /dev/null +++ b/app/Http/Controllers/ProjectTemplateController.php @@ -0,0 +1,89 @@ + session('status'), + 'templates' => ProjectTemplateResource::collection( + ProjectTemplate::query() + ->published() + ->with('sourceProject') + ->withCount('linkedProjects') + ->orderBy('name') + ->get(), + )->resolve(), + ]); + } + + public function edit(ProjectTemplate $projectTemplate): Response + { + abort_if(! $projectTemplate->is_published, 404); + + $projectTemplate->load('sourceProject')->loadCount('linkedProjects'); + + return Inertia::render('project-templates/Edit', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'template' => ProjectTemplateResource::make($projectTemplate)->resolve(), + ]); + } + + public function create(Project $project): Response + { + return Inertia::render('project-templates/Create', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'project' => ProjectResource::make($project)->resolve(), + ]); + } + + public function store(StoreProjectTemplateRequest $request, Project $project): RedirectResponse + { + $template = $this->createProjectTemplate->handle($project, $request->validated()); + + return to_route('project-templates.index') + ->with('status', "Template {$template->name} was created."); + } + + public function update(UpdateProjectTemplateRequest $request, ProjectTemplate $projectTemplate): RedirectResponse + { + abort_if(! $projectTemplate->is_published, 404); + + $template = $this->updateProjectTemplate->handle($projectTemplate, $request->validated()); + + return to_route('project-templates.index') + ->with('status', "Template {$template->name} was updated."); + } + + public function destroy(ProjectTemplate $projectTemplate): RedirectResponse + { + abort_if(! $projectTemplate->is_published, 404); + + $templateName = $projectTemplate->name; + $projectTemplate->delete(); + + return to_route('project-templates.index') + ->with('status', "Template {$templateName} was deleted."); + } +} diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 42cfbb9..2d7ca63 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -6,6 +6,7 @@ use App\Actions\Permissions\PermissionService; use App\Actions\Projects\BuildProjectTicketDetailPayload; use App\Actions\Projects\BuildProjectTicketOptionsPayload; use App\Actions\Tickets\CreateProjectTicket; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Actions\Tickets\UpdateProjectTicket; use App\Enums\Permission; use App\Http\Requests\StoreProjectTicketRequest; @@ -40,11 +41,18 @@ class ProjectTicketController extends Controller return Inertia::render( 'projects/TicketCreate', - $this->buildProjectTicketOptionsPayload->handle( - $project, - Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog', - session('status'), - ), + [ + ...$this->buildProjectTicketOptionsPayload->handle( + $project, + Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog', + session('status'), + ), + 'canManageProject' => $this->permissionService->allows( + $user, + Permission::ProjectAdmin, + $project, + ), + ], ); } @@ -108,6 +116,9 @@ class ProjectTicketController extends Controller if ($request->expectsJson()) { return response()->json([ + 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds( + $ticket->acceptanceCriteria()->get(), + ), 'message' => $statusMessage, 'updated' => $wasUpdated, ]); diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index fce99ed..c2b4067 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; -use App\Actions\Projects\BuildProjectTicketMetadataSettingsPayload; use App\Actions\ProjectTicketMetadata\DeleteProjectTicketPriority; use App\Actions\ProjectTicketMetadata\DeleteProjectTicketType; use App\Actions\ProjectTicketMetadata\DeleteTicketFieldDefinition; @@ -18,6 +17,7 @@ use App\Actions\ProjectTicketMetadata\UpdateProjectTicketType; use App\Actions\ProjectTicketMetadata\UpdateTicketFieldDefinition; use App\Actions\ProjectTicketMetadata\UpdateTicketLinkType; use App\Enums\Permission; +use App\Enums\ProjectSettingsSection; use App\Http\Requests\DestroyProjectTicketTypeRequest; use App\Http\Requests\StoreProjectNamedSettingRequest; use App\Http\Requests\StoreProjectTicketTypeRequest; @@ -35,13 +35,10 @@ use App\Models\TicketPriority; use App\Models\TicketType; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Inertia\Inertia; -use Inertia\Response; class ProjectTicketMetadataSettingsController extends Controller { public function __construct( - private readonly BuildProjectTicketMetadataSettingsPayload $buildProjectTicketMetadataSettingsPayload, private readonly DeleteProjectTicketPriority $deleteProjectTicketPriority, private readonly DeleteProjectTicketType $deleteProjectTicketType, private readonly DeleteTicketFieldDefinition $deleteTicketFieldDefinition, @@ -58,23 +55,13 @@ class ProjectTicketMetadataSettingsController extends Controller private readonly UpdateTicketLinkType $updateTicketLinkType, ) {} - public function show(Request $request, Project $project): Response - { - $this->abortUnlessCanManage($request, $project); - - return Inertia::render( - 'projects/settings/Metadata', - $this->buildProjectTicketMetadataSettingsPayload->handle($project, session('status'), session('error')), - ); - } - public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse { $this->abortUnlessCanManage($request, $project); $this->updateProjectTicketMetadataSettings->handle($project, $request->validated()); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', 'Settings have been updated.'); } @@ -84,7 +71,7 @@ class ProjectTicketMetadataSettingsController extends Controller $ticketType = $this->storeProjectTicketType->handle($project, $request->validated()); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketType->name} type was added."); } @@ -97,7 +84,7 @@ class ProjectTicketMetadataSettingsController extends Controller $ticketType = $this->updateProjectTicketType->handle($project, $ticketType, $request->validated()); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketType->fresh()->name} type was updated."); } @@ -116,12 +103,12 @@ class ProjectTicketMetadataSettingsController extends Controller ); if (! $result['deleted']) { - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('error', true) ->with('status', $result['message']); } - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', $result['message']); } @@ -131,7 +118,7 @@ class ProjectTicketMetadataSettingsController extends Controller $ticketPriority = $this->storeProjectTicketPriority->handle($project, $request->validated()); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketPriority->name} priority was added."); } @@ -144,7 +131,7 @@ class ProjectTicketMetadataSettingsController extends Controller $ticketPriority = $this->updateProjectTicketPriority->handle($project, $ticketPriority, $request->validated()); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketPriority->fresh()->name} priority was updated."); } @@ -158,12 +145,12 @@ class ProjectTicketMetadataSettingsController extends Controller $result = $this->deleteProjectTicketPriority->handle($project, $ticketPriority); if (! $result['deleted']) { - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('error', true) ->with('status', $result['message']); } - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', $result['message']); } @@ -175,7 +162,7 @@ class ProjectTicketMetadataSettingsController extends Controller $ticketFieldDefinition = $this->storeTicketFieldDefinition->handle($project, $request->validated()); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketFieldDefinition->label} field was added."); } @@ -188,7 +175,7 @@ class ProjectTicketMetadataSettingsController extends Controller $ticketFieldDefinition = $this->updateTicketFieldDefinition->handle($project, $ticketFieldDefinition, $request->validated()); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketFieldDefinition->fresh()->label} field was updated."); } @@ -201,7 +188,7 @@ class ProjectTicketMetadataSettingsController extends Controller $deletedLabel = $this->deleteTicketFieldDefinition->handle($project, $ticketFieldDefinition); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$deletedLabel} field was removed."); } @@ -211,7 +198,7 @@ class ProjectTicketMetadataSettingsController extends Controller $ticketLinkType = $this->storeTicketLinkType->handle($project, $request->validated()); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketLinkType->label} link type was added."); } @@ -225,7 +212,7 @@ class ProjectTicketMetadataSettingsController extends Controller $ticketLinkType = $this->updateTicketLinkType->handle($project, $ticketLinkType, $request->validated()); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$ticketLinkType->fresh()->label} link type was updated."); } @@ -238,7 +225,7 @@ class ProjectTicketMetadataSettingsController extends Controller $deletedLabel = $this->deleteTicketLinkType->handle($project, $ticketLinkType); - return to_route('projects.settings.metadata', $project) + return $this->settingsRedirect($project) ->with('status', "{$deletedLabel} link type was removed."); } @@ -249,4 +236,12 @@ class ProjectTicketMetadataSettingsController extends Controller 403, ); } + + private function settingsRedirect(Project $project): RedirectResponse + { + return to_route('projects.settings.index', [ + 'project' => $project, + 'section' => ProjectSettingsSection::Metadata->value, + ]); + } } diff --git a/app/Http/Requests/Api/MoveProjectTicketRequest.php b/app/Http/Requests/Api/MoveProjectTicketRequest.php index 6f87219..1ddccfa 100644 --- a/app/Http/Requests/Api/MoveProjectTicketRequest.php +++ b/app/Http/Requests/Api/MoveProjectTicketRequest.php @@ -24,12 +24,17 @@ class MoveProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'ticket_status_id' => [ 'required', 'integer', - Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + Rule::exists('ticket_statuses', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], ]; } diff --git a/app/Http/Requests/DestroyProjectTicketTypeRequest.php b/app/Http/Requests/DestroyProjectTicketTypeRequest.php index 97bc396..64a0617 100644 --- a/app/Http/Requests/DestroyProjectTicketTypeRequest.php +++ b/app/Http/Requests/DestroyProjectTicketTypeRequest.php @@ -29,12 +29,17 @@ class DestroyProjectTicketTypeRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'replacement_ticket_type_id' => [ 'nullable', 'integer', - Rule::exists('ticket_types', 'id')->where('project_id', $project->id), + Rule::exists('ticket_types', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], ]; } diff --git a/app/Http/Requests/MoveProjectTicketRequest.php b/app/Http/Requests/MoveProjectTicketRequest.php index 29d080a..5ed7e1f 100644 --- a/app/Http/Requests/MoveProjectTicketRequest.php +++ b/app/Http/Requests/MoveProjectTicketRequest.php @@ -29,12 +29,17 @@ class MoveProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'ticket_status_id' => [ 'required', 'integer', - Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + Rule::exists('ticket_statuses', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], 'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])], ]; diff --git a/app/Http/Requests/ProjectSettingsRequest.php b/app/Http/Requests/ProjectSettingsRequest.php new file mode 100644 index 0000000..96233de --- /dev/null +++ b/app/Http/Requests/ProjectSettingsRequest.php @@ -0,0 +1,43 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'section' => ['required', Rule::enum(ProjectSettingsSection::class)], + ]; + } + + protected function prepareForValidation(): void + { + $section = Arr::get($this->query(), 'section'); + + $this->merge([ + 'section' => is_string($section) && ProjectSettingsSection::tryFrom($section) !== null + ? $section + : ProjectSettingsSection::General->value, + ]); + } +} diff --git a/app/Http/Requests/ResetProjectTemplateRequest.php b/app/Http/Requests/ResetProjectTemplateRequest.php new file mode 100644 index 0000000..25b0dda --- /dev/null +++ b/app/Http/Requests/ResetProjectTemplateRequest.php @@ -0,0 +1,43 @@ +|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'confirmation_key' => [ + 'required', + 'string', + Rule::in([$project->key]), + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.', + ]; + } +} diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php index 6c7eeb0..2e28543 100644 --- a/app/Http/Requests/StoreProjectRequest.php +++ b/app/Http/Requests/StoreProjectRequest.php @@ -2,6 +2,10 @@ namespace App\Http\Requests; +use App\Actions\Permissions\PermissionService; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Enums\Permission; +use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Arr; @@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest protected function prepareForValidation(): void { $key = Arr::get($this->all(), 'key'); + $creationMode = Arr::get($this->all(), 'creation_mode', 'scratch'); + $copyAreas = Arr::get($this->all(), 'copy_areas', []); if ($key !== null) { $this->merge([ 'key' => strtoupper(trim((string) $key)), ]); } + + $this->merge([ + 'creation_mode' => is_string($creationMode) ? trim($creationMode) : 'scratch', + 'copy_areas' => is_array($copyAreas) + ? array_values($copyAreas) + : ($copyAreas === null || $copyAreas === '' ? [] : [(string) $copyAreas]), + ]); } /** @@ -36,6 +49,7 @@ class StoreProjectRequest extends FormRequest public function rules(): array { return [ + 'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])], 'key' => [ 'required', 'string', @@ -44,6 +58,51 @@ class StoreProjectRequest extends FormRequest Rule::unique('projects', 'key'), ], 'name' => ['required', 'string', 'max:255'], + 'source_project_id' => [ + Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'duplicate'), + 'nullable', + 'integer', + Rule::exists('projects', 'id'), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === null || $value === '') { + return; + } + + $project = Project::query()->find($value); + + if ($project === null) { + return; + } + + $permissionService = app(PermissionService::class); + + if (! $permissionService->allows($this->user(), Permission::ProjectRead, $project)) { + $fail('Select a project you can access as the duplication source.'); + } + }, + ], + 'source_template_id' => [ + Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'), + 'nullable', + 'integer', + Rule::exists('project_templates', 'id')->where(fn ($query) => $query->where('is_published', true)), + ], + 'copy_areas' => ['nullable', 'array'], + 'copy_areas.*' => [ + 'string', + Rule::in(DuplicateProjectConfiguration::copyableAreas()), + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'source_project_id.required' => 'Select a source project to duplicate from.', + 'source_template_id.required' => 'Select a template to create the project from.', ]; } } diff --git a/app/Http/Requests/StoreProjectTemplateRequest.php b/app/Http/Requests/StoreProjectTemplateRequest.php new file mode 100644 index 0000000..a210f53 --- /dev/null +++ b/app/Http/Requests/StoreProjectTemplateRequest.php @@ -0,0 +1,39 @@ +user()?->can('create-project') ?? false; + } + + protected function prepareForValidation(): void + { + if ($this->filled('slug')) { + $this->merge([ + 'slug' => Str::slug((string) $this->input('slug')), + ]); + } + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')], + 'copy_areas' => ['required', 'array', 'min:1'], + 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())], + ]; + } +} diff --git a/app/Http/Requests/StoreProjectTicketBranchRequest.php b/app/Http/Requests/StoreProjectTicketBranchRequest.php index 2d3e10b..9e1f88f 100644 --- a/app/Http/Requests/StoreProjectTicketBranchRequest.php +++ b/app/Http/Requests/StoreProjectTicketBranchRequest.php @@ -2,8 +2,10 @@ namespace App\Http\Requests; +use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StoreProjectTicketBranchRequest extends FormRequest { @@ -22,9 +24,21 @@ class StoreProjectTicketBranchRequest extends FormRequest */ public function rules(): array { + /** @var Project $project */ + $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; + return [ 'branch_name' => ['required', 'string', 'max:255'], - 'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'], + 'repository_connection_id' => [ + 'required', + 'integer', + Rule::exists('repository_connections', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), + ], ]; } } diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index 59036c9..2c90e92 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -48,6 +48,9 @@ class StoreProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'acceptance_criteria' => ['nullable', 'array'], @@ -66,17 +69,23 @@ class StoreProjectTicketRequest extends FormRequest 'ticket_priority_id' => [ 'required', 'integer', - Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id), + Rule::exists('ticket_priorities', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], 'ticket_status_id' => [ 'required', 'integer', - Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + Rule::exists('ticket_statuses', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], 'ticket_type_id' => [ 'required', 'integer', - Rule::exists('ticket_types', 'id')->where('project_id', $project->id), + Rule::exists('ticket_types', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], 'story_points' => ['nullable', 'integer', 'min:0'], 'title' => ['required', 'string', 'max:255'], diff --git a/app/Http/Requests/StoreTicketLinkRequest.php b/app/Http/Requests/StoreTicketLinkRequest.php index 50a299a..65904c6 100644 --- a/app/Http/Requests/StoreTicketLinkRequest.php +++ b/app/Http/Requests/StoreTicketLinkRequest.php @@ -28,6 +28,9 @@ class StoreTicketLinkRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'linked_ticket_id' => [ @@ -38,7 +41,9 @@ class StoreTicketLinkRequest extends FormRequest 'ticket_link_type_id' => [ 'required', 'integer', - Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id), + Rule::exists('ticket_link_types', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], ]; } diff --git a/app/Http/Requests/StoreTicketStatusRequest.php b/app/Http/Requests/StoreTicketStatusRequest.php index 43d4fb8..696c35f 100644 --- a/app/Http/Requests/StoreTicketStatusRequest.php +++ b/app/Http/Requests/StoreTicketStatusRequest.php @@ -26,12 +26,17 @@ class StoreTicketStatusRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'board_column_id' => [ 'nullable', 'integer', - Rule::exists('board_columns', 'id')->where('project_id', $project->id), + Rule::exists('board_columns', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], 'name' => ['required', 'string', 'max:255'], ]; diff --git a/app/Http/Requests/SwitchProjectTemplateRequest.php b/app/Http/Requests/SwitchProjectTemplateRequest.php new file mode 100644 index 0000000..4d898a9 --- /dev/null +++ b/app/Http/Requests/SwitchProjectTemplateRequest.php @@ -0,0 +1,51 @@ +|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'confirmation_key' => [ + 'required', + 'string', + Rule::in([$project->key]), + ], + 'source_template_id' => [ + 'required', + 'integer', + Rule::exists(ProjectTemplate::class, 'id')->where('is_published', true), + Rule::notIn(array_filter([$project->project_template_id])), + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirmation_key.in' => 'Enter the exact project key to confirm the template switch.', + 'source_template_id.not_in' => 'Choose a different published template to switch this project.', + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectTemplateRequest.php b/app/Http/Requests/UpdateProjectTemplateRequest.php new file mode 100644 index 0000000..85bfdfe --- /dev/null +++ b/app/Http/Requests/UpdateProjectTemplateRequest.php @@ -0,0 +1,47 @@ +user()?->can('create-project') ?? false; + } + + /** + * @return array|string> + */ + public function rules(): array + { + /** @var ProjectTemplate|null $projectTemplate */ + $projectTemplate = $this->route('projectTemplate'); + $requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'copy_areas' => ['required', 'array', 'min:1'], + 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())], + 'confirm_linked_projects_update' => [ + Rule::requiredIf($requiresImpactConfirmation), + 'accepted', + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirm_linked_projects_update.accepted' => 'Acknowledge the linked project warning before saving template changes.', + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index 1db69e5..9b42395 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -51,6 +51,9 @@ class UpdateProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'acceptance_criteria' => ['nullable', 'array'], @@ -68,7 +71,9 @@ class UpdateProjectTicketRequest extends FormRequest 'field_values.*.ticket_field_definition_id' => [ 'required', 'integer', - Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id), + Rule::exists('ticket_field_definitions', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], 'field_values.*.value_number' => ['nullable', 'numeric'], 'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], @@ -81,12 +86,16 @@ class UpdateProjectTicketRequest extends FormRequest 'ticket_priority_id' => [ 'required', 'integer', - Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id), + Rule::exists('ticket_priorities', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], 'ticket_type_id' => [ 'required', 'integer', - Rule::exists('ticket_types', 'id')->where('project_id', $project->id), + Rule::exists('ticket_types', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], 'title' => ['required', 'string', 'max:255'], 'story_points' => ['nullable', 'integer', 'min:0'], @@ -141,7 +150,7 @@ class UpdateProjectTicketRequest extends FormRequest $requestedTicketTypeId = Arr::get($validated, 'ticket_type_id'); $requestedTicketType = TicketType::query() - ->where('project_id', $project->id) + ->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id) ->find($requestedTicketTypeId); if ( diff --git a/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php index c6c8681..60372ef 100644 --- a/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php +++ b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php @@ -29,12 +29,17 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'allowed_ticket_status_ids' => ['nullable', 'array'], 'allowed_ticket_status_ids.*' => [ 'integer', - Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + Rule::exists('ticket_statuses', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), ], ]; } diff --git a/app/Http/Requests/ValidateProjectTicketBranchRequest.php b/app/Http/Requests/ValidateProjectTicketBranchRequest.php index 92f4f2b..ebae66a 100644 --- a/app/Http/Requests/ValidateProjectTicketBranchRequest.php +++ b/app/Http/Requests/ValidateProjectTicketBranchRequest.php @@ -2,8 +2,10 @@ namespace App\Http\Requests; +use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class ValidateProjectTicketBranchRequest extends FormRequest { @@ -22,9 +24,21 @@ class ValidateProjectTicketBranchRequest extends FormRequest */ public function rules(): array { + /** @var Project $project */ + $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; + return [ 'branch_name' => ['required', 'string', 'max:255'], - 'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'], + 'repository_connection_id' => [ + 'required', + 'integer', + Rule::exists('repository_connections', 'id')->where( + fn ($query) => $query->where($ownershipColumn, $ownershipValue), + ), + ], ]; } } diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index bc34640..a4bafea 100644 --- a/app/Http/Resources/ProjectResource.php +++ b/app/Http/Resources/ProjectResource.php @@ -2,8 +2,10 @@ namespace App\Http\Resources; +use App\Actions\Projects\ResolveProjectTemplateState; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Arr; class ProjectResource extends JsonResource { @@ -14,10 +16,32 @@ class ProjectResource extends JsonResource */ public function toArray(Request $request): array { + $this->resource->loadMissing('projectTemplate'); + + $templateState = app(ResolveProjectTemplateState::class)->handle($this->resource); + return [ 'id' => $this->id, 'name' => $this->name, 'key' => $this->key, + 'project_template_id' => $this->project_template_id, + 'project_template' => $this->projectTemplate === null + ? null + : ProjectTemplateResource::make($this->projectTemplate)->resolve(), + 'template_state' => $templateState === null + ? null + : [ + 'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []), + 'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []), + 'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []), + 'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false), + 'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []), + 'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []), + 'template_configuration' => Arr::get($templateState, 'templateConfiguration', []), + 'template_id' => Arr::get($templateState, 'templateId'), + 'template_name' => Arr::get($templateState, 'templateName'), + 'template_slug' => Arr::get($templateState, 'templateSlug'), + ], 'show_author' => $this->show_author, 'show_story_points' => $this->show_story_points, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Http/Resources/ProjectTemplateResource.php b/app/Http/Resources/ProjectTemplateResource.php new file mode 100644 index 0000000..cdfbc4f --- /dev/null +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -0,0 +1,53 @@ + + */ + public function toArray(Request $request): array + { + if ($this->resource instanceof ProjectTemplate) { + return [ + 'id' => $this->id, + 'project_id' => $this->project_id, + 'source_project_id' => $this->source_project_id, + 'source_project' => $this->relationLoaded('sourceProject') && $this->sourceProject !== null + ? ProjectResource::make($this->sourceProject)->resolve() + : null, + 'name' => $this->name, + 'slug' => $this->slug, + 'is_published' => $this->is_published, + 'copy_areas' => $this->copy_areas ?? [], + 'configuration_snapshot' => $this->configuration_snapshot ?? [], + 'linked_project_count' => $this->whenCounted('linkedProjects'), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } + + return [ + 'id' => Arr::get($this->resource, 'id'), + 'project_id' => Arr::get($this->resource, 'project_id'), + 'source_project_id' => Arr::get($this->resource, 'source_project_id'), + 'source_project' => Arr::get($this->resource, 'source_project'), + 'name' => Arr::get($this->resource, 'name'), + 'slug' => Arr::get($this->resource, 'slug'), + 'is_published' => Arr::get($this->resource, 'is_published', true), + 'copy_areas' => Arr::get($this->resource, 'copy_areas', []), + 'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []), + 'linked_project_count' => Arr::get($this->resource, 'linked_project_count'), + 'created_at' => Arr::get($this->resource, 'created_at'), + 'updated_at' => Arr::get($this->resource, 'updated_at'), + ]; + } +} diff --git a/app/Models/BoardColumn.php b/app/Models/BoardColumn.php index 9a5c634..c1fb63b 100644 --- a/app/Models/BoardColumn.php +++ b/app/Models/BoardColumn.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\BoardColumnFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,11 +11,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; -#[Fillable(['name', 'position', 'project_id'])] +#[Fillable(['name', 'position', 'project_id', 'project_template_id'])] class BoardColumn extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $boardColumn): void { + $boardColumn->syncProjectTemplateOwnership(); + }); + } public function project(): BelongsTo { diff --git a/app/Models/Concerns/SyncsProjectTemplateOwnership.php b/app/Models/Concerns/SyncsProjectTemplateOwnership.php new file mode 100644 index 0000000..cf02f1d --- /dev/null +++ b/app/Models/Concerns/SyncsProjectTemplateOwnership.php @@ -0,0 +1,41 @@ +getAttribute('project_template_id'); + $projectId = $this->getAttribute('project_id'); + + if ($projectTemplateId === null && $projectId !== null) { + $projectTemplateId = Project::query() + ->whereKey($projectId) + ->value('project_template_id'); + + if ($projectTemplateId !== null) { + $this->setAttribute('project_template_id', $projectTemplateId); + } + } + + if ($projectId === null && $projectTemplateId !== null) { + $projectId = ProjectTemplate::query() + ->whereKey($projectTemplateId) + ->value('project_id'); + + if ($projectId !== null) { + $this->setAttribute('project_id', $projectId); + } + } + } + + public function projectTemplate(): BelongsTo + { + return $this->belongsTo(ProjectTemplate::class); + } +} diff --git a/app/Models/NotificationAction.php b/app/Models/NotificationAction.php index e9e6cc2..d2217c9 100644 --- a/app/Models/NotificationAction.php +++ b/app/Models/NotificationAction.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\NotificationActionFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -23,12 +24,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'message_template', 'name', 'project_id', + 'project_template_id', ])] #[Hidden(['delivery_url'])] class NotificationAction extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; /** * @return array @@ -45,6 +48,13 @@ class NotificationAction extends Model ]; } + protected static function booted(): void + { + static::saving(function (self $notificationAction): void { + $notificationAction->syncProjectTemplateOwnership(); + }); + } + public function scopeEnabled(Builder $query): Builder { return $query->where('enabled', true); diff --git a/app/Models/Project.php b/app/Models/Project.php index 51cd5f5..ec907cb 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Str; -#[Fillable(['name', 'key', 'show_author', 'show_story_points'])] +#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points'])] class Project extends Model { /** @use HasFactory */ @@ -44,6 +46,17 @@ class Project extends Model return $this->morphMany(PermissionAssignment::class, 'scope'); } + public function projectTemplate(): BelongsTo + { + return $this->belongsTo(ProjectTemplate::class, 'project_template_id'); + } + + public function baseTemplate(): HasOne + { + return $this->hasOne(ProjectTemplate::class) + ->where('is_published', false); + } + public function boardColumns(): HasMany { return $this->hasMany(BoardColumn::class) diff --git a/app/Models/ProjectTemplate.php b/app/Models/ProjectTemplate.php new file mode 100644 index 0000000..726e775 --- /dev/null +++ b/app/Models/ProjectTemplate.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'copy_areas' => 'array', + 'configuration_snapshot' => 'array', + 'is_published' => 'boolean', + ]; + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('is_published', true); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function sourceProject(): BelongsTo + { + return $this->belongsTo(Project::class, 'source_project_id'); + } + + public function linkedProjects(): HasMany + { + return $this->hasMany(Project::class, 'project_template_id'); + } +} diff --git a/app/Models/RepositoryConnection.php b/app/Models/RepositoryConnection.php index 44414e5..9095d6d 100644 --- a/app/Models/RepositoryConnection.php +++ b/app/Models/RepositoryConnection.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Actions\Git\Data\GitRepository; use App\Actions\Git\GitProviderKey; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\RepositoryConnectionFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'base_url', 'owner_name', 'project_id', + 'project_template_id', 'provider', 'provider_repository_id', 'repository_full_name', @@ -24,6 +26,7 @@ class RepositoryConnection extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; /** * @return array @@ -35,6 +38,13 @@ class RepositoryConnection extends Model ]; } + protected static function booted(): void + { + static::saving(function (self $repositoryConnection): void { + $repositoryConnection->syncProjectTemplateOwnership(); + }); + } + public function project(): BelongsTo { return $this->belongsTo(Project::class); diff --git a/app/Models/TicketFieldDefinition.php b/app/Models/TicketFieldDefinition.php index 017a758..b5aaf27 100644 --- a/app/Models/TicketFieldDefinition.php +++ b/app/Models/TicketFieldDefinition.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketFieldDefinitionFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['field_type', 'label', 'position', 'project_id'])] +#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])] class TicketFieldDefinition extends Model { /** @@ -22,6 +23,14 @@ class TicketFieldDefinition extends Model /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $ticketFieldDefinition): void { + $ticketFieldDefinition->syncProjectTemplateOwnership(); + }); + } public function project(): BelongsTo { diff --git a/app/Models/TicketLinkType.php b/app/Models/TicketLinkType.php index f66f50e..d34c2f6 100644 --- a/app/Models/TicketLinkType.php +++ b/app/Models/TicketLinkType.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketLinkTypeFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])] +#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])] class TicketLinkType extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $ticketLinkType): void { + $ticketLinkType->syncProjectTemplateOwnership(); + }); + } public function counterpartLabel(): string { diff --git a/app/Models/TicketPriority.php b/app/Models/TicketPriority.php index 92b1f9c..1f37f56 100644 --- a/app/Models/TicketPriority.php +++ b/app/Models/TicketPriority.php @@ -2,17 +2,26 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketPriorityFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -#[Fillable(['name', 'position', 'project_id'])] +#[Fillable(['name', 'position', 'project_id', 'project_template_id'])] class TicketPriority extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $ticketPriority): void { + $ticketPriority->syncProjectTemplateOwnership(); + }); + } public function project(): BelongsTo { diff --git a/app/Models/TicketStatus.php b/app/Models/TicketStatus.php index e215340..0bfe7fe 100644 --- a/app/Models/TicketStatus.php +++ b/app/Models/TicketStatus.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketStatusFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,15 +12,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Str; -#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'slug'])] +#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])] class TicketStatus extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; protected static function booted(): void { static::saving(function (self $ticketStatus): void { + if ($ticketStatus->project_template_id === null && $ticketStatus->board_column_id !== null) { + $ticketStatus->project_template_id = BoardColumn::query() + ->whereKey($ticketStatus->board_column_id) + ->value('project_template_id'); + } + + $ticketStatus->syncProjectTemplateOwnership(); $ticketStatus->slug = Str::slug($ticketStatus->name); }); } diff --git a/app/Models/TicketType.php b/app/Models/TicketType.php index 22cff08..0a38385 100644 --- a/app/Models/TicketType.php +++ b/app/Models/TicketType.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketTypeFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['name', 'position', 'project_id', 'is_parent'])] +#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])] class TicketType extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $ticketType): void { + $ticketType->syncProjectTemplateOwnership(); + }); + } protected function casts(): array { diff --git a/database/factories/ProjectTemplateFactory.php b/database/factories/ProjectTemplateFactory.php new file mode 100644 index 0000000..2751f96 --- /dev/null +++ b/database/factories/ProjectTemplateFactory.php @@ -0,0 +1,36 @@ + + */ +class ProjectTemplateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'source_project_id' => Project::factory(), + 'name' => Str::title(fake()->unique()->words(3, true)), + 'slug' => fake()->unique()->slug(), + 'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], + 'configuration_snapshot' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => true, + 'show_story_points' => true, + ], + ], + ]; + } +} diff --git a/database/migrations/2026_05_25_011500_create_project_templates_table.php b/database/migrations/2026_05_25_011500_create_project_templates_table.php new file mode 100644 index 0000000..3147e8a --- /dev/null +++ b/database/migrations/2026_05_25_011500_create_project_templates_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('source_project_id')->nullable()->constrained('projects')->nullOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->json('copy_areas'); + $table->json('configuration_snapshot'); + $table->timestamps(); + + $table->index(['source_project_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('project_templates'); + } +}; diff --git a/database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php b/database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php new file mode 100644 index 0000000..c0c5489 --- /dev/null +++ b/database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php @@ -0,0 +1,54 @@ +foreignId('project_id')->nullable()->after('id')->constrained('projects')->nullOnDelete(); + $table->boolean('is_published')->default(true)->after('slug'); + }); + + Schema::table('projects', function (Blueprint $table): void { + $table->foreignId('project_template_id')->nullable()->after('key')->constrained('project_templates')->nullOnDelete(); + }); + + $projects = Project::query()->whereNull('project_template_id')->get(); + $createProjectBaseTemplate = app(CreateProjectBaseTemplate::class); + + foreach ($projects as $project) { + $baseTemplate = $createProjectBaseTemplate->handle($project); + + $project->forceFill([ + 'project_template_id' => $baseTemplate->id, + ])->save(); + } + + ProjectTemplate::query() + ->whereNull('project_id') + ->update(['is_published' => true]); + } + + public function down(): void + { + Project::query()->update(['project_template_id' => null]); + + ProjectTemplate::query()->where('is_published', false)->delete(); + + Schema::table('projects', function (Blueprint $table): void { + $table->dropConstrainedForeignId('project_template_id'); + }); + + Schema::table('project_templates', function (Blueprint $table): void { + $table->dropConstrainedForeignId('project_id'); + $table->dropColumn('is_published'); + }); + } +}; diff --git a/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php b/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php new file mode 100644 index 0000000..01838fc --- /dev/null +++ b/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php @@ -0,0 +1,116 @@ + + */ + private array $tables = [ + 'board_columns', + 'ticket_field_definitions', + 'ticket_link_types', + 'ticket_priorities', + 'ticket_statuses', + 'ticket_types', + 'repository_connections', + 'notification_actions', + ]; + + public function up(): void + { + foreach ($this->tables as $table) { + Schema::table($table, function (Blueprint $table): void { + $table->foreignId('project_template_id') + ->nullable() + ->after('project_id') + ->constrained('project_templates') + ->nullOnDelete(); + }); + } + + $projects = DB::table('projects') + ->leftJoin('project_templates', 'project_templates.id', '=', 'projects.project_template_id') + ->select([ + 'projects.id', + 'projects.key', + 'projects.name', + 'projects.project_template_id', + 'project_templates.project_id as template_project_id', + ]) + ->get(); + + foreach ($projects as $project) { + $projectTemplateId = $project->project_template_id; + + if ($projectTemplateId === null || (int) ($project->template_project_id ?? 0) !== (int) $project->id) { + $projectTemplateId = DB::table('project_templates')->insertGetId([ + 'project_id' => $project->id, + 'source_project_id' => $project->id, + 'name' => $project->name.' base template', + 'slug' => Str::lower($project->key).'-base-template', + 'is_published' => false, + 'copy_areas' => json_encode([ + 'project_settings', + 'board_workflow', + 'ticket_types', + 'ticket_priorities', + 'ticket_fields', + 'ticket_link_types', + 'repository_connections', + 'notification_actions', + ], JSON_THROW_ON_ERROR), + 'configuration_snapshot' => json_encode([], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('projects') + ->where('id', $project->id) + ->update([ + 'project_template_id' => $projectTemplateId, + 'updated_at' => now(), + ]); + } + + foreach ($this->tables as $table) { + DB::table($table) + ->where('project_id', $project->id) + ->update(['project_template_id' => $projectTemplateId]); + } + + $projectModel = Project::query()->find($project->id); + + if ($projectModel !== null) { + DB::table('project_templates') + ->where('id', $projectTemplateId) + ->update([ + 'configuration_snapshot' => json_encode( + app(DuplicateProjectConfiguration::class)->export( + $projectModel, + DuplicateProjectConfiguration::copyableAreas(), + ), + JSON_THROW_ON_ERROR, + ), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + foreach (array_reverse($this->tables) as $table) { + Schema::table($table, function (Blueprint $table): void { + $table->dropConstrainedForeignId('project_template_id'); + }); + } + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 6f9d1f5..9543c5b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,6 @@ -@import 'tailwindcss'; +@import "tailwindcss"; -@import 'tw-animate-css'; +@import "tw-animate-css"; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; @@ -9,13 +9,69 @@ @theme inline { --font-sans: - Instrument Sans, ui-sans-serif, system-ui, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; + Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; + --font-headline-lg: + Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; + --font-headline-lg-mobile: + Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; + --font-headline-md: + Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; + --font-body-md: + Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; + --font-body-sm: + Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; + --font-label-uppercase: + Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); + --radius-sm: 0.25rem; + --radius-md: 0.25rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + + --spacing-container-margin: 1.5rem; + --spacing-column-gap: 1rem; + --spacing-card-padding: 0.75rem; + --spacing-stack-gap-sm: 0.5rem; + --spacing-stack-gap-md: 0.75rem; + + --text-headline-lg: 1.5rem; + --text-headline-lg--line-height: 2rem; + --text-headline-lg--font-weight: 600; + --text-headline-lg--letter-spacing: -0.025em; + --text-headline-md: 1.125rem; + --text-headline-md--line-height: 1.5rem; + --text-headline-md--font-weight: 600; + --text-headline-md--letter-spacing: -0.015em; + --text-headline-lg-mobile: 1.25rem; + --text-headline-lg-mobile--line-height: 1.75rem; + --text-headline-lg-mobile--font-weight: 600; + --text-body-md: 0.875rem; + --text-body-md--line-height: 1.25rem; + --text-body-md--font-weight: 400; + --text-body-sm: 0.8125rem; + --text-body-sm--line-height: 1.125rem; + --text-body-sm--font-weight: 400; + --text-label-uppercase: 0.6875rem; + --text-label-uppercase--line-height: 1rem; + --text-label-uppercase--font-weight: 700; + --text-label-uppercase--letter-spacing: 0.05em; + + --shadow-kanban-card: 0 4px 12px rgb(0 0 0 / 0.08); + --shadow-kanban-nav: 0 12px 30px rgb(15 23 42 / 0.06); --color-background: var(--background); --color-foreground: var(--foreground); @@ -59,6 +115,54 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + + --color-surface: var(--surface); + --color-surface-dim: var(--surface-dim); + --color-surface-bright: var(--surface-bright); + --color-surface-container-lowest: var(--surface-container-lowest); + --color-surface-container-low: var(--surface-container-low); + --color-surface-container: var(--surface-container); + --color-surface-container-high: var(--surface-container-high); + --color-surface-container-highest: var(--surface-container-highest); + --color-surface-variant: var(--surface-variant); + --color-surface-tint: var(--surface-tint); + --color-on-surface: var(--on-surface); + --color-on-surface-variant: var(--on-surface-variant); + --color-on-background: var(--on-background); + --color-outline: var(--outline); + --color-outline-variant: var(--outline-variant); + --color-primary-container: var(--primary-container); + --color-on-primary: var(--on-primary); + --color-on-primary-container: var(--on-primary-container); + --color-primary-fixed: var(--primary-fixed); + --color-primary-fixed-dim: var(--primary-fixed-dim); + --color-on-primary-fixed: var(--on-primary-fixed); + --color-on-primary-fixed-variant: var(--on-primary-fixed-variant); + --color-secondary-container: var(--secondary-container); + --color-on-secondary: var(--on-secondary); + --color-on-secondary-container: var(--on-secondary-container); + --color-secondary-fixed: var(--secondary-fixed); + --color-secondary-fixed-dim: var(--secondary-fixed-dim); + --color-on-secondary-fixed: var(--on-secondary-fixed); + --color-on-secondary-fixed-variant: var(--on-secondary-fixed-variant); + --color-tertiary: var(--tertiary); + --color-tertiary-container: var(--tertiary-container); + --color-on-tertiary: var(--on-tertiary); + --color-on-tertiary-container: var(--on-tertiary-container); + --color-tertiary-fixed: var(--tertiary-fixed); + --color-tertiary-fixed-dim: var(--tertiary-fixed-dim); + --color-on-tertiary-fixed: var(--on-tertiary-fixed); + --color-on-tertiary-fixed-variant: var(--on-tertiary-fixed-variant); + --color-error: var(--error); + --color-error-container: var(--error-container); + --color-on-error: var(--on-error); + --color-on-error-container: var(--on-error-container); + --color-inverse-surface: var(--inverse-surface); + --color-inverse-on-surface: var(--inverse-on-surface); + --color-inverse-primary: var(--inverse-primary); + --color-priority-high: #ef4444; + --color-priority-medium: #f59e0b; + --color-priority-low: #3b82f6; } /* @@ -83,83 +187,171 @@ body, html { --font-sans: - 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; + Inter, "Instrument Sans", ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; } } :root { - --background: hsl(0 0% 100%); - --foreground: hsl(0 0% 3.9%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(0 0% 3.9%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(0 0% 3.9%); - --primary: hsl(0 0% 9%); - --primary-foreground: hsl(0 0% 98%); - --secondary: hsl(0 0% 92.1%); - --secondary-foreground: hsl(0 0% 9%); - --muted: hsl(0 0% 96.1%); - --muted-foreground: hsl(0 0% 45.1%); - --accent: hsl(0 0% 96.1%); - --accent-foreground: hsl(0 0% 9%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 92.8%); - --input: hsl(0 0% 89.8%); - --ring: hsl(0 0% 3.9%); - --chart-1: hsl(12 76% 61%); - --chart-2: hsl(173 58% 39%); - --chart-3: hsl(197 37% 24%); - --chart-4: hsl(43 74% 66%); - --chart-5: hsl(27 87% 67%); - --radius: 0.5rem; - --sidebar-background: hsl(0 0% 98%); - --sidebar-foreground: hsl(240 5.3% 26.1%); - --sidebar-primary: hsl(0 0% 10%); - --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(0 0% 94%); - --sidebar-accent-foreground: hsl(0 0% 30%); - --sidebar-border: hsl(0 0% 91%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); - --sidebar: hsl(0 0% 98%); + --background: #f7f9fb; + --foreground: #191c1e; + --card: #ffffff; + --card-foreground: #191c1e; + --popover: #ffffff; + --popover-foreground: #191c1e; + --primary: #006e2f; + --primary-foreground: #ffffff; + --secondary: #d0e1fb; + --secondary-foreground: #0b1c30; + --muted: #f2f4f6; + --muted-foreground: #3d4a3d; + --accent: #e6e8ea; + --accent-foreground: #191c1e; + --destructive: #ba1a1a; + --destructive-foreground: #ffffff; + --border: #bccbb9; + --input: #bccbb9; + --ring: #006e2f; + --chart-1: #22c55e; + --chart-2: #3b82f6; + --chart-3: #f59e0b; + --chart-4: #ef4444; + --chart-5: #505f76; + --radius: 0.25rem; + --surface: #f7f9fb; + --surface-dim: #d8dadc; + --surface-bright: #f7f9fb; + --surface-container-lowest: #ffffff; + --surface-container-low: #f2f4f6; + --surface-container: #eceef0; + --surface-container-high: #e6e8ea; + --surface-container-highest: #e0e3e5; + --surface-variant: #e0e3e5; + --surface-tint: #006e2f; + --on-surface: #191c1e; + --on-surface-variant: #3d4a3d; + --on-background: #191c1e; + --outline: #6d7b6c; + --outline-variant: #bccbb9; + --primary-container: #22c55e; + --on-primary: #ffffff; + --on-primary-container: #004b1e; + --primary-fixed: #6bff8f; + --primary-fixed-dim: #4ae176; + --on-primary-fixed: #002109; + --on-primary-fixed-variant: #005321; + --secondary-container: #d0e1fb; + --on-secondary: #ffffff; + --on-secondary-container: #54647a; + --secondary-fixed: #d3e4fe; + --secondary-fixed-dim: #b7c8e1; + --on-secondary-fixed: #0b1c30; + --on-secondary-fixed-variant: #38485d; + --tertiary: #9e4036; + --tertiary-container: #ff8b7c; + --on-tertiary: #ffffff; + --on-tertiary-container: #76231b; + --tertiary-fixed: #ffdad5; + --tertiary-fixed-dim: #ffb4a9; + --on-tertiary-fixed: #410001; + --on-tertiary-fixed-variant: #7f2a21; + --error: #ba1a1a; + --error-container: #ffdad6; + --on-error: #ffffff; + --on-error-container: #93000a; + --inverse-surface: #2d3133; + --inverse-on-surface: #eff1f3; + --inverse-primary: #4ae176; + --sidebar-background: #f2f4f6; + --sidebar-foreground: #191c1e; + --sidebar-primary: #006e2f; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e6e8ea; + --sidebar-accent-foreground: #191c1e; + --sidebar-border: #bccbb9; + --sidebar-ring: #006e2f; + --sidebar: #f2f4f6; } .dark { - --background: hsl(0 0% 3.9%); - --foreground: hsl(0 0% 98%); - --card: hsl(0 0% 3.9%); - --card-foreground: hsl(0 0% 98%); - --popover: hsl(0 0% 3.9%); - --popover-foreground: hsl(0 0% 98%); - --primary: hsl(0 0% 98%); - --primary-foreground: hsl(0 0% 9%); - --secondary: hsl(0 0% 14.9%); - --secondary-foreground: hsl(0 0% 98%); - --muted: hsl(0 0% 16.08%); - --muted-foreground: hsl(0 0% 63.9%); - --accent: hsl(0 0% 14.9%); - --accent-foreground: hsl(0 0% 98%); - --destructive: hsl(0 84% 60%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 14.9%); - --input: hsl(0 0% 14.9%); - --ring: hsl(0 0% 83.1%); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); - --sidebar-background: hsl(0 0% 7%); - --sidebar-foreground: hsl(0 0% 95.9%); - --sidebar-primary: hsl(360, 100%, 100%); - --sidebar-primary-foreground: hsl(0 0% 100%); - --sidebar-accent: hsl(0 0% 15.9%); - --sidebar-accent-foreground: hsl(240 4.8% 95.9%); - --sidebar-border: hsl(0 0% 15.9%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); - --sidebar: hsl(240 5.9% 10%); + --background: #0b1326; + --foreground: #e5eaf5; + --card: #131b2e; + --card-foreground: #e5eaf5; + --popover: #171f33; + --popover-foreground: #e5eaf5; + --primary: #4be277; + --primary-foreground: #002109; + --secondary: #222a3d; + --secondary-foreground: #e5eaf5; + --muted: #171f33; + --muted-foreground: #aeb8cd; + --accent: #222a3d; + --accent-foreground: #f3f6fc; + --destructive: #ffb4ab; + --destructive-foreground: #690005; + --border: #3b4358; + --input: #4a5268; + --ring: #4be277; + --chart-1: #4be277; + --chart-2: #60a5fa; + --chart-3: #fbbf24; + --chart-4: #f87171; + --chart-5: #b7c8e1; + --surface: #0b1326; + --surface-dim: #060e20; + --surface-bright: #2d3449; + --surface-container-lowest: #060e20; + --surface-container-low: #131b2e; + --surface-container: #171f33; + --surface-container-high: #222a3d; + --surface-container-highest: #2d3449; + --surface-variant: #2d3449; + --surface-tint: #4be277; + --on-surface: #e5eaf5; + --on-surface-variant: #aeb8cd; + --on-background: #e5eaf5; + --outline: #8993aa; + --outline-variant: #3b4358; + --primary-container: #136c35; + --on-primary: #002109; + --on-primary-container: #bbf7d0; + --primary-fixed: #6bff8f; + --primary-fixed-dim: #4be277; + --on-primary-fixed: #002109; + --on-primary-fixed-variant: #005321; + --secondary-container: #1e3a5f; + --on-secondary: #ffffff; + --on-secondary-container: #d0e1fb; + --secondary-fixed: #d3e4fe; + --secondary-fixed-dim: #b7c8e1; + --on-secondary-fixed: #0b1c30; + --on-secondary-fixed-variant: #38485d; + --tertiary: #ffb4a9; + --tertiary-container: #7f2a21; + --on-tertiary: #410001; + --on-tertiary-container: #ffdad5; + --tertiary-fixed: #ffdad5; + --tertiary-fixed-dim: #ffb4a9; + --on-tertiary-fixed: #410001; + --on-tertiary-fixed-variant: #ffb4a9; + --error: #f87171; + --error-container: #7f1d1d; + --on-error: #ffffff; + --on-error-container: #fee2e2; + --inverse-surface: #eff1f3; + --inverse-on-surface: #191c1e; + --inverse-primary: #006e2f; + --sidebar-background: #060e20; + --sidebar-foreground: #e5eaf5; + --sidebar-primary: #4be277; + --sidebar-primary-foreground: #002109; + --sidebar-accent: #171f33; + --sidebar-accent-foreground: #f3f6fc; + --sidebar-border: #2d3449; + --sidebar-ring: #4be277; + --sidebar: #060e20; } @layer base { @@ -167,6 +359,74 @@ @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground; + @apply min-h-screen bg-background text-foreground antialiased; + letter-spacing: -0.006em; + } + + ::selection { + background: color-mix(in srgb, var(--primary) 24%, transparent); + color: var(--foreground); + } + + textarea, + select { + @apply border-input bg-surface-container-lowest text-foreground rounded-md border px-3 py-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow,background-color]; + background-color: var(--surface-container-lowest) !important; + } + + textarea:focus-visible, + select:focus-visible { + @apply border-ring ring-ring/25 ring-3; + } + + textarea:disabled, + select:disabled { + @apply cursor-not-allowed opacity-55; + } + + table { + @apply w-full border-separate border-spacing-0; + } + + thead { + @apply bg-surface-container-low text-on-surface-variant; + } + + tbody tr { + @apply transition-colors hover:bg-primary/3; + } + + th { + @apply text-label-uppercase font-bold tracking-[0.08em]; + } +} + +@layer components { + .app-page { + @apply mx-auto flex w-full max-w-[96rem] flex-1 flex-col gap-6 px-4 py-5 sm:px-6 sm:py-7 lg:px-8; + } + + .app-page-narrow { + @apply mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-5 sm:px-6 sm:py-7 lg:px-8; + } + + .page-actions { + @apply flex flex-wrap items-center gap-2; + } + + .status-success { + @apply border-primary/25 bg-primary/8 text-on-primary-container rounded-lg border px-4 py-3 text-sm dark:text-primary-fixed; + } + + .empty-state { + @apply border-outline-variant bg-surface-container-low text-on-surface-variant rounded-lg border border-dashed px-5 py-8 text-center text-sm; + } + + .table-shell { + @apply border-outline-variant bg-surface-container-lowest overflow-hidden rounded-xl border; + } + + .eyebrow { + @apply text-label-uppercase text-primary font-bold uppercase tracking-[0.12em]; } } diff --git a/resources/js/app.ts b/resources/js/app.ts index c098682..e4a6cf4 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -20,7 +20,7 @@ createInertiaApp({ .mount(el); }, progress: { - color: '#4B5563', + color: '#22c55e', }, }); diff --git a/resources/js/components/AppContent.vue b/resources/js/components/AppContent.vue index 555a1a6..1598df3 100644 --- a/resources/js/components/AppContent.vue +++ b/resources/js/components/AppContent.vue @@ -1,14 +1,9 @@ diff --git a/resources/js/components/AppLogo.vue b/resources/js/components/AppLogo.vue index 2de902d..f7e20d5 100644 --- a/resources/js/components/AppLogo.vue +++ b/resources/js/components/AppLogo.vue @@ -4,13 +4,13 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue'; diff --git a/resources/js/components/AppLogoIcon.vue b/resources/js/components/AppLogoIcon.vue index 9a26231..152cd3f 100644 --- a/resources/js/components/AppLogoIcon.vue +++ b/resources/js/components/AppLogoIcon.vue @@ -15,15 +15,15 @@ defineProps(); diff --git a/resources/js/components/AppShell.vue b/resources/js/components/AppShell.vue index e76af0b..f691bac 100644 --- a/resources/js/components/AppShell.vue +++ b/resources/js/components/AppShell.vue @@ -1,13 +1,9 @@