feat: add stock management functionality by introducing StockController and updating AppSidebar to include stock menu item for improved inventory handling
This commit is contained in:
parent
c3c8e9f5bd
commit
e4b475cd82
64
app/Http/Controllers/Admin/Manage/StockController.php
Normal file
64
app/Http/Controllers/Admin/Manage/StockController.php
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\Manage\StockVerifyRequest;
|
||||||
|
use App\Models\Cutting;
|
||||||
|
use App\Services\Manage\StockService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class StockController extends Controller
|
||||||
|
{
|
||||||
|
use FlashesEntityMessage;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly StockService $stockService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return Inertia::render('admin/manage/stocks/Index', [
|
||||||
|
'pendingCuttings' => $this->stockService->getPendingVerificationCuttings($user),
|
||||||
|
'verifiedCuttings' => $this->stockService->getVerifiedCuttings(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(StockVerifyRequest $request, Cutting $cutting): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->stockService->verify(
|
||||||
|
$cutting,
|
||||||
|
$request->user(),
|
||||||
|
$request->validated('verification_note'),
|
||||||
|
$request->validated('results'),
|
||||||
|
$request->validated('result_prices'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->flashSuccess('Cutting berhasil diverifikasi. Stok produk telah ditambahkan ke toko.');
|
||||||
|
|
||||||
|
return redirect()->route('admin.manage.stocks.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(Request $request, Cutting $cutting): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'reason' => ['required', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->stockService->reject(
|
||||||
|
$cutting,
|
||||||
|
$request->user(),
|
||||||
|
$request->validated('reason'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->flashSuccess('Cutting berhasil ditolak dan dikembalikan ke proses.');
|
||||||
|
|
||||||
|
return redirect()->route('admin.manage.stocks.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Http/Requests/Admin/Manage/StockVerifyRequest.php
Normal file
108
app/Http/Requests/Admin/Manage/StockVerifyRequest.php
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\CuttingStatus;
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\PriceType;
|
||||||
|
use App\Models\Cutting;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class StockVerifyRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can(Permission::CUTTINGS_VERIFY->value) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'verification_note' => ['nullable', 'string', 'max:500'],
|
||||||
|
'results' => ['nullable', 'array'],
|
||||||
|
'results.*.product_variant_id' => ['required_with:results', 'integer', 'exists:product_variants,id'],
|
||||||
|
'results.*.warehouse_stock' => ['required_with:results', 'integer', 'min:0'],
|
||||||
|
'results.*.cutting_reject' => ['required_with:results', 'integer', 'min:0'],
|
||||||
|
'result_prices' => ['nullable', 'array'],
|
||||||
|
'result_prices.*.product_variant_id' => ['required_with:result_prices', 'integer', 'exists:product_variants,id'],
|
||||||
|
'result_prices.*.prices' => ['required_with:result_prices', 'array', 'min:1'],
|
||||||
|
'result_prices.*.prices.*.type' => ['required', Rule::enum(PriceType::class)],
|
||||||
|
'result_prices.*.prices.*.price' => ['required', 'integer', 'gt:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function attributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'verification_note' => 'catatan verifikasi',
|
||||||
|
'result_prices' => 'harga jual',
|
||||||
|
'result_prices.*.product_variant_id' => 'varian produk',
|
||||||
|
'result_prices.*.prices' => 'harga jual',
|
||||||
|
'result_prices.*.prices.*.type' => 'tipe harga',
|
||||||
|
'result_prices.*.prices.*.price' => 'harga jual',
|
||||||
|
'results' => 'hasil cutting',
|
||||||
|
'results.*.product_variant_id' => 'varian produk',
|
||||||
|
'results.*.warehouse_stock' => 'stok bagus',
|
||||||
|
'results.*.cutting_reject' => 'stok reject',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $validator): void {
|
||||||
|
/** @var Cutting $cutting */
|
||||||
|
$cutting = $this->route('cutting');
|
||||||
|
|
||||||
|
if ($cutting->status !== CuttingStatus::COMPLETED) {
|
||||||
|
$validator->errors()->add('status', 'Hanya cutting yang sudah selesai yang dapat diverifikasi.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->has('results')) {
|
||||||
|
$cuttingResults = $cutting->results->keyBy('product_variant_id');
|
||||||
|
foreach ($this->input('results', []) as $index => $item) {
|
||||||
|
$variantId = $item['product_variant_id'] ?? 0;
|
||||||
|
$warehouseStock = (int) ($item['warehouse_stock'] ?? 0);
|
||||||
|
$cuttingReject = (int) ($item['cutting_reject'] ?? 0);
|
||||||
|
|
||||||
|
$originalResult = $cuttingResults->get($variantId);
|
||||||
|
if ($originalResult === null) {
|
||||||
|
$validator->errors()->add("results.{$index}.product_variant_id", 'Varian produk tidak ditemukan pada cutting ini.');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($warehouseStock + $cuttingReject) !== (int) $originalResult->cutting_result) {
|
||||||
|
$validator->errors()->add("results.{$index}.warehouse_stock", "Total jumlah (diterima + reject) harus sama dengan hasil cutting asli ({$originalResult->cutting_result} pcs).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->has('result_prices') || $this->input('result_prices') === []) {
|
||||||
|
$validator->errors()->add('result_prices', 'Harga jual wajib diisi saat verifikasi.');
|
||||||
|
} else {
|
||||||
|
$variantIds = $cutting->results->pluck('product_variant_id')->all();
|
||||||
|
$submittedVariantIds = collect($this->input('result_prices', []))
|
||||||
|
->pluck('product_variant_id')
|
||||||
|
->map(fn ($id) => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
foreach ($variantIds as $variantId) {
|
||||||
|
if (! in_array($variantId, $submittedVariantIds, true)) {
|
||||||
|
$validator->errors()->add('result_prices', 'Harga jual wajib diisi untuk semua varian hasil cutting.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
232
app/Services/Manage/StockService.php
Normal file
232
app/Services/Manage/StockService.php
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Manage;
|
||||||
|
|
||||||
|
use App\Enums\CuttingStatus;
|
||||||
|
use App\Models\Cutting;
|
||||||
|
use App\Models\CuttingResultPrice;
|
||||||
|
use App\Models\ProductVariant;
|
||||||
|
use App\Models\RawMaterialPrice;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class StockService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all completed cuttings pending verification.
|
||||||
|
*
|
||||||
|
* @return Collection<int, Cutting>
|
||||||
|
*/
|
||||||
|
public function getPendingVerificationCuttings(User $user): Collection
|
||||||
|
{
|
||||||
|
return Cutting::query()
|
||||||
|
->with([
|
||||||
|
'createdBy.profile',
|
||||||
|
'rejection.rejectedBy.profile',
|
||||||
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||||
|
'results.productVariant.product:id,name',
|
||||||
|
'results.productVariant:id,product_id,name',
|
||||||
|
])
|
||||||
|
->where('status', CuttingStatus::COMPLETED)
|
||||||
|
->latest()
|
||||||
|
->get()
|
||||||
|
->each(function (Cutting $cutting): void {
|
||||||
|
$this->appendCostPreview($cutting);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all verified cuttings (stock history).
|
||||||
|
*
|
||||||
|
* @return Collection<int, Cutting>
|
||||||
|
*/
|
||||||
|
public function getVerifiedCuttings(): Collection
|
||||||
|
{
|
||||||
|
return Cutting::query()
|
||||||
|
->with([
|
||||||
|
'createdBy.profile',
|
||||||
|
'rejection.rejectedBy.profile',
|
||||||
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||||
|
'results.productVariant.product:id,name',
|
||||||
|
'results.productVariant:id,product_id,name',
|
||||||
|
])
|
||||||
|
->where('status', CuttingStatus::VERIFIED)
|
||||||
|
->latest()
|
||||||
|
->limit(50)
|
||||||
|
->get()
|
||||||
|
->each(function (Cutting $cutting): void {
|
||||||
|
$this->appendCostPreview($cutting);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a completed cutting - adds stock to store.
|
||||||
|
*
|
||||||
|
* @param list<array{product_variant_id: int, warehouse_stock: int, cutting_reject: int}>|null $results
|
||||||
|
* @param list<array{product_variant_id: int, prices: list<array{type: string, price: int}>}>|null $resultPrices
|
||||||
|
*/
|
||||||
|
public function verify(
|
||||||
|
Cutting $cutting,
|
||||||
|
User $user,
|
||||||
|
?string $verificationNote = null,
|
||||||
|
?array $results = null,
|
||||||
|
?array $resultPrices = null,
|
||||||
|
): void {
|
||||||
|
if ($cutting->status !== CuttingStatus::COMPLETED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'status' => 'Hanya cutting yang sudah selesai yang dapat diverifikasi.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($cutting, $user, $verificationNote, $results, $resultPrices): void {
|
||||||
|
$cutting->load(['materials.rawMaterialPrice', 'results']);
|
||||||
|
|
||||||
|
if ($results !== null) {
|
||||||
|
foreach ($results as $item) {
|
||||||
|
$cutting->results()
|
||||||
|
->where('product_variant_id', $item['product_variant_id'])
|
||||||
|
->update([
|
||||||
|
'warehouse_stock' => $item['warehouse_stock'],
|
||||||
|
'cutting_reject' => $item['cutting_reject'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$cutting->load('results');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyProductStockOnVerify($cutting);
|
||||||
|
$this->storeResultPrices($cutting, $resultPrices ?? []);
|
||||||
|
|
||||||
|
if ($verificationNote !== null && trim($verificationNote) !== '') {
|
||||||
|
$cutting->rejection()->create([
|
||||||
|
'reason' => trim($verificationNote),
|
||||||
|
'rejected_by_id' => $user->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutting->status = CuttingStatus::VERIFIED;
|
||||||
|
$cutting->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
$description = $cutting->description ?? '-';
|
||||||
|
|
||||||
|
$this->pushNotificationService->sendToRoles(
|
||||||
|
'📦 Stok Cutting Diverifikasi',
|
||||||
|
"Cutting dengan deskripsi '{$description}' telah diverifikasi dan stok produk telah ditambahkan ke toko.",
|
||||||
|
['owner', 'developer'],
|
||||||
|
'/admin/manage/stocks',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a completed cutting - sends back to in_progress.
|
||||||
|
*/
|
||||||
|
public function reject(Cutting $cutting, User $user, string $reason): void
|
||||||
|
{
|
||||||
|
if ($cutting->status !== CuttingStatus::COMPLETED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'status' => 'Hanya cutting yang sudah selesai yang dapat ditolak.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($cutting, $user, $reason): void {
|
||||||
|
$cutting->load(['materials.rawMaterialPrice', 'results']);
|
||||||
|
$this->deductRemainingMaterialStock($cutting);
|
||||||
|
|
||||||
|
$cutting->rejection()->create([
|
||||||
|
'reason' => trim($reason),
|
||||||
|
'rejected_by_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cutting->status = CuttingStatus::IN_PROGRESS;
|
||||||
|
$cutting->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
$description = $cutting->description ?? '-';
|
||||||
|
$this->pushNotificationService->sendToRoles(
|
||||||
|
'📦 Verifikasi Cutting Ditolak',
|
||||||
|
"Cutting dengan deskripsi '{$description}' ditolak dari verifikasi stok dengan alasan: '{$reason}'.",
|
||||||
|
['owner', 'developer'],
|
||||||
|
'/admin/manage/stocks',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyProductStockOnVerify(Cutting $cutting): void
|
||||||
|
{
|
||||||
|
foreach ($cutting->results as $result) {
|
||||||
|
if ($result->warehouse_stock > 0) {
|
||||||
|
ProductVariant::query()
|
||||||
|
->whereKey($result->product_variant_id)
|
||||||
|
->increment('stock', $result->warehouse_stock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->cutting_reject > 0) {
|
||||||
|
ProductVariant::query()
|
||||||
|
->whereKey($result->product_variant_id)
|
||||||
|
->increment('reject_stock', $result->cutting_reject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deductRemainingMaterialStock(Cutting $cutting): void
|
||||||
|
{
|
||||||
|
foreach ($cutting->materials as $material) {
|
||||||
|
if ((float) $material->remaining_material > 0) {
|
||||||
|
RawMaterialPrice::query()
|
||||||
|
->whereKey($material->raw_material_price_id)
|
||||||
|
->decrement('stock', $material->remaining_material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{product_variant_id: int, prices: list<array{type: string, price: int}>}> $resultPrices
|
||||||
|
*/
|
||||||
|
private function storeResultPrices(Cutting $cutting, array $resultPrices): void
|
||||||
|
{
|
||||||
|
$costPerUnit = (int) ($cutting->cost_per_unit ?? 0);
|
||||||
|
|
||||||
|
foreach ($resultPrices as $resultData) {
|
||||||
|
foreach ($resultData['prices'] as $priceData) {
|
||||||
|
if ((int) $priceData['price'] > 0) {
|
||||||
|
CuttingResultPrice::query()->create([
|
||||||
|
'cutting_id' => $cutting->id,
|
||||||
|
'product_variant_id' => $resultData['product_variant_id'],
|
||||||
|
'price_type' => $priceData['type'],
|
||||||
|
'price' => (int) $priceData['price'],
|
||||||
|
'cost_per_unit' => $costPerUnit,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function appendCostPreview(Cutting $cutting): void
|
||||||
|
{
|
||||||
|
$cutting->setAttribute('total_result_pieces', (int) $cutting->results->sum('cutting_result'));
|
||||||
|
$cutting->setAttribute('total_material_usage', (float) $cutting->materials->sum('material_usage'));
|
||||||
|
|
||||||
|
$totalMaterialCost = $cutting->total_material_cost ?? 0;
|
||||||
|
$sewingCost = (int) ($cutting->sewing_cost ?? 0);
|
||||||
|
$otherCost = (int) ($cutting->other_cost ?? 0);
|
||||||
|
$totalProductionCost = $totalMaterialCost + $sewingCost + $otherCost;
|
||||||
|
$costPerUnit = $cutting->cost_per_unit ?? 0;
|
||||||
|
|
||||||
|
$cutting->setAttribute('total_material_cost', $totalMaterialCost);
|
||||||
|
$cutting->setAttribute('total_material_cost_formatted', 'Rp '.number_format($totalMaterialCost, 0, ',', '.'));
|
||||||
|
$cutting->setAttribute('sewing_cost', $sewingCost);
|
||||||
|
$cutting->setAttribute('sewing_cost_formatted', 'Rp '.number_format($sewingCost, 0, ',', '.'));
|
||||||
|
$cutting->setAttribute('other_cost', $otherCost);
|
||||||
|
$cutting->setAttribute('other_cost_formatted', 'Rp '.number_format($otherCost, 0, ',', '.'));
|
||||||
|
$cutting->setAttribute('total_production_cost', $totalProductionCost);
|
||||||
|
$cutting->setAttribute('total_production_cost_formatted', 'Rp '.number_format($totalProductionCost, 0, ',', '.'));
|
||||||
|
$cutting->setAttribute('estimated_cost_per_unit', $costPerUnit);
|
||||||
|
$cutting->setAttribute('estimated_cost_per_unit_formatted', 'Rp '.number_format($costPerUnit, 0, ',', '.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import { Banknote, CalendarDays, Clock, FolderTree, History, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, Shield, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
|
import { Banknote, CalendarDays, Clock, FolderTree, History, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, Shield, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards, Warehouse } from '@lucide/vue';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@ -54,6 +54,7 @@ const menuGroups: MenuGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ title: 'Belanja', href: '/admin/manage/purchases', icon: ShoppingBag, permission: 'purchases.view' },
|
{ title: 'Belanja', href: '/admin/manage/purchases', icon: ShoppingBag, permission: 'purchases.view' },
|
||||||
{ title: 'Cutting', href: '/admin/manage/cuttings', icon: Scissors, permission: 'cuttings.view' },
|
{ title: 'Cutting', href: '/admin/manage/cuttings', icon: Scissors, permission: 'cuttings.view' },
|
||||||
|
{ title: 'Stok', href: '/admin/manage/stocks', icon: Warehouse, permission: 'cuttings.view' },
|
||||||
{ title: 'Pesanan', href: '/admin/manage/orders', icon: ShoppingCart, permission: 'orders.view' },
|
{ title: 'Pesanan', href: '/admin/manage/orders', icon: ShoppingCart, permission: 'orders.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
39
resources/js/pages/admin/manage/stocks/Index.vue
Normal file
39
resources/js/pages/admin/manage/stocks/Index.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head } from '@inertiajs/vue3';
|
||||||
|
import { Package } from '@lucide/vue';
|
||||||
|
import StockPendingSection from './table/StockPendingSection.vue';
|
||||||
|
import StockVerifiedSection from './table/StockVerifiedSection.vue';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
import type { CuttingListItem } from '@/types/cutting';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
pendingCuttings: CuttingListItem[];
|
||||||
|
verifiedCuttings: CuttingListItem[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Verifikasi Stok" />
|
||||||
|
|
||||||
|
<AdminLayout>
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">
|
||||||
|
Verifikasi Stok
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Verifikasi hasil cutting untuk menambahkan stok produk ke toko.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StockPendingSection :cuttings="pendingCuttings" />
|
||||||
|
|
||||||
|
<Card class="min-w-0">
|
||||||
|
<CardContent class="min-w-0">
|
||||||
|
<StockVerifiedSection :cuttings="verifiedCuttings" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import type { CuttingListItem } from '@/types/cutting';
|
||||||
|
import StockDataTableActions from './data-table-actions.vue';
|
||||||
|
|
||||||
|
const CM_PER_YARD = 91.44;
|
||||||
|
const CM_PER_METER = 100;
|
||||||
|
|
||||||
|
function formatTotalMaterialUsage(totalUsage: number | null | undefined, materials: any[]): string {
|
||||||
|
if (!totalUsage || !materials.length) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = materials[0]?.raw_material_price?.raw_material?.unit;
|
||||||
|
|
||||||
|
if (unit === 'yard') {
|
||||||
|
return (totalUsage * CM_PER_YARD).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === 'meter') {
|
||||||
|
return (totalUsage * CM_PER_METER).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalUsage.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalMaterialUsageUnit(materials: any[]): string {
|
||||||
|
const unit = materials[0]?.raw_material_price?.raw_material?.unit;
|
||||||
|
|
||||||
|
if (unit === 'yard' || unit === 'meter') {
|
||||||
|
return 'cm';
|
||||||
|
}
|
||||||
|
|
||||||
|
return materials[0]?.raw_material_price?.raw_material?.unit_abbreviation ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMaterialUsage(mat: any): string {
|
||||||
|
const usage = mat.material_usage ?? 0;
|
||||||
|
const unit = mat.raw_material_price?.raw_material?.unit;
|
||||||
|
|
||||||
|
if (unit === 'yard') {
|
||||||
|
return (usage * CM_PER_YARD).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === 'meter') {
|
||||||
|
return (usage * CM_PER_METER).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaterialUnit(mat: any): string {
|
||||||
|
const unit = mat.raw_material_price?.raw_material?.unit;
|
||||||
|
|
||||||
|
if (unit === 'yard' || unit === 'meter') {
|
||||||
|
return 'cm';
|
||||||
|
}
|
||||||
|
|
||||||
|
return mat.raw_material_price?.raw_material?.unit_abbreviation ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
cuttings: CuttingListItem[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="cuttings.length" class="space-y-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
Menunggu Verifikasi
|
||||||
|
</h3>
|
||||||
|
<span class="bg-primary text-primary-foreground inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
|
||||||
|
{{ cuttings.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card v-for="cutting in cuttings" :key="cutting.id"
|
||||||
|
class="flex flex-col justify-between overflow-hidden border bg-card/50 py-0 gap-0">
|
||||||
|
<div class="border-b bg-muted/20 px-4 py-3 flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h4 class="font-medium text-sm truncate">
|
||||||
|
Cutting #{{ cutting.id }}
|
||||||
|
</h4>
|
||||||
|
<span class="text-[10px] text-muted-foreground block truncate">
|
||||||
|
{{ cutting.created_at_formatted }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center">
|
||||||
|
<StockDataTableActions :cutting="cutting" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-3 flex-1 text-xs">
|
||||||
|
<div v-if="cutting.description" class="text-muted-foreground pb-2 border-b">
|
||||||
|
<span class="font-medium text-foreground">Catatan:</span> {{ cutting.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<span class="font-medium text-foreground">Bahan Baku:</span>
|
||||||
|
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
|
||||||
|
<li v-for="mat in cutting.materials" :key="mat.id">
|
||||||
|
{{ mat.raw_material_price?.raw_material?.name }} ({{
|
||||||
|
mat.raw_material_price?.variant }}) - {{ formatMaterialUsage(mat) }} {{ getMaterialUnit(mat) }}
|
||||||
|
</li>
|
||||||
|
<li v-if="!cutting.materials.length">Belum ada bahan baku</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<span class="font-medium text-foreground">Hasil Produk:</span>
|
||||||
|
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
|
||||||
|
<li v-for="res in cutting.results" :key="res.id">
|
||||||
|
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }}) -
|
||||||
|
{{ res.cutting_result }} pcs
|
||||||
|
<span class="text-[10px]">
|
||||||
|
({{ res.warehouse_stock }} bagus, {{ res.cutting_reject }} reject)
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li v-if="!cutting.results.length">Belum ada hasil produk</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cutting.results.length" class="space-y-1.5 pt-2 border-t">
|
||||||
|
<div class="flex items-center justify-between text-[11px]">
|
||||||
|
<span class="text-muted-foreground">Total Hasil Cutting:</span>
|
||||||
|
<span class="font-semibold tabular-nums">{{ cutting.total_result_pieces ?? 0 }}
|
||||||
|
pcs</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-[11px]">
|
||||||
|
<span class="text-muted-foreground">Total Pemakaian Bahan:</span>
|
||||||
|
<span class="font-semibold tabular-nums">{{ formatTotalMaterialUsage(cutting.total_material_usage, cutting.materials) }} {{ getTotalMaterialUsageUnit(cutting.materials) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-[11px]">
|
||||||
|
<span class="text-muted-foreground">Biaya Produksi:</span>
|
||||||
|
<span class="font-semibold tabular-nums">{{ cutting.total_production_cost_formatted ?? 'Rp 0' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-[11px] text-muted-foreground pt-2 border-t flex items-center justify-between">
|
||||||
|
<span>Pembuat:</span>
|
||||||
|
<span class="font-medium text-foreground">
|
||||||
|
{{ cutting.created_by?.profile?.full_name ?? cutting.created_by?.username }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
Menunggu Verifikasi
|
||||||
|
</h3>
|
||||||
|
<div class="rounded-md border px-6 py-10 text-center">
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Tidak ada cutting yang menunggu verifikasi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,154 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '@/components/ui/empty';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import type { CuttingListItem } from '@/types/cutting';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
cuttings: CuttingListItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
interface GroupedCuttingResults {
|
||||||
|
productId: number;
|
||||||
|
productName: string;
|
||||||
|
items: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
||||||
|
const groups: Record<number, GroupedCuttingResults> = {};
|
||||||
|
|
||||||
|
results.forEach((item) => {
|
||||||
|
const product = item.product_variant?.product;
|
||||||
|
const productId = product?.id ?? 0;
|
||||||
|
const productName = product?.name ?? 'Produk Tidak Diketahui';
|
||||||
|
|
||||||
|
if (!groups[productId]) {
|
||||||
|
groups[productId] = {
|
||||||
|
productId,
|
||||||
|
productName,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[productId].items.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showingCount = computed(() => props.cuttings.length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
Riwayat Verifikasi
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cuttings.length" class="space-y-4">
|
||||||
|
<div v-for="cutting in cuttings" :key="cutting.id" class="overflow-hidden rounded-md border">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
|
<div class="min-w-0 space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="font-medium leading-tight">
|
||||||
|
Cutting #{{ cutting.id }}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="default">
|
||||||
|
Terverifikasi
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<p>{{ cutting.created_at_formatted }}</p>
|
||||||
|
<p>Oleh {{ cutting.created_by?.profile?.full_name ?? cutting.created_by?.username }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="cutting.description" class="text-muted-foreground text-sm">
|
||||||
|
{{ cutting.description }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||||
|
<span>Total Hasil Cutting <strong class="text-primary">{{ cutting.total_result_pieces ??
|
||||||
|
0 }} pcs</strong></span>
|
||||||
|
<span>Biaya Produksi <strong class="text-primary">{{
|
||||||
|
cutting.total_production_cost_formatted ?? 'Rp 0' }}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h4 class="text-sm font-semibold tracking-tight text-foreground">Hasil Produk</h4>
|
||||||
|
<div class="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Varian</TableHead>
|
||||||
|
<TableHead>Hasil</TableHead>
|
||||||
|
<TableHead>Bagus</TableHead>
|
||||||
|
<TableHead>Reject</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-if="!cutting.results.length" :key="`${cutting.id}-result-empty`">
|
||||||
|
<TableCell colspan="4" class="text-muted-foreground">
|
||||||
|
Belum ada hasil produk
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<template v-else v-for="group in getGroupedResults(cutting.results)"
|
||||||
|
:key="group.productId">
|
||||||
|
<TableRow class="bg-muted/20 hover:bg-muted/20">
|
||||||
|
<TableCell colspan="4" class="font-semibold text-foreground">
|
||||||
|
{{ group.productName }}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-for="result in group.items" :key="result.id">
|
||||||
|
<TableCell class="pl-6 font-medium">
|
||||||
|
{{ result.product_variant?.name }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums">
|
||||||
|
{{ result.cutting_result }} pcs
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums">
|
||||||
|
{{ result.warehouse_stock }} pcs
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums">
|
||||||
|
{{ result.cutting_reject }} pcs
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounded-md border px-6 py-10">
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyTitle>Belum ada riwayat</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Belum ada cutting yang telah diverifikasi.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,409 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { router, useForm } from '@inertiajs/vue3';
|
||||||
|
import { Check, X } from '@lucide/vue';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSet,
|
||||||
|
} from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { useCan } from '@/composables/useCan';
|
||||||
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
|
import {
|
||||||
|
PRICE_TYPES,
|
||||||
|
PRICE_TYPE_LABELS,
|
||||||
|
} from '@/types/product';
|
||||||
|
import type { CuttingListItem } from '@/types/cutting';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
cutting: CuttingListItem;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { can } = useCan();
|
||||||
|
|
||||||
|
const rejectDialogOpen = ref(false);
|
||||||
|
const verifyDialogOpen = ref(false);
|
||||||
|
const allMatches = ref(true);
|
||||||
|
|
||||||
|
function buildEmptyPrices(): Record<string, string> {
|
||||||
|
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectForm = useForm({
|
||||||
|
reason: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyForm = useForm({
|
||||||
|
verification_note: '',
|
||||||
|
results: props.cutting.results.map(res => ({
|
||||||
|
product_variant_id: res.product_variant?.id || 0,
|
||||||
|
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
||||||
|
cutting_result: res.cutting_result,
|
||||||
|
warehouse_stock: res.warehouse_stock,
|
||||||
|
cutting_reject: res.cutting_reject,
|
||||||
|
original_warehouse_stock: res.warehouse_stock,
|
||||||
|
original_cutting_reject: res.cutting_reject,
|
||||||
|
prices: buildEmptyPrices(),
|
||||||
|
})),
|
||||||
|
result_prices: [] as Array<{
|
||||||
|
product_variant_id: number;
|
||||||
|
prices: Array<{ type: string; price: number }>;
|
||||||
|
}>,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(verifyDialogOpen, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
allMatches.value = true;
|
||||||
|
verifyForm.verification_note = '';
|
||||||
|
verifyForm.results = props.cutting.results.map(res => ({
|
||||||
|
product_variant_id: res.product_variant?.id || 0,
|
||||||
|
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
||||||
|
cutting_result: res.cutting_result,
|
||||||
|
warehouse_stock: res.warehouse_stock,
|
||||||
|
cutting_reject: res.cutting_reject,
|
||||||
|
original_warehouse_stock: res.warehouse_stock,
|
||||||
|
original_cutting_reject: res.cutting_reject,
|
||||||
|
prices: buildEmptyPrices(),
|
||||||
|
}));
|
||||||
|
verifyForm.result_prices = [];
|
||||||
|
verifyForm.clearErrors();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(allMatches, (matches) => {
|
||||||
|
if (!matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyForm.results = verifyForm.results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
warehouse_stock: result.original_warehouse_stock,
|
||||||
|
cutting_reject: result.original_cutting_reject,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function setAllMatches(value: boolean) {
|
||||||
|
allMatches.value = value;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
verifyForm.results = verifyForm.results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
warehouse_stock: result.original_warehouse_stock,
|
||||||
|
cutting_reject: result.original_cutting_reject,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setResultPrice(resultIndex: number, type: string, value: string) {
|
||||||
|
const result = verifyForm.results[resultIndex];
|
||||||
|
if (!result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.prices = {
|
||||||
|
...result.prices,
|
||||||
|
[type]: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultPricesPayload() {
|
||||||
|
return verifyForm.results.map((result) => ({
|
||||||
|
product_variant_id: result.product_variant_id,
|
||||||
|
prices: PRICE_TYPES.map((type) => ({
|
||||||
|
type,
|
||||||
|
price: Number.parseInt(parseRupiah(result.prices[type] ?? ''), 10) || 0,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitVerify() {
|
||||||
|
verifyForm
|
||||||
|
.transform((data) => ({
|
||||||
|
verification_note: data.verification_note,
|
||||||
|
results: data.results.map(({ product_variant_id, warehouse_stock, cutting_reject }) => ({
|
||||||
|
product_variant_id,
|
||||||
|
warehouse_stock,
|
||||||
|
cutting_reject,
|
||||||
|
})),
|
||||||
|
result_prices: buildResultPricesPayload(),
|
||||||
|
}))
|
||||||
|
.post(`/admin/manage/stocks/${props.cutting.id}/verify`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
verifyDialogOpen.value = false;
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
const message = Object.values(errors)[0];
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
typeof message === 'string'
|
||||||
|
? message
|
||||||
|
: 'Gagal memverifikasi cutting.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitReject() {
|
||||||
|
rejectForm.post(`/admin/manage/stocks/${props.cutting.id}/reject`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
rejectDialogOpen.value = false;
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
const message = Object.values(errors)[0];
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
typeof message === 'string'
|
||||||
|
? message
|
||||||
|
: 'Gagal menolak cutting.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(rejectDialogOpen, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
rejectForm.reset();
|
||||||
|
rejectForm.clearErrors();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||||
|
<Tooltip v-if="can('cuttings.verify')">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-green-600 hover:text-green-700"
|
||||||
|
@click="verifyDialogOpen = true"
|
||||||
|
>
|
||||||
|
<Check class="size-3.5" />
|
||||||
|
<span class="sr-only">Verifikasi</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Verifikasi & Tambah Stok</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip v-if="can('cuttings.reject')">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
@click="rejectDialogOpen = true"
|
||||||
|
>
|
||||||
|
<X class="size-3.5" />
|
||||||
|
<span class="sr-only">Tolak</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Tolak Verifikasi</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog v-model:open="rejectDialogOpen">
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Tolak Verifikasi Cutting</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitReject">
|
||||||
|
<FieldGroup>
|
||||||
|
<FieldSet class="grid gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="stock-reject-reason" required
|
||||||
|
>Alasan Penolakan</FieldLabel
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id="stock-reject-reason"
|
||||||
|
v-model="rejectForm.reason"
|
||||||
|
placeholder="Contoh: Jumlah barang yang diterima tidak sesuai"
|
||||||
|
rows="3"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<FieldError
|
||||||
|
:errors="
|
||||||
|
rejectForm.errors.reason
|
||||||
|
? [rejectForm.errors.reason]
|
||||||
|
: []
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldSet>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<DialogFooter class="mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="rejectForm.processing"
|
||||||
|
@click="rejectDialogOpen = false"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
:disabled="rejectForm.processing"
|
||||||
|
>
|
||||||
|
{{ rejectForm.processing ? 'Menyimpan...' : 'Tolak' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog v-model:open="verifyDialogOpen">
|
||||||
|
<DialogContent class="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitVerify">
|
||||||
|
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Verifikasi jumlah produk yang diterima di toko dan tentukan harga jual per varian. Stok akan ditambahkan setelah verifikasi.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<Label for="stock-all-matches" class="text-sm font-medium">
|
||||||
|
Semua sesuai dengan data cutting
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="stock-all-matches"
|
||||||
|
:model-value="allMatches"
|
||||||
|
@update:model-value="setAllMatches"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="p-3 border rounded-lg space-y-3">
|
||||||
|
<div class="font-medium text-sm">
|
||||||
|
{{ result.name }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2 items-center text-xs">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground block">Hasil Potong:</span>
|
||||||
|
<span class="font-semibold">{{ result.cutting_result }} pcs</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground block mb-0.5">Data cutting:</span>
|
||||||
|
<span class="font-medium tabular-nums">
|
||||||
|
{{ result.original_warehouse_stock }} bagus ·
|
||||||
|
{{ result.original_cutting_reject }} reject
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground block">Stok Reject:</span>
|
||||||
|
<Badge variant="secondary" class="font-semibold">
|
||||||
|
{{ result.cutting_reject }} pcs
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 items-end text-xs">
|
||||||
|
<div>
|
||||||
|
<label :for="`stock-good-${index}`" class="text-muted-foreground block mb-0.5">Stok Bagus (diterima):</label>
|
||||||
|
<Input
|
||||||
|
:id="`stock-good-${index}`"
|
||||||
|
type="number"
|
||||||
|
v-model.number="result.warehouse_stock"
|
||||||
|
min="0"
|
||||||
|
:max="result.cutting_result"
|
||||||
|
class="h-8 w-full px-2 text-xs"
|
||||||
|
:disabled="allMatches"
|
||||||
|
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label :for="`stock-reject-${index}`" class="text-muted-foreground block mb-0.5">Stok Reject (diterima):</label>
|
||||||
|
<Input
|
||||||
|
:id="`stock-reject-${index}`"
|
||||||
|
type="number"
|
||||||
|
v-model.number="result.cutting_reject"
|
||||||
|
min="0"
|
||||||
|
:max="result.cutting_result"
|
||||||
|
class="h-8 w-full px-2 text-xs"
|
||||||
|
:disabled="allMatches"
|
||||||
|
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Field v-for="type in PRICE_TYPES" :key="`${result.product_variant_id}-${type}`">
|
||||||
|
<FieldLabel class="text-xs" :for="`stock-price-${index}-${type}`">
|
||||||
|
{{ PRICE_TYPE_LABELS[type] }}
|
||||||
|
</FieldLabel>
|
||||||
|
<RupiahInput
|
||||||
|
:id="`stock-price-${index}-${type}`"
|
||||||
|
:model-value="result.prices[type]"
|
||||||
|
@update:model-value="setResultPrice(index, type, $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldError
|
||||||
|
:errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="stock-verification-note">Catatan Verifikasi</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
id="stock-verification-note"
|
||||||
|
v-model="verifyForm.verification_note"
|
||||||
|
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<FieldError
|
||||||
|
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="verifyForm.processing"
|
||||||
|
@click="verifyDialogOpen = false"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="verifyForm.processing"
|
||||||
|
>
|
||||||
|
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi & Tambah Stok' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@ -18,6 +18,7 @@
|
|||||||
use App\Http\Controllers\Admin\Manage\OrderDraftItemController;
|
use App\Http\Controllers\Admin\Manage\OrderDraftItemController;
|
||||||
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
||||||
use App\Http\Controllers\Admin\Manage\PurchaseDraftItemController;
|
use App\Http\Controllers\Admin\Manage\PurchaseDraftItemController;
|
||||||
|
use App\Http\Controllers\Admin\Manage\StockController;
|
||||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||||
use App\Http\Controllers\Admin\Master\ProductController;
|
use App\Http\Controllers\Admin\Master\ProductController;
|
||||||
@ -295,6 +296,20 @@
|
|||||||
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_DELETE->value)
|
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_DELETE->value)
|
||||||
->name('destroy');
|
->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('stocks')->name('stocks.')
|
||||||
|
->middleware('permission:'.Permission::CUTTINGS_VIEW->value)
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/', [StockController::class, 'index'])->name('index');
|
||||||
|
|
||||||
|
Route::post('{cutting}/verify', [StockController::class, 'verify'])
|
||||||
|
->middleware('permission:'.Permission::CUTTINGS_VERIFY->value)
|
||||||
|
->name('verify');
|
||||||
|
|
||||||
|
Route::post('{cutting}/reject', [StockController::class, 'reject'])
|
||||||
|
->middleware('permission:'.Permission::CUTTINGS_REJECT->value)
|
||||||
|
->name('reject');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('system')->name('system.')->group(function () {
|
Route::prefix('system')->name('system.')->group(function () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user