feat: make cutting results nullable and update UI to support create mode

This commit is contained in:
Yoga Pangestu 2026-06-30 21:35:52 +07:00
parent 9f0676c923
commit 4466c61352
15 changed files with 139 additions and 76 deletions

View File

@ -24,9 +24,9 @@ public function rules(): array
'integer',
Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
],
'cutting_result' => ['required', 'integer', 'min:1'],
'sample' => ['required', 'integer', 'min:0'],
'original_outside_sample' => ['required', 'integer', 'min:0'],
'cutting_result' => ['nullable', 'integer', 'min:1'],
'sample' => ['nullable', 'integer', 'min:0'],
'original_outside_sample' => ['nullable', 'integer', 'min:0'],
];
}
}

View File

@ -311,32 +311,40 @@ public function syncDraftResult(array $validated, User $user): array
->with('product:id,name')
->findOrFail($validated['product_variant_id']);
$cuttingResult = (int) $validated['cutting_result'];
$sample = (int) $validated['sample'];
$originalOutsideSample = (int) ($validated['original_outside_sample'] ?? 0);
$cuttingResult = array_key_exists('cutting_result', $validated) && $validated['cutting_result'] !== null
? (int) $validated['cutting_result']
: null;
$sample = array_key_exists('sample', $validated) && $validated['sample'] !== null
? (int) $validated['sample']
: null;
$originalOutsideSample = array_key_exists('original_outside_sample', $validated) && $validated['original_outside_sample'] !== null
? (int) $validated['original_outside_sample']
: null;
if ($cuttingResult < 1) {
throw ValidationException::withMessages([
'cutting_result' => 'Hasil cutting minimal 1 pcs.',
]);
}
if ($cuttingResult !== null) {
if ($cuttingResult < 1) {
throw ValidationException::withMessages([
'cutting_result' => 'Hasil cutting minimal 1 pcs.',
]);
}
if ($sample < 0) {
throw ValidationException::withMessages([
'sample' => 'sample tidak boleh negatif.',
]);
}
if ($sample !== null && $sample < 0) {
throw ValidationException::withMessages([
'sample' => 'sample tidak boleh negatif.',
]);
}
if ($originalOutsideSample < 0) {
throw ValidationException::withMessages([
'original_outside_sample' => 'Hasil cutting diluar sample tidak boleh negatif.',
]);
}
if ($originalOutsideSample !== null && $originalOutsideSample < 0) {
throw ValidationException::withMessages([
'original_outside_sample' => 'Hasil cutting diluar sample tidak boleh negatif.',
]);
}
if (($sample + $originalOutsideSample) !== $cuttingResult) {
throw ValidationException::withMessages([
'cutting_result' => 'Hasil cutting harus sama dengan sample ditambah hasil cutting diluar sample.',
]);
if ($sample !== null && $originalOutsideSample !== null && ($sample + $originalOutsideSample) !== $cuttingResult) {
throw ValidationException::withMessages([
'cutting_result' => 'Hasil cutting harus sama dengan sample ditambah hasil cutting diluar sample.',
]);
}
}
$item = CuttingResult::query()->updateOrCreate(
@ -909,9 +917,9 @@ private function presentDraftResult(CuttingResult $item): array
'product_name' => $variant?->product?->name ?? '',
'variant_name' => $variant?->name ?? '',
'stock' => $variant?->stock ?? 0,
'cutting_result' => (string) $item->cutting_result,
'sample' => (string) $item->sample,
'original_outside_sample' => (string) $item->original_outside_sample,
'cutting_result' => $item->cutting_result !== null ? (string) $item->cutting_result : null,
'sample' => $item->sample !== null ? (string) $item->sample : null,
'original_outside_sample' => $item->original_outside_sample !== null ? (string) $item->original_outside_sample : null,
'images' => $variant ? MediaPresenter::collection($variant, 'images') : [],
];
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cutting_results', function (Blueprint $table) {
$table->unsignedInteger('cutting_result')->nullable()->change();
$table->unsignedInteger('sample')->nullable()->change();
$table->unsignedInteger('original_outside_sample')->nullable()->default(null)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cutting_results', function (Blueprint $table) {
$table->unsignedInteger('cutting_result')->nullable(false)->change();
$table->unsignedInteger('sample')->nullable(false)->change();
$table->unsignedInteger('original_outside_sample')->nullable(false)->default(0)->change();
});
}
};

View File

@ -36,9 +36,9 @@ const initialData = computed(() => ({
product_name: item.product_variant?.product?.name ?? '',
variant_name: item.product_variant?.name ?? '',
stock: item.product_variant?.stock ?? 0,
cutting_result: String(item.cutting_result),
sample: String(item.sample),
original_outside_sample: String(item.original_outside_sample),
cutting_result: String(item.cutting_result ?? 0),
sample: String(item.sample ?? 0),
original_outside_sample: String(item.original_outside_sample ?? 0),
images: item.product_variant?.images ?? [],
})),
}));

View File

@ -140,7 +140,7 @@ const groupedResults = computed<GroupedResults[]>(() => {
{{ material.variant }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="material.images ?? []" />
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" />
</TableCell>
<TableCell class="text-right tabular-nums">
{{ material.material_usage_formatted }}
@ -178,7 +178,7 @@ const groupedResults = computed<GroupedResults[]>(() => {
{{ result.product_variant?.name }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="result.product_variant?.images ?? []" />
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" />
</TableCell>
<TableCell class="text-right tabular-nums">
{{ result.cutting_result }} pcs

View File

@ -13,6 +13,7 @@ defineProps<{
resultCart: CuttingResultCartItem[];
materialLineCost: (item: CuttingMaterialCartItem) => number;
totalMaterialCost: number;
isCreateMode: boolean;
}>();
const open = defineModel<boolean>('open', { required: true });
@ -61,7 +62,7 @@ const open = defineModel<boolean>('open', { required: true });
</p>
</div>
</div>
<div class="mt-1.5 flex items-center gap-3 text-xs tabular-nums">
<div v-if="!isCreateMode" class="mt-1.5 flex items-center gap-3 text-xs tabular-nums">
<span>Hasil: <strong>{{ item.cutting_result }}</strong></span>
<span class="text-muted-foreground">·</span>
<span>Sample: <strong>{{ item.sample }}</strong></span>

View File

@ -153,16 +153,19 @@ function submit() {
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
:get-result-cart-item="getResultCartItem" @add-result="addResult"
:is-create-mode="isCreateMode"
@decrease-result-qty="decreaseResultQty" />
</div>
<CuttingPosSummaryPanel :form="form" :material-cart="materialCart" :result-cart="resultCart"
:total-result-pieces="totalResultPieces" :submit-label="submitLabel" @submit="submit"
:is-create-mode="isCreateMode"
@open-detail="cartDetailOpen = true" @remove-material="removeMaterial"
@sync-material-field="syncMaterialField" @remove-result="removeResult"
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField" />
</div>
<CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart"
:material-line-cost="materialLineCost" :total-material-cost="totalMaterialCost" />
:material-line-cost="materialLineCost" :total-material-cost="totalMaterialCost"
:is-create-mode="isCreateMode" />
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Minus, Plus, Search } from '@lucide/vue';
import { Check, Minus, Plus, Search } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Button } from '@/components/ui/button';
@ -18,6 +18,7 @@ import type { CuttingCatalogVariant } from './useCuttingPosCart';
defineProps<{
filteredProducts: CuttingProductCatalogItem[];
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
isCreateMode: boolean;
}>();
const productSearch = defineModel<string>('productSearch', { required: true });
@ -83,25 +84,41 @@ const emit = defineEmits<{
</p>
</div>
<div v-if="getResultCartItem(variant.id)" class="flex shrink-0 items-center gap-1.5">
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('decrease-result-qty', variant.id)"
>
<Minus class="size-3.5" />
</Button>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{ getResultCartItem(variant.id)!.cutting_result }}
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('add-result', product, variant)"
>
<Plus class="size-3.5" />
</Button>
<template v-if="isCreateMode">
<span class="text-primary mr-1">
<Check class="size-4" />
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
class="hover:text-destructive hover:bg-destructive/10"
@click.stop="emit('decrease-result-qty', variant.id)"
>
<Minus class="size-3.5" />
</Button>
</template>
<template v-else>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('decrease-result-qty', variant.id)"
>
<Minus class="size-3.5" />
</Button>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{ getResultCartItem(variant.id)!.cutting_result }}
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('add-result', product, variant)"
>
<Plus class="size-3.5" />
</Button>
</template>
</div>
<Button
v-else

View File

@ -12,6 +12,7 @@ import type { CuttingResultCartItem } from '@/types/cutting';
defineProps<{
resultCart: CuttingResultCartItem[];
totalResultPieces: number;
isCreateMode: boolean;
}>();
const emit = defineEmits<{
@ -25,7 +26,7 @@ const emit = defineEmits<{
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium">Hasil Produk</p>
<Badge v-if="totalResultPieces > 0" variant="outline" class="tabular-nums text-xs">
<Badge v-if="!isCreateMode && totalResultPieces > 0" variant="outline" class="tabular-nums text-xs">
{{ totalResultPieces }} pcs
</Badge>
</div>
@ -52,7 +53,7 @@ const emit = defineEmits<{
</Button>
</div>
<div class="grid grid-cols-3 gap-2">
<div v-if="!isCreateMode" class="grid grid-cols-3 gap-2">
<Field>
<FieldLabel class="text-xs">Hasil</FieldLabel>
<NumberInput v-model="item.cutting_result" class="h-8"

View File

@ -24,6 +24,7 @@ defineProps<{
resultCart: CuttingResultCartItem[];
totalResultPieces: number;
submitLabel: string;
isCreateMode: boolean;
}>();
const emit = defineEmits<{
@ -54,7 +55,7 @@ const emit = defineEmits<{
>
Lihat Detail
</button>
<Badge v-if="totalResultPieces > 0" variant="secondary" class="tabular-nums font-semibold">
<Badge v-if="!isCreateMode && totalResultPieces > 0" variant="secondary" class="tabular-nums font-semibold">
Total: {{ totalResultPieces }} pcs
</Badge>
</span>
@ -87,6 +88,7 @@ const emit = defineEmits<{
<CuttingPosResultSummaryItems
:result-cart="resultCart"
:total-result-pieces="totalResultPieces"
:is-create-mode="isCreateMode"
@remove="emit('remove-result', $event)"
@sync-totals="emit('sync-result-totals', $event)"
@sync-field="emit('sync-result-field', $event)"

View File

@ -177,9 +177,9 @@ export function useCuttingPosCart(options: {
async function syncDraftResult(
product: CuttingProductCatalogItem,
variant: CuttingCatalogVariant,
cuttingResult: string,
sample: string,
originalOutsideSample: string,
cuttingResult: string | null,
sample: string | null,
originalOutsideSample: string | null,
) {
const { item } = await apiFetch<{ item: CuttingResultCartItem }>(draft_results.store.url(), {
method: 'POST',
@ -196,9 +196,9 @@ export function useCuttingPosCart(options: {
async function syncDraftResultById(
variantId: number,
cuttingResult: string,
sample: string,
originalOutsideSample: string,
cuttingResult: string | null,
sample: string | null,
originalOutsideSample: string | null,
) {
const { item } = await apiFetch<{ item: CuttingResultCartItem }>(draft_results.store.url(), {
method: 'POST',
@ -388,9 +388,9 @@ export function useCuttingPosCart(options: {
await syncDraftResult(
product,
variant,
String(nextQty),
String(nextsample),
String(nextoriginalOutsideSample),
null,
null,
null,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');

View File

@ -111,7 +111,7 @@ const totalCompleted = computed(() => props.cuttings.length);
</div>
<div v-if="cutting.results.length" class="space-y-1.5 pt-2 border-t">
<div class="flex items-center justify-between text-[11px]">
<div v-if="cutting.results.some(res => res.cutting_result !== null && res.cutting_result !== undefined)" 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>

View File

@ -182,7 +182,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
{{ material.variant }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="material.images ?? []" />
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" />
</TableCell>
<TableCell class="tabular-nums">
{{ material.material_usage_formatted }}
@ -225,7 +225,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
{{ result.product_variant?.name }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="result.product_variant?.images ?? []" />
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" />
</TableCell>
<TableCell class="tabular-nums">
{{ result.cutting_result }} pcs

View File

@ -77,15 +77,14 @@ defineProps<{
<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 }})
</li>
<li v-if="!cutting.results.length">Belum ada hasil produk</li>
</ul>
</div>
<div v-if="cutting.results.length" class="space-y-1.5 pt-2 border-t">
<div class="flex items-center justify-between text-[11px]">
<div v-if="cutting.results.some(res => res.cutting_result !== null && res.cutting_result !== undefined)" 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>

View File

@ -122,9 +122,9 @@ export type CuttingResultCartItem = {
product_name: string;
variant_name: string;
stock: number;
cutting_result: string;
sample: string;
original_outside_sample: string;
cutting_result: string | null;
sample: string | null;
original_outside_sample: string | null;
images?: MediaItem[];
};