Compare commits

..

No commits in common. "1d5863efaf56c07318b04d2afaf0f69e0be813ee" and "3c264dc7069f2d8219ad30821e266ab03e6a9fd1" have entirely different histories.

48 changed files with 120 additions and 1351 deletions

View File

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

View File

@ -1,47 +0,0 @@
<?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,14 +2,12 @@
namespace App\Http\Controllers\Admin\Master; namespace App\Http\Controllers\Admin\Master;
use App\Enums\CuttingStatus;
use App\Enums\RawMaterialUnit; use App\Enums\RawMaterialUnit;
use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\RawMaterialRequest; use App\Http\Requests\Admin\Master\RawMaterialRequest;
use App\Http\Requests\Admin\ToggleStatusRequest; use App\Http\Requests\Admin\ToggleStatusRequest;
use App\Models\CuttingMaterial;
use App\Models\RawMaterial; use App\Models\RawMaterial;
use App\Services\Master\RawMaterialService; use App\Services\Master\RawMaterialService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
@ -86,27 +84,6 @@ public function destroy(Request $request, RawMaterial $rawMaterial): RedirectRes
return redirect()->route('admin.master.raw_materials.index'); 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 public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMaterial): RedirectResponse
{ {
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user()); $this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user());

View File

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

View File

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

View File

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

View File

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

View File

@ -1,59 +0,0 @@
<?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,7 +30,6 @@ class CuttingService
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
@ -773,51 +772,22 @@ private function deductMaterialStock(Cutting $cutting): void
]); ]);
} }
$stockBefore = (float) $price->stock; if ((float) $price->stock < $totalTaken) {
if ($stockBefore < $totalTaken) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'materials' => "Stok {$price->rawMaterial?->name} ({$price->variant}) tidak mencukupi.", 'materials' => "Stok {$price->rawMaterial?->name} ({$price->variant}) tidak mencukupi.",
]); ]);
} }
$price->decrement('stock', $totalTaken); $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 private function reverseTotalMaterialStock(Cutting $cutting): void
{ {
foreach ($cutting->materials as $material) { foreach ($cutting->materials as $material) {
$price = RawMaterialPrice::query()->lockForUpdate()->find($material->raw_material_price_id); RawMaterialPrice::query()
->whereKey($material->raw_material_price_id)
if ($price === null) { ->increment('stock', (float) $material->material_usage);
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,7 +38,6 @@ public function __construct(
private readonly CashService $cashService, private readonly CashService $cashService,
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function defaultPriceType(OrderChannel $channel): ?PriceType public function defaultPriceType(OrderChannel $channel): ?PriceType
@ -771,45 +770,19 @@ private function applyDraftPrices(EloquentCollection $items, PriceType $priceTyp
private function decrementStock(OrderItem $item): void private function decrementStock(OrderItem $item): void
{ {
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD; $stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
$column = $this->stockColumn($stockQuality);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id); ProductVariant::query()
$stockBefore = (int) $variant->{$column}; ->whereKey($item->product_variant_id)
->decrement($this->stockColumn($stockQuality), $item->quantity);
$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 private function incrementStock(OrderItem $item): void
{ {
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD; $stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
$column = $this->stockColumn($stockQuality);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id); ProductVariant::query()
$stockBefore = (int) $variant->{$column}; ->whereKey($item->product_variant_id)
->increment($this->stockColumn($stockQuality), $item->quantity);
$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 private function availableStock(ProductVariant $variant, ProductStockQuality $stockQuality): int

View File

@ -29,7 +29,6 @@ class PurchaseService
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function paginateForIndex(array $tableQuery): LengthAwarePaginator
@ -598,38 +597,16 @@ private function presentDraftItem(PurchaseItem $item): array
private function incrementStock(PurchaseItem $item): void private function incrementStock(PurchaseItem $item): void
{ {
$price = RawMaterialPrice::query()->findOrFail($item->raw_material_price_id); RawMaterialPrice::query()
$stockBefore = (float) $price->stock; ->whereKey($item->raw_material_price_id)
->increment('stock', $item->quantity);
$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 private function decrementStock(PurchaseItem $item): void
{ {
$price = RawMaterialPrice::query()->findOrFail($item->raw_material_price_id); RawMaterialPrice::query()
$stockBefore = (float) $price->stock; ->whereKey($item->raw_material_price_id)
->decrement('stock', $item->quantity);
$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 private function notifyPurchase(string $typeLabel, string $body, string $url, ?string $userId = null): void

View File

@ -32,7 +32,6 @@ class RestockService
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function paginateForIndex(array $tableQuery): LengthAwarePaginator
@ -530,44 +529,16 @@ private function stockColumn(ProductStockQuality $stockType): string
private function incrementStock(RestockItem $item, ProductStockQuality $stockType): void private function incrementStock(RestockItem $item, ProductStockQuality $stockType): void
{ {
$column = $this->stockColumn($stockType); ProductVariant::query()
->whereKey($item->product_variant_id)
$variant = ProductVariant::query()->findOrFail($item->product_variant_id); ->increment($this->stockColumn($stockType), $item->quantity);
$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 private function decrementStock(RestockItem $item, ProductStockQuality $stockType): void
{ {
$column = $this->stockColumn($stockType); ProductVariant::query()
->whereKey($item->product_variant_id)
$variant = ProductVariant::query()->findOrFail($item->product_variant_id); ->decrement($this->stockColumn($stockType), $item->quantity);
$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 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; namespace App\Services\Manage;
use App\Models\OwnerVerificationRequest;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Models\RetailStockHistory;
use App\Models\User; use App\Models\User;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -13,7 +13,6 @@ class RetailStockService
{ {
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function transfer(int $variantId, int $quantity, User $user, ?string $notes = null): void public function transfer(int $variantId, int $quantity, User $user, ?string $notes = null): void
@ -69,33 +68,23 @@ private function executeTransfer(ProductVariant $variant, int $quantity, User $u
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$goodStockBefore = $variant->stock; $stockBefore = $variant->stock;
$retailStockBefore = $variant->retail_stock; $retailStockBefore = $variant->retail_stock;
$variant->decrement('stock', $quantity); $variant->decrement('stock', $quantity);
$variant->increment('retail_stock', $quantity); $variant->increment('retail_stock', $quantity);
$this->stockMutationService->record( RetailStockHistory::create([
stockable: $variant, 'product_variant_id' => $variant->id,
type: 'out', 'user_id' => $user->id,
quantity: -$quantity, 'quantity' => $quantity,
stockBefore: $goodStockBefore, 'stock_before' => $stockBefore,
stockAfter: $goodStockBefore - $quantity, 'retail_stock_before' => $retailStockBefore,
stockQuality: 'good', 'stock_after' => $stockBefore - $quantity,
description: $notes ? "Transfer ke stok ecer: {$notes}" : 'Transfer ke stok ecer', 'retail_stock_after' => $retailStockBefore + $quantity,
user: $user, 'notes' => $notes,
); 'created_at' => now(),
]);
$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

@ -1,36 +0,0 @@
<?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,7 +23,6 @@ class StokOpnameService
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
@ -260,25 +259,9 @@ public function verify(StokOpname $stokOpname, User $user, ?string $verification
ProductStockQuality::RETAIL => 'retail_stock', ProductStockQuality::RETAIL => 'retail_stock',
ProductStockQuality::REJECT => 'reject_stock', ProductStockQuality::REJECT => 'reject_stock',
}; };
$item->productVariant()->update([
$variant = $item->productVariant;
$stockBefore = (int) $variant->{$column};
$variant->update([
$column => $item->physical_stock, $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,7 +13,6 @@
use App\Models\User; use App\Models\User;
use App\Services\Concerns\CachesQuery; use App\Services\Concerns\CachesQuery;
use App\Services\Concerns\RunsInTransaction; use App\Services\Concerns\RunsInTransaction;
use App\Services\Manage\StockMutationService;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
@ -30,7 +29,6 @@ class ProductService
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery, string $status, string $categoryId = '', string $stockStatus = '', string $productId = ''): LengthAwarePaginator public function paginateForIndex(array $tableQuery, string $status, string $categoryId = '', string $stockStatus = '', string $productId = ''): LengthAwarePaginator
@ -147,8 +145,6 @@ function () use ($validated, $user, $isOwner, $isDraft): Product {
'retail_stock' => $variantData['retail_stock'], 'retail_stock' => $variantData['retail_stock'],
]); ]);
$this->recordVariantStockMutations($variant, $variantData);
$this->syncVariantImages($variant, $variantData, $index); $this->syncVariantImages($variant, $variantData, $index);
if (! empty($variantData['prices'])) { if (! empty($variantData['prices'])) {
@ -224,6 +220,38 @@ function () use ($validated, $product, $user, $canEditDirectly): void {
if ($canEditDirectly) { if ($canEditDirectly) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated)); $payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToProduct($product, $payload); $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 { } else {
$verificationRequest = OwnerVerificationRequest::create([ $verificationRequest = OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::UPDATE, 'action' => OwnerVerificationAction::UPDATE,
@ -544,9 +572,6 @@ private function applyPayloadToProduct(
foreach ($payload['variants'] ?? [] as $index => $variantData) { foreach ($payload['variants'] ?? [] as $index => $variantData) {
if (! empty($variantData['id'])) { if (! empty($variantData['id'])) {
$variant = $product->variants()->findOrFail($variantData['id']); $variant = $product->variants()->findOrFail($variantData['id']);
$this->recordVariantStockChanges($variant, $variantData);
$variant->update([ $variant->update([
'name' => $variantData['name'], 'name' => $variantData['name'],
'stock' => $variantData['stock'], 'stock' => $variantData['stock'],
@ -556,8 +581,6 @@ private function applyPayloadToProduct(
if ($verificationRequest !== null) { if ($verificationRequest !== null) {
$this->applyVariantImageChanges($verificationRequest, $variant, $variantData, (int) $index); $this->applyVariantImageChanges($verificationRequest, $variant, $variantData, (int) $index);
} else {
$this->syncVariantImages($variant, $variantData, $index);
} }
if (! empty($variantData['prices'])) { if (! empty($variantData['prices'])) {
@ -579,12 +602,8 @@ private function applyPayloadToProduct(
'retail_stock' => $variantData['retail_stock'], 'retail_stock' => $variantData['retail_stock'],
]); ]);
$this->recordVariantStockMutations($variant, $variantData);
if ($verificationRequest !== null) { if ($verificationRequest !== null) {
$this->copyRequestVariantImages($verificationRequest, (int) $index, $variant); $this->copyRequestVariantImages($verificationRequest, (int) $index, $variant);
} else {
$this->syncVariantImages($variant, $variantData, $index);
} }
if (! empty($variantData['prices'])) { if (! empty($variantData['prices'])) {
@ -784,8 +803,6 @@ private function buildPayloadFromValidated(array $validated): array
'retail_stock' => $variantData['retail_stock'], 'retail_stock' => $variantData['retail_stock'],
'prices' => $variantData['prices'] ?? [], 'prices' => $variantData['prices'] ?? [],
'remove_media_ids' => $variantData['remove_media_ids'] ?? [], 'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
'images' => $variantData['images'] ?? null,
's3_keys' => $variantData['s3_keys'] ?? null,
]) ])
->all(), ->all(),
]; ];
@ -795,7 +812,6 @@ private function syncVariantImages(
ProductVariant $variant, ProductVariant $variant,
array $variantData, array $variantData,
int $index, int $index,
bool $required = true,
): void { ): void {
$this->mediaService->syncCollection( $this->mediaService->syncCollection(
$variant, $variant,
@ -803,7 +819,7 @@ private function syncVariantImages(
$variantData['images'] ?? null, $variantData['images'] ?? null,
$variantData['remove_media_ids'] ?? null, $variantData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES, self::MAX_VARIANT_IMAGES,
required: $required, required: true,
errorKey: "variants.{$index}.s3_keys", errorKey: "variants.{$index}.s3_keys",
s3Keys: $variantData['s3_keys'] ?? null, s3Keys: $variantData['s3_keys'] ?? null,
); );
@ -862,59 +878,4 @@ private function applySorting(Builder $query, string $sort, string $direction):
$query->latest(); $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,7 +12,6 @@
use App\Models\User; use App\Models\User;
use App\Services\Concerns\CachesQuery; use App\Services\Concerns\CachesQuery;
use App\Services\Concerns\RunsInTransaction; use App\Services\Concerns\RunsInTransaction;
use App\Services\Manage\StockMutationService;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
@ -29,7 +28,6 @@ class RawMaterialService
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery, string $isActive, string $stockStatus = '', string $rawMaterialId = ''): LengthAwarePaginator public function paginateForIndex(array $tableQuery, string $isActive, string $stockStatus = '', string $rawMaterialId = ''): LengthAwarePaginator
@ -315,27 +313,6 @@ 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 public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
{ {
$rawMaterial = $verificationRequest->subject; $rawMaterial = $verificationRequest->subject;
@ -376,15 +353,6 @@ private function applyPayloadToRawMaterial(
->map(fn ($id) => (int) $id) ->map(fn ($id) => (int) $id)
->all(); ->all();
$deletingPriceIds = $rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->pluck('id')
->toArray();
if ($deletingPriceIds !== []) {
$this->ensurePricesNotUsedInActiveCutting($deletingPriceIds);
}
$rawMaterial->prices() $rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds) ->whereNotIn('id', $submittedPriceIds)
->get() ->get()
@ -395,19 +363,6 @@ private function applyPayloadToRawMaterial(
foreach ($payload['prices'] ?? [] as $index => $priceData) { foreach ($payload['prices'] ?? [] as $index => $priceData) {
if (! empty($priceData['id'])) { if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->findOrFail($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([ $price->update([
'variant' => $priceData['variant'], 'variant' => $priceData['variant'],
'price' => $priceData['price'], 'price' => $priceData['price'],
@ -429,15 +384,6 @@ private function applyPayloadToRawMaterial(
'stock' => $priceData['stock'], '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) { if ($verificationRequest !== null) {
$this->copyRequestPriceImages($verificationRequest, (int) $index, $price); $this->copyRequestPriceImages($verificationRequest, (int) $index, $price);
} elseif (isset($originalPrices[$index])) { } elseif (isset($originalPrices[$index])) {
@ -573,15 +519,6 @@ private function createPrice(RawMaterial $rawMaterial, array $priceData, int $in
'stock' => $priceData['stock'], '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); $this->syncPriceImages($price, $priceData, $index);
return $price; return $price;

View File

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

View File

@ -1,297 +0,0 @@
<?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,7 +20,6 @@
use App\Models\PayrollAdjustment; use App\Models\PayrollAdjustment;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Models\Purchase; use App\Models\Purchase;
use App\Models\PurchaseItem; use App\Models\PurchaseItem;
@ -59,7 +58,6 @@ class ModelLabel
PayrollAdjustment::class => 'Penyesuaian Gaji', PayrollAdjustment::class => 'Penyesuaian Gaji',
PayrollPeriod::class => 'Periode Gaji', PayrollPeriod::class => 'Periode Gaji',
Product::class => 'Produk', Product::class => 'Produk',
ProductPrice::class => 'Harga Produk',
ProductVariant::class => 'Varian Produk', ProductVariant::class => 'Varian Produk',
Purchase::class => 'Belanja', Purchase::class => 'Belanja',
PurchaseItem::class => 'Item Belanja', PurchaseItem::class => 'Item Belanja',

View File

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

View File

@ -1,35 +0,0 @@
<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,6 +10,5 @@ export { default as RowPrintAction } from './RowPrintAction.vue';
export { default as RowDetailAction } from './RowDetailAction.vue'; export { default as RowDetailAction } from './RowDetailAction.vue';
export { default as RowTransferAction } from './RowTransferAction.vue'; export { default as RowTransferAction } from './RowTransferAction.vue';
export { default as RowShareAction } from './RowShareAction.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 CreateButton } from './CreateButton.vue';
export { default as BackButton } from './BackButton.vue'; export { default as BackButton } from './BackButton.vue';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -160,14 +160,6 @@ async function submit() {
return; 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; loading.value = true;
try { try {

View File

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

View File

@ -1,13 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { Check, Plus, Search, ShoppingCart } from '@lucide/vue'; import { Plus, ShoppingCart } from '@lucide/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import ConfirmDialog from '@/components/ConfirmDialog.vue'; import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { import {
FieldError, FieldError,
FieldGroup, FieldGroup,
@ -112,7 +111,13 @@ const {
})); }));
} }
return []; return [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}];
} }
); );
@ -221,7 +226,7 @@ async function handleRemovePrice() {
console.error(error); console.error(error);
} }
} }
removePrice(clientId, true); removePrice(clientId);
} }
priceToDelete.value = null; priceToDelete.value = null;
@ -248,19 +253,9 @@ 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) { function syncCartItemQuantity(index: number) {
const price = prices.value[index]; const price = prices.value[index];
const cartItem = cart.value[index]; if (price) {
if (price && cartItem) {
price.stock = cartItem.quantity;
debouncedSave(price); debouncedSave(price);
} }
} }
@ -453,133 +448,7 @@ const catalogMaterialNames = computed(() => {
return [...new Set(props.catalog.map((c) => c.name))]; 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() { function onMaterialNameInput() {
isExistingMaterial.value = false;
selectedMaterialId.value = null;
const matched = props.catalog.find( const matched = props.catalog.find(
(c) => c.name.toLowerCase() === form.name.trim().toLowerCase() (c) => c.name.toLowerCase() === form.name.trim().toLowerCase()
); );
@ -695,7 +564,13 @@ function populateForm() {
} else { } else {
form.name = ''; form.name = '';
form.unit = 'yard'; form.unit = 'yard';
prices.value = []; prices.value = [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}];
} }
} }
@ -799,40 +674,7 @@ function submit() {
<div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_380px]"> <div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_380px]">
<!-- Left Column: Form Info & Variants (styled exactly like RawMaterialForm.vue) --> <!-- Left Column: Form Info & Variants (styled exactly like RawMaterialForm.vue) -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Search existing raw materials --> <RawMaterialInfoSection :form="form" :units="units" method="post" />
<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" <RawMaterialSharedPriceSection :form="form" :prices="prices" :use-same-price="useSamePrice"
@toggle-use-same-price="handleToggleUseSamePrice" @set-shared-price="handleSetSharedPrice" /> @toggle-use-same-price="handleToggleUseSamePrice" @set-shared-price="handleSetSharedPrice" />
@ -840,7 +682,6 @@ function submit() {
<RawMaterialVariantSection v-for="(price, index) in prices" :key="price.client_id" :form="form" <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="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
:price-errors="(clientId, field) => priceErrors(form, clientId, field)" :price-errors="(clientId, field) => priceErrors(form, clientId, field)"
:variant-options="selectedMaterialVariants"
@remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="handleApplyPriceToAll(price.client_id)" @remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="handleApplyPriceToAll(price.client_id)"
@update:variant="handleUpdateVariant(price.client_id, $event)" @update:variant="handleUpdateVariant(price.client_id, $event)"
@update:stock="handleUpdateStock(price.client_id, $event)" @update:stock="handleUpdateStock(price.client_id, $event)"
@ -880,7 +721,6 @@ function submit() {
@remove="removeFromCart" @remove="removeFromCart"
@adjust-quantity="adjustQuantity" @adjust-quantity="adjustQuantity"
@sync-quantity="syncCartItemQuantity" @sync-quantity="syncCartItemQuantity"
@sync-quantity-input="syncCartInput"
/> />
<!-- Totals, Discount, Shipping, Notes, Photo dropzone, Submit --> <!-- Totals, Discount, Shipping, Notes, Photo dropzone, Submit -->

View File

@ -1,129 +0,0 @@
<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,7 +107,6 @@ watch(
:pagination="pagination" :pagination="pagination"
:pagination-links="categories.links" :pagination-links="categories.links"
:sort="currentSort" :sort="currentSort"
row-key="id"
@sort-change="setSort" @sort-change="setSort"
@filters-reset="resetFilters" @filters-reset="resetFilters"
/> />

View File

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

View File

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

View File

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

View File

@ -94,23 +94,7 @@ const useSamePrice = ref(prices.value.length <= 1 || allPricesHaveSameValue());
const showDeleteConfirm = ref(false); const showDeleteConfirm = ref(false);
const priceToDelete = ref<string | null>(null); const priceToDelete = ref<string | null>(null);
async function confirmRemovePrice(clientId: string) { 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; priceToDelete.value = clientId;
showDeleteConfirm.value = true; showDeleteConfirm.value = true;
} }
@ -239,9 +223,6 @@ function submit() {
if (errors.system) { if (errors.system) {
toast.error(errors.system); toast.error(errors.system);
} }
if (errors.prices) {
toast.error(errors.prices);
}
}, },
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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