feat: implement owner verification process with new status and related functionality for improved stock management

This commit is contained in:
Yoga Pangestu 2026-06-21 14:51:15 +07:00
parent 5e71277056
commit 50f9328ebc
14 changed files with 567 additions and 55 deletions

View File

@ -11,6 +11,7 @@ enum CuttingStatus: string
case IN_PROGRESS = 'in_progress';
case COMPLETED = 'completed';
case PENDING_VERIFICATION = 'pending_verification';
case VERIFIED = 'verified';
case REJECTED = 'rejected';
@ -19,6 +20,7 @@ public function label(): string
return match ($this) {
self::IN_PROGRESS => 'Proses',
self::COMPLETED => 'Selesai',
self::PENDING_VERIFICATION => 'Menunggu Verifikasi',
self::VERIFIED => 'Terverifikasi',
self::REJECTED => 'Ditolak',
};
@ -33,7 +35,8 @@ public function canTransitionTo(self $status): bool
{
return match ($this) {
self::IN_PROGRESS => $status === self::COMPLETED,
self::COMPLETED => in_array($status, [self::VERIFIED, self::REJECTED, self::IN_PROGRESS], true),
self::COMPLETED => in_array($status, [self::PENDING_VERIFICATION, self::REJECTED, self::IN_PROGRESS], true),
self::PENDING_VERIFICATION => in_array($status, [self::VERIFIED, self::REJECTED, self::COMPLETED], true),
self::REJECTED => $status === self::IN_PROGRESS,
default => false,
};
@ -43,6 +46,7 @@ public function transitionPermission(): Permission
{
return match ($this) {
self::COMPLETED => Permission::CUTTINGS_COMPLETE,
self::PENDING_VERIFICATION => Permission::CUTTINGS_VERIFY,
self::VERIFIED => Permission::CUTTINGS_VERIFY,
self::REJECTED => Permission::CUTTINGS_REJECT,
self::IN_PROGRESS => Permission::CUTTINGS_UPDATE,
@ -66,6 +70,7 @@ public function availableActions(): array
],
],
self::COMPLETED => [],
self::PENDING_VERIFICATION => [],
self::REJECTED => [
[
'status' => self::IN_PROGRESS->value,

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\Models\Cutting;
use App\Services\Manage\StockService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OwnerVerificationController 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/owner-verifications/Index', [
'pendingCuttings' => $this->stockService->getPendingApprovalCuttings($user),
]);
}
public function approve(Request $request, Cutting $cutting): RedirectResponse
{
$request->validate([
'approval_note' => ['nullable', 'string', 'max:500'],
]);
$this->stockService->approveVerification(
$cutting,
$request->user(),
$request->input('approval_note'),
);
$this->flashSuccess('Verifikasi berhasil disetujui. Stok produk telah ditambahkan ke toko.');
return redirect()->route('admin.manage.owner-verifications.index');
}
public function reject(Request $request, Cutting $cutting): RedirectResponse
{
$request->validate([
'reason' => ['required', 'string', 'max:500'],
]);
$this->stockService->rejectVerification(
$cutting,
$request->user(),
$request->input('reason'),
);
$this->flashSuccess('Verifikasi berhasil ditolak.');
return redirect()->route('admin.manage.owner-verifications.index');
}
}

View File

@ -31,7 +31,7 @@ public function index(Request $request): Response
public function verify(StockVerifyRequest $request, Cutting $cutting): RedirectResponse
{
$this->stockService->verify(
$this->stockService->submitVerification(
$cutting,
$request->user(),
$request->validated('verification_note'),
@ -39,7 +39,7 @@ public function verify(StockVerifyRequest $request, Cutting $cutting): RedirectR
$request->validated('result_prices'),
);
$this->flashSuccess('Cutting berhasil diverifikasi. Stok produk telah ditambahkan ke toko.');
$this->flashSuccess('Verifikasi berhasil diajukan. Menunggu persetujuan owner.');
return redirect()->route('admin.manage.stocks.index');
}

View File

@ -73,6 +73,7 @@ public function share(Request $request): array
'pendingLeaveRequests' => fn () => $this->pendingLeaveRequests($request),
'pendingEmployeeAdvances' => fn () => $this->pendingEmployeeAdvances($request),
'pendingCuttings' => fn () => $this->pendingCuttings($request),
'pendingOwnerVerifications' => fn () => $this->pendingOwnerVerifications($request),
];
}
@ -114,4 +115,17 @@ private function pendingCuttings(Request $request): int
->where('status', CuttingStatus::COMPLETED)
->count();
}
private function pendingOwnerVerifications(Request $request): int
{
$user = $request->user();
if ($user === null || ! $user->can('cuttings.verify')) {
return 0;
}
return Cutting::query()
->where('status', CuttingStatus::PENDING_VERIFICATION)
->count();
}
}

View File

@ -7,7 +7,6 @@
use App\Models\CuttingResultPrice;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\RawMaterialPrice;
use App\Models\User;
use App\Services\System\PushNotificationService;
use Illuminate\Support\Collection;
@ -44,11 +43,11 @@ public function getPendingVerificationCuttings(User $user): Collection
}
/**
* Get all verified cuttings (stock history).
* Get all cuttings pending owner approval.
*
* @return Collection<int, Cutting>
*/
public function getVerifiedCuttings(): Collection
public function getPendingApprovalCuttings(User $user): Collection
{
return Cutting::query()
->with([
@ -57,10 +56,10 @@ public function getVerifiedCuttings(): Collection
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'results.productVariant.product:id,name',
'results.productVariant:id,product_id,name',
'resultPrices.productVariant:id,product_id,name',
])
->where('status', CuttingStatus::VERIFIED)
->where('status', CuttingStatus::PENDING_VERIFICATION)
->latest()
->limit(50)
->get()
->each(function (Cutting $cutting): void {
$this->appendCostPreview($cutting);
@ -68,12 +67,12 @@ public function getVerifiedCuttings(): Collection
}
/**
* Verify a completed cutting - adds stock to store.
* Submit verification for a completed cutting - saves data but does NOT add stock yet.
*
* @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(
public function submitVerification(
Cutting $cutting,
User $user,
?string $verificationNote = null,
@ -101,8 +100,6 @@ public function verify(
$cutting->load('results');
}
$this->applyProductStockOnVerify($cutting);
$this->storeResultPrices($cutting, $resultPrices ?? []);
if ($verificationNote !== null && trim($verificationNote) !== '') {
@ -112,6 +109,47 @@ public function verify(
]);
}
$cutting->status = CuttingStatus::PENDING_VERIFICATION;
$cutting->save();
});
$description = $cutting->description ?? '-';
$this->pushNotificationService->sendToRoles(
'📦 Verifikasi Stok Menunggu Persetujuan',
"Cutting dengan deskripsi '{$description}' telah diajukan verifikasi dan menunggu persetujuan owner.",
['owner', 'developer'],
'/admin/manage/owner-verifications',
);
}
/**
* Owner approves verification - adds stock to store.
*/
public function approveVerification(
Cutting $cutting,
User $user,
?string $approvalNote = null,
): void {
if ($cutting->status !== CuttingStatus::PENDING_VERIFICATION) {
throw ValidationException::withMessages([
'status' => 'Hanya cutting yang menunggu verifikasi yang dapat disetujui.',
]);
}
DB::transaction(function () use ($cutting, $user, $approvalNote): void {
$cutting->load(['materials.rawMaterialPrice', 'results', 'resultPrices']);
$this->applyProductStockOnVerify($cutting);
$this->applyResultPricesToProducts($cutting);
if ($approvalNote !== null && trim($approvalNote) !== '') {
$cutting->rejection()->create([
'reason' => trim($approvalNote),
'rejected_by_id' => $user->id,
]);
}
$cutting->status = CuttingStatus::VERIFIED;
$cutting->save();
});
@ -120,42 +158,42 @@ public function verify(
$this->pushNotificationService->sendToRoles(
'📦 Stok Cutting Diverifikasi',
"Cutting dengan deskripsi '{$description}' telah diverifikasi dan stok produk telah ditambahkan ke toko.",
"Cutting dengan deskripsi '{$description}' telah disetujui owner dan stok produk telah ditambahkan ke toko.",
['owner', 'developer'],
'/admin/manage/stocks',
'/admin/manage/owner-verifications',
);
}
/**
* Reject a completed cutting - sends back to in_progress.
* Owner rejects verification - sends back to completed.
*/
public function reject(Cutting $cutting, User $user, string $reason): void
{
if ($cutting->status !== CuttingStatus::COMPLETED) {
public function rejectVerification(
Cutting $cutting,
User $user,
string $reason,
): void {
if ($cutting->status !== CuttingStatus::PENDING_VERIFICATION) {
throw ValidationException::withMessages([
'status' => 'Hanya cutting yang sudah selesai yang dapat ditolak.',
'status' => 'Hanya cutting yang menunggu verifikasi 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->status = CuttingStatus::COMPLETED;
$cutting->save();
});
$description = $cutting->description ?? '-';
$this->pushNotificationService->sendToRoles(
'📦 Verifikasi Cutting Ditolak',
"Cutting dengan deskripsi '{$description}' ditolak dari verifikasi stok dengan alasan: '{$reason}'.",
'📦 Verifikasi Cutting Ditolak Owner',
"Cutting dengan deskripsi '{$description}' ditolak oleh owner dengan alasan: '{$reason}'.",
['owner', 'developer'],
'/admin/manage/stocks',
'/admin/manage/owner-verifications',
);
}
@ -176,17 +214,6 @@ private function applyProductStockOnVerify(Cutting $cutting): void
}
}
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
*/
@ -224,22 +251,33 @@ private function storeResultPrices(Cutting $cutting, array $resultPrices): void
'cost_per_unit' => $costPerUnit,
],
);
// Store/update in product_prices
ProductPrice::query()->updateOrCreate(
[
'variant_id' => $resultData['product_variant_id'],
'type' => $priceData['type'],
],
[
'price' => (int) $priceData['price'],
],
);
}
}
}
}
/**
* Update product_prices from cutting result prices (called on owner approval).
*/
private function applyResultPricesToProducts(Cutting $cutting): void
{
$resultPrices = $cutting->resultPrices()->with('productVariant')->get();
foreach ($resultPrices as $resultPrice) {
if ($resultPrice->price > 0) {
ProductPrice::query()->updateOrCreate(
[
'variant_id' => $resultPrice->product_variant_id,
'type' => $resultPrice->price_type->value,
],
[
'price' => $resultPrice->price,
],
);
}
}
}
private function appendCostPreview(Cutting $cutting): void
{
$cutting->setAttribute('total_result_pieces', (int) $cutting->results->sum('cutting_result'));

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("ALTER TABLE cuttings MODIFY COLUMN status ENUM('in_progress', 'completed', 'pending_verification', 'verified', 'rejected') NOT NULL DEFAULT 'in_progress'");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement("ALTER TABLE cuttings MODIFY COLUMN status ENUM('in_progress', 'completed', 'verified', 'rejected') NOT NULL DEFAULT 'in_progress'");
}
};

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, Warehouse } from '@lucide/vue';
import { Banknote, CalendarDays, CheckCircle, Clock, FolderTree, History, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, Shield, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards, Warehouse } from '@lucide/vue';
import {
Sidebar,
SidebarContent,
@ -24,7 +24,7 @@ interface MenuItem {
href: string;
icon: any;
permission?: string;
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances' | 'pendingCuttings';
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances' | 'pendingCuttings' | 'pendingOwnerVerifications';
}
interface MenuGroup {
@ -55,6 +55,7 @@ const menuGroups: MenuGroup[] = [
{ 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', badgeKey: 'pendingCuttings' },
{ title: 'Verifikasi Owner', href: '/admin/manage/owner-verifications', icon: CheckCircle, permission: 'cuttings.verify', badgeKey: 'pendingOwnerVerifications' },
{ title: 'Pesanan', href: '/admin/manage/orders', icon: ShoppingCart, permission: 'orders.view' },
],
},

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import OwnerVerificationPendingSection from './table/OwnerVerificationPendingSection.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CuttingListItem } from '@/types/cutting';
defineProps<{
pendingCuttings: CuttingListItem[];
}>();
</script>
<template>
<Head title="Verifikasi Owner" />
<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 Owner
</h2>
<p class="text-muted-foreground text-sm">
Persetujuan verifikasi stok cutting oleh owner.
</p>
</div>
</div>
<OwnerVerificationPendingSection :cuttings="pendingCuttings" />
</AdminLayout>
</template>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { Card } from '@/components/ui/card';
import type { CuttingListItem, CuttingResultPriceItem } from '@/types/cutting';
import { PRICE_TYPE_LABELS } from '@/types/product';
import OwnerVerificationDataTableActions from './data-table-actions.vue';
defineProps<{
cuttings: CuttingListItem[];
}>();
function getVariantPrices(cutting: CuttingListItem, variantId: number): CuttingResultPriceItem[] {
return (cutting.result_prices ?? []).filter(p => p.product_variant_id === variantId);
}
</script>
<template>
<div v-if="cuttings.length" class="space-y-4">
<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">
<OwnerVerificationDataTableActions :cutting="cutting" />
</div>
</div>
<div class="p-4 space-y-3 flex-1 text-xs">
<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.result_prices?.length" class="space-y-2 pt-2 border-t">
<span class="font-medium text-foreground">Harga:</span>
<div v-for="res in cutting.results" :key="`price-${res.id}`" class="space-y-1">
<div v-if="getVariantPrices(cutting, res.product_variant_id ?? 0).length > 0">
<p class="text-[11px] font-medium text-muted-foreground">
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }})
</p>
<div class="pl-2 space-y-0.5">
<div v-for="price in getVariantPrices(cutting, res.product_variant_id ?? 0)" :key="price.id"
class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">
{{ PRICE_TYPE_LABELS[price.price_type as keyof typeof PRICE_TYPE_LABELS] ?? price.type_label }}
</span>
<span class="font-medium tabular-nums">{{ price.price_formatted }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="cutting.results.length" class="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>
</div>
</Card>
</div>
</div>
<div v-else class="space-y-4">
<h3 class="text-lg font-semibold">
Menunggu Persetujuan
</h3>
<div class="rounded-md border px-6 py-10 text-center">
<p class="text-muted-foreground text-sm">
Tidak ada verifikasi yang menunggu persetujuan.
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,217 @@
<script setup lang="ts">
import { router, useForm } from '@inertiajs/vue3';
import { Check, X } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import { Textarea } from '@/components/ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { CuttingListItem } from '@/types/cutting';
const props = defineProps<{
cutting: CuttingListItem;
}>();
const { can } = useCan();
const approveDialogOpen = ref(false);
const rejectDialogOpen = ref(false);
const approveForm = useForm({
approval_note: '',
});
const rejectForm = useForm({
reason: '',
});
function submitApprove() {
approveForm
.post(`/admin/manage/owner-verifications/${props.cutting.id}/approve`, {
preserveScroll: true,
onSuccess: () => {
approveDialogOpen.value = false;
approveForm.reset();
toast.success('Verifikasi berhasil disetujui.');
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(message ?? 'Gagal menyetujui verifikasi.');
},
});
}
function submitReject() {
rejectForm
.post(`/admin/manage/owner-verifications/${props.cutting.id}/reject`, {
preserveScroll: true,
onSuccess: () => {
rejectDialogOpen.value = false;
rejectForm.reset();
toast.success('Verifikasi berhasil ditolak.');
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(message ?? 'Gagal menolak verifikasi.');
},
});
}
</script>
<template>
<div v-if="can('cuttings.verify')" class="flex items-center gap-1">
<Tooltip>
<TooltipTrigger as-child>
<Button
size="sm"
variant="ghost"
class="text-green-600 hover:text-green-700"
@click="approveDialogOpen = true"
>
<Check class="size-3.5" />
<span class="sr-only">Setujui</span>
</Button>
</TooltipTrigger>
<TooltipContent>Setujui Verifikasi</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
size="sm"
variant="ghost"
class="text-red-600 hover:text-red-700"
@click="rejectDialogOpen = true"
>
<X class="size-3.5" />
<span class="sr-only">Tolak</span>
</Button>
</TooltipTrigger>
<TooltipContent>Tolak Verifikasi</TooltipContent>
</Tooltip>
</div>
<!-- Approve Dialog -->
<Dialog v-model:open="approveDialogOpen">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Setujui Verifikasi</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="rounded-md border p-3 text-xs space-y-2">
<div>
<span class="text-muted-foreground">Cutting:</span>
<span class="ml-1 font-medium">#{{ cutting.id }}</span>
</div>
<div>
<span class="text-muted-foreground">Deskripsi:</span>
<span class="ml-1 font-medium">{{ cutting.description ?? '-' }}</span>
</div>
</div>
<Field>
<FieldLabel for="approval-note">Catatan (Opsional)</FieldLabel>
<Textarea
id="approval-note"
v-model="approveForm.approval_note"
placeholder="Tambahkan catatan persetujuan..."
rows="3"
/>
<FieldError
:errors="approveForm.errors.approval_note ? [approveForm.errors.approval_note] : []"
/>
</Field>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="approveDialogOpen = false"
>
Batal
</Button>
<Button
type="button"
:disabled="approveForm.processing"
@click="submitApprove"
>
<Check class="size-4" />
Setujui
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Reject Dialog -->
<Dialog v-model:open="rejectDialogOpen">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Tolak Verifikasi</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="rounded-md border p-3 text-xs space-y-2">
<div>
<span class="text-muted-foreground">Cutting:</span>
<span class="ml-1 font-medium">#{{ cutting.id }}</span>
</div>
<div>
<span class="text-muted-foreground">Deskripsi:</span>
<span class="ml-1 font-medium">{{ cutting.description ?? '-' }}</span>
</div>
</div>
<Field>
<FieldLabel for="reject-reason">Alasan Penolakan</FieldLabel>
<Textarea
id="reject-reason"
v-model="rejectForm.reason"
placeholder="Jelaskan alasan penolakan..."
rows="3"
/>
<FieldError
:errors="rejectForm.errors.reason ? [rejectForm.errors.reason] : []"
/>
</Field>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="rejectDialogOpen = false"
>
Batal
</Button>
<Button
type="button"
variant="destructive"
:disabled="rejectForm.processing"
@click="submitReject"
>
<X class="size-4" />
Tolak
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -100,13 +100,13 @@ function rowNumber(index: number): number {
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>
Total stok <strong class="text-primary">
Total stok bagus <strong class="text-primary">
{{ product.variants.reduce((acc, v) => acc + v.stock, 0) }}
</strong>
</span>
<span>
Total harga <strong class="text-primary"> Rp
{{ formatRupiah(product.variants.reduce((acc, v) => acc + (v.prices?.reduce((s, p) => s + p.price, 0) ?? 0), 0)) }}
Total stok reject <strong class="text-destructive">
{{ product.variants.reduce((acc, v) => acc + v.reject_stock, 0) }}
</strong>
</span>
</div>
@ -124,13 +124,14 @@ function rowNumber(index: number): number {
<TableRow>
<TableHead>Varian</TableHead>
<TableHead>Foto</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Stok Bagus</TableHead>
<TableHead>Stok Reject</TableHead>
<TableHead>Harga</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
<TableCell colspan="4" class="text-muted-foreground">
<TableCell colspan="5" class="text-muted-foreground">
Belum ada varian
</TableCell>
</TableRow>
@ -144,6 +145,9 @@ function rowNumber(index: number): number {
<TableCell class="tabular-nums">
{{ formatStock(variant.stock) }}
</TableCell>
<TableCell class="tabular-nums text-destructive">
{{ formatStock(variant.reject_stock) }}
</TableCell>
<TableCell>
<div v-if="variant.prices?.length" class="space-y-0.5 text-xs">
<div v-for="type in PRICE_TYPES" :key="type">

View File

@ -27,6 +27,17 @@ export type CuttingMaterialListItem = {
};
};
export type CuttingResultPriceItem = {
id: number;
product_variant_id: number;
price_type: string;
price: number;
price_formatted: string;
cost_per_unit: number;
cost_per_unit_formatted: string;
type_label: string;
};
export type CuttingResultListItem = {
id: number;
cutting_result: number;
@ -78,6 +89,7 @@ export type CuttingListItem = {
} | null;
materials: CuttingMaterialListItem[];
results: CuttingResultListItem[];
result_prices?: CuttingResultPriceItem[];
};
export type CuttingMaterialCartItem = {

View File

@ -28,6 +28,7 @@ export type ProductVariantItem = {
id: number;
name: string;
stock: number;
reject_stock: number;
prices: ProductPriceItem[];
images?: MediaItem[];
};

View File

@ -16,6 +16,7 @@
use App\Http\Controllers\Admin\Manage\CuttingDraftItemController;
use App\Http\Controllers\Admin\Manage\OrderController;
use App\Http\Controllers\Admin\Manage\OrderDraftItemController;
use App\Http\Controllers\Admin\Manage\OwnerVerificationController;
use App\Http\Controllers\Admin\Manage\PurchaseController;
use App\Http\Controllers\Admin\Manage\PurchaseDraftItemController;
use App\Http\Controllers\Admin\Manage\StockController;
@ -306,6 +307,18 @@
->middleware('permission:'.Permission::CUTTINGS_VERIFY->value)
->name('verify');
});
Route::prefix('owner-verifications')->name('owner-verifications.')
->middleware('permission:'.Permission::CUTTINGS_VERIFY->value)
->group(function () {
Route::get('/', [OwnerVerificationController::class, 'index'])->name('index');
Route::post('{cutting}/approve', [OwnerVerificationController::class, 'approve'])
->name('approve');
Route::post('{cutting}/reject', [OwnerVerificationController::class, 'reject'])
->name('reject');
});
});
Route::prefix('system')->name('system.')->group(function () {