Add raw materials management features including permissions in the Permission and Role enums, routes for CRUD operations in RawMaterialController, and UI updates in the sidebar for raw materials navigation. Enhance the application structure to support raw materials alongside existing product and category management.

This commit is contained in:
Yoga Pangestu 2026-06-10 14:00:29 +07:00
parent 448ac63260
commit 9e3325c16a
20 changed files with 1558 additions and 7 deletions

View File

@ -28,6 +28,12 @@ enum Permission: string
case PRODUCTS_DELETE = 'products.delete';
case PRODUCTS_TOGGLE_STATUS = 'products.toggle-status';
case RAW_MATERIALS_VIEW = 'raw-materials.view';
case RAW_MATERIALS_CREATE = 'raw-materials.create';
case RAW_MATERIALS_UPDATE = 'raw-materials.update';
case RAW_MATERIALS_DELETE = 'raw-materials.delete';
case RAW_MATERIALS_TOGGLE_STATUS = 'raw-materials.toggle-status';
public function label(): string
{
return match ($this) {
@ -50,6 +56,12 @@ public function label(): string
self::PRODUCTS_UPDATE => 'Ubah Produk',
self::PRODUCTS_DELETE => 'Hapus Produk',
self::PRODUCTS_TOGGLE_STATUS => 'Ubah Status Produk',
self::RAW_MATERIALS_VIEW => 'Lihat Bahan Baku',
self::RAW_MATERIALS_CREATE => 'Tambah Bahan Baku',
self::RAW_MATERIALS_UPDATE => 'Ubah Bahan Baku',
self::RAW_MATERIALS_DELETE => 'Hapus Bahan Baku',
self::RAW_MATERIALS_TOGGLE_STATUS => 'Ubah Status Bahan Baku',
};
}
@ -63,6 +75,8 @@ public function group(): string
self::CATEGORIES_DELETE => 'Kategori',
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,
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
};
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum RawMaterialUnit: string
{
use ProvidesEnumOptions;
case YARD = 'yard';
case METER = 'meter';
case KILOGRAM = 'kilogram';
public function label(): string
{
return match ($this) {
self::YARD => 'Yard',
self::METER => 'Meter',
self::KILOGRAM => 'Kilogram',
};
}
public function abbreviation(): string
{
return match ($this) {
self::YARD => 'yard',
self::METER => 'm',
self::KILOGRAM => 'kg',
};
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@ -53,6 +53,11 @@ public function permissions(): array
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::RAW_MATERIALS_VIEW,
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
],
self::ADMIN_TOKO => [
Permission::DASHBOARD_VIEW,
@ -78,11 +83,11 @@ public function permissions(): array
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::RAW_MATERIALS_VIEW,
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
],
self::MARKETING => [
Permission::DASHBOARD_VIEW,

View File

@ -0,0 +1,278 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\RawMaterialUnit;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\RawMaterialRequest;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class RawMaterialController extends Controller
{
use ParsesDataTableQuery;
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$search = $tableQuery['search'];
$sort = $tableQuery['sort'];
$direction = $tableQuery['direction'];
$isActive = $request->string('is_active')->toString();
$query = RawMaterial::query()
->with([
'prices' => fn ($query) => $query->orderBy('created_at'),
])
->when($search !== '', function (Builder $query) use ($search): void {
$query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhereHas('prices', fn (Builder $query) => $query->where('variant', 'like', "%{$search}%"));
});
})
->when(
$isActive !== '',
fn (Builder $query) => $query->where('is_active', $isActive === '1')
);
$this->applySorting($query, $sort, $direction);
$rawMaterials = $query
->paginate(10)
->withQueryString();
$rows = $rawMaterials->getCollection()
->flatMap(fn (RawMaterial $rawMaterial, int $index) => $this->flattenRawMaterialForTable(
$rawMaterial,
$index,
$rawMaterials->firstItem() ?? 1,
))
->values()
->all();
return Inertia::render('admin/raw-materials/Index', [
'rows' => $rows,
'pagination' => [
'current_page' => $rawMaterials->currentPage(),
'last_page' => $rawMaterials->lastPage(),
'per_page' => $rawMaterials->perPage(),
'total' => $rawMaterials->total(),
'links' => $rawMaterials->linkCollection()->toArray(),
],
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
]),
]);
}
public function create(): Response
{
return Inertia::render('admin/raw-materials/Create', [
'units' => RawMaterialUnit::selectOptions(),
]);
}
public function store(RawMaterialRequest $request): RedirectResponse
{
$validated = $request->validated();
DB::transaction(function () use ($validated): void {
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
]);
foreach ($validated['prices'] as $priceData) {
$this->createPrice($rawMaterial, $priceData);
}
});
Inertia::flash('success', 'Bahan baku berhasil ditambahkan.');
return redirect()->route('admin.master.raw-materials.index');
}
public function edit(RawMaterial $rawMaterial): Response
{
$rawMaterial->load([
'prices' => fn ($query) => $query->orderBy('created_at'),
]);
return Inertia::render('admin/raw-materials/Edit', [
'units' => RawMaterialUnit::selectOptions(),
'rawMaterial' => $this->transformRawMaterialForForm($rawMaterial),
]);
}
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
$validated = $request->validated();
DB::transaction(function () use ($validated, $rawMaterial): void {
$rawMaterial->name = $validated['name'];
$rawMaterial->unit = $validated['unit'];
$rawMaterial->save();
$submittedPriceIds = collect($validated['prices'])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->get()
->each(fn (RawMaterialPrice $price) => $price->delete());
foreach ($validated['prices'] as $priceData) {
if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->findOrFail($priceData['id']);
$price->variant = $priceData['variant'];
$price->price = $priceData['price'];
$price->stock = $priceData['stock'];
$price->save();
continue;
}
$this->createPrice($rawMaterial, $priceData);
}
});
Inertia::flash('success', 'Bahan baku berhasil diperbarui.');
return redirect()->route('admin.master.raw-materials.index');
}
public function toggleStatus(Request $request, RawMaterial $rawMaterial): RedirectResponse
{
$validated = $request->validate([
'is_active' => ['required', 'boolean'],
]);
$rawMaterial->is_active = $validated['is_active'];
$rawMaterial->save();
Inertia::flash('success', 'Status bahan baku berhasil diperbarui.');
return back();
}
public function destroy(RawMaterial $rawMaterial): RedirectResponse
{
DB::transaction(function () use ($rawMaterial): void {
$rawMaterial->prices()->delete();
$rawMaterial->delete();
});
Inertia::flash('success', 'Bahan baku berhasil dihapus.');
return redirect()->route('admin.master.raw-materials.index');
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'unit', 'is_active'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
/**
* @param array<string, mixed> $priceData
*/
private function createPrice(RawMaterial $rawMaterial, array $priceData): RawMaterialPrice
{
return $rawMaterial->prices()->create([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
}
/**
* @return list<array<string, mixed>>
*/
private function flattenRawMaterialForTable(RawMaterial $rawMaterial, int $materialIndex, int $firstItem): array
{
$prices = $rawMaterial->prices->isNotEmpty()
? $rawMaterial->prices
: collect([null]);
$variantCount = $prices->count();
return $prices
->values()
->map(function ($price, int $variantIndex) use ($rawMaterial, $materialIndex, $firstItem, $variantCount) {
return [
'row_id' => $price
? "{$rawMaterial->id}-{$price->id}"
: "{$rawMaterial->id}-empty",
'raw_material_id' => $rawMaterial->id,
'raw_material_name' => $rawMaterial->name,
'unit' => $rawMaterial->unit->value,
'unit_label' => $rawMaterial->unit->label(),
'unit_abbreviation' => $rawMaterial->unit->abbreviation(),
'is_active' => $rawMaterial->is_active,
'is_first_variant' => $variantIndex === 0,
'variant_count' => $variantCount,
'raw_material_row_number' => $firstItem + $materialIndex,
'price_id' => $price?->id,
'variant' => $price?->variant,
'stock' => $price?->stock,
'stock_formatted' => $price ? $this->formatStock($price->stock, $rawMaterial->unit) : null,
'price' => $price?->price,
'price_formatted' => $price ? $this->formatPrice($price->price) : null,
];
})
->all();
}
/**
* @return array<string, mixed>
*/
private function transformRawMaterialForForm(RawMaterial $rawMaterial): array
{
return [
'id' => $rawMaterial->id,
'name' => $rawMaterial->name,
'unit' => $rawMaterial->unit->value,
'prices' => $rawMaterial->prices
->map(fn (RawMaterialPrice $price) => [
'id' => $price->id,
'variant' => $price->variant,
'price' => (string) (int) $price->price,
'stock' => $this->formatStockInput($price->stock),
])
->values()
->all(),
];
}
private function formatPrice(float|string $price): string
{
return 'Rp '.number_format((float) $price, 0, ',', '.');
}
private function formatStock(float|string $stock, RawMaterialUnit $unit): string
{
$formatted = rtrim(rtrim(number_format((float) $stock, 4, ',', '.'), '0'), ',');
return "{$formatted} {$unit->abbreviation()}";
}
private function formatStockInput(float|string $stock): string
{
return rtrim(rtrim(number_format((float) $stock, 4, '.', ''), '0'), '.');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Admin;
use App\Enums\Permission;
use App\Enums\RawMaterialUnit;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class RawMaterialRequest extends FormRequest
{
public function authorize(): bool
{
$permission = $this->isMethod('POST')
? Permission::RAW_MATERIALS_CREATE
: Permission::RAW_MATERIALS_UPDATE;
return $this->user()?->can($permission->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:200'],
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
'prices' => ['required', 'array', 'min:1'],
'prices.*.id' => [
'nullable',
'integer',
Rule::exists('raw_material_prices', 'id')
->where('raw_material_id', $this->route('rawMaterial')?->id)
->whereNull('deleted_at'),
],
'prices.*.variant' => ['required', 'string', 'max:200'],
'prices.*.price' => ['required', 'numeric', 'gt:0'],
'prices.*.stock' => ['required', 'numeric', 'min:0'],
];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Enums\RawMaterialUnit;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class RawMaterial extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'is_active' => 'boolean',
'unit' => RawMaterialUnit::class,
];
}
#[Scope]
public function active(Builder $query): void
{
$query->where('is_active', true);
}
#[Scope]
public function inactive(Builder $query): void
{
$query->where('is_active', false);
}
public function prices(): HasMany
{
return $this->hasMany(RawMaterialPrice::class);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class RawMaterialPrice extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'price' => 'decimal:2',
'stock' => 'decimal:4',
];
}
public function rawMaterial(): BelongsTo
{
return $this->belongsTo(RawMaterial::class);
}
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('raw_materials', function (Blueprint $table) {
$table->id();
$table->string('name', 200);
$table->string('unit', 20);
$table->boolean('is_active')->default(true);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('raw_materials');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('raw_material_prices', function (Blueprint $table) {
$table->id();
$table->foreignId('raw_material_id')->constrained()->cascadeOnDelete();
$table->string('variant', 200);
$table->decimal('price', 18, 2);
$table->decimal('stock', 18, 4)->default(0);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('raw_material_prices');
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { FolderTree, LayoutDashboard, Package, Users } from '@lucide/vue';
import { FolderTree, Layers, LayoutDashboard, Package, Users } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -23,7 +23,10 @@ const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard')
const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees'));
const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/categories'));
const isProductsActive = computed(() => page.url.startsWith('/admin/master/products'));
const showMasterMenu = computed(() => can('categories.view') || can('products.view'));
const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/raw-materials'));
const showMasterMenu = computed(() => (
can('categories.view') || can('products.view') || can('raw-materials.view')
));
</script>
<template>
@ -77,6 +80,14 @@ const showMasterMenu = computed(() => can('categories.view') || can('products.vi
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('raw-materials.view')">
<SidebarMenuButton as-child tooltip="Bahan Baku" :is-active="isRawMaterialsActive">
<Link href="/admin/master/raw-materials">
<Layers />
<span>Bahan Baku</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -0,0 +1,374 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { RupiahInput } from '@/components/ui/rupiah-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { parseRupiah } from '@/lib/rupiah';
import type {
EnumOption,
RawMaterialFormData,
RawMaterialPriceFormItem,
} from '@/types/raw-material';
const props = withDefaults(
defineProps<{
units: EnumOption[];
initialData?: {
name?: string;
unit?: string;
prices?: Array<{
id?: number;
variant?: string;
price?: string;
stock?: string;
}>;
};
submitUrl: string;
method?: 'post' | 'put';
submitLabel?: string;
}>(),
{
method: 'post',
submitLabel: 'Simpan',
},
);
function createClientId(): string {
return `price-${crypto.randomUUID()}`;
}
function createEmptyPrice(): RawMaterialPriceFormItem {
return {
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
};
}
function buildInitialPrices(): RawMaterialPriceFormItem[] {
if (!props.initialData?.prices?.length) {
return [createEmptyPrice()];
}
return props.initialData.prices.map((price) => ({
client_id: createClientId(),
id: price.id,
variant: price.variant ?? '',
price: price.price ?? '',
stock: price.stock ?? '0',
}));
}
const prices = ref<RawMaterialPriceFormItem[]>(buildInitialPrices());
const useSamePrice = ref(prices.value.length <= 1 || allPricesHaveSameValue(prices.value));
const form = useForm({
name: props.initialData?.name ?? '',
unit: props.initialData?.unit ?? '',
});
function allPricesHaveSameValue(items: RawMaterialPriceFormItem[]): boolean {
if (items.length <= 1) {
return true;
}
const first = items[0].price.trim();
return items.every((item) => item.price.trim() === first);
}
function addPrice() {
const newPrice = createEmptyPrice();
if (useSamePrice.value && prices.value[0]) {
newPrice.price = prices.value[0].price;
}
prices.value = [...prices.value, newPrice];
}
function removePrice(clientId: string) {
if (prices.value.length <= 1) {
return;
}
prices.value = prices.value.filter((price) => price.client_id !== clientId);
}
function setPriceField(clientId: string, key: 'variant' | 'stock', value: string) {
prices.value = prices.value.map((price) =>
price.client_id === clientId ? { ...price, [key]: value } : price,
);
}
function setPriceValue(clientId: string, value: string) {
prices.value = prices.value.map((price) =>
price.client_id === clientId ? { ...price, price: value } : price,
);
}
function setSharedPrice(value: string) {
prices.value = prices.value.map((price) => ({ ...price, price: value }));
}
function toggleUseSamePrice(checked: boolean) {
useSamePrice.value = checked;
if (!checked || !prices.value[0]) {
return;
}
const sourcePrice = prices.value[0].price;
prices.value = prices.value.map((price) => ({ ...price, price: sourcePrice }));
}
function applyPriceToAllVariants(sourceClientId: string) {
const source = prices.value.find((price) => price.client_id === sourceClientId);
if (!source) {
return;
}
prices.value = prices.value.map((price) => ({ ...price, price: source.price }));
}
function parseStockValue(value: string): number {
const parsed = Number.parseFloat(value.replace(',', '.'));
return Number.isNaN(parsed) ? 0 : parsed;
}
function buildSubmitPayload(): RawMaterialFormData {
return {
name: form.name.trim(),
unit: form.unit,
prices: prices.value.map((price) => ({
...(price.id ? { id: price.id } : {}),
variant: price.variant.trim(),
price: Number.parseInt(parseRupiah(price.price), 10) || 0,
stock: parseStockValue(price.stock),
})),
};
}
function formError(key: string): string | undefined {
return (form.errors as Record<string, string>)[key];
}
function priceError(clientId: string, field: string): string | undefined {
const index = prices.value.findIndex((price) => price.client_id === clientId);
if (index === -1) {
return undefined;
}
return formError(`prices.${index}.${field}`);
}
function submit() {
const options = {
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
};
const payload = buildSubmitPayload();
if (props.method === 'put') {
form.transform(() => payload).put(props.submitUrl, options);
} else {
form.transform(() => payload).post(props.submitUrl, options);
}
}
</script>
<template>
<form @submit.prevent="submit">
<div class="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Bahan Baku</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="name" required>Nama Bahan Baku</FieldLabel>
<Input
id="name"
v-model="form.name"
type="text"
placeholder="Masukkan nama bahan baku"
/>
<FieldError :errors="form.errors.name ? [form.errors.name] : []" />
</Field>
<Field>
<FieldLabel for="unit" required>Satuan</FieldLabel>
<Select v-model="form.unit">
<SelectTrigger id="unit" class="w-full">
<SelectValue placeholder="Pilih satuan" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in units"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FieldError :errors="form.errors.unit ? [form.errors.unit] : []" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<Card v-if="prices.length > 1">
<CardHeader>
<CardTitle>Harga Bersama</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<label class="mb-4 flex cursor-pointer items-center gap-2">
<input
type="checkbox"
class="size-4 rounded border-input"
:checked="useSamePrice"
@change="toggleUseSamePrice(($event.target as HTMLInputElement).checked)"
>
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
</label>
<Field v-if="useSamePrice && prices[0]">
<FieldLabel :for="`shared_price_${prices[0].client_id}`" required>
Harga
</FieldLabel>
<RupiahInput
:id="`shared_price_${prices[0].client_id}`"
:model-value="prices[0].price"
@update:model-value="setSharedPrice"
/>
<FieldError
:errors="priceError(prices[0].client_id, 'price') ? [priceError(prices[0].client_id, 'price')!] : []"
/>
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card v-for="(price, index) in prices" :key="price.client_id">
<CardHeader class="flex flex-row items-start justify-between gap-4">
<CardTitle>Varian {{ index + 1 }}</CardTitle>
<div class="flex items-center gap-2">
<Button
v-if="prices.length > 1 && !useSamePrice"
type="button"
variant="outline"
size="sm"
@click="applyPriceToAllVariants(price.client_id)"
>
<Copy class="size-4" />
Terapkan Harga ke Semua
</Button>
<Button
v-if="prices.length > 1"
type="button"
variant="outline"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="removePrice(price.client_id)"
>
<Trash2 class="size-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-3">
<Field>
<FieldLabel :for="`variant_${price.client_id}`" required>
Nama Varian
</FieldLabel>
<Input
:id="`variant_${price.client_id}`"
:model-value="price.variant"
type="text"
placeholder="Contoh: Premium / 40s"
@update:model-value="setPriceField(price.client_id, 'variant', String($event))"
/>
<FieldError
:errors="priceError(price.client_id, 'variant') ? [priceError(price.client_id, 'variant')!] : []"
/>
</Field>
<Field>
<FieldLabel :for="`stock_${price.client_id}`" required>
Stok
</FieldLabel>
<Input
:id="`stock_${price.client_id}`"
:model-value="price.stock"
type="number"
min="0"
step="0.0001"
@update:model-value="setPriceField(price.client_id, 'stock', String($event))"
/>
<FieldError
:errors="priceError(price.client_id, 'stock') ? [priceError(price.client_id, 'stock')!] : []"
/>
</Field>
<Field v-if="!useSamePrice || prices.length === 1">
<FieldLabel :for="`price_${price.client_id}`" required>
Harga
</FieldLabel>
<RupiahInput
:id="`price_${price.client_id}`"
:model-value="price.price"
@update:model-value="setPriceValue(price.client_id, $event)"
/>
<FieldError
:errors="priceError(price.client_id, 'price') ? [priceError(price.client_id, 'price')!] : []"
/>
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<FieldError :errors="formError('prices') ? [formError('prices')!] : []" />
<div class="flex items-center justify-between gap-2">
<Button type="button" variant="outline" @click="addPrice">
<Plus class="size-4" />
Tambah Varian
</Button>
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</div>
</div>
</form>
</template>

View File

@ -0,0 +1,179 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import DataTableActions from '@/components/raw-materials/data-table-actions.vue';
import RawMaterialStatusToggle from '@/components/raw-materials/raw-material-status-toggle.vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { groupRawMaterialRows } from '@/lib/raw-materials';
import type {
DataTableFilterDef,
DataTablePagination,
DataTablePaginationLink,
} from '@/types/data-table';
import type { RawMaterialTableRow } from '@/types/raw-material';
const props = defineProps<{
rows: RawMaterialTableRow[];
pagination?: DataTablePagination;
paginationLinks?: DataTablePaginationLink[];
paginationDisplayedCount?: number;
filterDefs?: DataTableFilterDef[];
filterValues?: Record<string, string>;
}>();
const search = defineModel<string>('search', { default: '' });
const emit = defineEmits<{
'filter-change': [key: string, value: string];
'filters-reset': [];
}>();
const materialGroups = computed(() => groupRawMaterialRows(props.rows));
const showingCount = computed(() => props.paginationDisplayedCount ?? materialGroups.value.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 bahan baku';
}
return `Menampilkan ${showingCount.value} bahan baku dari ${total}`;
});
function groupHeaderRow(group: ReturnType<typeof groupRawMaterialRows>[number]): RawMaterialTableRow {
return group.variants[0];
}
</script>
<template>
<div class="space-y-4">
<DataTableToolbar
v-model:search="search"
:filter-defs="filterDefs"
:filter-values="filterValues"
@filter-change="(key, value) => emit('filter-change', key, value)"
@filters-reset="emit('filters-reset')"
/>
<div v-if="materialGroups.length" class="space-y-4">
<div
v-for="group in materialGroups"
:key="group.raw_material_id"
class="overflow-hidden rounded-md border"
>
<div class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
{{ group.raw_material_row_number }}
</span>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium leading-tight">
{{ group.raw_material_name }}
</h3>
<Badge variant="secondary">
{{ group.unit_label }}
</Badge>
</div>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<RawMaterialStatusToggle :row="groupHeaderRow(group)" />
<DataTableActions :row="groupHeaderRow(group)" />
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Harga</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="variant in group.variants"
:key="variant.row_id"
>
<TableCell class="font-medium">
{{ variant.variant ?? '-' }}
</TableCell>
<TableCell class="tabular-nums">
{{ variant.stock_formatted ?? '-' }}
</TableCell>
<TableCell class="tabular-nums">
{{ variant.price_formatted ?? '-' }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div
v-else
class="rounded-md border px-6 py-10"
>
<Empty>
<EmptyHeader>
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian atau filter untuk menemukan data yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div
v-if="pagination"
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div
v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end"
>
<Button
v-for="link in paginationLinks"
:key="`${link.label}-${link.url}`"
variant="outline"
size="sm"
:disabled="!link.url || link.active"
as-child
>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { Link, 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 { RawMaterialTableRow } from '@/types/raw-material';
const props = defineProps<{
row: RawMaterialTableRow;
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
function destroyRawMaterial() {
deleteProcessing.value = true;
router.delete(`/admin/master/raw-materials/${props.row.raw_material_id}`, {
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus bahan baku.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="can('raw-materials.update')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/master/raw-materials/${row.raw_material_id}/edit`">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="can('raw-materials.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('raw-materials.delete')"
v-model:open="deleteConfirmOpen"
title="Hapus bahan baku?"
:description="`Bahan baku ${row.raw_material_name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyRawMaterial"
/>
</template>

View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan';
import type { RawMaterialTableRow } from '@/types/raw-material';
const props = defineProps<{
row: RawMaterialTableRow;
}>();
const { can } = useCan();
const isActive = ref(props.row.is_active);
const processing = ref(false);
watch(
() => props.row.is_active,
(value) => {
isActive.value = value;
},
);
function toggleStatus(checked: boolean) {
if (!can('raw-materials.toggle-status')) {
return;
}
const previous = isActive.value;
isActive.value = checked;
processing.value = true;
router.patch(`/admin/master/raw-materials/${props.row.raw_material_id}/toggle-status`, {
is_active: checked,
}, {
preserveScroll: true,
onError: () => {
isActive.value = previous;
toast.error('Gagal memperbarui status bahan baku.');
},
onFinish: () => {
processing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center gap-2">
<Switch
:model-value="isActive"
:disabled="processing || !can('raw-materials.toggle-status')"
@update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge>
</div>
</template>

View File

@ -0,0 +1,30 @@
import type { RawMaterialTableGroup, RawMaterialTableRow } from '@/types/raw-material';
export function groupRawMaterialRows(rows: RawMaterialTableRow[]): RawMaterialTableGroup[] {
const groups: RawMaterialTableGroup[] = [];
const indexByMaterialId = new Map<number, number>();
for (const row of rows) {
const existingIndex = indexByMaterialId.get(row.raw_material_id);
if (existingIndex === undefined) {
indexByMaterialId.set(row.raw_material_id, groups.length);
groups.push({
raw_material_id: row.raw_material_id,
raw_material_row_number: row.raw_material_row_number,
raw_material_name: row.raw_material_name,
unit: row.unit,
unit_label: row.unit_label,
unit_abbreviation: row.unit_abbreviation,
is_active: row.is_active,
variants: [row],
});
continue;
}
groups[existingIndex].variants.push(row);
}
return groups;
}

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import RawMaterialForm from '@/components/raw-materials/RawMaterialForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption } from '@/types/raw-material';
defineProps<{
units: EnumOption[];
}>();
</script>
<template>
<Head title="Tambah Bahan Baku" />
<AdminLayout title="Tambah Bahan Baku">
<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">
Tambah Bahan Baku
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/master/raw-materials">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<RawMaterialForm
submit-url="/admin/master/raw-materials"
method="post"
submit-label="Simpan"
:units="units"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import { computed } from 'vue';
import RawMaterialForm from '@/components/raw-materials/RawMaterialForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption, RawMaterialEditItem } from '@/types/raw-material';
const props = defineProps<{
rawMaterial: RawMaterialEditItem;
units: EnumOption[];
}>();
const initialData = computed(() => ({
name: props.rawMaterial.name ?? '',
unit: props.rawMaterial.unit ?? '',
prices: props.rawMaterial.prices ?? [],
}));
</script>
<template>
<Head title="Ubah Bahan Baku" />
<AdminLayout title="Ubah Bahan Baku">
<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">
Ubah Bahan Baku
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/master/raw-materials">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<RawMaterialForm
:submit-url="`/admin/master/raw-materials/${rawMaterial.id}`"
method="put"
submit-label="Perbarui"
:initial-data="initialData"
:units="units"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,111 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import RawMaterialGroupedTable from '@/components/raw-materials/RawMaterialGroupedTable.vue';
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 { DataTableFilterDef } from '@/types/data-table';
import type { RawMaterialPagination, RawMaterialTableRow } from '@/types/raw-material';
const props = defineProps<{
rows: RawMaterialTableRow[];
pagination: RawMaterialPagination;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
is_active?: string;
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const { query, setSearch, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/master/raw-materials',
initial: { ...props.filters },
filterKeys: ['is_active'],
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const filterDefs = computed<DataTableFilterDef[]>(() => [
{
key: 'is_active',
label: 'Status',
type: 'select',
options: [
{ value: '1', label: 'Aktif' },
{ value: '0', label: 'Nonaktif' },
],
},
]);
const filterValues = computed(() => ({
is_active: query.value.is_active ?? '',
}));
const tablePagination = computed(() => ({
currentPage: props.pagination.current_page,
perPage: props.pagination.per_page,
lastPage: props.pagination.last_page,
total: props.pagination.total,
}));
const materialCountOnPage = computed(() => (
new Set(props.rows.map((row) => row.raw_material_id)).size
));
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Bahan Baku" />
<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">
Bahan Baku
</h2>
</div>
<Button v-if="can('raw-materials.create')" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/master/raw-materials/create">
<Plus class="size-4" />
Tambah
</Link>
</Button>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<RawMaterialGroupedTable
v-model:search="search"
:rows="rows"
:pagination="tablePagination"
:pagination-links="pagination.links"
:pagination-displayed-count="materialCountOnPage"
:filter-defs="filterDefs"
:filter-values="filterValues"
@filter-change="setFilter"
@filters-reset="resetFilters"
/>
</CardContent>
</Card>
</AdminLayout>
</template>

View File

@ -0,0 +1,77 @@
export type EnumOption = {
value: string;
label: string;
};
export type RawMaterialTableGroup = {
raw_material_id: number;
raw_material_row_number: number;
raw_material_name: string;
unit: string;
unit_label: string;
unit_abbreviation: string;
is_active: boolean;
variants: RawMaterialTableRow[];
};
export type RawMaterialTableRow = {
row_id: string;
raw_material_id: number;
raw_material_name: string;
unit: string;
unit_label: string;
unit_abbreviation: string;
is_active: boolean;
is_first_variant: boolean;
variant_count: number;
raw_material_row_number: number;
price_id: number | null;
variant: string | null;
stock: string | null;
stock_formatted: string | null;
price: string | null;
price_formatted: string | null;
};
export type RawMaterialPriceFormItem = {
client_id: string;
id?: number;
variant: string;
price: string;
stock: string;
};
export type RawMaterialEditItem = {
id: number;
name: string;
unit: string;
prices: Array<{
id: number;
variant: string;
price: string;
stock: string;
}>;
};
export type RawMaterialFormData = {
name: string;
unit: string;
prices: Array<{
id?: number;
variant: string;
price: number;
stock: number;
}>;
};
export type RawMaterialPagination = {
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\ProductController;
use App\Http\Controllers\Admin\RawMaterialController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
use Illuminate\Support\Facades\Route;
@ -71,6 +72,36 @@
Route::get('/', [ProductController::class, 'index'])->name('index');
});
Route::prefix('raw-materials')->name('raw-materials.')
->middleware('permission:'.Permission::RAW_MATERIALS_VIEW->value)
->group(function () {
Route::get('create', [RawMaterialController::class, 'create'])
->middleware('permission:'.Permission::RAW_MATERIALS_CREATE->value)
->name('create');
Route::post('/', [RawMaterialController::class, 'store'])
->middleware('permission:'.Permission::RAW_MATERIALS_CREATE->value)
->name('store');
Route::get('{rawMaterial}/edit', [RawMaterialController::class, 'edit'])
->middleware('permission:'.Permission::RAW_MATERIALS_UPDATE->value)
->name('edit');
Route::put('{rawMaterial}', [RawMaterialController::class, 'update'])
->middleware('permission:'.Permission::RAW_MATERIALS_UPDATE->value)
->name('update');
Route::patch('{rawMaterial}/toggle-status', [RawMaterialController::class, 'toggleStatus'])
->middleware('permission:'.Permission::RAW_MATERIALS_TOGGLE_STATUS->value)
->name('toggle-status');
Route::delete('{rawMaterial}', [RawMaterialController::class, 'destroy'])
->middleware('permission:'.Permission::RAW_MATERIALS_DELETE->value)
->name('destroy');
Route::get('/', [RawMaterialController::class, 'index'])->name('index');
});
});
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {