feat: implement NumberInput and DecimalInput components and reorganize form input imports
This commit is contained in:
parent
2d72854015
commit
15691a97c4
@ -18,8 +18,8 @@ import {
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import ImageUploadField from '@/components/ui/image-upload-field/ImageUploadField.vue';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
|
||||
@ -19,8 +19,8 @@ import {
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import ImageUploadField from '@/components/ui/image-upload-field/ImageUploadField.vue';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
|
||||
@ -18,8 +18,8 @@ import {
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { ImageUploadField } from '@/components/ui/image-upload-field';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { ImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
} from '@/components/ui/field';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
|
||||
@ -14,9 +14,9 @@ import {
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PhoneNumberInput } from '@/components/ui/phone-number-input';
|
||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Minus, Plus, Save, Scissors, Search, Trash2 } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -21,18 +18,10 @@ import {
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DecimalInput } from '@/components/form/decimal-input';
|
||||
import { NumberInput } from '@/components/form/number-input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
|
||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
@ -42,6 +31,10 @@ import type {
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Minus, Plus, Save, Scissors, Search, Trash2 } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
const props = defineProps<{
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
@ -71,7 +64,9 @@ function populateForm() {
|
||||
}
|
||||
|
||||
form.description = props.initialData.description;
|
||||
materialCart.value = props.initialData.materials.map((item) => ({ ...item }));
|
||||
materialCart.value = props.initialData.materials.map((item) => ({
|
||||
...item,
|
||||
}));
|
||||
resultCart.value = props.initialData.results.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
@ -92,9 +87,12 @@ const filteredRawMaterials = computed(() => {
|
||||
return props.rawMaterialCatalog;
|
||||
}
|
||||
|
||||
return props.rawMaterialCatalog.filter((rawMaterial) =>
|
||||
rawMaterial.name.toLowerCase().includes(keyword)
|
||||
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
|
||||
return props.rawMaterialCatalog.filter(
|
||||
(rawMaterial) =>
|
||||
rawMaterial.name.toLowerCase().includes(keyword) ||
|
||||
rawMaterial.prices.some((price) =>
|
||||
price.variant.toLowerCase().includes(keyword),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@ -105,14 +103,21 @@ const filteredProducts = computed(() => {
|
||||
return props.productCatalog;
|
||||
}
|
||||
|
||||
return props.productCatalog.filter((product) =>
|
||||
product.name.toLowerCase().includes(keyword)
|
||||
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)),
|
||||
return props.productCatalog.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(keyword) ||
|
||||
product.variants.some((variant) =>
|
||||
variant.name.toLowerCase().includes(keyword),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function getMaterialCartItem(priceId: number): CuttingMaterialCartItem | undefined {
|
||||
return materialCart.value.find((item) => item.raw_material_price_id === priceId);
|
||||
function getMaterialCartItem(
|
||||
priceId: number,
|
||||
): CuttingMaterialCartItem | undefined {
|
||||
return materialCart.value.find(
|
||||
(item) => item.raw_material_price_id === priceId,
|
||||
);
|
||||
}
|
||||
|
||||
function decreaseMaterialQty(priceId: number) {
|
||||
@ -130,13 +135,18 @@ function decreaseMaterialQty(priceId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function addMaterial(rawMaterial: CuttingRawMaterialCatalogItem, price: CatalogPrice) {
|
||||
function addMaterial(
|
||||
rawMaterial: CuttingRawMaterialCatalogItem,
|
||||
price: CatalogPrice,
|
||||
) {
|
||||
const existing = materialCart.value.find(
|
||||
(item) => item.raw_material_price_id === price.id,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.material_usage = String((Number(existing.material_usage) || 0) + 1);
|
||||
existing.material_usage = String(
|
||||
(Number(existing.material_usage) || 0) + 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -156,8 +166,12 @@ function removeMaterial(index: number) {
|
||||
materialCart.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function getResultCartItem(variantId: number): CuttingResultCartItem | undefined {
|
||||
return resultCart.value.find((item) => item.product_variant_id === variantId);
|
||||
function getResultCartItem(
|
||||
variantId: number,
|
||||
): CuttingResultCartItem | undefined {
|
||||
return resultCart.value.find(
|
||||
(item) => item.product_variant_id === variantId,
|
||||
);
|
||||
}
|
||||
|
||||
function decreaseResultQty(variantId: number) {
|
||||
@ -172,20 +186,29 @@ function decreaseResultQty(variantId: number) {
|
||||
} else {
|
||||
item.cutting_result = String(nextQty);
|
||||
// also adjust warehouse stock or run sync totals
|
||||
item.warehouse_stock = String(Math.max(0, (Number(item.warehouse_stock) || 0) - 1));
|
||||
item.warehouse_stock = String(
|
||||
Math.max(0, (Number(item.warehouse_stock) || 0) - 1),
|
||||
);
|
||||
syncResultTotals(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addResult(product: CuttingProductCatalogItem, variant: CuttingProductCatalogItem['variants'][number]) {
|
||||
function addResult(
|
||||
product: CuttingProductCatalogItem,
|
||||
variant: CuttingProductCatalogItem['variants'][number],
|
||||
) {
|
||||
const existing = resultCart.value.find(
|
||||
(item) => item.product_variant_id === variant.id,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.cutting_result = String((Number(existing.cutting_result) || 0) + 1);
|
||||
existing.warehouse_stock = String((Number(existing.warehouse_stock) || 0) + 1);
|
||||
existing.cutting_result = String(
|
||||
(Number(existing.cutting_result) || 0) + 1,
|
||||
);
|
||||
existing.warehouse_stock = String(
|
||||
(Number(existing.warehouse_stock) || 0) + 1,
|
||||
);
|
||||
syncResultTotals(existing);
|
||||
return;
|
||||
}
|
||||
@ -233,7 +256,9 @@ function submit() {
|
||||
const reject = Number(item.cutting_reject) || 0;
|
||||
|
||||
if (warehouse + reject !== total) {
|
||||
toast.error(`Hasil cutting ${item.product_name} - ${item.variant_name} harus sama dengan stok gudang + reject.`);
|
||||
toast.error(
|
||||
`Hasil cutting ${item.product_name} - ${item.variant_name} harus sama dengan stok gudang + reject.`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@ -292,64 +317,126 @@ function submit() {
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="materialSearch" placeholder="Cari bahan baku..." class="pl-9" />
|
||||
<Search
|
||||
class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
v-model="materialSearch"
|
||||
placeholder="Cari bahan baku..."
|
||||
class="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredRawMaterials.length === 0" class="py-8">
|
||||
<div
|
||||
v-if="filteredRawMaterials.length === 0"
|
||||
class="py-8"
|
||||
>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle>
|
||||
<EmptyTitle
|
||||
>Tidak ada bahan baku
|
||||
ditemukan</EmptyTitle
|
||||
>
|
||||
<EmptyDescription>
|
||||
Silakan lakukan pencarian untuk menemukan bahan baku.
|
||||
Silakan lakukan pencarian untuk
|
||||
menemukan bahan baku.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2">
|
||||
<PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
|
||||
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
|
||||
<PosCatalogCard
|
||||
v-for="rawMaterial in filteredRawMaterials"
|
||||
:key="rawMaterial.id"
|
||||
:title="rawMaterial.name"
|
||||
:cover-image="
|
||||
getFirstCoverImage(rawMaterial.prices)
|
||||
"
|
||||
>
|
||||
<template #header-extra>
|
||||
<Badge variant="secondary" class="mt-1.5">
|
||||
{{ rawMaterial.unit_label }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
|
||||
<p
|
||||
v-if="!rawMaterial.prices.length"
|
||||
class="px-3 py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
Belum ada varian
|
||||
</p>
|
||||
<div v-for="price in rawMaterial.prices" :key="price.id"
|
||||
<div
|
||||
v-for="price in rawMaterial.prices"
|
||||
:key="price.id"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
|
||||
:class="[
|
||||
'cursor-pointer hover:bg-muted/30',
|
||||
getMaterialCartItem(price.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
|
||||
getMaterialCartItem(price.id)
|
||||
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
|
||||
: '',
|
||||
]"
|
||||
@click="!getMaterialCartItem(price.id) && addMaterial(rawMaterial, price)">
|
||||
<PosCatalogVariantThumb :items="price.images" />
|
||||
@click="
|
||||
!getMaterialCartItem(price.id) &&
|
||||
addMaterial(rawMaterial, price)
|
||||
"
|
||||
>
|
||||
<PosCatalogVariantThumb
|
||||
:items="price.images"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ price.variant }}
|
||||
</p>
|
||||
<p class="text-xs tabular-nums text-muted-foreground">
|
||||
<p
|
||||
class="text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
Stok: {{ price.stock_formatted }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="getMaterialCartItem(price.id)" class="flex items-center gap-1.5 shrink-0">
|
||||
<Button type="button" variant="outline" size="icon-sm"
|
||||
@click.stop="decreaseMaterialQty(price.id)">
|
||||
<div
|
||||
v-if="getMaterialCartItem(price.id)"
|
||||
class="flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click.stop="
|
||||
decreaseMaterialQty(price.id)
|
||||
"
|
||||
>
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums">
|
||||
{{ getMaterialCartItem(price.id)!.material_usage }}
|
||||
<span
|
||||
class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums"
|
||||
>
|
||||
{{
|
||||
getMaterialCartItem(price.id)!
|
||||
.material_usage
|
||||
}}
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="icon-sm"
|
||||
@click.stop="addMaterial(rawMaterial, price)">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click.stop="
|
||||
addMaterial(rawMaterial, price)
|
||||
"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
|
||||
@click.stop="addMaterial(rawMaterial, price)">
|
||||
<Button
|
||||
v-else
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="shrink-0"
|
||||
@click.stop="
|
||||
addMaterial(rawMaterial, price)
|
||||
"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -366,58 +453,119 @@ function submit() {
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="productSearch" placeholder="Cari produk..." class="pl-9" />
|
||||
<Search
|
||||
class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
v-model="productSearch"
|
||||
placeholder="Cari produk..."
|
||||
class="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredProducts.length === 0" class="py-8">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
|
||||
<EmptyTitle
|
||||
>Tidak ada produk ditemukan</EmptyTitle
|
||||
>
|
||||
<EmptyDescription>
|
||||
Silakan lakukan pencarian untuk menemukan produk.
|
||||
Silakan lakukan pencarian untuk
|
||||
menemukan produk.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2">
|
||||
<PosCatalogCard v-for="product in filteredProducts" :key="product.id" :title="product.name"
|
||||
:cover-image="getFirstCoverImage(product.variants)">
|
||||
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
|
||||
<PosCatalogCard
|
||||
v-for="product in filteredProducts"
|
||||
:key="product.id"
|
||||
:title="product.name"
|
||||
:cover-image="
|
||||
getFirstCoverImage(product.variants)
|
||||
"
|
||||
>
|
||||
<p
|
||||
v-if="!product.variants.length"
|
||||
class="px-3 py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
Belum ada varian
|
||||
</p>
|
||||
<div v-for="variant in product.variants" :key="variant.id"
|
||||
<div
|
||||
v-for="variant in product.variants"
|
||||
:key="variant.id"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
|
||||
:class="[
|
||||
'cursor-pointer hover:bg-muted/30',
|
||||
getResultCartItem(variant.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
|
||||
getResultCartItem(variant.id)
|
||||
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
|
||||
: '',
|
||||
]"
|
||||
@click="!getResultCartItem(variant.id) && addResult(product, variant)">
|
||||
<PosCatalogVariantThumb :items="variant.images" />
|
||||
@click="
|
||||
!getResultCartItem(variant.id) &&
|
||||
addResult(product, variant)
|
||||
"
|
||||
>
|
||||
<PosCatalogVariantThumb
|
||||
:items="variant.images"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ variant.name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span class="tabular-nums">Stok: {{ variant.stock }} pcs</span>
|
||||
<p
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
<span class="tabular-nums"
|
||||
>Stok:
|
||||
{{ variant.stock }} pcs</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="getResultCartItem(variant.id)" class="flex items-center gap-1.5 shrink-0">
|
||||
<Button type="button" variant="outline" size="icon-sm"
|
||||
@click.stop="decreaseResultQty(variant.id)">
|
||||
<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="
|
||||
decreaseResultQty(variant.id)
|
||||
"
|
||||
>
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums">
|
||||
{{ getResultCartItem(variant.id)!.cutting_result }}
|
||||
<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="addResult(product, variant)">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click.stop="
|
||||
addResult(product, variant)
|
||||
"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
|
||||
@click.stop="addResult(product, variant)">
|
||||
<Button
|
||||
v-else
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="shrink-0"
|
||||
@click.stop="
|
||||
addResult(product, variant)
|
||||
"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -440,39 +588,61 @@ function submit() {
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="description">Keterangan</FieldLabel>
|
||||
<Textarea id="description" v-model="form.description"
|
||||
placeholder="Contoh: Cutting batch pagi" rows="2"
|
||||
:maxlength="FIELD_LIMITS.description" />
|
||||
<FieldError :errors="formErrors(form, 'description')" />
|
||||
<FieldLabel for="description"
|
||||
>Keterangan</FieldLabel
|
||||
>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="Contoh: Cutting batch pagi"
|
||||
rows="2"
|
||||
:maxlength="FIELD_LIMITS.description"
|
||||
/>
|
||||
<FieldError
|
||||
:errors="formErrors(form, 'description')"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">
|
||||
Bahan Baku
|
||||
</p>
|
||||
<p class="text-sm font-medium">Bahan Baku</p>
|
||||
|
||||
<div v-if="materialCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
<div
|
||||
v-if="materialCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
Belum ada bahan baku dipilih.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="(item, index) in materialCart" :key="item.raw_material_price_id"
|
||||
class="rounded-lg border p-3">
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<div
|
||||
v-for="(item, index) in materialCart"
|
||||
:key="item.raw_material_price_id"
|
||||
class="rounded-lg border p-3"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex items-start justify-between gap-2"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
>
|
||||
{{ item.raw_material_name }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ item.variant }} · Stok {{ item.stock_input }} {{
|
||||
item.unit_abbreviation }}
|
||||
<p
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ item.variant }} · Stok
|
||||
{{ item.stock_input }}
|
||||
{{ item.unit_abbreviation }}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
class="text-destructive hover:text-destructive size-7 shrink-0"
|
||||
@click="removeMaterial(index)">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||
@click="removeMaterial(index)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -480,17 +650,29 @@ function submit() {
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">
|
||||
Pemakaian ({{ item.unit_abbreviation }})
|
||||
Pemakaian ({{
|
||||
item.unit_abbreviation
|
||||
}})
|
||||
</FieldLabel>
|
||||
<Input v-model="item.material_usage" type="number" min="0.0001"
|
||||
step="0.0001" class="h-8" />
|
||||
<DecimalInput
|
||||
v-model="
|
||||
item.material_usage
|
||||
"
|
||||
class="h-8"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">
|
||||
Sisa ({{ item.unit_abbreviation }})
|
||||
Sisa ({{
|
||||
item.unit_abbreviation
|
||||
}})
|
||||
</FieldLabel>
|
||||
<Input v-model="item.remaining_material" type="number" min="0"
|
||||
step="0.0001" class="h-8" />
|
||||
<DecimalInput
|
||||
v-model="
|
||||
item.remaining_material
|
||||
"
|
||||
class="h-8"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@ -500,59 +682,108 @@ function submit() {
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">
|
||||
Hasil Produk
|
||||
</p>
|
||||
<p class="text-sm font-medium">Hasil Produk</p>
|
||||
|
||||
<div v-if="resultCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
<div
|
||||
v-if="resultCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
Belum ada produk hasil dipilih.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="(item, index) in resultCart" :key="item.product_variant_id"
|
||||
class="rounded-lg border p-3">
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<div
|
||||
v-for="(item, index) in resultCart"
|
||||
:key="item.product_variant_id"
|
||||
class="rounded-lg border p-3"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex items-start justify-between gap-2"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
>
|
||||
{{ item.product_name }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ item.variant_name }} · Stok {{ item.stock }} pcs
|
||||
<p
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ item.variant_name }} ·
|
||||
Stok {{ item.stock }} pcs
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon"
|
||||
class="text-destructive hover:text-destructive size-7 shrink-0"
|
||||
@click="removeResult(index)">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||
@click="removeResult(index)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Hasil</FieldLabel>
|
||||
<Input v-model="item.cutting_result" type="number" min="1" step="1"
|
||||
class="h-8" @change="syncResultTotals(item)" />
|
||||
<FieldLabel class="text-xs"
|
||||
>Hasil</FieldLabel
|
||||
>
|
||||
<NumberInput
|
||||
v-model="
|
||||
item.cutting_result
|
||||
"
|
||||
class="h-8"
|
||||
@change="
|
||||
syncResultTotals(item)
|
||||
"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Gudang</FieldLabel>
|
||||
<Input v-model="item.warehouse_stock" type="number" min="0" step="1"
|
||||
class="h-8" @change="syncResultTotals(item)" />
|
||||
<FieldLabel class="text-xs"
|
||||
>Gudang</FieldLabel
|
||||
>
|
||||
<NumberInput
|
||||
v-model="
|
||||
item.warehouse_stock
|
||||
"
|
||||
class="h-8"
|
||||
@change="
|
||||
syncResultTotals(item)
|
||||
"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel class="text-xs">Reject</FieldLabel>
|
||||
<Input v-model="item.cutting_reject" type="number" min="0" step="1"
|
||||
class="h-8" />
|
||||
<FieldLabel class="text-xs"
|
||||
>Reject</FieldLabel
|
||||
>
|
||||
<NumberInput
|
||||
v-model="
|
||||
item.cutting_reject
|
||||
"
|
||||
class="h-8"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full"
|
||||
:disabled="form.processing || materialCart.length === 0 || resultCart.length === 0">
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
materialCart.length === 0 ||
|
||||
resultCart.length === 0
|
||||
"
|
||||
>
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{
|
||||
form.processing
|
||||
? 'Menyimpan...'
|
||||
: submitLabel
|
||||
}}
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
@ -21,7 +21,8 @@ import {
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { NumberInput } from '@/components/form/number-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -539,8 +540,8 @@ function submit() {
|
||||
class="size-8 shrink-0" @click="adjustQuantity(index, -1)">
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<Input v-model="item.quantity" type="number" min="1" step="1"
|
||||
class="h-8 text-center" @change="syncCartItemQuantity(index)" />
|
||||
<NumberInput v-model="item.quantity" class="h-8 text-center"
|
||||
@change="syncCartItemQuantity(index)" />
|
||||
<Button type="button" variant="outline" size="icon"
|
||||
class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
|
||||
<Plus class="size-3.5" />
|
||||
|
||||
@ -21,9 +21,10 @@ import {
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { ImageUploadField } from '@/components/ui/image-upload-field';
|
||||
import { ImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { DecimalInput } from '@/components/form/decimal-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -476,8 +477,7 @@ function submit() {
|
||||
class="size-8 shrink-0" @click="adjustQuantity(index, -1)">
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<Input v-model="item.quantity" type="number" min="0.0001"
|
||||
step="0.0001" class="h-8 text-center"
|
||||
<DecimalInput v-model="item.quantity" class="h-8 text-center"
|
||||
@change="syncCartItemQuantity(index)" />
|
||||
<Button type="button" variant="outline" size="icon"
|
||||
class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
|
||||
|
||||
@ -19,7 +19,7 @@ import {
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneNumberInput } from '@/components/ui/phone-number-input';
|
||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
|
||||
@ -12,9 +12,10 @@ import {
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { MultipleImageUploadField } from '@/components/ui/image-upload-field';
|
||||
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { NumberInput } from '@/components/form/number-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
@ -378,8 +379,7 @@ function submit() {
|
||||
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
|
||||
Stok
|
||||
</FieldLabel>
|
||||
<Input :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
|
||||
type="number" min="0"
|
||||
<NumberInput :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
|
||||
@update:model-value="setVariantField(variant.client_id, 'stock', String($event))" />
|
||||
<FieldError
|
||||
:errors="variantError(variant.client_id, 'stock') ? [variantError(variant.client_id, 'stock')!] : []" />
|
||||
|
||||
@ -12,9 +12,10 @@ import {
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { MultipleImageUploadField } from '@/components/ui/image-upload-field';
|
||||
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { DecimalInput } from '@/components/form/decimal-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -311,8 +312,7 @@ function submit() {
|
||||
<FieldLabel :for="`stock_${price.client_id}`" required>
|
||||
Stok
|
||||
</FieldLabel>
|
||||
<Input :id="`stock_${price.client_id}`" :model-value="price.stock" type="number" min="0"
|
||||
step="0.0001"
|
||||
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
|
||||
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
|
||||
<FieldError
|
||||
:errors="priceError(price.client_id, 'stock') ? [priceError(price.client_id, 'stock')!] : []" />
|
||||
|
||||
@ -19,7 +19,7 @@ import {
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneNumberInput } from '@/components/ui/phone-number-input';
|
||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
||||
@ -5,9 +5,9 @@ import { toast } from 'vue-sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
|
||||
import { ImageUploadField } from '@/components/ui/image-upload-field';
|
||||
import { ImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneNumberInput } from '@/components/ui/phone-number-input';
|
||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { SystemSettingsData } from '@/types/setting';
|
||||
|
||||
85
resources/js/components/form/decimal-input/DecimalInput.vue
Normal file
85
resources/js/components/form/decimal-input/DecimalInput.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string;
|
||||
modelValue?: string | number;
|
||||
class?: HTMLAttributes['class'];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
// Formats JS float string/number (e.g. 1250.75) to ID decimal string (e.g. 1250,75)
|
||||
function formatDecimal(value: string | number): string {
|
||||
const str = String(value ?? '').trim();
|
||||
if (!str) return '';
|
||||
|
||||
// Normalize to dot for processing
|
||||
const normalized = str.replace(',', '.');
|
||||
if (isNaN(parseFloat(normalized))) return '';
|
||||
|
||||
// Split into integer and decimal parts
|
||||
const parts = normalized.split('.');
|
||||
const integerPart = parts[0];
|
||||
let decimalPart = parts[1];
|
||||
|
||||
if (decimalPart !== undefined) {
|
||||
decimalPart = decimalPart.slice(0, 2);
|
||||
return `${integerPart},${decimalPart}`;
|
||||
}
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Parses ID decimal string (e.g. 1250,75) back to JS float string (e.g. 1250.75)
|
||||
function parseDecimal(value: string): string {
|
||||
let normalized = value;
|
||||
const hasComma = value.includes(',');
|
||||
const dotCount = (value.match(/\./g) || []).length;
|
||||
|
||||
if (!hasComma && dotCount === 1) {
|
||||
normalized = value.replace('.', ',');
|
||||
}
|
||||
|
||||
// Remove all dots (if any were entered as thousands) and replace comma with dot
|
||||
let clean = normalized.replace(/\./g, '').replace(',', '.');
|
||||
|
||||
const parts = clean.split('.');
|
||||
if (parts.length > 2) {
|
||||
clean = parts[0] + '.' + parts.slice(1).join('');
|
||||
}
|
||||
|
||||
clean = clean.replace(/[^0-9.]/g, '');
|
||||
|
||||
const cleanParts = clean.split('.');
|
||||
if (cleanParts[1] !== undefined) {
|
||||
clean = `${cleanParts[0]}.${cleanParts[1].slice(0, 2)}`;
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
const displayValue = computed({
|
||||
get: () => formatDecimal(props.modelValue ?? ''),
|
||||
set: (value: string) => {
|
||||
emit('update:modelValue', parseDecimal(value));
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input
|
||||
:id="id"
|
||||
v-model="displayValue"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder ?? '0'"
|
||||
:disabled="disabled"
|
||||
:class="props.class"
|
||||
/>
|
||||
</template>
|
||||
1
resources/js/components/form/decimal-input/index.ts
Normal file
1
resources/js/components/form/decimal-input/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as DecimalInput } from './DecimalInput.vue';
|
||||
47
resources/js/components/form/number-input/NumberInput.vue
Normal file
47
resources/js/components/form/number-input/NumberInput.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string;
|
||||
modelValue?: string | number;
|
||||
class?: HTMLAttributes['class'];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
function formatNumber(value: string | number): string {
|
||||
const str = String(value ?? '').trim();
|
||||
const clean = str.replace(/\D/g, '');
|
||||
if (!clean) return '';
|
||||
return Number(clean).toLocaleString('id-ID');
|
||||
}
|
||||
|
||||
function parseNumber(value: string): string {
|
||||
return value.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
const displayValue = computed({
|
||||
get: () => formatNumber(props.modelValue ?? ''),
|
||||
set: (value: string) => {
|
||||
emit('update:modelValue', parseNumber(value));
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input
|
||||
:id="id"
|
||||
v-model="displayValue"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder ?? '0'"
|
||||
:disabled="disabled"
|
||||
:class="props.class"
|
||||
/>
|
||||
</template>
|
||||
1
resources/js/components/form/number-input/index.ts
Normal file
1
resources/js/components/form/number-input/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as NumberInput } from './NumberInput.vue';
|
||||
@ -13,7 +13,7 @@ import {
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneNumberInput } from '@/components/ui/phone-number-input';
|
||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import AccountLayout from '@/layouts/AccountLayout.vue';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user