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">
|
||||
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 {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@ -54,6 +54,7 @@ const menuGroups: MenuGroup[] = [
|
||||
items: [
|
||||
{ title: 'Belanja', href: '/admin/manage/purchases', icon: ShoppingBag, permission: 'purchases.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' },
|
||||
],
|
||||
},
|
||||
|
||||
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\PurchaseController;
|
||||
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\CustomerController;
|
||||
use App\Http\Controllers\Admin\Master\ProductController;
|
||||
@ -295,6 +296,20 @@
|
||||
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_DELETE->value)
|
||||
->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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user