feat: add stok opname management with new models, controllers, services, and frontend components for creating, editing, and viewing stok opnames

This commit is contained in:
Yoga Pangestu 2026-06-28 11:53:37 +07:00
parent 3d4745cb0a
commit a5158c3992
20 changed files with 1919 additions and 2 deletions

View File

@ -106,6 +106,13 @@ enum Permission: string
case STOCKS_VIEW = 'stocks.view';
case STOK_OPNAMES_VIEW = 'stok_opnames.view';
case STOK_OPNAMES_CREATE = 'stok_opnames.create';
case STOK_OPNAMES_UPDATE = 'stok_opnames.update';
case STOK_OPNAMES_DELETE = 'stok_opnames.delete';
case STOK_OPNAMES_SUBMIT = 'stok_opnames.submit';
case STOK_OPNAMES_VERIFY = 'stok_opnames.verify';
case CASH_VIEW = 'cash.view';
case CASH_DEPOSIT = 'cash.deposit';
case CASH_WITHDRAW = 'cash.withdraw';
@ -238,6 +245,13 @@ public function label(): string
self::STOCKS_VIEW => 'Lihat Stok',
self::STOK_OPNAMES_VIEW => 'Lihat Stok Opname',
self::STOK_OPNAMES_CREATE => 'Tambah Stok Opname',
self::STOK_OPNAMES_UPDATE => 'Ubah Stok Opname',
self::STOK_OPNAMES_DELETE => 'Hapus Stok Opname',
self::STOK_OPNAMES_SUBMIT => 'Ajukan Verifikasi Stok Opname',
self::STOK_OPNAMES_VERIFY => 'Verifikasi Stok Opname',
self::CASH_VIEW => 'Lihat Kas',
self::CASH_DEPOSIT => 'Setor Kas',
self::CASH_WITHDRAW => 'Tarik Kas',
@ -308,7 +322,9 @@ public function group(): string
self::CUTTINGS_REJECT => 'Cutting',
self::OWNER_VERIFICATIONS_VIEW, self::OWNER_VERIFICATIONS_VERIFY,
self::OWNER_VERIFICATIONS_REJECT => 'Verifikasi Owner',
self::STOCKS_VIEW => 'Stok',
self::STOCKS_VIEW,
self::STOK_OPNAMES_VIEW, self::STOK_OPNAMES_CREATE, self::STOK_OPNAMES_UPDATE,
self::STOK_OPNAMES_DELETE, self::STOK_OPNAMES_SUBMIT, self::STOK_OPNAMES_VERIFY => 'Stok',
self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_WITHDRAW, self::CASH_UPDATE, self::CASH_DELETE => 'Kas',
self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE,
self::EXPENSES_DELETE => 'Pengeluaran',

View File

@ -17,6 +17,7 @@ enum Role: string
case MARKETING_ONLINE = 'marketing-online';
case CASHIER = 'cashier';
case NON_OPERATOR = 'non-operator';
case STOK_OPNAME = 'stok-opname';
public function label(): string
{
@ -30,6 +31,7 @@ public function label(): string
self::MARKETING_ONLINE => 'Marketing Online',
self::CASHIER => 'Kasir',
self::NON_OPERATOR => 'Non Operator',
self::STOK_OPNAME => 'Stok Opname',
};
}
@ -135,6 +137,9 @@ public function permissions(): array
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_VERIFY,
Permission::CUTTINGS_VERIFY,
Permission::ATTENDANCES_VIEW,
@ -348,6 +353,35 @@ public function permissions(): array
Permission::PAYROLL_VIEW,
],
self::STOK_OPNAME => [
Permission::DASHBOARD_VIEW,
Permission::DASHBOARD_ATTENDANCE,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_CREATE,
Permission::STOK_OPNAMES_UPDATE,
Permission::STOK_OPNAMES_DELETE,
Permission::STOK_OPNAMES_SUBMIT,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
],
};
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum StokOpnameStatus: string
{
case DRAFT = 'draft';
case PENDING = 'pending';
case VERIFIED = 'verified';
case REJECTED = 'rejected';
public function label(): string
{
return match ($this) {
self::DRAFT => 'Draft',
self::PENDING => 'Menunggu Verifikasi',
self::VERIFIED => 'Terverifikasi',
self::REJECTED => 'Ditolak',
};
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\StokOpnameRejectRequest;
use App\Http\Requests\Admin\Manage\StokOpnameRequest;
use App\Http\Requests\Admin\Manage\StokOpnameSubmitRequest;
use App\Http\Requests\Admin\Manage\StokOpnameVerifyRequest;
use App\Models\StokOpname;
use App\Services\Manage\StokOpnameService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class StokOpnameController extends Controller
{
use FlashesEntityMessage, ParsesDataTableQuery;
public function __construct(
private readonly StokOpnameService $stokOpnameService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
return Inertia::render('admin/manage/stok-opnames/Index', [
'stokOpnames' => $this->stokOpnameService->paginateForIndex($tableQuery),
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function create(): Response
{
return Inertia::render('admin/manage/stok-opnames/Create', [
'catalog' => $this->stokOpnameService->catalogItems(),
]);
}
public function store(StokOpnameRequest $request): RedirectResponse
{
$this->stokOpnameService->create($request->validated(), $request->user());
$this->flashCreated('Stok Opname');
return redirect()->route('admin.manage.stok-opnames.index');
}
/**
* Auto-save draft via AJAX (creates or updates silently).
*/
public function autoSave(Request $request): \Illuminate\Http\JsonResponse
{
$user = $request->user();
if (! $user->can(\App\Enums\Permission::STOK_OPNAMES_CREATE->value) &&
! $user->can(\App\Enums\Permission::STOK_OPNAMES_UPDATE->value)) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$validated = $request->validate([
'stok_opname_id' => ['nullable', 'integer', 'exists:stok_opnames,id'],
'opname_date' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:1000'],
'items' => ['nullable', 'array'],
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
'items.*.physical_stock' => ['nullable', 'integer', 'min:0'],
'items.*.notes' => ['nullable', 'string', 'max:500'],
]);
$stokOpname = $this->stokOpnameService->autoSave($validated, $user);
return response()->json([
'stok_opname_id' => $stokOpname->id,
'message' => 'Draft tersimpan otomatis.',
]);
}
public function edit(StokOpname $stokOpname): Response
{
return Inertia::render('admin/manage/stok-opnames/Edit', [
'stokOpname' => $this->stokOpnameService->findForEdit($stokOpname),
'catalog' => $this->stokOpnameService->catalogItems(),
]);
}
public function update(StokOpnameRequest $request, StokOpname $stokOpname): RedirectResponse
{
$this->stokOpnameService->update($stokOpname, $request->validated());
$this->flashUpdated('Stok Opname');
return redirect()->route('admin.manage.stok-opnames.index');
}
public function destroy(StokOpname $stokOpname): RedirectResponse
{
$this->stokOpnameService->delete($stokOpname);
$this->flashDeleted('Stok Opname');
return redirect()->route('admin.manage.stok-opnames.index');
}
public function submit(StokOpnameSubmitRequest $request, StokOpname $stokOpname): RedirectResponse
{
$this->stokOpnameService->submit($stokOpname, $request->user());
$this->flashSuccess('Stok opname berhasil diajukan untuk verifikasi.');
return redirect()->route('admin.manage.stok-opnames.index');
}
public function verify(StokOpnameVerifyRequest $request, StokOpname $stokOpname): RedirectResponse
{
$this->stokOpnameService->verify(
$stokOpname,
$request->user(),
$request->validated('verification_notes'),
);
$this->flashSuccess('Stok opname berhasil diverifikasi dan stok telah disesuaikan.');
return redirect()->route('admin.manage.stok-opnames.index');
}
public function reject(StokOpnameRejectRequest $request, StokOpname $stokOpname): RedirectResponse
{
$this->stokOpnameService->reject(
$stokOpname,
$request->user(),
$request->validated('reason'),
);
$this->flashSuccess('Stok opname berhasil ditolak.');
return redirect()->route('admin.manage.stok-opnames.index');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class StokOpnameRejectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can(Permission::STOK_OPNAMES_VERIFY->value);
}
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:1000'],
];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class StokOpnameRequest extends FormRequest
{
public function authorize(): bool
{
if ($this->route('stokOpname')) {
return $this->user()->can(Permission::STOK_OPNAMES_UPDATE->value);
}
return $this->user()->can(Permission::STOK_OPNAMES_CREATE->value);
}
public function rules(): array
{
return [
'opname_date' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
'items.*.physical_stock' => ['required', 'integer', 'min:0'],
'items.*.notes' => ['nullable', 'string', 'max:500'],
];
}
public function messages(): array
{
return [
'opname_date.required' => 'Tanggal opname wajib diisi.',
'items.required' => 'Minimal 1 item stok opname harus ditambahkan.',
'items.min' => 'Minimal 1 item stok opname harus ditambahkan.',
'items.*.product_variant_id.required' => 'Varian produk wajib dipilih.',
'items.*.product_variant_id.exists' => 'Varian produk tidak valid.',
'items.*.physical_stock.required' => 'Stok fisik wajib diisi.',
'items.*.physical_stock.min' => 'Stok fisik tidak boleh negatif.',
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class StokOpnameSubmitRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can(Permission::STOK_OPNAMES_SUBMIT->value);
}
public function rules(): array
{
return [];
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class StokOpnameVerifyRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can(Permission::STOK_OPNAMES_VERIFY->value);
}
public function rules(): array
{
return [
'verification_notes' => ['nullable', 'string', 'max:1000'],
];
}
}

87
app/Models/StokOpname.php Normal file
View File

@ -0,0 +1,87 @@
<?php
namespace App\Models;
use App\Enums\StokOpnameStatus;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['status_label', 'opname_date_formatted', 'created_at_formatted', 'created_by_name'])]
class StokOpname extends Model
{
use HasFactory, InteractsWithActivityLog, SoftDeletes;
protected function casts(): array
{
return [
'opname_date' => 'date',
'status' => StokOpnameStatus::class,
];
}
public function items(): HasMany
{
return $this->hasMany(StokOpnameItem::class);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_id');
}
public function verifiedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by_id');
}
public function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status->label(),
);
}
public function opnameDateFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->opname_date?->translatedFormat('l, d F Y'),
);
}
public function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function createdByName(): Attribute
{
return Attribute::make(
get: fn () => $this->createdBy?->profile?->full_name ?? $this->createdBy?->username,
);
}
public function scopeDraft($query)
{
return $query->where('status', StokOpnameStatus::DRAFT);
}
public function scopePending($query)
{
return $query->where('status', StokOpnameStatus::PENDING);
}
public function scopeVerified($query)
{
return $query->where('status', StokOpnameStatus::VERIFIED);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class StokOpnameItem extends Model
{
use HasFactory, InteractsWithActivityLog;
protected function casts(): array
{
return [
'system_stock' => 'integer',
'physical_stock' => 'integer',
'difference' => 'integer',
];
}
public function stokOpname(): BelongsTo
{
return $this->belongsTo(StokOpname::class);
}
public function productVariant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class);
}
}

View File

@ -0,0 +1,397 @@
<?php
namespace App\Services\Manage;
use App\Enums\StokOpnameStatus;
use App\Models\Product;
use App\Models\StokOpname;
use App\Models\User;
use App\Services\System\PushNotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class StokOpnameService
{
public function __construct(
private readonly PushNotificationService $pushNotificationService,
) {}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = StokOpname::query()
->with(['createdBy.profile', 'verifiedBy.profile'])
->withCount(['items', 'items as items_with_difference_count' => function (Builder $query): void {
$query->where('difference', '!=', 0);
}])
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('notes', 'like', "%{$search}%")
->orWhereHas('createdBy.profile', function (Builder $query) use ($search): void {
$query->where('full_name', 'like', "%{$search}%");
});
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString();
}
/**
* Get product variants with current stock for the opname form.
*/
public function catalogItems(): array
{
return Product::query()
->active()
->with([
'variants' => fn ($query) => $query
->select('id', 'product_id', 'name', 'stock')
->orderBy('created_at'),
])
->orderBy('name')
->get()
->map(fn (Product $product) => [
'id' => $product->id,
'name' => $product->name,
'variants' => $product->variants->map(fn ($variant) => [
'id' => $variant->id,
'name' => $variant->name,
'stock' => $variant->stock,
]),
])
->toArray();
}
/**
* Get stok opname with items for editing.
*/
public function findForEdit(StokOpname $stokOpname): array
{
$stokOpname->load(['items.productVariant.product', 'createdBy.profile']);
return [
'id' => $stokOpname->id,
'opname_date' => $stokOpname->opname_date->format('Y-m-d'),
'status' => $stokOpname->status->value,
'status_label' => $stokOpname->status_label,
'notes' => $stokOpname->notes,
'verification_notes' => $stokOpname->verification_notes,
'created_by_name' => $stokOpname->created_by_name,
'created_at_formatted' => $stokOpname->created_at_formatted,
'items' => $stokOpname->items->map(fn ($item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'variant_name' => $item->productVariant->name,
'product_name' => $item->productVariant->product->name,
'system_stock' => $item->system_stock,
'physical_stock' => $item->physical_stock,
'difference' => $item->difference,
'notes' => $item->notes,
]),
];
}
/**
* @param array{opname_date: string, notes: string|null, items: list<array{product_variant_id: int, physical_stock: int, notes: string|null}>} $validated
*/
public function create(array $validated, User $user): StokOpname
{
try {
return DB::transaction(function () use ($validated, $user): StokOpname {
$stokOpname = StokOpname::create([
'opname_date' => $validated['opname_date'],
'notes' => $validated['notes'],
'status' => StokOpnameStatus::DRAFT,
'created_by_id' => $user->id,
]);
$this->syncItems($stokOpname, $validated['items'] ?? []);
return $stokOpname;
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat stok opname: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
/**
* @param array{opname_date: string, notes: string|null, items: list<array{product_variant_id: int, physical_stock: int, notes: string|null}>} $validated
*/
public function update(StokOpname $stokOpname, array $validated): void
{
if ($stokOpname->status !== StokOpnameStatus::DRAFT && $stokOpname->status !== StokOpnameStatus::REJECTED) {
throw ValidationException::withMessages([
'status' => 'Hanya stok opname dengan status draft atau ditolak yang dapat diubah.',
]);
}
try {
DB::transaction(function () use ($stokOpname, $validated): void {
$stokOpname->update([
'opname_date' => $validated['opname_date'],
'notes' => $validated['notes'],
'status' => StokOpnameStatus::DRAFT,
]);
$this->syncItems($stokOpname, $validated['items'] ?? []);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui stok opname: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
public function delete(StokOpname $stokOpname): void
{
if ($stokOpname->status !== StokOpnameStatus::DRAFT && $stokOpname->status !== StokOpnameStatus::REJECTED) {
throw ValidationException::withMessages([
'status' => 'Hanya stok opname dengan status draft atau ditolak yang dapat dihapus.',
]);
}
try {
DB::transaction(function () use ($stokOpname): void {
$stokOpname->items()->delete();
$stokOpname->delete();
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus stok opname: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
/**
* Submit stok opname for verification.
*/
public function submit(StokOpname $stokOpname, User $user): void
{
if ($stokOpname->status !== StokOpnameStatus::DRAFT && $stokOpname->status !== StokOpnameStatus::REJECTED) {
throw ValidationException::withMessages([
'status' => 'Hanya stok opname dengan status draft atau ditolak yang dapat diajukan.',
]);
}
if ($stokOpname->items()->count() === 0) {
throw ValidationException::withMessages([
'items' => 'Stok opname harus memiliki minimal 1 item.',
]);
}
try {
DB::transaction(function () use ($stokOpname): void {
$stokOpname->update([
'status' => StokOpnameStatus::PENDING,
]);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengajukan stok opname: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles(
'📋 Stok Opname Menunggu Verifikasi',
"Stok opname tanggal {$stokOpname->opname_date->format('d/m/Y')} oleh {$stokOpname->created_by_name} menunggu verifikasi.",
['owner', 'developer', 'admin-toko'],
route('admin.manage.stok-opnames.index'),
);
}
/**
* Verify stok opname - apply stock adjustments.
*/
public function verify(StokOpname $stokOpname, User $user, ?string $verificationNotes = null): void
{
if ($stokOpname->status !== StokOpnameStatus::PENDING) {
throw ValidationException::withMessages([
'status' => 'Hanya stok opname yang menunggu verifikasi yang dapat diverifikasi.',
]);
}
try {
DB::transaction(function () use ($stokOpname, $user, $verificationNotes): void {
foreach ($stokOpname->items as $item) {
if ($item->difference !== 0) {
$item->productVariant()->update([
'stock' => $item->physical_stock,
]);
}
}
$stokOpname->update([
'status' => StokOpnameStatus::VERIFIED,
'verified_by_id' => $user->id,
'verification_notes' => $verificationNotes,
]);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memverifikasi stok opname: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToUser(
'✅ Stok Opname Terverifikasi',
"Stok opname tanggal {$stokOpname->opname_date->format('d/m/Y')} telah diverifikasi dan stok telah disesuaikan.",
$stokOpname->created_by_id,
route('admin.manage.stok-opnames.index'),
);
}
/**
* Reject stok opname.
*/
public function reject(StokOpname $stokOpname, User $user, string $reason): void
{
if ($stokOpname->status !== StokOpnameStatus::PENDING) {
throw ValidationException::withMessages([
'status' => 'Hanya stok opname yang menunggu verifikasi yang dapat ditolak.',
]);
}
try {
DB::transaction(function () use ($stokOpname, $user, $reason): void {
$stokOpname->update([
'status' => StokOpnameStatus::REJECTED,
'verified_by_id' => $user->id,
'verification_notes' => $reason,
]);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menolak stok opname: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToUser(
'❌ Stok Opname Ditolak',
"Stok opname tanggal {$stokOpname->opname_date->format('d/m/Y')} ditolak dengan alasan: {$reason}",
$stokOpname->created_by_id,
route('admin.manage.stok-opnames.index'),
);
}
/**
* Auto-save: create or update a draft silently.
*
* @param array{stok_opname_id?: int|null, opname_date: string, notes: string|null, items: list<array{product_variant_id: int, physical_stock: int|null, notes: string|null}>|null} $validated
*/
public function autoSave(array $validated, User $user): StokOpname
{
try {
return DB::transaction(function () use ($validated, $user): StokOpname {
$stokOpname = null;
if (! empty($validated['stok_opname_id'])) {
$stokOpname = StokOpname::find($validated['stok_opname_id']);
}
if ($stokOpname && in_array($stokOpname->status, [StokOpnameStatus::DRAFT, StokOpnameStatus::REJECTED], true)) {
$stokOpname->update([
'opname_date' => $validated['opname_date'],
'notes' => $validated['notes'],
'status' => StokOpnameStatus::DRAFT,
]);
} else {
$stokOpname = StokOpname::create([
'opname_date' => $validated['opname_date'],
'notes' => $validated['notes'],
'status' => StokOpnameStatus::DRAFT,
'created_by_id' => $user->id,
]);
}
$this->syncItems($stokOpname, $validated['items'] ?? []);
return $stokOpname;
});
} catch (\Throwable $e) {
Log::error('Gagal auto-save stok opname: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
/**
* @param list<array{product_variant_id: int, physical_stock: int, notes: string|null}> $items
*/
private function syncItems(StokOpname $stokOpname, array $items): void
{
$stokOpname->items()->delete();
foreach ($items as $item) {
$systemStock = \App\Models\ProductVariant::find($item['product_variant_id'])?->stock ?? 0;
$stokOpname->items()->create([
'product_variant_id' => $item['product_variant_id'],
'system_stock' => $systemStock,
'physical_stock' => $item['physical_stock'],
'difference' => $item['physical_stock'] - $systemStock,
'notes' => $item['notes'] ?? null,
]);
}
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['opname_date', 'status', 'created_at'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -0,0 +1,42 @@
<?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('stok_opnames', function (Blueprint $table): void {
$table->id();
$table->date('opname_date');
$table->enum('status', ['draft', 'pending', 'verified', 'rejected'])->default('draft');
$table->text('notes')->nullable();
$table->text('verification_notes')->nullable();
$table->foreignId('created_by_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('verified_by_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->softDeletes();
});
Schema::create('stok_opname_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('stok_opname_id')->constrained('stok_opnames')->cascadeOnDelete();
$table->foreignId('product_variant_id')->constrained('product_variants')->cascadeOnDelete();
$table->unsignedInteger('system_stock')->default(0);
$table->unsignedInteger('physical_stock')->default(0);
$table->integer('difference')->default(0);
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['stok_opname_id', 'product_variant_id']);
});
}
public function down(): void
{
Schema::dropIfExists('stok_opname_items');
Schema::dropIfExists('stok_opnames');
}
};

View File

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

View File

@ -0,0 +1,285 @@
<script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CatalogProduct } from '@/types/stok-opname';
import { Head, router } from '@inertiajs/vue3';
import { useDebounceFn } from '@vueuse/core';
import { CheckCircle, Loader2, Send } from '@lucide/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { index, auto_save, submit } from '@/routes/admin/manage/stok-opnames';
const props = defineProps<{
catalog: CatalogProduct[];
}>();
const opnameDate = ref(new Date().toISOString().split('T')[0]);
const notes = ref('');
const stokOpnameId = ref<number | null>(null);
const saving = ref(false);
const lastSaved = ref<string | null>(null);
const submitting = ref(false);
// Flatten all variants into a list
interface VariantRow {
product_name: string;
variant_id: number;
variant_name: string;
system_stock: number;
physical_stock: number;
notes: string;
}
const variantRows = ref<VariantRow[]>([]);
onMounted(() => {
const rows: VariantRow[] = [];
for (const product of props.catalog) {
for (const variant of product.variants) {
rows.push({
product_name: product.name,
variant_id: variant.id,
variant_name: variant.name,
system_stock: variant.stock,
physical_stock: 0,
notes: '',
});
}
}
variantRows.value = rows;
});
const itemsPayload = computed(() =>
variantRows.value
.filter((row) => row.physical_stock > 0 || row.notes.trim() !== '')
.map((row) => ({
product_variant_id: row.variant_id,
physical_stock: row.physical_stock,
notes: row.notes || null,
}))
);
interface ProductGroup {
product_name: string;
rows: VariantRow[];
startIndex: number;
}
const groupedProducts = computed<ProductGroup[]>(() => {
const groups: ProductGroup[] = [];
let currentProduct = '';
let currentGroup: ProductGroup | null = null;
let idx = 0;
for (const row of variantRows.value) {
if (row.product_name !== currentProduct) {
currentProduct = row.product_name;
currentGroup = { product_name: row.product_name, rows: [], startIndex: idx };
groups.push(currentGroup);
}
currentGroup!.rows.push(row);
idx++;
}
return groups;
});
const autoSave = useDebounceFn(async () => {
if (variantRows.value.length === 0) return;
saving.value = true;
try {
const response = await fetch(auto_save.url(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '',
},
body: JSON.stringify({
stok_opname_id: stokOpnameId.value,
opname_date: opnameDate.value,
notes: notes.value || null,
items: itemsPayload.value,
}),
});
if (response.ok) {
const data = await response.json();
stokOpnameId.value = data.stok_opname_id;
lastSaved.value = new Date().toLocaleTimeString('id-ID');
}
} catch (e) {
console.error('Auto-save failed:', e);
} finally {
saving.value = false;
}
}, 1000);
watch([opnameDate, notes], () => autoSave());
function onPhysicalStockChange() {
autoSave();
}
function submitForVerification() {
if (!stokOpnameId.value) return;
submitting.value = true;
router.post(submit.url(stokOpnameId.value), {}, {
onFinish: () => {
submitting.value = false;
},
});
}
</script>
<template>
<Head title="Tambah Stok Opname" />
<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">Stok Opname</h2>
<p class="text-muted-foreground text-sm">
Hitung stok fisik gudang dan bandingkan dengan stok sistem
</p>
</div>
<div class="flex items-center gap-3">
<div v-if="saving" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<Loader2 class="size-3.5 animate-spin" />
Menyimpan...
</div>
<div v-else-if="lastSaved" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<CheckCircle class="size-3.5 text-green-600" />
Tersimpan {{ lastSaved }}
</div>
<BackButton :href="index.url()" />
</div>
</div>
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informasi Dasar</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="opname_date">Tanggal Opname</Label>
<DatePicker
id="opname_date"
v-model="opnameDate"
placeholder="Pilih tanggal opname"
/>
</div>
</div>
<div class="space-y-2">
<Label for="notes">Catatan</Label>
<Textarea
id="notes"
v-model="notes"
placeholder="Catatan stok opname (opsional)"
rows="2"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Daftar Produk</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-2">
<div
v-for="(group, gIdx) in groupedProducts"
:key="group.product_name"
class="overflow-hidden rounded-md border"
>
<div class="bg-muted/60 border-b px-4 py-2.5">
<span class="text-sm font-semibold">{{ group.product_name }}</span>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b">
<th class="text-muted-foreground h-9 w-12 px-4 text-center text-xs font-medium">No.</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Varian</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Sistem</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Fisik</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Selisih</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Catatan</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rIdx) in group.rows"
:key="row.variant_id"
class="border-b transition-colors last:border-b-0 hover:bg-muted/30"
>
<td class="text-muted-foreground p-3 text-center">{{ group.startIndex + rIdx + 1 }}</td>
<td class="p-3">{{ row.variant_name }}</td>
<td class="p-3 text-right tabular-nums">{{ row.system_stock }}</td>
<td class="p-3 text-right">
<Input
v-model.number="row.physical_stock"
type="number"
min="0"
class="ml-auto w-24 text-right tabular-nums"
@input="onPhysicalStockChange"
/>
</td>
<td class="p-3 text-right tabular-nums">
<span
:class="{
'text-green-600 font-semibold': row.physical_stock - row.system_stock > 0,
'text-red-600 font-semibold': row.physical_stock - row.system_stock < 0,
'text-muted-foreground': row.physical_stock - row.system_stock === 0,
}"
>
{{ row.physical_stock - row.system_stock > 0 ? '+' : '' }}{{ row.physical_stock - row.system_stock }}
</span>
</td>
<td class="p-3">
<Input
v-model="row.notes"
placeholder="Catatan..."
class="w-full min-w-[120px]"
@input="onPhysicalStockChange"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-if="variantRows.length === 0"
class="text-muted-foreground py-8 text-center"
>
Tidak ada produk aktif.
</div>
</CardContent>
</Card>
<div class="flex justify-end gap-3">
<BackButton :href="index.url()" label="Kembali" />
<Button
:disabled="!stokOpnameId || itemsPayload.length === 0 || submitting"
@click="submitForVerification"
>
<Send v-if="!submitting" class="mr-1.5 size-4" />
<Loader2 v-else class="mr-1.5 size-4 animate-spin" />
{{ submitting ? 'Mengajukan...' : 'Ajukan Verifikasi' }}
</Button>
</div>
</div>
</AdminLayout>
</template>

View File

@ -0,0 +1,310 @@
<script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CatalogProduct, StokOpnameDetail } from '@/types/stok-opname';
import { Head, router } from '@inertiajs/vue3';
import { useDebounceFn } from '@vueuse/core';
import { CheckCircle, Loader2, Send } from '@lucide/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { index, auto_save, submit } from '@/routes/admin/manage/stok-opnames';
const props = defineProps<{
stokOpname: StokOpnameDetail;
catalog: CatalogProduct[];
}>();
const opnameDate = ref(props.stokOpname.opname_date);
const notes = ref(props.stokOpname.notes ?? '');
const stokOpnameId = ref<number>(props.stokOpname.id);
const saving = ref(false);
const lastSaved = ref<string | null>(null);
const submitting = ref(false);
interface VariantRow {
product_name: string;
variant_id: number;
variant_name: string;
system_stock: number;
physical_stock: number;
notes: string;
}
const variantRows = ref<VariantRow[]>([]);
onMounted(() => {
const rows: VariantRow[] = [];
// Build a map of existing items
const existingItems = new Map<number, { physical_stock: number; notes: string }>();
for (const item of props.stokOpname.items) {
existingItems.set(item.product_variant_id, {
physical_stock: item.physical_stock,
notes: item.notes ?? '',
});
}
for (const product of props.catalog) {
for (const variant of product.variants) {
const existing = existingItems.get(variant.id);
rows.push({
product_name: product.name,
variant_id: variant.id,
variant_name: variant.name,
system_stock: variant.stock,
physical_stock: existing?.physical_stock ?? 0,
notes: existing?.notes ?? '',
});
}
}
variantRows.value = rows;
});
const itemsPayload = computed(() =>
variantRows.value
.filter((row) => row.physical_stock > 0 || row.notes.trim() !== '')
.map((row) => ({
product_variant_id: row.variant_id,
physical_stock: row.physical_stock,
notes: row.notes || null,
}))
);
interface ProductGroup {
product_name: string;
rows: VariantRow[];
startIndex: number;
}
const groupedProducts = computed<ProductGroup[]>(() => {
const groups: ProductGroup[] = [];
let currentProduct = '';
let currentGroup: ProductGroup | null = null;
let idx = 0;
for (const row of variantRows.value) {
if (row.product_name !== currentProduct) {
currentProduct = row.product_name;
currentGroup = { product_name: row.product_name, rows: [], startIndex: idx };
groups.push(currentGroup);
}
currentGroup!.rows.push(row);
idx++;
}
return groups;
});
const autoSave = useDebounceFn(async () => {
if (variantRows.value.length === 0) return;
saving.value = true;
try {
const response = await fetch(auto_save.url(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '',
},
body: JSON.stringify({
stok_opname_id: stokOpnameId.value,
opname_date: opnameDate.value,
notes: notes.value || null,
items: itemsPayload.value,
}),
});
if (response.ok) {
const data = await response.json();
stokOpnameId.value = data.stok_opname_id;
lastSaved.value = new Date().toLocaleTimeString('id-ID');
}
} catch (e) {
console.error('Auto-save failed:', e);
} finally {
saving.value = false;
}
}, 1000);
watch([opnameDate, notes], () => autoSave());
function onPhysicalStockChange() {
autoSave();
}
function submitForVerification() {
if (!stokOpnameId.value) return;
submitting.value = true;
router.post(submit.url(stokOpnameId.value), {}, {
onFinish: () => {
submitting.value = false;
},
});
}
</script>
<template>
<Head title="Ubah Stok Opname" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<h2 class="text-2xl font-bold tracking-tight">Stok Opname</h2>
<Badge
:variant="
stokOpname.status === 'verified' ? 'default' :
stokOpname.status === 'pending' ? 'outline' :
stokOpname.status === 'rejected' ? 'destructive' : 'secondary'
"
>
{{ stokOpname.status_label }}
</Badge>
</div>
<p class="text-muted-foreground text-sm">
Dibuat oleh {{ stokOpname.created_by_name }} pada {{ stokOpname.created_at_formatted }}
</p>
</div>
<div class="flex items-center gap-3">
<div v-if="saving" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<Loader2 class="size-3.5 animate-spin" />
Menyimpan...
</div>
<div v-else-if="lastSaved" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<CheckCircle class="size-3.5 text-green-600" />
Tersimpan {{ lastSaved }}
</div>
<BackButton :href="index.url()" />
</div>
</div>
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informasi Dasar</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="opname_date">Tanggal Opname</Label>
<DatePicker
id="opname_date"
v-model="opnameDate"
placeholder="Pilih tanggal opname"
/>
</div>
</div>
<div class="space-y-2">
<Label for="notes">Catatan</Label>
<Textarea
id="notes"
v-model="notes"
placeholder="Catatan stok opname (opsional)"
rows="2"
/>
</div>
<div
v-if="stokOpname.verification_notes"
class="rounded-lg border bg-muted/50 p-4"
>
<Label class="text-sm font-medium">Catatan Verifikasi</Label>
<p class="mt-1 text-sm">{{ stokOpname.verification_notes }}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Daftar Produk</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-2">
<div
v-for="(group, gIdx) in groupedProducts"
:key="group.product_name"
class="overflow-hidden rounded-md border"
>
<div class="bg-muted/60 border-b px-4 py-2.5">
<span class="text-sm font-semibold">{{ group.product_name }}</span>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b">
<th class="text-muted-foreground h-9 w-12 px-4 text-center text-xs font-medium">No.</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Varian</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Sistem</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Fisik</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Selisih</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Catatan</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rIdx) in group.rows"
:key="row.variant_id"
class="border-b transition-colors last:border-b-0 hover:bg-muted/30"
>
<td class="text-muted-foreground p-3 text-center">{{ group.startIndex + rIdx + 1 }}</td>
<td class="p-3">{{ row.variant_name }}</td>
<td class="p-3 text-right tabular-nums">{{ row.system_stock }}</td>
<td class="p-3 text-right">
<Input
v-model.number="row.physical_stock"
type="number"
min="0"
class="ml-auto w-24 text-right tabular-nums"
@input="onPhysicalStockChange"
/>
</td>
<td class="p-3 text-right tabular-nums">
<span
:class="{
'text-green-600 font-semibold': row.physical_stock - row.system_stock > 0,
'text-red-600 font-semibold': row.physical_stock - row.system_stock < 0,
'text-muted-foreground': row.physical_stock - row.system_stock === 0,
}"
>
{{ row.physical_stock - row.system_stock > 0 ? '+' : '' }}{{ row.physical_stock - row.system_stock }}
</span>
</td>
<td class="p-3">
<Input
v-model="row.notes"
placeholder="Catatan..."
class="w-full min-w-[120px]"
@input="onPhysicalStockChange"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
<div class="flex justify-end gap-3">
<BackButton :href="index.url()" label="Kembali" />
<Button
v-if="stokOpname.status === 'draft' || stokOpname.status === 'rejected'"
:disabled="itemsPayload.length === 0 || submitting"
@click="submitForVerification"
>
<Send v-if="!submitting" class="mr-1.5 size-4" />
<Loader2 v-else class="mr-1.5 size-4 animate-spin" />
{{ submitting ? 'Mengajukan...' : 'Ajukan Verifikasi' }}
</Button>
</div>
</div>
</AdminLayout>
</template>

View File

@ -0,0 +1,183 @@
<script setup lang="ts">
import CreateButton from '@/components/button/CreateButton.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import {
useDataTableQuery,
useDataTableQuerySync,
} from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableSort } from '@/types/data-table';
import type { PaginatedStokOpnames, StokOpnameListItem } from '@/types/stok-opname';
import { Head, router } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { createColumns } from './table/columns';
import { index, create, edit, submit, verify, reject, destroy } from '@/routes/admin/manage/stok-opnames';
const props = defineProps<{
stokOpnames: PaginatedStokOpnames;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const submitDialogOpen = ref(false);
const verifyDialogOpen = ref(false);
const rejectDialogOpen = ref(false);
const selectedStokOpname = ref<StokOpnameListItem | null>(null);
const rejectReason = ref('');
const { query, setSearch, setSort, resetFilters, syncFromServer } =
useDataTableQuery({
url: index.url(),
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
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.stokOpnames.current_page,
perPage: props.stokOpnames.per_page,
lastPage: props.stokOpnames.last_page,
total: props.stokOpnames.total,
}));
function openEdit(item: StokOpnameListItem) {
router.get(edit.url(item.id));
}
function openSubmitDialog(item: StokOpnameListItem) {
selectedStokOpname.value = item;
submitDialogOpen.value = true;
}
function openVerifyDialog(item: StokOpnameListItem) {
selectedStokOpname.value = item;
verifyDialogOpen.value = true;
}
function openRejectDialog(item: StokOpnameListItem) {
selectedStokOpname.value = item;
rejectReason.value = '';
rejectDialogOpen.value = true;
}
function confirmSubmit() {
if (!selectedStokOpname.value) return;
router.post(submit.url(selectedStokOpname.value.id));
submitDialogOpen.value = false;
}
function confirmVerify() {
if (!selectedStokOpname.value) return;
router.post(verify.url(selectedStokOpname.value.id));
verifyDialogOpen.value = false;
}
function confirmReject() {
if (!selectedStokOpname.value || !rejectReason.value.trim()) return;
router.post(reject.url(selectedStokOpname.value.id), {
reason: rejectReason.value,
});
rejectDialogOpen.value = false;
}
const columns = computed(() =>
createColumns(openEdit, openSubmitDialog, openVerifyDialog, openRejectDialog)
);
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Stok Opname" />
<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">Stok Opname</h2>
<p class="text-muted-foreground text-sm">
Kelola stok opname inventaris gudang
</p>
</div>
<CreateButton
v-if="can('stok_opnames.create')"
:href="create.url()"
/>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable
v-model:search="search"
:columns="columns"
:data="stokOpnames.data"
:pagination="pagination"
:pagination-links="stokOpnames.links"
:sort="currentSort"
@sort-change="setSort"
@filters-reset="resetFilters"
/>
</CardContent>
</Card>
<ConfirmDialog
v-model:open="submitDialogOpen"
title="Ajukan Verifikasi"
description="Stok opname akan diajukan untuk diverifikasi. Lanjutkan?"
confirm-label="Ajukan"
@confirm="confirmSubmit"
/>
<ConfirmDialog
v-model:open="verifyDialogOpen"
title="Verifikasi Stok Opname"
description="Stok opname akan diverifikasi dan stok gudang akan disesuaikan. Lanjutkan?"
confirm-label="Verifikasi"
@confirm="confirmVerify"
/>
<ConfirmDialog
v-model:open="rejectDialogOpen"
title="Tolak Stok Opname"
description="Masukkan alasan penolakan:"
confirm-label="Tolak"
@confirm="confirmReject"
>
<template #content>
<textarea
v-model="rejectReason"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Alasan penolakan..."
/>
</template>
</ConfirmDialog>
</AdminLayout>
</template>

View File

@ -0,0 +1,76 @@
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 { StokOpnameListItem, StokOpnameStatus } from '@/types/stok-opname';
import DataTableActions from './data-table-actions.vue';
import { cn } from '@/lib/utils';
const statusVariant: Record<StokOpnameStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
draft: 'secondary',
pending: 'outline',
verified: 'default',
rejected: 'destructive',
};
export function createColumns(
onEdit: (item: StokOpnameListItem) => void,
onSubmit: (item: StokOpnameListItem) => void,
onVerify: (item: StokOpnameListItem) => void,
onReject: (item: StokOpnameListItem) => void,
): ColumnDef<StokOpnameListItem>[] {
return [
{
accessorKey: 'opname_date_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Tanggal', column: 'opname_date' }),
},
{
accessorKey: 'status_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
cell: ({ row }) => h(Badge, {
variant: statusVariant[row.original.status],
class: cn(row.original.status === 'verified' && 'bg-green-600 text-white'),
}, () => row.original.status_label),
},
{
accessorKey: 'items_count',
enableSorting: false,
header: () => 'Jumlah Item',
cell: ({ row }) => `${row.original.items_count} item`,
},
{
accessorKey: 'items_with_difference_count',
enableSorting: false,
header: () => 'Selisih',
cell: ({ row }) => {
const count = row.original.items_with_difference_count;
if (count === 0) return '-';
return h(Badge, { variant: 'destructive' }, () => `${count} selisih`);
},
},
{
accessorKey: 'created_by_name',
enableSorting: false,
header: () => 'Dibuat Oleh',
},
{
accessorKey: 'created_at_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Waktu', column: 'created_at' }),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => h(DataTableActions, {
stokOpname: row.original,
onEdit: () => onEdit(row.original),
onSubmit: () => onSubmit(row.original),
onVerify: () => onVerify(row.original),
onReject: () => onReject(row.original),
}),
},
];
}

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button';
import { Button } from '@/components/ui/button';
import { useCan } from '@/composables/useCan';
import type { StokOpnameListItem } from '@/types/stok-opname';
import { Send, CheckCircle, XCircle } from '@lucide/vue';
import { destroy } from '@/routes/admin/manage/stok-opnames';
defineProps<{
stokOpname: StokOpnameListItem;
}>();
const emit = defineEmits<{
edit: [];
submit: [];
verify: [];
reject: [];
}>();
const { can } = useCan();
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Button
v-if="can('stok_opnames.submit') && (stokOpname.status === 'draft' || stokOpname.status === 'rejected')"
variant="ghost"
size="icon"
class="size-8 text-blue-600"
title="Ajukan Verifikasi"
@click="emit('submit')"
>
<Send class="size-4" />
</Button>
<Button
v-if="can('stok_opnames.verify') && stokOpname.status === 'pending'"
variant="ghost"
size="icon"
class="size-8 text-green-600"
title="Verifikasi"
@click="emit('verify')"
>
<CheckCircle class="size-4" />
</Button>
<Button
v-if="can('stok_opnames.verify') && stokOpname.status === 'pending'"
variant="ghost"
size="icon"
class="size-8 text-red-600"
title="Tolak"
@click="emit('reject')"
>
<XCircle class="size-4" />
</Button>
<RowEditAction
v-if="can('stok_opnames.update') && (stokOpname.status === 'draft' || stokOpname.status === 'rejected')"
@click="emit('edit')"
/>
<RowDeleteAction
v-if="can('stok_opnames.delete') && (stokOpname.status === 'draft' || stokOpname.status === 'rejected')"
:action-url="destroy.url(stokOpname.id)"
title="Hapus stok opname?"
description="Data stok opname ini akan dihapus permanen."
/>
</div>
</template>

View File

@ -0,0 +1,73 @@
export type StokOpnameStatus = 'draft' | 'pending' | 'verified' | 'rejected';
export type StokOpnameListItem = {
id: number;
opname_date: string;
opname_date_formatted: string;
status: StokOpnameStatus;
status_label: string;
notes: string | null;
verification_notes: string | null;
created_by_name: string;
created_at_formatted: string;
items_count: number;
items_with_difference_count: number;
};
export type StokOpnameItem = {
id: number;
product_variant_id: number;
variant_name: string;
product_name: string;
system_stock: number;
physical_stock: number;
difference: number;
notes: string | null;
};
export type StokOpnameDetail = {
id: number;
opname_date: string;
status: StokOpnameStatus;
status_label: string;
notes: string | null;
verification_notes: string | null;
created_by_name: string;
created_at_formatted: string;
items: StokOpnameItem[];
};
export type StokOpnameFormData = {
opname_date: string;
notes: string;
items: Array<{
product_variant_id: number;
physical_stock: number;
notes: string;
}>;
};
export type CatalogVariant = {
id: number;
name: string;
stock: number;
};
export type CatalogProduct = {
id: number;
name: string;
variants: CatalogVariant[];
};
export type PaginatedStokOpnames = {
data: StokOpnameListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};

View File

@ -21,6 +21,7 @@
use App\Http\Controllers\Admin\Manage\OwnerVerificationController;
use App\Http\Controllers\Admin\Manage\PurchaseController;
use App\Http\Controllers\Admin\Manage\PurchaseDraftItemController;
use App\Http\Controllers\Admin\Manage\StokOpnameController;
use App\Http\Controllers\Admin\Manage\StockController;
use App\Http\Controllers\Admin\Manage\StockEcerController;
use App\Http\Controllers\Admin\Master\CategoryController;
@ -362,6 +363,47 @@
Route::post('/transfer', [StockEcerController::class, 'transfer'])->name('transfer');
});
Route::prefix('stok-opnames')->name('stok-opnames.')
->middleware('permission:'.Permission::STOK_OPNAMES_VIEW->value)
->group(function () {
Route::get('/', [StokOpnameController::class, 'index'])->name('index');
Route::get('create', [StokOpnameController::class, 'create'])
->middleware('permission:'.Permission::STOK_OPNAMES_CREATE->value)
->name('create');
Route::post('/', [StokOpnameController::class, 'store'])
->middleware('permission:'.Permission::STOK_OPNAMES_CREATE->value)
->name('store');
Route::post('auto-save', [StokOpnameController::class, 'autoSave'])
->name('auto_save');
Route::get('{stokOpname}/edit', [StokOpnameController::class, 'edit'])
->middleware('permission:'.Permission::STOK_OPNAMES_UPDATE->value)
->name('edit');
Route::put('{stokOpname}', [StokOpnameController::class, 'update'])
->middleware('permission:'.Permission::STOK_OPNAMES_UPDATE->value)
->name('update');
Route::delete('{stokOpname}', [StokOpnameController::class, 'destroy'])
->middleware('permission:'.Permission::STOK_OPNAMES_DELETE->value)
->name('destroy');
Route::post('{stokOpname}/submit', [StokOpnameController::class, 'submit'])
->middleware('permission:'.Permission::STOK_OPNAMES_SUBMIT->value)
->name('submit');
Route::post('{stokOpname}/verify', [StokOpnameController::class, 'verify'])
->middleware('permission:'.Permission::STOK_OPNAMES_VERIFY->value)
->name('verify');
Route::post('{stokOpname}/reject', [StokOpnameController::class, 'reject'])
->middleware('permission:'.Permission::STOK_OPNAMES_VERIFY->value)
->name('reject');
});
Route::prefix('owner-verifications')->name('owner_verifications.')
->group(function () {
Route::post('cuttings/{cutting}/approve', [OwnerVerificationController::class, 'approveCutting'])