refactor: remove completed cuttings functionality from CuttingController and related components to streamline cutting management
This commit is contained in:
parent
e5c79727c4
commit
3308a3790a
@ -65,29 +65,7 @@ public function availableActions(): array
|
|||||||
'icon_only' => false,
|
'icon_only' => false,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
self::COMPLETED => [
|
self::COMPLETED => [],
|
||||||
[
|
|
||||||
'status' => self::VERIFIED->value,
|
|
||||||
'label' => 'Verifikasi',
|
|
||||||
'destructive' => false,
|
|
||||||
'permission' => Permission::CUTTINGS_VERIFY->value,
|
|
||||||
'icon_only' => false,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'status' => self::REJECTED->value,
|
|
||||||
'label' => 'Tolak',
|
|
||||||
'destructive' => true,
|
|
||||||
'permission' => Permission::CUTTINGS_REJECT->value,
|
|
||||||
'icon_only' => false,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'status' => self::IN_PROGRESS->value,
|
|
||||||
'label' => 'Kembalikan ke Proses',
|
|
||||||
'destructive' => true,
|
|
||||||
'permission' => Permission::CUTTINGS_REJECT->value,
|
|
||||||
'icon_only' => false,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
self::REJECTED => [
|
self::REJECTED => [
|
||||||
[
|
[
|
||||||
'status' => self::IN_PROGRESS->value,
|
'status' => self::IN_PROGRESS->value,
|
||||||
|
|||||||
@ -31,7 +31,6 @@ public function index(Request $request): Response
|
|||||||
return Inertia::render('admin/manage/cuttings/Index', [
|
return Inertia::render('admin/manage/cuttings/Index', [
|
||||||
'cuttings' => $this->cuttingService->paginateForIndex($tableQuery, $request->user()),
|
'cuttings' => $this->cuttingService->paginateForIndex($tableQuery, $request->user()),
|
||||||
'inProgressCuttings' => $this->cuttingService->getInProgressCuttings($request->user()),
|
'inProgressCuttings' => $this->cuttingService->getInProgressCuttings($request->user()),
|
||||||
'completedCuttings' => $this->cuttingService->getCompletedCuttings($request->user()),
|
|
||||||
'filters' => $this->dataTableFilters($tableQuery),
|
'filters' => $this->dataTableFilters($tableQuery),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Http\Requests\Admin\Master\RejectProductRequest;
|
use App\Http\Requests\Admin\Master\RejectProductRequest;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
|
use App\Services\Manage\CuttingService;
|
||||||
use App\Services\Master\ProductService;
|
use App\Services\Master\ProductService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@ -24,6 +25,7 @@ class ProductController extends Controller
|
|||||||
use ParsesDataTableQuery;
|
use ParsesDataTableQuery;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly CuttingService $cuttingService,
|
||||||
private readonly ProductService $productService,
|
private readonly ProductService $productService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -36,6 +38,7 @@ public function index(Request $request): Response
|
|||||||
|
|
||||||
return Inertia::render('admin/master/products/Index', [
|
return Inertia::render('admin/master/products/Index', [
|
||||||
'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $status),
|
'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $status),
|
||||||
|
'completedCuttings' => $this->cuttingService->getCompletedCuttings($request->user()),
|
||||||
'outOfStockGroups' => $this->productService->outOfStockGroups(),
|
'outOfStockGroups' => $this->productService->outOfStockGroups(),
|
||||||
'stockWarningGroups' => $this->productService->stockWarningGroups(),
|
'stockWarningGroups' => $this->productService->stockWarningGroups(),
|
||||||
'categories' => $this->productService->categoryOptions(),
|
'categories' => $this->productService->categoryOptions(),
|
||||||
|
|||||||
@ -206,9 +206,15 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ material.material_usage_formatted }}
|
{{ material.material_usage_formatted }}
|
||||||
|
<span v-if="material.uses_length_unit" class="text-muted-foreground">
|
||||||
|
({{ material.material_usage_cm_formatted }})
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ material.remaining_material_formatted }}
|
{{ material.remaining_material_formatted }}
|
||||||
|
<span v-if="material.uses_length_unit" class="text-muted-foreground">
|
||||||
|
({{ material.remaining_material_cm_formatted }})
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -0,0 +1,748 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { router, useForm } from '@inertiajs/vue3';
|
||||||
|
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 { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '@/components/ui/empty';
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSet,
|
||||||
|
} from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
|
import { useCan } from '@/composables/useCan';
|
||||||
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
|
import {
|
||||||
|
PRICE_TYPES,
|
||||||
|
PRICE_TYPE_LABELS,
|
||||||
|
} from '@/types/product';
|
||||||
|
import type { CuttingListItem } from '@/types/cutting';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
cuttings: CuttingListItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { can } = useCan();
|
||||||
|
const open = ref(false);
|
||||||
|
|
||||||
|
const totalCompleted = computed(() => props.cuttings.length);
|
||||||
|
|
||||||
|
// Status transition
|
||||||
|
const statusConfirmOpen = ref(false);
|
||||||
|
const statusProcessing = ref(false);
|
||||||
|
const pendingAction = ref<CuttingStatusAction | null>(null);
|
||||||
|
const activeCutting = ref<CuttingListItem | null>(null);
|
||||||
|
|
||||||
|
// Reject
|
||||||
|
const rejectDialogOpen = ref(false);
|
||||||
|
const rejectForm = useForm({
|
||||||
|
status: 'rejected',
|
||||||
|
reason: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
const verifyDialogOpen = ref(false);
|
||||||
|
const allMatches = ref(true);
|
||||||
|
const useSamePrice = ref(true);
|
||||||
|
const sharedPrices = ref<Record<string, string>>(buildEmptyPrices());
|
||||||
|
|
||||||
|
function buildEmptyPrices(): Record<string, string> {
|
||||||
|
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyForm = useForm({
|
||||||
|
status: 'verified',
|
||||||
|
verification_note: '',
|
||||||
|
results: [] as Array<{
|
||||||
|
product_variant_id: number;
|
||||||
|
name: string;
|
||||||
|
cutting_result: number;
|
||||||
|
warehouse_stock: number;
|
||||||
|
cutting_reject: number;
|
||||||
|
original_warehouse_stock: number;
|
||||||
|
original_cutting_reject: number;
|
||||||
|
prices: Record<string, string>;
|
||||||
|
}>,
|
||||||
|
result_prices: [] as Array<{
|
||||||
|
product_variant_id: number;
|
||||||
|
prices: Array<{ type: string; price: number }>;
|
||||||
|
}>,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(verifyDialogOpen, (isOpen) => {
|
||||||
|
if (isOpen && activeCutting.value) {
|
||||||
|
allMatches.value = true;
|
||||||
|
useSamePrice.value = true;
|
||||||
|
sharedPrices.value = buildEmptyPrices();
|
||||||
|
verifyForm.verification_note = '';
|
||||||
|
verifyForm.results = activeCutting.value.results.map(res => ({
|
||||||
|
product_variant_id: res.product_variant?.id || 0,
|
||||||
|
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
||||||
|
cutting_result: res.cutting_result,
|
||||||
|
warehouse_stock: res.warehouse_stock,
|
||||||
|
cutting_reject: res.cutting_reject,
|
||||||
|
original_warehouse_stock: res.warehouse_stock,
|
||||||
|
original_cutting_reject: res.cutting_reject,
|
||||||
|
prices: buildEmptyPrices(),
|
||||||
|
}));
|
||||||
|
verifyForm.result_prices = [];
|
||||||
|
verifyForm.clearErrors();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(allMatches, (matches) => {
|
||||||
|
if (!matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyForm.results = verifyForm.results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
warehouse_stock: result.original_warehouse_stock,
|
||||||
|
cutting_reject: result.original_cutting_reject,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function setAllMatches(value: boolean) {
|
||||||
|
allMatches.value = value;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
verifyForm.results = verifyForm.results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
warehouse_stock: result.original_warehouse_stock,
|
||||||
|
cutting_reject: result.original_cutting_reject,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSharedPrice(type: string, value: string) {
|
||||||
|
sharedPrices.value = { ...sharedPrices.value, [type]: value };
|
||||||
|
|
||||||
|
if (useSamePrice.value) {
|
||||||
|
verifyForm.results = verifyForm.results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
prices: { ...result.prices, [type]: value },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUseSamePrice(checked: boolean) {
|
||||||
|
useSamePrice.value = checked;
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
// Apply shared prices to all results
|
||||||
|
verifyForm.results = verifyForm.results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
prices: { ...sharedPrices.value },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setResultPrice(resultIndex: number, type: string, value: string) {
|
||||||
|
const result = verifyForm.results[resultIndex];
|
||||||
|
if (!result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.prices = {
|
||||||
|
...result.prices,
|
||||||
|
[type]: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPriceToAllVariants(sourceIndex: number) {
|
||||||
|
const source = verifyForm.results[sourceIndex];
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyForm.results = verifyForm.results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
prices: { ...source.prices },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultPricesPayload() {
|
||||||
|
return verifyForm.results.map((result) => ({
|
||||||
|
product_variant_id: result.product_variant_id,
|
||||||
|
prices: PRICE_TYPES.map((type) => ({
|
||||||
|
type,
|
||||||
|
price: Number.parseInt(parseRupiah(result.prices[type] ?? ''), 10) || 0,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAction(cutting: CuttingListItem, action: CuttingStatusAction) {
|
||||||
|
activeCutting.value = cutting;
|
||||||
|
|
||||||
|
if (action.status === 'verified') {
|
||||||
|
verifyDialogOpen.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.status === 'rejected') {
|
||||||
|
rejectForm.reset();
|
||||||
|
rejectForm.clearErrors();
|
||||||
|
rejectDialogOpen.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingAction.value = action;
|
||||||
|
statusConfirmOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusConfirmDescription(action: CuttingStatusAction): string {
|
||||||
|
if (action.status === 'verified') {
|
||||||
|
return 'Hasil cutting akan diverifikasi. Stok bagus dan reject akan ditambahkan ke produk.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.status === 'in_progress') {
|
||||||
|
return 'Cutting dikembalikan ke proses untuk diperbaiki.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionStatus() {
|
||||||
|
if (!pendingAction.value || !activeCutting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusProcessing.value = true;
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
`/admin/manage/cuttings/${activeCutting.value.id}/status`,
|
||||||
|
{
|
||||||
|
status: pendingAction.value.status,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
statusConfirmOpen.value = false;
|
||||||
|
pendingAction.value = null;
|
||||||
|
activeCutting.value = null;
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
const message = Object.values(errors)[0];
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
typeof message === 'string'
|
||||||
|
? message
|
||||||
|
: 'Gagal memperbarui status cutting.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
statusProcessing.value = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitVerify() {
|
||||||
|
if (!activeCutting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyForm
|
||||||
|
.transform((data) => ({
|
||||||
|
status: data.status,
|
||||||
|
verification_note: data.verification_note,
|
||||||
|
results: data.results.map(({ product_variant_id, warehouse_stock, cutting_reject }) => ({
|
||||||
|
product_variant_id,
|
||||||
|
warehouse_stock,
|
||||||
|
cutting_reject,
|
||||||
|
})),
|
||||||
|
result_prices: buildResultPricesPayload(),
|
||||||
|
}))
|
||||||
|
.post(`/admin/manage/cuttings/${activeCutting.value.id}/status`, {
|
||||||
|
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.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitReject() {
|
||||||
|
if (!activeCutting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectForm.post(`/admin/manage/cuttings/${activeCutting.value.id}/status`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
rejectDialogOpen.value = false;
|
||||||
|
activeCutting.value = null;
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
const message = Object.values(errors)[0];
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
typeof message === 'string'
|
||||||
|
? message
|
||||||
|
: 'Gagal menolak cutting.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(rejectDialogOpen, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
rejectForm.reset();
|
||||||
|
rejectForm.clearErrors();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Collapsible v-model:open="open">
|
||||||
|
<Card class="!py-0">
|
||||||
|
<CollapsibleTrigger
|
||||||
|
class="flex w-full items-center justify-between gap-3 px-6 py-4 text-left transition-colors hover:bg-muted/30">
|
||||||
|
<div class="min-w-0 space-y-1">
|
||||||
|
<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
|
||||||
|
</h3>
|
||||||
|
<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"
|
||||||
|
:class="open ? 'rotate-180' : ''" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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 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 })"
|
||||||
|
>
|
||||||
|
<Check class="size-3.5" />
|
||||||
|
<span class="sr-only">Verifikasi</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Verifikasi</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip v-if="can('cuttings.reject')">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<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 })"
|
||||||
|
>
|
||||||
|
<X class="size-3.5" />
|
||||||
|
<span class="sr-only">Tolak</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Tolak</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip v-if="can('cuttings.reject')">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<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 })"
|
||||||
|
>
|
||||||
|
<RotateCcw class="size-3.5" />
|
||||||
|
<span class="sr-only">Kembalikan ke Proses</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Kembalikan ke Proses</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-3 flex-1 text-xs">
|
||||||
|
<div v-if="cutting.description" class="text-muted-foreground pb-2 border-b">
|
||||||
|
<span class="font-medium text-foreground">Catatan:</span> {{ cutting.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<span class="font-medium text-foreground">Bahan Baku:</span>
|
||||||
|
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
|
||||||
|
<li v-for="mat in cutting.materials" :key="mat.id">
|
||||||
|
{{ mat.raw_material_price?.raw_material?.name }} ({{ mat.raw_material_price?.variant }}) - {{ mat.material_usage_formatted }}
|
||||||
|
</li>
|
||||||
|
<li v-if="!cutting.materials.length">Belum ada bahan baku</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<span class="font-medium text-foreground">Hasil Produk:</span>
|
||||||
|
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
|
||||||
|
<li v-for="res in cutting.results" :key="res.id">
|
||||||
|
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }}) - {{ res.cutting_result }} pcs
|
||||||
|
</li>
|
||||||
|
<li v-if="!cutting.results.length">Belum ada hasil produk</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-[11px] text-muted-foreground pt-2 border-t flex items-center justify-between">
|
||||||
|
<span>Pembuat:</span>
|
||||||
|
<span class="font-medium text-foreground">
|
||||||
|
{{ cutting.created_by?.profile?.full_name ?? cutting.created_by?.username }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Empty v-else class="py-8">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyTitle>Tidak ada cutting menunggu verifikasi</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Semua cutting selesai telah diverifikasi atau belum selesai diproses.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Card>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Reject Dialog -->
|
||||||
|
<Dialog v-model:open="rejectDialogOpen">
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Tolak Cutting</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitReject">
|
||||||
|
<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]
|
||||||
|
: []
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldSet>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<DialogFooter class="mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="rejectForm.processing"
|
||||||
|
@click="rejectDialogOpen = false"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
:disabled="rejectForm.processing"
|
||||||
|
>
|
||||||
|
{{ rejectForm.processing ? 'Menyimpan...' : 'Tolak' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Verify Dialog -->
|
||||||
|
<Dialog v-model:open="verifyDialogOpen">
|
||||||
|
<DialogContent class="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitVerify">
|
||||||
|
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Verifikasi jumlah produk yang diterima di toko dan tentukan harga jual per varian.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<span>{{ activeCutting.total_material_cost_formatted ?? '-' }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="flex justify-between gap-2 text-muted-foreground">
|
||||||
|
<span>Jasa jahit</span>
|
||||||
|
<span>{{ activeCutting.sewing_cost_formatted ?? '-' }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="flex justify-between gap-2 text-muted-foreground">
|
||||||
|
<span>Biaya lainnya</span>
|
||||||
|
<span>{{ activeCutting.other_cost_formatted ?? '-' }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="flex justify-between gap-2 font-medium">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{{ activeCutting.total_production_cost_formatted ?? '-' }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="flex justify-between gap-2 text-muted-foreground">
|
||||||
|
<span>Total hasil</span>
|
||||||
|
<span>{{ activeCutting.total_result_pieces ?? 0 }} pcs</span>
|
||||||
|
</p>
|
||||||
|
<p class="font-semibold tabular-nums pt-1 border-t">
|
||||||
|
{{ activeCutting.estimated_cost_per_unit_formatted }} / pcs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared Price Inputs -->
|
||||||
|
<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)"
|
||||||
|
/>
|
||||||
|
</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 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)"
|
||||||
|
>
|
||||||
|
<Copy class="size-3.5" />
|
||||||
|
Terapkan ke Semua
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2 items-center text-xs">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground block">Hasil Potong:</span>
|
||||||
|
<span class="font-semibold">{{ result.cutting_result }} pcs</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground block mb-0.5">Data cutting:</span>
|
||||||
|
<span class="font-medium tabular-nums">
|
||||||
|
{{ result.original_warehouse_stock }} bagus ·
|
||||||
|
{{ result.original_cutting_reject }} reject
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground block">Stok Reject:</span>
|
||||||
|
<Badge variant="secondary" class="font-semibold">
|
||||||
|
{{ result.cutting_reject }} pcs
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 items-end text-xs">
|
||||||
|
<div>
|
||||||
|
<label :for="`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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<FieldError
|
||||||
|
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="verifyForm.processing"
|
||||||
|
@click="verifyDialogOpen = false"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="verifyForm.processing"
|
||||||
|
>
|
||||||
|
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@ -4,7 +4,6 @@ import { Plus } from '@lucide/vue';
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import CuttingGroupedTable from '@/components/admin/manage/cuttings/CuttingGroupedTable.vue';
|
import CuttingGroupedTable from '@/components/admin/manage/cuttings/CuttingGroupedTable.vue';
|
||||||
import CuttingInProgressSection from '@/components/admin/manage/cuttings/CuttingInProgressSection.vue';
|
import CuttingInProgressSection from '@/components/admin/manage/cuttings/CuttingInProgressSection.vue';
|
||||||
import CuttingCompletedSection from '@/components/admin/manage/cuttings/CuttingCompletedSection.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
@ -15,7 +14,6 @@ import type { PaginatedCuttings, CuttingListItem } from '@/types/cutting';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
cuttings: PaginatedCuttings;
|
cuttings: PaginatedCuttings;
|
||||||
inProgressCuttings: CuttingListItem[];
|
inProgressCuttings: CuttingListItem[];
|
||||||
completedCuttings: CuttingListItem[];
|
|
||||||
filters: {
|
filters: {
|
||||||
search: string;
|
search: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
@ -82,11 +80,6 @@ watch(
|
|||||||
:cuttings="inProgressCuttings"
|
:cuttings="inProgressCuttings"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CuttingCompletedSection
|
|
||||||
v-if="hasAnyRole(['admin-toko', 'owner', 'developer'])"
|
|
||||||
:cuttings="completedCuttings"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card class="min-w-0">
|
<Card class="min-w-0">
|
||||||
<CardContent class="min-w-0">
|
<CardContent class="min-w-0">
|
||||||
<CuttingGroupedTable
|
<CuttingGroupedTable
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Head, Link } from '@inertiajs/vue3';
|
|||||||
import { Plus } from '@lucide/vue';
|
import { Plus } from '@lucide/vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import MasterOutOfStockCatalogSection from '@/components/admin/master/MasterOutOfStockCatalogSection.vue';
|
import MasterOutOfStockCatalogSection from '@/components/admin/master/MasterOutOfStockCatalogSection.vue';
|
||||||
|
import CuttingVerificationSection from '@/components/admin/master/products/CuttingVerificationSection.vue';
|
||||||
import ProductGroupedTable from '@/components/admin/master/products/ProductGroupedTable.vue';
|
import ProductGroupedTable from '@/components/admin/master/products/ProductGroupedTable.vue';
|
||||||
import RejectProductModal from '@/components/admin/master/products/RejectProductModal.vue';
|
import RejectProductModal from '@/components/admin/master/products/RejectProductModal.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -21,9 +22,11 @@ import type {
|
|||||||
ProductOutOfStockGroup,
|
ProductOutOfStockGroup,
|
||||||
ProductStockWarningGroup,
|
ProductStockWarningGroup,
|
||||||
} from '@/types/product';
|
} from '@/types/product';
|
||||||
|
import type { CuttingListItem } from '@/types/cutting';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
products: PaginatedProducts;
|
products: PaginatedProducts;
|
||||||
|
completedCuttings: CuttingListItem[];
|
||||||
outOfStockGroups: ProductOutOfStockGroup[];
|
outOfStockGroups: ProductOutOfStockGroup[];
|
||||||
stockWarningGroups: ProductStockWarningGroup[];
|
stockWarningGroups: ProductStockWarningGroup[];
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
@ -139,6 +142,11 @@ watch(
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CuttingVerificationSection
|
||||||
|
v-if="can('cuttings.verify')"
|
||||||
|
:cuttings="completedCuttings"
|
||||||
|
/>
|
||||||
|
|
||||||
<MasterOutOfStockCatalogSection
|
<MasterOutOfStockCatalogSection
|
||||||
severity="warning"
|
severity="warning"
|
||||||
title="Produk Stok Menipis"
|
title="Produk Stok Menipis"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user