Compare commits

...

10 Commits

Author SHA1 Message Date
Yoga Pangestu
1d5863efaf feat: integrate activity log functionality in ProductPrice model and enhance change formatting in ActivityLogService
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run
2026-07-27 23:00:22 +07:00
Yoga Pangestu
d74d120a6c feat: refactor stock mutation relationships in ProductVariant and RawMaterialPrice models 2026-07-27 22:46:25 +07:00
Yoga Pangestu
3052398ad8 feat: add stock mutation tracking and history feature
- Introduced StockMutation model and service to handle stock changes.
- Created migration for stock_mutations table.
- Implemented stock mutation recording in various services (CuttingService, OrderService, PurchaseService, RestockService, RetailStockService).
- Added StockHistoryController to manage stock history views.
- Developed frontend components for displaying stock history and actions.
- Updated routes to include stock history access with appropriate permissions.
- Enhanced ProductVariant and RawMaterialPrice models to support stock mutations.
- Added RowHistoryAction button for accessing stock history in product and raw material tables.
2026-07-27 22:45:49 +07:00
Yoga Pangestu
7dacf785cb feat: validate material usage input before submission in CuttingPosCombinationDialog 2026-07-27 21:53:42 +07:00
Yoga Pangestu
afdd0905db feat: add status field to product variant form and ensure it defaults to 'active' 2026-07-27 21:40:03 +07:00
Yoga Pangestu
1a2b741209 feat: update ProductRequest to require images only when product is new; enhance removeItem function to allow forced removal; add sync-quantity-input event to PurchasePosCartSummaryItems; implement material search functionality in PurchasePosForm; add disabled prop to RawMaterialInfoSection; integrate variant selection in RawMaterialVariantSection 2026-07-27 21:29:55 +07:00
Yoga Pangestu
562c5721dd feat: add checkVariantUsage endpoint and implement price usage validation in RawMaterial management 2026-07-27 19:02:54 +07:00
Yoga Pangestu
fa52f177e2 feat: add row-key prop to DataTable components across multiple pages for improved key handling 2026-07-27 18:58:05 +07:00
Yoga Pangestu
ec8cb94486 feat: enhance RowDeleteAction and useDestroy for reactive URL handling; add rowKey prop to DataTable 2026-07-27 18:51:37 +07:00
Yoga Pangestu
399b444b54 feat: remove RAW_MATERIALS_DELETE permission from permissions method 2026-07-27 18:39:21 +07:00
48 changed files with 1351 additions and 120 deletions

View File

@ -95,6 +95,7 @@ public function permissions(): array
Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::ORDERS_VIEW,
@ -166,6 +167,7 @@ public function permissions(): array
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::OWNER_VERIFICATIONS_VIEW,
@ -293,7 +295,7 @@ public function permissions(): array
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::RAW_MATERIALS_DELETE,
Permission::STOCKS_VIEW,
Permission::OWNER_VERIFICATIONS_VIEW,
@ -395,6 +397,7 @@ public function permissions(): array
Permission::LEAVE_REQUESTS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_CREATE,

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Admin\Manage\Stock;
use App\Http\Controllers\Controller;
use App\Models\ProductVariant;
use App\Models\RawMaterialPrice;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class StockHistoryController extends Controller
{
public function __invoke(Request $request): Response
{
$stockableType = $request->string('stockable_type')->toString();
$stockableId = (int) $request->string('stockable_id')->toString();
$modelClass = match ($stockableType) {
'product-variant' => ProductVariant::class,
'raw-material-price' => RawMaterialPrice::class,
default => abort(404, 'Tipe stok tidak valid.'),
};
$stockable = $modelClass::with(
$stockableType === 'product-variant'
? ['product', 'stockMutations.user.profile', 'stockMutations.source']
: ['rawMaterial', 'stockMutations.user.profile', 'stockMutations.source']
)->findOrFail($stockableId);
$mutations = $stockable->stockMutations()
->with(['user.profile', 'source'])
->latest()
->paginate(50);
$title = $stockableType === 'product-variant'
? "{$stockable->product?->name} - {$stockable->name}"
: "{$stockable->rawMaterial?->name} - {$stockable->variant}";
return Inertia::render('admin/manage/stock/History', [
'title' => $title,
'stockableType' => $stockableType,
'stockableId' => $stockable->id,
'mutations' => $mutations,
]);
}
}

View File

@ -2,12 +2,14 @@
namespace App\Http\Controllers\Admin\Master;
use App\Enums\CuttingStatus;
use App\Enums\RawMaterialUnit;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\RawMaterialRequest;
use App\Http\Requests\Admin\ToggleStatusRequest;
use App\Models\CuttingMaterial;
use App\Models\RawMaterial;
use App\Services\Master\RawMaterialService;
use App\Support\Media\MediaPresenter;
@ -84,6 +86,27 @@ public function destroy(Request $request, RawMaterial $rawMaterial): RedirectRes
return redirect()->route('admin.master.raw_materials.index');
}
public function checkVariantUsage(Request $request): JsonResponse
{
$priceIds = $request->query('price_ids', []);
if (! is_array($priceIds) || $priceIds === []) {
return response()->json(['in_use' => []]);
}
$priceIds = array_map('intval', $priceIds);
$inUse = CuttingMaterial::query()
->whereIn('raw_material_price_id', $priceIds)
->whereHas('cutting', fn ($q) => $q->where('status', '!=', CuttingStatus::COMPLETED))
->pluck('raw_material_price_id')
->unique()
->values()
->toArray();
return response()->json(['in_use' => $inUse]);
}
public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user());

View File

@ -37,7 +37,7 @@ public function rules(): array
...$this->productVariantRules(
productId: $this->route('product')?->id,
imagesRequired: $this->isMethod('POST'),
imagesRequired: $this->route('product') === null,
),
];
}

View File

@ -3,19 +3,21 @@
namespace App\Models;
use App\Enums\PriceType;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Activitylog\Support\LogOptions;
#[Guarded(['id'])]
#[Appends(['price_formatted', 'price_input'])]
class ProductPrice extends Model
{
// 1. Use Trait
use HasFactory;
use HasFactory, InteractsWithActivityLog;
// 2. Casting
protected function casts(): array
@ -26,18 +28,31 @@ protected function casts(): array
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logUnguarded()
->logOnlyDirty()
->dontLogEmptyChanges()
->logExcept([
'id',
'variant_id',
'deleted_at',
]);
}
// 3. Attribute
public function priceFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->price, 0, ',', '.'),
get: fn() => 'Rp ' . number_format($this->price, 0, ',', '.'),
);
}
public function priceInput(): Attribute
{
return Attribute::make(
get: fn () => (string) $this->price,
get: fn() => (string) $this->price,
);
}

View File

@ -11,6 +11,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
@ -91,4 +92,9 @@ public function product(): BelongsTo
{
return $this->belongsTo(Product::class)->withTrashed();
}
public function stockMutations(): MorphMany
{
return $this->morphMany(StockMutation::class, 'stockable');
}
}

View File

@ -11,6 +11,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
@ -91,4 +92,9 @@ public function rawMaterial(): BelongsTo
{
return $this->belongsTo(RawMaterial::class)->withTrashed();
}
public function stockMutations(): MorphMany
{
return $this->morphMany(StockMutation::class, 'stockable');
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Appends(['created_at_formatted', 'type_label'])]
class StockMutation extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return [
'quantity' => 'decimal:4',
'stock_before' => 'decimal:4',
'stock_after' => 'decimal:4',
];
}
public function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function typeLabel(): Attribute
{
return Attribute::make(
get: fn () => match ($this->type) {
'in' => 'Masuk',
'out' => 'Keluar',
'transfer' => 'Transfer',
'adjustment' => 'Penyesuaian',
default => $this->type,
},
);
}
public function stockable(): MorphTo
{
return $this->morphTo();
}
public function source(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -30,6 +30,7 @@ class CuttingService
public function __construct(
private readonly PushNotificationService $pushNotificationService,
private readonly MediaService $mediaService,
private readonly StockMutationService $stockMutationService,
) {}
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
@ -772,22 +773,51 @@ private function deductMaterialStock(Cutting $cutting): void
]);
}
if ((float) $price->stock < $totalTaken) {
$stockBefore = (float) $price->stock;
if ($stockBefore < $totalTaken) {
throw ValidationException::withMessages([
'materials' => "Stok {$price->rawMaterial?->name} ({$price->variant}) tidak mencukupi.",
]);
}
$price->decrement('stock', $totalTaken);
$this->stockMutationService->record(
stockable: $price,
type: 'out',
quantity: -$totalTaken,
stockBefore: $stockBefore,
stockAfter: $stockBefore - $totalTaken,
source: $cutting,
description: "Cutting #{$cutting->id}",
);
}
}
private function reverseTotalMaterialStock(Cutting $cutting): void
{
foreach ($cutting->materials as $material) {
RawMaterialPrice::query()
->whereKey($material->raw_material_price_id)
->increment('stock', (float) $material->material_usage);
$price = RawMaterialPrice::query()->lockForUpdate()->find($material->raw_material_price_id);
if ($price === null) {
continue;
}
$stockBefore = (float) $price->stock;
$totalReturned = (float) $material->material_usage;
$price->increment('stock', $totalReturned);
$this->stockMutationService->record(
stockable: $price,
type: 'in',
quantity: $totalReturned,
stockBefore: $stockBefore,
stockAfter: $stockBefore + $totalReturned,
source: $cutting,
description: "Cutting #{$cutting->id} (batal)",
);
}
}

View File

@ -38,6 +38,7 @@ public function __construct(
private readonly CashService $cashService,
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
private readonly MediaService $mediaService,
private readonly StockMutationService $stockMutationService,
) {}
public function defaultPriceType(OrderChannel $channel): ?PriceType
@ -770,19 +771,45 @@ private function applyDraftPrices(EloquentCollection $items, PriceType $priceTyp
private function decrementStock(OrderItem $item): void
{
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
$column = $this->stockColumn($stockQuality);
ProductVariant::query()
->whereKey($item->product_variant_id)
->decrement($this->stockColumn($stockQuality), $item->quantity);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id);
$stockBefore = (int) $variant->{$column};
$variant->decrement($column, $item->quantity);
$this->stockMutationService->record(
stockable: $variant,
type: 'out',
quantity: -$item->quantity,
stockBefore: $stockBefore,
stockAfter: $stockBefore - $item->quantity,
stockQuality: $stockQuality->value,
source: $item->order,
description: $item->order ? "Pesanan #{$item->order->order_number}" : null,
);
}
private function incrementStock(OrderItem $item): void
{
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
$column = $this->stockColumn($stockQuality);
ProductVariant::query()
->whereKey($item->product_variant_id)
->increment($this->stockColumn($stockQuality), $item->quantity);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id);
$stockBefore = (int) $variant->{$column};
$variant->increment($column, $item->quantity);
$this->stockMutationService->record(
stockable: $variant,
type: 'in',
quantity: $item->quantity,
stockBefore: $stockBefore,
stockAfter: $stockBefore + $item->quantity,
stockQuality: $stockQuality->value,
source: $item->order,
description: $item->order ? "Pesanan #{$item->order->order_number} (batal)" : null,
);
}
private function availableStock(ProductVariant $variant, ProductStockQuality $stockQuality): int

View File

@ -29,6 +29,7 @@ class PurchaseService
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
@ -597,16 +598,38 @@ private function presentDraftItem(PurchaseItem $item): array
private function incrementStock(PurchaseItem $item): void
{
RawMaterialPrice::query()
->whereKey($item->raw_material_price_id)
->increment('stock', $item->quantity);
$price = RawMaterialPrice::query()->findOrFail($item->raw_material_price_id);
$stockBefore = (float) $price->stock;
$price->increment('stock', $item->quantity);
$this->stockMutationService->record(
stockable: $price,
type: 'in',
quantity: (float) $item->quantity,
stockBefore: $stockBefore,
stockAfter: $stockBefore + (float) $item->quantity,
source: $item->purchase,
description: $item->purchase ? "Belanja #{$item->purchase->id}" : null,
);
}
private function decrementStock(PurchaseItem $item): void
{
RawMaterialPrice::query()
->whereKey($item->raw_material_price_id)
->decrement('stock', $item->quantity);
$price = RawMaterialPrice::query()->findOrFail($item->raw_material_price_id);
$stockBefore = (float) $price->stock;
$price->decrement('stock', $item->quantity);
$this->stockMutationService->record(
stockable: $price,
type: 'out',
quantity: -((float) $item->quantity),
stockBefore: $stockBefore,
stockAfter: $stockBefore - (float) $item->quantity,
source: $item->purchase,
description: $item->purchase ? "Belanja #{$item->purchase->id}" : null,
);
}
private function notifyPurchase(string $typeLabel, string $body, string $url, ?string $userId = null): void

View File

@ -32,6 +32,7 @@ class RestockService
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
@ -529,16 +530,44 @@ private function stockColumn(ProductStockQuality $stockType): string
private function incrementStock(RestockItem $item, ProductStockQuality $stockType): void
{
ProductVariant::query()
->whereKey($item->product_variant_id)
->increment($this->stockColumn($stockType), $item->quantity);
$column = $this->stockColumn($stockType);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id);
$stockBefore = (int) $variant->{$column};
$variant->increment($column, $item->quantity);
$this->stockMutationService->record(
stockable: $variant,
type: 'in',
quantity: $item->quantity,
stockBefore: $stockBefore,
stockAfter: $stockBefore + $item->quantity,
stockQuality: $stockType->value,
source: $item->restock,
description: $item->restock ? "Restock #{$item->restock->id}" : null,
);
}
private function decrementStock(RestockItem $item, ProductStockQuality $stockType): void
{
ProductVariant::query()
->whereKey($item->product_variant_id)
->decrement($this->stockColumn($stockType), $item->quantity);
$column = $this->stockColumn($stockType);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id);
$stockBefore = (int) $variant->{$column};
$variant->decrement($column, $item->quantity);
$this->stockMutationService->record(
stockable: $variant,
type: 'out',
quantity: -$item->quantity,
stockBefore: $stockBefore,
stockAfter: $stockBefore - $item->quantity,
stockQuality: $stockType->value,
source: $item->restock,
description: $item->restock ? "Restock #{$item->restock->id} (batal)" : null,
);
}
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void

View File

@ -2,8 +2,8 @@
namespace App\Services\Manage;
use App\Models\OwnerVerificationRequest;
use App\Models\ProductVariant;
use App\Models\RetailStockHistory;
use App\Models\User;
use App\Services\System\PushNotificationService;
use Illuminate\Support\Facades\DB;
@ -13,6 +13,7 @@ class RetailStockService
{
public function __construct(
private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {}
public function transfer(int $variantId, int $quantity, User $user, ?string $notes = null): void
@ -68,23 +69,33 @@ private function executeTransfer(ProductVariant $variant, int $quantity, User $u
->lockForUpdate()
->firstOrFail();
$stockBefore = $variant->stock;
$goodStockBefore = $variant->stock;
$retailStockBefore = $variant->retail_stock;
$variant->decrement('stock', $quantity);
$variant->increment('retail_stock', $quantity);
RetailStockHistory::create([
'product_variant_id' => $variant->id,
'user_id' => $user->id,
'quantity' => $quantity,
'stock_before' => $stockBefore,
'retail_stock_before' => $retailStockBefore,
'stock_after' => $stockBefore - $quantity,
'retail_stock_after' => $retailStockBefore + $quantity,
'notes' => $notes,
'created_at' => now(),
]);
$this->stockMutationService->record(
stockable: $variant,
type: 'out',
quantity: -$quantity,
stockBefore: $goodStockBefore,
stockAfter: $goodStockBefore - $quantity,
stockQuality: 'good',
description: $notes ? "Transfer ke stok ecer: {$notes}" : 'Transfer ke stok ecer',
user: $user,
);
$this->stockMutationService->record(
stockable: $variant,
type: 'in',
quantity: $quantity,
stockBefore: $retailStockBefore,
stockAfter: $retailStockBefore + $quantity,
stockQuality: 'retail',
description: $notes ? "Transfer dari stok bagus: {$notes}" : 'Transfer dari stok bagus',
user: $user,
);
});
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Services\Manage;
use App\Models\StockMutation;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class StockMutationService
{
public function record(
Model $stockable,
string $type,
float|int $quantity,
float|int $stockBefore,
float|int $stockAfter,
?Model $source = null,
?string $stockQuality = null,
?string $description = null,
?User $user = null,
): StockMutation {
return StockMutation::create([
'stockable_type' => $stockable->getMorphClass(),
'stockable_id' => $stockable->id,
'type' => $type,
'source_type' => $source?->getMorphClass(),
'source_id' => $source?->id,
'quantity' => $quantity,
'stock_before' => $stockBefore,
'stock_after' => $stockAfter,
'stock_quality' => $stockQuality,
'description' => $description,
'user_id' => $user?->id ?? auth()->id(),
]);
}
}

View File

@ -23,6 +23,7 @@ class StokOpnameService
public function __construct(
private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {}
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
@ -259,9 +260,25 @@ public function verify(StokOpname $stokOpname, User $user, ?string $verification
ProductStockQuality::RETAIL => 'retail_stock',
ProductStockQuality::REJECT => 'reject_stock',
};
$item->productVariant()->update([
$variant = $item->productVariant;
$stockBefore = (int) $variant->{$column};
$variant->update([
$column => $item->physical_stock,
]);
$this->stockMutationService->record(
stockable: $variant,
type: 'adjustment',
quantity: $item->difference,
stockBefore: $stockBefore,
stockAfter: $item->physical_stock,
stockQuality: $item->stock_quality->value,
source: $stokOpname,
description: "Stok Opname #{$stokOpname->id}",
user: $user,
);
}
}

View File

@ -13,6 +13,7 @@
use App\Models\User;
use App\Services\Concerns\CachesQuery;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Manage\StockMutationService;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
@ -29,6 +30,7 @@ class ProductService
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {}
public function paginateForIndex(array $tableQuery, string $status, string $categoryId = '', string $stockStatus = '', string $productId = ''): LengthAwarePaginator
@ -145,6 +147,8 @@ function () use ($validated, $user, $isOwner, $isDraft): Product {
'retail_stock' => $variantData['retail_stock'],
]);
$this->recordVariantStockMutations($variant, $variantData);
$this->syncVariantImages($variant, $variantData, $index);
if (! empty($variantData['prices'])) {
@ -220,38 +224,6 @@ function () use ($validated, $product, $user, $canEditDirectly): void {
if ($canEditDirectly) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToProduct($product, $payload);
foreach ($validated['variants'] as $index => $variantData) {
if (! empty($variantData['id'])) {
$variant = $product->variants()->find($variantData['id']);
if ($variant) {
$this->syncVariantImages($variant, $variantData, $index);
if (! empty($variantData['prices'])) {
foreach ($variantData['prices'] as $type => $priceValue) {
$variant->prices()->updateOrCreate(
['type' => $type],
['price' => $priceValue]
);
}
}
}
} else {
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'retail_stock' => $variantData['retail_stock'],
]);
$this->syncVariantImages($variant, $variantData, $index);
if (! empty($variantData['prices'])) {
foreach ($variantData['prices'] as $type => $priceValue) {
$variant->prices()->create([
'type' => $type,
'price' => $priceValue,
]);
}
}
}
}
} else {
$verificationRequest = OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::UPDATE,
@ -572,6 +544,9 @@ private function applyPayloadToProduct(
foreach ($payload['variants'] ?? [] as $index => $variantData) {
if (! empty($variantData['id'])) {
$variant = $product->variants()->findOrFail($variantData['id']);
$this->recordVariantStockChanges($variant, $variantData);
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
@ -581,6 +556,8 @@ private function applyPayloadToProduct(
if ($verificationRequest !== null) {
$this->applyVariantImageChanges($verificationRequest, $variant, $variantData, (int) $index);
} else {
$this->syncVariantImages($variant, $variantData, $index);
}
if (! empty($variantData['prices'])) {
@ -602,8 +579,12 @@ private function applyPayloadToProduct(
'retail_stock' => $variantData['retail_stock'],
]);
$this->recordVariantStockMutations($variant, $variantData);
if ($verificationRequest !== null) {
$this->copyRequestVariantImages($verificationRequest, (int) $index, $variant);
} else {
$this->syncVariantImages($variant, $variantData, $index);
}
if (! empty($variantData['prices'])) {
@ -803,6 +784,8 @@ private function buildPayloadFromValidated(array $validated): array
'retail_stock' => $variantData['retail_stock'],
'prices' => $variantData['prices'] ?? [],
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
'images' => $variantData['images'] ?? null,
's3_keys' => $variantData['s3_keys'] ?? null,
])
->all(),
];
@ -812,6 +795,7 @@ private function syncVariantImages(
ProductVariant $variant,
array $variantData,
int $index,
bool $required = true,
): void {
$this->mediaService->syncCollection(
$variant,
@ -819,7 +803,7 @@ private function syncVariantImages(
$variantData['images'] ?? null,
$variantData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES,
required: true,
required: $required,
errorKey: "variants.{$index}.s3_keys",
s3Keys: $variantData['s3_keys'] ?? null,
);
@ -878,4 +862,59 @@ private function applySorting(Builder $query, string $sort, string $direction):
$query->latest();
}
private function recordVariantStockMutations(ProductVariant $variant, array $variantData): void
{
$stockQualities = [
['column' => 'stock', 'quality' => 'good'],
['column' => 'reject_stock', 'quality' => 'reject'],
['column' => 'retail_stock', 'quality' => 'retail'],
];
foreach ($stockQualities as $sq) {
$value = (int) ($variantData[$sq['column']] ?? 0);
if ($value <= 0) {
continue;
}
$this->stockMutationService->record(
stockable: $variant,
type: 'adjustment',
quantity: $value,
stockBefore: 0,
stockAfter: $value,
stockQuality: $sq['quality'],
description: 'Stok awal dari master data',
);
}
}
private function recordVariantStockChanges(ProductVariant $variant, array $variantData): void
{
$stockQualities = [
['column' => 'stock', 'quality' => 'good'],
['column' => 'reject_stock', 'quality' => 'reject'],
['column' => 'retail_stock', 'quality' => 'retail'],
];
foreach ($stockQualities as $sq) {
$oldValue = (int) $variant->{$sq['column']};
$newValue = (int) ($variantData[$sq['column']] ?? 0);
if ($newValue === $oldValue) {
continue;
}
$this->stockMutationService->record(
stockable: $variant,
type: 'adjustment',
quantity: $newValue - $oldValue,
stockBefore: $oldValue,
stockAfter: $newValue,
stockQuality: $sq['quality'],
description: 'Edit langsung dari master data',
);
}
}
}

View File

@ -12,6 +12,7 @@
use App\Models\User;
use App\Services\Concerns\CachesQuery;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Manage\StockMutationService;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
@ -28,6 +29,7 @@ class RawMaterialService
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {}
public function paginateForIndex(array $tableQuery, string $isActive, string $stockStatus = '', string $rawMaterialId = ''): LengthAwarePaginator
@ -313,6 +315,27 @@ private function ensureNotUsedInActiveCutting(RawMaterial $rawMaterial): void
}
}
private function ensurePricesNotUsedInActiveCutting(array $priceIds): void
{
$usedPriceIds = CuttingMaterial::query()
->whereIn('raw_material_price_id', $priceIds)
->whereHas('cutting', fn ($q) => $q->where('status', '!=', CuttingStatus::COMPLETED))
->pluck('raw_material_price_id')
->unique()
->toArray();
if ($usedPriceIds !== []) {
$variantNames = RawMaterialPrice::whereIn('id', $usedPriceIds)
->pluck('variant')
->unique()
->implode(', ');
throw ValidationException::withMessages([
'prices' => "Variant '{$variantNames}' tidak dapat dihapus karena masih digunakan dalam proses cutting yang belum selesai.",
]);
}
}
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
@ -353,6 +376,15 @@ private function applyPayloadToRawMaterial(
->map(fn ($id) => (int) $id)
->all();
$deletingPriceIds = $rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->pluck('id')
->toArray();
if ($deletingPriceIds !== []) {
$this->ensurePricesNotUsedInActiveCutting($deletingPriceIds);
}
$rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->get()
@ -363,6 +395,19 @@ private function applyPayloadToRawMaterial(
foreach ($payload['prices'] ?? [] as $index => $priceData) {
if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->findOrFail($priceData['id']);
$oldStock = (float) $price->stock;
if ((float) $priceData['stock'] !== $oldStock) {
$this->stockMutationService->record(
stockable: $price,
type: 'adjustment',
quantity: (float) $priceData['stock'] - $oldStock,
stockBefore: $oldStock,
stockAfter: (float) $priceData['stock'],
description: 'Edit langsung dari master data',
);
}
$price->update([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
@ -384,6 +429,15 @@ private function applyPayloadToRawMaterial(
'stock' => $priceData['stock'],
]);
$this->stockMutationService->record(
stockable: $price,
type: 'adjustment',
quantity: (float) $priceData['stock'],
stockBefore: 0,
stockAfter: (float) $priceData['stock'],
description: 'Stok awal dari master data',
);
if ($verificationRequest !== null) {
$this->copyRequestPriceImages($verificationRequest, (int) $index, $price);
} elseif (isset($originalPrices[$index])) {
@ -519,6 +573,15 @@ private function createPrice(RawMaterial $rawMaterial, array $priceData, int $in
'stock' => $priceData['stock'],
]);
$this->stockMutationService->record(
stockable: $price,
type: 'adjustment',
quantity: (float) $priceData['stock'],
stockBefore: 0,
stockAfter: (float) $priceData['stock'],
description: 'Stok awal dari master data',
);
$this->syncPriceImages($price, $priceData, $index);
return $price;

View File

@ -4,6 +4,7 @@
use App\Enums\ActivityEventLabel;
use App\Models\User;
use App\Support\ActivityLog\FieldLabel;
use App\Support\ActivityLog\ModelLabel;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@ -62,18 +63,32 @@ private function formatChanges(?Collection $changes): array
$attributes = $changes->get('attributes', []);
$old = $changes->get('old', []);
if (! is_array($attributes)) {
return [];
}
$formatted = [];
foreach ($attributes as $field => $newValue) {
$formatted[] = [
'field' => (string) $field,
'old' => is_array($old) ? ($old[$field] ?? null) : null,
'new' => $newValue,
];
if (is_array($attributes) && count($attributes) > 0) {
foreach ($attributes as $field => $newValue) {
if (! FieldLabel::isFormInput((string) $field)) {
continue;
}
$formatted[] = [
'field' => FieldLabel::for((string) $field),
'old' => is_array($old) ? ($old[$field] ?? null) : null,
'new' => $newValue,
];
}
} elseif (is_array($old) && count($old) > 0) {
foreach ($old as $field => $oldValue) {
if (! FieldLabel::isFormInput((string) $field)) {
continue;
}
$formatted[] = [
'field' => FieldLabel::for((string) $field),
'old' => $oldValue,
'new' => null,
];
}
}
return $formatted;

View File

@ -0,0 +1,297 @@
<?php
namespace App\Support\ActivityLog;
class FieldLabel
{
/**
* @var array<string, string>
*/
private const LABELS = [
// Common
'name' => 'Nama',
'description' => 'Deskripsi',
'status' => 'Status',
'amount' => 'Jumlah',
'quantity' => 'Jumlah',
'price' => 'Harga',
'balance' => 'Saldo',
'discount' => 'Diskon',
'notes' => 'Keterangan',
'reason' => 'Alasan',
'type' => 'Jenis',
'unit' => 'Satuan',
'stock' => 'Stok',
'retail_stock' => 'Stok Ecer',
'reject_stock' => 'Stok Reject',
// Product
'category_id' => 'Kategori',
'category_ids' => 'Kategori',
'product_variant_id' => 'Varian Produk',
'variants' => 'Varian',
'prices' => 'Harga',
'is_active' => 'Status Aktif',
// Order
'customer_id' => 'Pelanggan',
'marketing_id' => 'Marketing',
'channel' => 'Channel',
'price_type' => 'Tipe Harga',
'payment_type' => 'Tipe Pembayaran',
'is_affiliate' => 'Pesanan Afiliasi',
'tiktok_order_id' => 'ID Pesanan TikTok Shop',
'shopee_order_id' => 'ID Pesanan Shopee',
'nego_price' => 'Harga Nego',
'items' => 'Item',
'subtotal' => 'Subtotal',
'total' => 'Total',
'shipping_cost' => 'Ongkir',
// Purchase
'supplier_id' => 'Supplier',
// Cutting
'materials' => 'Bahan Baku',
'results' => 'Hasil Produk',
'sewing_cost' => 'Jasa Jahit',
'other_cost' => 'Biaya Lainnya',
'material_usage' => 'Pemakaian',
'material_result' => 'Hasil',
'combination_material_result' => 'Hasil Kombinasi',
'cutting_result' => 'Hasil',
'sample' => 'Sample',
'original_outside_sample' => 'Diluar Sample',
'product_name' => 'Nama Produk',
// Employee / HR
'email' => 'Email',
'username' => 'Username',
'full_name' => 'Nama Lengkap',
'phone_number' => 'Nomor Telepon',
'gender' => 'Jenis Kelamin',
'birth_date' => 'Tanggal Lahir',
'address' => 'Alamat',
'role' => 'Role',
'join_date' => 'Tanggal Bergabung',
'employment_status' => 'Status Kepegawaian',
'base_salary' => 'Gaji Pokok',
'start_date' => 'Tanggal Mulai',
'end_date' => 'Tanggal Selesai',
'due_date' => 'Jatuh Tempo',
'opname_date' => 'Tanggal Opname',
'stock_type' => 'Tipe Stok',
'physical_stock' => 'Stok Fisik',
// Finance
'payment' => 'Pembayaran',
'paid_amount' => 'Jumlah Bayar',
// Attendance
'photo' => 'Foto',
'latitude' => 'Latitude',
'longitude' => 'Longitude',
'check_in' => 'Jam Masuk',
'check_out' => 'Jam Pulang',
// System
'app_name' => 'Nama Aplikasi',
'about_app' => 'Tentang Aplikasi',
'phone' => 'Nomor Telepon',
'logo' => 'Logo',
'favicon' => 'Favicon',
'login_cover' => 'Cover Login',
'permissions' => 'Hak Akses',
'appearance' => 'Tampilan',
'hero_image' => 'Foto Hero',
'about_image' => 'Foto Tentang Kami',
'gallery_s3_keys' => 'Koleksi Lookbook',
'profile_s3_key' => 'Foto Profil',
'profile_photo' => 'Foto Profil',
// Social media
'instagram_url' => 'Instagram',
'facebook_url' => 'Facebook',
'tiktok_url' => 'TikTok',
// HR Settings
'scheduled_check_in_time' => 'Jam Masuk Kerja',
'scheduled_check_out_time' => 'Jam Pulang Kerja',
'late_penalty_amount' => 'Denda Keterlambatan',
'absent_penalty_amount' => 'Denda Bolos',
// Marketplace fees
'tiktok_shop_platform_commission' => 'TikTok Shop - Komisi Platform',
'tiktok_shop_logistics_service_fee' => 'TikTok Shop - Biaya Logistik',
'tiktok_shop_dynamic_commission' => 'TikTok Shop - Komisi Dinamis',
'tiktok_shop_order_processing_fee' => 'TikTok Shop - Biaya Proses Pesanan',
'tiktok_shop_affiliate' => 'TikTok Shop - Komisi Affiliate',
'tiktok_shop_pre_order_service_fee' => 'TikTok Shop - Biaya Pre Order',
'shopee_admin_fee' => 'Shopee - Biaya Admin',
'shopee_program_fee' => 'Shopee - Biaya Program',
'shopee_shipping_savings' => 'Shopee - Hemat Biaya Kirim',
'shopee_premium' => 'Shopee - Premi',
'shopee_service_fee' => 'Shopee - Biaya Layanan',
'shopee_order_processing_fee' => 'Shopee - Biaya Proses Pesanan',
'shopee_ams_commission_fee' => 'Shopee - Komisi AMS',
'shopee_pre_order' => 'Shopee - Pre Order',
'shopee_live_extra' => 'Shopee - Live Extra',
// Media
's3_keys' => 'Foto',
'remove_media_ids' => 'Media yang Dihapus',
];
/**
* Field names that appear as form inputs in CRUD operations.
* Auto-generated/computed fields (slug, subtotal, total, etc.) are excluded.
*
* @var array<string, true>
*/
private const FORM_INPUT_FIELDS = [
// Common
'name' => true,
'description' => true,
'status' => true,
'amount' => true,
'quantity' => true,
'price' => true,
'balance' => true,
'discount' => true,
'notes' => true,
'reason' => true,
'type' => true,
'unit' => true,
'stock' => true,
'retail_stock' => true,
'reject_stock' => true,
// Product
'category_id' => true,
'category_ids' => true,
'product_variant_id' => true,
'variants' => true,
'prices' => true,
'is_active' => true,
// Order
'customer_id' => true,
'marketing_id' => true,
'channel' => true,
'price_type' => true,
'payment_type' => true,
'is_affiliate' => true,
'tiktok_order_id' => true,
'shopee_order_id' => true,
'nego_price' => true,
'items' => true,
'shipping_cost' => true,
// Purchase
'supplier_id' => true,
'raw_material_id' => true,
'raw_material_price_id' => true,
// Cutting
'materials' => true,
'results' => true,
'sewing_cost' => true,
'other_cost' => true,
'material_usage' => true,
'material_result' => true,
'combination_material_result' => true,
'cutting_result' => true,
'sample' => true,
'original_outside_sample' => true,
'product_name' => true,
// Employee / HR
'email' => true,
'username' => true,
'full_name' => true,
'phone_number' => true,
'gender' => true,
'birth_date' => true,
'address' => true,
'role' => true,
'join_date' => true,
'employment_status' => true,
'base_salary' => true,
'start_date' => true,
'end_date' => true,
'due_date' => true,
'opname_date' => true,
'stock_type' => true,
'physical_stock' => true,
// Finance
'payment' => true,
'paid_amount' => true,
// Attendance
'photo' => true,
'latitude' => true,
'longitude' => true,
'check_in' => true,
'check_out' => true,
// System
'app_name' => true,
'about_app' => true,
'phone' => true,
'logo' => true,
'favicon' => true,
'login_cover' => true,
'permissions' => true,
'appearance' => true,
'hero_image' => true,
'about_image' => true,
'gallery_s3_keys' => true,
'profile_s3_key' => true,
'profile_photo' => true,
'unit_price' => true,
'variant' => true,
// Social media
'instagram_url' => true,
'facebook_url' => true,
'tiktok_url' => true,
// HR Settings
'scheduled_check_in_time' => true,
'scheduled_check_out_time' => true,
'late_penalty_amount' => true,
'absent_penalty_amount' => true,
// Marketplace fees
'tiktok_shop_platform_commission' => true,
'tiktok_shop_logistics_service_fee' => true,
'tiktok_shop_dynamic_commission' => true,
'tiktok_shop_order_processing_fee' => true,
'tiktok_shop_affiliate' => true,
'tiktok_shop_pre_order_service_fee' => true,
'shopee_admin_fee' => true,
'shopee_program_fee' => true,
'shopee_shipping_savings' => true,
'shopee_premium' => true,
'shopee_service_fee' => true,
'shopee_order_processing_fee' => true,
'shopee_ams_commission_fee' => true,
'shopee_pre_order' => true,
'shopee_live_extra' => true,
// Media
's3_keys' => true,
'remove_media_ids' => true,
];
public static function for(string $field): string
{
return self::LABELS[$field] ?? ucfirst(str_replace('_', ' ', $field));
}
public static function isFormInput(string $field): bool
{
return isset(self::FORM_INPUT_FIELDS[$field]);
}
}

View File

@ -20,6 +20,7 @@
use App\Models\PayrollAdjustment;
use App\Models\PayrollPeriod;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\PurchaseItem;
@ -58,6 +59,7 @@ class ModelLabel
PayrollAdjustment::class => 'Penyesuaian Gaji',
PayrollPeriod::class => 'Periode Gaji',
Product::class => 'Produk',
ProductPrice::class => 'Harga Produk',
ProductVariant::class => 'Varian Produk',
Purchase::class => 'Belanja',
PurchaseItem::class => 'Item Belanja',

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('stock_mutations', function (Blueprint $table) {
$table->id();
$table->morphs('stockable');
$table->string('type');
$table->nullableMorphs('source');
$table->decimal('quantity', 18, 4);
$table->decimal('stock_before', 18, 4);
$table->decimal('stock_after', 18, 4);
$table->string('stock_quality')->nullable();
$table->string('description')->nullable();
$table->foreignId('user_id')->constrained();
$table->timestamps();
$table->index(['stockable_type', 'stockable_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('stock_mutations');
}
};

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { Trash2 } from '@lucide/vue';
import { toRef } from 'vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@ -19,7 +20,7 @@ const props = defineProps<{
}>();
const { open, processing, destroy } = useDestroy({
url: props.actionUrl,
url: toRef(props, 'actionUrl'),
errorMessage: props.errorMessage ?? 'Gagal menghapus data.',
onSuccess: props.onSuccess,
onError: props.onError,

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { History } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
defineProps<{
href?: string;
tooltip?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled" :as-child="!!href && !disabled"
@click="!href && !disabled && emit('click')">
<Link v-if="href && !disabled" :href="href">
<History class="size-4" />
<span class="sr-only">{{ tooltip || 'Riwayat Stok' }}</span>
</Link>
<template v-else>
<History class="size-4" />
<span class="sr-only">{{ tooltip || 'Riwayat Stok' }}</span>
</template>
</Button>
</TooltipTrigger>
<TooltipContent>{{ tooltip || 'Riwayat Stok' }}</TooltipContent>
</Tooltip>
</template>

View File

@ -10,5 +10,6 @@ export { default as RowPrintAction } from './RowPrintAction.vue';
export { default as RowDetailAction } from './RowDetailAction.vue';
export { default as RowTransferAction } from './RowTransferAction.vue';
export { default as RowShareAction } from './RowShareAction.vue';
export { default as RowHistoryAction } from './RowHistoryAction.vue';
export { default as CreateButton } from './CreateButton.vue';
export { default as BackButton } from './BackButton.vue';

View File

@ -37,6 +37,7 @@ const props = withDefaults(
paginationDisplayedCount?: number;
paginationItemLabel?: string;
getRowClassName?: (row: TData, index: number) => string | undefined;
rowKey?: string | ((row: TData, index: number) => string | number);
loading?: boolean;
}>(),
{
@ -76,6 +77,18 @@ const resolvedColumns = computed(() => (
props.showRowNumber ? [rowNumberColumn, ...props.columns] : props.columns
));
function getRowId(row: TData, index: number): string {
if (!props.rowKey) {
return String(index);
}
if (typeof props.rowKey === 'function') {
return String(props.rowKey(row, index));
}
return String((row as Record<string, unknown>)[props.rowKey as string]);
}
const table = useVueTable({
get data() {
return props.data;
@ -85,6 +98,7 @@ const table = useVueTable({
},
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
getRowId,
});
function handleSort(column: string): void {

View File

@ -1,9 +1,9 @@
import { router } from '@inertiajs/vue3';
import { ref } from 'vue';
import { computed, ref, type Ref } from 'vue';
import { toast } from 'vue-sonner';
interface UseDestroyOptions {
url: string;
url: string | Ref<string>;
preserveScroll?: boolean;
errorMessage?: string;
onSuccess?: () => void;
@ -13,11 +13,12 @@ interface UseDestroyOptions {
export function useDestroy({ url, preserveScroll = true, errorMessage, onSuccess, onError }: UseDestroyOptions) {
const open = ref(false);
const processing = ref(false);
const resolvedUrl = computed(() => (typeof url === 'string' ? url : url.value));
function destroy() {
processing.value = true;
router.delete(url, {
router.delete(resolvedUrl.value, {
preserveScroll,
onSuccess: () => {
open.value = false;

View File

@ -21,8 +21,8 @@ export function useVariantList<T extends VariantItem>(
items.value = [...items.value, createEmpty()];
}
function removeItem(clientId: string) {
if (items.value.length <= 1) {
function removeItem(clientId: string, force = false) {
if (!force && items.value.length <= 1) {
return;
}

View File

@ -151,7 +151,7 @@ watch(
<CardContent class="min-w-0">
<DataTable v-model:search="search" :columns="columns" :data="transactions.data" :pagination="pagination"
:pagination-links="transactions.links" :sort="currentSort" :filter-defs="filterDefs"
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"
:filter-values="filterValues" row-key="id" @sort-change="setSort" @filter-change="setFilter"
@filters-reset="resetFilters" />
</CardContent>
</Card>

View File

@ -156,6 +156,7 @@ watch(
:sort="currentSort"
:filter-defs="filterDefs"
:filter-values="filterValues"
row-key="id"
@sort-change="setSort"
@filter-change="setFilter"
@filters-reset="resetFilters"

View File

@ -107,6 +107,7 @@ watch(
:pagination="pagination"
:pagination-links="expenses.links"
:sort="currentSort"
row-key="id"
@sort-change="setSort"
@filters-reset="resetFilters"
/>

View File

@ -128,7 +128,7 @@ watch(
<CardContent class="min-w-0">
<DataTable v-model:search="search" :columns="columns" :data="employees.data" :pagination="pagination"
:pagination-links="employees.links" :sort="currentSort" :filter-defs="filterDefs"
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"
:filter-values="filterValues" row-key="id" @sort-change="setSort" @filter-change="setFilter"
@filters-reset="resetFilters" />
</CardContent>
</Card>

View File

@ -104,7 +104,7 @@ watch(
<CardContent class="min-w-0 pt-6">
<DataTable v-model:search="search" :columns="columns" :data="leaveRequests.data"
:pagination="pagination" :pagination-links="leaveRequests.links" :sort="currentSort"
@sort-change="setSort" @filters-reset="resetFilters" />
row-key="id" @sort-change="setSort" @filters-reset="resetFilters" />
</CardContent>
</Card>

View File

@ -160,6 +160,14 @@ async function submit() {
return;
}
for (const item of selectedMaterials.value) {
if (!item.material_usage || item.material_usage.trim() === '') {
toast.error(`Pemakaian untuk ${item.raw_material_name} - ${item.variant} wajib diisi.`);
return;
}
}
loading.value = true;
try {

View File

@ -24,6 +24,7 @@ const emit = defineEmits<{
remove: [index: number];
'adjust-quantity': [index: number, delta: number];
'sync-quantity': [index: number];
'sync-quantity-input': [index: number];
}>();
</script>
@ -82,6 +83,7 @@ const emit = defineEmits<{
<DecimalInput
v-model="item.quantity"
class="h-8 text-center"
@input="emit('sync-quantity-input', index)"
@change="emit('sync-quantity', index)"
/>
<Button

View File

@ -1,12 +1,13 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Plus, ShoppingCart } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { Check, Plus, Search, ShoppingCart } from '@lucide/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { apiFetch } from '@/lib/api';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
FieldError,
FieldGroup,
@ -111,13 +112,7 @@ const {
}));
}
return [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}];
return [];
}
);
@ -226,7 +221,7 @@ async function handleRemovePrice() {
console.error(error);
}
}
removePrice(clientId);
removePrice(clientId, true);
}
priceToDelete.value = null;
@ -253,9 +248,19 @@ function adjustQuantity(index: number, delta: number) {
}
}
function syncCartInput(index: number) {
const cartItem = cart.value[index];
const price = prices.value[index];
if (price && cartItem) {
price.stock = cartItem.quantity;
}
}
function syncCartItemQuantity(index: number) {
const price = prices.value[index];
if (price) {
const cartItem = cart.value[index];
if (price && cartItem) {
price.stock = cartItem.quantity;
debouncedSave(price);
}
}
@ -448,7 +453,133 @@ const catalogMaterialNames = computed(() => {
return [...new Set(props.catalog.map((c) => c.name))];
});
// Search existing raw materials from catalog
const materialSearch = ref('');
const materialSearchResults = ref<Array<{
priceId: number;
rawMaterialId: number;
name: string;
unit: string;
variant: string;
price: number;
priceFormatted: string;
stockFormatted: string;
images: MediaItem[];
}>>([]);
const searchContainerRef = ref<HTMLElement | null>(null);
const isExistingMaterial = ref(false);
const selectedMaterialId = ref<number | null>(null);
const selectedMaterialVariants = computed(() => {
if (!selectedMaterialId.value) return [];
const mat = props.catalog.find((r) => r.id === selectedMaterialId.value);
return mat ? mat.prices.map((p) => ({ value: p.variant, label: p.variant })) : [];
});
watch(() => prices.value.length, (len) => {
if (len === 0) {
isExistingMaterial.value = false;
selectedMaterialId.value = null;
materialSearch.value = '';
materialSearchResults.value = [];
}
});
function onDocumentClick(e: MouseEvent) {
if (searchContainerRef.value && !searchContainerRef.value.contains(e.target as Node)) {
materialSearchResults.value = [];
}
}
onMounted(() => document.addEventListener('click', onDocumentClick));
onUnmounted(() => document.removeEventListener('click', onDocumentClick));
function onMaterialSearch() {
const keyword = materialSearch.value.trim().toLowerCase();
const results: typeof materialSearchResults.value[0][] = [];
if (selectedMaterialId.value !== null) {
const mat = props.catalog.find((r) => r.id === selectedMaterialId.value);
if (mat) {
for (const price of mat.prices) {
if (!keyword || price.variant.toLowerCase().includes(keyword)) {
results.push({
priceId: price.id,
rawMaterialId: mat.id,
name: mat.name,
unit: mat.unit,
variant: price.variant,
price: price.price,
priceFormatted: price.price_formatted,
stockFormatted: price.stock_formatted,
images: price.images ?? [],
});
}
}
}
} else {
if (!keyword) {
materialSearchResults.value = [];
return;
}
for (const rm of props.catalog) {
for (const price of rm.prices) {
if (rm.name.toLowerCase().includes(keyword) || price.variant.toLowerCase().includes(keyword)) {
results.push({
priceId: price.id,
rawMaterialId: rm.id,
name: rm.name,
unit: rm.unit,
variant: price.variant,
price: price.price,
priceFormatted: price.price_formatted,
stockFormatted: price.stock_formatted,
images: price.images ?? [],
});
}
}
}
}
materialSearchResults.value = results.slice(0, 20);
}
function selectExistingMaterial(result: (typeof materialSearchResults.value)[0]) {
if (selectedMaterialId.value !== result.rawMaterialId) {
isExistingMaterial.value = true;
selectedMaterialId.value = result.rawMaterialId;
form.name = result.name;
form.unit = result.unit;
}
prices.value.push({
client_id: createClientId(),
id: result.priceId,
variant: result.variant,
price: String(result.price),
stock: '0',
media: createMediaUploadState(result.images),
});
toast.success(`"${result.name}${result.variant}" ditambahkan ke keranjang.`);
const lastPrice = prices.value[prices.value.length - 1];
if (lastPrice) {
debouncedSave(lastPrice);
}
onMaterialSearch();
}
function isInCart(priceId: number): boolean {
return prices.value.some((p) => p.id === priceId);
}
function onMaterialNameInput() {
isExistingMaterial.value = false;
selectedMaterialId.value = null;
const matched = props.catalog.find(
(c) => c.name.toLowerCase() === form.name.trim().toLowerCase()
);
@ -564,13 +695,7 @@ function populateForm() {
} else {
form.name = '';
form.unit = 'yard';
prices.value = [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}];
prices.value = [];
}
}
@ -674,7 +799,40 @@ function submit() {
<div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_380px]">
<!-- Left Column: Form Info & Variants (styled exactly like RawMaterialForm.vue) -->
<div class="space-y-6">
<RawMaterialInfoSection :form="form" :units="units" method="post" />
<!-- Search existing raw materials -->
<div ref="searchContainerRef" class="relative">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="materialSearch"
:placeholder="selectedMaterialId ? 'Cari varian dari ' + (props.catalog.find(r => r.id === selectedMaterialId)?.name ?? '') + '...' : 'Cari bahan baku yang sudah ada...'"
class="pl-9" @input="onMaterialSearch" />
</div>
<div v-if="materialSearchResults.length > 0" class="absolute left-0 right-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-popover shadow-md">
<div v-for="result in materialSearchResults" :key="result.priceId"
class="flex items-center gap-3 px-3 py-2 text-sm"
:class="isInCart(result.priceId)
? 'cursor-default opacity-60'
: 'cursor-pointer hover:bg-accent'"
@click="!isInCart(result.priceId) && selectExistingMaterial(result)"
>
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{{ selectedMaterialId ? '' : result.name + ' → ' }}{{ result.variant }}</p>
<p class="text-xs text-muted-foreground">
Stok: {{ result.stockFormatted }} | Harga: {{ result.priceFormatted }}
</p>
</div>
<Button v-if="isInCart(result.priceId)" type="button" variant="ghost" size="icon-sm" disabled>
<Check class="size-3.5 text-primary" />
</Button>
<Button v-else type="button" variant="outline" size="icon-sm">
<Plus class="size-3.5" />
</Button>
</div>
</div>
</div>
<RawMaterialInfoSection :form="form" :units="units" method="post" :disabled="isExistingMaterial" />
<RawMaterialSharedPriceSection :form="form" :prices="prices" :use-same-price="useSamePrice"
@toggle-use-same-price="handleToggleUseSamePrice" @set-shared-price="handleSetSharedPrice" />
@ -682,6 +840,7 @@ function submit() {
<RawMaterialVariantSection v-for="(price, index) in prices" :key="price.client_id" :form="form"
:price="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
:variant-options="selectedMaterialVariants"
@remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="handleApplyPriceToAll(price.client_id)"
@update:variant="handleUpdateVariant(price.client_id, $event)"
@update:stock="handleUpdateStock(price.client_id, $event)"
@ -721,6 +880,7 @@ function submit() {
@remove="removeFromCart"
@adjust-quantity="adjustQuantity"
@sync-quantity="syncCartItemQuantity"
@sync-quantity-input="syncCartInput"
/>
<!-- Totals, Discount, Shipping, Notes, Photo dropzone, Submit -->

View File

@ -0,0 +1,129 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import BackButton from '@/components/button/BackButton.vue';
import DataTable from '@/components/data-table/DataTable.vue';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index as productsIndex } from '@/routes/admin/master/products';
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw_materials';
import type { PaginatedStockMutations, StockMutation } from '@/types/stock-mutation';
const props = defineProps<{
title: string;
stockableType: string;
stockableId: number;
mutations: PaginatedStockMutations;
}>();
const backUrl = props.stockableType === 'product-variant'
? productsIndex.url()
: rawMaterialsIndex.url();
const pagination = {
currentPage: props.mutations.current_page,
perPage: props.mutations.per_page,
lastPage: props.mutations.last_page,
total: props.mutations.total,
};
const typeBadgeVariant = (type: string): string => {
const map: Record<string, string> = {
in: 'success',
out: 'destructive',
transfer: 'info',
adjustment: 'warning',
};
return map[type] ?? 'secondary';
};
const columns: ColumnDef<StockMutation>[] = [
{
accessorKey: 'created_at_formatted',
header: 'Tanggal',
cell: ({ row }) => row.original.created_at_formatted ?? '-',
},
{
accessorKey: 'type_label',
header: 'Tipe',
cell: ({ row }) => h(Badge, {
variant: typeBadgeVariant(row.original.type),
}, () => row.original.type_label),
},
{
accessorKey: 'description',
header: 'Keterangan',
cell: ({ row }) => {
const mutation = row.original;
const parts = [mutation.description ?? '-'];
if (mutation.stock_quality) {
const qualityLabels: Record<string, string> = {
good: 'Stok Bagus',
reject: 'Stok Reject',
retail: 'Stok Ecer',
};
parts.push(qualityLabels[mutation.stock_quality] ?? mutation.stock_quality);
}
return parts.join(' — ');
},
},
{
accessorKey: 'quantity',
header: 'Qty',
cell: ({ row }) => {
const qty = Number(row.original.quantity);
const prefix = qty >= 0 ? '+' : '';
const cls = qty > 0 ? 'text-green-600 font-medium tabular-nums'
: qty < 0 ? 'text-red-600 font-medium tabular-nums'
: 'tabular-nums';
return h('span', { class: cls }, `${prefix}${qty.toLocaleString('id-ID')}`);
},
},
{
accessorKey: 'stock_before',
header: 'Stok Sebelum',
cell: ({ row }) => h('span', { class: 'tabular-nums' }, Number(row.original.stock_before).toLocaleString('id-ID')),
},
{
accessorKey: 'stock_after',
header: 'Stok Sesudah',
cell: ({ row }) => h('span', { class: 'tabular-nums' }, Number(row.original.stock_after).toLocaleString('id-ID')),
},
{
accessorKey: 'user.profile.full_name',
id: 'user',
header: 'User',
cell: ({ row }) => row.original.user?.profile?.full_name ?? 'Sistem',
},
];
</script>
<template>
<Head :title="`Riwayat Stok - ${title}`" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">Riwayat Stok</h2>
<p class="text-sm text-muted-foreground">{{ title }}</p>
</div>
<BackButton :href="backUrl" />
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable
:columns="columns"
:data="mutations.data"
:pagination="pagination"
:pagination-links="mutations.links"
:show-row-number="true"
pagination-item-label="mutasi"
/>
</CardContent>
</Card>
</AdminLayout>
</template>

View File

@ -107,6 +107,7 @@ watch(
:pagination="pagination"
:pagination-links="categories.links"
:sort="currentSort"
row-key="id"
@sort-change="setSort"
@filters-reset="resetFilters"
/>

View File

@ -106,6 +106,7 @@ watch(
:pagination="pagination"
:pagination-links="customers.links"
:sort="currentSort"
row-key="id"
@sort-change="setSort"
@filters-reset="resetFilters"
/>

View File

@ -35,6 +35,7 @@ const editForm = useForm({
name: '',
description: '',
category_ids: [] as number[],
status: 'active',
variants: [] as any[],
});
@ -53,6 +54,7 @@ function populateForm(variant: Variant | null) {
editForm.name = props.product.name;
editForm.description = props.product.description ?? '';
editForm.category_ids = (props.product.categories ?? []).map((c) => c.id);
editForm.status = props.product.status ?? 'active';
editForm.variants = (props.product.variants ?? []).map((v) => {
const prices: Record<string, string> = {
@ -122,6 +124,7 @@ function submit() {
formData.append('_method', 'PUT');
formData.append('name', editForm.name.trim());
formData.append('description', editForm.description.trim());
formData.append('status', editForm.status);
editForm.category_ids.forEach((id) => {
formData.append('category_ids[]', String(id));

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { RowEditAction } from '@/components/button';
import { RowEditAction, RowHistoryAction } from '@/components/button';
import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
@ -83,6 +83,8 @@ function openVerificationDetail(requestId: number | undefined) {
}
}
import { history as stockHistory } from '@/routes/admin/manage/stock';
// Modal Edit Varian State
const isEditing = ref(false);
const editingProduct = ref<ProductListItem | null>(null);
@ -208,8 +210,13 @@ function openEditModal(variant: Variant, product: ProductListItem) {
<span v-else class="text-xs text-muted-foreground">Belum ada harga</span>
</TableCell>
<TableCell class="text-center">
<RowEditAction :disabled="product.has_pending_request" tooltip="Ubah Varian"
@click="openEditModal(variant, product)" />
<div class="flex items-center justify-center gap-1">
<RowHistoryAction
:href="stockHistory.url({ query: { stockable_type: 'product-variant', stockable_id: variant.id } })"
tooltip="Riwayat Stok" />
<RowEditAction :disabled="product.has_pending_request" tooltip="Ubah Varian"
@click="openEditModal(variant, product)" />
</div>
</TableCell>
</TableRow>
</TableBody>

View File

@ -94,7 +94,23 @@ const useSamePrice = ref(prices.value.length <= 1 || allPricesHaveSameValue());
const showDeleteConfirm = ref(false);
const priceToDelete = ref<string | null>(null);
function confirmRemovePrice(clientId: string) {
async function confirmRemovePrice(clientId: string) {
const price = prices.value.find((p) => p.client_id === clientId);
if (price?.id) {
try {
const res = await fetch(`/admin/master/raw-materials/check-variant-usage?price_ids[]=${price.id}`);
const data: { in_use: number[] } = await res.json();
if (data.in_use?.includes(price.id)) {
toast.error(`Varian "${price.variant}" tidak dapat dihapus karena masih digunakan dalam proses cutting yang belum selesai.`);
return;
}
} catch {
// proceed to confirm dialog if check fails
}
}
priceToDelete.value = clientId;
showDeleteConfirm.value = true;
}
@ -223,6 +239,9 @@ function submit() {
if (errors.system) {
toast.error(errors.system);
}
if (errors.prices) {
toast.error(errors.prices);
}
},
});
}

View File

@ -25,6 +25,7 @@ defineProps<{
units: EnumOption[];
method: 'post' | 'put';
selectPortalTarget?: HTMLElement;
disabled?: boolean;
}>();
</script>
@ -44,6 +45,7 @@ defineProps<{
type="text"
placeholder="Masukkan nama bahan baku"
:maxlength="FIELD_LIMITS.name"
:disabled="disabled"
/>
<FieldError :errors="formErrors(form, 'name')" />
</Field>

View File

@ -14,6 +14,13 @@ import {
} from '@/components/ui/field';
import FieldDescription from '@/components/ui/field/FieldDescription.vue';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FIELD_LIMITS } from '@/lib/field-limits';
import type { FormWithErrors } from '@/lib/form';
import type { RawMaterialPriceFormItem } from '@/types/raw-material';
@ -25,6 +32,7 @@ defineProps<{
totalPrices: number;
useSamePrice: boolean;
priceErrors: (clientId: string, field: string) => string[];
variantOptions?: { value: string; label: string }[];
}>();
const emit = defineEmits<{
@ -59,7 +67,18 @@ const emit = defineEmits<{
<FieldLabel :for="`variant_${price.client_id}`" required>
Nama Varian
</FieldLabel>
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
<Select v-if="variantOptions && variantOptions.length > 0" :model-value="price.variant"
@update:model-value="emit('update:variant', $event as string)">
<SelectTrigger :id="`variant_${price.client_id}`" class="w-full">
<SelectValue placeholder="Pilih varian" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in variantOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<Input v-else :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
placeholder="Masukkan nama varian" :maxlength="FIELD_LIMITS.variantName"
@update:model-value="emit('update:variant', String($event))" />
<FieldError :errors="priceErrors(price.client_id, 'variant')" />

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { RowEditAction } from '@/components/button';
import { RowEditAction, RowHistoryAction } from '@/components/button';
import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
@ -66,6 +66,8 @@ function allVariantImageStocks(material: RawMaterialListItem): string[] {
);
}
import { history as stockHistory } from '@/routes/admin/manage/stock';
// Modal Edit Varian State
const isEditing = ref(false);
const editingMaterial = ref<RawMaterialListItem | null>(null);
@ -166,8 +168,13 @@ function openEditModal(price: RawMaterialPrice, material: RawMaterialListItem) {
{{ price.price_formatted }}
</TableCell>
<TableCell class="text-center">
<RowEditAction tooltip="Ubah Varian"
@click="openEditModal(price, material)" />
<div class="flex items-center justify-center gap-1">
<RowHistoryAction
:href="stockHistory.url({ query: { stockable_type: 'raw-material-price', stockable_id: price.id } })"
tooltip="Riwayat Stok" />
<RowEditAction tooltip="Ubah Varian"
@click="openEditModal(price, material)" />
</div>
</TableCell>
</TableRow>
</TableBody>

View File

@ -106,6 +106,7 @@ watch(
:pagination="pagination"
:pagination-links="suppliers.links"
:sort="currentSort"
row-key="id"
@sort-change="setSort"
@filters-reset="resetFilters"
/>

View File

@ -104,6 +104,7 @@ watch(
:pagination="pagination"
:pagination-links="roles.links"
:sort="currentSort"
row-key="id"
@sort-change="setSort"
@filters-reset="resetFilters"
/>

View File

@ -0,0 +1,27 @@
import type { Paginated } from '@/types/common';
export interface StockMutation {
id: number;
stockable_type: string;
stockable_id: number;
type: 'in' | 'out' | 'transfer' | 'adjustment';
quantity: string;
stock_before: string;
stock_after: string;
stock_quality: string | null;
description: string | null;
created_at: string;
user: {
id: number;
profile: {
full_name: string;
} | null;
} | null;
}
export type PaginatedStockMutations = Paginated<StockMutation> & {
from: number | null;
to: number | null;
prev_page_url: string | null;
next_page_url: string | null;
};

View File

@ -24,6 +24,7 @@
use App\Http\Controllers\Admin\Manage\Restock\RestockController;
use App\Http\Controllers\Admin\Manage\Restock\RestockDraftItemController;
use App\Http\Controllers\Admin\Manage\Stock\RetailStockController;
use App\Http\Controllers\Admin\Manage\Stock\StockHistoryController;
use App\Http\Controllers\Admin\Manage\StokOpnameController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
@ -146,6 +147,9 @@
Route::prefix('raw-materials')->name('raw_materials.')
->middleware('permission:'.Permission::RAW_MATERIALS_VIEW->value)
->group(function () {
Route::get('check-variant-usage', [RawMaterialController::class, 'checkVariantUsage'])
->name('check_variant_usage');
Route::get('create', [RawMaterialController::class, 'create'])
->middleware('permission:'.Permission::RAW_MATERIALS_CREATE->value)
->name('create');
@ -419,6 +423,12 @@
Route::post('/transfer', [RetailStockController::class, 'transfer'])->name('transfer');
});
Route::prefix('stock')->name('stock.')
->middleware('permission:'.Permission::STOCKS_VIEW->value)
->group(function () {
Route::get('history', StockHistoryController::class)->name('history');
});
Route::prefix('stok-opnames')->name('stok-opnames.')
->middleware('permission:'.Permission::STOK_OPNAMES_VIEW->value)
->group(function () {