diff --git a/app-modules/catalog/database/factories/DepartureTimeSlotFactory.php b/app-modules/catalog/database/factories/DepartureTimeSlotFactory.php new file mode 100644 index 0000000..b69d6fc --- /dev/null +++ b/app-modules/catalog/database/factories/DepartureTimeSlotFactory.php @@ -0,0 +1,29 @@ + + */ +class DepartureTimeSlotFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $time = Carbon::createFromTime(fake()->numberBetween(0, 23), fake()->randomElement([0, 15, 30, 45])); + + return [ + 'label' => $time->format('h:i A'), + 'time' => $time->format('H:i'), + 'is_active' => true, + ]; + } +} diff --git a/app-modules/catalog/database/migrations/2026_08_06_130507_create_departure_time_slots_table.php b/app-modules/catalog/database/migrations/2026_08_06_130507_create_departure_time_slots_table.php new file mode 100644 index 0000000..0f3b8a8 --- /dev/null +++ b/app-modules/catalog/database/migrations/2026_08_06_130507_create_departure_time_slots_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('label'); + $table->time('time'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('departure_time_slots'); + } +}; diff --git a/app-modules/catalog/routes/catalog-routes.php b/app-modules/catalog/routes/catalog-routes.php index b3d9bbc..76821d8 100644 --- a/app-modules/catalog/routes/catalog-routes.php +++ b/app-modules/catalog/routes/catalog-routes.php @@ -1 +1,10 @@ middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { + Route::get('/companies', [EvCompanyController::class, 'index'])->name('catalog.companies.index'); + Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index'); +}); diff --git a/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/DepartureTimeSlotResource.php b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/DepartureTimeSlotResource.php new file mode 100644 index 0000000..5ebb71c --- /dev/null +++ b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/DepartureTimeSlotResource.php @@ -0,0 +1,51 @@ + ListDepartureTimeSlots::route('/'), + 'create' => CreateDepartureTimeSlot::route('/create'), + 'edit' => EditDepartureTimeSlot::route('/{record}/edit'), + ]; + } +} diff --git a/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Pages/CreateDepartureTimeSlot.php b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Pages/CreateDepartureTimeSlot.php new file mode 100644 index 0000000..6541a9b --- /dev/null +++ b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Pages/CreateDepartureTimeSlot.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('label') + ->required() + ->maxLength(255), + TimePicker::make('time') + ->required(), + Toggle::make('is_active') + ->required() + ->default(true), + ]); + } +} diff --git a/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Tables/DepartureTimeSlotsTable.php b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Tables/DepartureTimeSlotsTable.php new file mode 100644 index 0000000..c834559 --- /dev/null +++ b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Tables/DepartureTimeSlotsTable.php @@ -0,0 +1,45 @@ +columns([ + TextColumn::make('label') + ->searchable() + ->sortable(), + TextColumn::make('time') + ->time('H:i') + ->sortable(), + IconColumn::make('is_active') + ->boolean(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('time') + ->filters([ + TernaryFilter::make('is_active'), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/catalog/src/Http/Controllers/DestinationController.php b/app-modules/catalog/src/Http/Controllers/DestinationController.php new file mode 100644 index 0000000..afc9732 --- /dev/null +++ b/app-modules/catalog/src/Http/Controllers/DestinationController.php @@ -0,0 +1,18 @@ +where('is_active', true)->get() + ); + } +} diff --git a/app-modules/catalog/src/Http/Controllers/EvCompanyController.php b/app-modules/catalog/src/Http/Controllers/EvCompanyController.php new file mode 100644 index 0000000..078d340 --- /dev/null +++ b/app-modules/catalog/src/Http/Controllers/EvCompanyController.php @@ -0,0 +1,18 @@ +where('is_active', true)->get() + ); + } +} diff --git a/app-modules/catalog/src/Http/Resources/DestinationResource.php b/app-modules/catalog/src/Http/Resources/DestinationResource.php new file mode 100644 index 0000000..94cb2fd --- /dev/null +++ b/app-modules/catalog/src/Http/Resources/DestinationResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'mm_name' => $this->mm_name, + 'region' => $this->region, + 'description' => $this->description, + 'mm_description' => $this->mm_description, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'popular' => $this->popular, + ]; + } +} diff --git a/app-modules/catalog/src/Http/Resources/EvCompanyResource.php b/app-modules/catalog/src/Http/Resources/EvCompanyResource.php new file mode 100644 index 0000000..31fa521 --- /dev/null +++ b/app-modules/catalog/src/Http/Resources/EvCompanyResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'mm_name' => $this->mm_name, + 'slug' => $this->slug, + 'description' => $this->description, + 'mm_description' => $this->mm_description, + 'contact' => $this->contact, + 'address' => $this->address, + 'logo' => $this->logo, + ]; + } +} diff --git a/app-modules/catalog/src/Models/DepartureTimeSlot.php b/app-modules/catalog/src/Models/DepartureTimeSlot.php new file mode 100644 index 0000000..4841c97 --- /dev/null +++ b/app-modules/catalog/src/Models/DepartureTimeSlot.php @@ -0,0 +1,37 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'label', + 'time', + 'is_active', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'time' => 'datetime:H:i', + 'is_active' => 'boolean', + ]; + } +} diff --git a/app-modules/catalog/tests/Feature/CatalogReadApiTest.php b/app-modules/catalog/tests/Feature/CatalogReadApiTest.php new file mode 100644 index 0000000..622c4d4 --- /dev/null +++ b/app-modules/catalog/tests/Feature/CatalogReadApiTest.php @@ -0,0 +1,39 @@ +token = User::factory()->create()->createToken('test-token')->plainTextToken; +}); + +test('lists active ev companies', function () { + $active = EvCompany::factory()->create(['is_active' => true]); + EvCompany::factory()->create(['is_active' => false]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/companies') + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonFragment(['id' => $active->id]); +}); + +test('lists active destinations', function () { + $active = Destination::factory()->create(['is_active' => true]); + Destination::factory()->create(['is_active' => false]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/destinations') + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonFragment(['id' => $active->id]); +}); + +test('companies endpoint rejects unauthenticated requests', function () { + $this->getJson('/api/v1/companies')->assertUnauthorized(); +}); + +test('destinations endpoint rejects unauthenticated requests', function () { + $this->getJson('/api/v1/destinations')->assertUnauthorized(); +}); diff --git a/app-modules/catalog/tests/Feature/DepartureTimeSlotResourceTest.php b/app-modules/catalog/tests/Feature/DepartureTimeSlotResourceTest.php new file mode 100644 index 0000000..5cb76ef --- /dev/null +++ b/app-modules/catalog/tests/Feature/DepartureTimeSlotResourceTest.php @@ -0,0 +1,65 @@ +admin = User::factory()->create()->assignRole('admin'); + $this->actingAs($this->admin); +}); + +test('can list departure time slots', function () { + $timeSlots = DepartureTimeSlot::factory()->count(3)->create(); + + Livewire::test(ListDepartureTimeSlots::class) + ->assertOk() + ->assertCanSeeTableRecords($timeSlots); +}); + +test('can create a departure time slot', function () { + $timeSlot = DepartureTimeSlot::factory()->make(); + + Livewire::test(CreateDepartureTimeSlot::class) + ->fillForm([ + 'label' => $timeSlot->label, + 'time' => $timeSlot->time, + 'is_active' => true, + ]) + ->call('create') + ->assertNotified() + ->assertRedirect(); + + assertDatabaseHas(DepartureTimeSlot::class, [ + 'label' => $timeSlot->label, + ]); +}); + +test('can edit a departure time slot', function () { + $timeSlot = DepartureTimeSlot::factory()->create(); + + Livewire::test(EditDepartureTimeSlot::class, ['record' => $timeSlot->getRouteKey()]) + ->assertOk() + ->fillForm(['label' => 'Updated Label']) + ->call('save') + ->assertNotified(); + + assertDatabaseHas(DepartureTimeSlot::class, [ + 'id' => $timeSlot->id, + 'label' => 'Updated Label', + ]); +}); + +test('a departure time slot is a shared catalog entity not owned by a single route', function () { + $timeSlot = DepartureTimeSlot::factory()->create(); + + expect($timeSlot->getFillable())->not->toContain('ev_route_id'); +});