Compare commits
10 Commits
3c264dc706
...
1d5863efaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5863efaf | ||
|
|
d74d120a6c | ||
|
|
3052398ad8 | ||
|
|
7dacf785cb | ||
|
|
afdd0905db | ||
|
|
1a2b741209 | ||
|
|
562c5721dd | ||
|
|
fa52f177e2 | ||
|
|
ec8cb94486 | ||
|
|
399b444b54 |
@ -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,
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -37,7 +37,7 @@ public function rules(): array
|
||||
|
||||
...$this->productVariantRules(
|
||||
productId: $this->route('product')?->id,
|
||||
imagesRequired: $this->isMethod('POST'),
|
||||
imagesRequired: $this->route('product') === null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
59
app/Models/StockMutation.php
Normal file
59
app/Models/StockMutation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
36
app/Services/Manage/StockMutationService.php
Normal file
36
app/Services/Manage/StockMutationService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
297
app/Support/ActivityLog/FieldLabel.php
Normal file
297
app/Support/ActivityLog/FieldLabel.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
|
||||
35
resources/js/components/button/RowHistoryAction.vue
Normal file
35
resources/js/components/button/RowHistoryAction.vue
Normal 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>
|
||||
@ -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';
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -107,6 +107,7 @@ watch(
|
||||
:pagination="pagination"
|
||||
:pagination-links="expenses.links"
|
||||
:sort="currentSort"
|
||||
row-key="id"
|
||||
@sort-change="setSort"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 -->
|
||||
|
||||
129
resources/js/pages/admin/manage/stock/History.vue
Normal file
129
resources/js/pages/admin/manage/stock/History.vue
Normal 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>
|
||||
@ -107,6 +107,7 @@ watch(
|
||||
:pagination="pagination"
|
||||
:pagination-links="categories.links"
|
||||
:sort="currentSort"
|
||||
row-key="id"
|
||||
@sort-change="setSort"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
|
||||
@ -106,6 +106,7 @@ watch(
|
||||
:pagination="pagination"
|
||||
:pagination-links="customers.links"
|
||||
:sort="currentSort"
|
||||
row-key="id"
|
||||
@sort-change="setSort"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -106,6 +106,7 @@ watch(
|
||||
:pagination="pagination"
|
||||
:pagination-links="suppliers.links"
|
||||
:sort="currentSort"
|
||||
row-key="id"
|
||||
@sort-change="setSort"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
|
||||
@ -104,6 +104,7 @@ watch(
|
||||
:pagination="pagination"
|
||||
:pagination-links="roles.links"
|
||||
:sort="currentSort"
|
||||
row-key="id"
|
||||
@sort-change="setSort"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
|
||||
27
resources/js/types/stock-mutation.ts
Normal file
27
resources/js/types/stock-mutation.ts
Normal 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;
|
||||
};
|
||||
@ -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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user