Add supplier management features including Supplier model, SupplierController for CRUD operations, and SupplierRequest for validation. Integrate permissions in the Permission and Role enums, and update routes for supplier management. Enhance UI components for supplier listing and management, including a data table and form modal. Update sidebar for supplier navigation.

This commit is contained in:
Yoga Pangestu 2026-06-10 15:48:34 +07:00
parent 887b811cee
commit c21f71563f
14 changed files with 641 additions and 1 deletions

View File

@ -22,6 +22,11 @@ enum Permission: string
case CATEGORIES_UPDATE = 'categories.update';
case CATEGORIES_DELETE = 'categories.delete';
case SUPPLIERS_VIEW = 'suppliers.view';
case SUPPLIERS_CREATE = 'suppliers.create';
case SUPPLIERS_UPDATE = 'suppliers.update';
case SUPPLIERS_DELETE = 'suppliers.delete';
case PRODUCTS_VIEW = 'products.view';
case PRODUCTS_CREATE = 'products.create';
case PRODUCTS_UPDATE = 'products.update';
@ -51,6 +56,11 @@ public function label(): string
self::CATEGORIES_UPDATE => 'Ubah Kategori',
self::CATEGORIES_DELETE => 'Hapus Kategori',
self::SUPPLIERS_VIEW => 'Lihat Supplier',
self::SUPPLIERS_CREATE => 'Tambah Supplier',
self::SUPPLIERS_UPDATE => 'Ubah Supplier',
self::SUPPLIERS_DELETE => 'Hapus Supplier',
self::PRODUCTS_VIEW => 'Lihat Produk',
self::PRODUCTS_CREATE => 'Tambah Produk',
self::PRODUCTS_UPDATE => 'Ubah Produk',
@ -73,6 +83,8 @@ public function group(): string
self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
self::CATEGORIES_VIEW, self::CATEGORIES_CREATE, self::CATEGORIES_UPDATE,
self::CATEGORIES_DELETE => 'Kategori',
self::SUPPLIERS_VIEW, self::SUPPLIERS_CREATE, self::SUPPLIERS_UPDATE,
self::SUPPLIERS_DELETE => 'Supplier',
self::PRODUCTS_VIEW, self::PRODUCTS_CREATE, self::PRODUCTS_UPDATE,
self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk',
self::RAW_MATERIALS_VIEW, self::RAW_MATERIALS_CREATE, self::RAW_MATERIALS_UPDATE,

View File

@ -48,6 +48,10 @@ public function permissions(): array
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
@ -70,6 +74,10 @@ public function permissions(): array
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
@ -83,6 +91,10 @@ public function permissions(): array
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::RAW_MATERIALS_VIEW,
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
@ -93,6 +105,13 @@ public function permissions(): array
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::PRODUCTS_VIEW,
],
self::NON_OPERATOR => [

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\SupplierRequest;
use App\Models\Supplier;
use App\Services\Master\SupplierService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class SupplierController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly SupplierService $supplierService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
return Inertia::render('admin/master/suppliers/Index', [
'suppliers' => $this->supplierService->paginateForIndex($tableQuery),
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function store(SupplierRequest $request): RedirectResponse
{
$this->supplierService->create($request->validated());
Inertia::flash('success', 'Supplier berhasil ditambahkan.');
return redirect()->route('admin.master.suppliers.index');
}
public function update(SupplierRequest $request, Supplier $supplier): RedirectResponse
{
$this->supplierService->update($supplier, $request->validated());
Inertia::flash('success', 'Supplier berhasil diperbarui.');
return redirect()->route('admin.master.suppliers.index');
}
public function destroy(Supplier $supplier): RedirectResponse
{
$this->supplierService->delete($supplier);
Inertia::flash('success', 'Supplier berhasil dihapus.');
return redirect()->route('admin.master.suppliers.index');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Admin\Master;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class SupplierRequest extends FormRequest
{
public function authorize(): bool
{
$permission = $this->isMethod('POST')
? Permission::SUPPLIERS_CREATE
: Permission::SUPPLIERS_UPDATE;
return $this->user()?->can($permission->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:200'],
'address' => ['required', 'string'],
'phone_number' => ['required', 'string', 'max:20'],
];
}
}

13
app/Models/Supplier.php Normal file
View File

@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class Supplier extends Model
{
use SoftDeletes;
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Services\Master;
use App\Models\Supplier;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
class SupplierService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = Supplier::query()
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhere('phone_number', 'like', "%{$search}%")
->orWhere('address', 'like', "%{$search}%");
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString();
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated): void
{
Supplier::create($validated);
}
/**
* @param array<string, mixed> $validated
*/
public function update(Supplier $supplier, array $validated): void
{
$supplier->name = $validated['name'];
$supplier->phone_number = $validated['phone_number'];
$supplier->address = $validated['address'];
$supplier->save();
}
public function delete(Supplier $supplier): void
{
$supplier->delete();
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'phone_number', 'address'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -0,0 +1,34 @@
<?php
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('suppliers', function (Blueprint $table) {
$table->id();
$table->string('name', 200);
$table->string('phone_number', 20)->nullable();
$table->text('address')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('suppliers');
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { FolderTree, Layers, LayoutDashboard, Package, Users } from '@lucide/vue';
import { FolderTree, Layers, LayoutDashboard, Package, User, Users } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -24,6 +24,7 @@ const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employee
const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/categories'));
const isProductsActive = computed(() => page.url.startsWith('/admin/master/products'));
const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/raw-materials'));
const isSuppliersActive = computed(() => page.url.startsWith('/admin/master/suppliers'));
const showMasterMenu = computed(() => (
can('categories.view') || can('products.view') || can('raw-materials.view')
));
@ -88,6 +89,14 @@ const showMasterMenu = computed(() => (
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('raw-materials.view')">
<SidebarMenuButton as-child tooltip="Supplier" :is-active="isSuppliersActive">
<Link href="/admin/master/suppliers">
<User />
<span>Supplier</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -0,0 +1,134 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { PhoneNumberInput } from '@/components/ui/phone-number-input';
import { Textarea } from '@/components/ui/textarea';
import type { SupplierFormData, SupplierListItem } from '@/types/supplier';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
supplier?: SupplierListItem | null;
}>();
const isEditing = computed(() => props.supplier != null);
const form = useForm<SupplierFormData>({
name: '',
phone_number: '',
address: '',
});
function resetForm() {
form.reset();
form.clearErrors();
}
function populateForm(supplier: SupplierListItem | null | undefined) {
resetForm();
if (!supplier) {
return;
}
form.name = supplier.name;
form.phone_number = supplier.phone_number;
form.address = supplier.address;
}
watch(
() => props.supplier,
(supplier) => {
populateForm(supplier);
},
);
watch(open, (isOpen) => {
if (isOpen) {
populateForm(props.supplier);
} else {
resetForm();
}
});
function submit() {
const options = {
preserveScroll: true,
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
};
if (isEditing.value && props.supplier) {
form.put(`/admin/master/suppliers/${props.supplier.id}`, options);
} else {
form.post('/admin/master/suppliers', options);
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ isEditing ? 'Ubah Supplier' : 'Tambah Supplier' }}</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="supplier-name" required>Nama</FieldLabel>
<Input id="supplier-name" v-model="form.name" type="text" placeholder="Nama supplier"
autofocus />
<FieldError :errors="form.errors.name ? [form.errors.name] : []" />
</Field>
<Field>
<FieldLabel for="supplier-phone-number" required>Nomor Telepon</FieldLabel>
<PhoneNumberInput id="supplier-phone-number" v-model="form.phone_number"
placeholder="Nomor telepon supplier" />
<FieldError :errors="form.errors.phone_number ? [form.errors.phone_number] : []" />
</Field>
<Field>
<FieldLabel for="supplier-address" required>Alamat</FieldLabel>
<Textarea id="supplier-address" v-model="form.address" rows="3"
placeholder="Alamat supplier" />
<FieldError :errors="form.errors.address ? [form.errors.address] : []" />
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter class="mt-6">
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
Batal
</Button>
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,34 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import DataTableActions from '@/components/admin/master/suppliers/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import type { SupplierListItem } from '@/types/supplier';
export function createColumns(onEdit: (supplier: SupplierListItem) => void): ColumnDef<SupplierListItem>[] {
return [
{
accessorKey: 'name',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'name' }),
},
{
accessorKey: 'phone_number',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Telepon', column: 'phone_number' }),
},
{
accessorKey: 'address',
enableSorting: false,
header: () => h(DataTableColumnHeader, { title: 'Alamat', column: 'address' }),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => h(DataTableActions, {
supplier: row.original,
onEdit: () => onEdit(row.original),
}),
},
];
}

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { Pencil, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { SupplierListItem } from '@/types/supplier';
const props = defineProps<{
supplier: SupplierListItem;
}>();
const emit = defineEmits<{
edit: [supplier: SupplierListItem];
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
function destroySupplier() {
deleteProcessing.value = true;
router.delete(`/admin/master/suppliers/${props.supplier.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus supplier.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="can('suppliers.update')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" @click="emit('edit', supplier)">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="can('suppliers.delete')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true">
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog v-if="can('suppliers.delete')" v-model:open="deleteConfirmOpen" title="Hapus supplier?"
:description="`Supplier ${supplier.name} akan dihapus secara permanen. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroySupplier" />
</template>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableSort } from '@/types/data-table';
import { createColumns } from '@/components/admin/master/suppliers/columns';
import SupplierFormModal from '@/components/admin/master/suppliers/SupplierFormModal.vue';
import type { SupplierListItem, PaginatedSuppliers } from '@/types/supplier';
const props = defineProps<{
suppliers: PaginatedSuppliers;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const formModalOpen = ref(false);
const editingSupplier = ref<SupplierListItem | null>(null);
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/master/suppliers',
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const columns = computed(() => createColumns(openEditModal));
const currentSort = computed<DataTableSort | null>(() => {
if (!query.value.sort || !query.value.direction) {
return null;
}
return {
column: query.value.sort,
direction: query.value.direction,
};
});
const pagination = computed(() => ({
currentPage: props.suppliers.current_page,
perPage: props.suppliers.per_page,
lastPage: props.suppliers.last_page,
total: props.suppliers.total,
}));
function openCreateModal() {
editingSupplier.value = null;
formModalOpen.value = true;
}
function openEditModal(supplier: SupplierListItem) {
editingSupplier.value = supplier;
formModalOpen.value = true;
}
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Supplier" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Supplier
</h2>
</div>
<Button v-if="can('suppliers.create')" class="shrink-0 self-start sm:self-center" @click="openCreateModal">
<Plus class="size-4" />
Tambah
</Button>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable v-model:search="search" :columns="columns" :data="suppliers.data" :pagination="pagination"
:pagination-links="suppliers.links" :sort="currentSort" @sort-change="setSort"
@filters-reset="resetFilters" />
</CardContent>
</Card>
<SupplierFormModal v-if="can('suppliers.create') || can('suppliers.update')" v-model:open="formModalOpen"
:supplier="editingSupplier" />
</AdminLayout>
</template>

View File

@ -0,0 +1,31 @@
export type SupplierListItem = {
id: number;
name: string;
phone_number: string;
address: string;
};
export type SupplierFormData = {
name: string;
phone_number: string;
address: string;
};
export type SupplierFilters = {
search: string;
sort?: string;
direction?: 'asc' | 'desc' | null;
};
export type PaginatedSuppliers = {
data: SupplierListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};

View File

@ -6,6 +6,7 @@
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\ProductController;
use App\Http\Controllers\Admin\Master\RawMaterialController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
use Illuminate\Support\Facades\Route;
@ -102,6 +103,25 @@
Route::get('/', [RawMaterialController::class, 'index'])->name('index');
});
Route::prefix('suppliers')->name('suppliers.')
->middleware('permission:'.Permission::SUPPLIERS_VIEW->value)
->group(function () {
Route::get('/', [SupplierController::class, 'index'])->name('index');
Route::post('/', [SupplierController::class, 'store'])
->middleware('permission:'.Permission::SUPPLIERS_CREATE->value)
->name('store');
Route::put('{supplier}', [SupplierController::class, 'update'])
->middleware('permission:'.Permission::SUPPLIERS_UPDATE->value)
->name('update');
Route::delete('{supplier}', [SupplierController::class, 'destroy'])
->middleware('permission:'.Permission::SUPPLIERS_DELETE->value)
->name('destroy');
});
});
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {