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.
This commit is contained in:
Yoga Pangestu 2026-07-29 01:30:29 +07:00
parent 8edeaf0db7
commit 5c1ef6a7b0
10 changed files with 731 additions and 1 deletions

View File

@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\CustomerRequest;
use App\Models\Customer;
use App\Services\Admin\Master\CustomerService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class CustomerController extends Controller
{
public function __construct(
private CustomerService $service
) {}
public function index(): Response
{
return Inertia::render('admin/master/customer/index', [
'customers' => $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');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Admin\Master;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CustomerRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$customer = $this->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',
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services\Admin\Master;
use App\Models\Customer;
use Illuminate\Database\Eloquent\Collection;
class CustomerService
{
public function getAll(): Collection
{
return Customer::latest()->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();
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Database\Seeders;
use App\Models\Customer;
use Illuminate\Database\Seeder;
class CustomerSeeder extends Seeder
{
public function run(): void
{
Customer::factory()->count(100)->create();
}
}

View File

@ -18,6 +18,7 @@ public function run(): void
UserSeeder::class,
CategorySeeder::class,
SupplierSeeder::class,
CustomerSeeder::class,
]);
}
}

View File

@ -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[] = [

View File

@ -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<Customer>[] {
const { handleEdit, handleDeleteClick } = params;
return [
{
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
),
meta: {
className: 'w-[50px] text-center',
headerClassName: 'w-[50px] text-center',
},
},
{
accessorKey: 'name',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Nama</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('name') as string}
</span>
),
},
{
accessorKey: 'phone_number',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>No. Telepon</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span>
{row.getValue('phone_number') as string ?? '-'}
</span>
),
},
{
accessorKey: 'address',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Alamat</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="max-w-[200px] truncate block">
{row.getValue('address') as string ?? '-'}
</span>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const customer = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(customer)
}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(customer)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
},
];
}

View File

@ -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<Customer | null>(null);
const [deleting, setDeleting] = useState<Customer | null>(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 (
<>
<Head title="Customer" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Customer
</h2>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
<DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Tambah Customer</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '} <span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama customer"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
No. Telepon
</Label>
<Input
id="phone_number"
name="phone_number"
type="tel"
inputMode="numeric"
pattern="[0-9]*"
placeholder="Masukkan nomor telepon"
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label htmlFor="address">
Alamat
</Label>
<Input
id="address"
name="address"
placeholder="Masukkan alamat"
/>
<InputError message={errors.address} />
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
>
Batal
</Button>
<Button
type='submit'
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={customers}
searchKey="name"
searchPlaceholder="Cari customer..."
emptyText="Belum ada data customer."
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
>
<DialogContent>
{editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Edit Customer</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Nama{' '} <span className="text-destructive">*</span></Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama customer"
defaultValue={editing.name}
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-phone_number">No. Telepon</Label>
<Input
id="edit-phone_number"
name="phone_number"
type="tel"
inputMode="numeric"
pattern="[0-9]*"
placeholder="Masukkan nomor telepon"
defaultValue={editing.phone_number ?? ''}
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-address">Alamat</Label>
<Input
id="edit-address"
name="address"
placeholder="Masukkan alamat"
defaultValue={editing.address ?? ''}
/>
<InputError message={errors.address} />
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditing(null);
}}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => {
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}
/>
</div>
</>
);
}
CustomerIndex.layout = {
breadcrumbs: [
{
title: 'Master',
href: customerIndex(),
},
{
title: 'Customer',
href: customerIndex(),
},
],
};

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\SupplierController;
use Illuminate\Support\Facades\Route;
@ -12,6 +13,7 @@
Route::prefix('admin/master')->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']);
});
});

View File

@ -0,0 +1,181 @@
<?php
use App\Models\Customer;
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
test('guests are redirected to the login page', function () {
$response = $this->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]);
});