feat: phase 5 — complaints with ownership-guarded resolution and activity logging

This commit is contained in:
sean
2026-08-25 19:14:29 +07:00
parent 029a4a21f8
commit 28937a786e
3 changed files with 461 additions and 0 deletions
@@ -0,0 +1,151 @@
<?php
namespace App\Filament\Pages\Transactions;
use App\Services\ComplaintService;
use App\Services\CustomerService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Arr;
use UnitEnum;
class Complaints extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chat-bubble-left-ellipsis';
protected static string|UnitEnum|null $navigationGroup = 'Transaksi';
protected static ?int $navigationSort = 1;
protected static ?string $navigationLabel = 'Komplain';
protected static ?string $slug = 'transactions/complaints';
protected string $view = 'filament.pages.blank';
public function getTitle(): string|Htmlable
{
return 'Komplain';
}
public function table(Table $table): Table
{
/** @var ComplaintService $service */
$service = app(ComplaintService::class);
return $table
->records(function (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage, ?array $filters) use ($service) {
$status = Arr::get($filters, 'status.value') ?? Arr::get($filters, 'status');
return $service->paginate(
search: $search,
sortColumn: $sortColumn,
sortDirection: $sortDirection,
page: (int) $page,
perPage: (int) $recordsPerPage,
filters: array_filter(['status' => is_array($status) ? ($status['value'] ?? null) : $status]),
);
})
->columns([
TextColumn::make('complaint_number')->label('No. Komplain')->badge()->sortable(),
TextColumn::make('customers.name')->label('Pelanggan'),
TextColumn::make('orders.order_number')->label('Order')->placeholder('—'),
TextColumn::make('complaint_date')->label('Tgl')->date('d M Y')->sortable(),
TextColumn::make('category')
->label('Kategori')
->badge()
->color('info')
->formatStateUsing(fn (string $state): string => ComplaintService::CATEGORIES[$state] ?? $state),
TextColumn::make('priority')
->label('Prioritas')
->badge()
->color(fn (string $state): string => match ($state) {
'high' => 'danger',
'medium' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => ComplaintService::PRIORITIES[$state] ?? $state)
->sortable(),
TextColumn::make('description')->label('Deskripsi')->limit(40),
TextColumn::make('status')
->label('Status')
->badge()
->color(fn (string $state): string => $state === ComplaintService::STATUS_OPEN ? 'warning' : 'success')
->formatStateUsing(fn (string $state): string => $state === ComplaintService::STATUS_OPEN ? 'Terbuka' : 'Selesai')
->sortable(),
TextColumn::make('resolution')->label('Resolusi')->limit(30)->placeholder('—'),
])
->filters([
Filter::make('status')
->schema([
Select::make('value')->label('Status')->options([
ComplaintService::STATUS_OPEN => 'Terbuka',
ComplaintService::STATUS_RESOLVED => 'Selesai',
]),
]),
])
->recordActions([
Action::make('tutup')
->label('Tutup')
->color('success')
->icon('heroicon-o-check-badge')
->visible(fn (array $record): bool => $service->canResolve($record, auth()->user()))
->schema(fn (Schema $schema): Schema => $schema->components([
Textarea::make('resolution')->label('Resolusi / Tindak Lanjut')->required(),
]))
->action(function (array $data, array $record) use ($service): void {
try {
$service->resolve($record['id'], $data['resolution'], auth()->user());
} catch (AuthorizationException) {
Notification::make()->title('Akses ditolak')->body('Hanya pembuat komplain atau admin yang dapat menutup.')->danger()->send();
return;
}
Notification::make()->title('Komplain ditutup.')->success()->send();
}),
])
->headerActions([
CreateAction::make()
->label('Catat Komplain')
->schema(fn (Schema $schema): Schema => $schema->components([
Select::make('customer_id')->label('Pelanggan')->options(fn (): array => app(CustomerService::class)->optionsForSelect())->required()->searchable(),
Select::make('order_id')->label('Order Terkait (opsional)')->options(fn (): array => $service->orderOptions())->searchable(),
DatePicker::make('complaint_date')->label('Tanggal')->default(now()->toDateString())->required(),
Select::make('category')->label('Kategori')->options(ComplaintService::CATEGORIES)->default('product')->required(),
Select::make('priority')->label('Prioritas')->options(ComplaintService::PRIORITIES)->default('medium')->required(),
Textarea::make('description')->label('Deskripsi Masalah')->required()->columnSpanFull(),
]))
->using(function (array $data): void {
app(ComplaintService::class)->createComplaint($data, auth()->user());
Notification::make()->title('Komplain dicatat.')->success()->send();
}),
])
->paginated([10, 25, 50]);
}
public function content(Schema $schema): Schema
{
return $schema
->components([
EmbeddedTable::make(),
]);
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Services\InsForge\InsForgeClient;
use App\Services\InsForge\InsForgeTableService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Validation\ValidationException;
class ComplaintService extends InsForgeTableService
{
public const STATUS_OPEN = 'open';
public const STATUS_RESOLVED = 'resolved';
public const CATEGORIES = [
'product' => 'Produk',
'delivery' => 'Pengiriman',
'service' => 'Layanan',
'other' => 'Lainnya',
];
public const PRIORITIES = [
'low' => 'Rendah',
'medium' => 'Sedang',
'high' => 'Tinggi',
];
public function __construct(
InsForgeClient $client,
private readonly CustomerService $customers,
private readonly ActivityLogger $activity,
) {
parent::__construct($client);
}
protected function table(): string
{
return 'complaints';
}
protected function select(): string
{
return '*,customers(code,name),orders(order_number)';
}
protected function searchableColumns(): array
{
return ['complaint_number', 'description'];
}
protected function sortableColumns(): array
{
return ['complaint_number', 'complaint_date', 'status', 'priority', 'created_at'];
}
public function createComplaint(array $attributes, ?User $actor = null): array
{
[$client, $asUser] = $this->scoped();
$number = $this->nextComplaintNumber();
$row = array_filter([
'complaint_number' => $number,
'customer_id' => $attributes['customer_id'] ?? null,
'order_id' => $attributes['order_id'] ?? null,
'complaint_date' => $attributes['complaint_date'] ?? now()->toDateString(),
'category' => $attributes['category'] ?? 'other',
'description' => $attributes['description'] ?? null,
'priority' => $attributes['priority'] ?? 'medium',
'status' => self::STATUS_OPEN,
'created_by' => $actor?->id,
], fn ($value) => $value !== null && $value !== '');
if (empty($row['customer_id']) || empty($row['description'])) {
throw ValidationException::withMessages(['complaint' => 'Pelanggan dan deskripsi wajib diisi.']);
}
$client->insertRecord($this->table(), [$row], asUser: false);
$stored = $client->listRecords($this->table(), [
'select' => 'id',
'complaint_number' => 'eq.'.$number,
'limit' => 1,
], asUser: $asUser)[0] ?? null;
$this->activity->log('complaint_created', 'complaints', $stored['id'] ?? null, "Komplain {$number} dicatat.", $actor);
return ['id' => $stored['id'] ?? null, 'complaint_number' => $number];
}
public function resolve(string $complaintId, string $resolution, ?User $actor = null): void
{
[$client, $asUser] = $this->scoped();
$complaint = $client->getRecord($this->table(), $complaintId, asUser: $asUser)
?? throw ValidationException::withMessages(['complaint' => 'Komplain tidak ditemukan.']);
if (($complaint['status'] ?? '') !== self::STATUS_OPEN) {
throw ValidationException::withMessages(['complaint' => 'Komplain ini sudah ditutup.']);
}
$isOwner = $actor !== null && $actor->id === ($complaint['created_by'] ?? null);
if (! ($actor?->isAdmin() ?? false) && ! $isOwner) {
throw new AuthorizationException('Operator hanya dapat menutup komplain miliknya sendiri.');
}
$client->updateRecords($this->table(), ['id' => $complaintId], [
'status' => self::STATUS_RESOLVED,
'resolution' => $resolution,
'resolved_at' => now()->toISOString(),
], asUser: false);
$this->activity->log(
'complaint_resolved',
'complaints',
$complaintId,
"Komplain {$complaint['complaint_number']} ditutup.",
$actor,
);
}
public function canResolve(array $record, ?User $actor): bool
{
if (($record['status'] ?? '') !== self::STATUS_OPEN) {
return false;
}
return $actor !== null && ($actor->isAdmin() || $actor->id === ($record['created_by'] ?? null));
}
/** @return array<string, string> id => ORD-... */
public function orderOptions(): array
{
[$client] = $this->scoped();
return collect($client->listRecords('orders', [
'select' => 'id,order_number',
'deleted_at' => 'eq.null',
'order' => 'created_at.desc',
'limit' => 200,
]))->mapWithKeys(fn (array $row): array => [$row['id'] => $row['order_number']])->all();
}
private function nextComplaintNumber(): string
{
[$client, $asUser] = $this->scoped();
$prefix = 'CMP-'.now()->format('Ymd').'-';
$last = $client->listRecords($this->table(), [
'select' => 'complaint_number',
'complaint_number' => 'like.'.$prefix.'*',
'order' => 'complaint_number.desc',
'limit' => 1,
], asUser: $asUser)[0]['complaint_number'] ?? null;
preg_match('/(\d+)$/', (string) $last, $matches);
return $prefix.sprintf('%04d', ((int) ($matches[1] ?? 0)) + 1);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace Tests\Feature\Complaints;
use App\Filament\Pages\Transactions\Complaints as ComplaintsPage;
use App\Models\User;
use App\Services\ComplaintService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Validation\ValidationException;
use Livewire\Livewire;
use Tests\Support\InsForgeFake;
use Tests\TestCase;
class ComplaintTest extends TestCase
{
private const CUSTOMER_ID = 'cccccccc-0000-0000-0000-000000000001';
protected function setUp(): void
{
parent::setUp();
InsForgeFake::reset();
InsForgeFake::fakeRest();
InsForgeFake::seed('customers', [
'id' => self::CUSTOMER_ID,
'customer_code' => 'CUST-0001',
'name' => 'Andi',
]);
}
private function actor(string $role, string $suffix = '0001'): User
{
$user = new User;
$user->forceFill([
'id' => "eeee7777-0000-0000-0000-{$role}{$suffix}",
'name' => ucfirst($role),
'email' => "{$role}@beraspro.test",
'role' => $role,
'is_active' => true,
]);
return $user;
}
private function seedComplaint(array $overrides = []): array
{
$row = array_merge([
'id' => 'bbbb8888-0000-0000-0000-000000000001',
'complaint_number' => 'CMP-20260825-0001',
'customer_id' => self::CUSTOMER_ID,
'complaint_date' => '2026-08-25',
'category' => 'product',
'description' => 'Beras hitam',
'status' => ComplaintService::STATUS_OPEN,
'priority' => 'high',
'created_by' => $this->actor('operator')->id,
'customers' => ['code' => 'CUST-0001', 'name' => 'Andi'],
], $overrides);
InsForgeFake::seed('complaints', $row);
return $row;
}
public function test_create_generates_number_and_log(): void
{
$result = app(ComplaintService::class)->createComplaint([
'customer_id' => self::CUSTOMER_ID,
'description' => 'Kemasan rusak',
'category' => 'product',
'priority' => 'low',
], $this->actor('operator'));
$this->assertSame('CMP-'.now()->format('Ymd').'-0001', $result['complaint_number']);
$stored = array_values(InsForgeFake::$store['complaints'])[0];
$this->assertSame(ComplaintService::STATUS_OPEN, $stored['status']);
$log = end(InsForgeFake::$store['activity_logs']);
$this->assertSame('complaint_created', $log['action']);
}
public function test_creator_can_resolve_own_complaint(): void
{
$creator = $this->actor('operator');
$complaint = $this->seedComplaint(['created_by' => $creator->id]);
app(ComplaintService::class)->resolve($complaint['id'], 'ganti produk baru', $creator);
$stored = InsForgeFake::$store['complaints'][$complaint['id']];
$this->assertSame(ComplaintService::STATUS_RESOLVED, $stored['status']);
$this->assertSame('ganti produk baru', $stored['resolution']);
$this->assertNotNull($stored['resolved_at']);
}
public function test_other_operator_cannot_resolve(): void
{
$complaint = $this->seedComplaint();
$this->expectException(AuthorizationException::class);
app(ComplaintService::class)->resolve($complaint['id'], 'coba tutup', $this->actor('operator', '9999'));
}
public function test_admin_can_resolve_any_complaint(): void
{
$complaint = $this->seedComplaint();
app(ComplaintService::class)->resolve($complaint['id'], 'direfund', $this->actor('admin'));
$this->assertSame(ComplaintService::STATUS_RESOLVED, InsForgeFake::$store['complaints'][$complaint['id']]['status']);
}
public function test_already_resolved_cannot_be_resolved_again(): void
{
$complaint = $this->seedComplaint(['status' => ComplaintService::STATUS_RESOLVED, 'resolution' => 'ok']);
$this->expectException(ValidationException::class);
app(ComplaintService::class)->resolve($complaint['id'], 'lagi', $this->actor('admin'));
}
public function test_page_renders_and_shows_complaint(): void
{
$this->actingAs($this->actor('operator'));
$this->seedComplaint();
Livewire::test(ComplaintsPage::class)
->assertSuccessful()
->assertSee('CMP-20260825-0001')
->assertSee('Beras hitam');
}
public function test_close_action_hidden_for_non_owner_operator(): void
{
$user = $this->actor('operator', '9999');
$this->actingAs($user);
$complaint = $this->seedComplaint();
Livewire::test(ComplaintsPage::class)
->assertSuccessful()
->assertDontSeeHtml('wire:key="writable-actions-tutup"');
}
}