feat: implement raw material management features including CRUD operations and draft handling

- Added RawMaterialController and RawMaterialVariantController for managing raw materials and their variants.
- Introduced RawMaterialRequest and RawMaterialVariantRequest for validation.
- Implemented RawMaterialService and RawMaterialVariantService for business logic.
- Created hooks for saving drafts of raw materials.
- Developed UI components for creating, editing, and listing raw materials and variants.
This commit is contained in:
Yoga Pangestu 2026-08-02 00:54:05 +07:00
parent d943c3a240
commit 384460686e
23 changed files with 3549 additions and 58 deletions

View File

@ -10,8 +10,5 @@ enum RawMaterialUnit: string
case KG = 'kg';
case METER = 'meter';
case PCS = 'pcs';
case ROLL = 'roll';
case LITER = 'liter';
case PACK = 'pack';
case YARD = 'yard';
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Admin\Master\RawMaterial;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\RawMaterial\RawMaterialRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\RawMaterial;
use App\Services\Admin\Master\RawMaterial\RawMaterialService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class RawMaterialController extends Controller
{
public function __construct(
private RawMaterialService $service,
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/master/raw-material/index', [
'rawMaterials' => $this->service->paginated(
...$request->validatedWithDefaults(),
filters: $request->only(['is_active', 'stock']),
),
'filters' => $request->only(['is_active', 'stock']),
]);
}
public function create(): Response
{
return Inertia::render('admin/master/raw-material/create');
}
public function store(RawMaterialRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
'Bahan baku berhasil ditambahkan.',
'admin.master.raw-materials.index',
'admin.master.raw-materials.create'
);
}
public function edit(RawMaterial $rawMaterial): Response
{
return Inertia::render('admin/master/raw-material/edit', [
'rawMaterial' => $this->service->getForEdit($rawMaterial),
]);
}
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->update($rawMaterial, $request->validated()),
'Bahan baku berhasil diperbarui.',
'admin.master.raw-materials.index',
'admin.master.raw-materials.edit',
['rawMaterial' => $rawMaterial]
);
}
public function destroy(RawMaterial $rawMaterial): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($rawMaterial),
'Bahan baku berhasil dihapus.',
'admin.master.raw-materials.index'
);
}
public function toggleStatus(RawMaterial $rawMaterial): RedirectResponse
{
$this->service->toggleStatus($rawMaterial);
$status = $rawMaterial->fresh()->is_active ? 'Aktif' : 'Non Aktif';
Inertia::flash('toast', ['type' => 'success', 'message' => "Status bahan baku berhasil diubah menjadi {$status}."]);
return to_route('admin.master.raw-materials.index');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Admin\Master\RawMaterial;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\RawMaterial\RawMaterialVariantRequest;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class RawMaterialVariantController extends Controller
{
public function __construct(
private RawMaterialVariantService $variantService,
) {}
public function edit(RawMaterial $rawMaterial, RawMaterialPrice $variant): Response
{
return Inertia::render('admin/master/raw-material/variant/edit', [
'variant' => $this->variantService->getForEdit($variant),
]);
}
public function update(RawMaterialVariantRequest $request, RawMaterial $rawMaterial, RawMaterialPrice $variant): RedirectResponse
{
return $this->handleAction(
fn () => $this->variantService->update($variant, $request->validated()),
'Varian berhasil diperbarui.',
'admin.master.raw-materials.index',
'admin.master.raw-materials.variants.edit',
['rawMaterial' => $rawMaterial, 'variant' => $variant]
);
}
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): RedirectResponse
{
return $this->handleAction(
fn () => $this->variantService->delete($rawMaterial, $variant),
'Varian berhasil dihapus.',
'admin.master.raw-materials.index'
);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests\Admin\Master\RawMaterial;
use App\Enums\RawMaterialUnit;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RawMaterialRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function prepareForValidation(): void
{
$variants = $this->variants;
if (is_array($variants)) {
foreach ($variants as $i => $variant) {
if (isset($variant['price']) && is_string($variant['price'])) {
$this->request->set("variants.$i.price", (int) str_replace('.', '', $variant['price']));
}
}
}
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:200'],
'unit' => ['required', Rule::in(RawMaterialUnit::values())],
'is_active' => ['nullable', 'boolean'],
'variants' => ['required', 'array', 'min:1'],
'variants.*.id' => ['nullable', 'integer'],
'variants.*.variant' => ['required', 'string', 'max:200'],
'variants.*.price' => ['required', 'integer', 'min:0'],
'variants.*.stock' => ['required', 'numeric', 'min:0'],
'variants.*.photo_key' => ['required', 'string', 'max:500'],
];
}
public function attributes(): array
{
return [
'name' => 'Nama Bahan Baku',
'unit' => 'Satuan',
'is_active' => 'Status',
'variants' => 'Varian',
'variants.*.variant' => 'Nama Varian',
'variants.*.price' => 'Harga',
'variants.*.stock' => 'Stok',
'variants.*.photo_key' => 'Foto Varian',
];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\Admin\Master\RawMaterial;
use Illuminate\Foundation\Http\FormRequest;
class RawMaterialVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function prepareForValidation(): void
{
$price = $this->price;
if (is_string($price)) {
$this->request->set('price', (int) str_replace('.', '', $price));
}
}
public function rules(): array
{
return [
'variant' => ['required', 'string', 'max:200'],
'price' => ['required', 'integer', 'min:0'],
'stock' => ['required', 'numeric', 'min:0'],
'photo_key' => ['required', 'string', 'max:500'],
];
}
public function attributes(): array
{
return [
'variant' => 'Nama Varian',
'price' => 'Harga',
'stock' => 'Stok',
'photo_key' => 'Foto',
];
}
}

View File

@ -8,11 +8,13 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])]
class RawMaterialPrice extends Model
class RawMaterialPrice extends Model implements HasMedia
{
use HasFactory, SoftDeletes;
use HasFactory, InteractsWithMedia, SoftDeletes;
protected function casts(): array
{

View File

@ -0,0 +1,251 @@
<?php
namespace App\Services\Admin\Master\RawMaterial;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class RawMaterialService
{
use RegistersMedia;
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
) {}
public function getAll(array $filters = []): Collection
{
return RawMaterial::select('id', 'name', 'unit', 'is_active')
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
'rawMaterialPrices.media',
])
->when($filters['is_active'] ?? null, fn ($q, $isActive) => $q->where('is_active', $isActive === 'true'))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->latest()
->get()
->each(function ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function ($price) {
$media = $price->getMedia('photos');
$price->photo_url = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
});
});
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = RawMaterial::query()
->select('id', 'name', 'unit', 'is_active')
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->when(($filters['is_active'] ?? null) !== null && ($filters['is_active'] ?? null) !== '', function ($q) use ($filters) {
$q->where('is_active', $filters['is_active'] === 'true');
})
->when(($filters['stock'] ?? null) === 'empty', function ($q) {
$q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL) = 0');
})
->when(($filters['stock'] ?? null) === 'low', function ($q) {
$q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL) BETWEEN 0.0001 AND 9.9999');
})
->orderBy($sort, $direction)
->paginate($perPage);
$paginator->getCollection()->each(function ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function ($price) {
$media = $price->getMedia('photos');
$price->photo_url = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
});
});
return $paginator;
}
public function create(array $data): RawMaterial
{
return DB::transaction(function () use ($data) {
$rawMaterial = RawMaterial::create([
'name' => $data['name'],
'unit' => $data['unit'],
'is_active' => $data['is_active'] ?? true,
]);
$now = now();
$priceRows = collect($data['variants'])->map(fn ($v) => [
'raw_material_id' => $rawMaterial->id,
'variant' => $v['variant'],
'price' => $v['price'],
'stock' => $v['stock'],
'created_at' => $now,
'updated_at' => $now,
])->toArray();
DB::table('raw_material_prices')->insert($priceRows);
$insertedPrices = RawMaterialPrice::where('raw_material_id', $rawMaterial->id)->get();
$variantMap = $insertedPrices->mapWithKeys(fn ($p) => [$p->variant => $p->id]);
foreach ($data['variants'] as $variantData) {
if (! empty($variantData['photo_key'])) {
$priceId = $variantMap[$variantData['variant']];
$priceModel = RawMaterialPrice::find($priceId);
$this->registerPhoto($priceModel, $variantData['photo_key']);
}
}
return $rawMaterial;
});
}
public function getForEdit(RawMaterial $rawMaterial): array
{
$rawMaterial->load([
'rawMaterialPrices.media',
]);
$variants = $rawMaterial->rawMaterialPrices->map(function (RawMaterialPrice $price) {
$media = $price->getMedia('photos');
$photoKey = $media->first()?->file_name;
$photoUrl = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
return [
'id' => $price->id,
'variant' => $price->variant,
'price' => $price->price,
'stock' => $price->stock,
'photo_key' => $photoKey,
'photo_url' => $photoUrl,
];
});
return [
'id' => $rawMaterial->id,
'name' => $rawMaterial->name,
'unit' => $rawMaterial->unit->value,
'is_active' => $rawMaterial->is_active,
'raw_material_prices' => $variants,
];
}
public function update(RawMaterial $rawMaterial, array $data): RawMaterial
{
return DB::transaction(function () use ($rawMaterial, $data) {
$rawMaterial->update([
'name' => $data['name'],
'unit' => $data['unit'],
'is_active' => $data['is_active'] ?? $rawMaterial->is_active,
]);
$existingVariantIds = collect($data['variants'])
->pluck('id')
->filter()
->toArray();
$rawMaterial->rawMaterialPrices()
->whereNotIn('id', $existingVariantIds)
->each(function (RawMaterialPrice $price) {
$price->clearMediaCollection('photos');
$price->delete();
});
$existingPricesMap = RawMaterialPrice::whereIn('id', $existingVariantIds)
->with('media')
->get()
->mapWithKeys(fn ($p) => [$p->id => $p]);
$now = now();
$newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id']));
$newVariantIdMap = [];
if ($newVariantsData->isNotEmpty()) {
$newRows = $newVariantsData->map(fn ($v) => [
'raw_material_id' => $rawMaterial->id,
'variant' => $v['variant'],
'price' => $v['price'],
'stock' => $v['stock'],
'created_at' => $now,
'updated_at' => $now,
])->toArray();
DB::table('raw_material_prices')->insert($newRows);
$newlyCreated = RawMaterialPrice::where('raw_material_id', $rawMaterial->id)
->whereIn('variant', $newVariantsData->pluck('variant')->toArray())
->get();
$newVariantIdMap = $newlyCreated->mapWithKeys(fn ($p) => [$p->variant => $p->id])->toArray();
}
foreach ($data['variants'] as $variantData) {
$priceId = $variantData['id'] ?? $newVariantIdMap[$variantData['variant']] ?? null;
if (! $priceId) {
continue;
}
$priceModel = $existingPricesMap[$priceId] ?? RawMaterialPrice::find($priceId);
if (! $priceModel) {
continue;
}
$priceModel->update([
'variant' => $variantData['variant'],
'price' => $variantData['price'],
'stock' => $variantData['stock'],
]);
if (isset($variantData['photo_key'])) {
$existingKey = $priceModel->getMedia('photos')->first()?->file_name;
if ($existingKey !== $variantData['photo_key']) {
$priceModel->clearMediaCollection('photos');
if ($variantData['photo_key']) {
$this->registerPhoto($priceModel, $variantData['photo_key']);
}
}
}
}
return $rawMaterial;
});
}
public function delete(RawMaterial $rawMaterial): bool
{
return DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$price->clearMediaCollection('photos');
$price->delete();
});
return $rawMaterial->delete();
});
}
public function toggleStatus(RawMaterial $rawMaterial): void
{
$rawMaterial->update([
'is_active' => ! $rawMaterial->is_active,
]);
}
private function registerPhoto(RawMaterialPrice $price, string $s3Key): void
{
$this->registerMedia(
model: $price,
s3Key: $s3Key,
collectionName: 'photos',
orderColumn: 1,
);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Services\Admin\Master\RawMaterial;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Support\Facades\DB;
class RawMaterialVariantService
{
use RegistersMedia;
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
) {}
public function getForEdit(RawMaterialPrice $variant): array
{
$variant->load('media');
$media = $variant->getMedia('photos');
$photoKey = $media->first()?->file_name;
$photoUrl = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
return [
'id' => $variant->id,
'raw_material_id' => $variant->raw_material_id,
'variant' => $variant->variant,
'price' => $variant->price,
'stock' => $variant->stock,
'photo_key' => $photoKey,
'photo_url' => $photoUrl,
];
}
public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
{
DB::transaction(function () use ($variant, $data) {
$variant->update([
'variant' => $data['variant'],
'price' => $data['price'],
'stock' => $data['stock'],
]);
if (array_key_exists('photo_key', $data)) {
$existingKey = $variant->getMedia('photos')->first()?->file_name;
$newKey = $data['photo_key'];
if ($existingKey !== $newKey) {
$variant->clearMediaCollection('photos');
if ($newKey) {
$this->registerPhoto($variant, $newKey);
}
}
}
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Varian Diperbarui',
body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
);
return $variant->fresh();
}
public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{
$result = DB::transaction(function () use ($variant) {
$variant->clearMediaCollection('photos');
return $variant->delete();
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Varian Dihapus',
body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
);
return $result;
}
private function registerPhoto(RawMaterialPrice $variant, string $s3Key): void
{
$this->registerMedia(
model: $variant,
s3Key: $s3Key,
collectionName: 'photos',
orderColumn: 1,
);
}
}

View File

@ -10,7 +10,7 @@ public function definition(): array
{
return [
'name' => fake()->unique()->words(2, true),
'unit' => fake()->randomElement(['kg', 'meter', 'pcs', 'roll', 'liter', 'pack']),
'unit' => fake()->randomElement(['kg', 'meter', 'yard']),
'is_active' => true,
];
}

View File

@ -23,6 +23,7 @@ import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as productsIndex } from '@/routes/admin/master/products';
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as rolesIndex } from '@/routes/admin/settings/roles';
import { Link, router } from '@inertiajs/react';
@ -68,7 +69,7 @@ const analisaItem: NavMenuItem = {
const masterItems: NavMenuItem[] = [
{ title: 'Kategori', href: categoriesIndex.url(), icon: Tags },
{ title: 'Produk', href: productsIndex.url(), icon: Package },
{ title: 'Bahan Baku', href: '#', icon: Boxes },
{ title: 'Bahan Baku', href: rawMaterialsIndex.url(), icon: Boxes },
{ title: 'Supplier', href: suppliersIndex.url(), icon: Truck },
{ title: 'Customer', href: customersIndex.url(), icon: Users },
];

View File

@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react';
import { router } from '@inertiajs/react';
import {
saveRawMaterialDraft,
clearRawMaterialDraft,
type RawMaterialDraftData,
} from '@/lib/raw-material-draft';
type DraftType = 'create' | 'edit';
export function useRawMaterialDraftSave(
type: DraftType,
data: RawMaterialDraftData,
userId?: number,
rawMaterialId?: number,
delay = 500,
) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dataRef = useRef(data);
dataRef.current = data;
const submittedRef = useRef(false);
useEffect(() => {
return router.on('before', () => {
submittedRef.current = true;
});
}, []);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
if (!submittedRef.current) {
saveRawMaterialDraft(type, dataRef.current, userId, rawMaterialId);
}
timeoutRef.current = null;
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [data, type, userId, rawMaterialId, delay]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (submittedRef.current) {
clearRawMaterialDraft(type, userId, rawMaterialId);
} else {
saveRawMaterialDraft(type, dataRef.current, userId, rawMaterialId);
}
};
}, [type, userId, rawMaterialId]);
}

View File

@ -37,6 +37,10 @@ export function saveProductDraft(
userId?: number,
productId?: number,
): boolean {
if (type !== 'create') {
return false;
}
try {
const key = getKey(type, userId, productId);
localStorage.setItem(key, JSON.stringify(data));

View File

@ -0,0 +1,78 @@
const DRAFT_PREFIX = 'raw-material-draft';
export type RawMaterialDraftData = {
name: string;
unit: string;
isActive: boolean;
variants: Array<{
id?: number | null;
variant: string;
price: number;
stock: number;
photo?: string;
}>;
};
function getKey(
type: 'create' | 'edit',
userId?: number,
rawMaterialId?: number,
): string {
if (type === 'edit' && rawMaterialId) {
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${rawMaterialId}`;
}
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
}
export function saveRawMaterialDraft(
type: 'create' | 'edit',
data: RawMaterialDraftData,
userId?: number,
rawMaterialId?: number,
): boolean {
if (type !== 'create') {
return false;
}
try {
const key = getKey(type, userId, rawMaterialId);
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch {
return false;
}
}
export function loadRawMaterialDraft(
type: 'create' | 'edit',
userId?: number,
rawMaterialId?: number,
): RawMaterialDraftData | null {
try {
const key = getKey(type, userId, rawMaterialId);
const raw = localStorage.getItem(key);
if (!raw) {
return null;
}
return JSON.parse(raw) as RawMaterialDraftData;
} catch {
return null;
}
}
export function clearRawMaterialDraft(
type: 'create' | 'edit',
userId?: number,
rawMaterialId?: number,
): void {
try {
const key = getKey(type, userId, rawMaterialId);
localStorage.removeItem(key);
} catch {
// ignore
}
}

View File

@ -19,8 +19,7 @@ import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { useProductDraftSave } from '@/hooks/use-product-draft';
import { loadProductDraft } from '@/lib/product-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { clearProductDraft } from '@/lib/product-draft';
import { index as productIndex, update } from '@/routes/admin/master/products';
type Category = {
@ -95,14 +94,12 @@ export default function ProductEdit({ product, categories }: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
const draft = loadProductDraft('edit', userId, product.id);
clearProductDraft('edit', userId, product.id);
const [productName, setProductName] = useState(
draft?.productName ?? product.name,
);
const [status, setStatus] = useState(draft?.status ?? product.status);
const [productName, setProductName] = useState(product.name);
const [status, setStatus] = useState<string>(product.status);
const [description, setDescription] = useState(
draft?.description ?? product.description ?? '',
product.description ?? '',
);
const serverVariants: VariantState[] = product.product_variants.map(
@ -122,56 +119,23 @@ export default function ProductEdit({ product, categories }: Props) {
);
const [categoryIds, setCategoryIds] = useState<number[]>(
draft?.categoryIds ?? product.category_ids,
product.category_ids,
);
const [useSamePrice, setUseSamePrice] = useState(
draft?.useSamePrice ??
(serverVariants.length > 1
? serverVariants.every((v) =>
arePricesEqual(v.prices, serverVariants[0].prices),
)
: true),
serverVariants.length > 1
? serverVariants.every((v) =>
arePricesEqual(v.prices, serverVariants[0].prices),
)
: true,
);
const [sharedPrices, setSharedPrices] = useState<
Array<{ type: string; price: number }>
>(
draft?.sharedPrices ??
(serverVariants.length > 0
? serverVariants[0].prices
: createEmptyPrices()),
serverVariants.length > 0
? serverVariants[0].prices
: createEmptyPrices(),
);
const [variants, setVariants] = useState<VariantState[]>(() => {
if (draft?.variants && draft.variants.length > 0) {
const serverVariantMap = new Map(
serverVariants.map((sv) => [sv.id, sv]),
);
return draft.variants.map((v) => {
const serverMatch =
v.id != null ? serverVariantMap.get(v.id) : undefined;
const photos = Array.isArray(v.photos)
? v.photos.map((p, i) => ({
key: p.key,
url: serverMatch?.photos[i]?.url ?? getTemporaryUrl(p.key),
}))
: typeof v.photo === 'string' && v.photo
? [{ key: v.photo, url: serverMatch?.photos[0]?.url ?? getTemporaryUrl(v.photo) }]
: [];
return {
id: v.id ?? null,
name: v.name,
stock: v.stock,
reject_stock: v.reject_stock,
retail_stock: v.retail_stock,
photos,
uploading: false,
prices: v.prices,
};
});
}
return serverVariants.length > 0
? serverVariants
: [

View File

@ -0,0 +1,38 @@
export type RawMaterialVariant = {
id: number;
variant: string;
price: number;
stock: number;
photo_url: string | null;
};
export type RawMaterial = {
id: number;
name: string;
unit: string;
is_active: boolean;
raw_material_prices: RawMaterialVariant[];
};
export type RawMaterialVariantForEdit = {
id: number;
variant: string;
price: number;
stock: number;
photo_key: string | null;
photo_url: string | null;
};
export type RawMaterialForEdit = {
id: number;
name: string;
unit: string;
is_active: boolean;
raw_material_prices: RawMaterialVariantForEdit[];
};
export const UNIT_LABELS: Record<string, string> = {
kg: 'Kilogram',
meter: 'Meter',
yard: 'Yard',
};

View File

@ -0,0 +1,432 @@
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
import { loadRawMaterialDraft } from '@/lib/raw-material-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { index as rawMaterialIndex, store } from '@/routes/admin/master/raw-materials';
import { Form, Head, usePage } from '@inertiajs/react';
import {
ArrowLeft,
Check,
ClipboardPaste,
Copy,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
const UNITS = [
{ value: 'kg', label: 'Kilogram' },
{ value: 'meter', label: 'Meter' },
{ value: 'yard', label: 'Yard' },
];
type VariantState = {
variant: string;
price: number;
stock: number;
photo: string | null;
photoUrl: string | null;
uploading: boolean;
};
export default function RawMaterialCreate() {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
const draft = loadRawMaterialDraft('create', userId);
const [name, setName] = useState(draft?.name ?? '');
const [unit, setUnit] = useState(draft?.unit ?? 'kg');
const [variants, setVariants] = useState<VariantState[]>(() => {
if (draft?.variants && draft.variants.length > 0) {
return draft.variants.map((v) => ({
variant: v.variant,
price: v.price,
stock: v.stock,
photo: v.photo ?? null,
photoUrl: v.photo ? getTemporaryUrl(v.photo) : null,
uploading: false,
}));
}
return [
{
variant: '',
price: 0,
stock: 0,
photo: null,
photoUrl: null,
uploading: false,
},
];
});
const variantsRef = useRef(variants);
variantsRef.current = variants;
const draftData = {
name,
unit,
isActive: true,
variants: variants.map((v) => ({
variant: v.variant,
price: v.price,
stock: v.stock,
photo: v.photo ?? undefined,
})),
};
useRawMaterialDraftSave('create', draftData, userId);
const addVariant = useCallback(() => {
setVariants((prev) => [
...prev,
{
variant: '',
price: 0,
stock: 0,
photo: null,
photoUrl: null,
uploading: false,
},
]);
}, []);
const removeVariant = useCallback((index: number) => {
setVariants((prev) => prev.filter((_, i) => i !== index));
}, []);
const updateVariant = useCallback(
(index: number, field: keyof VariantState, value: unknown) => {
setVariants((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
[],
);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(null);
const confirmRemoveVariant = useCallback((index: number) => {
setDeleteVariantIndex(index);
setDeleteConfirmOpen(true);
}, []);
const copyPrice = useCallback((variantIndex: number) => {
setVariants((prev) => {
const price = prev[variantIndex].price;
navigator.clipboard.writeText(String(price));
setCopiedIndex(variantIndex);
setTimeout(() => setCopiedIndex(null), 1500);
return prev;
});
}, []);
const pastePrice = useCallback((variantIndex: number) => {
navigator.clipboard.readText().then((text) => {
try {
const price = Number(text);
if (!isNaN(price)) {
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = { ...updated[variantIndex], price };
return updated;
});
}
} catch {
// invalid clipboard data
}
});
}, []);
const applyToAll = useCallback((variantIndex: number) => {
setVariants((prev) => {
const sourcePrice = prev[variantIndex].price;
return prev.map((v, i) =>
i === variantIndex ? v : { ...v, price: sourcePrice },
);
});
}, []);
function getPayload() {
return {
name,
unit,
is_active: true,
variants: variantsRef.current.map((v) => ({
variant: v.variant,
price: Number(v.price),
stock: Number(v.stock),
photo_key: v.photo,
})),
};
}
return (
<>
<Head title="Tambah Bahan Baku" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">
Tambah Bahan Baku
</h2>
<Button asChild variant="outline">
<a href={rawMaterialIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form
action={store()}
transform={(data) => ({
...data,
...getPayload(),
})}
>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Bahan Baku</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">
Nama Bahan Baku{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Masukkan nama bahan baku"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>
Satuan <span className="text-destructive">*</span>
</Label>
<RadioGroup
name="unit"
value={unit}
onValueChange={setUnit}
className="flex flex-wrap gap-4"
>
{UNITS.map((u) => (
<div key={u.value} className="flex items-center space-x-2">
<RadioGroupItem value={u.value} id={`unit-${u.value}`} />
<Label htmlFor={`unit-${u.value}`} className="font-normal">
{u.label}
</Label>
</div>
))}
</RadioGroup>
<InputError message={errors.unit} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Varian Bahan Baku</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{variants.map((variant, variantIndex) => (
<div
key={variantIndex}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="font-medium">
Varian {variantIndex + 1}
</h4>
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => copyPrice(variantIndex)}
>
{copiedIndex === variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => pastePrice(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
{variantIndex > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => confirmRemoveVariant(variantIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">*</span>
</Label>
<Input
value={variant.variant}
onChange={(e) =>
updateVariant(variantIndex, 'variant', e.target.value)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={errors[`variants.${variantIndex}.variant`]}
/>
</div>
<div className="grid gap-2">
<Label>
Harga <span className="text-destructive">*</span>
</Label>
<RupiahInput
value={variant.price}
onValueChange={(val) =>
updateVariant(variantIndex, 'price', val)
}
/>
<InputError
message={errors[`variants.${variantIndex}.price`]}
/>
</div>
<div className="grid gap-2">
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<Input
type="number"
min={0}
step="0.0001"
value={variant.stock}
onChange={(e) =>
updateVariant(
variantIndex,
'stock',
Number(e.target.value),
)
}
/>
<InputError
message={errors[`variants.${variantIndex}.stock`]}
/>
</div>
</div>
<div className="grid gap-2">
<Label>Foto Varian <span className="text-destructive">*</span></Label>
<FileUpload
value={variant.photo}
onChange={(key) => {
updateVariant(variantIndex, 'photo', key);
updateVariant(
variantIndex,
'photoUrl',
key ? getTemporaryUrl(key) : null,
);
}}
folder="raw-material-variant"
existingUrl={variant.photoUrl}
onUploadingChange={(uploading) =>
updateVariant(variantIndex, 'uploading', uploading)
}
/>
<InputError
message={errors[`variants.${variantIndex}.photo_key`]}
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={addVariant}
>
<Plus className="h-4 w-4" />
Tambah Varian
</Button>
</CardContent>
</Card>
</div>
<div className="mt-6 flex items-center gap-4">
<Button
type="submit"
disabled={processing || variants.some((v) => v.uploading)}
>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</>
)}
</Form>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
if (!open) {
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}
}}
title="Hapus Varian"
description="Apakah Anda yakin ingin menghapus varian ini?"
confirmLabel="Hapus"
onConfirm={() => {
if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex);
}
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}}
/>
</div>
</>
);
}

View File

@ -0,0 +1,450 @@
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft';
import { clearRawMaterialDraft } from '@/lib/raw-material-draft';
import { getTemporaryUrl } from '@/lib/upload';
import { index as rawMaterialIndex, update } from '@/routes/admin/master/raw-materials';
import { Form, Head, usePage } from '@inertiajs/react';
import {
ArrowLeft,
Check,
ClipboardPaste,
Copy,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import type { RawMaterialForEdit, RawMaterialVariantForEdit } from './columns';
const UNITS = [
{ value: 'kg', label: 'Kilogram' },
{ value: 'meter', label: 'Meter' },
{ value: 'yard', label: 'Yard' },
];
type Props = {
rawMaterial: RawMaterialForEdit;
};
type VariantState = {
id: number | null;
variant: string;
price: number;
stock: number;
photo: string | null;
photoUrl: string | null;
uploading: boolean;
};
export default function RawMaterialEdit({ rawMaterial }: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
clearRawMaterialDraft('edit', userId, rawMaterial.id);
const [name, setName] = useState(rawMaterial.name);
const [unit, setUnit] = useState(rawMaterial.unit);
const [isActive, setIsActive] = useState(rawMaterial.is_active);
const serverVariants: VariantState[] = rawMaterial.raw_material_prices.map(
(v: RawMaterialVariantForEdit) => ({
id: v.id,
variant: v.variant,
price: v.price,
stock: v.stock,
photo: v.photo_key,
photoUrl: v.photo_url,
uploading: false,
}),
);
const [variants, setVariants] = useState<VariantState[]>(() => {
return serverVariants.length > 0
? serverVariants
: [
{
id: null,
variant: '',
price: 0,
stock: 0,
photo: null,
photoUrl: null,
uploading: false,
},
];
});
const variantsRef = useRef(variants);
variantsRef.current = variants;
const draftData = {
name,
unit,
isActive,
variants: variants.map((v) => ({
id: v.id,
variant: v.variant,
price: v.price,
stock: v.stock,
photo: v.photo ?? undefined,
})),
};
useRawMaterialDraftSave('edit', draftData, userId, rawMaterial.id);
const addVariant = useCallback(() => {
setVariants((prev) => [
...prev,
{
id: null,
variant: '',
price: 0,
stock: 0,
photo: null,
photoUrl: null,
uploading: false,
},
]);
}, []);
const removeVariant = useCallback((index: number) => {
setVariants((prev) => prev.filter((_, i) => i !== index));
}, []);
const updateVariant = useCallback(
(index: number, field: keyof VariantState, value: unknown) => {
setVariants((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
[],
);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(null);
const confirmRemoveVariant = useCallback((index: number) => {
setDeleteVariantIndex(index);
setDeleteConfirmOpen(true);
}, []);
const copyPrice = useCallback((variantIndex: number) => {
setVariants((prev) => {
const price = prev[variantIndex].price;
navigator.clipboard.writeText(String(price));
setCopiedIndex(variantIndex);
setTimeout(() => setCopiedIndex(null), 1500);
return prev;
});
}, []);
const pastePrice = useCallback((variantIndex: number) => {
navigator.clipboard.readText().then((text) => {
try {
const price = Number(text);
if (!isNaN(price)) {
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = { ...updated[variantIndex], price };
return updated;
});
}
} catch {
// invalid clipboard data
}
});
}, []);
const applyToAll = useCallback((variantIndex: number) => {
setVariants((prev) => {
const sourcePrice = prev[variantIndex].price;
return prev.map((v, i) =>
i === variantIndex ? v : { ...v, price: sourcePrice },
);
});
}, []);
function getPayload() {
return {
name,
unit,
is_active: isActive,
variants: variantsRef.current.map((v) => ({
id: v.id,
variant: v.variant,
price: Number(v.price),
stock: Number(v.stock),
photo_key: v.photo,
})),
};
}
return (
<>
<Head title="Edit Bahan Baku" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">
Edit Bahan Baku
</h2>
<Button asChild variant="outline">
<a href={rawMaterialIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form
action={update(rawMaterial.id)}
method="put"
transform={(data) => ({
...data,
...getPayload(),
})}
>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Bahan Baku</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">
Nama Bahan Baku{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Masukkan nama bahan baku"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>
Satuan <span className="text-destructive">*</span>
</Label>
<RadioGroup
name="unit"
value={unit}
onValueChange={setUnit}
className="flex flex-wrap gap-4"
>
{UNITS.map((u) => (
<div key={u.value} className="flex items-center space-x-2">
<RadioGroupItem value={u.value} id={`unit-${u.value}`} />
<Label htmlFor={`unit-${u.value}`} className="font-normal">
{u.label}
</Label>
</div>
))}
</RadioGroup>
<InputError message={errors.unit} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Varian Bahan Baku</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{variants.map((variant, variantIndex) => (
<div
key={variantIndex}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="font-medium">
Varian {variantIndex + 1}
</h4>
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => copyPrice(variantIndex)}
>
{copiedIndex === variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => pastePrice(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel Harga
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
{variantIndex > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => confirmRemoveVariant(variantIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">*</span>
</Label>
<Input
value={variant.variant}
onChange={(e) =>
updateVariant(variantIndex, 'variant', e.target.value)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={errors[`variants.${variantIndex}.variant`]}
/>
</div>
<div className="grid gap-2">
<Label>
Harga <span className="text-destructive">*</span>
</Label>
<RupiahInput
value={variant.price}
onValueChange={(val) =>
updateVariant(variantIndex, 'price', val)
}
/>
<InputError
message={errors[`variants.${variantIndex}.price`]}
/>
</div>
<div className="grid gap-2">
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<Input
type="number"
min={0}
step="0.0001"
value={Number(variant.stock) || 0}
onChange={(e) =>
updateVariant(
variantIndex,
'stock',
Number(e.target.value),
)
}
/>
<InputError
message={errors[`variants.${variantIndex}.stock`]}
/>
</div>
</div>
<div className="grid gap-2">
<Label>
Foto Varian <span className="text-destructive">*</span>
</Label>
<FileUpload
value={variant.photo}
onChange={(key) => {
updateVariant(variantIndex, 'photo', key);
updateVariant(
variantIndex,
'photoUrl',
key ? getTemporaryUrl(key) : null,
);
}}
folder="raw-material-variant"
existingUrl={variant.photoUrl}
onUploadingChange={(uploading) =>
updateVariant(variantIndex, 'uploading', uploading)
}
/>
<InputError
message={errors[`variants.${variantIndex}.photo_key`]}
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={addVariant}
>
<Plus className="h-4 w-4" />
Tambah Varian
</Button>
</CardContent>
</Card>
</div>
<div className="mt-6 flex items-center gap-4">
<Button
type="submit"
disabled={processing || variants.some((v) => v.uploading)}
>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</>
)}
</Form>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
if (!open) {
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}
}}
title="Hapus Varian"
description="Apakah Anda yakin ingin menghapus varian ini?"
confirmLabel="Hapus"
onConfirm={() => {
if (deleteVariantIndex !== null) {
removeVariant(deleteVariantIndex);
}
setDeleteConfirmOpen(false);
setDeleteVariantIndex(null);
}}
/>
</div>
</>
);
}

View File

@ -0,0 +1,318 @@
import { Head, router } from '@inertiajs/react';
import { Filter, Plus, X } from 'lucide-react';
import { useCallback, useState } from 'react';
import { CardTable } from '@/components/card-table';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
destroy,
create as rawMaterialCreate,
index as rawMaterialIndex,
edit as rawMaterialEdit,
toggleStatus,
} from '@/routes/admin/master/raw-materials';
import {
destroy as variantDestroy,
edit as variantEdit,
} from '@/routes/admin/master/raw-materials/variants';
import type { RawMaterial, RawMaterialVariant } from './columns';
import { RawMaterialCardRow } from './raw-material-card';
import { RawMaterialVariantSubRow } from './variant/sub-row';
type Props = {
rawMaterials: {
data: RawMaterial[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
filters: {
is_active?: string;
stock?: string;
};
};
export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
const [deleting, setDeleting] = useState<RawMaterial | null>(null);
const [deletingVariant, setDeletingVariant] = useState<{
rawMaterial: RawMaterial;
variant: RawMaterialVariant;
} | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState('');
const expand = useCardTableExpand(true);
const hasActiveFilters = filters.is_active || filters.stock;
const pagination = {
current_page: rawMaterials.current_page,
last_page: rawMaterials.last_page,
per_page: rawMaterials.per_page,
total: rawMaterials.total,
};
function applyFilter(key: string, value: string) {
const newFilters = { ...filters };
if (value === '' || value === 'all') {
delete newFilters[key as keyof typeof newFilters];
} else {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(rawMaterialIndex(), newFilters, {
preserveState: true,
replace: true,
});
}
function clearFilters() {
router.get(
rawMaterialIndex(),
{},
{
preserveState: true,
replace: true,
},
);
setFilterOpen(false);
}
function handlePageChange(page: number) {
router.get(
rawMaterialIndex.url(),
{
page,
per_page: pagination.per_page,
search,
...filters,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(
rawMaterialIndex.url(),
{
page: 1,
per_page: perPage,
search,
...filters,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
rawMaterialIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
...filters,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, filters],
);
function handleDelete() {
if (!deleting) return;
router.delete(destroy.url(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
function handleDeleteVariant() {
if (!deletingVariant) return;
router.delete(
variantDestroy.url({
rawMaterial: deletingVariant.rawMaterial.id,
variant: deletingVariant.variant.id,
}),
{
onSuccess: () => setDeletingVariant(null),
},
);
}
const filterToolbar = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" />
Filter
{hasActiveFilters && (
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{Object.values(filters).filter(Boolean).length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearFilters}
>
<X className="mr-1 h-3 w-3" />
Hapus Semua
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status
</label>
<Select
value={filters.is_active ?? 'all'}
onValueChange={(value) => applyFilter('is_active', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="true">Aktif</SelectItem>
<SelectItem value="false">Non Aktif</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Stok
</label>
<Select
value={filters.stock ?? 'all'}
onValueChange={(value) => applyFilter('stock', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Stok" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Stok</SelectItem>
<SelectItem value="empty">Habis</SelectItem>
<SelectItem value="low">Menipis (di bawah 10)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
);
return (
<>
<Head title="Bahan Baku" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Bahan Baku
</h2>
</div>
<Button asChild>
<a href={rawMaterialCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<CardTable
data={rawMaterials.data}
getItemKey={(rm) => rm.id}
expandedKeys={expand.expandedKeys}
onToggleExpand={expand.toggleExpand}
searchValue={search}
onSearchChange={handleSearchChange}
searchPlaceholder="Cari bahan baku..."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
toolbar={filterToolbar}
renderCard={({
item,
index,
isExpanded,
onToggleExpand,
}) => (
<RawMaterialCardRow
rawMaterial={item}
index={
(pagination.current_page - 1) *
pagination.per_page +
index +
1
}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
onEdit={(rm) => {
window.location.href = rawMaterialEdit.url(rm.id);
}}
onDelete={(rm) => setDeleting(rm)}
toggleStatusUrl={(id) => toggleStatus.url(id)}
/>
)}
renderSubContent={(rawMaterial) => (
<RawMaterialVariantSubRow rawMaterial={rawMaterial} />
)}
/>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Bahan Baku"
description={`Apakah Anda yakin ingin menghapus bahan baku "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDelete}
/>
<ConfirmDialog
open={deletingVariant !== null}
onOpenChange={(open) => {
if (!open) {
setDeletingVariant(null);
}
}}
title="Hapus Varian"
description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.variant}" dari bahan baku "${deletingVariant?.rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDeleteVariant}
/>
</div>
</>
);
}

View File

@ -0,0 +1,146 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { router } from '@inertiajs/react';
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import type { RawMaterial } from './columns';
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID', { maximumFractionDigits: 4 }).format(num);
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
export type RawMaterialCardRowParams = {
rawMaterial: RawMaterial;
index: number;
isExpanded: boolean;
onToggleExpand: () => void;
onEdit: (rawMaterial: RawMaterial) => void;
onDelete: (rawMaterial: RawMaterial) => void;
toggleStatusUrl: (id: number) => string;
};
export function RawMaterialCardRow({
rawMaterial,
index,
isExpanded,
onToggleExpand,
onEdit,
onDelete,
toggleStatusUrl,
}: RawMaterialCardRowParams) {
const variants = rawMaterial.raw_material_prices ?? [];
const totalStock = variants.reduce((sum, v) => sum + (Number(v.stock) || 0), 0);
const totalValue = variants.reduce((sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0), 0);
function handleToggle() {
router.post(toggleStatusUrl(rawMaterial.id), {}, { preserveScroll: true });
}
return (
<Card className="overflow-hidden">
<CardContent className="p-0">
<div className="flex items-start gap-3 p-4">
<Button
variant="ghost"
size="icon"
className="mt-0.5 h-6 w-6 shrink-0"
onClick={onToggleExpand}
>
<ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</Button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{index}.
</span>
<h3 className="truncate font-medium">
{rawMaterial.name}
</h3>
</div>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{variants.length} varian
</span>
<span>
Total Stok:{' '}
<span className="font-medium text-foreground">
{formatNumber(totalStock)}
</span>
</span>
<span>
Total Nilai:{' '}
<span className="font-medium text-foreground">
{formatCurrency(totalValue)}
</span>
</span>
</div>
<div className="mt-2">
<div className="flex items-center gap-2">
<Switch
size="sm"
checked={rawMaterial.is_active}
onCheckedChange={handleToggle}
/>
<span
className={`text-xs font-medium ${rawMaterial.is_active ? 'text-green-700' : 'text-red-700'}`}
>
{rawMaterial.is_active ? 'Aktif' : 'Non Aktif'}
</span>
</div>
</div>
</div>
<TooltipProvider>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(rawMaterial)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(rawMaterial)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,149 @@
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getTemporaryUrl } from '@/lib/upload';
import { index as rawMaterialIndex } from '@/routes/admin/master/raw-materials';
type Props = {
variant: {
id: number;
raw_material_id: number;
variant: string;
price: number;
stock: number;
photo_key: string | null;
photo_url: string | null;
};
};
export default function RawMaterialVariantEdit({ variant }: Props) {
const [variantName, setVariantName] = useState(variant.variant);
const [price, setPrice] = useState(variant.price);
const [stock, setStock] = useState(Number(variant.stock) || 0);
const [photo, setPhoto] = useState<string | null>(variant.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(variant.photo_url);
const [uploading, setUploading] = useState(false);
function getPayload() {
return {
variant: variantName,
price: Number(price),
stock: Number(stock),
photo_key: photo,
};
}
return (
<>
<Head title="Edit Varian" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">
Edit Varian
</h2>
<Button asChild variant="outline">
<a href={rawMaterialIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form
action={`/admin/master/raw-materials/${variant.raw_material_id}/variants/${variant.id}`}
method="put"
transform={() => getPayload()}
>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Varian</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">*</span>
</Label>
<Input
value={variantName}
onChange={(e) => setVariantName(e.target.value)}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError message={errors.variant} />
</div>
<div className="grid gap-2">
<Label>
Harga <span className="text-destructive">*</span>
</Label>
<RupiahInput
value={price}
onValueChange={setPrice}
/>
<InputError message={errors.price} />
</div>
<div className="grid gap-2">
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<Input
type="number"
min={0}
step="0.0001"
value={stock}
onChange={(e) => setStock(Number(e.target.value))}
/>
<InputError message={errors.stock} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Foto Varian <span className="text-destructive">*</span></CardTitle>
</CardHeader>
<CardContent>
<FileUpload
value={photo}
onChange={(key) => {
setPhoto(key);
setPhotoUrl(key ? getTemporaryUrl(key) : null);
}}
folder="raw-material-variant"
existingUrl={photoUrl}
onUploadingChange={setUploading}
/>
<InputError message={errors.photo_key} />
</CardContent>
</Card>
</div>
<div className="mt-6 flex items-center gap-4">
<Button
type="submit"
disabled={processing || uploading}
>
{processing
? 'Menyimpan...'
: uploading
? 'Mengunggah...'
: 'Simpan'}
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,201 @@
import { router } from '@inertiajs/react';
import { Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { RawMaterial, RawMaterialVariant } from '../columns';
import { edit as variantEdit, destroy as variantDestroy } from '@/routes/admin/master/raw-materials/variants';
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID', { maximumFractionDigits: 4 }).format(num);
}
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="relative block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80"
>
<img
src={url}
alt={title}
className="h-full w-full object-cover"
/>
</button>
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={url}
sources={[url]}
title={title}
/>
</>
);
}
export function RawMaterialVariantSubRow({
rawMaterial,
}: {
rawMaterial: RawMaterial;
}) {
const variants = rawMaterial.raw_material_prices ?? [];
const [deletingVariant, setDeletingVariant] = useState<RawMaterialVariant | null>(null);
function handleDeleteVariant() {
if (!deletingVariant) return;
router.delete(
variantDestroy.url({
rawMaterial: rawMaterial.id,
variant: deletingVariant.id,
}),
{
onSuccess: () => setDeletingVariant(null),
},
);
}
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">
No
</TableHead>
<TableHead className="w-[60px]">Foto</TableHead>
<TableHead>Nama Varian</TableHead>
<TableHead>Harga</TableHead>
<TableHead className="text-center">Stok</TableHead>
<TableHead className="w-[100px] text-center">
Aksi
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{variants.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
Tidak ada varian.
</TableCell>
</TableRow>
) : (
variants.map((variant, index) => (
<TableRow key={variant.id}>
<TableCell className="text-center">
{index + 1}
</TableCell>
<TableCell>
{variant.photo_url ? (
<VariantPhotoPreview
url={variant.photo_url}
title={variant.variant}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell className="font-medium">
{variant.variant}
</TableCell>
<TableCell>
{formatCurrency(variant.price)}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.stock)}
</TableCell>
<TableCell>
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
window.location.href = variantEdit.url({
rawMaterial: rawMaterial.id,
variant: variant.id,
});
}}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
setDeletingVariant(variant)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{deletingVariant && (
<ConfirmDialog
open={deletingVariant !== null}
onOpenChange={(open) => {
if (!open) {
setDeletingVariant(null);
}
}}
title="Hapus Varian"
description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant.variant}" dari bahan baku "${rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDeleteVariant}
/>
)}
</div>
);
}

View File

@ -15,6 +15,8 @@
use App\Http\Controllers\Admin\Master\Product\ProductController;
use App\Http\Controllers\Admin\Master\Product\ProductVariantController;
use App\Http\Controllers\Admin\Master\Product\StockMutationController;
use App\Http\Controllers\Admin\Master\RawMaterial\RawMaterialController;
use App\Http\Controllers\Admin\Master\RawMaterial\RawMaterialVariantController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\RoleController;
use Illuminate\Support\Facades\Route;
@ -40,6 +42,11 @@
Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update');
Route::post('products/{product}/variants/{variant}/transfer-stock', [ProductVariantController::class, 'transferStock'])->name('products.variants.transfer-stock');
Route::get('products/{product}/variants/{variant}/stock-mutations', [StockMutationController::class, 'index'])->name('products.variants.stock-mutations');
Route::resource('raw-materials', RawMaterialController::class)->except(['show']);
Route::post('raw-materials/{rawMaterial}/toggle-status', [RawMaterialController::class, 'toggleStatus'])->name('raw-materials.toggle-status');
Route::get('raw-materials/{rawMaterial}/variants/{variant}/edit', [RawMaterialVariantController::class, 'edit'])->name('raw-materials.variants.edit');
Route::put('raw-materials/{rawMaterial}/variants/{variant}', [RawMaterialVariantController::class, 'update'])->name('raw-materials.variants.update');
Route::delete('raw-materials/{rawMaterial}/variants/{variant}', [RawMaterialVariantController::class, 'destroy'])->name('raw-materials.variants.destroy');
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']);
});

File diff suppressed because it is too large Load Diff