feat: implement restock management features including CRUD operations, permissions, and UI components for creating and editing restocks
This commit is contained in:
parent
f6af44f773
commit
62beb309a8
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Admin/Manage/RestockDraftItemRequest.php
Normal file
31
app/Http/Requests/Admin/Manage/RestockDraftItemRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
55
app/Http/Requests/Admin/Manage/RestockRequest.php
Normal file
55
app/Http/Requests/Admin/Manage/RestockRequest.php
Normal 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
98
app/Models/Restock.php
Normal 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();
|
||||
}
|
||||
}
|
||||
80
app/Models/RestockItem.php
Normal file
80
app/Models/RestockItem.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
782
app/Services/Manage/RestockService.php
Normal file
782
app/Services/Manage/RestockService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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' },
|
||||
|
||||
42
resources/js/pages/admin/manage/restocks/Create.vue
Normal file
42
resources/js/pages/admin/manage/restocks/Create.vue
Normal 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>
|
||||
59
resources/js/pages/admin/manage/restocks/Edit.vue
Normal file
59
resources/js/pages/admin/manage/restocks/Edit.vue
Normal 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>
|
||||
90
resources/js/pages/admin/manage/restocks/Index.vue
Normal file
90
resources/js/pages/admin/manage/restocks/Index.vue
Normal 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>
|
||||
@ -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)"
|
||||
>
|
||||
−
|
||||
</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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
279
resources/js/pages/admin/manage/restocks/form/RestockPosForm.vue
Normal file
279
resources/js/pages/admin/manage/restocks/form/RestockPosForm.vue
Normal 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>
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
92
resources/js/types/restock.ts
Normal file
92
resources/js/types/restock.ts
Normal 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>;
|
||||
@ -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 () {
|
||||
|
||||
486
tests/Feature/Admin/Manage/RestockTest.php
Normal file
486
tests/Feature/Admin/Manage/RestockTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user