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 KG = 'kg';
|
||||||
case METER = 'meter';
|
case METER = 'meter';
|
||||||
case PCS = 'pcs';
|
case YARD = 'yard';
|
||||||
case ROLL = 'roll';
|
|
||||||
case LITER = 'liter';
|
|
||||||
case PACK = 'pack';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
class RawMaterialPrice extends Model
|
class RawMaterialPrice extends Model implements HasMedia
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, InteractsWithMedia, SoftDeletes;
|
||||||
|
|
||||||
protected function casts(): array
|
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 [
|
return [
|
||||||
'name' => fake()->unique()->words(2, true),
|
'name' => fake()->unique()->words(2, true),
|
||||||
'unit' => fake()->randomElement(['kg', 'meter', 'pcs', 'roll', 'liter', 'pack']),
|
'unit' => fake()->randomElement(['kg', 'meter', 'yard']),
|
||||||
'is_active' => true,
|
'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 categoriesIndex } from '@/routes/admin/master/categories';
|
||||||
import { index as customersIndex } from '@/routes/admin/master/customers';
|
import { index as customersIndex } from '@/routes/admin/master/customers';
|
||||||
import { index as productsIndex } from '@/routes/admin/master/products';
|
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 suppliersIndex } from '@/routes/admin/master/suppliers';
|
||||||
import { index as rolesIndex } from '@/routes/admin/settings/roles';
|
import { index as rolesIndex } from '@/routes/admin/settings/roles';
|
||||||
import { Link, router } from '@inertiajs/react';
|
import { Link, router } from '@inertiajs/react';
|
||||||
@ -68,7 +69,7 @@ const analisaItem: NavMenuItem = {
|
|||||||
const masterItems: NavMenuItem[] = [
|
const masterItems: NavMenuItem[] = [
|
||||||
{ title: 'Kategori', href: categoriesIndex.url(), icon: Tags },
|
{ title: 'Kategori', href: categoriesIndex.url(), icon: Tags },
|
||||||
{ title: 'Produk', href: productsIndex.url(), icon: Package },
|
{ 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: 'Supplier', href: suppliersIndex.url(), icon: Truck },
|
||||||
{ title: 'Customer', href: customersIndex.url(), icon: Users },
|
{ 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,
|
userId?: number,
|
||||||
productId?: number,
|
productId?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (type !== 'create') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = getKey(type, userId, productId);
|
const key = getKey(type, userId, productId);
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useProductDraftSave } from '@/hooks/use-product-draft';
|
import { useProductDraftSave } from '@/hooks/use-product-draft';
|
||||||
import { loadProductDraft } from '@/lib/product-draft';
|
import { clearProductDraft } from '@/lib/product-draft';
|
||||||
import { getTemporaryUrl } from '@/lib/upload';
|
|
||||||
import { index as productIndex, update } from '@/routes/admin/master/products';
|
import { index as productIndex, update } from '@/routes/admin/master/products';
|
||||||
|
|
||||||
type Category = {
|
type Category = {
|
||||||
@ -95,14 +94,12 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
|
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
|
||||||
const userId = auth.user?.id;
|
const userId = auth.user?.id;
|
||||||
|
|
||||||
const draft = loadProductDraft('edit', userId, product.id);
|
clearProductDraft('edit', userId, product.id);
|
||||||
|
|
||||||
const [productName, setProductName] = useState(
|
const [productName, setProductName] = useState(product.name);
|
||||||
draft?.productName ?? product.name,
|
const [status, setStatus] = useState<string>(product.status);
|
||||||
);
|
|
||||||
const [status, setStatus] = useState(draft?.status ?? product.status);
|
|
||||||
const [description, setDescription] = useState(
|
const [description, setDescription] = useState(
|
||||||
draft?.description ?? product.description ?? '',
|
product.description ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
const serverVariants: VariantState[] = product.product_variants.map(
|
const serverVariants: VariantState[] = product.product_variants.map(
|
||||||
@ -122,56 +119,23 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [categoryIds, setCategoryIds] = useState<number[]>(
|
const [categoryIds, setCategoryIds] = useState<number[]>(
|
||||||
draft?.categoryIds ?? product.category_ids,
|
product.category_ids,
|
||||||
);
|
);
|
||||||
const [useSamePrice, setUseSamePrice] = useState(
|
const [useSamePrice, setUseSamePrice] = useState(
|
||||||
draft?.useSamePrice ??
|
serverVariants.length > 1
|
||||||
(serverVariants.length > 1
|
? serverVariants.every((v) =>
|
||||||
? serverVariants.every((v) =>
|
arePricesEqual(v.prices, serverVariants[0].prices),
|
||||||
arePricesEqual(v.prices, serverVariants[0].prices),
|
)
|
||||||
)
|
: true,
|
||||||
: true),
|
|
||||||
);
|
);
|
||||||
const [sharedPrices, setSharedPrices] = useState<
|
const [sharedPrices, setSharedPrices] = useState<
|
||||||
Array<{ type: string; price: number }>
|
Array<{ type: string; price: number }>
|
||||||
>(
|
>(
|
||||||
draft?.sharedPrices ??
|
serverVariants.length > 0
|
||||||
(serverVariants.length > 0
|
? serverVariants[0].prices
|
||||||
? serverVariants[0].prices
|
: createEmptyPrices(),
|
||||||
: createEmptyPrices()),
|
|
||||||
);
|
);
|
||||||
const [variants, setVariants] = useState<VariantState[]>(() => {
|
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
|
return serverVariants.length > 0
|
||||||
? serverVariants
|
? 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\ProductController;
|
||||||
use App\Http\Controllers\Admin\Master\Product\ProductVariantController;
|
use App\Http\Controllers\Admin\Master\Product\ProductVariantController;
|
||||||
use App\Http\Controllers\Admin\Master\Product\StockMutationController;
|
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\Master\SupplierController;
|
||||||
use App\Http\Controllers\Admin\RoleController;
|
use App\Http\Controllers\Admin\RoleController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@ -40,6 +42,11 @@
|
|||||||
Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update');
|
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::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::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('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
|
||||||
Route::resource('customers', CustomerController::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