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

This commit is contained in:
Yoga Pangestu 2026-06-10 15:49:03 +07:00
parent c21f71563f
commit 426d7ed61c
14 changed files with 635 additions and 2 deletions

View File

@ -27,6 +27,11 @@ enum Permission: string
case SUPPLIERS_UPDATE = 'suppliers.update';
case SUPPLIERS_DELETE = 'suppliers.delete';
case CUSTOMERS_VIEW = 'customers.view';
case CUSTOMERS_CREATE = 'customers.create';
case CUSTOMERS_UPDATE = 'customers.update';
case CUSTOMERS_DELETE = 'customers.delete';
case PRODUCTS_VIEW = 'products.view';
case PRODUCTS_CREATE = 'products.create';
case PRODUCTS_UPDATE = 'products.update';
@ -61,6 +66,11 @@ public function label(): string
self::SUPPLIERS_UPDATE => 'Ubah Supplier',
self::SUPPLIERS_DELETE => 'Hapus Supplier',
self::CUSTOMERS_VIEW => 'Lihat Pelanggan',
self::CUSTOMERS_CREATE => 'Tambah Pelanggan',
self::CUSTOMERS_UPDATE => 'Ubah Pelanggan',
self::CUSTOMERS_DELETE => 'Hapus Pelanggan',
self::PRODUCTS_VIEW => 'Lihat Produk',
self::PRODUCTS_CREATE => 'Tambah Produk',
self::PRODUCTS_UPDATE => 'Ubah Produk',
@ -85,6 +95,8 @@ public function group(): string
self::CATEGORIES_DELETE => 'Kategori',
self::SUPPLIERS_VIEW, self::SUPPLIERS_CREATE, self::SUPPLIERS_UPDATE,
self::SUPPLIERS_DELETE => 'Supplier',
self::CUSTOMERS_VIEW, self::CUSTOMERS_CREATE, self::CUSTOMERS_UPDATE,
self::CUSTOMERS_DELETE => 'Pelanggan',
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

@ -52,6 +52,10 @@ public function permissions(): array
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
@ -78,6 +82,10 @@ public function permissions(): array
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
@ -112,6 +120,10 @@ public function permissions(): array
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_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\CustomerRequest;
use App\Models\Customer;
use App\Services\Master\CustomerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CustomerController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly CustomerService $customerService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
return Inertia::render('admin/master/customers/Index', [
'customers' => $this->customerService->paginateForIndex($tableQuery),
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function store(CustomerRequest $request): RedirectResponse
{
$this->customerService->create($request->validated());
Inertia::flash('success', 'Customer berhasil ditambahkan.');
return redirect()->route('admin.master.customers.index');
}
public function update(CustomerRequest $request, Customer $customer): RedirectResponse
{
$this->customerService->update($customer, $request->validated());
Inertia::flash('success', 'Customer berhasil diperbarui.');
return redirect()->route('admin.master.customers.index');
}
public function destroy(Customer $customer): RedirectResponse
{
$this->customerService->delete($customer);
Inertia::flash('success', 'Customer berhasil dihapus.');
return redirect()->route('admin.master.customers.index');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Admin\Master;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class CustomerRequest extends FormRequest
{
public function authorize(): bool
{
$permission = $this->isMethod('POST')
? Permission::CUSTOMERS_CREATE
: Permission::CUSTOMERS_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/Customer.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 Customer extends Model
{
use SoftDeletes;
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Services\Master;
use App\Models\Customer;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
class CustomerService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = Customer::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
{
Customer::create($validated);
}
/**
* @param array<string, mixed> $validated
*/
public function update(Customer $customer, array $validated): void
{
$customer->name = $validated['name'];
$customer->phone_number = $validated['phone_number'];
$customer->address = $validated['address'];
$customer->save();
}
public function delete(Customer $customer): void
{
$customer->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('customers', 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('customers');
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { FolderTree, Layers, LayoutDashboard, Package, User, Users } from '@lucide/vue';
import { FolderTree, Layers, LayoutDashboard, Package, User, UserCheck, Users } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -25,8 +25,10 @@ const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/cat
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 isCustomersActive = computed(() => page.url.startsWith('/admin/master/customers'));
const showMasterMenu = computed(() => (
can('categories.view') || can('products.view') || can('raw-materials.view')
|| can('suppliers.view') || can('customers.view')
));
</script>
@ -89,7 +91,7 @@ const showMasterMenu = computed(() => (
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('raw-materials.view')">
<SidebarMenuItem v-if="can('suppliers.view')">
<SidebarMenuButton as-child tooltip="Supplier" :is-active="isSuppliersActive">
<Link href="/admin/master/suppliers">
<User />
@ -97,6 +99,14 @@ const showMasterMenu = computed(() => (
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('customers.view')">
<SidebarMenuButton as-child tooltip="Customer" :is-active="isCustomersActive">
<Link href="/admin/master/customers">
<UserCheck />
<span>Customer</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 { CustomerFormData, CustomerListItem } from '@/types/customer';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
customer?: CustomerListItem | null;
}>();
const isEditing = computed(() => props.customer != null);
const form = useForm<CustomerFormData>({
name: '',
phone_number: '',
address: '',
});
function resetForm() {
form.reset();
form.clearErrors();
}
function populateForm(customer: CustomerListItem | null | undefined) {
resetForm();
if (!customer) {
return;
}
form.name = customer.name;
form.phone_number = customer.phone_number;
form.address = customer.address;
}
watch(
() => props.customer,
(customer) => {
populateForm(customer);
},
);
watch(open, (isOpen) => {
if (isOpen) {
populateForm(props.customer);
} 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.customer) {
form.put(`/admin/master/customers/${props.customer.id}`, options);
} else {
form.post('/admin/master/customers', options);
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ isEditing ? 'Ubah Customer' : 'Tambah Customer' }}</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="customer-name" required>Nama</FieldLabel>
<Input id="customer-name" v-model="form.name" type="text" placeholder="Nama customer"
autofocus />
<FieldError :errors="form.errors.name ? [form.errors.name] : []" />
</Field>
<Field>
<FieldLabel for="customer-phone-number" required>Nomor Telepon</FieldLabel>
<PhoneNumberInput id="customer-phone-number" v-model="form.phone_number"
placeholder="Nomor telepon customer" />
<FieldError :errors="form.errors.phone_number ? [form.errors.phone_number] : []" />
</Field>
<Field>
<FieldLabel for="customer-address" required>Alamat</FieldLabel>
<Textarea id="customer-address" v-model="form.address" rows="3"
placeholder="Alamat customer" />
<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/customers/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import type { CustomerListItem } from '@/types/customer';
export function createColumns(onEdit: (customer: CustomerListItem) => void): ColumnDef<CustomerListItem>[] {
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, {
customer: 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 { CustomerListItem } from '@/types/customer';
const props = defineProps<{
customer: CustomerListItem;
}>();
const emit = defineEmits<{
edit: [customer: CustomerListItem];
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
function destroyCustomer() {
deleteProcessing.value = true;
router.delete(`/admin/master/customers/${props.customer.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus customer.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="can('customers.update')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" @click="emit('edit', customer)">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="can('customers.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('customers.delete')" v-model:open="deleteConfirmOpen" title="Hapus customer?"
:description="`Customer ${customer.name} akan dihapus secara permanen. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyCustomer" />
</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 { createColumns } from '@/components/admin/master/customers/columns';
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 CustomerFormModal from '@/components/admin/master/customers/CustomerFormModal.vue';
import type { CustomerListItem, PaginatedCustomers } from '@/types/customer';
const props = defineProps<{
customers: PaginatedCustomers;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const formModalOpen = ref(false);
const editingCustomer = ref<CustomerListItem | null>(null);
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/master/customers',
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.customers.current_page,
perPage: props.customers.per_page,
lastPage: props.customers.last_page,
total: props.customers.total,
}));
function openCreateModal() {
editingCustomer.value = null;
formModalOpen.value = true;
}
function openEditModal(customer: CustomerListItem) {
editingCustomer.value = customer;
formModalOpen.value = true;
}
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Customer" />
<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">
Customer
</h2>
</div>
<Button v-if="can('customers.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="customers.data" :pagination="pagination"
:pagination-links="customers.links" :sort="currentSort" @sort-change="setSort"
@filters-reset="resetFilters" />
</CardContent>
</Card>
<CustomerFormModal v-if="can('customers.create') || can('customers.update')" v-model:open="formModalOpen"
:customer="editingCustomer" />
</AdminLayout>
</template>

View File

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

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\ProductController;
use App\Http\Controllers\Admin\Master\RawMaterialController;
use App\Http\Controllers\Admin\Master\SupplierController;
@ -122,6 +123,24 @@
->name('destroy');
});
Route::prefix('customers')->name('customers.')
->middleware('permission:'.Permission::CUSTOMERS_VIEW->value)
->group(function () {
Route::get('/', [CustomerController::class, 'index'])->name('index');
Route::post('/', [CustomerController::class, 'store'])
->middleware('permission:'.Permission::CUSTOMERS_CREATE->value)
->name('store');
Route::put('{customer}', [CustomerController::class, 'update'])
->middleware('permission:'.Permission::CUSTOMERS_UPDATE->value)
->name('update');
Route::delete('{customer}', [CustomerController::class, 'destroy'])
->middleware('permission:'.Permission::CUSTOMERS_DELETE->value)
->name('destroy');
});
});
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {