diff --git a/app/Livewire/Forms/Studio/Loyalty/TierForm.php b/app/Livewire/Forms/Studio/Loyalty/TierForm.php index e9fed04..0f14f22 100644 --- a/app/Livewire/Forms/Studio/Loyalty/TierForm.php +++ b/app/Livewire/Forms/Studio/Loyalty/TierForm.php @@ -26,6 +26,21 @@ public function rules(): array ]; } + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $minPoints = replaceCurrency($this->min_points); + $maxPoints = $this->max_points ? replaceCurrency($this->max_points) : null; + $ignoreId = $this->tier?->id; + + $tierErrors = Tier::validateTierPoints($minPoints, $maxPoints, $ignoreId); + + foreach ($tierErrors as $error) { + $validator->errors()->add('min_points', $error); + } + }); + } + public function validationAttributes(): array { return [ diff --git a/app/Livewire/Studio/Loyalty/Tier.php b/app/Livewire/Studio/Loyalty/Tier.php deleted file mode 100644 index 6ef5aa3..0000000 --- a/app/Livewire/Studio/Loyalty/Tier.php +++ /dev/null @@ -1,188 +0,0 @@ -loadTiers(); - } - - #[On('data:tiersUpdated')] - public function loadTiers(): void - { - $this->tiers = TierModel::with(['rewards', 'memberships.user.customer.orders.items']) - ->withCount(['memberships', 'rewards']) - ->orderBy('min_points', 'asc') - ->get() - ->map(function ($tier) { - $topMembership = $tier->memberships() - ->with(['user.customer.orders.items']) - ->orderByDesc('tier_points') - ->first(); - - $topMemberStats = null; - - if ($topMembership && $topMembership->user && $topMembership->user->customer) { - $orders = $topMembership->user->customer->orders; - - $totalOrders = $orders->count(); - - // Sum items - $totalPerfume = $orders->flatMap(fn ($order) => $order->items->where('orderable_type', Perfume::class))->sum('quantity'); - $totalProduct = $orders->flatMap(fn ($order) => $order->items->where('orderable_type', Product::class))->sum('quantity'); - $totalBottle = $orders->flatMap(fn ($order) => $order->items->where('orderable_type', Bottle::class))->sum('quantity'); - - // Sum total - $subtotal = $orders->sum('subtotal'); - $totalDiscount = $orders->sum('discount'); - $grandTotal = $orders->sum('total'); - - $topMemberStats = [ - 'name' => $topMembership->user->customer->name, - 'points' => currency($topMembership->tier_points), - 'total_orders' => currency($totalOrders), - 'items' => [ - 'total_perfume' => currency($totalPerfume), - 'total_product' => currency($totalProduct), - 'total_bottle' => currency($totalBottle), - ], - 'total' => [ - 'subtotal' => currency($subtotal, 'Rp'), - 'discount' => currency($totalDiscount, 'Rp'), - 'grand_total' => currency($grandTotal, 'Rp'), - ], - ]; - } - - $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): void - { - $this->resetValidation(); - $this->resetErrorBag(); - - $this->method = $method; - $this->modalTitle = $modalTitle; - - if ($id) { - $this->form->setTier(TierModel::byHashOrFail($id)); - } - } - - 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(); - - // Add computed properties to the model - $tier->total_members = 0; - $tier->total_rewards = 0; - $tier->top_member = null; - - // 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(): 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 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; - } - - return $t; - }); - - $this->toast('Tier berhasil diperbarui.'); - - Flux::modals()->close(); - } - - public function delete(TierModel $tier): void - { - $tier->delete(); - - // 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(): View - { - return view('livewire.studio.loyalty.tier.index', [ - 'pageTitle' => 'Tier', - ]); - } -} diff --git a/app/Livewire/Studio/Loyalty/Tier/Index.php b/app/Livewire/Studio/Loyalty/Tier/Index.php new file mode 100644 index 0000000..9390dc5 --- /dev/null +++ b/app/Livewire/Studio/Loyalty/Tier/Index.php @@ -0,0 +1,192 @@ +loadTiers(); + } + + #[On('data:tiersUpdated')] + public function loadTiers(): void + { + $this->tiers = TierModel::withCount('memberships') + ->withCount('rewards') + ->orderBy('min_points', 'asc') + ->get() + ->map(function ($tier) { + 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' => $tier->rewards_count, + ]; + }) + ->toArray(); + } + + public function getTopMemberStats($tierId): ?array + { + $tier = TierModel::with(['memberships.user.customer.orders.items'])->find($tierId); + + if (! $tier) { + return null; + } + + $topMembership = $tier->memberships() + ->with(['user.customer.orders.items']) + ->orderByDesc('tier_points') + ->first(); + + if (! $topMembership || ! $topMembership->user || ! $topMembership->user->customer) { + return null; + } + + $orders = $topMembership->user->customer->orders; + $totalOrders = $orders->count(); + + // Sum items + $totalPerfume = $orders->flatMap(fn ($order) => $order->items->where('orderable_type', Perfume::class))->sum('quantity'); + $totalProduct = $orders->flatMap(fn ($order) => $order->items->where('orderable_type', Product::class))->sum('quantity'); + $totalBottle = $orders->flatMap(fn ($order) => $order->items->where('orderable_type', Bottle::class))->sum('quantity'); + + // Sum total + $subtotal = $orders->sum('subtotal'); + $totalDiscount = $orders->sum('discount'); + $grandTotal = $orders->sum('total'); + + return [ + 'name' => $topMembership->user->customer->name, + 'points' => currency($topMembership->tier_points), + 'total_orders' => currency($totalOrders), + 'items' => [ + 'total_perfume' => currency($totalPerfume), + 'total_product' => currency($totalProduct), + 'total_bottle' => currency($totalBottle), + ], + 'total' => [ + 'subtotal' => currency($subtotal, 'Rp'), + 'discount' => currency($totalDiscount, 'Rp'), + 'grand_total' => currency($grandTotal, 'Rp'), + ], + ]; + } + + public function create(): void + { + $this->canOrAbort('create tier'); + + $this->form->store(); + + // Reorder the entire tier chain to ensure proper max_points + TierModel::reorderTierChain(); + + // Reload tiers to reflect the changes + $this->loadTiers(); + + $this->toast('Tier berhasil ditambahkan.'); + + Flux::modals()->close(); + } + + public function update(): void + { + $this->canOrAbort('update tier'); + + $oldMinPoints = $this->form->tier->min_points; + $newMinPoints = replaceCurrency($this->form->min_points); + + $this->form->update(); + + // If min_points changed, reorder the entire tier chain + if ($oldMinPoints !== $newMinPoints) { + TierModel::reorderTierChain(); + } + + // Reload tiers to reflect any changes from reordering + $this->loadTiers(); + + $this->toast('Tier berhasil diperbarui.'); + + Flux::modals()->close(); + } + + public function delete(TierModel $tier): void + { + $this->canOrAbort('delete tier'); + + // Validate if tier can be deleted + $canDelete = TierModel::canDeleteTier($tier->id); + if (! $canDelete['can_delete']) { + $this->toast($canDelete['message'], 'Gagal', 'danger'); + + return; + } + + $tier->delete(); + + // Reorder tier chain after deletion to ensure proper max_points + TierModel::reorderTierChain(); + + // Reload tiers to reflect the changes + $this->loadTiers(); + + $this->toast('Tier berhasil dihapus.'); + + Flux::modals()->close(); + } + + #[On('modal:open')] + public function openModal(string $method, string $modalTitle, ?string $id = null): void + { + $this->resetValidation(); + $this->resetErrorBag(); + + $this->method = $method; + $this->modalTitle = $modalTitle; + + if ($id) { + $this->form->setTier(TierModel::byHashOrFail($id)); + } + } + + public function render(): View + { + return view('livewire.studio.loyalty.tier.index', [ + 'pageTitle' => 'Tier', + ]); + } +} diff --git a/app/Livewire/Studio/Loyalty/Tier/Reward.php b/app/Livewire/Studio/Loyalty/Tier/Reward.php index b58e3aa..635ce79 100644 --- a/app/Livewire/Studio/Loyalty/Tier/Reward.php +++ b/app/Livewire/Studio/Loyalty/Tier/Reward.php @@ -3,7 +3,7 @@ namespace App\Livewire\Studio\Loyalty\Tier; use App\Livewire\Forms\Studio\Loyalty\Tier\RewardForm; -use App\Livewire\Studio\Loyalty\Tier; +use App\Livewire\Studio\Loyalty\Tier\Index as Tier; use App\Models\TierReward; use App\Traits\Notification\WithSubscribeNotification; use App\Traits\WithAuthorization; @@ -12,6 +12,7 @@ use App\Traits\WithToast; use App\Traits\WithUpdatedData; use Flux\Flux; +use Illuminate\Contracts\View\View; use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; @@ -29,6 +30,66 @@ class Reward extends Component public array $rewards = []; + public function create(): void + { + $this->canOrAbort('create tier reward'); + + $reward = $this->form->store(); + + $this->rewards = collect($this->rewards) + ->push( + $reward->only(['tier_id', 'name', 'value']) + ['hash' => $reward->hash] + ) + ->toArray(); + + $this->dispatch('data:tiersUpdated')->to(Tier::class); + + $this->toast('Hadiah berhasil ditambahkan.'); + + Flux::modal('reward-form-modal')->close(); + } + + public function update(): void + { + $this->canOrAbort('update tier reward'); + + $reward = $this->form->update(); + + $updatedReward = $reward->only(['tier_id', 'name', 'value']) + [ + 'hash' => $reward->hash, + ]; + + $this->rewards = collect($this->rewards) + ->map( + fn ($item) => $item['hash'] === $updatedReward['hash'] + ? $updatedReward + : $item + ) + ->toArray(); + + $this->toast('Hadiah berhasil diperbarui.'); + + Flux::modal('reward-form-modal')->close(); + } + + public function delete(TierReward $reward): void + { + $this->canOrAbort('delete tier reward'); + + $reward->delete(); + + $this->rewards = collect($this->rewards) + ->reject(fn ($item) => $item['hash'] === $reward->hash) + ->values() + ->toArray(); + + $this->toast('Hadiah berhasil dihapus.'); + + $this->dispatch('data:tiersUpdated')->to(Tier::class); + + Flux::modal('delete-reward-confirmation')->close(); + } + #[On('modal:open:reward')] public function openModal(string $method, string $modalTitle, ?string $tier_id = null, ?string $id = null) { @@ -41,58 +102,17 @@ public function openModal(string $method, string $modalTitle, ?string $tier_id = $this->form->tier_id = $tier_id; $this->rewards = TierReward::where('tier_id', $tier_id) + ->latest() ->get() - ->map(fn (TierReward $reward) => [ - 'hash' => $reward->hash, - 'tier_id' => $reward->tier_id, - 'name' => $reward->name, - 'value' => currency($reward->value, 'Rp'), - ]) + ->append('hash') ->toArray(); - if ($id) { - $this->form->setRewards(TierReward::byHashOrFail($id)); - } + $id && $this->form->setRewards( + TierReward::byHashOrFail($id) + ); } - public function create() - { - $this->canOrAbort('create tier reward'); - - $this->form->store(); - - $this->dispatch('data:tiersUpdated')->to(Tier::class); - - $this->toast('Hadiah berhasil ditambahkan.'); - - Flux::modal('reward-form-modal')->close(); - } - - public function update() - { - $this->canOrAbort('update tier reward'); - - $this->form->update(); - - $this->toast('Hadiah berhasil diperbarui.'); - - Flux::modal('reward-form-modal')->close(); - } - - public function delete(TierReward $reward) - { - $reward->delete(); - - $this->toast('Hadiah berhasil dihapus.'); - - $this->dispatch('data:tiersUpdated')->to(Tier::class); - - Flux::modal('delete')->close(); - - $this->rewards = array_values(array_filter($this->rewards, fn ($item) => $item['hash'] !== $reward->hash)); - } - - public function render() + public function render(): View { return view('livewire.studio.loyalty.tier.reward', [ 'pageTitle' => 'Hadiah', diff --git a/app/Models/Tier.php b/app/Models/Tier.php index 48fec59..c9b4973 100644 --- a/app/Models/Tier.php +++ b/app/Models/Tier.php @@ -47,4 +47,93 @@ public function rewards(): HasMany { return $this->hasMany(TierReward::class); } + + public static function getHighestTier() + { + return self::orderByDesc('min_points')->first(); + } + + public static function updatePreviousTierMaxPoints(int $newMinPoints): void + { + $previousTier = self::whereNull('max_points') + ->orWhere('max_points', '>=', $newMinPoints) + ->orderByDesc('min_points') + ->first(); + + if ($previousTier && is_null($previousTier->max_points)) { + $previousTier->update(['max_points' => $newMinPoints - 1]); + } + } + + public static function validateTierPoints(int $minPoints, ?int $maxPoints = null, ?int $ignoreId = null): array + { + $errors = []; + + // Get the highest tier (excluding current if updating) + $highestTier = self::when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId)) + ->orderByDesc('min_points') + ->first(); + + // If there's a highest tier with null max_points, it should be updated first + if ($highestTier && is_null($highestTier->max_points)) { + if ($minPoints <= $highestTier->min_points) { + $errors[] = "Min. poin harus lebih besar dari {$highestTier->min_points} (tier {$highestTier->name})"; + } + } elseif ($highestTier) { + $requiredMinPoints = ($highestTier->max_points ?? $highestTier->min_points) + 1; + if ($minPoints <= $highestTier->max_points) { + $errors[] = "Min. poin harus lebih besar dari {$highestTier->max_points} (tier {$highestTier->name})"; + } + } + + // Check for overlap with existing tiers + if (self::isOverlap($minPoints, $maxPoints, $ignoreId)) { + $errors[] = 'Rentang poin tier tumpang tindih dengan tier lain'; + } + + return $errors; + } + + public static function reorderTierChain(): void + { + $tiers = self::orderBy('min_points')->get(); + + foreach ($tiers as $index => $tier) { + if ($index < $tiers->count() - 1) { + // Set max_points to min_points of next tier - 1 + $nextTier = $tiers[$index + 1]; + $tier->update(['max_points' => $nextTier->min_points - 1]); + } else { + // Last tier should have null max_points + $tier->update(['max_points' => null]); + } + } + } + + public static function canDeleteTier(int $tierId): array + { + $tier = self::find($tierId); + if (! $tier) { + return ['can_delete' => false, 'message' => 'Tier tidak ditemukan']; + } + + $tiers = self::orderBy('min_points')->get(); + $tierIndex = $tiers->search(fn ($t) => $t->id === $tierId); + + // Can always delete the lowest tier (first tier) + if ($tierIndex === 0) { + return ['can_delete' => true, 'message' => '']; + } + + // Can always delete the highest tier (last tier with null max_points) + if ($tierIndex === $tiers->count() - 1) { + return ['can_delete' => true, 'message' => '']; + } + + // Cannot delete middle tiers as it would create gaps in the tier chain + return [ + 'can_delete' => false, + 'message' => 'Tidak dapat menghapus tier tengah karena akan mengacaukan sistem poin. Tier harus membentuk rantai yang kontinyu.', + ]; + } } diff --git a/resources/views/livewire/studio/loyalty/tier/index.blade.php b/resources/views/livewire/studio/loyalty/tier/index.blade.php index 02609ba..95a639e 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