feat(tickets): implement admin app ticket management with listing and search functionality

This commit is contained in:
2026-08-28 15:10:23 -03:00
parent 6ee3f22e41
commit c792e7d306
7 changed files with 280 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Ticket\Controllers\AdminApp;
use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest;
use App\Domains\Ticket\Resources\TicketResource;
use App\Domains\Ticket\Services\AdminAppTicketService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class TicketController extends Controller
{
public function __construct(private readonly AdminAppTicketService $ticketService) {}
public function index(AdminAppTicketIndexRequest $request): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return TicketResource::collection(
$this->ticketService->list($tenant, $request->validated())
)->additional($this->ticketService->counts($tenant));
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Ticket\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AdminAppTicketIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
class AdminAppTicketService
{
/** @return array{scanned_tickets: int, total_tickets: int} */
public function counts(Tenant $tenant): array
{
$query = Ticket::query()->where('tenant_code', $tenant->codigo);
return [
'scanned_tickets' => (clone $query)->whereNotNull('used_at')->count(),
'total_tickets' => $query->count(),
];
}
/**
* @param array{q?: string|null, page?: int, per_page?: int} $filters
* @return LengthAwarePaginator<Ticket>
*/
public function list(Tenant $tenant, array $filters = []): LengthAwarePaginator
{
$search = trim((string) ($filters['q'] ?? ''));
return Ticket::query()
->where('tenant_code', $tenant->codigo)
->when($search !== '', function (Builder $query) use ($search): void {
$query->when(
ctype_digit($search),
fn (Builder $searchQuery): Builder => $searchQuery
->where('tickets.id', (int) $search),
fn (Builder $searchQuery): Builder => $searchQuery
->where('ticket', 'like', "%{$search}%"),
);
})
->with([
...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS,
'user',
'sourceCatalogItem.category',
])
->orderByDesc('id')
->paginateFromRequest()
->withQueryString();
}
}

View File

@@ -30,6 +30,12 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
- `GET /tickets`.
- `POST /tickets/pdf`.
Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú
`adminapp.tickets`:
- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye
`scanned_tickets` y `total_tickets` para el tenant autenticado.
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
## Dependencias y reglas

View File

@@ -0,0 +1,12 @@
<?php
use App\Domains\Ticket\Controllers\AdminApp\TicketController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('tickets', [TicketController::class, 'index'])
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.index');
});

View File

@@ -11,3 +11,4 @@ Route::prefix('tenants/{tenant:codigo}')
});
require __DIR__.'/scanner.php';
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,163 @@
<?php
namespace Tests\Feature\Ticket;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppTicketControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
}
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/tenant/tickets')->assertUnauthorized();
}
public function test_the_tenant_must_have_the_tickets_menu(): void
{
$tenant = $this->createTenant('fiesta_futbol_infantil');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/tickets')->assertNotFound();
}
public function test_it_lists_only_tickets_from_the_authenticated_tenant(): void
{
$tenant = $this->createTenant('fiesta_futbol_infantil');
$otherTenant = $this->createTenant('other');
$admin = $this->createAdminAppUser($tenant);
$otherUser = $this->createAdminAppUser($otherTenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
$ticket = $this->createTicket($tenant, $admin);
$this->createTicket($otherTenant, $otherUser);
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $ticket->id)
->assertJsonPath('data.0.tenant_code', $tenant->codigo)
->assertJsonPath('meta.total', 1);
}
public function test_it_supports_id_and_uuid_search(): void
{
$tenant = $this->createTenant('fiesta_futbol_infantil');
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
$matching = $this->createTicket($tenant, $admin);
$this->createTicket($tenant, $admin);
$this->getJson("/api/v1/adminapp/tenant/tickets?q={$matching->id}")
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $matching->id);
$this->getJson('/api/v1/adminapp/tenant/tickets?q='.substr($matching->ticket, 0, 8))
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.ticket', $matching->ticket);
}
public function test_it_includes_tenant_scanned_and_total_ticket_counts(): void
{
$tenant = $this->createTenant('fiesta_futbol_infantil');
$otherTenant = $this->createTenant('other');
$admin = $this->createAdminAppUser($tenant);
$otherUser = $this->createAdminAppUser($otherTenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
$this->createTicket($tenant, $admin)->update(['used_at' => now()]);
$this->createTicket($tenant, $admin);
$this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]);
$this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match')
->assertOk()
->assertJsonCount(0, 'data')
->assertJsonPath('scanned_tickets', 1)
->assertJsonPath('total_tickets', 2);
}
private function createTenant(string $code): Tenant
{
$headerLogo = $this->createAttachment("{$code}-header.png");
$footerLogo = $this->createAttachment("{$code}-footer.png");
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => 'onticket',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#444444',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#ffffff',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $filename): Attachment
{
return Attachment::query()->create([
'path' => "test/{$filename}",
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
private function createAdminAppUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
}
private function grantTicketsMenu(Tenant $tenant): void
{
$menu = Menu::query()->create([
'code' => 'adminapp.tickets',
'label' => 'Tickets',
'route' => '/admin/tickets',
]);
$tenant->menues()->attach($menu->code);
}
private function createTicket(Tenant $tenant, User $user): Ticket
{
return Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => (string) Str::uuid(),
'user_id' => $user->id,
]);
}
}