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:
parent
3d4745cb0a
commit
a5158c3992
@ -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',
|
||||
|
||||
@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
21
app/Enums/StokOpnameStatus.php
Normal file
21
app/Enums/StokOpnameStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
143
app/Http/Controllers/Admin/Manage/StokOpnameController.php
Normal file
143
app/Http/Controllers/Admin/Manage/StokOpnameController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/Admin/Manage/StokOpnameRejectRequest.php
Normal file
21
app/Http/Requests/Admin/Manage/StokOpnameRejectRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Requests/Admin/Manage/StokOpnameRequest.php
Normal file
43
app/Http/Requests/Admin/Manage/StokOpnameRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Http/Requests/Admin/Manage/StokOpnameSubmitRequest.php
Normal file
19
app/Http/Requests/Admin/Manage/StokOpnameSubmitRequest.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php
Normal file
21
app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php
Normal 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
87
app/Models/StokOpname.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
app/Models/StokOpnameItem.php
Normal file
34
app/Models/StokOpnameItem.php
Normal 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);
|
||||
}
|
||||
}
|
||||
397
app/Services/Manage/StokOpnameService.php
Normal file
397
app/Services/Manage/StokOpnameService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
285
resources/js/pages/admin/manage/stok-opnames/Create.vue
Normal file
285
resources/js/pages/admin/manage/stok-opnames/Create.vue
Normal 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>
|
||||
310
resources/js/pages/admin/manage/stok-opnames/Edit.vue
Normal file
310
resources/js/pages/admin/manage/stok-opnames/Edit.vue
Normal 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>
|
||||
183
resources/js/pages/admin/manage/stok-opnames/Index.vue
Normal file
183
resources/js/pages/admin/manage/stok-opnames/Index.vue
Normal 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>
|
||||
@ -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),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -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>
|
||||
73
resources/js/types/stok-opname.ts
Normal file
73
resources/js/types/stok-opname.ts
Normal 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;
|
||||
}>;
|
||||
};
|
||||
@ -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'])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user