diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index e411c44..1d2a6df 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -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', diff --git a/app/Enums/Role.php b/app/Enums/Role.php index fb7f1b9..c967e86 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -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, + ], }; } diff --git a/app/Enums/StokOpnameStatus.php b/app/Enums/StokOpnameStatus.php new file mode 100644 index 0000000..30a2092 --- /dev/null +++ b/app/Enums/StokOpnameStatus.php @@ -0,0 +1,21 @@ + 'Draft', + self::PENDING => 'Menunggu Verifikasi', + self::VERIFIED => 'Terverifikasi', + self::REJECTED => 'Ditolak', + }; + } +} diff --git a/app/Http/Controllers/Admin/Manage/StokOpnameController.php b/app/Http/Controllers/Admin/Manage/StokOpnameController.php new file mode 100644 index 0000000..0e59953 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/StokOpnameController.php @@ -0,0 +1,143 @@ +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'); + } +} diff --git a/app/Http/Requests/Admin/Manage/StokOpnameRejectRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameRejectRequest.php new file mode 100644 index 0000000..22975c3 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/StokOpnameRejectRequest.php @@ -0,0 +1,21 @@ +user()->can(Permission::STOK_OPNAMES_VERIFY->value); + } + + public function rules(): array + { + return [ + 'reason' => ['required', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/StokOpnameRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameRequest.php new file mode 100644 index 0000000..b15617e --- /dev/null +++ b/app/Http/Requests/Admin/Manage/StokOpnameRequest.php @@ -0,0 +1,43 @@ +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.', + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/StokOpnameSubmitRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameSubmitRequest.php new file mode 100644 index 0000000..e93b0a5 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/StokOpnameSubmitRequest.php @@ -0,0 +1,19 @@ +user()->can(Permission::STOK_OPNAMES_SUBMIT->value); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php new file mode 100644 index 0000000..8310eeb --- /dev/null +++ b/app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php @@ -0,0 +1,21 @@ +user()->can(Permission::STOK_OPNAMES_VERIFY->value); + } + + public function rules(): array + { + return [ + 'verification_notes' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Models/StokOpname.php b/app/Models/StokOpname.php new file mode 100644 index 0000000..ca47235 --- /dev/null +++ b/app/Models/StokOpname.php @@ -0,0 +1,87 @@ + '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); + } +} diff --git a/app/Models/StokOpnameItem.php b/app/Models/StokOpnameItem.php new file mode 100644 index 0000000..6198e94 --- /dev/null +++ b/app/Models/StokOpnameItem.php @@ -0,0 +1,34 @@ + 'integer', + 'physical_stock' => 'integer', + 'difference' => 'integer', + ]; + } + + public function stokOpname(): BelongsTo + { + return $this->belongsTo(StokOpname::class); + } + + public function productVariant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } +} diff --git a/app/Services/Manage/StokOpnameService.php b/app/Services/Manage/StokOpnameService.php new file mode 100644 index 0000000..5c80ae4 --- /dev/null +++ b/app/Services/Manage/StokOpnameService.php @@ -0,0 +1,397 @@ +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} $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} $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|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 $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(); + } +} diff --git a/database/migrations/2026_06_28_110000_create_stok_opnames_table.php b/database/migrations/2026_06_28_110000_create_stok_opnames_table.php new file mode 100644 index 0000000..d83a80e --- /dev/null +++ b/database/migrations/2026_06_28_110000_create_stok_opnames_table.php @@ -0,0 +1,42 @@ +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'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index b416de0..8c10968 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + +