feat: implement owner verification actions in various management tables and enhance request handling in the OwnerVerificationRowActions component

This commit is contained in:
Yoga Pangestu 2026-06-27 15:11:20 +07:00
parent d5ca41a764
commit 5cffe43a07
14 changed files with 197 additions and 166 deletions

View File

@ -63,7 +63,9 @@ public function approveCutting(ApproveVerificationRequest $request, Cutting $cut
$this->flashSuccess('Verifikasi cutting berhasil disetujui. Stok produk telah ditambahkan ke toko.');
return redirect()->route('admin.manage.owner_verifications.index');
$fallback = route('admin.manage.owner_verifications.index');
return back(302, [], $fallback);
}
public function rejectCutting(RejectVerificationRequest $request, Cutting $cutting): RedirectResponse
@ -76,7 +78,9 @@ public function rejectCutting(RejectVerificationRequest $request, Cutting $cutti
$this->flashSuccess('Verifikasi cutting berhasil ditolak.');
return redirect()->route('admin.manage.owner_verifications.index');
$fallback = route('admin.manage.owner_verifications.index');
return back(302, [], $fallback);
}
public function approveRequest(
@ -91,7 +95,9 @@ public function approveRequest(
$this->flashSuccess('Verifikasi berhasil disetujui.');
return redirect()->route('admin.manage.owner_verifications.index');
$fallback = route('admin.manage.owner_verifications.index');
return back(302, [], $fallback);
}
public function rejectRequest(
@ -106,6 +112,8 @@ public function rejectRequest(
$this->flashSuccess('Verifikasi berhasil ditolak.');
return redirect()->route('admin.manage.owner_verifications.index');
$fallback = route('admin.manage.owner_verifications.index');
return back(302, [], $fallback);
}
}

View File

@ -70,6 +70,7 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
$pendingRequest = $purchase->pendingOwnerVerificationRequest;
$purchase->setAttribute('has_pending_request', $pendingRequest !== null);
$purchase->setAttribute('pending_request_id', $pendingRequest?->id);
$purchase->setAttribute('pending_request_action', $pendingRequest?->action->value);
$purchase->setAttribute('pending_request_action_label', $pendingRequest?->action->label());

View File

@ -77,6 +77,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
$pendingRequest = $product->pendingOwnerVerificationRequest;
$product->setAttribute('has_pending_request', $pendingRequest !== null);
$product->setAttribute('pending_request_id', $pendingRequest?->id);
$product->setAttribute('pending_request_action', $pendingRequest?->action->value);
$product->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
$product->setAttribute(

View File

@ -85,6 +85,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st
$pendingRequest = $rawMaterial->pendingOwnerVerificationRequest;
$rawMaterial->setAttribute('has_pending_request', $pendingRequest !== null);
$rawMaterial->setAttribute('pending_request_id', $pendingRequest?->id);
$rawMaterial->setAttribute('pending_request_action', $pendingRequest?->action->value);
$rawMaterial->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
$rawMaterial->setAttribute(

View File

@ -0,0 +1,135 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { X } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { RowApproveAction, RowRejectAction } from '@/components/button';
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 { useCan } from '@/composables/useCan';
import { approve, reject, approve_request, reject_request } from '@/routes/admin/manage/owner_verifications';
const props = defineProps<{
type: 'request' | 'cutting';
id: number;
title?: string;
subjectLabel?: string;
actionLabel?: string;
}>();
const { can } = useCan();
const rejectDialogOpen = ref(false);
const approveForm = useForm({});
const rejectForm = useForm({
reason: '',
});
const approveUrl = computed(() => (
props.type === 'cutting'
? approve.url(props.id)
: approve_request.url(props.id)
));
const rejectUrl = computed(() => (
props.type === 'cutting'
? reject.url(props.id)
: reject_request.url(props.id)
));
const canApprove = computed(() => can('owner_verifications.verify'));
const canReject = computed(() => can('owner_verifications.reject'));
function submitApprove() {
approveForm
.post(approveUrl.value, {
preserveScroll: true,
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
function submitReject() {
rejectForm
.post(rejectUrl.value, {
preserveScroll: true,
onSuccess: () => {
rejectDialogOpen.value = false;
rejectForm.reset();
},
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
</script>
<template>
<template v-if="canApprove">
<RowApproveAction size="sm" tooltip="Setujui" :disabled="approveForm.processing" @click="submitApprove" />
</template>
<template v-if="canReject">
<RowRejectAction size="sm" tooltip="Tolak" :disabled="rejectForm.processing" @click="rejectDialogOpen = true" />
</template>
<Dialog v-model:open="rejectDialogOpen">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Tolak Verifikasi</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div v-if="subjectLabel || title || actionLabel" class="rounded-md border p-3 text-xs space-y-2">
<div v-if="subjectLabel">
<span class="text-muted-foreground">Modul:</span>
<span class="ml-1 font-medium">{{ subjectLabel }}</span>
</div>
<div v-if="title">
<span class="text-muted-foreground">Item:</span>
<span class="ml-1 font-medium">{{ title }}</span>
</div>
<div v-if="actionLabel">
<span class="text-muted-foreground">Aksi:</span>
<span class="ml-1 font-medium">{{ actionLabel }}</span>
</div>
</div>
<Field>
<FieldLabel for="owner-verification-reject-reason">Alasan Penolakan</FieldLabel>
<Textarea id="owner-verification-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

@ -1,6 +1,7 @@
export const CuttingStatus = {
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed',
PENDING_VERIFICATION: 'pending_verification',
VERIFIED: 'verified',
REJECTED: 'rejected',
} as const;

View File

@ -6,6 +6,7 @@ import { toast } from 'vue-sonner';
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -286,6 +287,10 @@ function actionIcon(status: string) {
<template>
<div class="flex flex-wrap items-center justify-end gap-1">
<OwnerVerificationRowActions v-if="cutting.status === CuttingStatus.PENDING_VERIFICATION" type="cutting"
:id="cutting.id" :title="cutting.description ?? `Cutting #${cutting.id}`" subject-label="Cutting"
action-label="Verifikasi Stok" />
<template v-for="action in availableActions" :key="action.status">
<RowStatusAction v-if="canPerformAction(action)" :size="'sm'" :icon="actionIcon(action.status)"
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />

View File

@ -1,25 +1,6 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { X } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { RowApproveAction, RowDetailAction, RowRejectAction } from '@/components/button';
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 { useCan } from '@/composables/useCan';
import { approve, reject, approve_request, reject_request } from '@/routes/admin/manage/owner_verifications';
import { RowDetailAction } from '@/components/button';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import type { OwnerVerificationItem } from '@/types/owner-verification';
const props = defineProps<{
@ -29,125 +10,13 @@ const props = defineProps<{
const emit = defineEmits<{
view: [];
}>();
const { can } = useCan();
const rejectDialogOpen = ref(false);
const approveForm = useForm({});
const rejectForm = useForm({
reason: '',
});
const approveUrl = computed(() => (
props.item.source === 'cutting'
? approve.url(props.item.id)
: approve_request.url(props.item.id)
));
const rejectUrl = computed(() => (
props.item.source === 'cutting'
? reject.url(props.item.id)
: reject_request.url(props.item.id)
));
function submitApprove() {
approveForm
.post(approveUrl.value, {
preserveScroll: true,
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
function submitReject() {
rejectForm
.post(rejectUrl.value, {
preserveScroll: true,
onSuccess: () => {
rejectDialogOpen.value = false;
rejectForm.reset();
},
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<RowDetailAction tooltip="Lihat" @click="emit('view')" />
<template v-if="item.is_pending && can('owner_verifications.verify')">
<RowApproveAction
size="sm"
tooltip="Setujui"
:disabled="approveForm.processing"
@click="submitApprove"
/>
<RowRejectAction
size="sm"
tooltip="Tolak"
:disabled="rejectForm.processing"
@click="rejectDialogOpen = true"
/>
</template>
<OwnerVerificationRowActions v-if="item.is_pending" :type="item.source === 'cutting' ? 'cutting' : 'request'"
:id="item.id" :title="item.title" :subject-label="item.subject_label" :action-label="item.action_label" />
</div>
<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">Modul:</span>
<span class="ml-1 font-medium">{{ item.subject_label }}</span>
</div>
<div>
<span class="text-muted-foreground">Item:</span>
<span class="ml-1 font-medium">{{ item.title }}</span>
</div>
<div>
<span class="text-muted-foreground">Aksi:</span>
<span class="ml-1 font-medium">{{ item.action_label }}</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

@ -1,8 +1,9 @@
<script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { useCan } from '@/composables/useCan';
import type { PurchaseListItem } from '@/types/purchase';
import { edit, destroy } from '@/routes/admin/manage/purchases';
import type { PurchaseListItem } from '@/types/purchase';
defineProps<{
purchase: PurchaseListItem;
@ -13,20 +14,18 @@ const { can } = useCan();
<template>
<div class="flex items-center justify-end gap-1">
<RowEditAction
v-if="can('purchases.update')"
:href="edit.url(purchase.id)"
<OwnerVerificationRowActions v-if="purchase.has_pending_request && purchase.pending_request_id" type="request"
:id="purchase.pending_request_id"
:title="`${purchase.total_formatted} — ${purchase.supplier?.name ?? purchase.supplier_name ?? 'Supplier'}`"
subject-label="Belanja" :action-label="purchase.pending_request_action_label" />
<RowEditAction v-if="can('purchases.update')" :href="edit.url(purchase.id)"
:disabled="purchase.has_pending_request"
:tooltip="purchase.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'"
/>
<RowDeleteAction
v-if="can('purchases.delete')"
:action-url="destroy.url(purchase.id)"
:tooltip="purchase.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'" />
<RowDeleteAction v-if="can('purchases.delete')" :action-url="destroy.url(purchase.id)"
:disabled="purchase.has_pending_request"
:tooltip="purchase.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'"
title="Hapus belanja?"
:tooltip="purchase.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'" title="Hapus belanja?"
:description="`Pengajuan hapus belanja ${purchase.total_formatted} dari ${purchase.supplier?.name ?? purchase.supplier_name} akan dikirim ke owner untuk verifikasi.`"
error-message="Gagal mengajukan penghapusan belanja."
/>
error-message="Gagal mengajukan penghapusan belanja." />
</div>
</template>

View File

@ -1,10 +1,11 @@
<script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { useCan } from '@/composables/useCan';
import type { ProductListItem } from '@/types/product';
import { edit, destroy } from '@/routes/admin/master/products';
import type { ProductListItem } from '@/types/product';
defineProps<{
const props = defineProps<{
product: ProductListItem;
}>();
@ -13,20 +14,17 @@ const { can } = useCan();
<template>
<div class="flex items-center justify-end gap-1">
<RowEditAction
v-if="can('products.update')"
:href="edit.url(product.id)"
<OwnerVerificationRowActions v-if="product.has_pending_request && product.pending_request_id" type="request"
:id="product.pending_request_id" :title="product.name" subject-label="Produk"
:action-label="product.pending_request_action_label" />
<RowEditAction v-if="can('products.update')" :href="edit.url(product.id)"
:disabled="product.has_pending_request"
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'"
/>
<RowDeleteAction
v-if="can('products.delete')"
:action-url="destroy.url(product.id)"
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'" />
<RowDeleteAction v-if="can('products.delete')" :action-url="destroy.url(product.id)"
:disabled="product.has_pending_request"
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'"
title="Hapus produk?"
:tooltip="product.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'" title="Hapus produk?"
:description="`Pengajuan hapus produk ${product.name} akan dikirim ke owner untuk verifikasi.`"
error-message="Gagal mengajukan penghapusan produk."
/>
error-message="Gagal mengajukan penghapusan produk." />
</div>
</template>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { useCan } from '@/composables/useCan';
import type { RawMaterialListItem } from '@/types/raw-material';
import { edit, destroy } from '@/routes/admin/master/raw_materials';
@ -13,6 +14,15 @@ const { can } = useCan();
<template>
<div class="flex items-center justify-end gap-1">
<OwnerVerificationRowActions
v-if="material.has_pending_request && material.pending_request_id"
type="request"
:id="material.pending_request_id"
:title="material.name"
subject-label="Bahan Baku"
:action-label="material.pending_request_action_label"
/>
<RowEditAction
v-if="can('raw_materials.update')"
:href="edit.url(material.id)"

View File

@ -64,6 +64,7 @@ export interface Product {
export interface ProductListItem extends Product {
has_pending_request?: boolean;
pending_request_id?: number;
pending_request_action?: string;
pending_request_action_label?: string;
display_is_active?: boolean;

View File

@ -39,6 +39,7 @@ export type PurchaseListItem = {
photos?: MediaItem | null;
items: any[];
has_pending_request?: boolean;
pending_request_id?: number;
pending_request_action?: string;
pending_request_action_label?: string;
};

View File

@ -28,6 +28,7 @@ export type RawMaterialListItem = {
is_active: boolean;
prices: RawMaterialPrice[];
has_pending_request?: boolean;
pending_request_id?: number;
pending_request_action?: string;
pending_request_action_label?: string;
display_is_active?: boolean;