feat: implement owner verification process with new status and related functionality for improved stock management
This commit is contained in:
parent
5e71277056
commit
50f9328ebc
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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'");
|
||||
}
|
||||
};
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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">
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -28,6 +28,7 @@ export type ProductVariantItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
stock: number;
|
||||
reject_stock: number;
|
||||
prices: ProductPriceItem[];
|
||||
images?: MediaItem[];
|
||||
};
|
||||
|
||||
@ -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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user