From 28937a786e5fd48260c186264d060b37c9dbbcaf Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 25 Aug 2026 19:14:29 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20phase=205=20=E2=80=94=20complaints=20wi?= =?UTF-8?q?th=20ownership-guarded=20resolution=20and=20activity=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Pages/Transactions/Complaints.php | 151 ++++++++++++++++ app/Services/ComplaintService.php | 164 ++++++++++++++++++ tests/Feature/Complaints/ComplaintTest.php | 146 ++++++++++++++++ 3 files changed, 461 insertions(+) create mode 100644 app/Filament/Pages/Transactions/Complaints.php create mode 100644 app/Services/ComplaintService.php create mode 100644 tests/Feature/Complaints/ComplaintTest.php diff --git a/app/Filament/Pages/Transactions/Complaints.php b/app/Filament/Pages/Transactions/Complaints.php new file mode 100644 index 0000000..8175af0 --- /dev/null +++ b/app/Filament/Pages/Transactions/Complaints.php @@ -0,0 +1,151 @@ +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(), + ]); + } +} diff --git a/app/Services/ComplaintService.php b/app/Services/ComplaintService.php new file mode 100644 index 0000000..c8f5b08 --- /dev/null +++ b/app/Services/ComplaintService.php @@ -0,0 +1,164 @@ + '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 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); + } +} diff --git a/tests/Feature/Complaints/ComplaintTest.php b/tests/Feature/Complaints/ComplaintTest.php new file mode 100644 index 0000000..62675ad --- /dev/null +++ b/tests/Feature/Complaints/ComplaintTest.php @@ -0,0 +1,146 @@ + 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"'); + } +}