feat: make cutting results nullable and update UI to support create mode
This commit is contained in:
parent
9f0676c923
commit
4466c61352
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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') : [],
|
||||
];
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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 ?? [],
|
||||
})),
|
||||
}));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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.');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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[];
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user