diff --git a/app/Http/Controllers/Admin/Master/SupplierController.php b/app/Http/Controllers/Admin/Master/SupplierController.php new file mode 100644 index 0000000..9f70e26 --- /dev/null +++ b/app/Http/Controllers/Admin/Master/SupplierController.php @@ -0,0 +1,52 @@ + $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'); + } +} diff --git a/app/Http/Requests/Admin/Master/SupplierRequest.php b/app/Http/Requests/Admin/Master/SupplierRequest.php new file mode 100644 index 0000000..eea8eba --- /dev/null +++ b/app/Http/Requests/Admin/Master/SupplierRequest.php @@ -0,0 +1,34 @@ +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', + ]; + } +} diff --git a/app/Services/Admin/Master/SupplierService.php b/app/Services/Admin/Master/SupplierService.php new file mode 100644 index 0000000..f5cf3ee --- /dev/null +++ b/app/Services/Admin/Master/SupplierService.php @@ -0,0 +1,31 @@ +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(); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ca0d0c6..4efad73 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,7 @@ public function run(): void $this->call([ UserSeeder::class, CategorySeeder::class, + SupplierSeeder::class, ]); } } diff --git a/database/seeders/SupplierSeeder.php b/database/seeders/SupplierSeeder.php new file mode 100644 index 0000000..b38f62a --- /dev/null +++ b/database/seeders/SupplierSeeder.php @@ -0,0 +1,14 @@ +count(100)->create(); + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index cf3ef02..1a5aca7 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 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 }, ]; diff --git a/resources/js/pages/admin/master/supplier/columns.tsx b/resources/js/pages/admin/master/supplier/columns.tsx new file mode 100644 index 0000000..c909045 --- /dev/null +++ b/resources/js/pages/admin/master/supplier/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 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[] { + 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 supplier = row.original; + + return ( + +
+ + + + + + Edit + + + + + + + + + Hapus + + +
+
+ ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/master/supplier/index.tsx b/resources/js/pages/admin/master/supplier/index.tsx new file mode 100644 index 0000000..5dd6232 --- /dev/null +++ b/resources/js/pages/admin/master/supplier/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 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(null); + const [deleting, setDeleting] = useState(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 ( + <> + + +
+
+
+

+ Supplier +

+
+ + + + +
setCreateOpen(false)}> + {({ errors, processing }) => { + + return ( + <> + + Tambah Supplier + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + + + + ); + }} +
+
+
+
+ + + + { + if (!open) { + setEditing(null); + } + }} + > + + {editing && ( +
setEditing(null)}> + {({ errors, processing }) => { + + return ( + <> + + Edit Supplier + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + + + + ); + }} +
+ )} +
+
+ + { + 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} + /> +
+ + ); +} + +SupplierIndex.layout = { + breadcrumbs: [ + { + title: 'Master', + href: supplierIndex(), + }, + { + title: 'Supplier', + href: supplierIndex(), + }, + ], +}; diff --git a/routes/web.php b/routes/web.php index 6d9aff6..476e8da 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ 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']); }); }); diff --git a/tests/Feature/Admin/Master/SupplierTest.php b/tests/Feature/Admin/Master/SupplierTest.php new file mode 100644 index 0000000..c93d000 --- /dev/null +++ b/tests/Feature/Admin/Master/SupplierTest.php @@ -0,0 +1,181 @@ +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]); +});