feat(customer): membuat module customer

-membuat model, migrasi seeder dan faker
-membuat relasi di user
-membuat test untuk memastikan jalannya aplikasi
This commit is contained in:
Yoga Pangestu 2025-09-30 11:20:58 +07:00
parent ef05482000
commit ac44f25001
14 changed files with 477 additions and 2 deletions

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Datatable;
use App\Models\Customer;
use App\Traits\Datatable\WithAppendColumn;
use App\Traits\Datatable\WithConfiguration;
use App\Traits\Datatable\WithPrependColumn;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Blade;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class CustomersTable extends DataTableComponent
{
use WithAppendColumn, WithConfiguration, WithPrependColumn;
protected $model = Customer::class;
public function columns(): array
{
return [
Column::make('Nama', 'name')->searchable(),
Column::make('Nomor Telepon', 'phone_number')->searchable(),
Column::make('Jenis Kelamin', 'gender')
->format(fn ($value) => Blade::render(
'<flux:badge color="'.$value->color().'">'
.$value->label()
.'</flux:badge>'
))
->html(),
Column::make('Aksi')
->label(function ($row) {
$actions = '';
$actions .= view('components.datatables.edit-modal', [
'id' => $row->id,
'method' => 'update',
'modalTitle' => 'Ubah Customer',
])->render();
$actions .= view('components.datatables.delete', [
'id' => $row->id,
'deleteRoute' => route('studio.loyalty.customer.delete', $row->id),
])->render();
return $actions;
})
->html(),
];
}
public function builder(): Builder
{
return Customer::select('id', 'name', 'phone_number', 'gender');
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Livewire\Forms;
use App\Enums\Gender;
use App\Models\Customer;
use App\Rules\PhoneNumber;
use Illuminate\Validation\Rule;
use Livewire\Form;
class CustomerForm extends Form
{
public ?Customer $customer = null;
public string $name = '';
public ?string $phone_number = null;
public string $gender = '';
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50'],
'phone_number' => ['nullable', 'string', new PhoneNumber],
'gender' => ['required', Rule::in(Gender::cases())],
];
}
public function validationAttributes(): array
{
return [
'name' => 'nama',
'phone_number' => 'nomor telepon',
'gender' => 'jenis kelamin',
];
}
public function setCustomer(Customer $customer)
{
$this->customer = $customer;
$this->name = $customer->name;
$this->phone_number = $customer->phone_number;
$this->gender = $customer->gender->value;
}
public function store()
{
$this->validate();
Customer::create([
'name' => $this->name,
'phone_number' => $this->phone_number,
'gender' => $this->gender,
]);
}
public function update()
{
$this->validate();
$this->customer->update([
'name' => $this->name,
'phone_number' => $this->phone_number,
'gender' => $this->gender,
]);
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Livewire\Studio\Loyalty;
use App\Livewire\Forms\CustomerForm;
use App\Models\Customer as CustomerModel;
use App\Traits\WithCloseModal;
use App\Traits\WithConfirmation;
use App\Traits\WithUpdatedData;
use Flux\Flux;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Customer')]
class Customer extends Component
{
use WithCloseModal, WithConfirmation, WithUpdatedData;
public CustomerForm $form;
public string $method = 'create';
public string $modalTitle = '';
#[On('modal:open')]
public function openModal(string $method, string $modalTitle, ?string $id = null)
{
$this->resetValidation();
$this->resetErrorBag();
$this->method = $method;
$this->modalTitle = $modalTitle;
if ($id) {
$this->form->setCustomer(CustomerModel::findOrFail($id));
}
}
public function create()
{
$this->form->store();
$this->dispatch('refreshDatatable');
Flux::toast(
heading: 'Berhasil',
text: 'Customer berhasil ditambahkan.',
variant: 'success',
duration: 3000
);
Flux::modals()->close();
}
public function update()
{
$this->form->update();
$this->dispatch('refreshDatatable');
Flux::toast(
heading: 'Berhasil',
text: 'Customer berhasil diperbarui.',
variant: 'success',
duration: 3000
);
Flux::modals()->close();
}
public function delete(CustomerModel $customer)
{
$customer->delete();
$this->dispatch('refreshDatatable');
Flux::toast(
heading: 'Berhasil',
text: 'Customer berhasil dihapus.',
variant: 'success',
);
Flux::modals()->close();
}
public function render()
{
return view('livewire.studio.loyalty.customers', [
'pageTitle' => 'Customer',
]);
}
}

28
app/Models/Customer.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use App\Enums\Gender;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Customer extends Model
{
use HasFactory, SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'gender' => Gender::class,
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -30,4 +30,9 @@ public function employee(): HasOne
{
return $this->hasOne(Employee::class);
}
public function customer(): HasOne
{
return $this->hasOne(Customer::class);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Enums\Gender;
use Illuminate\Database\Eloquent\Factories\Factory;
class CustomerFactory extends Factory
{
public function definition(): array
{
return [
'name' => $this->faker->name(),
'phone_number' => fake()->randomElement([
fake()->numerify('0812 #### ###'),
fake()->numerify('0812 #### ####'),
fake()->numerify('0812 #### #####'),
]),
'gender' => $this->faker->randomElement(Gender::cases()),
];
}
}

View File

@ -0,0 +1,33 @@
<?php
use App\Enums\Gender;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
$table->string('name', 50);
$table->string('phone_number', 20)->nullable();
$table->enum('gender', [Gender::values()])->comment(Gender::comment());
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('customers');
}
};

View File

@ -0,0 +1,35 @@
<?php
namespace Database\Seeders;
use App\Enums\Gender;
use App\Models\Customer;
use Illuminate\Database\Seeder;
class CustomerSeeder extends Seeder
{
public function run(): void
{
$customers = [
[
'name' => 'Yoga Pangestu',
'phone_number' => '082121495806',
'gender' => Gender::MALE,
],
[
'name' => 'Budi',
'phone_number' => '082121673845',
'gender' => Gender::MALE,
],
[
'name' => 'Jaka',
'phone_number' => '082127382347',
'gender' => Gender::MALE,
],
];
foreach ($customers as $customer) {
Customer::create($customer);
}
}
}

View File

@ -15,6 +15,7 @@ public function run(): void
VoucherSeeder::class,
CategorySeeder::class,
BrandSeeder::class,
CustomerSeeder::class,
]);
}
}

View File

@ -31,6 +31,10 @@ class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-z
<flux:navlist.item icon="ticket" href="{{ route('studio.loyalty.voucher.index') }}"
:current="request()->routeIs('studio.loyalty.voucher.*')" wire:navigate.hover>Voucher
</flux:navlist.item>
<flux:navlist.item icon="user-plus" href="{{ route('studio.loyalty.customer.index') }}"
:current="request()->routeIs('studio.loyalty.customer.*')" wire:navigate.hover>Customer
</flux:navlist.item>
</div>
</div>

View File

@ -0,0 +1,48 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
<div>
<flux:modal.trigger name="form-modal">
<flux:button variant="primary" class="text-sm"
wire:click="$dispatch('modal:open', {method: 'create', 'modalTitle': 'Tambah Customer'})">Tambah
</flux:button>
</flux:modal.trigger>
</div>
</div>
<div class="mt-6">
<livewire:datatable.customers-table />
</div>
@include('components.confirmation.delete')
<flux:modal name="form-modal" class="w-[95%] max-w-sm md:max-w-xl mx-auto" @close="closeModal('form-modal')">
<div class="p-4 space-y-6">
<flux:heading size="lg">{{ $modalTitle }}</flux:heading>
<flux:input label="Nama" placeholder="Masukkan nama customer" wire:model.live.debounce.500ms="form.name"
autofocus autocomplete="off" clearable />
<flux:input label="Nomor Telepon" placeholder="Masukkan nomor telepon" mask="9999 9999 99999"
wire:model.live.debounce.500ms="form.phone_number" autocomplete="off" clearable />
<flux:radio.group wire:model.live="form.gender" variant="buttons" class="w-full *:flex-1"
label="Jenis Kelamin">
@foreach (\App\Enums\Gender::cases() as $gender)
<flux:radio value="{{ $gender->value }}" icon="{{ $gender->icon() }}">
{{ $gender->label() }}
</flux:radio>
@endforeach
</flux:radio.group>
<div class="flex">
<flux:spacer />
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="{{ $method }}">
Simpan
</flux:button>
</div>
</div>
</flux:modal>
</flux:main>

View File

@ -43,8 +43,8 @@
wire:model.live.debounce.500ms="form.quota"
x-mask:dynamic="$money($input, ',')" autocomplete="off" />
<flux:input label="Batas Per Pembeli"
placeholder="Masukkan batas per pembeli"
<flux:input label="Batas Per Customer"
placeholder="Masukkan batas per customer"
wire:model.live.debounce.500ms="form.limit_per_user"
x-mask:dynamic="$money($input, ',')" autocomplete="off" />
</div>

View File

@ -5,6 +5,7 @@
use App\Livewire\Studio\Catalog\Brand as BrandComponent;
use App\Livewire\Studio\Catalog\Category as CategoryComponent;
use App\Livewire\Studio\Dashboard\Overview;
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent;
use App\Livewire\Studio\Loyalty\Membership as MembershipComponent;
use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate;
use App\Livewire\Studio\Loyalty\Voucher\Edit as VoucherEdit;
@ -62,6 +63,13 @@
Route::delete('memberships/{membership}/delete', MembershipComponent::class)->name('delete');
});
Route::prefix('loyalty')
->as('studio.loyalty.customer.')
->group(function () {
Route::get('customers', CustomerComponent::class)->name('index');
Route::delete('customers/{customer}/delete', CustomerComponent::class)->name('delete');
});
Route::prefix('loyalty')
->as('studio.loyalty.voucher.')
->group(function () {

View File

@ -0,0 +1,69 @@
<?php
use App\Livewire\Studio\Loyalty\Customer;
use App\Models\Customer as CustomerModel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders successfully', function () {
Livewire::test(Customer::class)
->assertViewIs('livewire.studio.loyalty.customers')
->assertViewHas('pageTitle', 'Customer');
});
it('displays all customers', function () {
CustomerModel::factory()->count(10)->create();
$customer = CustomerModel::first();
Livewire::test(Customer::class)->assertSee($customer->name);
});
it('can create a customer', function () {
$data = CustomerModel::factory()->raw();
Livewire::test(Customer::class)
->set('form.name', $data['name'])
->set('form.phone_number', $data['phone_number'])
->set('form.gender', $data['gender'])
->call('create')
->assertHasNoErrors()
->assertDispatched('refreshDatatable');
expect(CustomerModel::count())->toBe(1);
expect(CustomerModel::first()->name)->toBe($data['name']);
});
it('can update a customer', function () {
$customer = CustomerModel::factory()->create();
$data = CustomerModel::factory()->raw();
Livewire::test(Customer::class)
->call('openModal', 'update', 'Edit Customer', $customer->id)
->set('form.name', $data['name'])
->set('form.phone_number', $data['phone_number'])
->set('form.gender', $data['gender'])
->call('update')
->assertHasNoErrors()
->assertDispatched('refreshDatatable');
$customer->refresh();
expect($customer->name)->toBe($data['name']);
expect($customer->phone_number)->toBe($data['phone_number']);
expect($customer->gender)->toBe($data['gender']);
});
it('can delete an customer', function () {
$customer = CustomerModel::factory()->create();
Livewire::test(Customer::class)
->call('delete', $customer)
->assertHasNoErrors()
->assertDispatched('refreshDatatable');
expect(CustomerModel::count())->toBe(0);
});