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:
Yoga Pangestu 2026-06-21 11:53:41 +07:00
parent c3c8e9f5bd
commit e4b475cd82
9 changed files with 1185 additions and 1 deletions

View 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');
}
}

View 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;
}
}
}
});
}
}

View 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, ',', '.'));
}
}

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Banknote, CalendarDays, Clock, FolderTree, History, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, Shield, ShoppingBag, ShoppingCart, 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' },
],
},

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 () {