feat: implement owner verification system with enums, service, and controller updates for managing verification requests

This commit is contained in:
Yoga Pangestu 2026-06-26 19:13:10 +07:00
parent d0223b2800
commit 21b990799e
48 changed files with 4285 additions and 470 deletions

View File

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum OwnerVerificationAction: string
{
use ProvidesEnumOptions;
case CREATE = 'create';
case UPDATE = 'update';
case DELETE = 'delete';
case TOGGLE_STATUS = 'toggle_status';
case STOCK_VERIFY = 'stock_verify';
public function label(): string
{
return match ($this) {
self::CREATE => 'Tambah',
self::UPDATE => 'Ubah',
self::DELETE => 'Hapus',
self::TOGGLE_STATUS => 'Ubah Status',
self::STOCK_VERIFY => 'Verifikasi Stok',
};
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum OwnerVerificationStatus: string
{
use ProvidesEnumOptions;
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function label(): string
{
return match ($this) {
self::PENDING => 'Menunggu Verifikasi',
self::APPROVED => 'Disetujui',
self::REJECTED => 'Ditolak',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::PENDING => 'outline',
self::APPROVED => 'default',
self::REJECTED => 'destructive',
};
}
}

View File

@ -170,6 +170,8 @@ public function permissions(): array
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::OWNER_VERIFICATIONS_VIEW,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
@ -280,6 +282,8 @@ public function permissions(): array
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::OWNER_VERIFICATIONS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::CUTTINGS_VIEW,

View File

@ -2,11 +2,16 @@
namespace App\Http\Controllers\Admin\Manage;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\ApproveVerificationRequest;
use App\Http\Requests\Admin\Manage\RejectVerificationRequest;
use App\Models\Cutting;
use App\Models\OwnerVerificationRequest;
use App\Services\Manage\OwnerVerificationService;
use App\Services\Manage\StockService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -15,22 +20,40 @@
class OwnerVerificationController extends Controller
{
use FlashesEntityMessage;
use FlashesEntityMessage, ParsesDataTableQuery;
public function __construct(
private readonly OwnerVerificationService $ownerVerificationService,
private readonly StockService $stockService,
) {}
public function index(Request $request): Response
{
$user = $request->user();
$tableQuery = $this->parseDataTableQuery($request);
$status = $request->string('status')->toString();
$subjectType = $request->string('subject_type')->toString();
$action = $request->string('action')->toString();
return Inertia::render('admin/manage/owner-verifications/Index', [
'pendingCuttings' => $this->stockService->getPendingApprovalCuttings($user),
'verificationRequests' => $this->ownerVerificationService->paginateForIndex(
$request->user(),
$tableQuery,
$status,
$subjectType,
$action,
),
'filters' => $this->dataTableFilters($tableQuery, [
'status' => $status,
'subject_type' => $subjectType,
'action' => $action,
]),
'statusOptions' => OwnerVerificationStatus::selectOptions(),
'subjectOptions' => $this->ownerVerificationService->subjectTypeOptions($request->user()),
'actionOptions' => OwnerVerificationAction::selectOptions(),
]);
}
public function approve(ApproveVerificationRequest $request, Cutting $cutting): RedirectResponse
public function approveCutting(ApproveVerificationRequest $request, Cutting $cutting): RedirectResponse
{
$this->stockService->approveVerification(
$cutting,
@ -38,12 +61,12 @@ public function approve(ApproveVerificationRequest $request, Cutting $cutting):
$request->validated('approval_note'),
);
$this->flashSuccess('Verifikasi berhasil disetujui. Stok produk telah ditambahkan ke toko.');
$this->flashSuccess('Verifikasi cutting berhasil disetujui. Stok produk telah ditambahkan ke toko.');
return redirect()->route('admin.manage.owner_verifications.index');
}
public function reject(RejectVerificationRequest $request, Cutting $cutting): RedirectResponse
public function rejectCutting(RejectVerificationRequest $request, Cutting $cutting): RedirectResponse
{
$this->stockService->rejectVerification(
$cutting,
@ -51,6 +74,36 @@ public function reject(RejectVerificationRequest $request, Cutting $cutting): Re
$request->validated('reason'),
);
$this->flashSuccess('Verifikasi cutting berhasil ditolak.');
return redirect()->route('admin.manage.owner_verifications.index');
}
public function approveRequest(
ApproveVerificationRequest $request,
OwnerVerificationRequest $ownerVerificationRequest,
): RedirectResponse {
$this->ownerVerificationService->approveRequest(
$ownerVerificationRequest,
$request->user(),
$request->validated('approval_note'),
);
$this->flashSuccess('Verifikasi berhasil disetujui.');
return redirect()->route('admin.manage.owner_verifications.index');
}
public function rejectRequest(
RejectVerificationRequest $request,
OwnerVerificationRequest $ownerVerificationRequest,
): RedirectResponse {
$this->ownerVerificationService->rejectRequest(
$ownerVerificationRequest,
$request->user(),
$request->validated('reason'),
);
$this->flashSuccess('Verifikasi berhasil ditolak.');
return redirect()->route('admin.manage.owner_verifications.index');

View File

@ -46,7 +46,7 @@ public function store(PurchaseRequest $request): RedirectResponse
{
$this->purchaseService->create($request->validated(), $request->user());
$this->flashCreated('Belanja');
$this->flashSuccess('Belanja berhasil diajukan dan menunggu verifikasi owner.');
return redirect()->route('admin.manage.purchases.index');
}
@ -62,18 +62,18 @@ public function edit(Purchase $purchase): Response
public function update(PurchaseRequest $request, Purchase $purchase): RedirectResponse
{
$this->purchaseService->update($purchase, $request->validated());
$this->purchaseService->update($purchase, $request->validated(), $request->user());
$this->flashUpdated('Belanja');
$this->flashSuccess('Perubahan belanja berhasil diajukan dan menunggu verifikasi owner.');
return redirect()->route('admin.manage.purchases.index');
}
public function destroy(Purchase $purchase): RedirectResponse
public function destroy(Request $request, Purchase $purchase): RedirectResponse
{
$this->purchaseService->delete($purchase);
$this->purchaseService->delete($purchase, $request->user());
$this->flashDeleted('Belanja');
$this->flashSuccess('Penghapusan belanja berhasil diajukan dan menunggu verifikasi owner.');
return redirect()->route('admin.manage.purchases.index');
}

View File

@ -53,9 +53,9 @@ public function create(): Response
public function store(ProductRequest $request): RedirectResponse
{
$this->productService->create($request->validated());
$this->productService->create($request->validated(), $request->user());
$this->flashCreated('Produk');
$this->flashSuccess('Produk berhasil diajukan dan menunggu verifikasi owner.');
return redirect()->route('admin.master.products.index');
}
@ -81,27 +81,27 @@ public function edit(Product $product): Response
public function update(ProductRequest $request, Product $product): RedirectResponse
{
$this->productService->update($product, $request->validated());
$this->productService->update($product, $request->validated(), $request->user());
$this->flashUpdated('Produk');
$this->flashSuccess('Perubahan produk berhasil diajukan dan menunggu verifikasi owner.');
return redirect()->route('admin.master.products.index');
}
public function toggleStatus(ToggleStatusRequest $request, Product $product): RedirectResponse
{
$this->productService->toggleStatus($product, $request->validated());
$this->productService->toggleStatus($product, $request->validated(), $request->user());
$this->flashStatusUpdated('produk');
$this->flashSuccess('Perubahan status produk berhasil diajukan dan menunggu verifikasi owner.');
return back();
}
public function destroy(Product $product): RedirectResponse
public function destroy(Request $request, Product $product): RedirectResponse
{
$this->productService->delete($product);
$this->productService->delete($product, $request->user());
$this->flashDeleted('Produk');
$this->flashSuccess('Penghapusan produk berhasil diajukan dan menunggu verifikasi owner.');
return redirect()->route('admin.master.products.index');
}

View File

@ -49,9 +49,9 @@ public function create(): Response
public function store(RawMaterialRequest $request): RedirectResponse
{
$this->rawMaterialService->create($request->validated());
$this->rawMaterialService->create($request->validated(), $request->user());
$this->flashCreated('Bahan baku');
$this->flashSuccess('Bahan baku berhasil diajukan dan menunggu verifikasi owner.');
return redirect()->route('admin.master.raw_materials.index');
}
@ -74,27 +74,27 @@ public function edit(RawMaterial $rawMaterial): Response
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
$this->rawMaterialService->update($rawMaterial, $request->validated());
$this->rawMaterialService->update($rawMaterial, $request->validated(), $request->user());
$this->flashUpdated('Bahan baku');
$this->flashSuccess('Perubahan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
return redirect()->route('admin.master.raw_materials.index');
}
public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated());
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user());
$this->flashStatusUpdated('bahan baku');
$this->flashSuccess('Perubahan status bahan baku berhasil diajukan dan menunggu verifikasi owner.');
return back();
}
public function destroy(RawMaterial $rawMaterial): RedirectResponse
public function destroy(Request $request, RawMaterial $rawMaterial): RedirectResponse
{
$this->rawMaterialService->delete($rawMaterial);
$this->rawMaterialService->delete($rawMaterial, $request->user());
$this->flashDeleted('Bahan baku');
$this->flashSuccess('Penghapusan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
return redirect()->route('admin.master.raw_materials.index');
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Middleware;
use App\Models\Product;
use App\Models\Purchase;
use App\Models\RawMaterial;
use Closure;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class EnsureNoPendingOwnerVerification
{
public function handle(Request $request, Closure $next, string $routeParameter): Response
{
$subject = $request->route($routeParameter);
if (! $subject instanceof Product && ! $subject instanceof RawMaterial && ! $subject instanceof Purchase) {
return $next($request);
}
if (! $subject->hasPendingOwnerVerification()) {
return $next($request);
}
$label = match ($subject::class) {
Product::class => 'Produk',
RawMaterial::class => 'Bahan baku',
Purchase::class => 'Belanja',
default => 'Data',
};
$redirectRoute = match ($subject::class) {
Product::class => route('admin.master.products.index'),
RawMaterial::class => route('admin.master.raw_materials.index'),
Purchase::class => route('admin.manage.purchases.index'),
default => route('admin.dashboard'),
};
Inertia::flash('error', "{$label} sedang menunggu verifikasi owner.");
return redirect($redirectRoute);
}
}

View File

@ -5,6 +5,7 @@
use App\Models\Cutting;
use App\Models\EmployeeAdvance;
use App\Models\LeaveRequest;
use App\Services\Manage\OwnerVerificationService;
use App\Services\System\Setting\SystemService;
use App\Settings\SystemSettings;
use Illuminate\Http\Request;
@ -121,8 +122,6 @@ private function pendingOwnerVerifications(Request $request): int
return 0;
}
return Cutting::query()
->pendingVerification()
->count();
return app(OwnerVerificationService::class)->pendingCountForUser($user);
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Models\Concerns;
trait HasPendingOwnerVerification
{
public function hasPendingOwnerVerification(): bool
{
return $this->pendingOwnerVerificationRequest()->exists();
}
}

View File

@ -0,0 +1,186 @@
<?php
namespace App\Models;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\HasRejection;
use App\Models\Concerns\InteractsWithActivityLog;
use App\Support\ActivityLog\ModelLabel;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
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\MorphTo;
use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])]
#[Appends([
'action_label',
'status_label',
'subject_label',
'submitted_by_name',
'verified_by_name',
'created_at_formatted',
'verified_at_formatted',
'is_pending',
])]
class OwnerVerificationRequest extends Model implements HasMedia
{
use HasFactory, HasModuleMedia, HasRejection, InteractsWithActivityLog;
protected function casts(): array
{
return [
'action' => OwnerVerificationAction::class,
'status' => OwnerVerificationStatus::class,
'payload' => 'array',
'verified_at' => 'datetime',
];
}
public function subject(): MorphTo
{
return $this->morphTo();
}
public function submittedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by_id');
}
public function verifiedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by_id');
}
public function actionLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->action?->label(),
);
}
public function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function isPending(): Attribute
{
return Attribute::make(
get: fn () => $this->status === OwnerVerificationStatus::PENDING,
);
}
public function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status?->label(),
);
}
public function subjectLabel(): Attribute
{
return Attribute::make(
get: fn () => ModelLabel::for($this->subject_type),
);
}
public function submittedByName(): Attribute
{
return Attribute::make(
get: fn () => $this->submittedBy?->profile?->full_name
?? $this->submittedBy?->username
?? '-',
);
}
public function verifiedAtFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->verified_at?->translatedFormat('l, d F Y H:i'),
);
}
public function verifiedByName(): Attribute
{
return Attribute::make(
get: fn () => $this->verifiedBy?->profile?->full_name
?? $this->verifiedBy?->username,
);
}
#[Scope]
public function approved(Builder $query): void
{
$query->where('status', OwnerVerificationStatus::APPROVED);
}
#[Scope]
public function pending(Builder $query): void
{
$query->where('status', OwnerVerificationStatus::PENDING);
}
#[Scope]
public function rejected(Builder $query): void
{
$query->where('status', OwnerVerificationStatus::REJECTED);
}
#[Scope]
public function forSubmitter(Builder $query, User $user): void
{
$query->where('submitted_by_id', $user->id);
}
#[Scope]
public function visibleTo(Builder $query, User $user): void
{
if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
return;
}
$query->forSubmitter($user);
}
public function pendingToggleIsActive(): ?bool
{
if ($this->action !== OwnerVerificationAction::TOGGLE_STATUS) {
return null;
}
$payload = is_array($this->payload) ? $this->payload : [];
$new = $payload['new'] ?? null;
if (! is_array($new) || ! array_key_exists('is_active', $new)) {
return null;
}
return (bool) $new['is_active'];
}
public static function mediaModuleName(): string
{
return 'owner_verification_request';
}
public function variantImageCollection(int $index): string
{
return "variant_images_{$index}";
}
public function priceImageCollection(int $index): string
{
return "price_images_{$index}";
}
}

View File

@ -2,6 +2,8 @@
namespace App\Models;
use App\Enums\OwnerVerificationStatus;
use App\Models\Concerns\HasPendingOwnerVerification;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
@ -11,6 +13,8 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
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\Sluggable\Attributes\Sluggable;
@ -22,7 +26,7 @@
#[Sluggable(from: 'name', to: 'slug')]
class Product extends Model
{
use HasFactory, InteractsWithActivityLog, SoftDeletes;
use HasFactory, HasPendingOwnerVerification, InteractsWithActivityLog, SoftDeletes;
protected function casts(): array
{
@ -41,6 +45,18 @@ public function variants(): HasMany
return $this->hasMany(ProductVariant::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();
}
#[Scope]
public function active(Builder $query): void
{

View File

@ -2,6 +2,8 @@
namespace App\Models;
use App\Enums\OwnerVerificationStatus;
use App\Models\Concerns\HasPendingOwnerVerification;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
@ -11,6 +13,8 @@
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;
@ -24,7 +28,7 @@
])]
class Purchase extends Model implements HasMedia
{
use HasFactory, HasModuleMedia, InteractsWithActivityLog, SoftDeletes;
use HasFactory, HasModuleMedia, HasPendingOwnerVerification, InteractsWithActivityLog, SoftDeletes;
protected function casts(): array
{
@ -46,6 +50,18 @@ public function items(): HasMany
return $this->hasMany(PurchaseItem::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();
}
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);

View File

@ -2,7 +2,9 @@
namespace App\Models;
use App\Enums\OwnerVerificationStatus;
use App\Enums\RawMaterialUnit;
use App\Models\Concerns\HasPendingOwnerVerification;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
@ -12,13 +14,15 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['unit_label', 'unit_abbreviation'])]
class RawMaterial extends Model
{
use HasFactory, InteractsWithActivityLog, SoftDeletes;
use HasFactory, HasPendingOwnerVerification, InteractsWithActivityLog, SoftDeletes;
protected function casts(): array
{
@ -45,6 +49,18 @@ public function prices(): HasMany
return $this->hasMany(RawMaterialPrice::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();
}
public function unitAbbreviation(): Attribute
{
return Attribute::make(

View File

@ -0,0 +1,542 @@
<?php
namespace App\Services\Manage;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Models\Cutting;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
use App\Models\Purchase;
use App\Models\RawMaterial;
use App\Models\User;
use App\Services\Manage\PurchaseService;
use App\Services\Master\ProductService;
use App\Services\Master\RawMaterialService;
use App\Services\System\PushNotificationService;
use App\Support\ActivityLog\ModelLabel;
use App\Support\OwnerVerification\VerificationChangeFormatter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator as Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class OwnerVerificationService
{
public function __construct(
private readonly StockService $stockService,
private readonly ProductService $productService,
private readonly RawMaterialService $rawMaterialService,
private readonly PurchaseService $purchaseService,
private readonly PushNotificationService $pushNotificationService,
) {}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(
User $user,
array $tableQuery,
string $status = '',
string $subjectType = '',
string $action = '',
): LengthAwarePaginator {
$perPage = 10;
$page = Paginator::resolveCurrentPage();
$includeCuttings = $this->shouldIncludeCuttings($user, $status, $subjectType, $action);
$cuttingRows = $includeCuttings
? $this->pendingCuttingRows($user, $tableQuery)
: collect();
$cuttingCount = $cuttingRows->count();
$requestsOnly = $action === OwnerVerificationAction::STOCK_VERIFY->value
|| $subjectType === Cutting::class;
$query = $this->buildVerificationRequestQuery($user, $tableQuery, $status, $subjectType, $action);
$requestTotal = $requestsOnly ? 0 : (clone $query)->count();
$total = $cuttingCount + $requestTotal;
if ($requestsOnly) {
$items = $cuttingRows->forPage($page, $perPage)->values();
return $this->makePaginator($items, $total, $perPage, $page);
}
if ($cuttingCount > 0) {
if ($page === 1) {
$requestLimit = max(0, $perPage - $cuttingCount);
$requestItems = $requestLimit > 0
? $query->take($requestLimit)->get()->map(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request))
: collect();
$items = $cuttingRows->concat($requestItems)->values();
} else {
$requestOffset = ($page - 1) * $perPage - $cuttingCount;
$items = $query
->skip(max(0, $requestOffset))
->take($perPage)
->get()
->map(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request));
}
return $this->makePaginator($items, $total, $perPage, $page);
}
return $query
->paginate($perPage)
->withQueryString()
->through(fn (OwnerVerificationRequest $request) => $this->presentRequestRow($request));
}
/**
* @return Collection<int, array<string, mixed>>
*/
private function pendingCuttingRows(User $user, array $tableQuery): Collection
{
$rows = $this->stockService
->getPendingApprovalCuttings($user)
->map(fn (Cutting $cutting) => $this->presentCuttingRow($cutting));
if ($tableQuery['search'] === '') {
return $rows->values();
}
$search = mb_strtolower($tableQuery['search']);
return $rows
->filter(function (array $row) use ($search): bool {
foreach (['title', 'summary', 'submitted_by_name', 'action_label', 'subject_label', 'status_label'] as $field) {
$value = mb_strtolower((string) ($row[$field] ?? ''));
if ($value !== '' && str_contains($value, $search)) {
return true;
}
}
return false;
})
->values();
}
public function pendingCountForUser(User $user): int
{
$requestCount = OwnerVerificationRequest::query()
->pending()
->visibleTo($user)
->count();
if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
return $requestCount;
}
return $requestCount + Cutting::query()->pendingVerification()->count();
}
/**
* @return list<array{value: string, label: string}>
*/
public function subjectTypeOptions(User $user): array
{
$options = OwnerVerificationRequest::query()
->visibleTo($user)
->distinct()
->pluck('subject_type')
->filter()
->map(fn (string $type) => [
'value' => $type,
'label' => ModelLabel::for($type),
]);
if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)
&& Cutting::query()->pendingVerification()->exists()) {
$options->push([
'value' => Cutting::class,
'label' => ModelLabel::for(Cutting::class),
]);
}
return $options
->unique('value')
->sortBy('label')
->values()
->all();
}
public function approveRequest(
OwnerVerificationRequest $request,
User $user,
?string $approvalNote = null,
): void {
if ($request->status !== OwnerVerificationStatus::PENDING) {
throw ValidationException::withMessages([
'status' => 'Hanya pengajuan yang menunggu verifikasi yang dapat disetujui.',
]);
}
try {
DB::transaction(function () use ($request, $user, $approvalNote): void {
$this->applyVerificationRequest($request);
if ($approvalNote !== null && trim($approvalNote) !== '') {
$request->rejection()->create([
'reason' => trim($approvalNote),
'rejected_by_id' => $user->id,
]);
}
$request->update([
'status' => OwnerVerificationStatus::APPROVED,
'verified_by_id' => $user->id,
'verified_at' => now(),
]);
$this->clearVerificationRequestMedia($request);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menyetujui verifikasi owner: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$subjectLabel = ModelLabel::for($request->subject_type);
$this->notifyRequestSubmitter(
$request,
'✅ Pengajuan Disetujui',
"Pengajuan {$request->action->label()} {$subjectLabel} '{$this->requestTitle($request)}' telah disetujui owner.",
);
}
public function rejectRequest(
OwnerVerificationRequest $request,
User $user,
string $reason,
): void {
if ($request->status !== OwnerVerificationStatus::PENDING) {
throw ValidationException::withMessages([
'status' => 'Hanya pengajuan yang menunggu verifikasi yang dapat ditolak.',
]);
}
try {
DB::transaction(function () use ($request, $user, $reason): void {
$this->rejectVerificationRequest($request);
$request->rejection()->create([
'reason' => trim($reason),
'rejected_by_id' => $user->id,
]);
$request->update([
'status' => OwnerVerificationStatus::REJECTED,
'verified_by_id' => $user->id,
'verified_at' => now(),
]);
$this->clearVerificationRequestMedia($request);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menolak verifikasi owner: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$title = $this->requestTitle($request);
$subjectLabel = ModelLabel::for($request->subject_type);
$this->pushNotificationService->sendToRoles(
'❌ Pengajuan Ditolak',
"Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.",
['owner', 'developer'],
route('admin.manage.owner_verifications.index'),
);
$this->notifyRequestSubmitter(
$request,
'❌ Pengajuan Ditolak',
"Pengajuan {$request->action->label()} {$subjectLabel} '{$title}' ditolak dengan alasan: '{$reason}'.",
);
}
private function rejectVerificationRequest(OwnerVerificationRequest $request): void
{
match ($request->subject_type) {
Product::class => $this->productService->rejectVerificationRequest($request),
RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request),
Purchase::class => $this->purchaseService->rejectVerificationRequest($request),
default => throw ValidationException::withMessages([
'subject_type' => 'Tipe data verifikasi tidak didukung.',
]),
};
}
private function applyVerificationRequest(OwnerVerificationRequest $request): void
{
match ($request->subject_type) {
Product::class => $this->productService->applyVerificationRequest($request),
RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request),
Purchase::class => $this->purchaseService->applyVerificationRequest($request),
default => throw ValidationException::withMessages([
'subject_type' => 'Tipe data verifikasi tidak didukung.',
]),
};
}
private function clearVerificationRequestMedia(OwnerVerificationRequest $request): void
{
match ($request->subject_type) {
Product::class => $this->productService->clearVerificationRequestMedia($request),
RawMaterial::class => $this->rawMaterialService->clearVerificationRequestMedia($request),
Purchase::class => $this->purchaseService->clearVerificationRequestMedia($request),
default => null,
};
}
/**
* @return array<string, mixed>
*/
private function presentRequestRow(OwnerVerificationRequest $request): array
{
$payload = is_array($request->payload) ? $request->payload : [];
return [
'id' => $request->id,
'source' => 'request',
'subject_type' => $request->subject_type,
'subject_label' => ModelLabel::for($request->subject_type),
'subject_id' => $request->subject_id,
'action' => $request->action->value,
'action_label' => $request->action->label(),
'status' => $request->status->value,
'status_label' => $request->status->label(),
'title' => $this->requestTitle($request),
'submitted_by_name' => $request->submittedBy?->profile?->full_name
?? $request->submittedBy?->username
?? '-',
'verified_by_name' => $request->verifiedBy?->profile?->full_name
?? $request->verifiedBy?->username,
'verified_at_formatted' => $request->verified_at?->translatedFormat('l, d F Y H:i'),
'created_at' => $request->created_at?->toIso8601String(),
'created_at_formatted' => $request->created_at?->translatedFormat('l, d F Y H:i'),
'changes' => VerificationChangeFormatter::format($payload),
'rejection_reason' => $request->rejection?->reason,
'is_pending' => $request->status === OwnerVerificationStatus::PENDING,
];
}
/**
* @return array<string, mixed>
*/
private function presentCuttingRow(Cutting $cutting): array
{
$totalPieces = $cutting->results->sum('cutting_result');
return [
'id' => $cutting->id,
'source' => 'cutting',
'subject_type' => Cutting::class,
'subject_label' => ModelLabel::for(Cutting::class),
'subject_id' => $cutting->id,
'action' => OwnerVerificationAction::STOCK_VERIFY->value,
'action_label' => OwnerVerificationAction::STOCK_VERIFY->label(),
'status' => OwnerVerificationStatus::PENDING->value,
'status_label' => OwnerVerificationStatus::PENDING->label(),
'title' => "Cutting #{$cutting->id}",
'summary' => $cutting->description ?? "Total hasil {$totalPieces} pcs",
'submitted_by_name' => $cutting->submittedBy?->profile?->full_name
?? $cutting->submittedBy?->username
?? '-',
'created_at' => $cutting->created_at?->toIso8601String(),
'created_at_formatted' => $cutting->created_at?->translatedFormat('l, d F Y H:i'),
'changes' => [],
'is_pending' => true,
'detail' => [
'results' => $cutting->results,
'result_prices' => $cutting->result_prices,
'total_result_pieces' => $cutting->total_result_pieces,
],
];
}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
private function buildVerificationRequestQuery(
User $user,
array $tableQuery,
string $status,
string $subjectType,
string $action,
): Builder {
if ($action === OwnerVerificationAction::STOCK_VERIFY->value || $subjectType === Cutting::class) {
return OwnerVerificationRequest::query()->whereRaw('0 = 1');
}
$query = OwnerVerificationRequest::query()
->visibleTo($user)
->with([
'subject',
'submittedBy.profile',
'verifiedBy.profile',
'rejection',
])
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('action', 'like', "%{$search}%")
->orWhere('status', 'like', "%{$search}%")
->orWhereHas('submittedBy', function (Builder $query) use ($search): void {
$query->where('username', 'like', "%{$search}%")
->orWhereHas('profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%"));
})
->orWhereHasMorph('subject', [Product::class, RawMaterial::class, Purchase::class], function (Builder $query, string $type) use ($search): void {
if ($type === Purchase::class) {
$query->where('notes', 'like', "%{$search}%")
->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
return;
}
$query->where('name', 'like', "%{$search}%");
});
});
})
->when($status !== '', fn (Builder $query) => $query->where('status', $status))
->when($subjectType !== '', fn (Builder $query) => $query->where('subject_type', $subjectType))
->when($action !== '', fn (Builder $query) => $query->where('action', $action));
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query;
}
private function shouldIncludeCuttings(
User $user,
string $status,
string $subjectType,
string $action,
): bool {
if (! $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
return false;
}
if ($status !== '' && $status !== OwnerVerificationStatus::PENDING->value) {
return false;
}
if ($subjectType !== '' && $subjectType !== Cutting::class) {
return false;
}
if ($action !== '' && $action !== OwnerVerificationAction::STOCK_VERIFY->value) {
return false;
}
return Cutting::query()->pendingVerification()->exists();
}
/**
* @param Collection<int, array<string, mixed>> $items
*/
private function makePaginator(
Collection $items,
int $total,
int $perPage,
int $page,
): LengthAwarePaginator {
return (new Paginator(
$items,
$total,
$perPage,
$page,
['path' => Paginator::resolveCurrentPath(), 'pageName' => 'page'],
))->withQueryString();
}
private function requestTitle(OwnerVerificationRequest $request): string
{
if ($request->subject instanceof Product || $request->subject instanceof RawMaterial) {
return $request->subject->name;
}
if ($request->subject instanceof Purchase) {
$request->subject->loadMissing('supplier');
return $request->subject->supplier?->name ?? 'Belanja';
}
$payload = is_array($request->payload) ? $request->payload : [];
foreach (['new', 'old'] as $key) {
$section = $payload[$key] ?? null;
if (is_array($section) && isset($section['supplier_name'])) {
return (string) $section['supplier_name'];
}
}
foreach (['new', 'old'] as $key) {
$section = $payload[$key] ?? null;
if (is_array($section) && isset($section['name'])) {
return (string) $section['name'];
}
}
if (isset($payload['name'])) {
return (string) $payload['name'];
}
return 'Item Baru';
}
private function notifyRequestSubmitter(
OwnerVerificationRequest $request,
string $title,
string $body,
): void {
if (! $request->submitted_by_id) {
return;
}
$url = match ($request->subject_type) {
Product::class => route('admin.master.products.index'),
RawMaterial::class => route('admin.master.raw_materials.index'),
Purchase::class => route('admin.manage.purchases.index'),
default => route('admin.manage.owner_verifications.index'),
};
$this->pushNotificationService->sendToUser(
$title,
$body,
$request->submitted_by_id,
$url,
);
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'action', 'status'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -2,6 +2,9 @@
namespace App\Services\Manage;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Models\OwnerVerificationRequest;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterial;
@ -37,6 +40,7 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
->with([
'supplier:id,name',
'createdBy.profile',
'pendingOwnerVerificationRequest',
'items.rawMaterialPrice.rawMaterial:id,name,unit',
'media',
])
@ -63,6 +67,12 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
MediaPresenter::first($purchase, 'photos'),
);
$pendingRequest = $purchase->pendingOwnerVerificationRequest;
$purchase->setAttribute('has_pending_request', $pendingRequest !== null);
$purchase->setAttribute('pending_request_action', $pendingRequest?->action->value);
$purchase->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
return $purchase;
});
}
@ -242,17 +252,30 @@ public function create(array $validated, User $user): Purchase
$item->update([
'purchase_id' => $purchase->id,
]);
$this->incrementStock($item);
}
$this->syncPhotos($purchase, $validated);
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => null,
'new' => $this->snapshotPurchase($purchase),
],
]);
return $purchase;
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat pembelian: '.$e->getMessage(), [
Log::error('Gagal mengajukan pembelian: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -261,11 +284,10 @@ public function create(array $validated, User $user): Purchase
]);
}
$purchase->load('supplier');
$this->pushNotificationService->sendToRoles(
'🛒 Belanja Baru',
"Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah ditambahkan oleh {$user->profile?->full_name}.",
['owner', 'developer'],
$this->notifyForPendingRequest(
$user,
'Tambah Belanja',
"Pengajuan belanja dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} menunggu verifikasi owner.",
route('admin.manage.purchases.index'),
);
@ -275,44 +297,32 @@ public function create(array $validated, User $user): Purchase
/**
* @param array<string, mixed> $validated
*/
public function update(Purchase $purchase, array $validated): void
public function update(Purchase $purchase, array $validated, User $user): void
{
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
try {
DB::transaction(function () use ($purchase, $validated): void {
$purchase->load('items');
foreach ($purchase->items as $item) {
$this->decrementStock($item);
}
$purchase->items()->delete();
$lineItems = $this->buildLineItems($validated['items']);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
$total = max($subtotal - $discount + $shippingCost, 0);
$purchase->update([
'supplier_id' => $validated['supplier_id'],
'subtotal' => $subtotal,
'discount' => $discount,
'shipping_cost' => $shippingCost,
'total' => $total,
'notes' => $validated['notes'] ?? null,
DB::transaction(function () use ($purchase, $validated, $user): void {
$verificationRequest = OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::UPDATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => $this->snapshotPurchase($purchase),
'new' => $this->buildPayloadFromValidated($validated),
],
]);
foreach ($lineItems as $itemData) {
$purchaseItem = $purchase->items()->create($itemData);
$this->incrementStock($purchaseItem);
if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) {
$this->syncRequestPhotos($verificationRequest, $validated);
}
$this->syncPhotos($purchase, $validated);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui pembelian: '.$e->getMessage(), [
Log::error('Gagal mengajukan perubahan belanja: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -322,34 +332,33 @@ public function update(Purchase $purchase, array $validated): void
}
$purchase->load('supplier');
$this->pushNotificationService->sendToRoles(
'✏️ Belanja Diperbarui',
"Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah diperbarui.",
['owner', 'developer'],
$this->notifyForPendingRequest(
$user,
'Ubah Belanja',
"Pengajuan ubah belanja dari supplier {$purchase->supplier->name} menunggu verifikasi owner.",
route('admin.manage.purchases.index'),
);
}
public function delete(Purchase $purchase): void
public function delete(Purchase $purchase, User $user): void
{
$supplierName = $purchase->supplier->name;
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
try {
DB::transaction(function () use ($purchase): void {
$purchase->load('items');
foreach ($purchase->items as $item) {
$this->decrementStock($item);
}
$purchase->clearMediaCollection('photos');
$purchase->items()->delete();
$purchase->delete();
});
} catch (ValidationException $e) {
throw $e;
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::DELETE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => $this->snapshotPurchase($purchase),
'new' => null,
],
]);
} catch (\Throwable $e) {
Log::error('Gagal menghapus pembelian: '.$e->getMessage(), [
Log::error('Gagal mengajukan penghapusan belanja: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -358,14 +367,87 @@ public function delete(Purchase $purchase): void
]);
}
$this->pushNotificationService->sendToRoles(
'🗑️ Belanja Dihapus',
"Pembelian dari supplier {$supplierName} senilai {$purchase->total_formatted} telah dihapus.",
['owner', 'developer'],
$purchase->load('supplier');
$this->notifyForPendingRequest(
$user,
'Hapus Belanja',
"Pengajuan hapus belanja dari supplier {$purchase->supplier->name} menunggu verifikasi owner.",
route('admin.manage.purchases.index'),
);
}
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 belanja tidak didukung.',
]),
};
}
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 belanja tidak didukung.',
]),
};
}
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
{
$verificationRequest->clearMediaCollection('photos');
}
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
{
$purchase = $verificationRequest->subject;
if (! $purchase instanceof Purchase) {
throw ValidationException::withMessages([
'purchase' => 'Belanja tidak ditemukan.',
]);
}
$purchase->load('items');
foreach ($purchase->items as $item) {
$this->incrementStock($item);
}
}
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
{
$purchase = $verificationRequest->subject;
if (! $purchase instanceof Purchase) {
throw ValidationException::withMessages([
'purchase' => 'Belanja tidak ditemukan.',
]);
}
$this->applyPayloadToPurchase($purchase, $this->payloadNew($verificationRequest), $verificationRequest);
}
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
{
$purchase = $verificationRequest->subject;
if (! $purchase instanceof Purchase) {
throw ValidationException::withMessages([
'purchase' => 'Belanja tidak ditemukan.',
]);
}
$this->executeDelete($purchase);
}
/**
* @param list<array{raw_material_price_id: int, quantity: float|int|string}> $items
* @return list<array{raw_material_price_id: int, quantity: float, unit_price: int, subtotal: int}>
@ -455,6 +537,230 @@ private function decrementStock(PurchaseItem $item): void
->decrement('stock', $item->quantity);
}
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl): void
{
$this->pushNotificationService->sendToRoles(
"📦 {$typeLabel} Menunggu Persetujuan Owner",
$body,
['owner', 'developer'],
route('admin.manage.owner_verifications.index'),
);
$this->pushNotificationService->sendToUser(
'📤 Pengajuan Terkirim',
$body,
$user->id,
$submitterUrl,
);
}
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
{
$purchase = $verificationRequest->subject;
if (! $purchase instanceof Purchase) {
return;
}
DB::transaction(function () use ($purchase): void {
$purchase->clearMediaCollection('photos');
$purchase->items()->delete();
$purchase->delete();
});
}
private function executeDelete(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
$purchase->load('items');
foreach ($purchase->items as $item) {
$this->decrementStock($item);
}
$purchase->clearMediaCollection('photos');
$purchase->items()->delete();
$purchase->delete();
});
}
/**
* @param array<string, mixed> $payload
*/
private function applyPayloadToPurchase(
Purchase $purchase,
array $payload,
?OwnerVerificationRequest $verificationRequest = null,
): void {
DB::transaction(function () use ($purchase, $payload, $verificationRequest): void {
$purchase->load('items');
foreach ($purchase->items as $item) {
$this->decrementStock($item);
}
$purchase->items()->delete();
foreach ($payload['items'] ?? [] as $itemData) {
$purchaseItem = $purchase->items()->create([
'raw_material_price_id' => $itemData['raw_material_price_id'],
'quantity' => $itemData['quantity'],
'unit_price' => $itemData['unit_price'],
'subtotal' => $itemData['subtotal'],
]);
$this->incrementStock($purchaseItem);
}
$purchase->update([
'supplier_id' => $payload['supplier_id'],
'subtotal' => $payload['subtotal'],
'discount' => $payload['discount'],
'shipping_cost' => $payload['shipping_cost'],
'total' => $payload['total'],
'notes' => $payload['notes'] ?? null,
]);
if ($verificationRequest !== null) {
$this->applyRequestPhotos($verificationRequest, $purchase, $payload);
}
});
}
/**
* @return array<string, mixed>
*/
private function snapshotPurchase(Purchase $purchase): array
{
$purchase->load([
'supplier:id,name',
'items.rawMaterialPrice.rawMaterial:id,name',
]);
return [
'supplier_id' => $purchase->supplier_id,
'supplier_name' => $purchase->supplier?->name,
'subtotal' => $purchase->subtotal,
'discount' => $purchase->discount,
'shipping_cost' => $purchase->shipping_cost,
'total' => $purchase->total,
'notes' => $purchase->notes,
'items' => $purchase->items
->map(fn (PurchaseItem $item) => [
'raw_material_price_id' => $item->raw_material_price_id,
'raw_material_name' => $item->rawMaterialPrice?->rawMaterial?->name,
'variant' => $item->rawMaterialPrice?->variant,
'quantity' => (float) $item->quantity,
'unit_price' => $item->unit_price,
'subtotal' => $item->subtotal,
])
->all(),
'has_photos' => $purchase->hasMedia('photos'),
];
}
/**
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
private function buildPayloadFromValidated(array $validated): array
{
$lineItems = $this->enrichLineItems($this->buildLineItems($validated['items']));
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
$total = max($subtotal - $discount + $shippingCost, 0);
$supplier = Supplier::query()->find($validated['supplier_id']);
return [
'supplier_id' => (int) $validated['supplier_id'],
'supplier_name' => $supplier?->name,
'subtotal' => $subtotal,
'discount' => $discount,
'shipping_cost' => $shippingCost,
'total' => $total,
'notes' => $validated['notes'] ?? null,
'items' => $lineItems,
'remove_media_ids' => $validated['remove_media_ids'] ?? [],
];
}
/**
* @param list<array{raw_material_price_id: int, quantity: float, unit_price: int, subtotal: int}> $lineItems
* @return list<array<string, mixed>>
*/
private function enrichLineItems(array $lineItems): array
{
$prices = RawMaterialPrice::query()
->with('rawMaterial:id,name')
->whereIn('id', array_column($lineItems, 'raw_material_price_id'))
->get()
->keyBy('id');
return collect($lineItems)
->map(function (array $item) use ($prices) {
$price = $prices->get($item['raw_material_price_id']);
return array_merge($item, [
'raw_material_name' => $price?->rawMaterial?->name,
'variant' => $price?->variant,
]);
})
->all();
}
/**
* @param array<string, mixed> $validated
*/
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',
);
}
/**
* @param array<string, mixed> $payload
*/
private function applyRequestPhotos(
OwnerVerificationRequest $verificationRequest,
Purchase $purchase,
array $payload,
): void {
if ($verificationRequest->hasMedia('photos')) {
$purchase->clearMediaCollection('photos');
foreach ($verificationRequest->getMedia('photos') as $media) {
$media->copy($purchase, 'photos');
}
return;
}
foreach ($payload['remove_media_ids'] ?? [] as $mediaId) {
$purchase->deleteMedia((int) $mediaId);
}
}
/**
* @return array<string, mixed>
*/
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 applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'total', 'discount', 'subtotal'], true)) {

View File

@ -53,6 +53,7 @@ public function getPendingApprovalCuttings(User $user): Collection
return Cutting::query()
->with([
'createdBy.profile',
'submittedBy.profile',
'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'results.productVariant.product:id,name',

View File

@ -2,9 +2,15 @@
namespace App\Services\Master;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Models\Category;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\User;
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;
@ -18,6 +24,7 @@ class ProductService
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
/**
@ -28,6 +35,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
$query = Product::query()
->with([
'categories',
'pendingOwnerVerificationRequest',
'variants' => fn ($query) => $query
->with(['media', 'prices' => fn ($query) => $query->orderBy('type')])
->orderBy('created_at'),
@ -66,6 +74,16 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
);
});
$pendingRequest = $product->pendingOwnerVerificationRequest;
$product->setAttribute('has_pending_request', $pendingRequest !== null);
$product->setAttribute('pending_request_action', $pendingRequest?->action->value);
$product->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
$product->setAttribute(
'display_is_active',
$pendingRequest?->pendingToggleIsActive() ?? $product->is_active,
);
return $product;
});
}
@ -73,26 +91,43 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated): void
public function create(array $validated, User $user): void
{
try {
DB::transaction(function () use ($validated): void {
DB::transaction(function () use ($validated, $user): void {
$product = Product::create([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'is_active' => true,
'is_active' => false,
]);
$product->categories()->sync($validated['category_ids']);
$product->categories()->sync($validated['category_ids'] ?? []);
foreach ($validated['variants'] as $index => $variantData) {
$this->createVariant($product, $variantData, $index);
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
]);
$this->syncVariantImages($variant, $variantData, $index);
}
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Product::class,
'subject_id' => $product->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => null,
'new' => $this->snapshotProduct($product->fresh(['categories', 'variants'])),
],
]);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat produk: '.$e->getMessage(), [
Log::error('Gagal mengajukan pembuatan produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -100,55 +135,43 @@ public function create(array $validated): void
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->notifyForPendingRequest(
$user,
'Tambah Produk',
"Pengajuan tambah produk '{$validated['name']}' menunggu verifikasi owner.",
route('admin.master.products.index'),
);
}
/**
* @param array<string, mixed> $validated
*/
public function update(Product $product, array $validated): void
public function update(Product $product, array $validated, User $user): void
{
try {
DB::transaction(function () use ($validated, $product): void {
$product->update([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
DB::transaction(function () use ($validated, $product, $user): void {
$verificationRequest = OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::UPDATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Product::class,
'subject_id' => $product->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => $this->snapshotProduct($product),
'new' => $this->enrichPayload($this->buildPayloadFromValidated($validated)),
],
]);
$product->categories()->sync($validated['category_ids']);
$submittedVariantIds = collect($validated['variants'])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$product->variants()
->whereNotIn('id', $submittedVariantIds)
->get()
->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');
$variant->delete();
});
foreach ($validated['variants'] as $index => $variantData) {
if (! empty($variantData['id'])) {
$variant = $product->variants()->findOrFail($variantData['id']);
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
]);
$this->syncVariantImages($variant, $variantData, $index);
continue;
}
$this->createVariant($product, $variantData, $index);
$isNewVariant = empty($variantData['id']);
$this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant);
}
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui produk: '.$e->getMessage(), [
Log::error('Gagal mengajukan perubahan produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -156,37 +179,463 @@ public function update(Product $product, array $validated): void
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->notifyForPendingRequest(
$user,
'Ubah Produk',
"Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.",
route('admin.master.products.index'),
);
}
public function toggleStatus(Product $product, array $validated): void
public function delete(Product $product, User $user): void
{
try {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::DELETE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Product::class,
'subject_id' => $product->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => $this->snapshotProduct($product),
'new' => null,
],
]);
} catch (\Throwable $e) {
Log::error('Gagal mengajukan penghapusan produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->notifyForPendingRequest(
$user,
'Hapus Produk',
"Pengajuan hapus produk '{$product->name}' menunggu verifikasi owner.",
route('admin.master.products.index'),
);
}
/**
* @param array<string, mixed> $validated
*/
public function toggleStatus(Product $product, array $validated, User $user): void
{
try {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::TOGGLE_STATUS,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Product::class,
'subject_id' => $product->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => [
'name' => $product->name,
'is_active' => $product->is_active,
],
'new' => [
'name' => $product->name,
'is_active' => (bool) $validated['is_active'],
],
],
]);
} catch (\Throwable $e) {
Log::error('Gagal mengajukan perubahan status produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
$this->notifyForPendingRequest(
$user,
'Ubah Status Produk',
"Pengajuan ubah status produk '{$product->name}' menjadi {$statusLabel} menunggu verifikasi owner.",
route('admin.master.products.index'),
);
}
public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void
{
match ($verificationRequest->action) {
OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest),
OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest),
OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest),
OwnerVerificationAction::TOGGLE_STATUS => $this->applyToggleStatus($verificationRequest),
default => throw ValidationException::withMessages([
'action' => 'Aksi verifikasi produk tidak didukung.',
]),
};
}
public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void
{
match ($verificationRequest->action) {
OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest),
OwnerVerificationAction::UPDATE => $this->rollbackUpdate($verificationRequest),
OwnerVerificationAction::DELETE, OwnerVerificationAction::TOGGLE_STATUS => null,
default => throw ValidationException::withMessages([
'action' => 'Aksi verifikasi produk tidak didukung.',
]),
};
}
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
{
$verificationRequest->load('media');
$newPayload = $this->payloadNew($verificationRequest);
foreach ($newPayload['variants'] ?? [] as $index => $_variant) {
$verificationRequest->clearMediaCollection($verificationRequest->variantImageCollection((int) $index));
}
}
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'product' => 'Produk tidak ditemukan.',
]);
}
$product->update([
'is_active' => $validated['is_active'],
'is_active' => true,
]);
}
public function delete(Product $product): void
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
{
try {
DB::transaction(function () use ($product): void {
$product->variants()->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');
});
$product->variants()->delete();
$product->categories()->detach();
$product->delete();
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
'product' => 'Produk tidak ditemukan.',
]);
}
$this->applyPayloadToProduct($product, $this->payloadNew($verificationRequest), $verificationRequest);
}
/**
* @param array<string, mixed> $payload
*/
private function applyPayloadToProduct(
Product $product,
array $payload,
?OwnerVerificationRequest $verificationRequest = null,
): void {
$product->update([
'name' => $payload['name'],
'description' => $payload['description'] ?? null,
]);
if (array_key_exists('is_active', $payload)) {
$product->update([
'is_active' => (bool) $payload['is_active'],
]);
}
$product->categories()->sync($payload['category_ids'] ?? []);
$submittedVariantIds = collect($payload['variants'] ?? [])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$product->variants()
->whereNotIn('id', $submittedVariantIds)
->get()
->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');
$variant->delete();
});
foreach ($payload['variants'] ?? [] as $index => $variantData) {
if (! empty($variantData['id'])) {
$variant = $product->variants()->findOrFail($variantData['id']);
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
]);
if ($verificationRequest !== null) {
$this->applyVariantImageChanges($verificationRequest, $variant, $variantData, (int) $index);
}
continue;
}
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
]);
if ($verificationRequest !== null) {
$this->copyRequestVariantImages($verificationRequest, (int) $index, $variant);
}
}
}
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'product' => 'Produk tidak ditemukan.',
]);
}
DB::transaction(function () use ($product): void {
$product->variants()->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');
});
$product->variants()->delete();
$product->categories()->detach();
$product->delete();
});
}
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'product' => 'Produk tidak ditemukan.',
]);
}
$newPayload = $this->payloadNew($verificationRequest);
$product->update([
'is_active' => (bool) ($newPayload['is_active'] ?? false),
]);
}
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl): void
{
$this->pushNotificationService->sendToRoles(
"📦 {$typeLabel} Menunggu Persetujuan Owner",
$body,
['owner', 'developer'],
route('admin.manage.owner_verifications.index'),
);
$this->pushNotificationService->sendToUser(
'📤 Pengajuan Terkirim',
$body,
$user->id,
$submitterUrl,
);
}
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
return;
}
DB::transaction(function () use ($product): void {
$product->variants()->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');
});
$product->variants()->delete();
$product->categories()->detach();
$product->delete();
});
}
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'product' => 'Produk tidak ditemukan.',
]);
}
$oldPayload = $this->payloadOld($verificationRequest);
if ($oldPayload === []) {
return;
}
$this->applyPayloadToProduct($product, $oldPayload);
}
/**
* @return array<string, mixed>
*/
private function payloadOld(OwnerVerificationRequest $verificationRequest): array
{
$payload = $verificationRequest->payload ?? [];
if (array_key_exists('old', $payload)) {
return is_array($payload['old']) ? $payload['old'] : [];
}
return [];
}
/**
* @return array<string, mixed>
*/
private function payloadNew(OwnerVerificationRequest $verificationRequest): array
{
$payload = $verificationRequest->payload ?? [];
if (array_key_exists('new', $payload)) {
return is_array($payload['new']) ? $payload['new'] : [];
}
return $payload;
}
/**
* @return array<string, mixed>
*/
private function snapshotProduct(Product $product): array
{
$product->load(['categories', 'variants']);
return $this->enrichPayload([
'name' => $product->name,
'description' => $product->description,
'category_ids' => $product->categories->pluck('id')->all(),
'is_active' => $product->is_active,
'variants' => $product->variants
->map(fn (ProductVariant $variant) => [
'id' => $variant->id,
'name' => $variant->name,
'stock' => $variant->stock,
])
->all(),
]);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function enrichPayload(array $data): array
{
$categoryIds = $data['category_ids'] ?? [];
$data['category_names'] = $categoryIds === []
? []
: Category::query()->whereIn('id', $categoryIds)->pluck('name')->all();
return $data;
}
/**
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
private function buildPayloadFromValidated(array $validated): array
{
return [
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'category_ids' => $validated['category_ids'] ?? [],
'variants' => collect($validated['variants'] ?? [])
->map(fn (array $variantData) => [
'id' => $variantData['id'] ?? null,
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
])
->all(),
];
}
/**
* @param array<string, mixed> $variantData
*/
private function syncVariantImages(
ProductVariant $variant,
array $variantData,
int $index,
): void {
$this->mediaService->syncCollection(
$variant,
'images',
$variantData['images'] ?? null,
$variantData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES,
required: true,
errorKey: "variants.{$index}.images",
);
}
/**
* @param array<string, mixed> $variantData
*/
private function syncRequestVariantImages(
OwnerVerificationRequest $verificationRequest,
array $variantData,
int $index,
bool $required = true,
): void {
$collection = $verificationRequest->variantImageCollection($index);
$this->mediaService->syncCollection(
$verificationRequest,
$collection,
$variantData['images'] ?? null,
$variantData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES,
required: $required,
errorKey: "variants.{$index}.images",
);
}
/**
* @param array<string, mixed> $variantData
*/
private function applyVariantImageChanges(
OwnerVerificationRequest $verificationRequest,
ProductVariant $variant,
array $variantData,
int $index,
): void {
$removeIds = $variantData['remove_media_ids'] ?? [];
if ($removeIds !== []) {
$variant->getMedia('images')
->whereIn('id', $removeIds)
->each->delete();
}
$this->copyRequestVariantImages($verificationRequest, $index, $variant);
if ($variant->getMedia('images')->isEmpty()) {
throw ValidationException::withMessages([
"variants.{$index}.images" => 'Foto varian wajib diisi.',
]);
}
}
private function copyRequestVariantImages(OwnerVerificationRequest $verificationRequest, int $index, ProductVariant $variant): void
{
$verificationRequest->getMedia($verificationRequest->variantImageCollection($index))
->each(fn ($media) => $media->copy($variant, 'images'));
}
private function applySorting(Builder $query, string $sort, string $direction): void
@ -199,35 +648,4 @@ private function applySorting(Builder $query, string $sort, string $direction):
$query->latest();
}
/**
* @param array<string, mixed> $variantData
*/
private function createVariant(Product $product, array $variantData, int $index): ProductVariant
{
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
]);
$this->syncVariantImages($variant, $variantData, $index);
return $variant;
}
/**
* @param array<string, mixed> $variantData
*/
private function syncVariantImages(ProductVariant $variant, array $variantData, int $index): void
{
$this->mediaService->syncCollection(
$variant,
'images',
$variantData['images'] ?? null,
$variantData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES,
required: true,
errorKey: "variants.{$index}.images",
);
}
}

View File

@ -2,10 +2,15 @@
namespace App\Services\Master;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\RawMaterialUnit;
use App\Models\OwnerVerificationRequest;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\User;
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;
@ -19,6 +24,7 @@ class RawMaterialService
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
/**
@ -28,6 +34,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
{
$query = RawMaterial::query()
->with([
'pendingOwnerVerificationRequest',
'prices' => fn ($query) => $query
->orderBy('created_at')
->with(['rawMaterial:id,unit', 'media']),
@ -75,6 +82,16 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
);
});
$pendingRequest = $rawMaterial->pendingOwnerVerificationRequest;
$rawMaterial->setAttribute('has_pending_request', $pendingRequest !== null);
$rawMaterial->setAttribute('pending_request_action', $pendingRequest?->action->value);
$rawMaterial->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
$rawMaterial->setAttribute(
'display_is_active',
$pendingRequest?->pendingToggleIsActive() ?? $rawMaterial->is_active,
);
return $rawMaterial;
});
}
@ -82,23 +99,36 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated): void
public function create(array $validated, User $user): void
{
try {
DB::transaction(function () use ($validated): void {
DB::transaction(function () use ($validated, $user): void {
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
'is_active' => false,
]);
foreach ($validated['prices'] as $index => $priceData) {
$this->createPrice($rawMaterial, $priceData, $index);
}
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'])),
],
]);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat bahan baku: '.$e->getMessage(), [
Log::error('Gagal mengajukan pembuatan bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -106,53 +136,43 @@ public function create(array $validated): void
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->notifyForPendingRequest(
$user,
'Tambah Bahan Baku',
"Pengajuan tambah bahan baku '{$validated['name']}' menunggu verifikasi owner.",
route('admin.master.raw_materials.index'),
);
}
/**
* @param array<string, mixed> $validated
*/
public function update(RawMaterial $rawMaterial, array $validated): void
public function update(RawMaterial $rawMaterial, array $validated, User $user): void
{
try {
DB::transaction(function () use ($validated, $rawMaterial): void {
$rawMaterial->update([
'name' => $validated['name'],
DB::transaction(function () use ($validated, $rawMaterial, $user): void {
$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)),
],
]);
$submittedPriceIds = collect($validated['prices'])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->get()
->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images');
$price->delete();
});
foreach ($validated['prices'] as $index => $priceData) {
if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->findOrFail($priceData['id']);
$price->update([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
$this->syncPriceImages($price, $priceData, $index);
continue;
}
$this->createPrice($rawMaterial, $priceData, $index);
$isNewPrice = empty($priceData['id']);
$this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice);
}
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui bahan baku: '.$e->getMessage(), [
Log::error('Gagal mengajukan perubahan bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -160,36 +180,391 @@ public function update(RawMaterial $rawMaterial, array $validated): void
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->notifyForPendingRequest(
$user,
'Ubah Bahan Baku',
"Pengajuan ubah bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
route('admin.master.raw_materials.index'),
);
}
public function toggleStatus(RawMaterial $rawMaterial, array $validated): void
public function delete(RawMaterial $rawMaterial, User $user): void
{
try {
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,
],
]);
} catch (\Throwable $e) {
Log::error('Gagal mengajukan penghapusan bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->notifyForPendingRequest(
$user,
'Hapus Bahan Baku',
"Pengajuan hapus bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
route('admin.master.raw_materials.index'),
);
}
/**
* @param array<string, mixed> $validated
*/
public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $user): void
{
try {
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'],
],
],
]);
} catch (\Throwable $e) {
Log::error('Gagal mengajukan perubahan status bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
$this->notifyForPendingRequest(
$user,
'Ubah Status Bahan Baku',
"Pengajuan ubah status bahan baku '{$rawMaterial->name}' menjadi {$statusLabel} menunggu verifikasi owner.",
route('admin.master.raw_materials.index'),
);
}
public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void
{
match ($verificationRequest->action) {
OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest),
OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest),
OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest),
OwnerVerificationAction::TOGGLE_STATUS => $this->applyToggleStatus($verificationRequest),
default => throw ValidationException::withMessages([
'action' => 'Aksi verifikasi bahan baku tidak didukung.',
]),
};
}
public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void
{
match ($verificationRequest->action) {
OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest),
OwnerVerificationAction::UPDATE => $this->rollbackUpdate($verificationRequest),
OwnerVerificationAction::DELETE, OwnerVerificationAction::TOGGLE_STATUS => null,
default => throw ValidationException::withMessages([
'action' => 'Aksi verifikasi bahan baku tidak didukung.',
]),
};
}
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
{
$verificationRequest->load('media');
$newPayload = $this->payloadNew($verificationRequest);
foreach ($newPayload['prices'] ?? [] as $index => $_price) {
$verificationRequest->clearMediaCollection($verificationRequest->priceImageCollection((int) $index));
}
}
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl): void
{
$this->pushNotificationService->sendToRoles(
"📦 {$typeLabel} Menunggu Persetujuan Owner",
$body,
['owner', 'developer'],
route('admin.manage.owner_verifications.index'),
);
$this->pushNotificationService->sendToUser(
'📤 Pengajuan Terkirim',
$body,
$user->id,
$submitterUrl,
);
}
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
$rawMaterial->update([
'is_active' => $validated['is_active'],
'is_active' => true,
]);
}
public function delete(RawMaterial $rawMaterial): void
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
{
try {
DB::transaction(function () use ($rawMaterial): void {
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images');
});
$rawMaterial->prices()->delete();
$rawMaterial->delete();
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
$this->applyPayloadToRawMaterial($rawMaterial, $this->payloadNew($verificationRequest), $verificationRequest);
}
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
DB::transaction(function () use ($rawMaterial): void {
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images');
});
$rawMaterial->prices()->delete();
$rawMaterial->delete();
});
}
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
$newPayload = $this->payloadNew($verificationRequest);
$rawMaterial->update([
'is_active' => (bool) ($newPayload['is_active'] ?? false),
]);
}
/**
* @param array<string, mixed> $payload
*/
private function applyPayloadToRawMaterial(
RawMaterial $rawMaterial,
array $payload,
?OwnerVerificationRequest $verificationRequest = null,
): void {
$rawMaterial->update([
'name' => $payload['name'],
'unit' => $payload['unit'] ?? $rawMaterial->unit,
]);
if (array_key_exists('is_active', $payload)) {
$rawMaterial->update([
'is_active' => (bool) $payload['is_active'],
]);
}
$submittedPriceIds = collect($payload['prices'] ?? [])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->get()
->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images');
$price->delete();
});
foreach ($payload['prices'] ?? [] as $index => $priceData) {
if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->findOrFail($priceData['id']);
$price->update([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
if ($verificationRequest !== null) {
$this->applyPriceImageChanges($verificationRequest, $price, $priceData, (int) $index);
}
continue;
}
$price = $rawMaterial->prices()->create([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
if ($verificationRequest !== null) {
$this->copyRequestPriceImages($verificationRequest, (int) $index, $price);
}
}
}
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
return;
}
DB::transaction(function () use ($rawMaterial): void {
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images');
});
$rawMaterial->prices()->delete();
$rawMaterial->delete();
});
}
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
$oldPayload = $this->payloadOld($verificationRequest);
if ($oldPayload === []) {
return;
}
$this->applyPayloadToRawMaterial($rawMaterial, $oldPayload);
}
/**
* @return array<string, mixed>
*/
private function payloadOld(OwnerVerificationRequest $verificationRequest): array
{
$payload = $verificationRequest->payload ?? [];
if (array_key_exists('old', $payload)) {
return is_array($payload['old']) ? $payload['old'] : [];
}
return [];
}
/**
* @return array<string, mixed>
*/
private function payloadNew(OwnerVerificationRequest $verificationRequest): array
{
$payload = $verificationRequest->payload ?? [];
if (array_key_exists('new', $payload)) {
return is_array($payload['new']) ? $payload['new'] : [];
}
return $payload;
}
/**
* @return array<string, mixed>
*/
private function snapshotRawMaterial(RawMaterial $rawMaterial): array
{
$rawMaterial->load(['prices']);
return $this->enrichPayload([
'name' => $rawMaterial->name,
'unit' => $rawMaterial->unit->value,
'is_active' => $rawMaterial->is_active,
'prices' => $rawMaterial->prices
->map(fn (RawMaterialPrice $price) => [
'id' => $price->id,
'variant' => $price->variant,
'price' => $price->price,
'stock' => $price->stock,
])
->all(),
]);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function enrichPayload(array $data): array
{
if (isset($data['unit'])) {
$unit = $data['unit'] instanceof RawMaterialUnit
? $data['unit']
: RawMaterialUnit::from($data['unit']);
$data['unit'] = $unit->value;
$data['unit_label'] = $unit->label();
}
return $data;
}
/**
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
private function buildPayloadFromValidated(array $validated): array
{
return [
'name' => $validated['name'],
'unit' => $validated['unit'],
'prices' => collect($validated['prices'] ?? [])
->map(fn (array $priceData) => [
'id' => $priceData['id'] ?? null,
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
'remove_media_ids' => $priceData['remove_media_ids'] ?? [],
])
->all(),
];
}
private function applySorting(Builder $query, string $sort, string $direction): void
@ -234,4 +609,58 @@ private function syncPriceImages(RawMaterialPrice $price, array $priceData, int
errorKey: "prices.{$index}.images",
);
}
/**
* @param array<string, mixed> $priceData
*/
private function syncRequestPriceImages(
OwnerVerificationRequest $verificationRequest,
array $priceData,
int $index,
bool $required = true,
): void {
$collection = $verificationRequest->priceImageCollection($index);
$this->mediaService->syncCollection(
$verificationRequest,
$collection,
$priceData['images'] ?? null,
$priceData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES,
required: $required,
errorKey: "prices.{$index}.images",
);
}
/**
* @param array<string, mixed> $priceData
*/
private function applyPriceImageChanges(
OwnerVerificationRequest $verificationRequest,
RawMaterialPrice $price,
array $priceData,
int $index,
): void {
$removeIds = $priceData['remove_media_ids'] ?? [];
if ($removeIds !== []) {
$price->getMedia('images')
->whereIn('id', $removeIds)
->each->delete();
}
$this->copyRequestPriceImages($verificationRequest, $index, $price);
if ($price->getMedia('images')->isEmpty()) {
throw ValidationException::withMessages([
"prices.{$index}.images" => 'Foto varian wajib diisi.',
]);
}
}
private function copyRequestPriceImages(OwnerVerificationRequest $verificationRequest, int $index, RawMaterialPrice $price): void
{
$verificationRequest->getMedia($verificationRequest->priceImageCollection($index))
->each(fn ($media) => $media->copy($price, 'images'));
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Support\OwnerVerification;
class VerificationChangeFormatter
{
/**
* @var list<string>
*/
private const HIDDEN_FIELDS = [
'category_ids',
'unit',
];
/**
* @param array{old?: array<string, mixed>|null, new?: array<string, mixed>|null} $payload
* @return list<array{field: string, old: mixed, new: mixed}>
*/
public static function format(array $payload): array
{
$old = $payload['old'] ?? null;
$new = $payload['new'] ?? null;
if (! is_array($old) && ! is_array($new)) {
return [];
}
$fields = array_unique(array_merge(
array_keys(is_array($old) ? $old : []),
array_keys(is_array($new) ? $new : []),
));
$changes = [];
foreach ($fields as $field) {
if (in_array($field, self::HIDDEN_FIELDS, true)) {
continue;
}
$oldValue = is_array($old) ? ($old[$field] ?? null) : null;
$newValue = is_array($new) ? ($new[$field] ?? null) : null;
if ($oldValue === $newValue) {
continue;
}
$changes[] = [
'field' => self::label((string) $field),
'old' => self::presentValue($field, $oldValue),
'new' => self::presentValue($field, $newValue),
];
}
return $changes;
}
private static function label(string $field): string
{
return match ($field) {
'name' => 'Nama',
'description' => 'Deskripsi',
'category_names' => 'Kategori',
'supplier_name' => 'Supplier',
'supplier_id' => 'Supplier',
'subtotal' => 'Subtotal',
'discount' => 'Diskon',
'shipping_cost' => 'Ongkir',
'total' => 'Total',
'notes' => 'Keterangan',
'items' => 'Item Belanja',
'has_photos' => 'Foto Bukti',
'unit' => 'Satuan',
'unit_label' => 'Satuan',
'is_active' => 'Status Aktif',
'variants' => 'Varian',
'prices' => 'Varian Harga',
default => ucfirst(str_replace('_', ' ', $field)),
};
}
private static function presentValue(string $field, mixed $value): mixed
{
if ($value === null) {
return null;
}
if ($field === 'is_active') {
return (bool) $value;
}
if ($field === 'variants' && is_array($value)) {
return collect($value)
->map(function (array $variant): string {
$name = $variant['name'] ?? '-';
$stock = $variant['stock'] ?? 0;
return "{$name} (stok: {$stock})";
})
->implode(', ');
}
if ($field === 'prices' && is_array($value)) {
return collect($value)
->map(function (array $price): string {
$variant = $price['variant'] ?? '-';
$stock = $price['stock'] ?? 0;
$priceValue = $price['price'] ?? 0;
return "{$variant} (stok: {$stock}, harga: {$priceValue})";
})
->implode(', ');
}
if ($field === 'items' && is_array($value)) {
return collect($value)
->map(function (array $item): string {
$name = $item['raw_material_name'] ?? '-';
$variant = $item['variant'] ?? '-';
$quantity = $item['quantity'] ?? 0;
$subtotal = $item['subtotal'] ?? 0;
return "{$name} / {$variant} (jumlah: {$quantity}, subtotal: {$subtotal})";
})
->implode('; ');
}
if ($field === 'has_photos') {
return (bool) $value ? 'Ada' : 'Tidak ada';
}
if ($field === 'category_names' && is_array($value)) {
return implode(', ', $value);
}
return $value;
}
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\EnsureNoPendingOwnerVerification;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Console\Scheduling\Schedule;
@ -23,6 +24,7 @@
'role' => RoleMiddleware::class,
'permission' => PermissionMiddleware::class,
'role_or_permission' => RoleOrPermissionMiddleware::class,
'no_pending_owner_verification' => EnsureNoPendingOwnerVerification::class,
]);
$middleware->web(append: [

View File

@ -0,0 +1,31 @@
<?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('owner_verification_requests', function (Blueprint $table): void {
$table->id();
$table->string('action', 50);
$table->string('status', 50)->default('pending');
$table->nullableMorphs('subject');
$table->foreignId('submitted_by_id')->constrained('users')->restrictOnDelete();
$table->foreignId('verified_by_id')->nullable()->constrained('users')->restrictOnDelete();
$table->json('payload');
$table->timestamp('verified_at')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
});
}
public function down(): void
{
Schema::dropIfExists('owner_verification_requests');
}
};

View File

@ -13,6 +13,7 @@ const props = defineProps<{
tooltip?: string;
tooltipClass?: string;
buttonClass?: string;
disabled?: boolean;
onSuccess?: () => void;
onError?: (errors: Record<string, string>) => string | void;
}>();
@ -28,11 +29,13 @@ const { open, processing, destroy } = useDestroy({
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
:class="buttonClass" @click="open = true">
<Trash2 class="size-4" />
<span class="sr-only">{{ tooltip || 'Hapus' }}</span>
</Button>
<span class="inline-flex">
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
:class="buttonClass" :disabled="disabled" @click="!disabled && (open = true)">
<Trash2 class="size-4" />
<span class="sr-only">{{ tooltip || 'Hapus' }}</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent :class="tooltipClass">{{ tooltip || 'Hapus' }}</TooltipContent>
</Tooltip>

View File

@ -7,14 +7,20 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
defineProps<{
href?: string;
tooltip?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" :as-child="!!href">
<Link v-if="href" :href="href">
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled" :as-child="!!href && !disabled"
@click="!href && !disabled && emit('click')">
<Link v-if="href && !disabled" :href="href">
<Eye class="size-4" />
<span class="sr-only">{{ tooltip || 'Detail' }}</span>
</Link>

View File

@ -3,33 +3,70 @@ import { Link } from '@inertiajs/vue3';
import { Pencil } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
defineProps<{
const props = defineProps<{
href?: string;
tooltip?: string;
tooltipClass?: string;
buttonClass?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
click: [];
}>();
function handleClick(event: MouseEvent) {
if (props.disabled) {
event.preventDefault();
return;
}
if (!props.href) {
event.preventDefault();
emit('click');
}
}
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" :class="buttonClass" :as-child="!!href"
@click="!href && emit('click')">
<Link v-if="href" :href="href">
<span class="inline-flex">
<Button
v-if="href"
variant="ghost"
size="icon"
as-child
class="size-8"
:class="cn(buttonClass, disabled && 'opacity-50')"
>
<Link
:href="href"
:tabindex="disabled ? -1 : undefined"
:aria-disabled="disabled || undefined"
:class="disabled ? 'cursor-not-allowed' : undefined"
@click="handleClick"
>
<Pencil class="size-4" />
<span class="sr-only">{{ tooltip || 'Ubah' }}</span>
</Link>
</Button>
<Button
v-else
variant="ghost"
size="icon"
class="size-8"
:class="buttonClass"
:disabled="disabled"
@click="handleClick"
>
<Pencil class="size-4" />
<span class="sr-only">{{ tooltip || 'Ubah' }}</span>
</Link>
<template v-else>
<Pencil class="size-4" />
<span class="sr-only">{{ tooltip || 'Ubah' }}</span>
</template>
</Button>
</Button>
</span>
</TooltipTrigger>
<TooltipContent :class="tooltipClass">{{ tooltip || 'Ubah' }}</TooltipContent>
</Tooltip>

View File

@ -8,13 +8,18 @@ defineProps<{
disabled?: boolean;
size?: 'default' | 'sm';
}>();
defineEmits<{
click: [];
}>();
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" :size="size === 'sm' ? 'sm' : 'icon'"
:class="size === 'sm' ? '' : 'size-8'" class="text-destructive hover:text-destructive" :disabled="disabled">
:class="size === 'sm' ? '' : 'size-8'" class="text-destructive hover:text-destructive" :disabled="disabled"
@click="$emit('click')">
<X :class="size === 'sm' ? 'size-3.5' : 'size-4'" />
<span class="sr-only">{{ tooltip || 'Tolak' }}</span>
</Button>

View File

@ -1,29 +1,143 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import OwnerVerificationPendingSection from './table/OwnerVerificationPendingSection.vue';
import { computed, ref, watch } from 'vue';
import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CuttingListItem } from '@/types/cutting';
import type {
OwnerVerificationItem,
PaginatedOwnerVerificationRequests,
SelectOption,
} from '@/types/owner-verification';
import type { DataTableSort } from '@/types/data-table';
import OwnerVerificationDetailModal from './table/OwnerVerificationDetailModal.vue';
import { createColumns } from './table/columns';
import { index } from '@/routes/admin/manage/owner_verifications';
defineProps<{
pendingCuttings: CuttingListItem[];
const props = defineProps<{
verificationRequests: PaginatedOwnerVerificationRequests;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
status?: string;
subject_type?: string;
action?: string;
};
statusOptions: SelectOption[];
subjectOptions: SelectOption[];
actionOptions: SelectOption[];
}>();
const search = ref(props.filters.search ?? '');
const detailModalOpen = ref(false);
const selectedItem = ref<OwnerVerificationItem | null>(null);
const { query, setSearch, setSort, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
url: index.url(),
initial: { ...props.filters },
filterKeys: ['status', 'subject_type', 'action'],
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const columns = computed(() => createColumns(openDetailModal));
const filterDefs = computed(() => [
{
key: 'status',
label: 'Status',
type: 'select' as const,
options: props.statusOptions,
},
{
key: 'subject_type',
label: 'Modul',
type: 'select' as const,
options: props.subjectOptions,
},
{
key: 'action',
label: 'Aksi',
type: 'select' as const,
options: props.actionOptions,
},
]);
const filterValues = computed(() => ({
status: query.value.status ?? '',
subject_type: query.value.subject_type ?? '',
action: query.value.action ?? '',
}));
const currentSort = computed<DataTableSort | null>(() => {
if (!query.value.sort || !query.value.direction) {
return null;
}
return {
column: query.value.sort,
direction: query.value.direction,
};
});
const pagination = computed(() => ({
currentPage: props.verificationRequests.current_page,
perPage: props.verificationRequests.per_page,
lastPage: props.verificationRequests.last_page,
total: props.verificationRequests.total,
}));
function openDetailModal(item: OwnerVerificationItem) {
selectedItem.value = item;
detailModalOpen.value = true;
}
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Verifikasi Owner" />
<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">
Verifikasi Owner
</h2>
<p class="text-muted-foreground text-sm">
Persetujuan verifikasi stok cutting oleh owner.
</p>
</div>
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Verifikasi Owner
</h2>
</div>
<OwnerVerificationPendingSection :cuttings="pendingCuttings" />
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable
v-model:search="search"
:columns="columns"
:data="verificationRequests.data"
:pagination="pagination"
:pagination-links="verificationRequests.links"
:sort="currentSort"
:filter-defs="filterDefs"
:filter-values="filterValues"
search-placeholder="Cari verifikasi..."
@sort-change="setSort"
@filter-change="setFilter"
@filters-reset="resetFilters"
/>
</CardContent>
</Card>
<OwnerVerificationDetailModal
v-model:open="detailModalOpen"
:item="selectedItem"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { OwnerVerificationItem } from '@/types/owner-verification';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
item?: OwnerVerificationItem | null;
}>();
const hasChanges = computed(() => (props.item?.changes.length ?? 0) > 0);
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '-';
}
if (typeof value === 'boolean') {
return value ? 'Aktif' : 'Nonaktif';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detail Verifikasi</DialogTitle>
</DialogHeader>
<div v-if="item" class="space-y-4 text-sm">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-muted-foreground">Waktu</p>
<p class="font-medium">{{ item.created_at_formatted ?? '-' }}</p>
</div>
<div>
<p class="text-muted-foreground">Diajukan Oleh</p>
<p class="font-medium">{{ item.submitted_by_name }}</p>
</div>
<div>
<p class="text-muted-foreground">Aksi</p>
<p class="font-medium">{{ item.action_label }}</p>
</div>
<div>
<p class="text-muted-foreground">Modul</p>
<p class="font-medium">{{ item.subject_label }}</p>
</div>
<div>
<p class="text-muted-foreground">Item</p>
<p class="font-medium">{{ item.title }}</p>
</div>
<div>
<p class="text-muted-foreground">Status</p>
<Badge
:variant="item.status === 'approved' ? 'default' : item.status === 'rejected' ? 'destructive' : 'outline'"
>
{{ item.status_label }}
</Badge>
</div>
<div v-if="item.verified_by_name">
<p class="text-muted-foreground">Diverifikasi Oleh</p>
<p class="font-medium">{{ item.verified_by_name }}</p>
</div>
<div v-if="item.verified_at_formatted">
<p class="text-muted-foreground">Waktu Verifikasi</p>
<p class="font-medium">{{ item.verified_at_formatted }}</p>
</div>
</div>
<div v-if="item.summary" class="rounded-md border p-3">
<p class="text-muted-foreground">Ringkasan</p>
<p class="font-medium">{{ item.summary }}</p>
</div>
<div v-if="item.rejection_reason" class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p class="text-muted-foreground">Alasan Penolakan</p>
<p class="font-medium">{{ item.rejection_reason }}</p>
</div>
<div v-if="hasChanges" class="space-y-2">
<p class="font-medium">Perubahan Data</p>
<div class="overflow-hidden rounded-md border">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="px-3 py-2 text-left font-medium">Field</th>
<th class="px-3 py-2 text-left font-medium">Sebelum</th>
<th class="px-3 py-2 text-left font-medium">Sesudah</th>
</tr>
</thead>
<tbody>
<tr v-for="change in item.changes" :key="change.field" class="border-t">
<td class="px-3 py-2 align-top font-medium">{{ change.field }}</td>
<td class="px-3 py-2 align-top text-muted-foreground">
{{ formatValue(change.old) }}
</td>
<td class="px-3 py-2 align-top">{{ formatValue(change.new) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -1,91 +0,0 @@
<script setup lang="ts">
import { Card } from '@/components/ui/card';
import type { CuttingListItem, CuttingResultPriceItem } from '@/types/cutting';
import { PRICE_TYPE_LABELS } from '@/types/product';
import OwnerVerificationDataTableActions from './data-table-actions.vue';
defineProps<{
cuttings: CuttingListItem[];
}>();
function getVariantPrices(cutting: CuttingListItem, variantId: number): CuttingResultPriceItem[] {
return (cutting.result_prices ?? []).filter(p => p.product_variant_id === variantId);
}
</script>
<template>
<div v-if="cuttings.length" class="space-y-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="cutting in cuttings" :key="cutting.id"
class="flex flex-col justify-between overflow-hidden border bg-card/50 py-0 gap-0">
<div class="border-b bg-muted/20 px-4 py-3 flex items-center justify-between gap-2">
<div class="min-w-0">
<h4 class="font-medium text-sm truncate">
Cutting #{{ cutting.id }}
</h4>
<span class="text-[10px] text-muted-foreground block truncate">
{{ cutting.created_at_formatted }}
</span>
</div>
<div class="flex shrink-0 items-center">
<OwnerVerificationDataTableActions :cutting="cutting" />
</div>
</div>
<div class="p-4 space-y-3 flex-1 text-xs">
<div class="space-y-1">
<span class="font-medium text-foreground">Hasil Produk:</span>
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
<li v-for="res in cutting.results" :key="res.id">
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }}) -
{{ res.cutting_result }} pcs
<span class="text-[10px]">
({{ res.warehouse_stock }} bagus, {{ res.cutting_reject }} reject)
</span>
</li>
<li v-if="!cutting.results.length">Belum ada hasil produk</li>
</ul>
</div>
<div v-if="cutting.result_prices?.length" class="space-y-2 pt-2 border-t">
<span class="font-medium text-foreground">Harga:</span>
<div v-for="res in cutting.results" :key="`price-${res.id}`" class="space-y-1">
<div v-if="getVariantPrices(cutting, res.product_variant_id ?? 0).length > 0">
<p class="text-[11px] font-medium text-muted-foreground">
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }})
</p>
<div class="pl-2 space-y-0.5">
<div v-for="price in getVariantPrices(cutting, res.product_variant_id ?? 0)" :key="price.id"
class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">
{{ PRICE_TYPE_LABELS[price.price_type as keyof typeof PRICE_TYPE_LABELS] ?? price.type_label }}
</span>
<span class="font-medium tabular-nums">{{ price.price_formatted }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="cutting.results.length" class="pt-2 border-t">
<div class="flex items-center justify-between text-[11px]">
<span class="text-muted-foreground">Total Hasil Cutting:</span>
<span class="font-semibold tabular-nums">{{ cutting.total_result_pieces ?? 0 }} pcs</span>
</div>
</div>
</div>
</Card>
</div>
</div>
<div v-else class="space-y-4">
<h3 class="text-lg font-semibold">
Menunggu Persetujuan
</h3>
<div class="rounded-md border px-6 py-10 text-center">
<p class="text-muted-foreground text-sm">
Tidak ada verifikasi yang menunggu persetujuan.
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,70 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/data-table';
import type { OwnerVerificationItem } from '@/types/owner-verification';
import DataTableActions from './data-table-actions.vue';
function statusBadgeVariant(status: string): 'default' | 'destructive' | 'outline' | 'secondary' {
if (status === 'approved') {
return 'default';
}
if (status === 'rejected') {
return 'destructive';
}
return 'outline';
}
export function createColumns(
onView: (item: OwnerVerificationItem) => void,
): ColumnDef<OwnerVerificationItem>[] {
return [
{
accessorKey: 'created_at_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Waktu', column: 'created_at' }),
cell: ({ row }) => row.original.created_at_formatted ?? '-',
},
{
accessorKey: 'submitted_by_name',
enableSorting: false,
header: 'Diajukan Oleh',
},
{
accessorKey: 'action_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Aksi', column: 'action' }),
},
{
accessorKey: 'subject_label',
enableSorting: false,
header: 'Modul',
},
{
accessorKey: 'title',
enableSorting: false,
header: 'Item',
},
{
accessorKey: 'status_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
cell: ({ row }) => h(
Badge,
{ variant: statusBadgeVariant(row.original.status) },
() => row.original.status_label,
),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => h(DataTableActions, {
item: row.original,
onView: () => onView(row.original),
}),
},
];
}

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { X } from '@lucide/vue';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { RowApproveAction, RowRejectAction } from '@/components/button';
import { RowApproveAction, RowDetailAction, RowRejectAction } from '@/components/button';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -19,11 +19,15 @@ import {
} from '@/components/ui/field';
import { Textarea } from '@/components/ui/textarea';
import { useCan } from '@/composables/useCan';
import { approve, reject } from '@/routes/admin/manage/owner_verifications';
import type { CuttingListItem } from '@/types/cutting';
import { approve, reject, approve_request, reject_request } from '@/routes/admin/manage/owner_verifications';
import type { OwnerVerificationItem } from '@/types/owner-verification';
const props = defineProps<{
cutting: CuttingListItem;
item: OwnerVerificationItem;
}>();
const emit = defineEmits<{
view: [];
}>();
const { can } = useCan();
@ -31,16 +35,27 @@ const { can } = useCan();
const rejectDialogOpen = ref(false);
const approveForm = useForm({});
const rejectForm = useForm({
reason: '',
});
const approveUrl = computed(() => (
props.item.source === 'cutting'
? approve.url(props.item.id)
: approve_request.url(props.item.id)
));
const rejectUrl = computed(() => (
props.item.source === 'cutting'
? reject.url(props.item.id)
: reject_request.url(props.item.id)
));
function submitApprove() {
approveForm
.post(approve.url(props.cutting.id), {
.post(approveUrl.value, {
preserveScroll: true,
onError: (errors: any) => {
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
@ -50,13 +65,13 @@ function submitApprove() {
function submitReject() {
rejectForm
.post(reject.url(props.cutting.id), {
.post(rejectUrl.value, {
preserveScroll: true,
onSuccess: () => {
rejectDialogOpen.value = false;
rejectForm.reset();
},
onError: (errors: any) => {
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
@ -66,13 +81,25 @@ function submitReject() {
</script>
<template>
<div v-if="can('owner_verifications.verify')" class="flex items-center gap-1">
<RowApproveAction size="sm" tooltip="Setujui Verifikasi" :disabled="approveForm.processing"
@click="submitApprove" />
<RowRejectAction size="sm" tooltip="Tolak Verifikasi" @click="rejectDialogOpen = true" />
<div class="flex items-center justify-end gap-1">
<RowDetailAction tooltip="Lihat" @click="emit('view')" />
<template v-if="item.is_pending && can('owner_verifications.verify')">
<RowApproveAction
size="sm"
tooltip="Setujui"
:disabled="approveForm.processing"
@click="submitApprove"
/>
<RowRejectAction
size="sm"
tooltip="Tolak"
:disabled="rejectForm.processing"
@click="rejectDialogOpen = true"
/>
</template>
</div>
<!-- Reject Dialog -->
<Dialog v-model:open="rejectDialogOpen">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
@ -82,19 +109,27 @@ function submitReject() {
<div class="space-y-4">
<div class="rounded-md border p-3 text-xs space-y-2">
<div>
<span class="text-muted-foreground">Cutting:</span>
<span class="ml-1 font-medium">#{{ cutting.id }}</span>
<span class="text-muted-foreground">Modul:</span>
<span class="ml-1 font-medium">{{ item.subject_label }}</span>
</div>
<div>
<span class="text-muted-foreground">Deskripsi:</span>
<span class="ml-1 font-medium">{{ cutting.description ?? '-' }}</span>
<span class="text-muted-foreground">Item:</span>
<span class="ml-1 font-medium">{{ item.title }}</span>
</div>
<div>
<span class="text-muted-foreground">Aksi:</span>
<span class="ml-1 font-medium">{{ item.action_label }}</span>
</div>
</div>
<Field>
<FieldLabel for="reject-reason">Alasan Penolakan</FieldLabel>
<Textarea id="reject-reason" v-model="rejectForm.reason" placeholder="Jelaskan alasan penolakan..."
rows="3" />
<Textarea
id="reject-reason"
v-model="rejectForm.reason"
placeholder="Jelaskan alasan penolakan..."
rows="3"
/>
<FieldError :errors="rejectForm.errors.reason ? [rejectForm.errors.reason] : []" />
</Field>
</div>
@ -103,7 +138,12 @@ function submitReject() {
<Button type="button" variant="outline" @click="rejectDialogOpen = false">
Batal
</Button>
<Button type="button" variant="destructive" :disabled="rejectForm.processing" @click="submitReject">
<Button
type="button"
variant="destructive"
:disabled="rejectForm.processing"
@click="submitReject"
>
<X class="size-4" />
Tolak
</Button>

View File

@ -107,6 +107,9 @@ function getGroupedItems(items: any[]): GroupedPurchaseItems[] {
<h3 class="font-medium leading-tight">
{{ purchase.supplier?.name }}
</h3>
<p v-if="purchase.has_pending_request" class="text-sm text-amber-600">
Sedang menunggu verifikasi owner
</p>
<div class="text-muted-foreground space-y-1 text-sm">
<p>{{ purchase.created_at_formatted }}</p>
<p>Oleh {{ purchase.created_by?.profile?.full_name ?? purchase.created_by?.username }}

View File

@ -13,9 +13,20 @@ const { can } = useCan();
<template>
<div class="flex items-center justify-end gap-1">
<RowEditAction v-if="can('purchases.update')" :href="edit.url(purchase.id)" />
<RowDeleteAction v-if="can('purchases.delete')" :action-url="destroy.url(purchase.id)" title="Hapus belanja?"
:description="`Belanja ${purchase.total_formatted} dari ${purchase.supplier_name} akan dihapus. Stok bahan baku akan dikurangi.`"
error-message="Gagal menghapus belanja." />
<RowEditAction
v-if="can('purchases.update')"
:href="edit.url(purchase.id)"
:disabled="purchase.has_pending_request"
:tooltip="purchase.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'"
/>
<RowDeleteAction
v-if="can('purchases.delete')"
:action-url="destroy.url(purchase.id)"
:disabled="purchase.has_pending_request"
:tooltip="purchase.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'"
title="Hapus belanja?"
:description="`Pengajuan hapus belanja ${purchase.total_formatted} dari ${purchase.supplier?.name ?? purchase.supplier_name} akan dikirim ke owner untuk verifikasi.`"
error-message="Gagal mengajukan penghapusan belanja."
/>
</div>
</template>

View File

@ -87,6 +87,9 @@ function rowNumber(index: number): number {
{{ product.name }}
</h3>
</div>
<p v-if="product.has_pending_request" class="text-sm text-amber-600">
Sedang menunggu verifikasi owner
</p>
<div v-if="product.categories.length" class="flex flex-wrap gap-1">
<Badge v-for="category in product.categories" :key="category.id" variant="outline">
{{ category.name }}

View File

@ -1,5 +1,4 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan';
import type { ProductListItem } from '@/types/product';
@ -14,9 +13,20 @@ const { can } = useCan();
<template>
<div class="flex items-center justify-end gap-1">
<RowEditAction v-if="can('products.update')" :href="edit.url(product.id)" />
<RowDeleteAction v-if="can('products.delete')" :action-url="destroy.url(product.id)" title="Hapus produk?"
:description="`Produk ${product.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
error-message="Gagal menghapus produk." />
<RowEditAction
v-if="can('products.update')"
:href="edit.url(product.id)"
:disabled="product.has_pending_request"
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'"
/>
<RowDeleteAction
v-if="can('products.delete')"
:action-url="destroy.url(product.id)"
:disabled="product.has_pending_request"
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'"
title="Hapus produk?"
:description="`Pengajuan hapus produk ${product.name} akan dikirim ke owner untuk verifikasi.`"
error-message="Gagal mengajukan penghapusan produk."
/>
</div>
</template>

View File

@ -14,38 +14,50 @@ const props = defineProps<{
const { can } = useCan();
const isActive = ref(props.product.is_active);
function resolveIsActive(product: ProductListItem): boolean {
return product.display_is_active ?? product.is_active;
}
const isActive = ref(resolveIsActive(props.product));
const processing = ref(false);
watch(
() => props.product.is_active,
() => resolveIsActive(props.product),
(value) => {
isActive.value = value;
},
);
function toggleStatus(checked: boolean) {
if (!can('products.update')) {
if (!can('products.toggle_status') || props.product.has_pending_request) {
return;
}
if (checked === isActive.value) {
return;
}
const previous = isActive.value;
isActive.value = checked;
processing.value = true;
isActive.value = checked;
router.patch(toggle_status.url(props.product.id), {
is_active: checked,
}, {
preserveScroll: true,
onError: (errors: any) => {
isActive.value = previous;
isActive.value = resolveIsActive(props.product);
if (errors.system) {
toast.error(errors.system);
}
if (errors.product) {
toast.error(errors.product);
}
},
onFinish: () => {
processing.value = false;
isActive.value = resolveIsActive(props.product);
},
});
}
@ -53,8 +65,11 @@ function toggleStatus(checked: boolean) {
<template>
<div class="flex items-center gap-2">
<Switch :model-value="isActive" :disabled="processing || !can('products.update')"
@update:model-value="toggleStatus" />
<Switch
:model-value="isActive"
:disabled="processing || !can('products.toggle_status') || product.has_pending_request"
@update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge>

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTableEmpty } from '@/components/data-table';
import {
Table,
TableBody,
@ -83,12 +83,15 @@ function rowNumber(index: number): number {
{{ material.unit_label }}
</Badge>
</div>
<p v-if="material.has_pending_request" class="text-sm text-amber-600">
Sedang menunggu verifikasi owner
</p>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>
Total stok <strong class="text-primary"> {{material.prices.reduce((acc, price) =>
acc +
Number(price.stock), 0)
}}
}}
{{ material.unit_abbreviation }}
</strong>
</span>

View File

@ -13,9 +13,20 @@ const { can } = useCan();
<template>
<div class="flex items-center justify-end gap-1">
<RowEditAction v-if="can('raw_materials.update')" :href="edit.url(material.id)" />
<RowDeleteAction v-if="can('raw_materials.delete')" :action-url="destroy.url(material.id)" title="Hapus bahan baku?"
:description="`Bahan baku ${material.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
error-message="Gagal menghapus bahan baku." />
<RowEditAction
v-if="can('raw_materials.update')"
:href="edit.url(material.id)"
:disabled="material.has_pending_request"
:tooltip="material.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'"
/>
<RowDeleteAction
v-if="can('raw_materials.delete')"
:action-url="destroy.url(material.id)"
:disabled="material.has_pending_request"
:tooltip="material.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'"
title="Hapus bahan baku?"
:description="`Pengajuan hapus bahan baku ${material.name} akan dikirim ke owner untuk verifikasi.`"
error-message="Gagal mengajukan penghapusan bahan baku."
/>
</div>
</template>

View File

@ -14,31 +14,38 @@ const props = defineProps<{
const { can } = useCan();
const isActive = ref(props.material.is_active);
function resolveIsActive(material: RawMaterialListItem): boolean {
return material.display_is_active ?? material.is_active;
}
const isActive = ref(resolveIsActive(props.material));
const processing = ref(false);
watch(
() => props.material.is_active,
() => resolveIsActive(props.material),
(value) => {
isActive.value = value;
},
);
function toggleStatus(checked: boolean) {
if (!can('raw_materials.toggle_status')) {
if (!can('raw_materials.toggle_status') || props.material.has_pending_request) {
return;
}
if (checked === isActive.value) {
return;
}
const previous = isActive.value;
isActive.value = checked;
processing.value = true;
isActive.value = checked;
router.patch(toggle_status.url(props.material.id), {
is_active: checked,
}, {
preserveScroll: true,
onError: (errors: any) => {
isActive.value = previous;
isActive.value = resolveIsActive(props.material);
if (errors.system) {
toast.error(errors.system);
@ -46,6 +53,7 @@ function toggleStatus(checked: boolean) {
},
onFinish: () => {
processing.value = false;
isActive.value = resolveIsActive(props.material);
},
});
}
@ -53,8 +61,11 @@ function toggleStatus(checked: boolean) {
<template>
<div class="flex items-center gap-2">
<Switch :model-value="isActive" :disabled="processing || !can('raw_materials.toggle_status')"
@update:model-value="toggleStatus" />
<Switch
:model-value="isActive"
:disabled="processing || !can('raw_materials.toggle_status') || material.has_pending_request"
@update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge>

View File

@ -0,0 +1,51 @@
export type OwnerVerificationSource = 'cutting' | 'request';
export interface VerificationChange {
field: string;
old: unknown;
new: unknown;
}
export interface OwnerVerificationItem {
id: number;
source: OwnerVerificationSource;
subject_type: string | null;
subject_label: string;
subject_id: number | null;
action: string;
action_label: string;
status: string;
status_label: string;
title: string;
summary?: string;
submitted_by_name: string;
verified_by_name?: string | null;
verified_at_formatted?: string | null;
created_at?: string | null;
created_at_formatted: string | null;
changes: VerificationChange[];
rejection_reason?: string | null;
is_pending: boolean;
detail?: Record<string, unknown>;
}
/** @deprecated Use OwnerVerificationItem */
export type OwnerVerificationRequestItem = OwnerVerificationItem;
export interface SelectOption {
value: string;
label: string;
}
export interface PaginatedOwnerVerificationRequests {
data: OwnerVerificationItem[];
current_page: number;
per_page: number;
last_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
}

View File

@ -61,3 +61,36 @@ export interface Product {
categories: Category[];
variants: Variant[];
}
export interface ProductListItem extends Product {
has_pending_request?: boolean;
pending_request_action?: string;
pending_request_action_label?: string;
display_is_active?: boolean;
}
export interface CategoryOption {
value: number;
label: string;
}
export interface PaginatedProducts {
data: ProductListItem[];
current_page: number;
per_page: number;
last_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
}
export interface ProductVariantFormItem {
client_id: string;
id?: number;
name: string;
stock: string | number;
media: import('@/types/media').MediaUploadState;
}

View File

@ -38,6 +38,9 @@ export type PurchaseListItem = {
} | null;
photos?: MediaItem | null;
items: any[];
has_pending_request?: boolean;
pending_request_action?: string;
pending_request_action_label?: string;
};
export type PurchaseCartItem = {

View File

@ -27,6 +27,10 @@ export type RawMaterialListItem = {
unit_abbreviation: string;
is_active: boolean;
prices: RawMaterialPrice[];
has_pending_request?: boolean;
pending_request_action?: string;
pending_request_action_label?: string;
display_is_active?: boolean;
};
export type RawMaterialPriceFormItem = {

View File

@ -89,19 +89,31 @@
->name('store');
Route::get('{product}/edit', [ProductController::class, 'edit'])
->middleware('permission:'.Permission::PRODUCTS_UPDATE->value)
->middleware([
'permission:'.Permission::PRODUCTS_UPDATE->value,
'no_pending_owner_verification:product',
])
->name('edit');
Route::put('{product}', [ProductController::class, 'update'])
->middleware('permission:'.Permission::PRODUCTS_UPDATE->value)
->middleware([
'permission:'.Permission::PRODUCTS_UPDATE->value,
'no_pending_owner_verification:product',
])
->name('update');
Route::patch('{product}/toggle-status', [ProductController::class, 'toggleStatus'])
->middleware('permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value)
->middleware([
'permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value,
'no_pending_owner_verification:product',
])
->name('toggle_status');
Route::delete('{product}', [ProductController::class, 'destroy'])
->middleware('permission:'.Permission::PRODUCTS_DELETE->value)
->middleware([
'permission:'.Permission::PRODUCTS_DELETE->value,
'no_pending_owner_verification:product',
])
->name('destroy');
Route::get('/', [ProductController::class, 'index'])->name('index');
@ -119,19 +131,31 @@
->name('store');
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');
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');
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');
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');
Route::get('/', [RawMaterialController::class, 'index'])->name('index');
@ -202,15 +226,24 @@
->name('store');
Route::get('{purchase}/edit', [PurchaseController::class, 'edit'])
->middleware('permission:'.Permission::PURCHASES_UPDATE->value)
->middleware([
'permission:'.Permission::PURCHASES_UPDATE->value,
'no_pending_owner_verification:purchase',
])
->name('edit');
Route::put('{purchase}', [PurchaseController::class, 'update'])
->middleware('permission:'.Permission::PURCHASES_UPDATE->value)
->middleware([
'permission:'.Permission::PURCHASES_UPDATE->value,
'no_pending_owner_verification:purchase',
])
->name('update');
Route::delete('{purchase}', [PurchaseController::class, 'destroy'])
->middleware('permission:'.Permission::PURCHASES_DELETE->value)
->middleware([
'permission:'.Permission::PURCHASES_DELETE->value,
'no_pending_owner_verification:purchase',
])
->name('destroy');
});
@ -319,13 +352,21 @@
->group(function () {
Route::get('/', [OwnerVerificationController::class, 'index'])->name('index');
Route::post('{cutting}/approve', [OwnerVerificationController::class, 'approve'])
Route::post('cuttings/{cutting}/approve', [OwnerVerificationController::class, 'approveCutting'])
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_VERIFY->value)
->name('approve');
Route::post('{cutting}/reject', [OwnerVerificationController::class, 'reject'])
Route::post('cuttings/{cutting}/reject', [OwnerVerificationController::class, 'rejectCutting'])
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_REJECT->value)
->name('reject');
Route::post('requests/{ownerVerificationRequest}/approve', [OwnerVerificationController::class, 'approveRequest'])
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_VERIFY->value)
->name('approve_request');
Route::post('requests/{ownerVerificationRequest}/reject', [OwnerVerificationController::class, 'rejectRequest'])
->middleware('permission:'.Permission::OWNER_VERIFICATIONS_REJECT->value)
->name('reject_request');
});
});

View File

@ -0,0 +1,356 @@
<?php
use App\Enums\CuttingStatus;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission as PermissionEnum;
use App\Enums\Role as RoleEnum;
use App\Models\Cutting;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
use App\Models\Purchase;
use App\Models\RawMaterialPrice;
use App\Models\Supplier;
use App\Models\User;
use App\Support\ActivityLog\ModelLabel;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
});
function createOwnerVerificationRequestFor(User $submitter, ?Product $product = null): OwnerVerificationRequest
{
$product ??= Product::factory()->create(['is_active' => false]);
return OwnerVerificationRequest::query()->create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Product::class,
'subject_id' => $product->id,
'submitted_by_id' => $submitter->id,
'payload' => ['old' => null, 'new' => ['name' => $product->name]],
]);
}
function createOwnerVerifierUser(): User
{
$user = User::factory()->create();
$user->assignRole(RoleEnum::OWNER->value);
$user->forgetCachedPermissions();
return $user;
}
function createPendingCutting(?User $submitter = null): Cutting
{
$submitter ??= User::factory()->create();
return Cutting::factory()->create([
'status' => CuttingStatus::PENDING_VERIFICATION,
'submitted_by_id' => $submitter->id,
'created_by_id' => $submitter->id,
'description' => 'Cutting test pending',
]);
}
describe('Owner Verification Index', function () {
test('submitter with view permission only sees own verification requests', function () {
$submitter = User::factory()->create();
$submitter->assignRole(RoleEnum::ADMIN_TOKO->value);
$submitter->forgetCachedPermissions();
$otherUser = User::factory()->create();
$otherUser->assignRole(RoleEnum::ADMIN_TOKO->value);
$otherUser->forgetCachedPermissions();
$ownRequest = createOwnerVerificationRequestFor($submitter);
createOwnerVerificationRequestFor($otherUser);
$response = $this->actingAs($submitter)
->get(route('admin.manage.owner_verifications.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('admin/manage/owner-verifications/Index')
->has('verificationRequests.data', 1)
->where('verificationRequests.data.0.id', $ownRequest->id)
->where('verificationRequests.data.0.source', 'request')
);
});
test('verifier sees all verification requests', function () {
$submitter = User::factory()->create();
$submitter->assignRole(RoleEnum::ADMIN_TOKO->value);
$submitter->forgetCachedPermissions();
$verifier = createOwnerVerifierUser();
createOwnerVerificationRequestFor($submitter);
createOwnerVerificationRequestFor($submitter);
$response = $this->actingAs($verifier)
->get(route('admin.manage.owner_verifications.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('admin/manage/owner-verifications/Index')
->has('verificationRequests.data', 2)
);
});
test('submitter cannot approve verification request', function () {
$submitter = User::factory()->create();
$submitter->assignRole(RoleEnum::ADMIN_TOKO->value);
$submitter->forgetCachedPermissions();
$request = createOwnerVerificationRequestFor($submitter);
$this->actingAs($submitter)
->post(route('admin.manage.owner_verifications.approve_request', $request))
->assertForbidden();
});
test('admin toko role has owner verification view permission', function () {
$user = User::factory()->create();
$user->assignRole(RoleEnum::ADMIN_TOKO->value);
$user->forgetCachedPermissions();
expect($user->can(PermissionEnum::OWNER_VERIFICATIONS_VIEW->value))->toBeTrue();
expect($user->can(PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value))->toBeFalse();
});
test('admin bahan baku role has owner verification view permission', function () {
$user = User::factory()->create();
$user->assignRole(RoleEnum::ADMIN_BAHAN_BAKU->value);
$user->forgetCachedPermissions();
expect($user->can(PermissionEnum::OWNER_VERIFICATIONS_VIEW->value))->toBeTrue();
expect($user->can(PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value))->toBeFalse();
});
});
describe('Owner Verification Unified Cutting List', function () {
test('verifier sees pending cuttings in the same verification table', function () {
$verifier = createOwnerVerifierUser();
$cutting = createPendingCutting();
$request = createOwnerVerificationRequestFor(User::factory()->create());
$response = $this->actingAs($verifier)
->get(route('admin.manage.owner_verifications.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('admin/manage/owner-verifications/Index')
->has('verificationRequests.data', 2)
->where('verificationRequests.data.0.source', 'cutting')
->where('verificationRequests.data.0.id', $cutting->id)
->where('verificationRequests.data.0.action', OwnerVerificationAction::STOCK_VERIFY->value)
->where('verificationRequests.data.0.subject_type', Cutting::class)
->where('verificationRequests.data.1.source', 'request')
->where('verificationRequests.data.1.id', $request->id)
);
});
test('submitter does not see pending cuttings in verification table', function () {
$submitter = User::factory()->create();
$submitter->assignRole(RoleEnum::ADMIN_TOKO->value);
$submitter->forgetCachedPermissions();
createPendingCutting();
createOwnerVerificationRequestFor($submitter);
$response = $this->actingAs($submitter)
->get(route('admin.manage.owner_verifications.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('admin/manage/owner-verifications/Index')
->has('verificationRequests.data', 1)
->where('verificationRequests.data.0.source', 'request')
);
});
test('filtering by cutting module shows only pending cuttings', function () {
$verifier = createOwnerVerifierUser();
$cutting = createPendingCutting();
createOwnerVerificationRequestFor(User::factory()->create());
$response = $this->actingAs($verifier)
->get(route('admin.manage.owner_verifications.index', [
'subject_type' => Cutting::class,
]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('verificationRequests.data', 1)
->where('verificationRequests.data.0.source', 'cutting')
->where('verificationRequests.data.0.id', $cutting->id)
);
});
test('filtering by stock verify action shows only pending cuttings', function () {
$verifier = createOwnerVerifierUser();
$cutting = createPendingCutting();
createOwnerVerificationRequestFor(User::factory()->create());
$response = $this->actingAs($verifier)
->get(route('admin.manage.owner_verifications.index', [
'action' => OwnerVerificationAction::STOCK_VERIFY->value,
]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('verificationRequests.data', 1)
->where('verificationRequests.data.0.source', 'cutting')
->where('verificationRequests.data.0.id', $cutting->id)
);
});
test('filtering by approved status hides pending cuttings', function () {
$verifier = createOwnerVerifierUser();
createPendingCutting();
OwnerVerificationRequest::query()->create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::APPROVED,
'subject_type' => Product::class,
'subject_id' => Product::factory()->create()->id,
'submitted_by_id' => User::factory()->create()->id,
'payload' => ['old' => null, 'new' => ['name' => 'Approved Product']],
'verified_by_id' => $verifier->id,
'verified_at' => now(),
]);
$response = $this->actingAs($verifier)
->get(route('admin.manage.owner_verifications.index', [
'status' => OwnerVerificationStatus::APPROVED->value,
]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('verificationRequests.data', 1)
->where('verificationRequests.data.0.source', 'request')
->where('verificationRequests.data.0.status', OwnerVerificationStatus::APPROVED->value)
);
});
test('subject options include cutting when pending cuttings exist', function () {
$verifier = createOwnerVerifierUser();
createPendingCutting();
$response = $this->actingAs($verifier)
->get(route('admin.manage.owner_verifications.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('subjectOptions', fn ($options) => collect($options)->contains(
fn (array $option) => $option['value'] === Cutting::class
&& $option['label'] === ModelLabel::for(Cutting::class),
))
);
});
test('verifier can approve pending cutting from verification page', function () {
$verifier = createOwnerVerifierUser();
$cutting = createPendingCutting();
$this->actingAs($verifier)
->post(route('admin.manage.owner_verifications.approve', $cutting))
->assertRedirect(route('admin.manage.owner_verifications.index'));
expect($cutting->fresh()->status)->toBe(CuttingStatus::VERIFIED);
});
test('verifier can reject pending cutting from verification page', function () {
$verifier = createOwnerVerifierUser();
$cutting = createPendingCutting();
$this->actingAs($verifier)
->post(route('admin.manage.owner_verifications.reject', $cutting), [
'reason' => 'Data tidak sesuai',
])
->assertRedirect(route('admin.manage.owner_verifications.index'));
$cutting->refresh();
expect($cutting->status)->toBe(CuttingStatus::COMPLETED);
expect($cutting->rejection?->reason)->toBe('Data tidak sesuai');
expect($cutting->rejection?->rejected_by_id)->toBe($verifier->id);
});
});
describe('Owner Verification Purchase Module', function () {
test('verifier sees purchase verification requests in index', function () {
$verifier = createOwnerVerifierUser();
$submitter = User::factory()->create();
$purchase = Purchase::factory()->create();
OwnerVerificationRequest::query()->create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'submitted_by_id' => $submitter->id,
'payload' => [
'old' => null,
'new' => ['supplier_name' => $purchase->supplier?->name ?? 'Supplier'],
],
]);
$response = $this->actingAs($verifier)
->get(route('admin.manage.owner_verifications.index', [
'subject_type' => Purchase::class,
]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('verificationRequests.data', 1)
->where('verificationRequests.data.0.source', 'request')
->where('verificationRequests.data.0.subject_type', Purchase::class)
->where('verificationRequests.data.0.subject_id', $purchase->id)
);
});
test('purchase submitter sees only own purchase verification requests', function () {
$submitter = User::factory()->create();
$submitter->assignRole(RoleEnum::ADMIN_BAHAN_BAKU->value);
$submitter->forgetCachedPermissions();
$otherUser = User::factory()->create();
$ownPurchase = Purchase::factory()->create(['created_by_id' => $submitter->id]);
$otherPurchase = Purchase::factory()->create(['created_by_id' => $otherUser->id]);
OwnerVerificationRequest::query()->create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Purchase::class,
'subject_id' => $ownPurchase->id,
'submitted_by_id' => $submitter->id,
'payload' => ['old' => null, 'new' => ['supplier_name' => 'Own']],
]);
OwnerVerificationRequest::query()->create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Purchase::class,
'subject_id' => $otherPurchase->id,
'submitted_by_id' => $otherUser->id,
'payload' => ['old' => null, 'new' => ['supplier_name' => 'Other']],
]);
$response = $this->actingAs($submitter)
->get(route('admin.manage.owner_verifications.index', [
'subject_type' => Purchase::class,
]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('verificationRequests.data', 1)
->where('verificationRequests.data.0.subject_id', $ownPurchase->id)
);
});
});

View File

@ -0,0 +1,341 @@
<?php
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission as PermissionEnum;
use App\Models\OwnerVerificationRequest;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterialPrice;
use App\Models\Supplier;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
});
function createPurchaseUserWithPermission(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 createPurchaseVerifierUser(): 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 approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerificationRequest
{
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
test()->actingAs($verifier)
->post(route('admin.manage.owner_verifications.approve_request', $verificationRequest))
->assertRedirect(route('admin.manage.owner_verifications.index'));
return $verificationRequest->fresh();
}
describe('Purchase Owner Verification', function () {
test('create purchase submits verification without changing stock', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$initialStock = (float) $price->stock;
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'notes' => 'Belanja test',
])
->assertRedirect(route('admin.manage.purchases.index'));
$purchase = Purchase::query()->latest()->first();
expect($purchase)->not->toBeNull();
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'action' => OwnerVerificationAction::CREATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect((float) $price->fresh()->stock)->toBe($initialStock);
});
test('approving create purchase increments stock', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
approveLatestPurchaseVerificationRequest($verifier);
expect((float) $price->fresh()->stock)->toBe(12.0);
});
test('update purchase submits verification request only', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
PermissionEnum::PURCHASES_UPDATE,
);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10, 'price' => 50_000]);
$otherPrice = RawMaterialPrice::factory()->create(['stock' => 5, 'price' => 30_000]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
approveLatestPurchaseVerificationRequest($verifier);
$purchase = Purchase::query()->latest()->firstOrFail();
$originalTotal = $purchase->total;
$this->actingAs($user)
->put(route('admin.manage.purchases.update', $purchase), [
'supplier_id' => $supplier->id,
'discount' => 1000,
'shipping_cost' => 0,
'notes' => 'Ubah belanja',
'items' => [
['raw_material_price_id' => $otherPrice->id, 'quantity' => 1],
],
])
->assertRedirect(route('admin.manage.purchases.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'action' => OwnerVerificationAction::UPDATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect($purchase->fresh()->total)->toBe($originalTotal);
});
test('delete purchase submits verification request only', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
PermissionEnum::PURCHASES_DELETE,
);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
approveLatestPurchaseVerificationRequest($verifier);
$purchase = Purchase::query()->latest()->firstOrFail();
$this->actingAs($user)
->delete(route('admin.manage.purchases.destroy', $purchase))
->assertRedirect(route('admin.manage.purchases.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'action' => OwnerVerificationAction::DELETE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeTrue();
expect((float) $price->fresh()->stock)->toBe(12.0);
});
test('rejecting create purchase deletes purchase without changing stock', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
$purchase = Purchase::query()->latest()->firstOrFail();
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
$this->actingAs($verifier)
->post(route('admin.manage.owner_verifications.reject_request', $verificationRequest), [
'reason' => 'Tidak sesuai',
])
->assertRedirect(route('admin.manage.owner_verifications.index'));
expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeFalse();
expect(PurchaseItem::query()->where('purchase_id', $purchase->id)->exists())->toBeFalse();
expect((float) $price->fresh()->stock)->toBe(10.0);
});
test('pending purchase blocks edit route', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
PermissionEnum::PURCHASES_UPDATE,
);
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
$purchase = Purchase::query()->latest()->firstOrFail();
$this->actingAs($user)
->get(route('admin.manage.purchases.edit', $purchase))
->assertRedirect(route('admin.manage.purchases.index'));
});
test('purchase index exposes pending verification state', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
$purchase = Purchase::query()->latest()->firstOrFail();
$response = $this->actingAs($user)
->get(route('admin.manage.purchases.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('purchases.data.0.id', $purchase->id)
->where('purchases.data.0.has_pending_request', true)
->where('purchases.data.0.pending_request_action', OwnerVerificationAction::CREATE->value)
);
});
});

View File

@ -1,7 +1,10 @@
<?php
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission as PermissionEnum;
use App\Models\Category;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\User;
@ -51,6 +54,33 @@ function createProductWithVariants(?Category $category = null): Product
return $product;
}
function createProductVerifierUser(): 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 approveLatestOwnerVerificationRequest(User $verifier): OwnerVerificationRequest
{
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
test()->actingAs($verifier)
->post(route('admin.manage.owner_verifications.approve_request', $verificationRequest))
->assertRedirect(route('admin.manage.owner_verifications.index'));
return $verificationRequest->fresh();
}
function variantWithImage(string $name = 'All Size', int $stock = 10): array
{
return [
@ -160,7 +190,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
// ─── Store ────────────────────────────────────────────────
describe('Product Store', function () {
test('authenticated user with permission can create a product', function () {
test('authenticated user with permission can submit product creation request', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
$category = Category::factory()->create();
@ -176,8 +206,15 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
])
->assertRedirect(route('admin.master.products.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Product::class,
'action' => OwnerVerificationAction::CREATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
$this->assertDatabaseHas('products', [
'name' => 'Produk Baru',
'is_active' => false,
]);
});
@ -313,8 +350,9 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
->assertSessionHasErrors('variants.0.stock');
});
test('creating product also creates variants and syncs categories', function () {
test('approving create request activates product with variants and categories', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
$verifier = createProductVerifierUser();
$cat1 = Category::factory()->create();
$cat2 = Category::factory()->create();
@ -333,12 +371,19 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
$product = Product::where('name', 'Produk Lengkap')->first();
expect($product)->not->toBeNull();
expect($product->is_active)->toBeFalse();
expect($product->categories)->toHaveCount(2);
expect($product->variants)->toHaveCount(3);
approveLatestOwnerVerificationRequest($verifier);
$product->refresh();
expect($product->is_active)->toBeTrue();
});
test('product slug is auto-generated', function () {
test('approving create request auto-generates product slug', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
$verifier = createProductVerifierUser();
$category = Category::factory()->create();
@ -352,7 +397,43 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
$this->assertDatabaseHas('products', [
'name' => 'Batik Modern Elegan',
'slug' => 'batik-modern-elegan',
'is_active' => false,
]);
approveLatestOwnerVerificationRequest($verifier);
$this->assertDatabaseHas('products', [
'name' => 'Batik Modern Elegan',
'slug' => 'batik-modern-elegan',
'is_active' => true,
]);
});
test('rejecting create request deletes product', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE);
$verifier = createProductVerifierUser();
$category = Category::factory()->create();
$this->actingAs($user)
->post(route('admin.master.products.store'), [
'name' => 'Produk Ditolak',
'category_ids' => [$category->id],
'variants' => [variantWithImage()],
]);
$product = Product::where('name', 'Produk Ditolak')->first();
expect($product)->not->toBeNull();
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
$this->actingAs($verifier)
->post(route('admin.manage.owner_verifications.reject_request', $verificationRequest), [
'reason' => 'Tidak sesuai standar',
])
->assertRedirect(route('admin.manage.owner_verifications.index'));
$this->assertSoftDeleted('products', ['id' => $product->id]);
});
});
@ -385,12 +466,33 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
->get(route('admin.master.products.edit', $product))
->assertForbidden();
});
test('edit is blocked when product has pending request', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE);
$product = createProductWithVariants();
$category = $product->categories->first();
$variant = $product->variants->first();
$this->actingAs($user)
->put(route('admin.master.products.update', $product), [
'name' => 'Nama Diubah',
'category_ids' => [$category->id],
'variants' => [
['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock],
],
]);
$this->actingAs($user)
->get(route('admin.master.products.edit', $product))
->assertRedirect(route('admin.master.products.index'));
});
});
// ─── Update ───────────────────────────────────────────────
describe('Product Update', function () {
test('authenticated user with permission can update a product', function () {
test('authenticated user with permission can submit product update request', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE);
$product = createProductWithVariants();
@ -408,6 +510,36 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
])
->assertRedirect(route('admin.master.products.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_id' => $product->id,
'subject_type' => Product::class,
'action' => OwnerVerificationAction::UPDATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect($product->fresh()->name)->not->toBe('Nama Diubah');
});
test('approving update request applies product changes', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE);
$verifier = createProductVerifierUser();
$product = createProductWithVariants();
$category = $product->categories->first();
$variant = $product->variants->first();
$this->actingAs($user)
->put(route('admin.master.products.update', $product), [
'name' => 'Nama Diubah',
'description' => 'Deskripsi baru',
'category_ids' => [$category->id],
'variants' => [
['id' => $variant->id, 'name' => 'New Variant', 'stock' => 20],
],
]);
approveLatestOwnerVerificationRequest($verifier);
expect($product->fresh()->name)->toBe('Nama Diubah');
expect($variant->fresh()->name)->toBe('New Variant');
});
@ -473,8 +605,9 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
->assertSessionHasErrors('category_ids');
});
test('unsubmitted variants are removed on update', function () {
test('approving update request removes unsubmitted variants', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE);
$verifier = createProductVerifierUser();
$product = createProductWithVariants();
$category = $product->categories->first();
@ -489,6 +622,8 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
],
]);
approveLatestOwnerVerificationRequest($verifier);
$product->refresh();
expect($product->variants)->toHaveCount(1);
expect($product->variants->first()->name)->toBe('Kept Variant');
@ -498,7 +633,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
// ─── Toggle Status ────────────────────────────────────────
describe('Product Toggle Status', function () {
test('authenticated user with permission can toggle product status', function () {
test('authenticated user with permission can submit toggle status request', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_TOGGLE_STATUS);
$product = createProductWithVariants();
@ -509,6 +644,57 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
])
->assertRedirect();
$this->assertDatabaseHas('owner_verification_requests', [
'subject_id' => $product->id,
'subject_type' => Product::class,
'action' => OwnerVerificationAction::TOGGLE_STATUS->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect($product->fresh()->is_active)->toBeTrue();
});
test('product index exposes display is active for pending toggle status request', function () {
$user = createProductUserWithPermission(
PermissionEnum::PRODUCTS_VIEW,
PermissionEnum::PRODUCTS_TOGGLE_STATUS,
);
$product = createProductWithVariants();
$product->update(['is_active' => true]);
$this->actingAs($user)
->patch(route('admin.master.products.toggle_status', $product), [
'is_active' => false,
])
->assertRedirect();
$response = $this->actingAs($user)
->get(route('admin.master.products.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('products.data.0.id', $product->id)
->where('products.data.0.is_active', true)
->where('products.data.0.display_is_active', false)
->where('products.data.0.has_pending_request', true)
->where('products.data.0.pending_request_action', OwnerVerificationAction::TOGGLE_STATUS->value)
);
});
test('approving toggle status request updates product status', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_TOGGLE_STATUS);
$verifier = createProductVerifierUser();
$product = createProductWithVariants();
$this->actingAs($user)
->patch(route('admin.master.products.toggle_status', $product), [
'is_active' => false,
]);
approveLatestOwnerVerificationRequest($verifier);
expect($product->fresh()->is_active)->toBeFalse();
});
@ -548,7 +734,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
// ─── Destroy ──────────────────────────────────────────────
describe('Product Destroy', function () {
test('authenticated user with permission can delete a product', function () {
test('authenticated user with permission can submit delete request', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
$product = createProductWithVariants();
@ -557,7 +743,47 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
->delete(route('admin.master.products.destroy', $product))
->assertRedirect(route('admin.master.products.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_id' => $product->id,
'subject_type' => Product::class,
'action' => OwnerVerificationAction::DELETE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
$this->assertNotSoftDeleted('products', ['id' => $product->id]);
});
test('approving delete request soft deletes product and variants', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
$verifier = createProductVerifierUser();
$product = createProductWithVariants();
$variantIds = $product->variants->pluck('id')->toArray();
$this->actingAs($user)
->delete(route('admin.master.products.destroy', $product));
approveLatestOwnerVerificationRequest($verifier);
$this->assertSoftDeleted('products', ['id' => $product->id]);
foreach ($variantIds as $variantId) {
$this->assertSoftDeleted('product_variants', ['id' => $variantId]);
}
});
test('approving delete request detaches categories', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
$verifier = createProductVerifierUser();
$product = createProductWithVariants();
$this->actingAs($user)
->delete(route('admin.master.products.destroy', $product));
approveLatestOwnerVerificationRequest($verifier);
$this->assertDatabaseMissing('product_categories', ['product_id' => $product->id]);
});
test('guest cannot delete a product', function () {
@ -580,33 +806,6 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array
$this->assertNotSoftDeleted('products', ['id' => $product->id]);
});
test('deleting product also soft deletes variants', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
$product = createProductWithVariants();
$variantIds = $product->variants->pluck('id')->toArray();
$this->actingAs($user)
->delete(route('admin.master.products.destroy', $product));
$this->assertSoftDeleted('products', ['id' => $product->id]);
foreach ($variantIds as $variantId) {
$this->assertSoftDeleted('product_variants', ['id' => $variantId]);
}
});
test('deleting product detaches categories', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE);
$product = createProductWithVariants();
$this->actingAs($user)
->delete(route('admin.master.products.destroy', $product));
$this->assertDatabaseMissing('product_categories', ['product_id' => $product->id]);
});
});
// ─── Product Model ────────────────────────────────────────

View File

@ -1,7 +1,10 @@
<?php
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission as PermissionEnum;
use App\Enums\RawMaterialUnit;
use App\Models\OwnerVerificationRequest;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\User;
@ -59,6 +62,33 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
];
}
function createRawMaterialVerifierUser(): 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 approveLatestOwnerVerificationRequest(User $verifier): OwnerVerificationRequest
{
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
test()->actingAs($verifier)
->post(route('admin.manage.owner_verifications.approve_request', $verificationRequest))
->assertRedirect(route('admin.manage.owner_verifications.index'));
return $verificationRequest->fresh();
}
// ─── Index ────────────────────────────────────────────────
describe('Raw Material Index', function () {
@ -146,7 +176,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
// ─── Store ────────────────────────────────────────────────
describe('Raw Material Store', function () {
test('authenticated user with permission can create a 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);
$this->actingAs($user)
@ -162,6 +192,13 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
$this->assertDatabaseHas('raw_materials', [
'name' => 'Kain Sutra',
'unit' => RawMaterialUnit::METER->value,
'is_active' => false,
]);
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => RawMaterial::class,
'action' => OwnerVerificationAction::CREATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
});
@ -346,7 +383,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
// ─── Update ───────────────────────────────────────────────
describe('Raw Material Update', function () {
test('authenticated user with permission can update a raw material', function () {
test('authenticated user with permission can submit raw material update request', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_UPDATE);
$rawMaterial = createRawMaterialWithPrices();
@ -362,6 +399,35 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
])
->assertRedirect(route('admin.master.raw_materials.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_id' => $rawMaterial->id,
'subject_type' => RawMaterial::class,
'action' => OwnerVerificationAction::UPDATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect($rawMaterial->fresh()->name)->not->toBe('Nama Diubah');
expect($price->fresh()->variant)->not->toBe('Putih');
});
test('approving update request applies raw material changes', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_UPDATE);
$verifier = createRawMaterialVerifierUser();
$rawMaterial = createRawMaterialWithPrices();
$price = $rawMaterial->prices->first();
$this->actingAs($user)
->put(route('admin.master.raw_materials.update', $rawMaterial), [
'name' => 'Nama Diubah',
'unit' => RawMaterialUnit::KILOGRAM->value,
'prices' => [
['id' => $price->id, 'variant' => 'Putih', 'price' => 90000, 'stock' => 25],
],
]);
approveLatestOwnerVerificationRequest($verifier);
expect($rawMaterial->fresh()->name)->toBe('Nama Diubah');
expect($price->fresh()->variant)->toBe('Putih');
});
@ -423,8 +489,9 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
->assertSessionHasErrors('prices');
});
test('unsubmitted prices are removed on update', function () {
test('unsubmitted prices are removed after approving update request', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_UPDATE);
$verifier = createRawMaterialVerifierUser();
$rawMaterial = createRawMaterialWithPrices();
$priceToKeep = $rawMaterial->prices->first();
@ -438,6 +505,8 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
],
]);
approveLatestOwnerVerificationRequest($verifier);
$rawMaterial->refresh();
expect($rawMaterial->prices)->toHaveCount(1);
expect($rawMaterial->prices->first()->variant)->toBe('Kept');
@ -447,7 +516,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
// ─── Toggle Status ────────────────────────────────────────
describe('Raw Material Toggle Status', function () {
test('authenticated user with permission can toggle raw material status', function () {
test('authenticated user with permission can submit toggle status request', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_TOGGLE_STATUS);
$rawMaterial = RawMaterial::factory()->create();
@ -458,6 +527,56 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
])
->assertRedirect();
$this->assertDatabaseHas('owner_verification_requests', [
'subject_id' => $rawMaterial->id,
'subject_type' => RawMaterial::class,
'action' => OwnerVerificationAction::TOGGLE_STATUS->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect($rawMaterial->fresh()->is_active)->toBeTrue();
});
test('raw material index exposes display is active for pending toggle status request', function () {
$user = createRawMaterialUserWithPermission(
PermissionEnum::RAW_MATERIALS_VIEW,
PermissionEnum::RAW_MATERIALS_TOGGLE_STATUS,
);
$rawMaterial = RawMaterial::factory()->create(['is_active' => true]);
$this->actingAs($user)
->patch(route('admin.master.raw_materials.toggle_status', $rawMaterial), [
'is_active' => false,
])
->assertRedirect();
$response = $this->actingAs($user)
->get(route('admin.master.raw_materials.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('rawMaterials.data.0.id', $rawMaterial->id)
->where('rawMaterials.data.0.is_active', true)
->where('rawMaterials.data.0.display_is_active', false)
->where('rawMaterials.data.0.has_pending_request', true)
->where('rawMaterials.data.0.pending_request_action', OwnerVerificationAction::TOGGLE_STATUS->value)
);
});
test('approving toggle status request updates raw material status', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_TOGGLE_STATUS);
$verifier = createRawMaterialVerifierUser();
$rawMaterial = RawMaterial::factory()->create();
$this->actingAs($user)
->patch(route('admin.master.raw_materials.toggle_status', $rawMaterial), [
'is_active' => false,
]);
approveLatestOwnerVerificationRequest($verifier);
expect($rawMaterial->fresh()->is_active)->toBeFalse();
});
@ -497,7 +616,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
// ─── Destroy ──────────────────────────────────────────────
describe('Raw Material Destroy', function () {
test('authenticated user with permission can delete a raw material', function () {
test('authenticated user with permission can submit delete request', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
$rawMaterial = createRawMaterialWithPrices();
@ -506,7 +625,33 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
->delete(route('admin.master.raw_materials.destroy', $rawMaterial))
->assertRedirect(route('admin.master.raw_materials.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_id' => $rawMaterial->id,
'subject_type' => RawMaterial::class,
'action' => OwnerVerificationAction::DELETE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
$this->assertNotSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
});
test('approving delete request soft deletes raw material and prices', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
$verifier = createRawMaterialVerifierUser();
$rawMaterial = createRawMaterialWithPrices();
$priceIds = $rawMaterial->prices->pluck('id')->toArray();
$this->actingAs($user)
->delete(route('admin.master.raw_materials.destroy', $rawMaterial));
approveLatestOwnerVerificationRequest($verifier);
$this->assertSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
foreach ($priceIds as $priceId) {
$this->assertSoftDeleted('raw_material_prices', ['id' => $priceId]);
}
});
test('guest cannot delete a raw material', function () {
@ -530,7 +675,7 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
$this->assertNotSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
});
test('deleting raw material also soft deletes prices', function () {
test('deleting raw material is not applied until owner approves', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
$rawMaterial = createRawMaterialWithPrices();
@ -539,10 +684,10 @@ function priceWithImage(string $variant = 'Default', int $price = 50000, float $
$this->actingAs($user)
->delete(route('admin.master.raw_materials.destroy', $rawMaterial));
$this->assertSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
$this->assertNotSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
foreach ($priceIds as $priceId) {
$this->assertSoftDeleted('raw_material_prices', ['id' => $priceId]);
$this->assertNotSoftDeleted('raw_material_prices', ['id' => $priceId]);
}
});
});