feat: implement restock management features including CRUD operations, permissions, and UI components for creating and editing restocks

This commit is contained in:
Yoga Pangestu 2026-07-13 19:15:17 +07:00
parent f6af44f773
commit 62beb309a8
28 changed files with 3223 additions and 1 deletions

View File

@ -85,6 +85,11 @@ enum Permission: string
case PURCHASES_UPDATE = 'purchases.update';
case PURCHASES_DELETE = 'purchases.delete';
case RESTOCKS_VIEW = 'restocks.view';
case RESTOCKS_CREATE = 'restocks.create';
case RESTOCKS_UPDATE = 'restocks.update';
case RESTOCKS_DELETE = 'restocks.delete';
case ORDERS_VIEW = 'orders.view';
case ORDERS_CREATE = 'orders.create';
case ORDERS_UPDATE = 'orders.update';
@ -233,6 +238,11 @@ public function label(): string
self::PURCHASES_UPDATE => 'Ubah Belanja',
self::PURCHASES_DELETE => 'Hapus Belanja',
self::RESTOCKS_VIEW => 'Lihat Restock',
self::RESTOCKS_CREATE => 'Catat Restock',
self::RESTOCKS_UPDATE => 'Ubah Restock',
self::RESTOCKS_DELETE => 'Hapus Restock',
self::ORDERS_VIEW => 'Lihat Pesanan',
self::ORDERS_CREATE => 'Tambah Pesanan',
self::ORDERS_UPDATE => 'Ubah Pesanan',
@ -333,6 +343,8 @@ public function group(): string
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE,
self::PURCHASES_DELETE => 'Belanja',
self::RESTOCKS_VIEW, self::RESTOCKS_CREATE, self::RESTOCKS_UPDATE,
self::RESTOCKS_DELETE => 'Restock',
self::ORDERS_VIEW, self::ORDERS_CREATE, self::ORDERS_UPDATE,
self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE,
self::ORDERS_CANCEL => 'Pesanan',

View File

@ -152,6 +152,11 @@ public function permissions(): array
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::RESTOCKS_VIEW,
Permission::RESTOCKS_CREATE,
Permission::RESTOCKS_UPDATE,
Permission::RESTOCKS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,

View File

@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Admin\Manage\Restock;
use App\Enums\Permission;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\RestockRequest;
use App\Models\Restock;
use App\Services\Manage\RestockService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class RestockController extends Controller
{
use FlashesEntityMessage, ParsesDataTableQuery;
public function __construct(
private readonly RestockService $restockService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$tableQuery['search_id'] = $request->string('search_id')->trim()->toString();
return Inertia::render('admin/manage/restocks/Index', [
'restocks' => $this->restockService->paginateForIndex($tableQuery),
'filters' => $this->dataTableFilters($tableQuery, [
'search_id' => $tableQuery['search_id'],
]),
]);
}
public function create(Request $request): Response
{
$user = $request->user();
$this->restockService->clearDraftItemsForUser($user);
return Inertia::render('admin/manage/restocks/Create', [
'productCatalog' => $this->restockService->productCatalog(user: $user),
'draftItems' => [],
]);
}
public function store(RestockRequest $request): RedirectResponse
{
$this->restockService->create($request->validated(), $request->user());
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
$this->flashCreated('Restock');
} else {
$this->flashSuccess('Restock berhasil diajukan dan menunggu verifikasi owner.');
}
return redirect()->route('admin.manage.restocks.index');
}
public function edit(Restock $restock): Response
{
return Inertia::render('admin/manage/restocks/Edit', [
'restock' => $this->restockService->findForEdit($restock),
'productCatalog' => $this->restockService->productCatalog($restock),
]);
}
public function update(RestockRequest $request, Restock $restock): RedirectResponse
{
$this->restockService->update($restock, $request->validated(), $request->user());
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
$this->flashUpdated('Restock');
} else {
$this->flashSuccess('Perubahan restock berhasil diajukan dan menunggu verifikasi owner.');
}
return redirect()->route('admin.manage.restocks.index');
}
public function destroy(Request $request, Restock $restock): RedirectResponse
{
$this->restockService->delete($restock, $request->user());
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
$this->flashDeleted('Restock');
} else {
$this->flashSuccess('Penghapusan restock berhasil diajukan dan menunggu verifikasi owner.');
}
return redirect()->route('admin.manage.restocks.index');
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Admin\Manage\Restock;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\RestockDraftItemRequest;
use App\Models\ProductVariant;
use App\Services\Manage\RestockService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RestockDraftItemController extends Controller
{
public function __construct(
private readonly RestockService $restockService,
) {}
public function store(RestockDraftItemRequest $request): JsonResponse
{
$item = $this->restockService->syncDraftItem($request->validated(), $request->user());
return response()->json(['item' => $item]);
}
public function destroy(Request $request, ProductVariant $productVariant): JsonResponse
{
$this->restockService->removeDraftItem($request->user(), $productVariant);
return response()->json(['ok' => true]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RestockDraftItemRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::RESTOCKS_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'product_variant_id' => [
'required',
'integer',
Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
],
'quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
'unit_price' => ['required', 'integer', 'gt:0'],
];
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use App\Enums\ProductStockQuality;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
class RestockRequest extends FormRequest
{
use ValidatesMediaUploads;
public function authorize(): bool
{
$permission = $this->isMethod('POST')
? Permission::RESTOCKS_CREATE
: Permission::RESTOCKS_UPDATE;
return $this->user()?->can($permission->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'notes' => ['nullable', 'string', 'max:100'],
'stock_type' => ['required', 'string', 'in:'.implode(',', ProductStockQuality::values())],
...$this->photoRules('photos', 1),
'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => ['required', 'integer'],
'items.*.quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
'items.*.unit_price' => ['required', 'integer', 'gt:0'],
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'notes' => 'keterangan',
'stock_type' => 'tipe stok',
'items' => 'produk',
'items.*.product_variant_id' => 'varian produk',
'items.*.quantity' => 'jumlah',
'items.*.unit_price' => 'harga',
...$this->photoUploadAttributes('bukti restock', 'photos'),
];
}
}

98
app/Models/Restock.php Normal file
View File

@ -0,0 +1,98 @@
<?php
namespace App\Models;
use App\Enums\OwnerVerificationStatus;
use App\Enums\ProductStockQuality;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\HasPendingOwnerVerification;
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 Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])]
#[Appends([
'created_at_formatted',
'subtotal_formatted',
'total_formatted',
])]
class Restock extends Model implements HasMedia
{
// 1. Use Trait
use HasFactory, HasModuleMedia, HasPendingOwnerVerification, InteractsWithActivityLog, SoftDeletes;
// 2. Casting
protected function casts(): array
{
return [
'subtotal' => 'integer',
'total' => 'integer',
'stock_type' => ProductStockQuality::class,
];
}
// 3. Attribute
public function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function subtotalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'),
);
}
public function totalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'),
);
}
// 4. Other Methods
public static function mediaModuleName(): string
{
return 'restock';
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('photos');
}
// 5. Relation
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_id');
}
public function items(): HasMany
{
return $this->hasMany(RestockItem::class);
}
public function ownerVerificationRequests(): MorphMany
{
return $this->morphMany(OwnerVerificationRequest::class, 'subject');
}
public function pendingOwnerVerificationRequest(): MorphOne
{
return $this->morphOne(OwnerVerificationRequest::class, 'subject')
->where('status', OwnerVerificationStatus::PENDING)
->latestOfMany();
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Models;
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 Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends([
'quantity_formatted',
'quantity_input',
'subtotal_formatted',
'unit_price_formatted',
])]
class RestockItem extends Model
{
// 1. Use Trait
use HasFactory, InteractsWithActivityLog, SoftDeletes;
// 2. Casting
protected function casts(): array
{
return [
'quantity' => 'integer',
'subtotal' => 'integer',
'unit_price' => 'integer',
];
}
// 3. Attribute
public function quantityFormatted(): Attribute
{
return Attribute::make(
get: fn () => "{$this->quantity} pcs",
);
}
public function quantityInput(): Attribute
{
return Attribute::make(
get: fn () => (string) $this->quantity,
);
}
public function subtotalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'),
);
}
public function unitPriceFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->unit_price, 0, ',', '.'),
);
}
// 4. Relation
public function restock(): BelongsTo
{
return $this->belongsTo(Restock::class)->withTrashed();
}
public function productVariant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class)->withTrashed();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
}

View File

@ -11,6 +11,7 @@
use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\RawMaterial;
use App\Models\Restock;
use App\Models\User;
use App\Services\Concerns\CachesQuery;
use App\Services\Master\ProductService;
@ -37,6 +38,7 @@ public function __construct(
private readonly ProductService $productService,
private readonly RawMaterialService $rawMaterialService,
private readonly PurchaseService $purchaseService,
private readonly RestockService $restockService,
private readonly PushNotificationService $pushNotificationService,
private readonly MarketplaceService $marketplaceService,
private readonly RetailStockService $retailStockService,
@ -291,6 +293,7 @@ private function rejectVerificationRequest(OwnerVerificationRequest $request): v
Product::class => $this->productService->rejectVerificationRequest($request),
RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request),
Purchase::class => $this->purchaseService->rejectVerificationRequest($request),
Restock::class => $this->restockService->rejectVerificationRequest($request),
MarketplaceSettings::class => null,
default => throw ValidationException::withMessages([
'subject_type' => 'Tipe data verifikasi tidak didukung.',
@ -304,6 +307,7 @@ private function applyVerificationRequest(OwnerVerificationRequest $request): vo
Product::class => $this->productService->applyVerificationRequest($request),
RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request),
Purchase::class => $this->purchaseService->applyVerificationRequest($request),
Restock::class => $this->restockService->applyVerificationRequest($request),
MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request),
default => throw ValidationException::withMessages([
'subject_type' => 'Tipe data verifikasi tidak didukung.',
@ -317,6 +321,7 @@ private function clearVerificationRequestMedia(OwnerVerificationRequest $request
Product::class => $this->productService->clearVerificationRequestMedia($request),
RawMaterial::class => $this->rawMaterialService->clearVerificationRequestMedia($request),
Purchase::class => $this->purchaseService->clearVerificationRequestMedia($request),
Restock::class => $this->restockService->clearVerificationRequestMedia($request),
default => null,
};
}

View File

@ -0,0 +1,782 @@
<?php
namespace App\Services\Manage;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Restock;
use App\Models\RestockItem;
use App\Models\User;
use App\Services\Concerns\CachesQuery;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
class RestockService
{
use CachesQuery, RunsInTransaction;
private const MAX_PHOTOS = 1;
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = Restock::query()
->with([
'createdBy.profile',
'pendingOwnerVerificationRequest.submittedBy.profile',
'items.productVariant.product:id,name',
'items.productVariant.media',
'media',
])
->when(($tableQuery['search_id'] ?? '') !== '', function (Builder $query) use ($tableQuery): void {
$query->where('restocks.id', $tableQuery['search_id']);
})
->when(($tableQuery['search_id'] ?? '') === '' && $tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('notes', 'like', "%{$search}%")
->orWhereHas('items.productVariant', function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhereHas('product', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
});
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(25)
->withQueryString()
->through(function (Restock $restock) {
$restock->setAttribute(
'photos',
MediaPresenter::first($restock, 'photos'),
);
$pendingRequest = $restock->pendingOwnerVerificationRequest;
$restock->setAttribute('has_pending_request', $pendingRequest !== null);
$restock->setAttribute('pending_request_id', $pendingRequest?->id);
$restock->setAttribute('pending_request_action', $pendingRequest?->action->value);
$restock->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
$restock->setAttribute('pending_request_submitted_by_name', $pendingRequest?->submittedBy?->profile?->full_name ?? $pendingRequest?->submittedBy?->username);
$restock->setAttribute('stock_type_label', $restock->stock_type->label());
$restock->items->each(fn (RestockItem $item) => $this->breakItemCircularReference($item));
return $restock;
});
}
public function productCatalog(?Restock $restock = null, ?User $user = null): Collection
{
$selectedVariantIds = $restock
? $restock->items()->pluck('product_variant_id')->all()
: ($user ? $this->draftItemsQuery($user)->pluck('product_variant_id')->all() : []);
return Product::query()
->with([
'variants' => fn ($query) => $query
->with(['media', 'prices'])
->orderBy('created_at'),
])
->where(function (Builder $query) use ($selectedVariantIds): void {
$query->active();
if ($selectedVariantIds !== []) {
$query->orWhereHas(
'variants',
fn (Builder $query) => $query->whereIn('id', $selectedVariantIds),
);
}
})
->orderBy('name')
->get()
->each(function (Product $product): void {
$product->variants->each(function (ProductVariant $variant): void {
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
$hargaModal = $variant->prices
->firstWhere('type', PriceType::HARGA_MODAL);
$variant->setAttribute(
'harga_modal',
$hargaModal?->price ?? 0,
);
$variant->unsetRelation('prices');
});
});
}
public function findForEdit(Restock $restock): Restock
{
$restock->load([
'items.productVariant.product:id,name',
'items.productVariant.media',
'media',
]);
$restock->setAttribute(
'photos',
MediaPresenter::first($restock, 'photos'),
);
$restock->items->each(function (RestockItem $item): void {
$variant = $item->productVariant;
if ($variant) {
$variant->setAttribute('images', MediaPresenter::collection($variant, 'images'));
}
$this->breakItemCircularReference($item);
});
return $restock;
}
public function draftItemsForUser(User $user): array
{
return $this->draftItemsQuery($user)
->with([
'productVariant.product:id,name',
'productVariant.media',
])
->get()
->map(function (RestockItem $item) {
$result = $this->presentDraftItem($item);
$this->breakItemCircularReference($item);
return $result;
})
->values()
->all();
}
public function clearDraftItemsForUser(User $user): void
{
$this->draftItemsQuery($user)->delete();
}
public function syncDraftItem(array $validated, User $user): array
{
$variant = ProductVariant::query()
->with('product:id,name')
->findOrFail($validated['product_variant_id']);
$quantity = (float) $validated['quantity'];
$unitPrice = (int) $validated['unit_price'];
$subtotal = (int) round($quantity * $unitPrice);
$item = RestockItem::query()->updateOrCreate(
[
'user_id' => $user->id,
'product_variant_id' => $variant->id,
'restock_id' => null,
],
[
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $subtotal,
],
);
$item->load([
'productVariant.product:id,name',
'productVariant.media',
]);
$result = $this->presentDraftItem($item);
$this->breakItemCircularReference($item);
return $result;
}
public function removeDraftItem(User $user, ProductVariant $productVariant): void
{
RestockItem::query()
->whereNull('restock_id')
->where('user_id', $user->id)
->where('product_variant_id', $productVariant->id)
->delete();
}
public function create(array $validated, User $user): Restock
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$restock = $this->runInTransaction(
function () use ($validated, $user, $isOwner): Restock {
$resolvedItems = $this->processRequestItems($validated['items'] ?? []);
$subtotal = array_sum(array_column($resolvedItems, 'subtotal'));
$restock = Restock::create([
'created_by_id' => $user->id,
'subtotal' => $subtotal,
'total' => $subtotal,
'notes' => $validated['notes'] ?? null,
'stock_type' => $validated['stock_type'] ?? 'good',
]);
foreach ($resolvedItems as $itemData) {
$restock->items()->create([
'product_variant_id' => $itemData['product_variant_id'],
'quantity' => $itemData['quantity'],
'unit_price' => $itemData['unit_price'],
'subtotal' => $itemData['subtotal'],
]);
}
$this->syncPhotos($restock, $validated);
$restock->load(['items.productVariant.product:id,name']);
$stockType = $restock->stock_type;
if ($isOwner) {
foreach ($restock->items as $item) {
$this->incrementStock($item, $stockType);
}
} else {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Restock::class,
'subject_id' => $restock->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => null,
'new' => $this->snapshotRestock($restock),
],
]);
}
return $restock;
},
'Gagal membuat restock',
);
if (! $isOwner) {
$this->notifyForPendingRequest(
$user,
'Tambah Restock',
"Pengajuan restock senilai {$restock->total_formatted} menunggu verifikasi owner.",
route('admin.manage.restocks.index', ['search_id' => $restock->id]),
(string) $restock->id,
);
}
$this->cacheForgetByPattern('manage:restocks:*');
return $restock;
}
public function update(Restock $restock, array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$restock->load(['items.productVariant.product:id,name']);
$this->runInTransaction(
function () use ($restock, $validated, $user, $isOwner): void {
if ($isOwner) {
$payload = $this->buildPayloadFromValidated($validated);
$this->applyPayloadToRestock($restock, $payload);
if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) {
$this->syncPhotos($restock, $validated);
}
} else {
$verificationRequest = OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::UPDATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Restock::class,
'subject_id' => $restock->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => $this->snapshotRestock($restock),
'new' => $this->buildPayloadFromValidated($validated),
],
]);
if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) {
$this->syncRequestPhotos($verificationRequest, $validated);
}
}
},
'Gagal memperbarui restock',
);
if (! $isOwner) {
$this->notifyForPendingRequest(
$user,
'Ubah Restock',
'Pengajuan ubah restock menunggu verifikasi owner.',
route('admin.manage.restocks.index', ['search_id' => $restock->id]),
(string) $restock->id,
);
}
$this->cacheForgetByPattern('manage:restocks:*');
}
public function delete(Restock $restock, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
if ($isOwner) {
$this->executeDelete($restock);
$this->cacheForgetByPattern('manage:restocks:*');
return;
}
$restock->load(['items.productVariant.product:id,name']);
$this->runInTransaction(
function () use ($restock, $user): void {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::DELETE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Restock::class,
'subject_id' => $restock->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => $this->snapshotRestock($restock),
'new' => null,
],
]);
},
'Gagal mengajukan penghapusan restock',
);
$this->notifyForPendingRequest(
$user,
'Hapus Restock',
'Pengajuan hapus restock menunggu verifikasi owner.',
route('admin.manage.restocks.index', ['search_id' => $restock->id]),
(string) $restock->id,
);
}
public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void
{
match ($verificationRequest->action) {
OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest),
OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest),
OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest),
default => throw ValidationException::withMessages([
'action' => 'Aksi verifikasi restock tidak didukung.',
]),
};
$this->cacheForgetByPattern('manage:restocks:*');
}
public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void
{
match ($verificationRequest->action) {
OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest),
OwnerVerificationAction::UPDATE, OwnerVerificationAction::DELETE => null,
default => throw ValidationException::withMessages([
'action' => 'Aksi verifikasi restock tidak didukung.',
]),
};
$this->cacheForgetByPattern('manage:restocks:*');
}
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
{
$verificationRequest->clearMediaCollection('photos');
}
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
{
$restock = $verificationRequest->subject;
if (! $restock instanceof Restock) {
throw ValidationException::withMessages([
'restock' => 'Restock tidak ditemukan.',
]);
}
$restock->load('items');
$stockType = $restock->stock_type;
foreach ($restock->items as $item) {
$this->incrementStock($item, $stockType);
}
}
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
{
$restock = $verificationRequest->subject;
if (! $restock instanceof Restock) {
throw ValidationException::withMessages([
'restock' => 'Restock tidak ditemukan.',
]);
}
$this->applyPayloadToRestock($restock, $this->payloadNew($verificationRequest), $verificationRequest);
}
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
{
$restock = $verificationRequest->subject;
if (! $restock instanceof Restock) {
throw ValidationException::withMessages([
'restock' => 'Restock tidak ditemukan.',
]);
}
$this->executeDelete($restock);
}
private function processRequestItems(array $items): array
{
$resolvedItems = [];
foreach ($items as $index => $itemData) {
$variant = ProductVariant::query()->find($itemData['product_variant_id']);
if ($variant === null) {
throw ValidationException::withMessages([
"items.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.',
]);
}
$quantity = (float) $itemData['quantity'];
$unitPrice = (int) $itemData['unit_price'];
$lineSubtotal = (int) round($quantity * $unitPrice);
$resolvedItems[] = [
'product_variant_id' => $variant->id,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $lineSubtotal,
];
}
return $resolvedItems;
}
private function syncPhotos(Restock $restock, array $validated): void
{
$this->mediaService->syncCollection(
$restock,
'photos',
$validated['photos'] ?? null,
$validated['remove_media_ids'] ?? null,
self::MAX_PHOTOS,
required: false,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
);
}
private function draftItemsQuery(User $user): Builder
{
return RestockItem::query()
->whereNull('restock_id')
->where('user_id', $user->id);
}
private function presentDraftItem(RestockItem $item): array
{
$variant = $item->productVariant;
$product = $variant?->product;
return [
'product_variant_id' => $item->product_variant_id,
'product_name' => $product?->name ?? '',
'variant_name' => $variant?->name ?? '',
'stock' => $variant?->stock ?? 0,
'quantity' => $item->quantity_input,
'unit_price' => $item->unit_price,
'images' => $variant ? MediaPresenter::collection($variant, 'images') : [],
];
}
private function stockColumn(ProductStockQuality $stockType): string
{
return match ($stockType) {
ProductStockQuality::GOOD => 'stock',
ProductStockQuality::REJECT => 'reject_stock',
ProductStockQuality::RETAIL => 'retail_stock',
};
}
private function incrementStock(RestockItem $item, ProductStockQuality $stockType): void
{
ProductVariant::query()
->whereKey($item->product_variant_id)
->increment($this->stockColumn($stockType), $item->quantity);
}
private function decrementStock(RestockItem $item, ProductStockQuality $stockType): void
{
ProductVariant::query()
->whereKey($item->product_variant_id)
->decrement($this->stockColumn($stockType), $item->quantity);
}
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void
{
$ownerUrl = route('admin.manage.restocks.index');
if ($search !== null) {
$ownerUrl = route('admin.manage.restocks.index', ['search_id' => $search]);
}
$this->pushNotificationService->sendToRoles(
"📦 {$typeLabel} Menunggu Persetujuan Owner",
$body,
['owner', 'developer', 'direktur'],
$ownerUrl,
);
$this->pushNotificationService->sendToUser(
'📤 Pengajuan Terkirim',
$body,
$user->id,
$submitterUrl,
);
}
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
{
$restock = $verificationRequest->subject;
if (! $restock instanceof Restock) {
return;
}
$this->runInTransaction(
function () use ($restock): void {
$restock->clearMediaCollection('photos');
$restock->items()->delete();
$restock->delete();
},
'Gagal menolak restock',
);
}
private function executeDelete(Restock $restock): void
{
$this->runInTransaction(
function () use ($restock): void {
$restock->load('items');
$stockType = $restock->stock_type;
foreach ($restock->items as $item) {
$this->decrementStock($item, $stockType);
}
$restock->clearMediaCollection('photos');
$restock->items()->delete();
$restock->delete();
},
'Gagal menghapus restock',
);
}
private function applyPayloadToRestock(
Restock $restock,
array $payload,
?OwnerVerificationRequest $verificationRequest = null,
): void {
$this->runInTransaction(
function () use ($restock, $payload, $verificationRequest): void {
$restock->load('items');
$stockType = $restock->stock_type;
foreach ($restock->items as $item) {
$this->decrementStock($item, $stockType);
}
$restock->items()->delete();
foreach ($payload['items'] ?? [] as $itemData) {
$restockItem = $restock->items()->create([
'product_variant_id' => $itemData['product_variant_id'],
'quantity' => $itemData['quantity'],
'unit_price' => $itemData['unit_price'],
'subtotal' => $itemData['subtotal'],
]);
$this->incrementStock($restockItem, $stockType);
}
$restock->update([
'subtotal' => $payload['subtotal'],
'total' => $payload['total'],
'notes' => $payload['notes'] ?? null,
'stock_type' => $payload['stock_type'] ?? $restock->stock_type,
]);
if ($verificationRequest !== null) {
$this->applyRequestPhotos($verificationRequest, $restock, $payload);
}
},
'Gagal memperbarui restock',
);
}
private function snapshotRestock(Restock $restock): array
{
$restock->load([
'items.productVariant.product:id,name',
]);
return [
'subtotal' => $restock->subtotal,
'total' => $restock->total,
'notes' => $restock->notes,
'stock_type' => $restock->stock_type->value,
'items' => $restock->items
->map(fn (RestockItem $item) => [
'product_variant_id' => $item->product_variant_id,
'product_name' => $item->productVariant?->product?->name,
'variant_name' => $item->productVariant?->name,
'quantity' => (float) $item->quantity,
'unit_price' => $item->unit_price,
'subtotal' => $item->subtotal,
])
->all(),
'has_photos' => $restock->hasMedia('photos'),
];
}
private function buildPayloadFromValidated(array $validated): array
{
$lineItems = $this->enrichLineItems($this->processRequestItems($validated['items']));
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
return [
'subtotal' => $subtotal,
'total' => $subtotal,
'notes' => $validated['notes'] ?? null,
'stock_type' => $validated['stock_type'] ?? 'good',
'items' => $lineItems,
'remove_media_ids' => $validated['remove_media_ids'] ?? [],
];
}
private function enrichLineItems(array $lineItems): array
{
$variants = ProductVariant::query()
->with('product:id,name')
->whereIn('id', array_column($lineItems, 'product_variant_id'))
->get()
->keyBy('id');
return collect($lineItems)
->map(function (array $item) use ($variants) {
$variant = $variants->get($item['product_variant_id']);
return array_merge($item, [
'product_name' => $variant?->product?->name,
'variant_name' => $variant?->name,
]);
})
->all();
}
private function syncRequestPhotos(OwnerVerificationRequest $verificationRequest, array $validated): void
{
$this->mediaService->syncCollection(
$verificationRequest,
'photos',
$validated['photos'] ?? null,
$validated['remove_media_ids'] ?? null,
self::MAX_PHOTOS,
required: false,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
);
}
private function applyRequestPhotos(
OwnerVerificationRequest $verificationRequest,
Restock $restock,
array $payload,
): void {
if ($verificationRequest->hasMedia('photos')) {
$restock->clearMediaCollection('photos');
foreach ($verificationRequest->getMedia('photos') as $media) {
$media->copy($restock, 'photos');
}
return;
}
foreach ($payload['remove_media_ids'] ?? [] as $mediaId) {
$restock->deleteMedia((int) $mediaId);
}
}
private function payloadNew(OwnerVerificationRequest $verificationRequest): array
{
$payload = $verificationRequest->payload ?? [];
if (array_key_exists('new', $payload)) {
return is_array($payload['new']) ? $payload['new'] : [];
}
return is_array($payload) ? $payload : [];
}
private function breakItemCircularReference(RestockItem $item): void
{
$variant = $item->productVariant;
if ($variant) {
$product = $variant->product;
$item->setAttribute('variant_name', $variant->name);
$item->setAttribute('stock', $variant->stock ?? 0);
if ($product) {
$item->setAttribute('product_id', $product->id);
$item->setAttribute('product_name', $product->name);
}
$variant->unsetRelation('product');
}
$item->unsetRelation('productVariant');
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'total', 'subtotal'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -27,6 +27,8 @@
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\Rejection;
use App\Models\Restock;
use App\Models\RestockItem;
use App\Models\Supplier;
use App\Models\SystemConfiguration;
use App\Models\User;
@ -64,6 +66,8 @@ class ModelLabel
RawMaterial::class => 'Bahan Baku',
RawMaterialPrice::class => 'Harga Bahan Baku',
Rejection::class => 'Penolakan',
Restock::class => 'Restock',
RestockItem::class => 'Item Restock',
Supplier::class => 'Supplier',
SystemConfiguration::class => 'Konfigurasi Sistem',
User::class => 'Pengguna',

View File

@ -0,0 +1,32 @@
<?php
use App\Enums\ProductStockQuality;
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('restocks', function (Blueprint $table) {
$table->id();
$table->foreignId('created_by_id')->constrained('users')->cascadeOnDelete();
$table->unsignedBigInteger('subtotal');
$table->unsignedBigInteger('total');
$table->string('notes', 100)->nullable();
$table->enum('stock_type', ProductStockQuality::values())->default(ProductStockQuality::GOOD->value);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('restocks');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('restock_items', function (Blueprint $table) {
$table->id();
$table->foreignId('restock_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('product_variant_id')->constrained()->cascadeOnDelete();
$table->integer('quantity');
$table->unsignedBigInteger('unit_price');
$table->unsignedBigInteger('subtotal');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('restock_items');
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Banknote, CalendarDays, Clock, ClipboardList, FolderTree, History, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, Shield, ShoppingBag, ShoppingCart, TrendingUp, User, UserCheck, Users, Wallet, WalletCards, Warehouse } from '@lucide/vue';
import { Banknote, CalendarDays, Clock, ClipboardList, FolderTree, History, Layers, LayoutDashboard, Package, PackagePlus, Receipt, Scissors, Settings2, Shield, ShoppingBag, ShoppingCart, TrendingUp, User, UserCheck, Users, Wallet, WalletCards, Warehouse } from '@lucide/vue';
import {
Sidebar,
SidebarContent,
@ -56,6 +56,7 @@ const menuGroups: MenuGroup[] = [
items: [
{ title: 'Belanja', href: admin.manage.purchases.index.url(), icon: ShoppingBag, permission: 'purchases.view' },
{ title: 'Cutting', href: admin.manage.cuttings.index.url(), icon: Scissors, permission: 'cuttings.view' },
{ title: 'Restock', href: admin.manage.restocks.index.url(), icon: PackagePlus, permission: 'restocks.view' },
{ title: 'Stok Gudang', href: admin.manage.stocks.index.url(), icon: Warehouse, permission: 'stocks.view', badgeKey: 'pendingCuttings' },
{ title: 'Stok Opname', href: admin.manage.stokOpnames.index.url(), icon: ClipboardList, permission: 'stok_opnames.view' },
{ title: 'Pesanan', href: admin.manage.orders.index.url(), icon: ShoppingCart, permission: 'orders.view' },

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, store } from '@/routes/admin/manage/restocks';
import type {
RestockCartItem,
RestockProductCatalogItem,
} from '@/types/restock';
import RestockPosForm from './form/RestockPosForm.vue';
defineProps<{
productCatalog: RestockProductCatalogItem[];
draftItems: RestockCartItem[];
}>();
</script>
<template>
<Head title="Tambah Restock" />
<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">
Tambah Restock
</h2>
</div>
<BackButton :href="index.url()" />
</div>
<RestockPosForm
:product-catalog="productCatalog"
:draft-items="draftItems"
:submit-url="store.url()"
method="post"
submit-label="Simpan"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, update } from '@/routes/admin/manage/restocks';
import type {
RestockCartItem,
RestockEditItem,
RestockProductCatalogItem,
} from '@/types/restock';
import RestockPosForm from './form/RestockPosForm.vue';
const props = defineProps<{
restock: RestockEditItem;
productCatalog: RestockProductCatalogItem[];
}>();
const initialData = computed(() => ({
notes: props.restock.notes ?? '',
stock_type: props.restock.stock_type ?? 'good',
photos: props.restock.photos ? [props.restock.photos] : [],
items: props.restock.items.map((item) => ({
product_variant_id: item.product_variant_id,
product_name: item.product_name,
variant_name: item.variant_name,
stock: item.stock ?? 0,
quantity: item.quantity_input,
unit_price: item.unit_price,
images: item.product_variant?.images ?? [],
})),
}));
</script>
<template>
<Head title="Ubah Restock" />
<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">
Ubah Restock
</h2>
</div>
<BackButton :href="index.url()" />
</div>
<RestockPosForm
:product-catalog="productCatalog"
:initial-data="initialData"
:submit-url="update.url(restock.id)"
method="put"
submit-label="Perbarui"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,90 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import {
useDataTableQuery,
useDataTableQuerySync,
} from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, create } from '@/routes/admin/manage/restocks';
import type { PaginatedRestocks } from '@/types/restock';
import RestockGroupedTable from './table/RestockGroupedTable.vue';
const props = defineProps<{
restocks: PaginatedRestocks;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const { setSearch, resetFilters, syncFromServer } = useDataTableQuery({
url: index.url(),
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const tablePagination = computed(() => ({
currentPage: props.restocks.current_page,
perPage: props.restocks.per_page,
lastPage: props.restocks.last_page,
total: props.restocks.total,
}));
const firstItem = computed(() =>
props.restocks.data.length > 0
? (props.restocks.current_page - 1) * props.restocks.per_page + 1
: 0,
);
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Restock" />
<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">Restock</h2>
</div>
<CreateButton
v-if="can('restocks.create')"
:href="create.url()"
/>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<RestockGroupedTable
v-model:search="search"
:restocks="restocks.data"
:first-item="firstItem"
:pagination="tablePagination"
:pagination-links="restocks.links"
@filters-reset="resetFilters"
/>
</CardContent>
</Card>
</AdminLayout>
</template>

View File

@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import { Trash2 } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import { formatRupiah } from '@/lib/rupiah';
import type { RestockCartItem } from '@/types/restock';
const props = defineProps<{
cart: RestockCartItem[];
lineSubtotal: (item: RestockCartItem) => number;
total: number;
}>();
const emit = defineEmits<{
remove: [index: number];
'adjust-quantity': [index: number, delta: number];
'set-quantity': [index: number, value: string];
}>();
const open = defineModel<boolean>('open', { required: true });
function onQtyInput(index: number, event: Event) {
emit('set-quantity', index, (event.target as HTMLInputElement).value);
}
const hasItems = computed(() => props.cart.length > 0);
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg min-w-0 max-h-[90dvh] flex flex-col overflow-y-auto">
<DialogHeader>
<DialogTitle>Detail Keranjang</DialogTitle>
</DialogHeader>
<div v-if="!hasItems" class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
<EmptyDescription>
Pilih produk untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="scrollbar-thin max-h-96 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="(item, index) in cart"
:key="`detail-${item.product_variant_id}`"
class="rounded-lg border p-3"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.product_name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ item.variant_name }}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="emit('remove', index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<label class="mt-2 block text-xs text-muted-foreground">
Jumlah (pcs)
</label>
<div class="mt-1 flex items-center gap-1">
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded border text-sm transition-colors hover:bg-muted"
@click="emit('adjust-quantity', index, -1)"
>
&minus;
</button>
<input
type="text"
inputmode="decimal"
:value="item.quantity"
class="h-8 w-full rounded border bg-transparent px-2 text-center text-sm focus:outline-none focus:ring-1 focus:ring-ring"
@change="onQtyInput(index, $event)"
/>
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded border text-sm transition-colors hover:bg-muted"
@click="emit('adjust-quantity', index, 1)"
>
+
</button>
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>@ Rp {{ formatRupiah(item.unit_price) }}</span>
<span class="font-medium text-foreground">Rp {{ formatRupiah(lineSubtotal(item)) }}</span>
</div>
</div>
</div>
<div v-if="hasItems" class="border-t pt-3">
<div class="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,110 @@
<script setup lang="ts">
import { Minus, Plus, Trash2 } from '@lucide/vue';
import { DecimalInput } from '@/components/form/decimal-input';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Field,
FieldLabel,
} from '@/components/ui/field';
import { formatRupiah } from '@/lib/rupiah';
import type { RestockCartItem } from '@/types/restock';
defineProps<{
cart: RestockCartItem[];
lineSubtotal: (item: RestockCartItem) => number;
}>();
const emit = defineEmits<{
remove: [index: number];
'adjust-quantity': [index: number, delta: number];
'sync-quantity': [index: number];
}>();
</script>
<template>
<div v-if="cart.length === 0" class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
<EmptyDescription>
Pilih produk untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="(item, index) in cart"
:key="item.product_variant_id"
class="rounded-lg border p-3"
>
<div class="flex gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.product_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="emit('remove', index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<Field>
<FieldLabel class="text-xs">Jumlah (pcs)</FieldLabel>
<div class="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
class="size-8 shrink-0"
@click="emit('adjust-quantity', index, -1)"
>
<Minus class="size-3.5" />
</Button>
<DecimalInput
v-model="item.quantity"
class="h-8 text-center"
@change="emit('sync-quantity', index)"
/>
<Button
type="button"
variant="outline"
size="icon"
class="size-8 shrink-0"
@click="emit('adjust-quantity', index, 1)"
>
<Plus class="size-3.5" />
</Button>
</div>
</Field>
<p class="text-xs text-muted-foreground">
Harga satuan Rp {{ formatRupiah(item.unit_price) }}
</p>
<p class="text-right text-sm font-medium">
Rp {{ formatRupiah(lineSubtotal(item)) }}
</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import { Minus, Plus, Search } from '@lucide/vue';
import { ref } from 'vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { RestockCartItem, RestockProductCatalogItem } from '@/types/restock';
import type { RestockCatalogVariant } from './useRestockPosCart';
const props = defineProps<{
filteredProducts: RestockProductCatalogItem[];
getCartItem: (variantId: number) => RestockCartItem | undefined;
isCreateMode: boolean;
}>();
const previewOpen = ref(false);
const previewUrl = ref<string | null>(null);
const previewTitle = ref('');
const productSearch = defineModel<string>('search', { required: true });
const emit = defineEmits<{
'add-product': [product: RestockProductCatalogItem, variant: RestockCatalogVariant];
'decrease-qty': [variantId: number];
}>();
function handleThumbClick(variantId: number) {
for (const product of props.filteredProducts) {
const variant = product.variants.find((v) => v.id === variantId);
if (variant && variant.images && variant.images.length > 0) {
previewUrl.value = variant.images[0].url;
previewTitle.value = `${product.name} - ${variant.name}`;
previewOpen.value = true;
break;
}
}
}
</script>
<template>
<Card class="min-w-0">
<CardHeader class="flex flex-row items-center justify-between pb-3 space-y-0">
<CardTitle class="text-base">Pilih Produk</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="productSearch" placeholder="Cari produk..." class="pl-9" />
</div>
<div v-if="filteredProducts.length === 0" class="py-8">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan produk.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<PosCatalogCard v-for="product in filteredProducts" :key="product.id" :title="product.name"
:cover-image="getFirstCoverImage(product.variants)">
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div v-for="variant in product.variants" :key="variant.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
'cursor-pointer hover:bg-muted/30',
getCartItem(variant.id)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '',
]" @click="!getCartItem(variant.id) && emit('add-product', product, variant)">
<PosCatalogVariantThumb :items="variant.images" custom-preview
@click-thumb="handleThumbClick(variant.id)" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ variant.name }}
</p>
<p class="text-xs text-muted-foreground">
<span class="tabular-nums">Stok: {{ variant.stock }} pcs</span>
</p>
</div>
<div v-if="getCartItem(variant.id)" class="flex shrink-0 items-center gap-1.5">
<Button type="button" variant="outline" size="icon-sm"
@click.stop="emit('decrease-qty', variant.id)">
<Minus class="size-3.5" />
</Button>
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
{{ getCartItem(variant.id)!.quantity }}
</span>
<Button type="button" variant="outline" size="icon-sm"
@click.stop="emit('add-product', product, variant)">
<Plus class="size-3.5" />
</Button>
</div>
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
@click.stop="emit('add-product', product, variant)">
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
</div>
</CardContent>
</Card>
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :title="previewTitle" />
</template>

View File

@ -0,0 +1,92 @@
<script setup lang="ts">
import { Save } from '@lucide/vue';
import { computed } from 'vue';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { FormWithErrors } from '@/lib/form';
import { formatRupiah } from '@/lib/rupiah';
import type { MediaUploadState } from '@/types/media';
defineProps<{
form: FormWithErrors & {
stock_type: string;
notes: string;
processing?: boolean;
};
subtotal: number;
total: number;
cartEmpty: boolean;
submitLabel: string;
}>();
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
const isUploading = computed(() => photoState.value.pendingUploads > 0);
</script>
<template>
<Separator />
<div class="space-y-2 text-sm">
<div class="flex justify-between text-base font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
</div>
</div>
<Field>
<FieldLabel for="stock_type">Tipe Stok</FieldLabel>
<Select v-model="form.stock_type">
<SelectTrigger id="stock_type">
<SelectValue placeholder="Pilih tipe stok" />
</SelectTrigger>
<SelectContent>
<SelectItem value="good">Bagus</SelectItem>
<SelectItem value="reject">Reject</SelectItem>
<SelectItem value="retail">Eceran</SelectItem>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'stock_type')" />
</Field>
<Field>
<FieldLabel for="notes">Keterangan</FieldLabel>
<Textarea
id="notes"
v-model="form.notes"
placeholder="Masukkan keterangan"
rows="2"
:maxlength="FIELD_LIMITS.notes"
/>
<FieldError :errors="formErrors(form, 'notes')" />
</Field>
<MediaDropzone
id="restock-photos"
v-model="photoState"
label="Bukti Restock"
:max-files="1"
:errors="formErrors(form, 'photos')"
/>
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty || isUploading">
<Save class="size-4" />
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</template>

View File

@ -0,0 +1,279 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ShoppingCart } from '@lucide/vue';
import { computed, onMounted, 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 {
FieldGroup,
FieldSet,
} from '@/components/ui/field';
import { formErrors } from '@/lib/form';
import { appendMediaToFormData, appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaItem, MediaUploadState } from '@/types/media';
import type { RestockCartItem, RestockProductCatalogItem } from '@/types/restock';
import RestockPosCartDetailDialog from './RestockPosCartDetailDialog.vue';
import RestockPosCartSummaryItems from './RestockPosCartSummaryItems.vue';
import RestockPosCatalogPanel from './RestockPosCatalogPanel.vue';
import RestockPosCheckoutSection from './RestockPosCheckoutSection.vue';
import { useRestockPosCart } from './useRestockPosCart';
const props = defineProps<{
productCatalog: RestockProductCatalogItem[];
initialData?: {
notes: string;
items: RestockCartItem[];
photos?: MediaItem[];
};
draftItems?: RestockCartItem[];
submitUrl: string;
method: 'post' | 'put';
submitLabel: string;
}>();
const isCreateMode = computed(() => props.method === 'post');
const photoState = ref<MediaUploadState>(createMediaUploadState());
const cartDetailOpen = ref(false);
const showDeleteConfirm = ref(false);
const itemIndexToDelete = ref<number | null>(null);
const form = useForm({
notes: '',
stock_type: 'good',
});
const {
productSearch,
cart,
filteredProducts,
subtotal,
setCart,
loadDraftItems,
addProduct,
removeItem,
decreaseQty,
updateQuantity,
updateUnitPrice,
getCartItem,
} = useRestockPosCart({
productCatalog: () => props.productCatalog,
isCreateMode: () => isCreateMode.value,
});
onMounted(() => {
if (props.initialData) {
form.notes = props.initialData.notes;
form.stock_type = props.initialData.stock_type ?? 'good';
photoState.value = createMediaUploadState(
props.initialData.photos ?? [],
);
setCart(props.initialData.items);
} else if (props.draftItems && props.draftItems.length > 0) {
loadDraftItems(props.draftItems);
}
});
function confirmRemoveItem(index: number) {
itemIndexToDelete.value = index;
showDeleteConfirm.value = true;
}
async function handleRemoveItem() {
if (itemIndexToDelete.value === null) return;
await removeItem(itemIndexToDelete.value);
itemIndexToDelete.value = null;
showDeleteConfirm.value = false;
}
function lineSubtotal(item: RestockCartItem): number {
const qty = Number(item.quantity) || 0;
return Math.round(qty * item.unit_price);
}
const total = computed(() => subtotal.value);
const isUploading = computed(() =>
photoState.value.pendingUploads > 0,
);
function buildFormData(): FormData {
const formData = new FormData();
if (props.method === 'put') {
formData.append('_method', 'PUT');
}
formData.append('notes', form.notes ?? '');
formData.append('stock_type', form.stock_type ?? 'good');
cart.value.forEach((item, index) => {
formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id));
formData.append(`items[${index}][quantity]`, item.quantity);
formData.append(`items[${index}][unit_price]`, String(item.unit_price));
});
appendRootPhotosToFormData(formData, photoState.value);
return formData;
}
function submit() {
if (cart.value.length === 0) {
toast.error('Tambahkan minimal satu produk.');
return;
}
for (let i = 0; i < cart.value.length; i++) {
const item = cart.value[i];
if (!item.quantity || Number(item.quantity) <= 0) {
toast.error(`Jumlah pada baris ke-${i + 1} harus lebih besar dari 0.`);
return;
}
if (!item.unit_price || item.unit_price <= 0) {
toast.error(`Harga satuan pada baris ke-${i + 1} harus lebih besar dari 0.`);
return;
}
}
const payload = buildFormData();
form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
} else {
const firstError = Object.values(errors)[0];
if (firstError) {
toast.error(firstError);
} else {
toast.error('Perbaiki kesalahan pada form.');
}
}
},
});
}
</script>
<template>
<div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_380px]">
<!-- Left Column: Product Catalog -->
<div class="space-y-6">
<RestockPosCatalogPanel
:filtered-products="filteredProducts"
:get-cart-item="getCartItem"
:is-create-mode="isCreateMode"
@add-product="addProduct"
@decrease-qty="decreaseQty"
@update:search="productSearch = $event"
/>
</div>
<!-- Right Column: Summary Panel (Ringkasan Restock) -->
<Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3">
<CardTitle class="flex items-center justify-between gap-2 text-base">
<span class="flex items-center gap-2">
<ShoppingCart class="size-4" />
Ringkasan Restock
</span>
</CardTitle>
</CardHeader>
<CardContent>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<!-- Cart items -->
<RestockPosCartSummaryItems
:cart="cart"
:line-subtotal="lineSubtotal"
@remove="confirmRemoveItem"
@adjust-quantity="(index, delta) => {
const item = cart[index];
if (item) {
const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty <= 0) {
confirmRemoveItem(index);
} else {
updateQuantity(item.product_variant_id, String(nextQty));
}
}
}"
@sync-quantity="(index) => {
const item = cart[index];
if (item) {
updateQuantity(item.product_variant_id, item.quantity);
}
}"
/>
<!-- Totals, Notes, Photo, Submit -->
<RestockPosCheckoutSection
v-model:photo-state="photoState"
:form="form"
:subtotal="subtotal"
:total="total"
:cart-empty="cart.length === 0 || isUploading"
:submit-label="submitLabel"
/>
</FieldSet>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
<!-- Floating Cart button on mobile -->
<button
v-if="cart.length > 0"
type="button"
class="fixed top-50 right-6 z-50 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 xl:hidden"
@click="cartDetailOpen = true"
>
<ShoppingCart class="size-6" />
<span class="absolute -right-1 -top-1 flex size-6 items-center justify-center rounded-full bg-destructive text-[11px] font-bold text-destructive-foreground">
{{ cart.length > 99 ? '99+' : cart.length }}
</span>
</button>
<!-- Cart detail dialog (mobile) -->
<RestockPosCartDetailDialog
v-model:open="cartDetailOpen"
:cart="cart"
:line-subtotal="lineSubtotal"
:total="total"
@remove="confirmRemoveItem"
@adjust-quantity="(index, delta) => {
const item = cart[index];
if (item) {
const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty <= 0) {
confirmRemoveItem(index);
} else {
updateQuantity(item.product_variant_id, String(nextQty));
}
}
}"
@set-quantity="(index, value) => {
const item = cart[index];
if (item) {
updateQuantity(item.product_variant_id, value);
}
}"
/>
<!-- Confirm deletion dialog -->
<ConfirmDialog
v-model:open="showDeleteConfirm"
title="Hapus Item?"
description="Apakah Anda yakin ingin menghapus item ini dari keranjang?"
confirm-label="Hapus"
cancel-label="Batal"
destructive
@confirm="handleRemoveItem"
/>
</template>

View File

@ -0,0 +1,196 @@
import { computed, ref, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { toast } from 'vue-sonner';
import { apiFetch } from '@/lib/api';
import draft_items from '@/routes/admin/manage/restocks/draft_items';
import type {
RestockCartItem,
RestockProductCatalogItem,
} from '@/types/restock';
export type RestockCatalogVariant = RestockProductCatalogItem['variants'][number] & { harga_modal: number };
export function useRestockPosCart(options: {
productCatalog: MaybeRefOrGetter<RestockProductCatalogItem[]>;
isCreateMode: MaybeRefOrGetter<boolean>;
}) {
const productSearch = ref('');
const cart = ref<RestockCartItem[]>([]);
function setCart(items: RestockCartItem[]) {
cart.value = items.map((item) => ({ ...item }));
}
function loadDraftItems(items: RestockCartItem[]) {
if (!toValue(options.isCreateMode)) {
return;
}
if (items.length > 0) {
cart.value = items.map((item) => ({ ...item }));
}
}
const filteredProducts = computed(() => {
const keyword = productSearch.value.trim().toLowerCase();
const catalog = toValue(options.productCatalog);
if (!keyword) {
return catalog;
}
return catalog.filter(
(product) =>
product.name.toLowerCase().includes(keyword)
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)),
);
});
const subtotal = computed(() =>
cart.value.reduce((sum, item) => {
const qty = Number(item.quantity) || 0;
return sum + Math.round(qty * item.unit_price);
}, 0),
);
function upsertCartItem(item: RestockCartItem) {
const index = cart.value.findIndex(
(cartItem) => cartItem.product_variant_id === item.product_variant_id,
);
if (index === -1) {
cart.value.push({ ...item });
return;
}
cart.value[index] = { ...item };
}
async function syncDraftItem(variantId: number, quantity: string, unitPrice: number) {
const { item } = await apiFetch<{ item: RestockCartItem }>(draft_items.store.url(), {
method: 'POST',
body: JSON.stringify({
product_variant_id: variantId,
quantity,
unit_price: unitPrice,
}),
});
upsertCartItem(item);
}
function getCartItem(variantId: number): RestockCartItem | undefined {
return cart.value.find((item) => item.product_variant_id === variantId);
}
function addProduct(product: RestockProductCatalogItem, variant: RestockCatalogVariant) {
const existing = cart.value.find(
(item) => item.product_variant_id === variant.id,
);
if (existing) {
existing.quantity = String((Number(existing.quantity) || 0) + 1);
trySyncToBackend(existing);
return;
}
cart.value.push({
product_variant_id: variant.id,
product_name: product.name,
variant_name: variant.name,
stock: variant.stock,
quantity: '1',
unit_price: variant.harga_modal ?? 0,
images: variant.images ?? [],
});
}
async function trySyncToBackend(item: RestockCartItem) {
if (!toValue(options.isCreateMode)) return;
if (!item.unit_price || item.unit_price <= 0) return;
if (!item.quantity || Number(item.quantity) <= 0) return;
try {
await syncDraftItem(item.product_variant_id, item.quantity, item.unit_price);
} catch (error) {
console.error(error);
}
}
async function removeItem(index: number) {
const item = cart.value[index];
if (toValue(options.isCreateMode) && item.unit_price > 0) {
try {
await apiFetch(draft_items.destroy.url(item.product_variant_id), {
method: 'DELETE',
});
} catch (error) {
// Item might not exist in DB yet, ignore
}
}
cart.value.splice(index, 1);
}
async function decreaseQty(variantId: number) {
const item = cart.value.find((i) => i.product_variant_id === variantId);
if (!item) {
return;
}
const nextQty = (Number(item.quantity) || 0) - 1;
if (nextQty <= 0) {
const index = cart.value.findIndex((i) => i.product_variant_id === variantId);
if (index !== -1) {
await removeItem(index);
}
return;
}
item.quantity = String(nextQty);
await trySyncToBackend(item);
}
async function updateQuantity(variantId: number, quantity: string) {
const item = cart.value.find((i) => i.product_variant_id === variantId);
if (!item) {
return;
}
item.quantity = quantity;
await trySyncToBackend(item);
}
async function updateUnitPrice(variantId: number, unitPrice: number) {
const item = cart.value.find((i) => i.product_variant_id === variantId);
if (!item) {
return;
}
item.unit_price = unitPrice;
await trySyncToBackend(item);
}
return {
productSearch,
cart,
filteredProducts,
subtotal,
setCart,
loadDraftItems,
addProduct,
removeItem,
decreaseQty,
updateQuantity,
updateUnitPrice,
getCartItem,
};
}

View File

@ -0,0 +1,194 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import VerificationDetailModal from '@/components/owner-verification/VerificationDetailModal.vue';
import { Badge } from '@/components/ui/badge';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { usePaginationSummary } from '@/composables/usePaginationSummary';
import { groupedTableRowNumber } from '@/lib/grouped-table';
import type {
DataTablePagination,
DataTablePaginationLink,
} from '@/types/data-table';
import type { RestockItemDetail, RestockListItem } from '@/types/restock';
import DataTableActions from './data-table-actions.vue';
const props = defineProps<{
restocks: RestockListItem[];
firstItem?: number;
pagination?: DataTablePagination;
paginationLinks?: DataTablePaginationLink[];
}>();
const search = defineModel<string>('search', { default: '' });
const emit = defineEmits<{
'filters-reset': [];
}>();
const showingCount = computed(() => props.restocks.length);
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'restock');
function rowNumber(index: number): number {
return groupedTableRowNumber(props.firstItem, index);
}
interface GroupedRestockItems {
productId: number;
productName: string;
items: RestockItemDetail[];
}
function getGroupedItems(items: RestockItemDetail[]): GroupedRestockItems[] {
const groups: Record<number, GroupedRestockItems> = {};
items.forEach((item) => {
const productId = item.product_variant?.product?.id ?? 0;
const productName = item.product_name || 'Produk Tidak Diketahui';
if (!groups[productId]) {
groups[productId] = {
productId,
productName,
items: [],
};
}
groups[productId].items.push(item);
});
return Object.values(groups);
}
const verificationModalOpen = ref(false);
const selectedRequestId = ref<number | null>(null);
function openVerificationDetail(requestId: number | undefined) {
if (requestId) {
selectedRequestId.value = requestId;
verificationModalOpen.value = true;
}
}
</script>
<template>
<div class="space-y-4">
<DataTableToolbar v-model:search="search" @filters-reset="emit('filters-reset')" />
<div v-if="restocks.length" class="space-y-4">
<div v-for="(restock, index) in restocks" :key="restock.id" class="overflow-hidden rounded-md border">
<div
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
{{ rowNumber(index) }}
</span>
<div class="min-w-0 space-y-2">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="font-medium leading-tight">
Restock #{{ restock.id }}
</h3>
<Badge variant="secondary">{{ restock.stock_type_label }}</Badge>
<Badge v-if="restock.has_pending_request" variant="outline" class="border-amber-500 text-amber-600">
Menunggu Verifikasi
</Badge>
<Badge v-else variant="outline" class="border-green-500 text-green-600">
Terverifikasi
</Badge>
</div>
<p v-if="restock.has_pending_request" class="text-sm text-amber-600">
{{ restock.pending_request_submitted_by_name }} mengajukan {{ restock.pending_request_action_label?.toLowerCase() }} restock ini
<button type="button" class="underline-offset-2 hover:underline" @click="openVerificationDetail(restock.pending_request_id)">lihat</button>
</p>
<div class="text-muted-foreground space-y-1 text-sm">
<p>{{ restock.created_at_formatted }}</p>
<p>Oleh {{ restock.created_by?.profile?.full_name ?? restock.created_by?.username }}
</p>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span>Total <strong class="text-primary">{{ restock.total_formatted }}</strong></span>
</div>
<p v-if="restock.notes" class="text-muted-foreground text-sm">
{{ restock.notes }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<MediaThumbnailCell :items="restock.photos ? [restock.photos] : []" />
<DataTableActions :restock="restock" />
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead>Jumlah</TableHead>
<TableHead>Harga Satuan</TableHead>
<TableHead>Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!restock.items.length" :key="`${restock.id}-empty`">
<TableCell colspan="4" class="text-muted-foreground">
Belum ada item
</TableCell>
</TableRow>
<template v-else v-for="group in getGroupedItems(restock.items)" :key="group.productId">
<TableRow class="bg-muted/20 hover:bg-muted/20">
<TableCell colspan="4" class="font-semibold text-foreground">
{{ group.productName }}
</TableCell>
</TableRow>
<TableRow v-for="item in group.items" :key="item.id">
<TableCell class="pl-6 font-medium">
{{ item.variant_name }}
</TableCell>
<TableCell class="tabular-nums">
{{ item.quantity_formatted }}
</TableCell>
<TableCell class="tabular-nums">
{{ item.unit_price_formatted }}
</TableCell>
<TableCell class="tabular-nums font-medium">
{{ item.subtotal_formatted }}
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</div>
<div v-else class="rounded-md border px-6 py-10">
<Empty>
<EmptyHeader>
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan data yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" />
<VerificationDetailModal v-model:open="verificationModalOpen" :request-id="selectedRequestId" />
</div>
</template>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { useCan } from '@/composables/useCan';
import { edit, destroy } from '@/routes/admin/manage/restocks';
import type { RestockListItem } from '@/types/restock';
defineProps<{
restock: RestockListItem;
}>();
const { can } = useCan();
</script>
<template>
<div class="flex items-center justify-end gap-1">
<OwnerVerificationRowActions v-if="restock.has_pending_request && restock.pending_request_id" type="request"
:id="restock.pending_request_id" />
<RowEditAction v-if="can('restocks.update')" :href="edit.url(restock.id)"
:disabled="restock.has_pending_request"
:tooltip="restock.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'" />
<RowDeleteAction v-if="can('restocks.delete')" :action-url="destroy.url(restock.id)"
:disabled="restock.has_pending_request"
:tooltip="restock.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'" title="Hapus restock?"
:description="`Pengajuan hapus restock ${restock.total_formatted} akan dikirim ke owner untuk verifikasi.`"
error-message="Gagal mengajukan penghapusan restock." />
</div>
</template>

View File

@ -0,0 +1,92 @@
import type { Paginated } from '@/types/common';
import type { MediaItem } from '@/types/media';
import type { ProductListItem } from '@/types/product';
export type RestockProductCatalogItem = ProductListItem & {
variants: Array<ProductListItem['variants'][number] & {
harga_modal: number;
}>;
};
export type RestockItemListItem = {
id: number;
product_name: string;
variant_name: string;
quantity_formatted: string;
unit_price_formatted: string;
subtotal_formatted: string;
};
export type RestockItemDetail = RestockItemListItem & {
product_variant_id?: number;
product_variant?: {
id: number;
name: string;
stock?: number;
product?: {
id: number;
name: string;
};
};
};
export type RestockListItem = {
id: number;
stock_type: string;
stock_type_label: string;
subtotal_formatted: string;
total_formatted: string;
notes: string | null;
created_at_formatted: string;
created_by_name?: string;
created_by?: {
username: string;
profile?: {
full_name: string;
} | null;
} | null;
photos?: MediaItem | null;
items: RestockItemDetail[];
has_pending_request?: boolean;
pending_request_id?: number;
pending_request_action?: string;
pending_request_action_label?: string;
pending_request_submitted_by_name?: string;
};
export type RestockCartItem = {
product_variant_id: number;
product_name: string;
variant_name: string;
stock: number;
quantity: string;
unit_price: number;
images?: MediaItem[];
};
export type RestockFormData = {
notes: string;
photos: File[];
remove_media_ids: number[];
items: RestockCartItem[];
};
export type RestockEditItem = {
id: number;
notes: string | null;
stock_type: string;
photos?: MediaItem | null;
items: Array<{
product_variant_id: number;
product_name: string;
variant_name: string;
quantity_input: string;
unit_price: number;
stock?: number;
product_variant?: {
images?: MediaItem[];
};
}>;
};
export type PaginatedRestocks = Paginated<RestockListItem>;

View File

@ -21,6 +21,8 @@
use App\Http\Controllers\Admin\Manage\OwnerVerificationController;
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseController;
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseDraftItemController;
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\StockController;
use App\Http\Controllers\Admin\Manage\StokOpnameController;
@ -263,6 +265,49 @@
->name('draft_items.destroy');
});
Route::prefix('restocks')->name('restocks.')
->middleware('permission:'.Permission::RESTOCKS_VIEW->value)
->group(function () {
Route::get('/', [RestockController::class, 'index'])->name('index');
Route::get('create', [RestockController::class, 'create'])
->middleware('permission:'.Permission::RESTOCKS_CREATE->value)
->name('create');
Route::post('/', [RestockController::class, 'store'])
->middleware('permission:'.Permission::RESTOCKS_CREATE->value)
->name('store');
Route::post('draft-items', [RestockDraftItemController::class, 'store'])
->middleware('permission:'.Permission::RESTOCKS_CREATE->value)
->name('draft_items.store');
Route::get('{restock}/edit', [RestockController::class, 'edit'])
->middleware([
'permission:'.Permission::RESTOCKS_UPDATE->value,
'no_pending_owner_verification:restock',
])
->name('edit');
Route::put('{restock}', [RestockController::class, 'update'])
->middleware([
'permission:'.Permission::RESTOCKS_UPDATE->value,
'no_pending_owner_verification:restock',
])
->name('update');
Route::delete('{restock}', [RestockController::class, 'destroy'])
->middleware([
'permission:'.Permission::RESTOCKS_DELETE->value,
'no_pending_owner_verification:restock',
])
->name('destroy');
Route::delete('draft-items/{productVariant}', [RestockDraftItemController::class, 'destroy'])
->middleware('permission:'.Permission::RESTOCKS_CREATE->value)
->name('draft_items.destroy');
});
Route::prefix('orders')->name('orders.')
->middleware('permission:'.Permission::ORDERS_VIEW->value)
->group(function () {

View File

@ -0,0 +1,486 @@
<?php
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission as PermissionEnum;
use App\Enums\ProductStockQuality;
use App\Models\OwnerVerificationRequest;
use App\Models\ProductVariant;
use App\Models\Restock;
use App\Models\RestockItem;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
});
function createRestockUserWithPermission(PermissionEnum ...$permissions): User
{
$user = User::factory()->create();
$user->givePermissionTo(
array_merge(
[PermissionEnum::DASHBOARD_VIEW->value],
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
)
);
$user->forgetCachedPermissions();
return $user;
}
function createRestockOwnerUser(PermissionEnum ...$permissions): User
{
$user = User::factory()->create();
$user->givePermissionTo(
array_merge(
[
PermissionEnum::DASHBOARD_VIEW->value,
PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value,
],
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
)
);
$user->forgetCachedPermissions();
return $user;
}
function createRestockVerifierUser(): User
{
$user = User::factory()->create();
$user->givePermissionTo([
PermissionEnum::DASHBOARD_VIEW->value,
PermissionEnum::OWNER_VERIFICATIONS_VIEW->value,
PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value,
PermissionEnum::OWNER_VERIFICATIONS_REJECT->value,
]);
$user->forgetCachedPermissions();
return $user;
}
function approveLatestRestockVerificationRequest(User $verifier): OwnerVerificationRequest
{
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
test()->actingAs($verifier)
->post(route('admin.manage.owner_verifications.approve_request', $verificationRequest))
->assertRedirect();
return $verificationRequest->fresh();
}
function getRestockItemsPayload(ProductVariant $variant, int $quantity = 2, int $unitPrice = 50000): array
{
return [
[
'product_variant_id' => $variant->id,
'quantity' => $quantity,
'unit_price' => $unitPrice,
],
];
}
describe('Restock CRUD', function () {
test('owner can create restock and stock is incremented immediately', function () {
$user = createRestockOwnerUser(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
);
$variant = ProductVariant::factory()->create(['stock' => 10]);
$initialStock = $variant->stock;
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'notes' => 'Restock test',
'stock_type' => 'good',
'items' => getRestockItemsPayload($variant, 3),
])
->assertRedirect(route('admin.manage.restocks.index'));
$restock = Restock::query()->latest()->first();
expect($restock)->not->toBeNull();
expect($restock->notes)->toBe('Restock test');
expect($restock->stock_type)->toBe(ProductStockQuality::GOOD);
expect($variant->fresh()->stock)->toBe($initialStock + 3);
});
test('owner can create restock with retail stock type', function () {
$user = createRestockOwnerUser(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
);
$variant = ProductVariant::factory()->create(['retail_stock' => 5]);
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'notes' => 'Restock eceran',
'stock_type' => 'retail',
'items' => getRestockItemsPayload($variant, 2),
])
->assertRedirect(route('admin.manage.restocks.index'));
expect($variant->fresh()->retail_stock)->toBe(7);
});
test('owner can create restock with reject stock type', function () {
$user = createRestockOwnerUser(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
);
$variant = ProductVariant::factory()->create(['reject_stock' => 3]);
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'notes' => 'Restock reject',
'stock_type' => 'reject',
'items' => getRestockItemsPayload($variant, 4),
])
->assertRedirect(route('admin.manage.restocks.index'));
expect($variant->fresh()->reject_stock)->toBe(7);
});
test('owner can delete restock and stock is decremented', function () {
$user = createRestockOwnerUser(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
PermissionEnum::RESTOCKS_DELETE,
);
$variant = ProductVariant::factory()->create(['stock' => 10]);
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'stock_type' => 'good',
'items' => getRestockItemsPayload($variant, 3),
])
->assertRedirect();
expect($variant->fresh()->stock)->toBe(13);
$restock = Restock::query()->latest()->first();
$this->actingAs($user)
->delete(route('admin.manage.restocks.destroy', $restock))
->assertRedirect();
expect($variant->fresh()->stock)->toBe(10);
});
test('index page returns ok', function () {
$user = createRestockOwnerUser(PermissionEnum::RESTOCKS_VIEW);
$this->actingAs($user)
->get(route('admin.manage.restocks.index'))
->assertOk();
});
test('create page returns ok', function () {
$user = createRestockOwnerUser(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
);
$this->actingAs($user)
->get(route('admin.manage.restocks.create'))
->assertOk();
});
test('edit page returns ok', function () {
$user = createRestockOwnerUser(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
PermissionEnum::RESTOCKS_UPDATE,
);
$variant = ProductVariant::factory()->create();
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'stock_type' => 'good',
'items' => getRestockItemsPayload($variant),
])
->assertRedirect();
$restock = Restock::query()->latest()->first();
$this->actingAs($user)
->get(route('admin.manage.restocks.edit', $restock))
->assertOk();
});
});
describe('Restock Owner Verification', function () {
test('non-owner create restock submits verification without changing stock', function () {
$user = createRestockUserWithPermission(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
);
$variant = ProductVariant::factory()->create(['stock' => 10]);
$initialStock = $variant->stock;
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'stock_type' => 'good',
'items' => getRestockItemsPayload($variant, 3),
])
->assertRedirect(route('admin.manage.restocks.index'));
$restock = Restock::query()->latest()->first();
expect($restock)->not->toBeNull();
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Restock::class,
'subject_id' => $restock->id,
'action' => OwnerVerificationAction::CREATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect($variant->fresh()->stock)->toBe($initialStock);
});
test('approving create restock increments stock', function () {
$user = createRestockUserWithPermission(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
);
$verifier = createRestockVerifierUser();
$variant = ProductVariant::factory()->create(['stock' => 10]);
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'stock_type' => 'good',
'items' => getRestockItemsPayload($variant, 3),
])
->assertRedirect();
approveLatestRestockVerificationRequest($verifier);
expect($variant->fresh()->stock)->toBe(13);
});
test('rejecting create restock does not change stock and deletes restock', function () {
$user = createRestockUserWithPermission(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
);
$verifier = createRestockVerifierUser();
$variant = ProductVariant::factory()->create(['stock' => 10]);
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'stock_type' => 'good',
'items' => getRestockItemsPayload($variant, 3),
])
->assertRedirect();
$restock = Restock::query()->latest()->first();
$restockId = $restock->id;
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
$this->actingAs($verifier)
->post(route('admin.manage.owner_verifications.reject_request', $verificationRequest))
->assertRedirect();
expect($variant->fresh()->stock)->toBe(10);
expect(Restock::find($restockId))->toBeNull();
expect(Restock::withTrashed()->find($restockId))->not->toBeNull();
});
test('non-owner delete restock submits verification request', function () {
$user = createRestockUserWithPermission(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
PermissionEnum::RESTOCKS_DELETE,
);
$verifier = createRestockVerifierUser();
$variant = ProductVariant::factory()->create(['stock' => 10]);
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'stock_type' => 'good',
'items' => getRestockItemsPayload($variant, 3),
])
->assertRedirect();
$restock = Restock::query()->latest()->first();
approveLatestRestockVerificationRequest($verifier);
expect($variant->fresh()->stock)->toBe(13);
$this->actingAs($user)
->delete(route('admin.manage.restocks.destroy', $restock))
->assertRedirect();
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Restock::class,
'subject_id' => $restock->id,
'action' => OwnerVerificationAction::DELETE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect($variant->fresh()->stock)->toBe(13);
});
test('approving delete restock decrements stock', function () {
$user = createRestockUserWithPermission(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
PermissionEnum::RESTOCKS_DELETE,
);
$verifier = createRestockVerifierUser();
$variant = ProductVariant::factory()->create(['stock' => 10]);
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'stock_type' => 'good',
'items' => getRestockItemsPayload($variant, 3),
])
->assertRedirect();
$restock = Restock::query()->latest()->first();
approveLatestRestockVerificationRequest($verifier);
expect($variant->fresh()->stock)->toBe(13);
$this->actingAs($user)
->delete(route('admin.manage.restocks.destroy', $restock))
->assertRedirect();
approveLatestRestockVerificationRequest($verifier);
expect($variant->fresh()->stock)->toBe(10);
});
test('non-owner update restock submits verification request', function () {
$user = createRestockUserWithPermission(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
PermissionEnum::RESTOCKS_UPDATE,
);
$verifier = createRestockVerifierUser();
$variant = ProductVariant::factory()->create(['stock' => 10]);
$otherVariant = ProductVariant::factory()->create(['stock' => 5]);
$this->actingAs($user)
->post(route('admin.manage.restocks.store'), [
'stock_type' => 'good',
'items' => getRestockItemsPayload($variant, 2),
])
->assertRedirect();
$restock = Restock::query()->latest()->first();
approveLatestRestockVerificationRequest($verifier);
expect($variant->fresh()->stock)->toBe(12);
$this->actingAs($user)
->put(route('admin.manage.restocks.update', $restock), [
'stock_type' => 'good',
'items' => getRestockItemsPayload($otherVariant, 4),
])
->assertRedirect();
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Restock::class,
'subject_id' => $restock->id,
'action' => OwnerVerificationAction::UPDATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
});
});
describe('Restock Draft Items', function () {
test('draft item can be created and deleted', function () {
$user = createRestockOwnerUser(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
);
$variant = ProductVariant::factory()->create();
$this->actingAs($user)
->postJson(route('admin.manage.restocks.draft_items.store'), [
'product_variant_id' => $variant->id,
'quantity' => 5,
'unit_price' => 50000,
])
->assertOk();
$this->assertDatabaseHas('restock_items', [
'user_id' => $user->id,
'product_variant_id' => $variant->id,
'quantity' => 5,
'unit_price' => 50000,
'restock_id' => null,
]);
$itemId = RestockItem::where('user_id', $user->id)
->where('product_variant_id', $variant->id)
->whereNull('restock_id')
->value('id');
$this->actingAs($user)
->deleteJson(route('admin.manage.restocks.draft_items.destroy', ['productVariant' => $variant->id]))
->assertOk();
$this->assertDatabaseMissing('restock_items', ['id' => $itemId]);
});
test('draft items are cleared when opening create page', function () {
$user = createRestockOwnerUser(
PermissionEnum::RESTOCKS_VIEW,
PermissionEnum::RESTOCKS_CREATE,
);
$variant = ProductVariant::factory()->create();
$this->actingAs($user)
->postJson(route('admin.manage.restocks.draft_items.store'), [
'product_variant_id' => $variant->id,
'quantity' => 5,
'unit_price' => 50000,
])
->assertOk();
$itemId = RestockItem::where('user_id', $user->id)
->where('product_variant_id', $variant->id)
->whereNull('restock_id')
->value('id');
expect($itemId)->not->toBeNull();
$this->actingAs($user)
->get(route('admin.manage.restocks.create'))
->assertOk();
expect(RestockItem::find($itemId))->toBeNull();
});
});