diff --git a/AGENTS.md b/AGENTS.md index 8d2c268..884e1c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,6 @@ This project has domain-specific skills available. You MUST activate the relevan - `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using ,
, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation. - `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. - `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features. -- `project-request-validation-style` — Always activate for this project when changing Laravel controllers or FormRequest classes. Use dedicated FormRequest classes for controller validation, access validated data through `$validated = $request->validated();` plus `Arr::get(...)`, avoid request helper extraction like `boolean()/integer()/string()`, and do not add custom single-field getter methods on FormRequest classes. ## Conventions @@ -103,7 +102,7 @@ This project has domain-specific skills available. You MUST activate the relevan - Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. - Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` - - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === @@ -210,7 +209,6 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti # Inertia + Vue Vue components must have a single root element. - - IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. diff --git a/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php index fc3470b..9f29d33 100644 --- a/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php +++ b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php @@ -3,40 +3,31 @@ namespace App\Actions\ProjectTicketMetadata; use App\Models\Project; -use App\Models\TicketType; class InitializeProjectTicketMetadata { public function handle(Project $project): void { - $project->ticketTypes()->firstOrCreate( - ['system_key' => 'task'], + $project->ticketTypes()->create( [ - 'is_builtin' => true, 'name' => 'Task', 'position' => 1, ], ); - $project->ticketTypes()->firstOrCreate( - ['system_key' => 'parent'], + $project->ticketTypes()->create( [ - 'is_builtin' => true, - 'name' => 'Parent', + 'name' => 'Release', + 'is_parent' => true, 'position' => 2, ], ); - $project->ticketTypes() - ->whereIn('system_key', TicketType::BUILTIN_KEYS) - ->orderBy('position') - ->get() - ->values() - ->each(function (TicketType $ticketType, int $index): void { - $ticketType->forceFill([ - 'is_builtin' => true, - 'position' => $index + 1, - ])->saveQuietly(); - }); + $project->ticketPriorities()->create( + [ + 'name' => 'Medium', + 'position' => 1, + ] + ); } } diff --git a/app/Console/Commands/CreateInitialAdminUser.php b/app/Console/Commands/CreateAdminUser.php similarity index 72% rename from app/Console/Commands/CreateInitialAdminUser.php rename to app/Console/Commands/CreateAdminUser.php index 337a37b..21d56ed 100644 --- a/app/Console/Commands/CreateInitialAdminUser.php +++ b/app/Console/Commands/CreateAdminUser.php @@ -9,9 +9,9 @@ use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; use Illuminate\Support\Facades\Hash; -#[Signature('app:create-initial-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')] +#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')] #[Description('Command description')] -class CreateInitialAdminUser extends Command +class CreateAdminUser extends Command { /** * Execute the console command. @@ -23,12 +23,13 @@ class CreateInitialAdminUser extends Command 'email' => $this->argument('email'), 'password' => Hash::make($this->argument('password')), ]); - $this->info('User created'); $user->permissionAssignments()->create([ 'permission' => Permission::GlobalAdmin, ]); + $this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password'))); + return self::SUCCESS; } } diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index 4913d8c..eb72a8d 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -3,9 +3,9 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; -use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Enums\Permission; use App\Http\Requests\StoreProjectNamedSettingRequest; +use App\Http\Requests\StoreProjectTicketTypeRequest; use App\Http\Requests\StoreTicketFieldDefinitionRequest; use App\Http\Requests\UpdateProjectNamedSettingRequest; use App\Http\Requests\UpdateTicketFieldDefinitionRequest; @@ -26,14 +26,12 @@ use Inertia\Response; class ProjectTicketMetadataSettingsController extends Controller { public function __construct( - private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly PermissionService $permissionService, ) {} public function show(Request $request, Project $project): Response { $this->abortUnlessCanManage($request, $project); - $this->initializeProjectTicketMetadata->handle($project); return Inertia::render('projects/settings/Metadata', [ 'fieldTypeOptions' => collect(TicketFieldDefinition::FIELD_TYPES) @@ -43,6 +41,7 @@ class ProjectTicketMetadataSettingsController extends Controller ]) ->all(), 'project' => $project, + 'error' => session('error'), 'status' => session('status'), 'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(), 'ticketLinkTypes' => $project->ticketLinkTypes()->get(), @@ -51,27 +50,23 @@ class ProjectTicketMetadataSettingsController extends Controller ]); } - public function storeType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse + public function storeType(StoreProjectTicketTypeRequest $request, Project $project): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $this->initializeProjectTicketMetadata->handle($project); $validated = $request->validated(); - $name = Arr::get($validated, 'name'); - $position = Arr::get($validated, 'position'); - $ticketType = DB::transaction(function () use ($project, $name, $position): TicketType { + $ticketType = DB::transaction(function () use ($project, $validated): TicketType { $ticketType = $project->ticketTypes()->create([ - 'is_builtin' => false, - 'name' => $name, + 'name' => Arr::get($validated, 'name'), 'position' => $project->ticketTypes()->count() + 1, - 'system_key' => null, + 'is_parent' => Arr::get($validated, 'is_parent', false), ]); $this->reorderModels( $project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(), $ticketType, - $position ?? $project->ticketTypes()->count(), + Arr::get($validated, 'position') ?? $project->ticketTypes()->count(), ); return $ticketType; @@ -87,21 +82,19 @@ class ProjectTicketMetadataSettingsController extends Controller TicketType $ticketType, ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $this->initializeProjectTicketMetadata->handle($project); $validated = $request->validated(); - $name = Arr::get($validated, 'name'); - $position = Arr::get($validated, 'position'); - DB::transaction(function () use ($project, $name, $position, $ticketType): void { + DB::transaction(function () use ($project, $validated, $ticketType): void { $ticketType->update([ - 'name' => $name, + 'name' => Arr::get($validated, 'name'), + 'is_parent' => Arr::get($validated, 'is_parent', false), ]); $this->reorderModels( $project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(), $ticketType, - $position, + Arr::get($validated, 'position'), ); }); @@ -113,7 +106,11 @@ class ProjectTicketMetadataSettingsController extends Controller { $this->abortUnlessCanManage($request, $project); - abort_if($ticketType->is_builtin, 403); + if ($project->ticketTypes()->count() === 1) { + return to_route('projects.settings.metadata', $project) + ->with('error', true) + ->with('status', 'The last ticket type cannot be deleted.'); + } $deletedName = $ticketType->name; @@ -188,6 +185,12 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); + if ($project->ticketPriorities()->count() === 1) { + return to_route('projects.settings.metadata', $project) + ->with('error', true) + ->with('status', 'The last ticket priority cannot be deleted.'); + } + $deletedName = $ticketPriority->name; DB::transaction(function () use ($project, $ticketPriority): void { @@ -207,21 +210,18 @@ class ProjectTicketMetadataSettingsController extends Controller $this->abortUnlessCanManage($request, $project); $validated = $request->validated(); - $label = Arr::get($validated, 'label'); - $fieldType = Arr::get($validated, 'field_type'); - $position = Arr::get($validated, 'position'); - $ticketFieldDefinition = DB::transaction(function () use ($project, $fieldType, $label): TicketFieldDefinition { + $ticketFieldDefinition = DB::transaction(function () use ($project, $validated): TicketFieldDefinition { $ticketFieldDefinition = $project->ticketFieldDefinitions()->create([ - 'field_type' => $fieldType, - 'label' => $label, + 'field_type' => Arr::get($validated, 'field_type'), + 'label' => Arr::get($validated, 'label'), 'position' => $project->ticketFieldDefinitions()->count() + 1, ]); $this->reorderModels( $project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(), $ticketFieldDefinition, - $position ?? $project->ticketFieldDefinitions()->count(), + Arr::get($validated, 'position') ?? $project->ticketFieldDefinitions()->count(), ); return $ticketFieldDefinition; @@ -239,20 +239,17 @@ class ProjectTicketMetadataSettingsController extends Controller $this->abortUnlessCanManage($request, $project); $validated = $request->validated(); - $label = Arr::get($validated, 'label'); - $fieldType = Arr::get($validated, 'field_type'); - $position = Arr::get($validated, 'position'); - DB::transaction(function () use ($project, $fieldType, $label, $ticketFieldDefinition, $position): void { + DB::transaction(function () use ($project, $ticketFieldDefinition, $validated): void { $ticketFieldDefinition->update([ - 'field_type' => $fieldType, - 'label' => $label, + 'field_type' => Arr::get($validated, 'field_type'), + 'label' => Arr::get($validated, 'label'), ]); $this->reorderModels( $project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(), $ticketFieldDefinition, - $position, + Arr::get($validated, 'position'), ); }); diff --git a/app/Http/Requests/StoreProjectTicketTypeRequest.php b/app/Http/Requests/StoreProjectTicketTypeRequest.php new file mode 100644 index 0000000..2d8f8fd --- /dev/null +++ b/app/Http/Requests/StoreProjectTicketTypeRequest.php @@ -0,0 +1,31 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'position' => ['nullable', 'integer', 'min:1'], + 'is_parent' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectNamedSettingRequest.php b/app/Http/Requests/UpdateProjectNamedSettingRequest.php index cb3f60c..4cbed99 100644 --- a/app/Http/Requests/UpdateProjectNamedSettingRequest.php +++ b/app/Http/Requests/UpdateProjectNamedSettingRequest.php @@ -25,6 +25,7 @@ class UpdateProjectNamedSettingRequest extends FormRequest return [ 'name' => ['required', 'string', 'max:255'], 'position' => ['required', 'integer', 'min:1'], + 'is_parent' => ['nullable', 'boolean'], ]; } } diff --git a/app/Models/TicketType.php b/app/Models/TicketType.php index 6021023..cb6d401 100644 --- a/app/Models/TicketType.php +++ b/app/Models/TicketType.php @@ -8,20 +8,19 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -#[Fillable(['is_builtin', 'name', 'position', 'project_id', 'system_key'])] +#[Fillable(['name', 'position', 'project_id', 'is_parent'])] class TicketType extends Model { - /** - * @var array - */ - public const BUILTIN_KEYS = [ - 'task', - 'parent', - ]; - /** @use HasFactory */ use HasFactory; + protected function casts(): array + { + return [ + 'is_parent' => 'boolean', + ]; + } + public function project(): BelongsTo { return $this->belongsTo(Project::class); diff --git a/database/migrations/2026_03_25_003100_create_ticket_types_table.php b/database/migrations/2026_03_25_003100_create_ticket_types_table.php index 938050b..39eaf30 100644 --- a/database/migrations/2026_03_25_003100_create_ticket_types_table.php +++ b/database/migrations/2026_03_25_003100_create_ticket_types_table.php @@ -15,13 +15,11 @@ return new class extends Migration $table->id(); $table->foreignId('project_id')->constrained()->cascadeOnDelete(); $table->string('name'); - $table->string('system_key')->nullable(); - $table->boolean('is_builtin')->default(false); + $table->boolean('is_parent')->default(false); $table->unsignedInteger('position'); $table->timestamps(); $table->unique(['project_id', 'position']); - $table->unique(['project_id', 'system_key']); }); } diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue index 4007c43..eb905b6 100644 --- a/resources/js/pages/projects/settings/Metadata.vue +++ b/resources/js/pages/projects/settings/Metadata.vue @@ -26,6 +26,7 @@ import type { BreadcrumbItem, ProjectTicketMetadataSettingsPageProps, } from '@/types'; +import { Checkbox } from '@/components/ui/checkbox'; const props = defineProps(); @@ -94,7 +95,12 @@ const breadcrumbs: BreadcrumbItem[] = [
{{ status }}
@@ -128,6 +134,16 @@ const breadcrumbs: BreadcrumbItem[] = [ placeholder="Bug" /> + + +