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:
parent
d943c3a240
commit
384460686e
@ -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';
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
251
app/Services/Admin/Master/RawMaterial/RawMaterialService.php
Normal file
251
app/Services/Admin/Master/RawMaterial/RawMaterialService.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
59
resources/js/hooks/use-raw-material-draft.ts
Normal file
59
resources/js/hooks/use-raw-material-draft.ts
Normal 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]);
|
||||
}
|
||||
@ -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));
|
||||
|
||||
78
resources/js/lib/raw-material-draft.ts
Normal file
78
resources/js/lib/raw-material-draft.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
: [
|
||||
|
||||
38
resources/js/pages/admin/master/raw-material/columns.tsx
Normal file
38
resources/js/pages/admin/master/raw-material/columns.tsx
Normal 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',
|
||||
};
|
||||
432
resources/js/pages/admin/master/raw-material/create.tsx
Normal file
432
resources/js/pages/admin/master/raw-material/create.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
450
resources/js/pages/admin/master/raw-material/edit.tsx
Normal file
450
resources/js/pages/admin/master/raw-material/edit.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
318
resources/js/pages/admin/master/raw-material/index.tsx
Normal file
318
resources/js/pages/admin/master/raw-material/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
149
resources/js/pages/admin/master/raw-material/variant/edit.tsx
Normal file
149
resources/js/pages/admin/master/raw-material/variant/edit.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
201
resources/js/pages/admin/master/raw-material/variant/sub-row.tsx
Normal file
201
resources/js/pages/admin/master/raw-material/variant/sub-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
1069
tests/Feature/Admin/Master/RawMaterialTest.php
Normal file
1069
tests/Feature/Admin/Master/RawMaterialTest.php
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user