diff --git a/app/Livewire/Forms/Studio/Loyalty/TierForm.php b/app/Livewire/Forms/Studio/Loyalty/TierForm.php index 2b032f1..e9fed04 100644 --- a/app/Livewire/Forms/Studio/Loyalty/TierForm.php +++ b/app/Livewire/Forms/Studio/Loyalty/TierForm.php @@ -4,6 +4,7 @@ use App\Models\Tier; use App\Rules\UnsignedInteger; +use Illuminate\Support\Facades\DB; use Livewire\Form; class TierForm extends Form @@ -21,7 +22,7 @@ public function rules(): array return [ 'name' => ['required', 'string', 'max:50'], 'min_points' => ['required', new UnsignedInteger], - 'max_points' => ['nullable', new UnsignedInteger], + 'max_points' => ['nullable', new UnsignedInteger, 'gte:min_points'], ]; } @@ -34,7 +35,7 @@ public function validationAttributes(): array ]; } - public function setTier(Tier $tier) + public function setTier(Tier $tier): void { $this->tier = $tier; @@ -43,23 +44,27 @@ public function setTier(Tier $tier) $this->max_points = $tier->max_points; } - public function store() + public function store(): Tier { $this->validate(); - return Tier::create($this->prepareSavedData()); + return DB::transaction(function () { + return Tier::create($this->prepareSavedData()); + }); } - public function update() + public function update(): Tier { $this->validate(); - $this->tier->update($this->prepareSavedData()); + return DB::transaction(function () { + $this->tier->update($this->prepareSavedData()); - return $this->tier; + return $this->tier; + }); } - private function prepareSavedData() + private function prepareSavedData(): array { return [ 'name' => $this->name, diff --git a/app/Livewire/Studio/Loyalty/Tier.php b/app/Livewire/Studio/Loyalty/Tier.php index 4034f72..6ef5aa3 100644 --- a/app/Livewire/Studio/Loyalty/Tier.php +++ b/app/Livewire/Studio/Loyalty/Tier.php @@ -14,6 +14,8 @@ use App\Traits\WithToast; use App\Traits\WithUpdatedData; use Flux\Flux; +use Illuminate\Contracts\View\View; +use Illuminate\Support\Collection; use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; @@ -23,7 +25,7 @@ class Tier extends Component { use WithAuthorization, WithCloseModal, WithConfirmation, WithSubscribeNotification, WithToast, WithUpdatedData; - public array $tiers = []; + public Collection $tiers; public TierForm $form; @@ -31,13 +33,13 @@ class Tier extends Component public string $modalTitle = ''; - public function mount() + public function mount(): void { $this->loadTiers(); } #[On('data:tiersUpdated')] - public function loadTiers() + public function loadTiers(): void { $this->tiers = TierModel::with(['rewards', 'memberships.user.customer.orders.items']) ->withCount(['memberships', 'rewards']) @@ -50,6 +52,7 @@ public function loadTiers() ->first(); $topMemberStats = null; + if ($topMembership && $topMembership->user && $topMembership->user->customer) { $orders = $topMembership->user->customer->orders; @@ -82,22 +85,16 @@ public function loadTiers() ]; } - return [ - 'id' => $tier->id, - 'hash' => $tier->hash, - 'name' => $tier->name, - 'min_points' => currency($tier->min_points), - 'max_points' => currency($tier->max_points), - 'total_members' => currency($tier->memberships_count), - 'total_rewards' => currency($tier->rewards_count), - 'top_member' => $topMemberStats, - ]; - }) - ->toArray(); + $tier->top_member = $topMemberStats; + $tier->total_members = $tier->memberships_count; + $tier->total_rewards = $tier->rewards_count; + + return $tier; + }); } #[On('modal:open')] - public function openModal(string $method, string $modalTitle, ?string $id = null) + public function openModal(string $method, string $modalTitle, ?string $id = null): void { $this->resetValidation(); $this->resetErrorBag(); @@ -110,70 +107,79 @@ public function openModal(string $method, string $modalTitle, ?string $id = null } } - public function create() + public function create(): void { $this->canOrAbort('create tier'); + $minPoints = (int) $this->form->min_points; + $maxPoints = (int) $this->form->max_points; + + if (TierModel::isOverlap($minPoints, $maxPoints)) { + $this->toast('Range poin yang kamu masukkan bertabrakan dengan tier yang sudah ada.', 'Info', 'warning'); + + return; + } + $tier = $this->form->store(); - $tiers = $this->tiers; - $tiers[] = [ - 'id' => $tier->id, - 'hash' => $tier->hash, - 'name' => $tier->name, - 'min_points' => currency($tier->min_points, ''), - 'max_points' => currency($tier->max_points, ''), - 'total_members' => 0, - 'top_member' => null, - 'total_rewards' => 0, - ]; + // Add computed properties to the model + $tier->total_members = 0; + $tier->total_rewards = 0; + $tier->top_member = null; - usort($tiers, fn ($a, $b) => replaceCurrency($a['min_points']) <=> replaceCurrency($b['min_points'])); - - $this->tiers = $tiers; + // Add new tier to collection and sort + $this->tiers = $this->tiers->push($tier)->sortBy('min_points')->values(); $this->toast('Tier berhasil ditambahkan.'); Flux::modals()->close(); } - public function update() + public function update(): void { $this->canOrAbort('update tier'); + $tierId = $this->form->tier->id; + $minPoints = (int) $this->form->min_points; + $maxPoints = (int) $this->form->max_points; + + if (TierModel::isOverlap($minPoints, $maxPoints, $tierId)) { + $this->toast('Range poin yang kamu masukkan bertabrakan dengan tier yang sudah ada.', 'Info', 'warning'); + + return; + } + $tier = $this->form->update(); - // update array tiers - $tiers = $this->tiers; - foreach ($tiers as $index => $item) { - if ($item['hash'] === $tier->hash) { - $tiers[$index]['name'] = $tier->name; - $tiers[$index]['min_points'] = currency($tier->min_points, ''); - $tiers[$index]['max_points'] = currency($tier->max_points, ''); - break; + // Update the tier in collection + $this->tiers = $this->tiers->map(function ($t) use ($tier) { + if ($t->hash === $tier->hash) { + $t->name = $tier->name; + $t->min_points = $tier->min_points; + $t->max_points = $tier->max_points; } - } - $this->tiers = $tiers; + + return $t; + }); $this->toast('Tier berhasil diperbarui.'); Flux::modals()->close(); } - public function delete(TierModel $tier) + public function delete(TierModel $tier): void { $tier->delete(); - // update array tiers - $tiers = array_values(array_filter($this->tiers, fn ($item) => $item['hash'] !== $tier->hash)); - $this->tiers = $tiers; + // Remove tier from collection + $this->tiers = $this->tiers->filter(fn ($t) => $t->hash !== $tier->hash)->values(); $this->toast('Tier berhasil dihapus.'); Flux::modals()->close(); } - public function render() + public function render(): View { return view('livewire.studio.loyalty.tier.index', [ 'pageTitle' => 'Tier', diff --git a/app/Models/Tier.php b/app/Models/Tier.php index 8e1782b..48fec59 100644 --- a/app/Models/Tier.php +++ b/app/Models/Tier.php @@ -25,6 +25,19 @@ protected function casts(): array ]; } + public static function isOverlap(int $minPoints, ?int $maxPoints, ?int $ignoreId = null): bool + { + $maxPointsCheck = $maxPoints ?? PHP_INT_MAX; + + return self::when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId)) + ->get() + ->contains(function ($tier) use ($minPoints, $maxPointsCheck) { + $tierMax = $tier->max_points ?? PHP_INT_MAX; + + return ($minPoints <= $tierMax) && ($maxPointsCheck >= $tier->min_points); + }); + } + public function memberships(): HasMany { return $this->hasMany(Membership::class); diff --git a/resources/views/livewire/studio/loyalty/tier/index.blade.php b/resources/views/livewire/studio/loyalty/tier/index.blade.php index e9de912..6aab52e 100644 --- a/resources/views/livewire/studio/loyalty/tier/index.blade.php +++ b/resources/views/livewire/studio/loyalty/tier/index.blade.php @@ -22,7 +22,7 @@ class="hover:shadow-lg transition-shadow duration-200 bg-white dark:bg-gray-900
@can('create tier reward') - @if ($tier['total_rewards'] == 0) + @if ($tier->total_rewards == 0) Belum ada hadiah untuk tier ini, silakan tambah terlebih dahulu. @@ -30,7 +30,7 @@ class="hover:shadow-lg transition-shadow duration-200 bg-white dark:bg-gray-900 + wire:click="$dispatch('modal:open:reward', {method: 'create', 'modalTitle': 'Tambah Hadiah', 'tier_id': '{{ $tier->id }}'})"> Tambah @@ -39,41 +39,41 @@ class="hover:shadow-lg transition-shadow duration-200 bg-white dark:bg-gray-900 @endcan
- {{ $tier['name'] }} + {{ $tier->name }}
Min. Poin: - {{ $tier['min_points'] }} + {{ currency($tier->min_points) }}
Maks. Poin: - {{ $tier['max_points'] }} + {{ currency($tier->max_points) }}
Total Member: - {{ $tier['total_members'] }} + {{ $tier->total_members }}
Total Hadiah: - {{ $tier['total_rewards'] }} + {{ $tier->total_rewards }}
- @if ($tier['top_member']) + @if ($tier->top_member)
Member Dengan Poin Tertinggi:
- {{ $tier['top_member']['name'] }} - {{ $tier['top_member']['points'] }} Poin + {{ $tier->top_member['name'] }} + {{ $tier->top_member['points'] }} Poin
Statistik Order
Order: - {{ $tier['top_member']['total_orders'] }}x + {{ $tier->top_member['total_orders'] }}x
Parfum Yang Dibeli: - {{ $tier['top_member']['items']['total_perfume'] }} ml + {{ $tier->top_member['items']['total_perfume'] }} ml
Produk Yang Dibeli: - {{ $tier['top_member']['items']['total_product'] }} pcs + {{ $tier->top_member['items']['total_product'] }} pcs
Botol Yang Dibeli: - {{ $tier['top_member']['items']['total_bottle'] }} pcs + {{ $tier->top_member['items']['total_bottle'] }} pcs
Subtotal Belanja: - {{ $tier['top_member']['total']['subtotal'] }} + {{ $tier->top_member['total']['subtotal'] }}
Diskon: - {{ $tier['top_member']['total']['discount'] }} + {{ $tier->top_member['total']['discount'] }}
Total Belanja: - {{ $tier['top_member']['total']['grand_total'] }} + {{ $tier->top_member['total']['grand_total'] }}
@@ -117,7 +117,7 @@ class="mt-2 bg-white dark:bg-gray-800 rounded-lg shadow p-4 text-gray-700 dark:t + wire:click="$dispatch('modal:open:reward', {method: 'create', 'modalTitle': '{{ $tier->name }}', 'tier_id': '{{ $tier->id }}'})"> @@ -127,7 +127,7 @@ class="mt-2 bg-white dark:bg-gray-800 rounded-lg shadow p-4 text-gray-700 dark:t + wire:click="$dispatch('modal:open:reward', {method: 'create', 'modalTitle': 'Tambah Hadiah', 'tier_id': '{{ $tier->id }}'})"> @@ -137,7 +137,7 @@ class="mt-2 bg-white dark:bg-gray-800 rounded-lg shadow p-4 text-gray-700 dark:t + wire:click="$dispatch('modal:open', {method: 'update', 'modalTitle': 'Ubah Tier', 'id': '{{ $tier->hash }}'})"> @@ -147,7 +147,7 @@ class="mt-2 bg-white dark:bg-gray-800 rounded-lg shadow p-4 text-gray-700 dark:t + wire:click="$dispatch('fn:confirmAction', {id: '{{ $tier->hash }}'})"> @@ -160,13 +160,14 @@ class="mt-2 bg-white dark:bg-gray-800 rounded-lg shadow p-4 text-gray-700 dark:t
@include('components.modals.confirmation', [ - 'modalName' => 'confirmation-modal', + 'modalName' => 'delete-confirmation', 'modalTitle' => 'Apakah Anda yakin?', 'modalMessage' => 'Data yang berelasi dengan data ini juga akan ikut terhapus.', 'buttonVariant' => 'primary', 'buttonColor' => 'danger', 'buttonText' => 'Ya, Hapus', ]) +
{{ $modalTitle }} diff --git a/tests/Feature/Livewire/Studio/Loyalty/TierTest.php b/tests/Feature/Livewire/Studio/Loyalty/TierTest.php index e7b8444..1dc2963 100644 --- a/tests/Feature/Livewire/Studio/Loyalty/TierTest.php +++ b/tests/Feature/Livewire/Studio/Loyalty/TierTest.php @@ -15,6 +15,7 @@ use App\Models\TierReward; use App\Models\User; use Database\Seeders\RolePermissionSeeder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Gate; use Livewire\Livewire; @@ -58,9 +59,10 @@ function mountTierComponent(User $user) $component = mountTierComponent($this->user); + expect($component->tiers)->toBeInstanceOf(\Illuminate\Support\Collection::class); expect($component->tiers)->toHaveCount(2); - expect($component->tiers[0]['name'])->toBe('Bronze'); - expect($component->tiers[1]['name'])->toBe('Silver'); + expect($component->tiers[0]->name)->toBe('Bronze'); + expect($component->tiers[1]->name)->toBe('Silver'); }); it('displays all tiers in the view', function () { @@ -79,9 +81,10 @@ function mountTierComponent(User $user) $component = mountTierComponent($this->user); - expect($component->tiers[0]['name'])->toBe('Bronze'); - expect($component->tiers[1]['name'])->toBe('Silver'); - expect($component->tiers[2]['name'])->toBe('Platinum'); + expect($component->tiers)->toBeInstanceOf(\Illuminate\Support\Collection::class); + expect($component->tiers[0]->name)->toBe('Bronze'); + expect($component->tiers[1]->name)->toBe('Silver'); + expect($component->tiers[2]->name)->toBe('Platinum'); }); /* @@ -112,7 +115,9 @@ function mountTierComponent(User $user) ->call('create') ->assertHasNoErrors() ->assertSet('tiers', function ($tiers) { - return count($tiers) === 1 && $tiers[0]['name'] === 'Gold'; + return $tiers instanceof \Illuminate\Support\Collection && + $tiers->count() === 1 && + $tiers[0]->name === 'Gold'; }); $this->assertDatabaseHas('tiers', [ @@ -149,9 +154,10 @@ function mountTierComponent(User $user) ->set('form.max_points', '1000') ->call('create'); - expect($component->tiers[0]['name'])->toBe('Bronze'); - expect($component->tiers[1]['name'])->toBe('Silver'); - expect($component->tiers[2]['name'])->toBe('Gold'); + expect($component->tiers)->toBeInstanceOf(\Illuminate\Support\Collection::class); + expect($component->tiers[0]->name)->toBe('Bronze'); + expect($component->tiers[1]->name)->toBe('Silver'); + expect($component->tiers[2]->name)->toBe('Gold'); }); it('validates required fields when creating tier', function () { @@ -225,9 +231,9 @@ function mountTierComponent(User $user) ->call('update') ->assertHasNoErrors() ->assertSet('tiers', function ($tiers) use ($tier) { - $updatedTier = collect($tiers)->firstWhere('hash', $tier->hash); + $updatedTier = $tiers->firstWhere('hash', $tier->hash); - return $updatedTier['name'] === 'Gold'; + return $updatedTier->name === 'Gold'; }); $tier->refresh(); @@ -299,7 +305,9 @@ function mountTierComponent(User $user) ->call('delete', $tier) ->assertHasNoErrors() ->assertSet('tiers', function ($tiers) use ($tier) { - return ! collect($tiers)->contains('hash', $tier->hash); + return ! $tiers->contains(function ($t) use ($tier) { + return $t->hash === $tier->hash; + }); }); expect(TierModel::count())->toBe(0); @@ -342,8 +350,8 @@ function mountTierComponent(User $user) $component = mountTierComponent($this->user); - $loadedTier = collect($component->tiers)->firstWhere('id', $tier->id); - expect($loadedTier['total_members'])->toBe('2'); + $loadedTier = $component->tiers->firstWhere('id', $tier->id); + expect($loadedTier->total_members)->toBe(2); }); it('loads tiers with rewards count', function () { @@ -354,9 +362,9 @@ function mountTierComponent(User $user) $component = mountTierComponent($this->user); - $loadedTier = collect($component->tiers)->firstWhere('id', $tier->id); + $loadedTier = $component->tiers->firstWhere('id', $tier->id); - expect((int) $loadedTier['total_rewards'])->toBe(3); + expect((int) $loadedTier->total_rewards)->toBe(3); }); it('loads top member stats when tier has memberships', function () { @@ -478,14 +486,14 @@ function mountTierComponent(User $user) $component = mountTierComponent($this->user); - $loadedTier = collect($component->tiers)->firstWhere('id', $tier->id); - expect($loadedTier['top_member'])->not->toBeNull(); - expect($loadedTier['top_member']['name'])->toBe('John Doe'); - expect($loadedTier['top_member']['points'])->toContain('500'); - expect($loadedTier['top_member']['total_orders'])->toContain('2'); - expect($loadedTier['top_member']['items']['total_perfume'])->toContain('3'); - expect($loadedTier['top_member']['items']['total_product'])->toContain('3'); - expect($loadedTier['top_member']['items']['total_bottle'])->toContain('1'); + $loadedTier = $component->tiers->firstWhere('id', $tier->id); + expect($loadedTier->top_member)->not->toBeNull(); + expect($loadedTier->top_member['name'])->toBe('John Doe'); + expect($loadedTier->top_member['points'])->toContain('500'); + expect($loadedTier->top_member['total_orders'])->toContain('2'); + expect($loadedTier->top_member['items']['total_perfume'])->toContain('3'); + expect($loadedTier->top_member['items']['total_product'])->toContain('3'); + expect($loadedTier->top_member['items']['total_bottle'])->toContain('1'); }); it('shows null top member when tier has no memberships', function () { @@ -493,8 +501,8 @@ function mountTierComponent(User $user) $component = mountTierComponent($this->user); - $loadedTier = collect($component->tiers)->firstWhere('id', $tier->id); - expect($loadedTier['top_member'])->toBeNull(); + $loadedTier = $component->tiers->firstWhere('id', $tier->id); + expect($loadedTier->top_member)->toBeNull(); }); it('reloads tiers when data:tiersUpdated event is dispatched', function () { @@ -507,7 +515,7 @@ function mountTierComponent(User $user) $component->dispatch('data:tiersUpdated') ->assertSet('tiers', function ($tiers) { - return count($tiers) === 2; + return $tiers->count() === 2; }); }); @@ -534,6 +542,8 @@ function mountTierComponent(User $user) expect($component->method)->toBe('update'); expect($component->modalTitle)->toBe('Ubah Tier'); expect($component->form->name)->toBe($tier->name); + expect($component->form->min_points)->toBe((string) $tier->min_points); + expect($component->form->max_points)->toBe($tier->max_points ? (string) $tier->max_points : null); }); it('sets form data when opening update modal', function () { @@ -565,9 +575,9 @@ function mountTierComponent(User $user) $component = mountTierComponent($this->user); - $loadedTier = collect($component->tiers)->firstWhere('id', $tier->id); - expect($loadedTier['min_points'])->toContain('1.000'); - expect($loadedTier['max_points'])->toContain('5.000'); + $loadedTier = $component->tiers->firstWhere('id', $tier->id); + expect(currency($loadedTier->min_points))->toContain('1.000'); + expect(currency($loadedTier->max_points))->toContain('5.000'); }); it('handles currency formatting in form input', function () { @@ -599,8 +609,8 @@ function mountTierComponent(User $user) $component = mountTierComponent($this->user); - $loadedTier = collect($component->tiers)->firstWhere('id', $tier->id); - expect($loadedTier['max_points'])->not->toBeNull(); + $loadedTier = $component->tiers->firstWhere('id', $tier->id); + expect(currency($loadedTier->max_points))->not->toBeNull(); }); it('handles tier with zero min_points', function () { @@ -612,14 +622,14 @@ function mountTierComponent(User $user) $component = mountTierComponent($this->user); - $loadedTier = collect($component->tiers)->firstWhere('id', $tier->id); - expect($loadedTier['min_points'])->toContain('0'); + $loadedTier = $component->tiers->firstWhere('id', $tier->id); + expect(currency($loadedTier->min_points))->toContain('0'); }); it('handles empty tiers list', function () { $component = mountTierComponent($this->user); - expect($component->tiers)->toBe([]); + expect($component->tiers)->toBeInstanceOf(Collection::class); }); it('updates tier array correctly after update', function () { @@ -636,8 +646,8 @@ function mountTierComponent(User $user) ->set('form.max_points', '5.000') ->call('update'); - $updatedTier = collect($component->tiers)->firstWhere('hash', $tier->hash); - expect($updatedTier['name'])->toBe('Gold'); - expect($updatedTier['min_points'])->toContain('1.000'); - expect($updatedTier['max_points'])->toContain('5.000'); + $updatedTier = $component->tiers->firstWhere('hash', $tier->hash); + expect($updatedTier->name)->toBe('Gold'); + expect(currency($updatedTier->min_points))->toContain('1.000'); + expect(currency($updatedTier->max_points))->toContain('5.000'); });