feat: update CuttingVerificationSection and ProductGroupedTable components for improved functionality and UI consistency

This commit is contained in:
Yoga Pangestu 2026-06-20 21:39:01 +07:00
parent 45f28e69ef
commit 7d39f85da7
3 changed files with 108 additions and 207 deletions

View File

@ -4,6 +4,7 @@ import { Check, ChevronDown, Copy, RotateCcw, Scissors, X } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -41,15 +42,14 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { RupiahInput } from '@/components/form/rupiah-input';
import { useCan } from '@/composables/useCan';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { parseRupiah } from '@/lib/rupiah';
import type { CuttingListItem } from '@/types/cutting';
import {
PRICE_TYPES,
PRICE_TYPE_LABELS,
} from '@/types/product';
import type { CuttingListItem } from '@/types/cutting';
const props = defineProps<{
cuttings: CuttingListItem[];
@ -172,6 +172,7 @@ function toggleUseSamePrice(checked: boolean) {
function setResultPrice(resultIndex: number, type: string, value: string) {
const result = verifyForm.results[resultIndex];
if (!result) {
return;
}
@ -184,6 +185,7 @@ function setResultPrice(resultIndex: number, type: string, value: string) {
function applyPriceToAllVariants(sourceIndex: number) {
const source = verifyForm.results[sourceIndex];
if (!source) {
return;
}
@ -209,6 +211,7 @@ function openAction(cutting: CuttingListItem, action: CuttingStatusAction) {
if (action.status === 'verified') {
verifyDialogOpen.value = true;
return;
}
@ -216,6 +219,7 @@ function openAction(cutting: CuttingListItem, action: CuttingStatusAction) {
rejectForm.reset();
rejectForm.clearErrors();
rejectDialogOpen.value = true;
return;
}
@ -287,21 +291,21 @@ function submitVerify() {
result_prices: buildResultPricesPayload(),
}))
.post(`/admin/manage/cuttings/${activeCutting.value.id}/status`, {
preserveScroll: true,
onSuccess: () => {
verifyDialogOpen.value = false;
activeCutting.value = null;
},
onError: (errors) => {
const message = Object.values(errors)[0];
preserveScroll: true,
onSuccess: () => {
verifyDialogOpen.value = false;
activeCutting.value = null;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(
typeof message === 'string'
? message
: 'Gagal memverifikasi cutting.',
);
},
});
toast.error(
typeof message === 'string'
? message
: 'Gagal memverifikasi cutting.',
);
},
});
}
function submitReject() {
@ -344,15 +348,13 @@ watch(rejectDialogOpen, (isOpen) => {
<div class="flex items-center gap-2">
<Scissors class="size-4 shrink-0 text-green-600 dark:text-green-400" />
<h3 class="font-semibold leading-tight">
Cutting Menunggu Verifikasi
Verifikasi Cutting
</h3>
<Badge v-if="totalCompleted > 0" variant="outline" class="border-green-600/30 text-green-600 dark:text-green-400">
<Badge v-if="totalCompleted > 0" variant="outline"
class="border-green-600/30 text-green-600 dark:text-green-400">
{{ totalCompleted }}
</Badge>
</div>
<p class="text-xs text-muted-foreground">
Daftar cutting yang telah selesai dikerjakan. Verifikasi untuk menambahkan stok ke produk.
</p>
</div>
<ChevronDown class="size-4 shrink-0 text-muted-foreground transition-transform duration-200"
@ -362,7 +364,8 @@ watch(rejectDialogOpen, (isOpen) => {
<CollapsibleContent>
<CardContent class="pt-0 pb-6">
<div v-if="cuttings.length" 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">
<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">
@ -375,11 +378,8 @@ watch(rejectDialogOpen, (isOpen) => {
<div class="flex shrink-0 flex-wrap items-center justify-end gap-1">
<Tooltip v-if="can('cuttings.verify')">
<TooltipTrigger as-child>
<Button
size="sm"
variant="ghost"
@click="openAction(cutting, { status: 'verified', label: 'Verifikasi', destructive: false, permission: 'cuttings.verify', icon_only: false })"
>
<Button size="sm" variant="ghost"
@click="openAction(cutting, { status: 'verified', label: 'Verifikasi', destructive: false, permission: 'cuttings.verify', icon_only: false })">
<Check class="size-3.5" />
<span class="sr-only">Verifikasi</span>
</Button>
@ -389,12 +389,9 @@ watch(rejectDialogOpen, (isOpen) => {
<Tooltip v-if="can('cuttings.reject')">
<TooltipTrigger as-child>
<Button
size="sm"
variant="ghost"
<Button size="sm" variant="ghost"
class="text-destructive hover:text-destructive"
@click="openAction(cutting, { status: 'rejected', label: 'Tolak', destructive: true, permission: 'cuttings.reject', icon_only: false })"
>
@click="openAction(cutting, { status: 'rejected', label: 'Tolak', destructive: true, permission: 'cuttings.reject', icon_only: false })">
<X class="size-3.5" />
<span class="sr-only">Tolak</span>
</Button>
@ -404,12 +401,9 @@ watch(rejectDialogOpen, (isOpen) => {
<Tooltip v-if="can('cuttings.reject')">
<TooltipTrigger as-child>
<Button
size="sm"
variant="ghost"
<Button size="sm" variant="ghost"
class="text-destructive hover:text-destructive"
@click="openAction(cutting, { status: 'in_progress', label: 'Kembalikan ke Proses', destructive: true, permission: 'cuttings.reject', icon_only: false })"
>
@click="openAction(cutting, { status: 'in_progress', label: 'Kembalikan ke Proses', destructive: true, permission: 'cuttings.reject', icon_only: false })">
<RotateCcw class="size-3.5" />
<span class="sr-only">Kembalikan ke Proses</span>
</Button>
@ -428,7 +422,8 @@ watch(rejectDialogOpen, (isOpen) => {
<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 }}) - {{ mat.material_usage_formatted }}
{{ mat.raw_material_price?.raw_material?.name }} ({{
mat.raw_material_price?.variant }}) - {{ mat.material_usage_formatted }}
</li>
<li v-if="!cutting.materials.length">Belum ada bahan baku</li>
</ul>
@ -438,7 +433,8 @@ watch(rejectDialogOpen, (isOpen) => {
<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
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }}) -
{{ res.cutting_result }} pcs
</li>
<li v-if="!cutting.results.length">Belum ada hasil produk</li>
</ul>
@ -446,10 +442,12 @@ watch(rejectDialogOpen, (isOpen) => {
<div v-if="cutting.estimated_cost_per_unit_formatted" class="text-[11px] pt-2 border-t">
<span class="text-muted-foreground">Harga modal: </span>
<span class="font-semibold tabular-nums">{{ cutting.estimated_cost_per_unit_formatted }} / pcs</span>
<span class="font-semibold tabular-nums">{{
cutting.estimated_cost_per_unit_formatted }} / pcs</span>
</div>
<div class="text-[11px] text-muted-foreground pt-2 border-t flex items-center justify-between">
<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 }}
@ -473,22 +471,12 @@ watch(rejectDialogOpen, (isOpen) => {
</Collapsible>
<!-- Status Confirm Dialog (Return to Process) -->
<ConfirmDialog
v-model:open="statusConfirmOpen"
:title="
pendingAction
? `${pendingAction.label} cutting?`
: 'Ubah status cutting?'
"
:description="
pendingAction ? statusConfirmDescription(pendingAction) : ''
"
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false"
:loading="statusProcessing"
@confirm="transitionStatus"
/>
<ConfirmDialog v-model:open="statusConfirmOpen" :title="pendingAction
? `${pendingAction.label} cutting?`
: 'Ubah status cutting?'
" :description="pendingAction ? statusConfirmDescription(pendingAction) : ''
" :confirm-label="pendingAction?.label ?? 'Konfirmasi'" cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false" :loading="statusProcessing" @confirm="transitionStatus" />
<!-- Reject Dialog -->
<Dialog v-model:open="rejectDialogOpen">
@ -501,42 +489,24 @@ watch(rejectDialogOpen, (isOpen) => {
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="cutting-reject-reason" required
>Alasan Penolakan</FieldLabel
>
<Textarea
id="cutting-reject-reason"
v-model="rejectForm.reason"
placeholder="Contoh: Jumlah barang yang diterima tidak sesuai"
rows="3"
autofocus
:maxlength="FIELD_LIMITS.reason"
/>
<FieldError
:errors="
rejectForm.errors.reason
? [rejectForm.errors.reason]
: []
"
/>
<FieldLabel for="cutting-reject-reason" required>Alasan Penolakan</FieldLabel>
<Textarea id="cutting-reject-reason" v-model="rejectForm.reason"
placeholder="Contoh: Jumlah barang yang diterima tidak sesuai" rows="3" autofocus
:maxlength="FIELD_LIMITS.reason" />
<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"
>
<Button type="button" variant="outline" :disabled="rejectForm.processing"
@click="rejectDialogOpen = false">
Batal
</Button>
<Button
type="submit"
variant="destructive"
:disabled="rejectForm.processing"
>
<Button type="submit" variant="destructive" :disabled="rejectForm.processing">
{{ rejectForm.processing ? 'Menyimpan...' : 'Tolak' }}
</Button>
</DialogFooter>
@ -561,17 +531,11 @@ watch(rejectDialogOpen, (isOpen) => {
<Label for="all-matches-verif" class="text-sm font-medium">
Semua sesuai dengan data cutting
</Label>
<Switch
id="all-matches-verif"
:model-value="allMatches"
@update:model-value="setAllMatches"
/>
<Switch id="all-matches-verif" :model-value="allMatches" @update:model-value="setAllMatches" />
</div>
<div
v-if="activeCutting?.estimated_cost_per_unit_formatted"
class="rounded-lg border bg-muted/40 p-3 text-sm space-y-1"
>
<div v-if="activeCutting?.estimated_cost_per_unit_formatted"
class="rounded-lg border bg-muted/40 p-3 text-sm space-y-1">
<p class="font-medium">Harga Modal Batch</p>
<p class="flex justify-between gap-2 text-muted-foreground">
<span>Bahan baku</span>
@ -600,47 +564,39 @@ watch(rejectDialogOpen, (isOpen) => {
<div class="space-y-3">
<!-- Shared Price Toggle -->
<div v-if="verifyForm.results.length > 1" class="flex items-center justify-between rounded-lg border p-3">
<div v-if="verifyForm.results.length > 1"
class="flex items-center justify-between rounded-lg border p-3">
<Label for="use-same-price" class="text-sm font-medium">
Gunakan harga yang sama untuk semua varian
</Label>
<Switch
id="use-same-price"
:model-value="useSamePrice"
@update:model-value="toggleUseSamePrice"
/>
<Switch id="use-same-price" :model-value="useSamePrice"
@update:model-value="toggleUseSamePrice" />
</div>
<!-- Shared Price Inputs -->
<div v-if="useSamePrice && verifyForm.results.length > 1" class="rounded-lg border p-3 space-y-3">
<div v-if="useSamePrice && verifyForm.results.length > 1"
class="rounded-lg border p-3 space-y-3">
<p class="text-sm font-medium">Harga Jual</p>
<div class="grid gap-2 sm:grid-cols-2">
<Field v-for="type in PRICE_TYPES" :key="`shared-${type}`">
<FieldLabel class="text-xs" :for="`shared-price-${type}`">
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput
:id="`shared-price-${type}`"
:model-value="sharedPrices[type]"
@update:model-value="setSharedPrice(type, $event)"
/>
<RupiahInput :id="`shared-price-${type}`" :model-value="sharedPrices[type]"
@update:model-value="setSharedPrice(type, $event)" />
</Field>
</div>
</div>
<!-- Per Variant Section -->
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="p-3 border rounded-lg 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="flex items-center justify-between">
<div class="font-medium text-sm">
{{ result.name }}
</div>
<Button
v-if="!useSamePrice && verifyForm.results.length > 1 && index > 0"
type="button"
variant="outline"
size="sm"
@click="applyPriceToAllVariants(index)"
>
<Button v-if="!useSamePrice && verifyForm.results.length > 1 && index > 0" type="button"
variant="outline" size="sm" @click="applyPriceToAllVariants(index)">
<Copy class="size-3.5" />
Terapkan ke Semua
</Button>
@ -666,79 +622,53 @@ watch(rejectDialogOpen, (isOpen) => {
</div>
<div class="grid grid-cols-2 gap-2 items-end text-xs">
<div>
<label :for="`verif-good-${index}`" class="text-muted-foreground block mb-0.5">Stok Bagus (diterima):</label>
<Input
:id="`verif-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"
/>
<label :for="`verif-good-${index}`" class="text-muted-foreground block mb-0.5">Stok
Bagus (diterima):</label>
<Input :id="`verif-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="`verif-reject-${index}`" class="text-muted-foreground block mb-0.5">Stok Reject (diterima):</label>
<Input
:id="`verif-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"
/>
<label :for="`verif-reject-${index}`"
class="text-muted-foreground block mb-0.5">Stok Reject (diterima):</label>
<Input :id="`verif-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 v-if="!useSamePrice || verifyForm.results.length === 1" class="grid gap-2 sm:grid-cols-2">
<div v-if="!useSamePrice || verifyForm.results.length === 1"
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="`verif-price-${index}-${type}`">
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput
:id="`verif-price-${index}-${type}`"
:model-value="result.prices[type]"
@update:model-value="setResultPrice(index, type, $event)"
/>
<RupiahInput :id="`verif-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] : []"
/>
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
<Field>
<FieldLabel for="verification-note">Catatan Verifikasi</FieldLabel>
<Textarea
id="verification-note"
v-model="verifyForm.verification_note"
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko"
rows="3"
/>
<Textarea id="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] : []"
/>
: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"
>
<Button type="button" variant="outline" :disabled="verifyForm.processing"
@click="verifyDialogOpen = false">
Batal
</Button>
<Button
type="submit"
:disabled="verifyForm.processing"
>
<Button type="submit" :disabled="verifyForm.processing">
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi' }}
</Button>
</DialogFooter>

View File

@ -157,7 +157,7 @@ function approvalStatusVariant(status: string): 'default' | 'secondary' | 'destr
<TableHead>Foto</TableHead>
<TableHead>Stok Bagus</TableHead>
<TableHead>Stok Reject</TableHead>
<TableHead>Harga Cutting Terakhir</TableHead>
<TableHead>Harga</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@ -14,6 +14,7 @@ import {
useDataTableQuerySync,
} from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CuttingListItem } from '@/types/cutting';
import type { DataTableFilterDef } from '@/types/data-table';
import type {
CategoryOption,
@ -22,7 +23,6 @@ import type {
ProductOutOfStockGroup,
ProductStockWarningGroup,
} from '@/types/product';
import type { CuttingListItem } from '@/types/cutting';
const props = defineProps<{
products: PaginatedProducts;
@ -120,21 +120,16 @@ watch(
</script>
<template>
<Head title="Produk" />
<AdminLayout>
<div
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<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">Produk</h2>
</div>
<Button
v-if="can('products.create')"
as-child
class="shrink-0 self-start sm:self-center"
>
<Button v-if="can('products.create')" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/master/products/create">
<Plus class="size-4" />
Tambah
@ -142,46 +137,22 @@ watch(
</Button>
</div>
<CuttingVerificationSection
v-if="can('cuttings.verify')"
:cuttings="completedCuttings"
/>
<CuttingVerificationSection v-if="can('cuttings.verify')" :cuttings="completedCuttings" />
<MasterOutOfStockCatalogSection
severity="warning"
title="Stok Menipis"
description="Hanya produk aktif · stok masih ada, tapi di bawah batas minimum · Klik kartu untuk melihat data."
empty-title="Tidak ada peringatan stok"
empty-description="Semua varian masih berada di atas batas stok minimum."
:groups="stockWarningGroups"
/>
<MasterOutOfStockCatalogSection severity="warning" title="Stok Menipis" empty-title="Tidak ada peringatan stok"
empty-description="Semua varian masih berada di atas batas stok minimum." :groups="stockWarningGroups" />
<MasterOutOfStockCatalogSection
title="Produk Stok Habis"
description="Hanya produk aktif · Klik kartu untuk melihat data."
:groups="outOfStockGroups"
/>
<MasterOutOfStockCatalogSection title="Stok Habis" :groups="outOfStockGroups" />
<Card class="min-w-0">
<CardContent class="min-w-0">
<ProductGroupedTable
v-model:search="search"
:products="products.data"
:first-item="firstItem"
:pagination="tablePagination"
:pagination-links="products.links"
:filter-defs="filterDefs"
:filter-values="filterValues"
@filter-change="setFilter"
@filters-reset="resetFilters"
@reject="openRejectModal"
/>
<ProductGroupedTable v-model:search="search" :products="products.data" :first-item="firstItem"
:pagination="tablePagination" :pagination-links="products.links" :filter-defs="filterDefs"
:filter-values="filterValues" @filter-change="setFilter" @filters-reset="resetFilters"
@reject="openRejectModal" />
</CardContent>
</Card>
<RejectProductModal
v-model:open="rejectModalOpen"
:product="rejectingProduct"
/>
<RejectProductModal v-model:open="rejectModalOpen" :product="rejectingProduct" />
</AdminLayout>
</template>