From 5c1ef6a7b0876b76cf29c275f6f1662c846861d8 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 29 Jul 2026 01:30:29 +0700 Subject: [PATCH] feat: add customer management functionality and update navigation - Introduced CustomerController for handling customer operations. - Added resourceful routes for customers in the web routes. - Updated DatabaseSeeder to include CustomerSeeder for initial data population. - Modified sidebar navigation to link to the customers index. --- .../Admin/Master/CustomerController.php | 52 ++++ .../Requests/Admin/Master/CustomerRequest.php | 34 +++ app/Services/Admin/Master/CustomerService.php | 31 +++ database/seeders/CustomerSeeder.php | 14 + database/seeders/DatabaseSeeder.php | 1 + resources/js/components/app-sidebar.tsx | 3 +- .../pages/admin/master/customer/columns.tsx | 160 +++++++++++ .../js/pages/admin/master/customer/index.tsx | 254 ++++++++++++++++++ routes/web.php | 2 + tests/Feature/Admin/Master/CustomerTest.php | 181 +++++++++++++ 10 files changed, 731 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Admin/Master/CustomerController.php create mode 100644 app/Http/Requests/Admin/Master/CustomerRequest.php create mode 100644 app/Services/Admin/Master/CustomerService.php create mode 100644 database/seeders/CustomerSeeder.php create mode 100644 resources/js/pages/admin/master/customer/columns.tsx create mode 100644 resources/js/pages/admin/master/customer/index.tsx create mode 100644 tests/Feature/Admin/Master/CustomerTest.php diff --git a/app/Http/Controllers/Admin/Master/CustomerController.php b/app/Http/Controllers/Admin/Master/CustomerController.php new file mode 100644 index 0000000..d66ab79 --- /dev/null +++ b/app/Http/Controllers/Admin/Master/CustomerController.php @@ -0,0 +1,52 @@ + $this->service->getAll(), + ]); + } + + public function store(CustomerRequest $request): RedirectResponse + { + $this->service->create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil ditambahkan.']); + + return to_route('admin.master.customers.index'); + } + + public function update(CustomerRequest $request, Customer $customer): RedirectResponse + { + $this->service->update($customer, $request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil diperbarui.']); + + return to_route('admin.master.customers.index'); + } + + public function destroy(Customer $customer): RedirectResponse + { + $this->service->delete($customer); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil dihapus.']); + + return to_route('admin.master.customers.index'); + } +} diff --git a/app/Http/Requests/Admin/Master/CustomerRequest.php b/app/Http/Requests/Admin/Master/CustomerRequest.php new file mode 100644 index 0000000..e73f30b --- /dev/null +++ b/app/Http/Requests/Admin/Master/CustomerRequest.php @@ -0,0 +1,34 @@ +route('customer'); + + return [ + 'name' => ['required', 'string', 'max:200', Rule::unique('customers', 'name')->ignore($customer)], + 'phone_number' => ['nullable', 'string', 'max:20', 'digits_between:1,20'], + 'address' => ['nullable', 'string'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'nama', + 'phone_number' => 'nomor telepon', + 'address' => 'alamat', + ]; + } +} diff --git a/app/Services/Admin/Master/CustomerService.php b/app/Services/Admin/Master/CustomerService.php new file mode 100644 index 0000000..ffbce46 --- /dev/null +++ b/app/Services/Admin/Master/CustomerService.php @@ -0,0 +1,31 @@ +get(); + } + + public function create(array $data): Customer + { + return Customer::create($data); + } + + public function update(Customer $customer, array $data): Customer + { + $customer->update($data); + + return $customer; + } + + public function delete(Customer $customer): bool + { + return $customer->delete(); + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 0000000..70c1ba1 --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,14 @@ +count(100)->create(); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4efad73..ba7e7c8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ public function run(): void UserSeeder::class, CategorySeeder::class, SupplierSeeder::class, + CustomerSeeder::class, ]); } } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 1a5aca7..7a9873c 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -36,6 +36,7 @@ import { import { useCurrentUrl } from '@/hooks/use-current-url'; import { dashboard } from '@/routes'; import { index as categoriesIndex } from '@/routes/admin/master/categories'; +import { index as customersIndex } from '@/routes/admin/master/customers'; import { index as suppliersIndex } from '@/routes/admin/master/suppliers'; type NavMenuItem = { title: string; href: string; icon: LucideIcon }; @@ -57,7 +58,7 @@ const masterItems: NavMenuItem[] = [ { title: 'Produk', href: '#', icon: Package }, { title: 'Bahan Baku', href: '#', icon: Boxes }, { title: 'Supplier', href: suppliersIndex.url(), icon: Truck }, - { title: 'Customer', href: '#', icon: Users }, + { title: 'Customer', href: customersIndex.url(), icon: Users }, ]; const kelolaItems: NavMenuItem[] = [ diff --git a/resources/js/pages/admin/master/customer/columns.tsx b/resources/js/pages/admin/master/customer/columns.tsx new file mode 100644 index 0000000..034c06f --- /dev/null +++ b/resources/js/pages/admin/master/customer/columns.tsx @@ -0,0 +1,160 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +export type Customer = { + id: number; + name: string; + phone_number: string | null; + address: string | null; +}; + +type CreateColumnsParams = { + handleEdit: (customer: Customer) => void; + handleDeleteClick: (customer: Customer) => void; +}; + +export function createCustomerColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick } = params; + + return [ + { + id: 'no', + header: () => No, + cell: ({ row }) => ( + + {row.index + 1} + + ), + meta: { + className: 'w-[50px] text-center', + headerClassName: 'w-[50px] text-center', + }, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.getValue('name') as string} + + ), + }, + { + accessorKey: 'phone_number', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.getValue('phone_number') as string ?? '-'} + + ), + }, + { + accessorKey: 'address', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.getValue('address') as string ?? '-'} + + ), + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const customer = row.original; + + return ( + +
+ + + + + + Edit + + + + + + + + + Hapus + + +
+
+ ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/master/customer/index.tsx b/resources/js/pages/admin/master/customer/index.tsx new file mode 100644 index 0000000..dd48e20 --- /dev/null +++ b/resources/js/pages/admin/master/customer/index.tsx @@ -0,0 +1,254 @@ +import { Form, Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { destroy, index as customerIndex, store, update } from '@/routes/admin/master/customers'; +import { createCustomerColumns } from './columns'; +import type { Customer } from './columns'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { DataTable } from '@/components/data-table'; + +type Props = { + customers: Customer[]; +}; + +export default function CustomerIndex({ customers }: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + const columns = createCustomerColumns({ + handleEdit: (customer) => setEditing(customer), + handleDeleteClick: (customer) => setDeleting(customer), + }); + + return ( + <> + + +
+
+
+

+ Customer +

+
+ + + + +
setCreateOpen(false)}> + {({ errors, processing }) => { + + return ( + <> + + Tambah Customer + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + + + + ); + }} +
+
+
+
+ + + + { + if (!open) { + setEditing(null); + } + }} + > + + {editing && ( +
setEditing(null)}> + {({ errors, processing }) => { + + return ( + <> + + Edit Customer + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + + + + ); + }} +
+ )} +
+
+ + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Customer" + description={`Apakah Anda yakin ingin menghapus customer "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`} + confirmLabel="Hapus" + onConfirm={handleDelete} + /> +
+ + ); +} + +CustomerIndex.layout = { + breadcrumbs: [ + { + title: 'Master', + href: customerIndex(), + }, + { + title: 'Customer', + href: customerIndex(), + }, + ], +}; diff --git a/routes/web.php b/routes/web.php index 476e8da..5176df0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('admin.master.')->group(function () { Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit']); Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']); + Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']); }); }); diff --git a/tests/Feature/Admin/Master/CustomerTest.php b/tests/Feature/Admin/Master/CustomerTest.php new file mode 100644 index 0000000..3e04623 --- /dev/null +++ b/tests/Feature/Admin/Master/CustomerTest.php @@ -0,0 +1,181 @@ +get(route('admin.master.customers.index')); + $response->assertRedirect(route('login')); +}); + +test('authenticated users can visit the customer index page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.master.customers.index')); + $response->assertOk(); +}); + +test('customer index page displays customers', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $customers = Customer::factory()->count(3)->create(); + + $response = $this->get(route('admin.master.customers.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/master/customer/index') + ->has('customers', 3) + ); +}); + +test('customer can be created', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.customers.store'), [ + 'name' => 'Customer Test', + 'phone_number' => '08123456789', + 'address' => 'Jl. Test No. 1', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.master.customers.index')); + + $this->assertDatabaseHas('customers', [ + 'name' => 'Customer Test', + 'phone_number' => '08123456789', + 'address' => 'Jl. Test No. 1', + ]); +}); + +test('customer can be created without optional fields', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.customers.store'), [ + 'name' => 'Customer Minimal', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.master.customers.index')); + + $this->assertDatabaseHas('customers', [ + 'name' => 'Customer Minimal', + ]); +}); + +test('customer name is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.customers.store'), [ + 'name' => '', + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('customer name must not exceed 200 characters', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.customers.store'), [ + 'name' => str_repeat('a', 201), + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('customer name must be unique', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Customer::factory()->create(['name' => 'Existing Customer']); + + $response = $this->post(route('admin.master.customers.store'), [ + 'name' => 'Existing Customer', + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('customer phone_number must be numeric', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.customers.store'), [ + 'name' => 'Customer With Letters', + 'phone_number' => 'abc123', + ]); + + $response->assertSessionHasErrors('phone_number'); +}); + +test('customer can be updated', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $customer = Customer::factory()->create(); + + $response = $this->put(route('admin.master.customers.update', $customer), [ + 'name' => 'Customer Updated', + 'phone_number' => '0987654321', + 'address' => 'Jl. Updated No. 2', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.master.customers.index')); + + $customer->refresh(); + expect($customer->name)->toBe('Customer Updated'); + expect($customer->phone_number)->toBe('0987654321'); + expect($customer->address)->toBe('Jl. Updated No. 2'); +}); + +test('customer name can be updated to itself', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $customer = Customer::factory()->create(['name' => 'My Customer']); + + $response = $this->put(route('admin.master.customers.update', $customer), [ + 'name' => 'My Customer', + ]); + + $response->assertSessionHasNoErrors(); +}); + +test('customer update name must be unique excluding itself', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $customer = Customer::factory()->create(['name' => 'First']); + Customer::factory()->create(['name' => 'Second']); + + $response = $this->put(route('admin.master.customers.update', $customer), [ + 'name' => 'Second', + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('customer can be deleted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $customer = Customer::factory()->create(); + + $response = $this->delete(route('admin.master.customers.destroy', $customer)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.master.customers.index')); + + $this->assertSoftDeleted('customers', ['id' => $customer->id]); +});