Compare commits

...

10 Commits

19 changed files with 581 additions and 81 deletions

View File

@ -72,11 +72,13 @@ public function updated(string $propertyName, mixed $value): void
public function register(): void
{
$this->form->auth();
$this->sendRegistrationSuccessToast();
$this->notifyPrivilegedUsersAboutNewMember();
$this->redirectAfterRegistration();
if ($this->form->auth()) {
$this->sendRegistrationSuccessToast();
$this->notifyPrivilegedUsersAboutNewMember();
$this->redirectAfterRegistration();
} else {
$this->toast('Terjadi kesalahan saat mendaftarkan akun. Silakan hubungi admin atau coba beberapa saat lagi.', 'Kesalahan', 'danger');
}
}
public function render(): View

View File

@ -16,6 +16,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Livewire\Form;
@ -92,7 +93,7 @@ public function validationAttributes(): array
];
}
public function auth(): string
public function auth(): ?string
{
$this->validateAll();
@ -101,56 +102,66 @@ public function auth(): string
$referralCode = ReferralCode::where('code', $this->referral_code)->first();
$user = null;
DB::transaction(function () use ($tier, $maxUser, $referralCode, &$user): void {
$user = User::create([
'email' => $this->email,
'username' => $this->username,
'password' => Hash::make($this->password),
'status' => UserStatus::ACTIVE,
]);
Customer::create([
'user_id' => $user->id,
'name' => $this->name,
'phone_number' => $this->phone_number,
'gender' => $this->gender,
]);
Membership::create([
'user_id' => $user->id,
'tier_id' => $tier->id,
'reward_points' => 10,
]);
ReferralCode::create([
'user_id' => $user->id,
'code' => generateReferralCode($this->username, $maxUser),
]);
if ($this->referral_code) {
ReferralUsage::create([
'user_id' => $user->id,
'referral_code_id' => $referralCode->id,
try {
DB::transaction(function () use ($tier, $maxUser, $referralCode, &$user): void {
$user = User::create([
'email' => $this->email,
'username' => $this->username,
'password' => Hash::make($this->password),
'status' => UserStatus::ACTIVE,
]);
$referralCode->increment('uses_count');
}
Customer::create([
'user_id' => $user->id,
'name' => $this->name,
'phone_number' => $this->phone_number,
'gender' => $this->gender,
]);
PointRecord::create([
'user_id' => $user->id,
'description' => 'Pendaftaran',
'change' => 10,
'is_addition' => true,
'type' => PointRecordType::TIER,
Membership::create([
'user_id' => $user->id,
'tier_id' => $tier->id,
'reward_points' => 10,
]);
ReferralCode::create([
'user_id' => $user->id,
'code' => generateReferralCode($this->username, $maxUser),
]);
if ($this->referral_code) {
ReferralUsage::create([
'user_id' => $user->id,
'referral_code_id' => $referralCode->id,
]);
$referralCode->increment('uses_count');
}
PointRecord::create([
'user_id' => $user->id,
'description' => 'Pendaftaran',
'change' => 10,
'is_addition' => true,
'type' => PointRecordType::TIER,
]);
$user->assignRole('Customer');
Auth::login($user);
$user->sendEmailVerificationNotification();
});
return $this->name;
} catch (\Exception $e) {
Log::error('Registration Error: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
'email' => $this->email,
'username' => $this->username,
]);
$user->assignRole('Customer');
Auth::login($user);
$user->sendEmailVerificationNotification();
});
return $this->name;
return null;
}
}
}

View File

@ -6,6 +6,7 @@
use App\Models\Outlet;
use App\Rules\UnsignedInteger;
use App\Services\StockActivityLogService;
use App\Traits\Forms\WithFormStockAdjustment;
use App\Traits\Media\WithMediaHandler;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
@ -13,7 +14,7 @@
class BottleForm extends Form
{
use WithMediaHandler;
use WithFormStockAdjustment, WithMediaHandler;
public ?Bottle $bottle = null;
@ -70,6 +71,7 @@ public function setBottle(Bottle $bottle): void
$this->description = $bottle->description;
$this->outlet_ids = $this->bottle->outlets->pluck('id')->toArray();
$this->image = $this->mapMediaCollection($bottle->getMedia('image'));
$this->loadStockAdjustments($bottle);
}
public function store(): void
@ -129,6 +131,8 @@ public function update(): void
}
}
$this->saveStockAdjustments($this->bottle, 'bottle');
$this->syncMedia($this->image, $this->bottle, 'image');
$this->uploadMedia($this->image, $this->bottle, 'image');
});

View File

@ -7,6 +7,7 @@
use App\Models\Perfume;
use App\Rules\UnsignedInteger;
use App\Services\StockActivityLogService;
use App\Traits\Forms\WithFormStockAdjustment;
use App\Traits\Media\WithMediaHandler;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
@ -14,7 +15,7 @@
class PerfumeForm extends Form
{
use WithMediaHandler;
use WithFormStockAdjustment, WithMediaHandler;
public ?Perfume $perfume = null;
@ -96,6 +97,7 @@ public function setPerfume(Perfume $perfume): void
$this->category_ids = $perfume->categories->pluck('id')->toArray();
$this->outlet_ids = $this->perfume->outlets->pluck('id')->toArray();
$this->image = $this->mapMediaCollection($perfume->getMedia('image'));
$this->loadStockAdjustments($perfume);
}
public function store(): void
@ -163,6 +165,8 @@ public function update(): void
}
}
$this->saveStockAdjustments($this->perfume, 'perfume');
$this->syncMedia($data['image'], $this->perfume, 'image');
$this->uploadMedia($data['image'], $this->perfume, 'image');
});

View File

@ -6,6 +6,7 @@
use App\Models\Product;
use App\Rules\UnsignedInteger;
use App\Services\StockActivityLogService;
use App\Traits\Forms\WithFormStockAdjustment;
use App\Traits\Media\WithMediaHandler;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
@ -13,7 +14,7 @@
class ProductForm extends Form
{
use WithMediaHandler;
use WithFormStockAdjustment, WithMediaHandler;
public ?Product $product = null;
@ -67,6 +68,7 @@ public function setProduct(Product $product): void
$this->description = $product->description;
$this->outlet_ids = $this->product->outlets->pluck('id')->toArray();
$this->image = $this->mapMediaCollection($product->getMedia('image'));
$this->loadStockAdjustments($product);
}
public function store(): void
@ -130,6 +132,8 @@ public function update(): void
}
}
$this->saveStockAdjustments($this->product, 'product');
$this->syncMedia($data['image'], $this->product, 'image');
$this->uploadMedia($data['image'], $this->product, 'image');
});

View File

@ -5,6 +5,7 @@
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\UserStatus;
use App\Notifications\VerifyEmailQueued;
use Dyrynda\Database\Support\CascadeSoftDeletes;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\MustVerifyEmail;
@ -46,6 +47,11 @@ protected function casts(): array
];
}
public function sendEmailVerificationNotification()
{
$this->notify(new VerifyEmailQueued);
}
#[Scope]
protected function active(Builder $query): void
{

View File

@ -0,0 +1,26 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
class VerifyEmailQueued extends VerifyEmail implements ShouldQueue
{
use Queueable;
public function toMail($notifiable): MailMessage
{
$verificationUrl = $this->verificationUrl($notifiable);
return (new MailMessage)
->subject('Verifikasi Alamat Email')
->greeting('Halo, '.$notifiable->customer->name.'!')
->line('Klik tombol di bawah ini untuk memverifikasi alamat email Anda.')
->action('Verifikasi Email', $verificationUrl)
->line('Jika Anda tidak membuat akun, abaikan email ini.')
->salutation('Salam, '.config('app.name'));
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Traits\Forms;
use App\Models\Outlet;
use App\Services\StockActivityLogService;
use Illuminate\Database\Eloquent\Model;
trait WithFormStockAdjustment
{
public array $stock_adjustments = [];
protected function loadStockAdjustments(Model $item): void
{
$this->stock_adjustments = [
'outlet' => [],
];
$user = auth()->user();
$userOutletIds = $user->outlets->pluck('id')->toArray();
$itemOutlets = $item->outlets()
->whereIn('outlets.id', $userOutletIds)
->withPivot('stock')
->get();
foreach ($itemOutlets as $outlet) {
$stock = $outlet->pivot->stock ?? 0;
$this->stock_adjustments['outlet'][$outlet->id] = [
'name' => $outlet->name,
'stock' => $stock,
'new_stock' => (string) $stock,
'note' => '',
];
}
}
protected function saveStockAdjustments(Model $item, string $itemType): void
{
$itemName = $item->name.($itemType === 'bottle' ? " ({$item->size}ml)" : '');
if (isset($this->stock_adjustments['outlet'])) {
foreach ($this->stock_adjustments['outlet'] as $id => $adj) {
$newStock = (int) str_replace(['.', ','], '', (string) ($adj['new_stock'] ?: '0'));
$oldStock = (int) $adj['stock'];
if ($newStock === $oldStock) {
continue;
}
$outlet = Outlet::find($id);
if ($outlet) {
$item->outlets()->syncWithoutDetaching([$id => ['stock' => $newStock]]);
StockActivityLogService::logOutlet(
logName: 'adjustment',
event: 'update',
description: "Penyesuaian manual untuk {$itemName}: berubah dari ".formatCurrencyNumber($oldStock).' menjadi '.formatCurrencyNumber($newStock).'. Catatan: '.($adj['note'] ?: '-'),
performedOn: $item,
outlet: $outlet,
quantityChange: $newStock - $oldStock,
previousStock: $oldStock,
newStock: $newStock,
extraProperties: [
'note' => $adj['note'],
'adjustment_type' => 'manual',
'item_type' => $itemType,
'item_id' => $item->id,
]
);
}
}
}
}
}

View File

@ -52,6 +52,9 @@ public function run(): void
TestimonialSeeder::class,
ArticleSeeder::class,
OrderSeeder::class,
PurchaseSeeder::class,
ExpenseSeeder::class,
PayrollSeeder::class,
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Seeders;
use App\Models\Expense;
use App\Models\Outlet;
use App\Models\User;
use Illuminate\Database\Seeder;
class ExpenseSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$users = User::all();
$outlets = Outlet::all();
if ($users->isEmpty() || $outlets->isEmpty()) {
return;
}
Expense::factory()
->count(100)
->recycle($users)
->recycle($outlets)
->create();
}
}

View File

@ -16,7 +16,7 @@ public function run(): void
'Murni' => 1.0,
];
$sizes = Bottle::distinct()->pluck('size');
$sizes = Bottle::reorder()->pluck('size');
foreach ($sizes as $size) {
foreach ($ratios as $quality => $ratio) {

View File

@ -48,33 +48,46 @@ public function run(): void
$orderableItems = $orderableItems->merge($products);
}
for ($i = 0; $i < 50; $i++) {
$outlet = $outlets->random();
$user = $users->random();
$customer = $customers->random();
$startDate = now()->subMonths(6);
$endDate = now();
$orderCount = 0;
$status = $this->getRandomStatus();
$orderedAt = now()->subDays(rand(0, 90));
for ($date = $startDate->copy(); $date->lte($endDate); $date->addDay()) {
$ordersPerDay = rand(1, 10);
$order = Order::create([
'outlet_id' => $outlet->id,
'user_id' => $user->id,
'customer_id' => $customer->id,
'voucher_id' => null,
'invoice_number' => 'INV'.$orderedAt->format('Ymd').str_pad($i + 1, 4, '0', STR_PAD_LEFT),
'channel' => OrderChannel::OUTLET,
'status' => $status,
'ordered_at' => $orderedAt,
'created_at' => $orderedAt,
'updated_at' => $orderedAt,
'cogs' => 0,
'subtotal' => 0,
'discount' => 0,
'voucher_discount' => 0,
'total' => 0,
]);
for ($i = 0; $i < $ordersPerDay; $i++) {
$orderCount++;
$outlet = $outlets->random();
$user = $users->random();
$customer = $customers->random();
$this->createOrderItems($order, $user, $orderableItems);
$status = $this->getRandomStatus();
$orderedAt = $date->copy()->startOfDay()->addHours(rand(8, 22))->addMinutes(rand(0, 59));
if ($orderedAt->gt(now())) {
$orderedAt = now();
}
$order = Order::create([
'outlet_id' => $outlet->id,
'user_id' => $user->id,
'customer_id' => $customer->id,
'voucher_id' => null,
'invoice_number' => 'INV'.$orderedAt->format('Ymd').str_pad($orderCount, 4, '0', STR_PAD_LEFT),
'channel' => OrderChannel::OUTLET,
'status' => $status,
'ordered_at' => $orderedAt,
'created_at' => $orderedAt,
'updated_at' => $orderedAt,
'cogs' => 0,
'subtotal' => 0,
'discount' => 0,
'voucher_discount' => 0,
'total' => 0,
]);
$this->createOrderItems($order, $user, $orderableItems);
}
}
}
@ -102,7 +115,7 @@ private function createOrderItems(Order $order, User $user, Collection $orderabl
for ($j = 0; $j < $itemCount; $j++) {
$item = $orderables->random();
$qty = rand(1, 3);
$qty = rand(20, 100);
$price = $item->sale_price ?? rand(50000, 150000);
$cogs = $item->cost_price ?? (int) ($price * 0.4);

View File

@ -0,0 +1,56 @@
<?php
namespace Database\Seeders;
use App\Enums\IsPaid;
use App\Models\Payroll;
use App\Models\PayrollAdjustment;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
class PayrollSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$users = User::whereHas('roles', function ($q) {
$q->whereIn('name', ['Admin', 'Leader']);
})->get();
if ($users->isEmpty()) {
return;
}
$now = Carbon::now();
foreach ($users as $user) {
// 12 months backward including current month
for ($i = 0; $i < 12; $i++) {
$month = $now->copy()->subMonths($i);
$isCurrentMonth = $i === 0;
$payroll = Payroll::factory()
->for($user)
->withPeriodMonth($month->format('Y-m'))
->create([
'is_paid' => $isCurrentMonth ? IsPaid::NOT_PAID : IsPaid::PAID,
'paid_at' => $isCurrentMonth ? null : $month->copy()->addDays(28),
'created_at' => $month->copy()->addDays(20),
'updated_at' => $month->copy()->addDays(20),
]);
// Create some adjustments
PayrollAdjustment::factory()
->count(rand(0, 3))
->create([
'payroll_id' => $payroll->id,
'created_at' => $month->copy()->addDays(22),
'updated_at' => $month->copy()->addDays(22),
]);
}
}
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace Database\Seeders;
use App\Models\Perfume;
use App\Models\Product;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\User;
use App\Models\Warehouse;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
class PurchaseSeeder extends Seeder
{
public function run(): void
{
$warehouses = Warehouse::all();
if ($warehouses->isEmpty()) {
$warehouses = Warehouse::factory(3)->create();
}
$users = User::all();
if ($users->isEmpty()) {
$users = User::factory(5)->create();
}
$perfumes = Perfume::all();
$products = Product::all();
if ($perfumes->isEmpty() && $products->isEmpty()) {
$perfumes = Perfume::factory(10)->create();
}
$purchasableItems = collect();
if ($perfumes->isNotEmpty()) {
$purchasableItems = $purchasableItems->merge($perfumes);
}
if ($products->isNotEmpty()) {
$purchasableItems = $purchasableItems->merge($products);
}
$startDate = now()->subMonths(6)->startOfMonth();
$endDate = now();
$purchaseCount = 0;
// Iterate through each month
for ($date = $startDate->copy(); $date->lte($endDate); $date->addMonth()) {
// Purchases twice a month (e.g., 5th and 20th)
$purchaseDates = [
$date->copy()->day(5),
$date->copy()->day(20),
];
foreach ($purchaseDates as $purchaseDate) {
if ($purchaseDate->gt($endDate)) {
continue; // Skip if date is in the future
}
$purchaseCount++;
$warehouse = $warehouses->random();
$purchaseTime = $purchaseDate->copy()->addHours(rand(9, 16))->addMinutes(rand(0, 59));
$purchase = Purchase::create([
'warehouse_id' => $warehouse->id,
'invoice_number' => 'PO'.$purchaseTime->format('Ymd').str_pad($purchaseCount, 4, '0', STR_PAD_LEFT),
'purchase_date' => $purchaseTime->toDateString(),
'total' => 0,
'note' => 'Restock '.$purchaseTime->format('F Y'),
'created_at' => $purchaseTime,
'updated_at' => $purchaseTime,
]);
$this->createPurchaseItems($purchase, $users->random(), $purchasableItems);
}
}
}
private function createPurchaseItems(Purchase $purchase, User $user, Collection $purchasables): void
{
$itemCount = rand(5, 15);
$totalPurchase = 0;
for ($j = 0; $j < $itemCount; $j++) {
$item = $purchasables->random();
$qty = rand(50, 500);
$unitPrice = $item->cost_price ?? (int) (($item->sale_price ?? rand(50000, 150000)) * 0.4);
$totalPrice = $unitPrice * $qty;
PurchaseItem::create([
'purchase_id' => $purchase->id,
'user_id' => $user->id,
'purchasable_type' => $item->getMorphClass(),
'purchasable_id' => $item->id,
'quantity' => $qty,
'unit_price' => $unitPrice,
'total_price' => $totalPrice,
'created_at' => $purchase->created_at,
'updated_at' => $purchase->created_at,
]);
$totalPurchase += $totalPrice;
}
$purchase->update([
'total' => $totalPurchase,
]);
}
}

View File

@ -76,5 +76,89 @@ public function run(): void
$owner->assignRole(UserRole::Owner);
$owner->outlets()->attach($outlet_ids);
// leader
$leader = User::create([
'email' => 'leader@gmail.com',
'username' => 'leader',
'password' => Hash::make(config('myconfig.password_default')),
'status' => UserStatus::ACTIVE,
'email_verified_at' => now(),
]);
Employee::create([
'user_id' => $leader->id,
'code' => '0003',
'full_name' => 'Leader User',
'phone_number' => '082121495801',
'gender' => Gender::MALE,
'address' => 'Leader Address',
'birthdate' => '2000-01-01',
'hire_date' => now(),
'status' => EmploymentStatus::PERMANENT,
]);
ReferralCode::create([
'user_id' => $leader->id,
'code' => generateReferralCode('leader', 3),
]);
$leader->assignRole(UserRole::Leader);
$leader->outlets()->attach($outlet_ids);
// admin per outlet
$outlets = Outlet::all();
$userCounter = 4;
foreach ($outlets as $outlet) {
$adminUsername = 'admin_'.preg_replace('/[^a-zA-Z0-9]/', '', strtolower($outlet->name));
$adminEmail = $adminUsername.'@gmail.com';
$admin = User::create([
'email' => $adminEmail,
'username' => $adminUsername,
'password' => Hash::make(config('myconfig.password_default')),
'status' => UserStatus::ACTIVE,
'email_verified_at' => now(),
]);
Employee::create([
'user_id' => $admin->id,
'code' => str_pad($userCounter, 4, '0', STR_PAD_LEFT),
'full_name' => 'Admin '.$outlet->name,
'phone_number' => '0821214958'.str_pad($userCounter, 2, '0', STR_PAD_LEFT),
'gender' => Gender::MALE,
'address' => 'Admin Address',
'birthdate' => '2000-01-01',
'hire_date' => now(),
'status' => EmploymentStatus::PERMANENT,
]);
ReferralCode::create([
'user_id' => $admin->id,
'code' => generateReferralCode($adminUsername, $userCounter),
]);
$admin->assignRole(UserRole::Admin);
$admin->outlets()->attach($outlet->id);
$userCounter++;
}
// partner
$partner = User::create([
'email' => 'partner@gmail.com',
'username' => 'partner',
'password' => Hash::make(config('myconfig.password_default')),
'status' => UserStatus::ACTIVE,
'email_verified_at' => now(),
]);
ReferralCode::create([
'user_id' => $partner->id,
'code' => generateReferralCode('partner', $userCounter),
]);
$partner->assignRole(UserRole::Partner);
$partner->outlets()->attach($outlet_ids);
}
}

View File

@ -0,0 +1,59 @@
@props(['form'])
<div class="mt-8 space-y-4">
<div class="flex items-center gap-2 px-1">
<flux:icon icon="map-pin" variant="outline" class="text-gray-400" size="sm" />
<flux:heading size="lg">Manajemen Stok Outlet</flux:heading>
</div>
@if (count($form->stock_adjustments['outlet'] ?? []) > 0)
<flux:card class="space-y-6">
<div class="flex items-center gap-2 border-b border-gray-100 dark:border-white/5 pb-4 -mx-6 px-6">
<div class="w-1.5 h-1.5 rounded-full bg-emerald-500"></div>
<flux:heading size="md" class="font-bold">Outlet Penjualan</flux:heading>
</div>
<div class="space-y-8">
@foreach ($form->stock_adjustments['outlet'] as $id => $adj)
<div class="space-y-4">
<div class="flex justify-between items-center">
<div class="flex flex-col">
<span
class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ $adj['name'] }}</span>
</div>
<flux:badge color="emerald" size="sm" variant="pill" class="font-mono">Stok:
{{ number_format($adj['stock']) }}</flux:badge>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<flux:field>
<flux:label class="text-xs font-medium opacity-70">Penyesuaian Stok Baru</flux:label>
<flux:input.group>
<flux:input.group.prefix class="bg-gray-50/50 dark:bg-white/5">
<flux:icon icon="plus" variant="mini" />
</flux:input.group.prefix>
<flux:input type="number"
wire:model="form.stock_adjustments.outlet.{{ $id }}.new_stock"
class="font-mono" />
</flux:input.group>
</flux:field>
<flux:field>
<flux:label class="text-xs font-medium opacity-70">Catatan Perubahan</flux:label>
<flux:input placeholder="Alasan penyesuaian..."
wire:model="form.stock_adjustments.outlet.{{ $id }}.note" autocomplete="off" />
</flux:field>
</div>
</div>
@if (!$loop->last)
<flux:separator variant="subtle" />
@endif
@endforeach
</div>
</flux:card>
@else
<flux:card class="p-6 text-center italic text-gray-400 text-sm">
Tidak ada outlet yang terhubung atau Anda tidak memiliki akses ke outlet manapun.
</flux:card>
@endif
</div>

View File

@ -63,6 +63,10 @@ class="col-span-{{ auth()->user()->hasRole(['Developer', 'Owner'])? '1': '2' }}"
</div>
</div>
</flux:card>
@if ($form->bottle?->exists)
<x-forms.stock-management :form="$form" />
@endif
</div>
</div>
</div>

View File

@ -87,6 +87,10 @@ class="grid grid-cols-1 md:grid-cols-{{ auth()->user()->hasRole(['Developer', 'O
</div>
</div>
</flux:card>
@if ($form->perfume?->exists)
<x-forms.stock-management :form="$form" />
@endif
</div>
</div>
</div>

View File

@ -58,6 +58,10 @@ class="col-span-{{ auth()->user()->hasRole(['Developer', 'Owner'])? '1': '2' }}"
</div>
</div>
</flux:card>
@if ($form->product?->exists)
<x-forms.stock-management :form="$form" />
@endif
</div>
</div>
</div>