Compare commits
No commits in common. "9b20d26a4ebaef7c3c1ac43d4cd21bcda15291d2" and "5c1f92e48b3fbb44bd7bc1ca106e5ae4a1b4acd7" have entirely different histories.
9b20d26a4e
...
5c1f92e48b
@ -85,11 +85,6 @@ enum Permission: string
|
|||||||
case PURCHASES_UPDATE = 'purchases.update';
|
case PURCHASES_UPDATE = 'purchases.update';
|
||||||
case PURCHASES_DELETE = 'purchases.delete';
|
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_VIEW = 'orders.view';
|
||||||
case ORDERS_CREATE = 'orders.create';
|
case ORDERS_CREATE = 'orders.create';
|
||||||
case ORDERS_UPDATE = 'orders.update';
|
case ORDERS_UPDATE = 'orders.update';
|
||||||
@ -238,11 +233,6 @@ public function label(): string
|
|||||||
self::PURCHASES_UPDATE => 'Ubah Belanja',
|
self::PURCHASES_UPDATE => 'Ubah Belanja',
|
||||||
self::PURCHASES_DELETE => 'Hapus 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_VIEW => 'Lihat Pesanan',
|
||||||
self::ORDERS_CREATE => 'Tambah Pesanan',
|
self::ORDERS_CREATE => 'Tambah Pesanan',
|
||||||
self::ORDERS_UPDATE => 'Ubah Pesanan',
|
self::ORDERS_UPDATE => 'Ubah Pesanan',
|
||||||
@ -343,8 +333,6 @@ public function group(): string
|
|||||||
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
|
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
|
||||||
self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE,
|
self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE,
|
||||||
self::PURCHASES_DELETE => 'Belanja',
|
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_VIEW, self::ORDERS_CREATE, self::ORDERS_UPDATE,
|
||||||
self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE,
|
self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE,
|
||||||
self::ORDERS_CANCEL => 'Pesanan',
|
self::ORDERS_CANCEL => 'Pesanan',
|
||||||
|
|||||||
@ -152,11 +152,6 @@ public function permissions(): array
|
|||||||
Permission::LEAVE_REQUESTS_UPDATE,
|
Permission::LEAVE_REQUESTS_UPDATE,
|
||||||
Permission::LEAVE_REQUESTS_DELETE,
|
Permission::LEAVE_REQUESTS_DELETE,
|
||||||
|
|
||||||
Permission::RESTOCKS_VIEW,
|
|
||||||
Permission::RESTOCKS_CREATE,
|
|
||||||
Permission::RESTOCKS_UPDATE,
|
|
||||||
Permission::RESTOCKS_DELETE,
|
|
||||||
|
|
||||||
Permission::CUSTOMERS_VIEW,
|
Permission::CUSTOMERS_VIEW,
|
||||||
Permission::CUSTOMERS_CREATE,
|
Permission::CUSTOMERS_CREATE,
|
||||||
Permission::CUSTOMERS_UPDATE,
|
Permission::CUSTOMERS_UPDATE,
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<?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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin\Master;
|
namespace App\Http\Controllers\Admin\Master;
|
||||||
|
|
||||||
|
use App\Enums\Permission;
|
||||||
use App\Enums\RawMaterialUnit;
|
use App\Enums\RawMaterialUnit;
|
||||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||||
@ -55,7 +56,12 @@ public function create(): Response
|
|||||||
public function store(RawMaterialRequest $request): RedirectResponse
|
public function store(RawMaterialRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->rawMaterialService->create($request->validated(), $request->user());
|
$this->rawMaterialService->create($request->validated(), $request->user());
|
||||||
$this->flashCreated('Bahan baku');
|
|
||||||
|
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||||
|
$this->flashCreated('Bahan baku');
|
||||||
|
} else {
|
||||||
|
$this->flashSuccess('Bahan baku berhasil diajukan dan menunggu verifikasi owner.');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('admin.master.raw_materials.index');
|
return redirect()->route('admin.master.raw_materials.index');
|
||||||
}
|
}
|
||||||
@ -71,7 +77,12 @@ public function edit(RawMaterial $rawMaterial): Response
|
|||||||
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
|
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->rawMaterialService->update($rawMaterial, $request->validated(), $request->user());
|
$this->rawMaterialService->update($rawMaterial, $request->validated(), $request->user());
|
||||||
$this->flashUpdated('Bahan baku');
|
|
||||||
|
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||||
|
$this->flashUpdated('Bahan baku');
|
||||||
|
} else {
|
||||||
|
$this->flashSuccess('Perubahan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('admin.master.raw_materials.index');
|
return redirect()->route('admin.master.raw_materials.index');
|
||||||
}
|
}
|
||||||
@ -79,7 +90,12 @@ public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): R
|
|||||||
public function destroy(Request $request, RawMaterial $rawMaterial): RedirectResponse
|
public function destroy(Request $request, RawMaterial $rawMaterial): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->rawMaterialService->delete($rawMaterial, $request->user());
|
$this->rawMaterialService->delete($rawMaterial, $request->user());
|
||||||
$this->flashDeleted('Bahan baku');
|
|
||||||
|
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||||
|
$this->flashDeleted('Bahan baku');
|
||||||
|
} else {
|
||||||
|
$this->flashSuccess('Penghapusan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('admin.master.raw_materials.index');
|
return redirect()->route('admin.master.raw_materials.index');
|
||||||
}
|
}
|
||||||
@ -87,7 +103,12 @@ public function destroy(Request $request, RawMaterial $rawMaterial): RedirectRes
|
|||||||
public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMaterial): RedirectResponse
|
public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMaterial): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user());
|
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user());
|
||||||
$this->flashStatusUpdated('bahan baku');
|
|
||||||
|
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||||
|
$this->flashStatusUpdated('bahan baku');
|
||||||
|
} else {
|
||||||
|
$this->flashSuccess('Perubahan status bahan baku berhasil diajukan dan menunggu verifikasi owner.');
|
||||||
|
}
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,15 +11,16 @@ public function show(SystemService $systemService): JsonResponse
|
|||||||
{
|
{
|
||||||
$systemData = $systemService->systemData();
|
$systemData = $systemService->systemData();
|
||||||
$appName = $systemData['app_name'] ?? config('app.name', 'DST Collection');
|
$appName = $systemData['app_name'] ?? config('app.name', 'DST Collection');
|
||||||
$iconUrl = $systemData['favicon_url'] ?? $systemData['logo_url'] ?? asset('assets/logo.png');
|
$logoUrl = $systemData['logo_url'] ?? asset('assets/logo.png');
|
||||||
|
|
||||||
|
// Determine mime type from logoUrl extension
|
||||||
$mimeType = 'image/png';
|
$mimeType = 'image/png';
|
||||||
$lowerIconUrl = strtolower($iconUrl);
|
$lowerLogoUrl = strtolower($logoUrl);
|
||||||
if (str_contains($lowerIconUrl, '.svg')) {
|
if (str_contains($lowerLogoUrl, '.svg')) {
|
||||||
$mimeType = 'image/svg+xml';
|
$mimeType = 'image/svg+xml';
|
||||||
} elseif (str_contains($lowerIconUrl, '.jpg') || str_contains($lowerIconUrl, '.jpeg')) {
|
} elseif (str_contains($lowerLogoUrl, '.jpg') || str_contains($lowerLogoUrl, '.jpeg')) {
|
||||||
$mimeType = 'image/jpeg';
|
$mimeType = 'image/jpeg';
|
||||||
} elseif (str_contains($lowerIconUrl, '.webp')) {
|
} elseif (str_contains($lowerLogoUrl, '.webp')) {
|
||||||
$mimeType = 'image/webp';
|
$mimeType = 'image/webp';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,23 +37,23 @@ public function show(SystemService $systemService): JsonResponse
|
|||||||
'id' => '/',
|
'id' => '/',
|
||||||
'icons' => [
|
'icons' => [
|
||||||
[
|
[
|
||||||
'src' => $iconUrl,
|
'src' => $logoUrl,
|
||||||
'sizes' => '64x64',
|
'sizes' => '64x64',
|
||||||
'type' => $mimeType,
|
'type' => $mimeType,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'src' => $iconUrl,
|
'src' => $logoUrl,
|
||||||
'sizes' => '192x192',
|
'sizes' => '192x192',
|
||||||
'type' => $mimeType,
|
'type' => $mimeType,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'src' => $iconUrl,
|
'src' => $logoUrl,
|
||||||
'sizes' => '512x512',
|
'sizes' => '512x512',
|
||||||
'type' => $mimeType,
|
'type' => $mimeType,
|
||||||
'purpose' => 'any',
|
'purpose' => 'any',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'src' => $iconUrl,
|
'src' => $logoUrl,
|
||||||
'sizes' => '512x512',
|
'sizes' => '512x512',
|
||||||
'type' => $mimeType,
|
'type' => $mimeType,
|
||||||
'purpose' => 'maskable',
|
'purpose' => 'maskable',
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
<?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'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -32,10 +32,7 @@ public function rules(): array
|
|||||||
'category_ids' => ['required', 'array', 'min:1'],
|
'category_ids' => ['required', 'array', 'min:1'],
|
||||||
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
||||||
|
|
||||||
...$this->productVariantRules(
|
...$this->productVariantRules(productId: $this->route('product')?->id),
|
||||||
productId: $this->route('product')?->id,
|
|
||||||
imagesRequired: $this->isMethod('POST'),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,14 +49,4 @@ public function attributes(): array
|
|||||||
...$this->productVariantAttributes(),
|
...$this->productVariantAttributes(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function messages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'variants.*.s3_keys.required' => 'Foto varian wajib diisi.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,10 +30,7 @@ public function rules(): array
|
|||||||
'name' => ['required', 'string', 'max:200'],
|
'name' => ['required', 'string', 'max:200'],
|
||||||
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
|
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
|
||||||
|
|
||||||
...$this->rawMaterialPriceRules(
|
...$this->rawMaterialPriceRules(rawMaterialId: $this->route('rawMaterial')?->id),
|
||||||
rawMaterialId: $this->route('rawMaterial')?->id,
|
|
||||||
imagesRequired: $this->isMethod('POST'),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,14 +45,4 @@ public function attributes(): array
|
|||||||
...$this->rawMaterialPriceAttributes(),
|
...$this->rawMaterialPriceAttributes(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function messages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'prices.*.s3_keys.required' => 'Foto varian wajib diisi.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ trait HasProductVariantRules
|
|||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function productVariantRules(string $variantsKey = 'variants', ?int $productId = null, bool $imagesRequired = true): array
|
protected function productVariantRules(string $variantsKey = 'variants', ?int $productId = null): array
|
||||||
{
|
{
|
||||||
$isAdminBahanBaku = $this->user()?->hasRole(Role::ADMIN_BAHAN_BAKU->value) ?? false;
|
$isAdminBahanBaku = $this->user()?->hasRole(Role::ADMIN_BAHAN_BAKU->value) ?? false;
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ protected function productVariantRules(string $variantsKey = 'variants', ?int $p
|
|||||||
"{$variantsKey}.*.name" => ['required', 'string', 'max:200'],
|
"{$variantsKey}.*.name" => ['required', 'string', 'max:200'],
|
||||||
"{$variantsKey}.*.stock" => ['required', 'integer', 'min:0'],
|
"{$variantsKey}.*.stock" => ['required', 'integer', 'min:0'],
|
||||||
"{$variantsKey}.*.retail_stock" => ['required', 'integer', 'min:0'],
|
"{$variantsKey}.*.retail_stock" => ['required', 'integer', 'min:0'],
|
||||||
...$this->variantImageRules($variantsKey, required: $imagesRequired),
|
...$this->variantImageRules($variantsKey),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($productId) {
|
if ($productId) {
|
||||||
|
|||||||
@ -11,14 +11,14 @@ trait HasRawMaterialPriceRules
|
|||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function rawMaterialPriceRules(string $pricesKey = 'prices', ?int $rawMaterialId = null, bool $imagesRequired = true): array
|
protected function rawMaterialPriceRules(string $pricesKey = 'prices', ?int $rawMaterialId = null): array
|
||||||
{
|
{
|
||||||
$rules = [
|
$rules = [
|
||||||
"{$pricesKey}" => ['required', 'array', 'min:1'],
|
"{$pricesKey}" => ['required', 'array', 'min:1'],
|
||||||
"{$pricesKey}.*.variant" => ['required', 'string', 'max:200'],
|
"{$pricesKey}.*.variant" => ['required', 'string', 'max:200'],
|
||||||
"{$pricesKey}.*.price" => ['required', 'integer', 'gt:0'],
|
"{$pricesKey}.*.price" => ['required', 'integer', 'gt:0'],
|
||||||
"{$pricesKey}.*.stock" => ['required', 'numeric', 'decimal:0,4', 'min:0'],
|
"{$pricesKey}.*.stock" => ['required', 'numeric', 'decimal:0,4', 'min:0'],
|
||||||
...$this->variantImageRules($pricesKey, required: $imagesRequired),
|
...$this->variantImageRules($pricesKey),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($rawMaterialId) {
|
if ($rawMaterialId) {
|
||||||
|
|||||||
@ -22,12 +22,10 @@ protected function photoRules(string $prefix = 'photos', int $max = 1): array
|
|||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function variantImageRules(string $variantsKey = 'variants', int $max = 5, bool $required = false): array
|
protected function variantImageRules(string $variantsKey = 'variants', int $max = 5): array
|
||||||
{
|
{
|
||||||
$s3KeysRule = $required ? 'required' : 'nullable';
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"{$variantsKey}.*.s3_keys" => [$s3KeysRule, 'array', "max:{$max}"],
|
"{$variantsKey}.*.s3_keys" => ['nullable', 'array', "max:{$max}"],
|
||||||
"{$variantsKey}.*.s3_keys.*" => ['required', 'string'],
|
"{$variantsKey}.*.s3_keys.*" => ['required', 'string'],
|
||||||
"{$variantsKey}.*.remove_media_ids" => ['nullable', 'array'],
|
"{$variantsKey}.*.remove_media_ids" => ['nullable', 'array'],
|
||||||
"{$variantsKey}.*.remove_media_ids.*" => ['integer'],
|
"{$variantsKey}.*.remove_media_ids.*" => ['integer'],
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
<?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,7 +11,6 @@
|
|||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\Purchase;
|
use App\Models\Purchase;
|
||||||
use App\Models\RawMaterial;
|
use App\Models\RawMaterial;
|
||||||
use App\Models\Restock;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Concerns\CachesQuery;
|
use App\Services\Concerns\CachesQuery;
|
||||||
use App\Services\Master\ProductService;
|
use App\Services\Master\ProductService;
|
||||||
@ -38,7 +37,6 @@ public function __construct(
|
|||||||
private readonly ProductService $productService,
|
private readonly ProductService $productService,
|
||||||
private readonly RawMaterialService $rawMaterialService,
|
private readonly RawMaterialService $rawMaterialService,
|
||||||
private readonly PurchaseService $purchaseService,
|
private readonly PurchaseService $purchaseService,
|
||||||
private readonly RestockService $restockService,
|
|
||||||
private readonly PushNotificationService $pushNotificationService,
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
private readonly MarketplaceService $marketplaceService,
|
private readonly MarketplaceService $marketplaceService,
|
||||||
private readonly RetailStockService $retailStockService,
|
private readonly RetailStockService $retailStockService,
|
||||||
@ -293,7 +291,6 @@ private function rejectVerificationRequest(OwnerVerificationRequest $request): v
|
|||||||
Product::class => $this->productService->rejectVerificationRequest($request),
|
Product::class => $this->productService->rejectVerificationRequest($request),
|
||||||
RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request),
|
RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request),
|
||||||
Purchase::class => $this->purchaseService->rejectVerificationRequest($request),
|
Purchase::class => $this->purchaseService->rejectVerificationRequest($request),
|
||||||
Restock::class => $this->restockService->rejectVerificationRequest($request),
|
|
||||||
MarketplaceSettings::class => null,
|
MarketplaceSettings::class => null,
|
||||||
default => throw ValidationException::withMessages([
|
default => throw ValidationException::withMessages([
|
||||||
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
||||||
@ -307,7 +304,6 @@ private function applyVerificationRequest(OwnerVerificationRequest $request): vo
|
|||||||
Product::class => $this->productService->applyVerificationRequest($request),
|
Product::class => $this->productService->applyVerificationRequest($request),
|
||||||
RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request),
|
RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request),
|
||||||
Purchase::class => $this->purchaseService->applyVerificationRequest($request),
|
Purchase::class => $this->purchaseService->applyVerificationRequest($request),
|
||||||
Restock::class => $this->restockService->applyVerificationRequest($request),
|
|
||||||
MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request),
|
MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request),
|
||||||
default => throw ValidationException::withMessages([
|
default => throw ValidationException::withMessages([
|
||||||
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
'subject_type' => 'Tipe data verifikasi tidak didukung.',
|
||||||
@ -321,7 +317,6 @@ private function clearVerificationRequestMedia(OwnerVerificationRequest $request
|
|||||||
Product::class => $this->productService->clearVerificationRequestMedia($request),
|
Product::class => $this->productService->clearVerificationRequestMedia($request),
|
||||||
RawMaterial::class => $this->rawMaterialService->clearVerificationRequestMedia($request),
|
RawMaterial::class => $this->rawMaterialService->clearVerificationRequestMedia($request),
|
||||||
Purchase::class => $this->purchaseService->clearVerificationRequestMedia($request),
|
Purchase::class => $this->purchaseService->clearVerificationRequestMedia($request),
|
||||||
Restock::class => $this->restockService->clearVerificationRequestMedia($request),
|
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,782 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Services\Master;
|
namespace App\Services\Master;
|
||||||
|
|
||||||
use App\Enums\OwnerVerificationAction;
|
use App\Enums\OwnerVerificationAction;
|
||||||
|
use App\Enums\OwnerVerificationStatus;
|
||||||
|
use App\Enums\Permission;
|
||||||
use App\Enums\RawMaterialUnit;
|
use App\Enums\RawMaterialUnit;
|
||||||
use App\Models\OwnerVerificationRequest;
|
use App\Models\OwnerVerificationRequest;
|
||||||
use App\Models\RawMaterial;
|
use App\Models\RawMaterial;
|
||||||
@ -32,6 +34,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
|
|||||||
{
|
{
|
||||||
$query = RawMaterial::query()
|
$query = RawMaterial::query()
|
||||||
->with([
|
->with([
|
||||||
|
'pendingOwnerVerificationRequest.submittedBy.profile',
|
||||||
'prices' => fn ($query) => $query
|
'prices' => fn ($query) => $query
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->with('media')
|
->with('media')
|
||||||
@ -82,6 +85,18 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
|
|||||||
$price->unsetRelation('rawMaterial');
|
$price->unsetRelation('rawMaterial');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$pendingRequest = $rawMaterial->pendingOwnerVerificationRequest;
|
||||||
|
|
||||||
|
$rawMaterial->setAttribute('has_pending_request', $pendingRequest !== null);
|
||||||
|
$rawMaterial->setAttribute('pending_request_id', $pendingRequest?->id);
|
||||||
|
$rawMaterial->setAttribute('pending_request_action', $pendingRequest?->action->value);
|
||||||
|
$rawMaterial->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
|
||||||
|
$rawMaterial->setAttribute('pending_request_submitted_by_name', $pendingRequest?->submittedBy?->profile?->full_name ?? $pendingRequest?->submittedBy?->username);
|
||||||
|
$rawMaterial->setAttribute(
|
||||||
|
'display_is_active',
|
||||||
|
$pendingRequest?->pendingToggleIsActive() ?? $rawMaterial->is_active,
|
||||||
|
);
|
||||||
|
|
||||||
return $rawMaterial;
|
return $rawMaterial;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -103,90 +118,229 @@ public function findForEdit(RawMaterial $rawMaterial): RawMaterial
|
|||||||
|
|
||||||
public function create(array $validated, User $user): void
|
public function create(array $validated, User $user): void
|
||||||
{
|
{
|
||||||
|
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||||
|
|
||||||
$rawMaterial = $this->runInTransaction(
|
$rawMaterial = $this->runInTransaction(
|
||||||
function () use ($validated): RawMaterial {
|
function () use ($validated, $user, $isOwner): RawMaterial {
|
||||||
$rawMaterial = RawMaterial::create([
|
$rawMaterial = RawMaterial::create([
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'unit' => $validated['unit'],
|
'unit' => $validated['unit'],
|
||||||
'is_active' => true,
|
'is_active' => $isOwner,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($validated['prices'] as $index => $priceData) {
|
foreach ($validated['prices'] as $index => $priceData) {
|
||||||
$this->createPrice($rawMaterial, $priceData, $index);
|
$this->createPrice($rawMaterial, $priceData, $index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $isOwner) {
|
||||||
|
OwnerVerificationRequest::create([
|
||||||
|
'action' => OwnerVerificationAction::CREATE,
|
||||||
|
'status' => OwnerVerificationStatus::PENDING,
|
||||||
|
'subject_type' => RawMaterial::class,
|
||||||
|
'subject_id' => $rawMaterial->id,
|
||||||
|
'submitted_by_id' => $user->id,
|
||||||
|
'payload' => [
|
||||||
|
'old' => null,
|
||||||
|
'new' => $this->snapshotRawMaterial($rawMaterial->fresh(['prices'])),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return $rawMaterial;
|
return $rawMaterial;
|
||||||
},
|
},
|
||||||
'Gagal membuat bahan baku',
|
'Gagal membuat bahan baku',
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->cacheForgetByPattern('master:raw_materials:*');
|
if ($isOwner) {
|
||||||
|
$this->cacheForgetByPattern('master:raw_materials:*');
|
||||||
|
}
|
||||||
|
|
||||||
$this->notifyOwner(
|
if (! $isOwner) {
|
||||||
'Tambah Bahan Baku',
|
$this->notifyForPendingRequest(
|
||||||
"Bahan baku '{$validated['name']}' telah ditambahkan oleh {$user->name}.",
|
$user,
|
||||||
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
|
'Tambah Bahan Baku',
|
||||||
);
|
"Pengajuan tambah bahan baku '{$validated['name']}' menunggu verifikasi owner.",
|
||||||
|
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
|
||||||
|
(string) $rawMaterial->id,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(RawMaterial $rawMaterial, array $validated, User $user): void
|
public function update(RawMaterial $rawMaterial, array $validated, User $user): void
|
||||||
{
|
{
|
||||||
$this->runInTransaction(
|
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||||
function () use ($validated, $rawMaterial): void {
|
|
||||||
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
|
|
||||||
$this->applyPayloadToRawMaterial($rawMaterial, $payload);
|
|
||||||
|
|
||||||
foreach ($validated['prices'] as $index => $priceData) {
|
$this->runInTransaction(
|
||||||
if (! empty($priceData['id'])) {
|
function () use ($validated, $rawMaterial, $user, $isOwner): void {
|
||||||
$price = $rawMaterial->prices()->find($priceData['id']);
|
if ($isOwner) {
|
||||||
if ($price) {
|
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
|
||||||
$this->syncPriceImages($price, $priceData, $index);
|
$this->applyPayloadToRawMaterial($rawMaterial, $payload);
|
||||||
|
|
||||||
|
foreach ($validated['prices'] as $index => $priceData) {
|
||||||
|
if (! empty($priceData['id'])) {
|
||||||
|
$price = $rawMaterial->prices()->find($priceData['id']);
|
||||||
|
if ($price) {
|
||||||
|
$this->syncPriceImages($price, $priceData, $index);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->createPrice($rawMaterial, $priceData, $index);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
$this->createPrice($rawMaterial, $priceData, $index);
|
} else {
|
||||||
|
$verificationRequest = OwnerVerificationRequest::create([
|
||||||
|
'action' => OwnerVerificationAction::UPDATE,
|
||||||
|
'status' => OwnerVerificationStatus::PENDING,
|
||||||
|
'subject_type' => RawMaterial::class,
|
||||||
|
'subject_id' => $rawMaterial->id,
|
||||||
|
'submitted_by_id' => $user->id,
|
||||||
|
'payload' => [
|
||||||
|
'old' => $this->snapshotRawMaterial($rawMaterial),
|
||||||
|
'new' => $this->enrichPayload($this->buildPayloadFromValidated($validated)),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($validated['prices'] as $index => $priceData) {
|
||||||
|
$isNewPrice = empty($priceData['id']);
|
||||||
|
$this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Gagal memperbarui bahan baku',
|
'Gagal memperbarui bahan baku',
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->cacheForgetByPattern('master:raw_materials:*');
|
if ($isOwner) {
|
||||||
|
$this->cacheForgetByPattern('master:raw_materials:*');
|
||||||
|
}
|
||||||
|
|
||||||
$this->notifyOwner(
|
if (! $isOwner) {
|
||||||
'Ubah Bahan Baku',
|
$changedVariants = [];
|
||||||
"Bahan baku '{$rawMaterial->name}' telah diperbarui oleh {$user->name}.",
|
foreach ($validated['prices'] as $priceData) {
|
||||||
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
|
if (! empty($priceData['id'])) {
|
||||||
);
|
$originalPrice = $rawMaterial->prices->firstWhere('id', $priceData['id']);
|
||||||
|
if ($originalPrice) {
|
||||||
|
$isChanged = false;
|
||||||
|
if ($originalPrice->variant !== $priceData['variant']) {
|
||||||
|
$isChanged = true;
|
||||||
|
}
|
||||||
|
if ($originalPrice->price !== (int) $priceData['price']) {
|
||||||
|
$isChanged = true;
|
||||||
|
}
|
||||||
|
if (rtrim(rtrim(number_format((float) $originalPrice->stock, 4, '.', ''), '0'), '.') !== rtrim(rtrim(number_format((float) $priceData['stock'], 4, '.', ''), '0'), '.')) {
|
||||||
|
$isChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isChanged) {
|
||||||
|
$changedVariants[] = $priceData['variant'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$changedVariants[] = $priceData['variant'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($changedVariants)) {
|
||||||
|
$variantsStr = implode(', ', $changedVariants);
|
||||||
|
$this->notifyForPendingRequest(
|
||||||
|
$user,
|
||||||
|
'Ubah Varian Bahan Baku',
|
||||||
|
"Pengajuan ubah varian '{$variantsStr}' pada bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
|
||||||
|
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
|
||||||
|
(string) $rawMaterial->id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->notifyForPendingRequest(
|
||||||
|
$user,
|
||||||
|
'Ubah Bahan Baku',
|
||||||
|
"Pengajuan ubah bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
|
||||||
|
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
|
||||||
|
(string) $rawMaterial->id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(RawMaterial $rawMaterial, User $user): void
|
public function delete(RawMaterial $rawMaterial, User $user): void
|
||||||
{
|
{
|
||||||
$name = $rawMaterial->name;
|
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||||
|
|
||||||
$this->applyDeleteSubject($rawMaterial);
|
if ($isOwner) {
|
||||||
$this->cacheForgetByPattern('master:raw_materials:*');
|
$this->applyDeleteSubject($rawMaterial);
|
||||||
|
$this->cacheForgetByPattern('master:raw_materials:*');
|
||||||
|
|
||||||
$this->notifyOwner(
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->runInTransaction(
|
||||||
|
function () use ($rawMaterial, $user): void {
|
||||||
|
OwnerVerificationRequest::create([
|
||||||
|
'action' => OwnerVerificationAction::DELETE,
|
||||||
|
'status' => OwnerVerificationStatus::PENDING,
|
||||||
|
'subject_type' => RawMaterial::class,
|
||||||
|
'subject_id' => $rawMaterial->id,
|
||||||
|
'submitted_by_id' => $user->id,
|
||||||
|
'payload' => [
|
||||||
|
'old' => $this->snapshotRawMaterial($rawMaterial),
|
||||||
|
'new' => null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
'Gagal mengajukan penghapusan bahan baku',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->notifyForPendingRequest(
|
||||||
|
$user,
|
||||||
'Hapus Bahan Baku',
|
'Hapus Bahan Baku',
|
||||||
"Bahan baku '{$name}' telah dihapus oleh {$user->name}.",
|
"Pengajuan hapus bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
|
||||||
route('admin.master.raw_materials.index'),
|
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
|
||||||
|
(string) $rawMaterial->id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $user): void
|
public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $user): void
|
||||||
{
|
{
|
||||||
$rawMaterial->update([
|
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||||
'is_active' => (bool) $validated['is_active'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->cacheForgetByPattern('master:raw_materials:*');
|
if ($isOwner) {
|
||||||
|
$rawMaterial->update([
|
||||||
|
'is_active' => (bool) $validated['is_active'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cacheForgetByPattern('master:raw_materials:*');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->runInTransaction(
|
||||||
|
function () use ($rawMaterial, $validated, $user): void {
|
||||||
|
OwnerVerificationRequest::create([
|
||||||
|
'action' => OwnerVerificationAction::TOGGLE_STATUS,
|
||||||
|
'status' => OwnerVerificationStatus::PENDING,
|
||||||
|
'subject_type' => RawMaterial::class,
|
||||||
|
'subject_id' => $rawMaterial->id,
|
||||||
|
'submitted_by_id' => $user->id,
|
||||||
|
'payload' => [
|
||||||
|
'old' => [
|
||||||
|
'name' => $rawMaterial->name,
|
||||||
|
'is_active' => $rawMaterial->is_active,
|
||||||
|
],
|
||||||
|
'new' => [
|
||||||
|
'name' => $rawMaterial->name,
|
||||||
|
'is_active' => (bool) $validated['is_active'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
'Gagal mengajukan perubahan status bahan baku',
|
||||||
|
);
|
||||||
|
|
||||||
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
|
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
|
||||||
|
|
||||||
$this->notifyOwner(
|
$this->notifyForPendingRequest(
|
||||||
|
$user,
|
||||||
'Ubah Status Bahan Baku',
|
'Ubah Status Bahan Baku',
|
||||||
"Status bahan baku '{$rawMaterial->name}' telah diubah menjadi {$statusLabel} oleh {$user->name}.",
|
"Pengajuan ubah status bahan baku '{$rawMaterial->name}' menjadi {$statusLabel} menunggu verifikasi owner.",
|
||||||
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
|
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
|
||||||
|
(string) $rawMaterial->id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,13 +384,25 @@ public function clearVerificationRequestMedia(OwnerVerificationRequest $verifica
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function notifyOwner(string $typeLabel, string $body, string $url): void
|
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void
|
||||||
{
|
{
|
||||||
|
$ownerUrl = route('admin.master.raw_materials.index');
|
||||||
|
if ($search !== null) {
|
||||||
|
$ownerUrl = route('admin.master.raw_materials.index', ['search_id' => $search]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->pushNotificationService->sendToRoles(
|
$this->pushNotificationService->sendToRoles(
|
||||||
"📦 {$typeLabel}",
|
"📦 {$typeLabel} Menunggu Persetujuan Owner",
|
||||||
$body,
|
$body,
|
||||||
['owner', 'developer', 'direktur'],
|
['owner', 'developer', 'direktur'],
|
||||||
$url,
|
$ownerUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pushNotificationService->sendToUser(
|
||||||
|
'📤 Pengajuan Terkirim',
|
||||||
|
$body,
|
||||||
|
$user->id,
|
||||||
|
$submitterUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,9 +39,6 @@ public function systemData(): array
|
|||||||
'logo_url' => $logo['url'] ?? null,
|
'logo_url' => $logo['url'] ?? null,
|
||||||
'favicon_url' => $favicon['url'] ?? null,
|
'favicon_url' => $favicon['url'] ?? null,
|
||||||
'login_cover_url' => $loginCover['url'] ?? null,
|
'login_cover_url' => $loginCover['url'] ?? null,
|
||||||
'logo' => $logo,
|
|
||||||
'favicon' => $favicon,
|
|
||||||
'login_cover' => $loginCover,
|
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -60,10 +57,6 @@ function () use ($validated): void {
|
|||||||
$settings->address = $validated['address'] ?? null;
|
$settings->address = $validated['address'] ?? null;
|
||||||
$settings->save();
|
$settings->save();
|
||||||
|
|
||||||
$hasLogoInput = isset($validated['logo']) || isset($validated['logo_s3_key']);
|
|
||||||
$hasFaviconInput = isset($validated['favicon']) || isset($validated['favicon_s3_key']);
|
|
||||||
$hasLoginCoverInput = isset($validated['login_cover']) || isset($validated['login_cover_s3_key']);
|
|
||||||
|
|
||||||
$this->syncPhotos(
|
$this->syncPhotos(
|
||||||
$configuration,
|
$configuration,
|
||||||
[
|
[
|
||||||
@ -99,15 +92,15 @@ function () use ($validated): void {
|
|||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
if ($hasLogoInput && ! $configuration->hasMedia('logo')) {
|
if (! $configuration->hasMedia('logo')) {
|
||||||
$errors['logo'] = 'Logo wajib diisi.';
|
$errors['logo'] = 'Logo wajib diisi.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasFaviconInput && ! $configuration->hasMedia('favicon')) {
|
if (! $configuration->hasMedia('favicon')) {
|
||||||
$errors['favicon'] = 'Favicon wajib diisi.';
|
$errors['favicon'] = 'Favicon wajib diisi.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasLoginCoverInput && ! $configuration->hasMedia('login_cover')) {
|
if (! $configuration->hasMedia('login_cover')) {
|
||||||
$errors['login_cover'] = 'Cover login wajib diisi.';
|
$errors['login_cover'] = 'Cover login wajib diisi.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,8 +27,6 @@
|
|||||||
use App\Models\RawMaterial;
|
use App\Models\RawMaterial;
|
||||||
use App\Models\RawMaterialPrice;
|
use App\Models\RawMaterialPrice;
|
||||||
use App\Models\Rejection;
|
use App\Models\Rejection;
|
||||||
use App\Models\Restock;
|
|
||||||
use App\Models\RestockItem;
|
|
||||||
use App\Models\Supplier;
|
use App\Models\Supplier;
|
||||||
use App\Models\SystemConfiguration;
|
use App\Models\SystemConfiguration;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -66,8 +64,6 @@ class ModelLabel
|
|||||||
RawMaterial::class => 'Bahan Baku',
|
RawMaterial::class => 'Bahan Baku',
|
||||||
RawMaterialPrice::class => 'Harga Bahan Baku',
|
RawMaterialPrice::class => 'Harga Bahan Baku',
|
||||||
Rejection::class => 'Penolakan',
|
Rejection::class => 'Penolakan',
|
||||||
Restock::class => 'Restock',
|
|
||||||
RestockItem::class => 'Item Restock',
|
|
||||||
Supplier::class => 'Supplier',
|
Supplier::class => 'Supplier',
|
||||||
SystemConfiguration::class => 'Konfigurasi Sistem',
|
SystemConfiguration::class => 'Konfigurasi Sistem',
|
||||||
User::class => 'Pengguna',
|
User::class => 'Pengguna',
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -32,6 +32,7 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript-eslint": "^8.23.0",
|
"typescript-eslint": "^8.23.0",
|
||||||
"vite": "^7.3.6",
|
"vite": "^7.3.6",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vue-tsc": "^2.2.4"
|
"vue-tsc": "^2.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
36
public/manifest.webmanifest
Normal file
36
public/manifest.webmanifest
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "DST Collection",
|
||||||
|
"short_name": "DST Collection",
|
||||||
|
"description": "DST Collection - Progressive Web App",
|
||||||
|
"theme_color": "#171717",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"id": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/pwa-64x64.png",
|
||||||
|
"sizes": "64x64",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/pwa-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/pwa-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/maskable-icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -16,11 +16,6 @@ if ('serviceWorker' in navigator) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
(window as any).deferredPwaPrompt = e;
|
|
||||||
});
|
|
||||||
|
|
||||||
void restoreConnection();
|
void restoreConnection();
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
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 { 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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@ -56,7 +56,6 @@ const menuGroups: MenuGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ title: 'Belanja', href: admin.manage.purchases.index.url(), icon: ShoppingBag, permission: 'purchases.view' },
|
{ 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: '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 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: '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' },
|
{ title: 'Pesanan', href: admin.manage.orders.index.url(), icon: ShoppingCart, permission: 'orders.view' },
|
||||||
|
|||||||
@ -22,13 +22,11 @@ const props = withDefaults(
|
|||||||
urls?: string[];
|
urls?: string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
titles?: string[];
|
titles?: string[];
|
||||||
stocks?: string[];
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
urls: () => [],
|
urls: () => [],
|
||||||
title: 'Pratinjau Foto',
|
title: 'Pratinjau Foto',
|
||||||
titles: () => [],
|
titles: () => [],
|
||||||
stocks: () => [],
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -76,14 +74,6 @@ const activeTitle = computed(() => {
|
|||||||
return props.title ?? 'Pratinjau Foto';
|
return props.title ?? 'Pratinjau Foto';
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeStock = computed(() => {
|
|
||||||
if (props.stocks && props.stocks.length > 0 && currentIndex.value >= 0) {
|
|
||||||
return props.stocks[currentIndex.value] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function nextImage() {
|
function nextImage() {
|
||||||
if (!hasMultiple.value) {
|
if (!hasMultiple.value) {
|
||||||
return;
|
return;
|
||||||
@ -152,7 +142,6 @@ function handleTouchEnd(e: TouchEvent) {
|
|||||||
<DialogContent class="sm:max-w-2xl p-4 overflow-hidden gap-4">
|
<DialogContent class="sm:max-w-2xl p-4 overflow-hidden gap-4">
|
||||||
<DialogHeader class="pb-2 border-b">
|
<DialogHeader class="pb-2 border-b">
|
||||||
<DialogTitle class="text-base font-semibold">{{ activeTitle }}</DialogTitle>
|
<DialogTitle class="text-base font-semibold">{{ activeTitle }}</DialogTitle>
|
||||||
<p v-if="activeStock" class="text-sm text-muted-foreground">{{ activeStock }}</p>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div class="relative flex items-center justify-center min-h-[300px] select-none"
|
<div class="relative flex items-center justify-center min-h-[300px] select-none"
|
||||||
|
|||||||
@ -15,7 +15,6 @@ const props = defineProps<{
|
|||||||
title?: string;
|
title?: string;
|
||||||
allUrls?: string[];
|
allUrls?: string[];
|
||||||
allTitles?: string[];
|
allTitles?: string[];
|
||||||
allStocks?: string[];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
@ -122,5 +121,5 @@ function openGallery() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="allUrls ?? items.map(item => item.url)" :title="title" :titles="allTitles" :stocks="allStocks" @close="onPreviewClose" />
|
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="allUrls ?? items.map(item => item.url)" :title="title" :titles="allTitles" @close="onPreviewClose" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -195,7 +195,6 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
|||||||
?.images ?? []
|
?.images ?? []
|
||||||
"
|
"
|
||||||
:max-visible="1"
|
:max-visible="1"
|
||||||
:all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)"
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
@ -289,7 +288,6 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
|||||||
<MediaThumbnailCell
|
<MediaThumbnailCell
|
||||||
:items="material.images ?? []"
|
:items="material.images ?? []"
|
||||||
:max-visible="1"
|
:max-visible="1"
|
||||||
:all-stocks="(material.images ?? []).map(() => `Pemakaian: ${material.material_usage_formatted}`)"
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
|
|||||||
@ -38,17 +38,15 @@ const props = defineProps<{
|
|||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
const previewUrl = ref<string | null>(null);
|
const previewUrl = ref<string | null>(null);
|
||||||
const previewTitle = ref<string>('');
|
const previewTitle = ref<string>('');
|
||||||
const previewStock = ref<string>('');
|
|
||||||
|
|
||||||
const allVariantImages = computed(() => {
|
const allVariantImages = computed(() => {
|
||||||
const list: { priceId: number; title: string; stock: string; url: string }[] = [];
|
const list: { priceId: number; title: string; url: string }[] = [];
|
||||||
props.filteredRawMaterials.forEach((rawMaterial) => {
|
props.filteredRawMaterials.forEach((rawMaterial) => {
|
||||||
rawMaterial.prices.forEach((price) => {
|
rawMaterial.prices.forEach((price) => {
|
||||||
if (price.images && price.images.length > 0) {
|
if (price.images && price.images.length > 0) {
|
||||||
list.push({
|
list.push({
|
||||||
priceId: price.id,
|
priceId: price.id,
|
||||||
title: `${rawMaterial.name} - ${price.variant}`,
|
title: `${rawMaterial.name} - ${price.variant}`,
|
||||||
stock: `Stok: ${price.stock_formatted}`,
|
|
||||||
url: price.images[0].url,
|
url: price.images[0].url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -66,7 +64,6 @@ function handleThumbClick(priceId: number) {
|
|||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
previewUrl.value = allVariantImages.value[idx].url;
|
previewUrl.value = allVariantImages.value[idx].url;
|
||||||
previewTitle.value = allVariantImages.value[idx].title;
|
previewTitle.value = allVariantImages.value[idx].title;
|
||||||
previewStock.value = allVariantImages.value[idx].stock;
|
|
||||||
previewOpen.value = true;
|
previewOpen.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -76,7 +73,6 @@ watch(previewUrl, (newUrl) => {
|
|||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
previewTitle.value = matched.title;
|
previewTitle.value = matched.title;
|
||||||
previewStock.value = matched.stock;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -210,5 +206,5 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
|
|||||||
:existing-cart-items="materialCart" :initial-variant="combinationInitialVariant"
|
:existing-cart-items="materialCart" :initial-variant="combinationInitialVariant"
|
||||||
@combination-created="emit('combination-created', $event)" />
|
@combination-created="emit('combination-created', $event)" />
|
||||||
|
|
||||||
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" :stocks="previewStock ? [previewStock] : []" />
|
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -138,7 +138,6 @@ function setCombinationResult(combinationId: number | undefined, value: string)
|
|||||||
v-if="item.images?.length"
|
v-if="item.images?.length"
|
||||||
:items="item.images"
|
:items="item.images"
|
||||||
:max-visible="1"
|
:max-visible="1"
|
||||||
:all-stocks="item.images.map(() => `Stok: ${item.stock_input} ${item.unit_abbreviation}`)"
|
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-sm font-medium">
|
<p class="truncate text-sm font-medium">
|
||||||
@ -181,7 +180,6 @@ function setCombinationResult(combinationId: number | undefined, value: string)
|
|||||||
v-if="item.images?.length"
|
v-if="item.images?.length"
|
||||||
:items="item.images"
|
:items="item.images"
|
||||||
:max-visible="1"
|
:max-visible="1"
|
||||||
:all-stocks="item.images.map(() => `Stok: ${item.stock_input} ${item.unit_abbreviation}`)"
|
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-sm font-medium">
|
<p class="truncate text-sm font-medium">
|
||||||
|
|||||||
@ -30,17 +30,15 @@ const props = defineProps<{
|
|||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
const previewUrl = ref<string | null>(null);
|
const previewUrl = ref<string | null>(null);
|
||||||
const previewTitle = ref<string>('');
|
const previewTitle = ref<string>('');
|
||||||
const previewStock = ref<string>('');
|
|
||||||
|
|
||||||
const allVariantImages = computed(() => {
|
const allVariantImages = computed(() => {
|
||||||
const list: { variantId: number; title: string; stock: string; url: string }[] = [];
|
const list: { variantId: number; title: string; url: string }[] = [];
|
||||||
props.filteredProducts.forEach((product) => {
|
props.filteredProducts.forEach((product) => {
|
||||||
product.variants.forEach((variant) => {
|
product.variants.forEach((variant) => {
|
||||||
if (variant.images && variant.images.length > 0) {
|
if (variant.images && variant.images.length > 0) {
|
||||||
list.push({
|
list.push({
|
||||||
variantId: variant.id,
|
variantId: variant.id,
|
||||||
title: `${product.name} - ${variant.name}`,
|
title: `${product.name} - ${variant.name}`,
|
||||||
stock: `Stok: ${variant.stock} pcs`,
|
|
||||||
url: variant.images[0].url,
|
url: variant.images[0].url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -58,7 +56,6 @@ function handleThumbClick(variantId: number) {
|
|||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
previewUrl.value = allVariantImages.value[idx].url;
|
previewUrl.value = allVariantImages.value[idx].url;
|
||||||
previewTitle.value = allVariantImages.value[idx].title;
|
previewTitle.value = allVariantImages.value[idx].title;
|
||||||
previewStock.value = allVariantImages.value[idx].stock;
|
|
||||||
previewOpen.value = true;
|
previewOpen.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,7 +65,6 @@ watch(previewUrl, (newUrl) => {
|
|||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
previewTitle.value = matched.title;
|
previewTitle.value = matched.title;
|
||||||
previewStock.value = matched.stock;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -172,5 +168,5 @@ const quickCreateOpen = ref(false);
|
|||||||
<QuickCreateProductModal v-model:open="quickCreateOpen" :categories="categories" :selected-materials="materialCart"
|
<QuickCreateProductModal v-model:open="quickCreateOpen" :categories="categories" :selected-materials="materialCart"
|
||||||
@created="emit('product-created', $event)" />
|
@created="emit('product-created', $event)" />
|
||||||
|
|
||||||
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" :stocks="previewStock ? [previewStock] : []" />
|
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="previewUrls" :title="previewTitle" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -50,7 +50,6 @@ const emit = defineEmits<{
|
|||||||
v-if="item.images?.length"
|
v-if="item.images?.length"
|
||||||
:items="item.images"
|
:items="item.images"
|
||||||
:max-visible="1"
|
:max-visible="1"
|
||||||
:all-stocks="item.images.map(() => `Stok: ${item.stock} pcs`)"
|
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-sm font-medium">
|
<p class="truncate text-sm font-medium">
|
||||||
|
|||||||
@ -77,7 +77,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MediaDropzone :id="`variant_images_${variant.client_id}`" :model-value="variant.media"
|
<MediaDropzone :id="`variant_images_${variant.client_id}`" :model-value="variant.media"
|
||||||
label="Foto Varian" :max-files="5" :errors="variantErrors(variant.client_id, 's3_keys')"
|
label="Foto Varian" :max-files="5" :errors="variantErrors(variant.client_id, 'images')"
|
||||||
@update:model-value="emit('update:media', $event)" />
|
@update:model-value="emit('update:media', $event)" />
|
||||||
</div>
|
</div>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@ -265,7 +265,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
|||||||
{{ material.variant }}
|
{{ material.variant }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" :title="`${group.rawMaterialName} - ${material.variant}`" :all-stocks="(material.images ?? []).map(() => `Pemakaian: ${material.material_usage_formatted}`)" />
|
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" :title="`${group.rawMaterialName} - ${material.variant}`" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{
|
{{
|
||||||
@ -322,7 +322,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
|||||||
{{ result.product_variant?.name }}
|
{{ result.product_variant?.name }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :title="`${group.productName} - ${result.product_variant?.name}`" :all-stocks="(result.product_variant?.images ?? []).map(() => `Hasil: ${result.cutting_result} pcs`)" />
|
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :title="`${group.productName} - ${result.product_variant?.name}`" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ result.cutting_result }} pcs
|
{{ result.cutting_result }} pcs
|
||||||
|
|||||||
@ -201,7 +201,7 @@ function submitVerify() {
|
|||||||
<div v-for="material in cutting.materials" :key="material.id"
|
<div v-for="material in cutting.materials" :key="material.id"
|
||||||
class="flex items-center gap-3 rounded-lg border p-3 bg-muted/10">
|
class="flex items-center gap-3 rounded-lg border p-3 bg-muted/10">
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<MediaThumbnailCell :items="material.raw_material_price?.images ?? []" :max-visible="1" :all-stocks="(material.raw_material_price?.images ?? []).map(() => `Pemakaian: ${material.material_usage_formatted}`)" />
|
<MediaThumbnailCell :items="material.raw_material_price?.images ?? []" :max-visible="1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium text-sm truncate">
|
<p class="font-medium text-sm truncate">
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
<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 previewStock = 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}`;
|
|
||||||
previewStock.value = `Stok: ${variant.stock} pcs`;
|
|
||||||
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" :stocks="previewStock ? [previewStock] : []" />
|
|
||||||
</template>
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,279 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,196 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -200,7 +200,7 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="result.product_variant?.images ?? []"
|
<MediaThumbnailCell :items="result.product_variant?.images ?? []"
|
||||||
:max-visible="1" :all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
|
:max-visible="1" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ result.cutting_result }} pcs
|
{{ result.cutting_result }} pcs
|
||||||
|
|||||||
@ -131,7 +131,7 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="result.product_variant?.images ?? []"
|
<MediaThumbnailCell :items="result.product_variant?.images ?? []"
|
||||||
:max-visible="1" :all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
|
:max-visible="1" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ result.cutting_result }} pcs
|
{{ result.cutting_result }} pcs
|
||||||
@ -189,7 +189,7 @@ function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
|
|||||||
{{ material.variant }}
|
{{ material.variant }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" :all-stocks="(material.images ?? []).map(() => `${material.material_result ?? '-'} pcs`)" />
|
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
<template
|
<template
|
||||||
|
|||||||
@ -124,7 +124,7 @@ const showingCount = computed(() => props.cuttings.length);
|
|||||||
{{ result.product_variant?.name }}
|
{{ result.product_variant?.name }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" :all-stocks="(result.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
|
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ result.cutting_result }} pcs
|
{{ result.cutting_result }} pcs
|
||||||
|
|||||||
@ -221,7 +221,7 @@ function submitVerify() {
|
|||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<MediaThumbnailCell :items="cutting.results[index]?.product_variant?.images ?? []" :max-visible="1" :all-stocks="(cutting.results[index]?.product_variant?.images ?? []).map(() => `${result.cutting_result} pcs`)" />
|
<MediaThumbnailCell :items="cutting.results[index]?.product_variant?.images ?? []" :max-visible="1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium text-sm">{{ result.name }}</p>
|
<p class="font-medium text-sm">{{ result.name }}</p>
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Plus, Save, Search, X } from '@lucide/vue';
|
import { Plus, Save } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
import { useVariantList } from '@/composables/useVariantList';
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||||
@ -16,7 +15,7 @@ import ProductVariantSection from './ProductVariantSection.vue';
|
|||||||
|
|
||||||
|
|
||||||
const { hasRole } = useCan();
|
const { hasRole } = useCan();
|
||||||
const showPrices = computed(() => !hasRole('admin-bahan-baku'));
|
const showPrices = !hasRole('admin-bahan-baku');
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@ -119,33 +118,6 @@ const {
|
|||||||
|
|
||||||
const copiedPrices = ref<Record<string, string> | null>(null);
|
const copiedPrices = ref<Record<string, string> | null>(null);
|
||||||
|
|
||||||
const variantSearchQuery = ref('');
|
|
||||||
|
|
||||||
function shouldShowVariant(variant: ProductVariantFormItem, index: number) {
|
|
||||||
const hasError = Object.keys(form.errors).some((key) => key.startsWith(`variants.${index}.`));
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = variantSearchQuery.value.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return variant.name.toLowerCase().includes(query) || variant.name.trim() === '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasVisibleVariants = computed(() => {
|
|
||||||
return variants.value.some((variant, index) => shouldShowVariant(variant, index));
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleAddVariant() {
|
|
||||||
variantSearchQuery.value = '';
|
|
||||||
addVariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyPrices(variantPrices: Record<string, string>) {
|
function copyPrices(variantPrices: Record<string, string>) {
|
||||||
copiedPrices.value = { ...variantPrices };
|
copiedPrices.value = { ...variantPrices };
|
||||||
}
|
}
|
||||||
@ -216,7 +188,7 @@ function buildFormData(): FormData {
|
|||||||
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
||||||
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
|
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
|
||||||
|
|
||||||
if (variant.prices && showPrices.value) {
|
if (variant.prices && showPrices) {
|
||||||
Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => {
|
Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => {
|
||||||
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0));
|
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0));
|
||||||
});
|
});
|
||||||
@ -258,24 +230,8 @@ function submit() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Section pencarian varian -->
|
<ProductVariantSection v-for="(variant, index) in variants" :key="variant.client_id" :form="form"
|
||||||
<div v-if="variants.length > 0" class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
:variant="variant" :index="index" :can-remove="variants.length > 1" :has-copied-prices="!!copiedPrices"
|
||||||
<h3 class="text-lg font-semibold tracking-tight">Daftar Varian ({{ variants.length }})</h3>
|
|
||||||
<div v-if="variants.length > 1" class="relative w-full md:max-w-xs">
|
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
||||||
<Input v-model="variantSearchQuery" type="text" placeholder="Cari varian berdasarkan nama..."
|
|
||||||
class="pl-9 pr-8 h-9 w-full bg-background" />
|
|
||||||
<button v-if="variantSearchQuery" type="button"
|
|
||||||
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded-full hover:bg-muted cursor-pointer flex items-center justify-center"
|
|
||||||
@click="variantSearchQuery = ''">
|
|
||||||
<X class="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProductVariantSection v-for="(variant, index) in variants" :key="variant.client_id"
|
|
||||||
v-show="shouldShowVariant(variant, index)" :form="form" :variant="variant" :index="index"
|
|
||||||
:can-remove="variants.length > 1" :has-copied-prices="!!copiedPrices" :show-prices="showPrices"
|
|
||||||
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
||||||
@remove="confirmRemoveVariant(variant.client_id)"
|
@remove="confirmRemoveVariant(variant.client_id)"
|
||||||
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
||||||
@ -287,20 +243,8 @@ function submit() {
|
|||||||
@paste-prices="pastePrices(variant.client_id)"
|
@paste-prices="pastePrices(variant.client_id)"
|
||||||
@apply-to-all-prices="applyToAllPrices(variant.prices as Record<string, string>)" />
|
@apply-to-all-prices="applyToAllPrices(variant.prices as Record<string, string>)" />
|
||||||
|
|
||||||
<div v-if="variants.length > 0 && !hasVisibleVariants"
|
|
||||||
class="flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-muted/40 text-center animate-in fade-in duration-200">
|
|
||||||
<Search class="size-8 text-muted-foreground mb-2" />
|
|
||||||
<p class="text-sm font-medium">Tidak ada varian yang cocok</p>
|
|
||||||
<p class="text-xs text-muted-foreground mt-0.5">Tidak ditemukan varian dengan nama "{{
|
|
||||||
variantSearchQuery }}"</p>
|
|
||||||
<Button type="button" variant="outline" size="sm" class="mt-4 cursor-pointer"
|
|
||||||
@click="variantSearchQuery = ''">
|
|
||||||
Clear Pencarian
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Button type="button" variant="outline" @click="handleAddVariant">
|
<Button type="button" variant="outline" @click="addVariant">
|
||||||
<Plus class="size-4" />
|
<Plus class="size-4" />
|
||||||
Tambah Varian
|
Tambah Varian
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -210,7 +210,7 @@ function submit() {
|
|||||||
|
|
||||||
<MediaDropzone :id="`variant_images_${editingVariantFormItem.client_id}`"
|
<MediaDropzone :id="`variant_images_${editingVariantFormItem.client_id}`"
|
||||||
v-model="editingVariantFormItem.media" label="Foto Varian" :max-files="5" required
|
v-model="editingVariantFormItem.media" label="Foto Varian" :max-files="5" required
|
||||||
:errors="formErrors(editForm, `variants.${editingVariantIndex}.s3_keys`)" />
|
:errors="formErrors(editForm, `variants.${editingVariantIndex}.images`)" />
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
|||||||
@ -167,7 +167,7 @@ function updatePrice(type: string, value: string) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MediaDropzone :id="`variant_images_${variant.client_id}`" :model-value="variant.media"
|
<MediaDropzone :id="`variant_images_${variant.client_id}`" :model-value="variant.media"
|
||||||
label="Foto Varian" :max-files="5" required :errors="variantErrors(variant.client_id, 's3_keys')"
|
label="Foto Varian" :max-files="5" required :errors="variantErrors(variant.client_id, 'images')"
|
||||||
@update:model-value="emit('update:media', $event)" />
|
@update:model-value="emit('update:media', $event)" />
|
||||||
</div>
|
</div>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@ -67,12 +67,6 @@ function allVariantImageTitles(product: ProductListItem): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function allVariantImageStocks(product: ProductListItem): string[] {
|
|
||||||
return product.variants.flatMap((variant) =>
|
|
||||||
(variant.images ?? []).map(() => `Stok Bagus: ${variant.stock_formatted} | Reject: ${variant.reject_stock_formatted} | Ecer: ${variant.retail_stock_formatted}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationModalOpen = ref(false);
|
const verificationModalOpen = ref(false);
|
||||||
const selectedRequestId = ref<number | null>(null);
|
const selectedRequestId = ref<number | null>(null);
|
||||||
|
|
||||||
@ -178,7 +172,7 @@ function openEditModal(variant: Variant, product: ProductListItem) {
|
|||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" :all-urls="allVariantImageUrls(product)" :all-titles="allVariantImageTitles(product)" :all-stocks="allVariantImageStocks(product)" />
|
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" :all-urls="allVariantImageUrls(product)" :all-titles="allVariantImageTitles(product)" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ variant.stock_formatted }}
|
{{ variant.stock_formatted }}
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Plus, Save, Search, X } from '@lucide/vue';
|
import { Plus, Save } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { FieldError } from '@/components/ui/field';
|
import { FieldError } from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { useVariantList } from '@/composables/useVariantList';
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
import { parseRupiah } from '@/lib/rupiah';
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
@ -122,31 +121,6 @@ function allPricesHaveSameValue(): boolean {
|
|||||||
return prices.value.every((item) => item.price.trim() === first);
|
return prices.value.every((item) => item.price.trim() === first);
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantSearchQuery = ref('');
|
|
||||||
|
|
||||||
function shouldShowVariant(price: RawMaterialPriceFormItem, index: number) {
|
|
||||||
const hasError = Object.keys(form.errors).some((key) => key.startsWith(`prices.${index}.`));
|
|
||||||
if (hasError) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = variantSearchQuery.value.trim().toLowerCase();
|
|
||||||
if (!query) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return price.variant.toLowerCase().includes(query) || price.variant.trim() === '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasVisiblePrices = computed(() => {
|
|
||||||
return prices.value.some((price, index) => shouldShowVariant(price, index));
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleAddPrice() {
|
|
||||||
variantSearchQuery.value = '';
|
|
||||||
addPrice();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPrice() {
|
function addPrice() {
|
||||||
const newPrice: RawMaterialPriceFormItem = {
|
const newPrice: RawMaterialPriceFormItem = {
|
||||||
client_id: createClientId(),
|
client_id: createClientId(),
|
||||||
@ -236,31 +210,7 @@ function submit() {
|
|||||||
<RawMaterialSharedPriceSection :form="form" :prices="prices" :use-same-price="useSamePrice"
|
<RawMaterialSharedPriceSection :form="form" :prices="prices" :use-same-price="useSamePrice"
|
||||||
@toggle-use-same-price="toggleUseSamePrice" @set-shared-price="setSharedPrice" />
|
@toggle-use-same-price="toggleUseSamePrice" @set-shared-price="setSharedPrice" />
|
||||||
|
|
||||||
<!-- Section pencarian varian -->
|
<RawMaterialVariantSection v-for="(price, index) in prices" :key="price.client_id" :form="form"
|
||||||
<div v-if="prices.length > 0" class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<h3 class="text-lg font-semibold tracking-tight">Daftar Varian ({{ prices.length }})</h3>
|
|
||||||
<div v-if="prices.length > 1" class="relative w-full md:max-w-xs">
|
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
v-model="variantSearchQuery"
|
|
||||||
type="text"
|
|
||||||
placeholder="Cari varian berdasarkan nama..."
|
|
||||||
class="pl-9 pr-8 h-9 w-full bg-background"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="variantSearchQuery"
|
|
||||||
type="button"
|
|
||||||
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded-full hover:bg-muted cursor-pointer flex items-center justify-center"
|
|
||||||
@click="variantSearchQuery = ''"
|
|
||||||
>
|
|
||||||
<X class="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RawMaterialVariantSection v-for="(price, index) in prices" :key="price.client_id"
|
|
||||||
v-show="shouldShowVariant(price, index)"
|
|
||||||
:form="form"
|
|
||||||
:price="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
|
:price="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
|
||||||
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
|
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
|
||||||
@remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="applyPriceToAllVariants(price.client_id)"
|
@remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="applyPriceToAllVariants(price.client_id)"
|
||||||
@ -268,19 +218,10 @@ function submit() {
|
|||||||
@update:stock="setPriceField(price.client_id, 'stock', $event)"
|
@update:stock="setPriceField(price.client_id, 'stock', $event)"
|
||||||
@update:price="setPriceValue(price.client_id, $event)" />
|
@update:price="setPriceValue(price.client_id, $event)" />
|
||||||
|
|
||||||
<div v-if="prices.length > 0 && !hasVisiblePrices" class="flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-muted/40 text-center animate-in fade-in duration-200">
|
|
||||||
<Search class="size-8 text-muted-foreground mb-2" />
|
|
||||||
<p class="text-sm font-medium">Tidak ada varian yang cocok</p>
|
|
||||||
<p class="text-xs text-muted-foreground mt-0.5">Tidak ditemukan varian dengan nama "{{ variantSearchQuery }}"</p>
|
|
||||||
<Button type="button" variant="outline" size="sm" class="mt-4 cursor-pointer" @click="variantSearchQuery = ''">
|
|
||||||
Clear Pencarian
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldError :errors="formErrors(form, 'prices')" />
|
<FieldError :errors="formErrors(form, 'prices')" />
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Button type="button" variant="outline" @click="handleAddPrice">
|
<Button type="button" variant="outline" @click="addPrice">
|
||||||
<Plus class="size-4" />
|
<Plus class="size-4" />
|
||||||
Tambah Varian
|
Tambah Varian
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -165,7 +165,7 @@ function submit() {
|
|||||||
|
|
||||||
<MediaDropzone :id="`price_images_${editingPriceFormItem.client_id}`"
|
<MediaDropzone :id="`price_images_${editingPriceFormItem.client_id}`"
|
||||||
v-model="editingPriceFormItem.media" label="Foto Varian" :max-files="5" required
|
v-model="editingPriceFormItem.media" label="Foto Varian" :max-files="5" required
|
||||||
:errors="formErrors(editForm, `prices.${editingPriceIndex}.s3_keys`)" />
|
:errors="formErrors(editForm, `prices.${editingPriceIndex}.images`)" />
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
|||||||
@ -84,7 +84,7 @@ const emit = defineEmits<{
|
|||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
<MediaDropzone :id="`price_images_${price.client_id}`" v-model="price.media" label="Foto Varian"
|
<MediaDropzone :id="`price_images_${price.client_id}`" v-model="price.media" label="Foto Varian"
|
||||||
:max-files="5" required :errors="priceErrors(price.client_id, 's3_keys')" />
|
:max-files="5" required :errors="priceErrors(price.client_id, 'images')" />
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -61,12 +61,6 @@ function allVariantImageTitles(material: RawMaterialListItem): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function allVariantImageStocks(material: RawMaterialListItem): string[] {
|
|
||||||
return material.prices.flatMap((price) =>
|
|
||||||
(price.images ?? []).map(() => `Stok: ${price.stock_formatted}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationModalOpen = ref(false);
|
const verificationModalOpen = ref(false);
|
||||||
const selectedRequestId = ref<number | null>(null);
|
const selectedRequestId = ref<number | null>(null);
|
||||||
|
|
||||||
@ -173,8 +167,7 @@ function openEditModal(price: RawMaterialPrice, material: RawMaterialListItem) {
|
|||||||
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1"
|
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1"
|
||||||
:title="`${material.name} - ${price.variant}`"
|
:title="`${material.name} - ${price.variant}`"
|
||||||
:all-urls="allVariantImageUrls(material)"
|
:all-urls="allVariantImageUrls(material)"
|
||||||
:all-titles="allVariantImageTitles(material)"
|
:all-titles="allVariantImageTitles(material)" />
|
||||||
:all-stocks="allVariantImageStocks(material)" />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ price.stock_formatted }}
|
{{ price.stock_formatted }}
|
||||||
|
|||||||
@ -29,9 +29,9 @@ const form = useForm({
|
|||||||
address: props.data.address ?? '',
|
address: props.data.address ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const logoState = ref<MediaUploadState>(createMediaUploadState(props.data.logo ? [props.data.logo] : []));
|
const logoState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
const faviconState = ref<MediaUploadState>(createMediaUploadState(props.data.favicon ? [props.data.favicon] : []));
|
const faviconState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
const loginCoverState = ref<MediaUploadState>(createMediaUploadState(props.data.login_cover ? [props.data.login_cover] : []));
|
const loginCoverState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
|
|
||||||
const isUploading = computed(() =>
|
const isUploading = computed(() =>
|
||||||
logoState.value.pendingUploads > 0 ||
|
logoState.value.pendingUploads > 0 ||
|
||||||
|
|||||||
12
resources/js/types/global.d.ts
vendored
12
resources/js/types/global.d.ts
vendored
@ -1,17 +1,5 @@
|
|||||||
import type { Auth } from '@/types/auth';
|
import type { Auth } from '@/types/auth';
|
||||||
|
|
||||||
interface BeforeInstallPromptEvent extends Event {
|
|
||||||
readonly platforms: string[];
|
|
||||||
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
|
|
||||||
prompt(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
deferredPwaPrompt?: BeforeInstallPromptEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extend ImportMeta interface for Vite...
|
// Extend ImportMeta interface for Vite...
|
||||||
declare module 'vite/client' {
|
declare module 'vite/client' {
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
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>;
|
|
||||||
@ -12,12 +12,6 @@ export type MarketplaceFeeRule = {
|
|||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MediaItem = {
|
|
||||||
id: number;
|
|
||||||
url: string;
|
|
||||||
thumb_url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SystemSettingsData = {
|
export type SystemSettingsData = {
|
||||||
app_name: string;
|
app_name: string;
|
||||||
about_app: string;
|
about_app: string;
|
||||||
@ -27,9 +21,6 @@ export type SystemSettingsData = {
|
|||||||
logo_url: string;
|
logo_url: string;
|
||||||
favicon_url: string | null;
|
favicon_url: string | null;
|
||||||
login_cover_url: string;
|
login_cover_url: string;
|
||||||
logo: MediaItem | null;
|
|
||||||
favicon: MediaItem | null;
|
|
||||||
login_cover: MediaItem | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SocialMediaSettingsData = {
|
export type SocialMediaSettingsData = {
|
||||||
@ -63,6 +54,12 @@ export type HrSettingsData = {
|
|||||||
absent_penalty_amount: number;
|
absent_penalty_amount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MediaItem = {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
thumb_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type HomepageSettingsData = {
|
export type HomepageSettingsData = {
|
||||||
hero_image_url: string | null;
|
hero_image_url: string | null;
|
||||||
about_image_url: string | null;
|
about_image_url: string | null;
|
||||||
|
|||||||
@ -21,8 +21,6 @@
|
|||||||
use App\Http\Controllers\Admin\Manage\OwnerVerificationController;
|
use App\Http\Controllers\Admin\Manage\OwnerVerificationController;
|
||||||
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseController;
|
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseController;
|
||||||
use App\Http\Controllers\Admin\Manage\Purchase\PurchaseDraftItemController;
|
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\RetailStockController;
|
||||||
use App\Http\Controllers\Admin\Manage\Stock\StockController;
|
use App\Http\Controllers\Admin\Manage\Stock\StockController;
|
||||||
use App\Http\Controllers\Admin\Manage\StokOpnameController;
|
use App\Http\Controllers\Admin\Manage\StokOpnameController;
|
||||||
@ -156,19 +154,31 @@
|
|||||||
->name('store');
|
->name('store');
|
||||||
|
|
||||||
Route::get('{rawMaterial}/edit', [RawMaterialController::class, 'edit'])
|
Route::get('{rawMaterial}/edit', [RawMaterialController::class, 'edit'])
|
||||||
->middleware('permission:'.Permission::RAW_MATERIALS_UPDATE->value)
|
->middleware([
|
||||||
|
'permission:'.Permission::RAW_MATERIALS_UPDATE->value,
|
||||||
|
'no_pending_owner_verification:rawMaterial',
|
||||||
|
])
|
||||||
->name('edit');
|
->name('edit');
|
||||||
|
|
||||||
Route::put('{rawMaterial}', [RawMaterialController::class, 'update'])
|
Route::put('{rawMaterial}', [RawMaterialController::class, 'update'])
|
||||||
->middleware('permission:'.Permission::RAW_MATERIALS_UPDATE->value)
|
->middleware([
|
||||||
|
'permission:'.Permission::RAW_MATERIALS_UPDATE->value,
|
||||||
|
'no_pending_owner_verification:rawMaterial',
|
||||||
|
])
|
||||||
->name('update');
|
->name('update');
|
||||||
|
|
||||||
Route::patch('{rawMaterial}/toggle-status', [RawMaterialController::class, 'toggleStatus'])
|
Route::patch('{rawMaterial}/toggle-status', [RawMaterialController::class, 'toggleStatus'])
|
||||||
->middleware('permission:'.Permission::RAW_MATERIALS_TOGGLE_STATUS->value)
|
->middleware([
|
||||||
|
'permission:'.Permission::RAW_MATERIALS_TOGGLE_STATUS->value,
|
||||||
|
'no_pending_owner_verification:rawMaterial',
|
||||||
|
])
|
||||||
->name('toggle_status');
|
->name('toggle_status');
|
||||||
|
|
||||||
Route::delete('{rawMaterial}', [RawMaterialController::class, 'destroy'])
|
Route::delete('{rawMaterial}', [RawMaterialController::class, 'destroy'])
|
||||||
->middleware('permission:'.Permission::RAW_MATERIALS_DELETE->value)
|
->middleware([
|
||||||
|
'permission:'.Permission::RAW_MATERIALS_DELETE->value,
|
||||||
|
'no_pending_owner_verification:rawMaterial',
|
||||||
|
])
|
||||||
->name('destroy');
|
->name('destroy');
|
||||||
|
|
||||||
Route::get('{rawMaterial}', [RawMaterialController::class, 'show'])->name('show');
|
Route::get('{rawMaterial}', [RawMaterialController::class, 'show'])->name('show');
|
||||||
@ -265,49 +275,6 @@
|
|||||||
->name('draft_items.destroy');
|
->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.')
|
Route::prefix('orders')->name('orders.')
|
||||||
->middleware('permission:'.Permission::ORDERS_VIEW->value)
|
->middleware('permission:'.Permission::ORDERS_VIEW->value)
|
||||||
->group(function () {
|
->group(function () {
|
||||||
|
|||||||
@ -1,486 +0,0 @@
|
|||||||
<?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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -395,20 +395,6 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
|
|||||||
->assertSessionHasErrors('variants.0.stock');
|
->assertSessionHasErrors('variants.0.stock');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('variant s3_keys is required', function () {
|
|
||||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
|
|
||||||
|
|
||||||
$category = Category::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->post(route('admin.master.products.store'), [
|
|
||||||
'name' => 'Produk Baru',
|
|
||||||
'category_ids' => [$category->id],
|
|
||||||
'variants' => [['name' => 'All Size', 'stock' => 10, 'retail_stock' => 0, 'prices' => defaultPrices()]],
|
|
||||||
])
|
|
||||||
->assertSessionHasErrors('variants.0.s3_keys');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('approving create request activates product with variants and categories', function () {
|
test('approving create request activates product with variants and categories', function () {
|
||||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
|
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
|
||||||
$verifier = createProductVerifierUser();
|
$verifier = createProductVerifierUser();
|
||||||
|
|||||||
@ -228,7 +228,7 @@ function createRawMaterialVerifierUser(): User
|
|||||||
// ─── Store ────────────────────────────────────────────────
|
// ─── Store ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Raw Material Store', function () {
|
describe('Raw Material Store', function () {
|
||||||
test('authenticated user with permission can create raw material', function () {
|
test('authenticated user with permission can submit raw material creation request', function () {
|
||||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_CREATE);
|
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_CREATE);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@ -244,7 +244,13 @@ function createRawMaterialVerifierUser(): User
|
|||||||
$this->assertDatabaseHas('raw_materials', [
|
$this->assertDatabaseHas('raw_materials', [
|
||||||
'name' => 'Kain Sutra',
|
'name' => 'Kain Sutra',
|
||||||
'unit' => RawMaterialUnit::METER->value,
|
'unit' => RawMaterialUnit::METER->value,
|
||||||
'is_active' => true,
|
'is_active' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('owner_verification_requests', [
|
||||||
|
'subject_type' => RawMaterial::class,
|
||||||
|
'action' => OwnerVerificationAction::CREATE->value,
|
||||||
|
'status' => OwnerVerificationStatus::PENDING->value,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -376,18 +382,6 @@ function createRawMaterialVerifierUser(): User
|
|||||||
->assertSessionHasErrors('prices.0.stock');
|
->assertSessionHasErrors('prices.0.stock');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('price s3_keys is required', function () {
|
|
||||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_CREATE);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->post(route('admin.master.raw_materials.store'), [
|
|
||||||
'name' => 'Kain Sutra',
|
|
||||||
'unit' => RawMaterialUnit::METER->value,
|
|
||||||
'prices' => [['variant' => 'Merah', 'price' => 50000, 'stock' => 10]],
|
|
||||||
])
|
|
||||||
->assertSessionHasErrors('prices.0.s3_keys');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('creating raw material also creates prices', function () {
|
test('creating raw material also creates prices', function () {
|
||||||
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_CREATE);
|
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_CREATE);
|
||||||
|
|
||||||
|
|||||||
104
vite.config.ts
104
vite.config.ts
@ -3,26 +3,90 @@ import { wayfinder } from '@laravel/vite-plugin-wayfinder';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import laravel from 'laravel-vite-plugin';
|
import laravel from 'laravel-vite-plugin';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
export default defineConfig({
|
const manifestIcons = [
|
||||||
plugins: [
|
{
|
||||||
laravel({
|
src: '/assets/pwa-64x64.png',
|
||||||
input: ['resources/css/app.css', 'resources/js/app.ts'],
|
sizes: '64x64',
|
||||||
refresh: false,
|
type: 'image/png',
|
||||||
}),
|
},
|
||||||
inertia(),
|
{
|
||||||
tailwindcss(),
|
src: '/assets/pwa-192x192.png',
|
||||||
vue({
|
sizes: '192x192',
|
||||||
template: {
|
type: 'image/png',
|
||||||
transformAssetUrls: {
|
},
|
||||||
base: null,
|
{
|
||||||
includeAbsolute: false,
|
src: '/assets/pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/assets/maskable-icon-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
const appName = env.VITE_APP_NAME || 'DST Collection';
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
laravel({
|
||||||
|
input: ['resources/css/app.css', 'resources/js/app.ts'],
|
||||||
|
refresh: false,
|
||||||
|
}),
|
||||||
|
inertia(),
|
||||||
|
tailwindcss(),
|
||||||
|
vue({
|
||||||
|
template: {
|
||||||
|
transformAssetUrls: {
|
||||||
|
base: null,
|
||||||
|
includeAbsolute: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
}),
|
wayfinder({
|
||||||
wayfinder({
|
formVariants: true,
|
||||||
formVariants: true,
|
}),
|
||||||
}),
|
VitePWA({
|
||||||
],
|
buildBase: '/build/',
|
||||||
|
scope: '/',
|
||||||
|
base: '/',
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
// Kita daftarkan SW manual di app.ts (public/sw.js yang support push)
|
||||||
|
// VitePWA hanya generate manifest di sini
|
||||||
|
injectRegister: null,
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: [
|
||||||
|
'**/*.{js,css,html,ico,jpg,png,svg,woff,woff2,ttf,eot}',
|
||||||
|
],
|
||||||
|
navigateFallback: '/',
|
||||||
|
navigateFallbackDenylist: [/^\/telescope/],
|
||||||
|
maximumFileSizeToCacheInBytes: 3_000_000,
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: appName,
|
||||||
|
short_name: appName,
|
||||||
|
description: `${appName} - Progressive Web App`,
|
||||||
|
theme_color: '#171717',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
id: '/',
|
||||||
|
icons: [...manifestIcons],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user