feat: implement supplier management with CRUD functionality

- Added SupplierController for handling supplier operations.
- Created SupplierRequest for validation of supplier data.
- Introduced SupplierService for business logic related to suppliers.
- Developed UI components for supplier management, including a data table and dialogs for creating and editing suppliers.
- Updated routes to include resourceful routes for suppliers.
- Added SupplierSeeder for populating initial supplier data.
- Implemented tests for supplier functionality, including creation, updating, and deletion.
This commit is contained in:
Yoga Pangestu 2026-07-29 01:16:58 +07:00
parent e93fd181ee
commit 8edeaf0db7
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\SupplierRequest;
use App\Models\Supplier;
use App\Services\Admin\Master\SupplierService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class SupplierController extends Controller
{
public function __construct(
private SupplierService $service
) {}
public function index(): Response
{
return Inertia::render('admin/master/supplier/index', [
'suppliers' => $this->service->getAll(),
]);
}
public function store(SupplierRequest $request): RedirectResponse
{
$this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil ditambahkan.']);
return to_route('admin.master.suppliers.index');
}
public function update(SupplierRequest $request, Supplier $supplier): RedirectResponse
{
$this->service->update($supplier, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil diperbarui.']);
return to_route('admin.master.suppliers.index');
}
public function destroy(Supplier $supplier): RedirectResponse
{
$this->service->delete($supplier);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil dihapus.']);
return to_route('admin.master.suppliers.index');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Admin\Master;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SupplierRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$supplier = $this->route('supplier');
return [
'name' => ['required', 'string', 'max:200', Rule::unique('suppliers', 'name')->ignore($supplier)],
'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\Supplier;
use Illuminate\Database\Eloquent\Collection;
class SupplierService
{
public function getAll(): Collection
{
return Supplier::latest()->get();
}
public function create(array $data): Supplier
{
return Supplier::create($data);
}
public function update(Supplier $supplier, array $data): Supplier
{
$supplier->update($data);
return $supplier;
}
public function delete(Supplier $supplier): bool
{
return $supplier->delete();
}
}

View File

@ -17,6 +17,7 @@ public function run(): void
$this->call([
UserSeeder::class,
CategorySeeder::class,
SupplierSeeder::class,
]);
}
}

View File

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

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 suppliersIndex } from '@/routes/admin/master/suppliers';
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
@ -55,7 +56,7 @@ const masterItems: NavMenuItem[] = [
{ title: 'Kategori', href: categoriesIndex.url(), icon: Tags },
{ title: 'Produk', href: '#', icon: Package },
{ title: 'Bahan Baku', href: '#', icon: Boxes },
{ title: 'Supplier', href: '#', icon: Truck },
{ title: 'Supplier', href: suppliersIndex.url(), icon: Truck },
{ title: 'Customer', href: '#', icon: Users },
];

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 Supplier = {
id: number;
name: string;
phone_number: string | null;
address: string | null;
};
type CreateColumnsParams = {
handleEdit: (supplier: Supplier) => void;
handleDeleteClick: (supplier: Supplier) => void;
};
export function createSupplierColumns(
params: CreateColumnsParams,
): ColumnDef<Supplier>[] {
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 supplier = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(supplier)
}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(supplier)
}
>
<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 supplierIndex, store, update } from '@/routes/admin/master/suppliers';
import { createSupplierColumns } from './columns';
import type { Supplier } from './columns';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
type Props = {
suppliers: Supplier[];
};
export default function SupplierIndex({ suppliers }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Supplier | null>(null);
const [deleting, setDeleting] = useState<Supplier | null>(null);
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
const columns = createSupplierColumns({
handleEdit: (supplier) => setEditing(supplier),
handleDeleteClick: (supplier) => setDeleting(supplier),
});
return (
<>
<Head title="Supplier" />
<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">
Supplier
</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 Supplier</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 supplier"
/>
<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={suppliers}
searchKey="name"
searchPlaceholder="Cari supplier..."
emptyText="Belum ada data supplier."
/>
<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 Supplier</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 supplier"
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 Supplier"
description={`Apakah Anda yakin ingin menghapus supplier "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDelete}
/>
</div>
</>
);
}
SupplierIndex.layout = {
breadcrumbs: [
{
title: 'Master',
href: supplierIndex(),
},
{
title: 'Supplier',
href: supplierIndex(),
},
],
};

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\SupplierController;
use Illuminate\Support\Facades\Route;
Route::inertia('/', 'welcome')->name('home');
@ -10,6 +11,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']);
});
});

View File

@ -0,0 +1,181 @@
<?php
use App\Models\Supplier;
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.suppliers.index'));
$response->assertRedirect(route('login'));
});
test('authenticated users can visit the supplier index page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.master.suppliers.index'));
$response->assertOk();
});
test('supplier index page displays suppliers', function () {
$user = User::factory()->create();
$this->actingAs($user);
$suppliers = Supplier::factory()->count(3)->create();
$response = $this->get(route('admin.master.suppliers.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/supplier/index')
->has('suppliers', 3)
);
});
test('supplier can be created', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.suppliers.store'), [
'name' => 'Supplier Test',
'phone_number' => '08123456789',
'address' => 'Jl. Test No. 1',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.suppliers.index'));
$this->assertDatabaseHas('suppliers', [
'name' => 'Supplier Test',
'phone_number' => '08123456789',
'address' => 'Jl. Test No. 1',
]);
});
test('supplier can be created without optional fields', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.suppliers.store'), [
'name' => 'Supplier Minimal',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.suppliers.index'));
$this->assertDatabaseHas('suppliers', [
'name' => 'Supplier Minimal',
]);
});
test('supplier phone_number must be numeric', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.suppliers.store'), [
'name' => 'Supplier With Letters',
'phone_number' => 'abc123',
]);
$response->assertSessionHasErrors('phone_number');
});
test('supplier name is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.suppliers.store'), [
'name' => '',
]);
$response->assertSessionHasErrors('name');
});
test('supplier name must not exceed 200 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.suppliers.store'), [
'name' => str_repeat('a', 201),
]);
$response->assertSessionHasErrors('name');
});
test('supplier name must be unique', function () {
$user = User::factory()->create();
$this->actingAs($user);
Supplier::factory()->create(['name' => 'Existing Supplier']);
$response = $this->post(route('admin.master.suppliers.store'), [
'name' => 'Existing Supplier',
]);
$response->assertSessionHasErrors('name');
});
test('supplier can be updated', function () {
$user = User::factory()->create();
$this->actingAs($user);
$supplier = Supplier::factory()->create();
$response = $this->put(route('admin.master.suppliers.update', $supplier), [
'name' => 'Supplier Updated',
'phone_number' => '0987654321',
'address' => 'Jl. Updated No. 2',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.suppliers.index'));
$supplier->refresh();
expect($supplier->name)->toBe('Supplier Updated');
expect($supplier->phone_number)->toBe('0987654321');
expect($supplier->address)->toBe('Jl. Updated No. 2');
});
test('supplier name can be updated to itself', function () {
$user = User::factory()->create();
$this->actingAs($user);
$supplier = Supplier::factory()->create(['name' => 'My Supplier']);
$response = $this->put(route('admin.master.suppliers.update', $supplier), [
'name' => 'My Supplier',
]);
$response->assertSessionHasNoErrors();
});
test('supplier update name must be unique excluding itself', function () {
$user = User::factory()->create();
$this->actingAs($user);
$supplier = Supplier::factory()->create(['name' => 'First']);
Supplier::factory()->create(['name' => 'Second']);
$response = $this->put(route('admin.master.suppliers.update', $supplier), [
'name' => 'Second',
]);
$response->assertSessionHasErrors('name');
});
test('supplier can be deleted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$supplier = Supplier::factory()->create();
$response = $this->delete(route('admin.master.suppliers.destroy', $supplier));
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.suppliers.index'));
$this->assertSoftDeleted('suppliers', ['id' => $supplier->id]);
});