feat: implement NumberInput and DecimalInput components and reorganize form input imports

This commit is contained in:
Yoga Pangestu 2026-06-13 15:00:22 +07:00
parent 2d72854015
commit 15691a97c4
26 changed files with 525 additions and 159 deletions

View File

@ -18,8 +18,8 @@ import {
FieldLabel, FieldLabel,
FieldSet, FieldSet,
} from '@/components/ui/field'; } from '@/components/ui/field';
import ImageUploadField from '@/components/ui/image-upload-field/ImageUploadField.vue'; import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
import { RupiahInput } from '@/components/ui/rupiah-input'; import { RupiahInput } from '@/components/form/rupiah-input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useFormDialog } from '@/composables/useFormDialog'; import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';

View File

@ -19,8 +19,8 @@ import {
FieldLabel, FieldLabel,
FieldSet, FieldSet,
} from '@/components/ui/field'; } from '@/components/ui/field';
import ImageUploadField from '@/components/ui/image-upload-field/ImageUploadField.vue'; import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
import { RupiahInput } from '@/components/ui/rupiah-input'; import { RupiahInput } from '@/components/form/rupiah-input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useFormDialog } from '@/composables/useFormDialog'; import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';

View File

@ -18,8 +18,8 @@ import {
FieldLabel, FieldLabel,
FieldSet, FieldSet,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { ImageUploadField } from '@/components/ui/image-upload-field'; import { ImageUploadField } from '@/components/form/image-upload-field';
import { RupiahInput } from '@/components/ui/rupiah-input'; import { RupiahInput } from '@/components/form/rupiah-input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useFormDialog } from '@/composables/useFormDialog'; import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';

View File

@ -20,7 +20,7 @@ import {
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; 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 { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { parseRupiah } from '@/lib/rupiah'; import { parseRupiah } from '@/lib/rupiah';

View File

@ -14,9 +14,9 @@ import {
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { RupiahInput } from '@/components/ui/rupiah-input'; import { RupiahInput } from '@/components/form/rupiah-input';
import { import {
Select, Select,
SelectContent, SelectContent,

View File

@ -1,9 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
import { Minus, Plus, Save, Scissors, Search, Trash2 } from '@lucide/vue'; import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -21,18 +18,10 @@ import {
FieldSet, FieldSet,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; 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 { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea'; 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 { getFirstCoverImage } from '@/lib/catalog-cover';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
@ -42,6 +31,10 @@ import type {
CuttingRawMaterialCatalogItem, CuttingRawMaterialCatalogItem,
CuttingResultCartItem, CuttingResultCartItem,
} from '@/types/cutting'; } 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<{ const props = defineProps<{
rawMaterialCatalog: CuttingRawMaterialCatalogItem[]; rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
@ -71,7 +64,9 @@ function populateForm() {
} }
form.description = props.initialData.description; 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 })); resultCart.value = props.initialData.results.map((item) => ({ ...item }));
} }
@ -92,9 +87,12 @@ const filteredRawMaterials = computed(() => {
return props.rawMaterialCatalog; return props.rawMaterialCatalog;
} }
return props.rawMaterialCatalog.filter((rawMaterial) => return props.rawMaterialCatalog.filter(
rawMaterial.name.toLowerCase().includes(keyword) (rawMaterial) =>
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)), 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;
} }
return props.productCatalog.filter((product) => return props.productCatalog.filter(
product.name.toLowerCase().includes(keyword) (product) =>
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)), product.name.toLowerCase().includes(keyword) ||
product.variants.some((variant) =>
variant.name.toLowerCase().includes(keyword),
),
); );
}); });
function getMaterialCartItem(priceId: number): CuttingMaterialCartItem | undefined { function getMaterialCartItem(
return materialCart.value.find((item) => item.raw_material_price_id === priceId); priceId: number,
): CuttingMaterialCartItem | undefined {
return materialCart.value.find(
(item) => item.raw_material_price_id === priceId,
);
} }
function decreaseMaterialQty(priceId: number) { 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( const existing = materialCart.value.find(
(item) => item.raw_material_price_id === price.id, (item) => item.raw_material_price_id === price.id,
); );
if (existing) { if (existing) {
existing.material_usage = String((Number(existing.material_usage) || 0) + 1); existing.material_usage = String(
(Number(existing.material_usage) || 0) + 1,
);
return; return;
} }
@ -156,8 +166,12 @@ function removeMaterial(index: number) {
materialCart.value.splice(index, 1); materialCart.value.splice(index, 1);
} }
function getResultCartItem(variantId: number): CuttingResultCartItem | undefined { function getResultCartItem(
return resultCart.value.find((item) => item.product_variant_id === variantId); variantId: number,
): CuttingResultCartItem | undefined {
return resultCart.value.find(
(item) => item.product_variant_id === variantId,
);
} }
function decreaseResultQty(variantId: number) { function decreaseResultQty(variantId: number) {
@ -172,20 +186,29 @@ function decreaseResultQty(variantId: number) {
} else { } else {
item.cutting_result = String(nextQty); item.cutting_result = String(nextQty);
// also adjust warehouse stock or run sync totals // 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); syncResultTotals(item);
} }
} }
} }
function addResult(product: CuttingProductCatalogItem, variant: CuttingProductCatalogItem['variants'][number]) { function addResult(
product: CuttingProductCatalogItem,
variant: CuttingProductCatalogItem['variants'][number],
) {
const existing = resultCart.value.find( const existing = resultCart.value.find(
(item) => item.product_variant_id === variant.id, (item) => item.product_variant_id === variant.id,
); );
if (existing) { if (existing) {
existing.cutting_result = String((Number(existing.cutting_result) || 0) + 1); existing.cutting_result = String(
existing.warehouse_stock = String((Number(existing.warehouse_stock) || 0) + 1); (Number(existing.cutting_result) || 0) + 1,
);
existing.warehouse_stock = String(
(Number(existing.warehouse_stock) || 0) + 1,
);
syncResultTotals(existing); syncResultTotals(existing);
return; return;
} }
@ -233,7 +256,9 @@ function submit() {
const reject = Number(item.cutting_reject) || 0; const reject = Number(item.cutting_reject) || 0;
if (warehouse + reject !== total) { 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; return;
} }
@ -292,64 +317,126 @@ function submit() {
<CardContent> <CardContent>
<div class="space-y-4"> <div class="space-y-4">
<div class="relative"> <div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" /> <Search
<Input v-model="materialSearch" placeholder="Cari bahan baku..." class="pl-9" /> 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>
<div v-if="filteredRawMaterials.length === 0" class="py-8"> <div
v-if="filteredRawMaterials.length === 0"
class="py-8"
>
<Empty> <Empty>
<EmptyHeader> <EmptyHeader>
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle> <EmptyTitle
>Tidak ada bahan baku
ditemukan</EmptyTitle
>
<EmptyDescription> <EmptyDescription>
Silakan lakukan pencarian untuk menemukan bahan baku. Silakan lakukan pencarian untuk
menemukan bahan baku.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
</div> </div>
<div v-else class="columns-1 gap-4 sm:columns-2"> <div v-else class="columns-1 gap-4 sm:columns-2">
<PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id" <PosCatalogCard
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)"> v-for="rawMaterial in filteredRawMaterials"
:key="rawMaterial.id"
:title="rawMaterial.name"
:cover-image="
getFirstCoverImage(rawMaterial.prices)
"
>
<template #header-extra> <template #header-extra>
<Badge variant="secondary" class="mt-1.5"> <Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }} {{ rawMaterial.unit_label }}
</Badge> </Badge>
</template> </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 Belum ada varian
</p> </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="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[ :class="[
'cursor-pointer hover:bg-muted/30', '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)"> @click="
<PosCatalogVariantThumb :items="price.images" /> !getMaterialCartItem(price.id) &&
addMaterial(rawMaterial, price)
"
>
<PosCatalogVariantThumb
:items="price.images"
/>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">
{{ price.variant }} {{ price.variant }}
</p> </p>
<p class="text-xs tabular-nums text-muted-foreground"> <p
class="text-xs text-muted-foreground tabular-nums"
>
Stok: {{ price.stock_formatted }} Stok: {{ price.stock_formatted }}
</p> </p>
</div> </div>
<div v-if="getMaterialCartItem(price.id)" class="flex items-center gap-1.5 shrink-0"> <div
<Button type="button" variant="outline" size="icon-sm" v-if="getMaterialCartItem(price.id)"
@click.stop="decreaseMaterialQty(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" /> <Minus class="size-3.5" />
</Button> </Button>
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums"> <span
{{ getMaterialCartItem(price.id)!.material_usage }} class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums"
>
{{
getMaterialCartItem(price.id)!
.material_usage
}}
</span> </span>
<Button type="button" variant="outline" size="icon-sm" <Button
@click.stop="addMaterial(rawMaterial, price)"> type="button"
variant="outline"
size="icon-sm"
@click.stop="
addMaterial(rawMaterial, price)
"
>
<Plus class="size-3.5" /> <Plus class="size-3.5" />
</Button> </Button>
</div> </div>
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0" <Button
@click.stop="addMaterial(rawMaterial, price)"> v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
@click.stop="
addMaterial(rawMaterial, price)
"
>
<Plus class="size-3.5" /> <Plus class="size-3.5" />
</Button> </Button>
</div> </div>
@ -366,58 +453,119 @@ function submit() {
<CardContent> <CardContent>
<div class="space-y-4"> <div class="space-y-4">
<div class="relative"> <div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" /> <Search
<Input v-model="productSearch" placeholder="Cari produk..." class="pl-9" /> 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>
<div v-if="filteredProducts.length === 0" class="py-8"> <div v-if="filteredProducts.length === 0" class="py-8">
<Empty> <Empty>
<EmptyHeader> <EmptyHeader>
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle> <EmptyTitle
>Tidak ada produk ditemukan</EmptyTitle
>
<EmptyDescription> <EmptyDescription>
Silakan lakukan pencarian untuk menemukan produk. Silakan lakukan pencarian untuk
menemukan produk.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
</div> </div>
<div v-else class="columns-1 gap-4 sm:columns-2"> <div v-else class="columns-1 gap-4 sm:columns-2">
<PosCatalogCard v-for="product in filteredProducts" :key="product.id" :title="product.name" <PosCatalogCard
:cover-image="getFirstCoverImage(product.variants)"> v-for="product in filteredProducts"
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground"> :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 Belum ada varian
</p> </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="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[ :class="[
'cursor-pointer hover:bg-muted/30', '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)"> @click="
<PosCatalogVariantThumb :items="variant.images" /> !getResultCartItem(variant.id) &&
addResult(product, variant)
"
>
<PosCatalogVariantThumb
:items="variant.images"
/>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">
{{ variant.name }} {{ variant.name }}
</p> </p>
<p class="text-xs text-muted-foreground"> <p
<span class="tabular-nums">Stok: {{ variant.stock }} pcs</span> class="text-xs text-muted-foreground"
>
<span class="tabular-nums"
>Stok:
{{ variant.stock }} pcs</span
>
</p> </p>
</div> </div>
<div v-if="getResultCartItem(variant.id)" class="flex items-center gap-1.5 shrink-0"> <div
<Button type="button" variant="outline" size="icon-sm" v-if="getResultCartItem(variant.id)"
@click.stop="decreaseResultQty(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" /> <Minus class="size-3.5" />
</Button> </Button>
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums"> <span
{{ getResultCartItem(variant.id)!.cutting_result }} class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums"
>
{{
getResultCartItem(variant.id)!
.cutting_result
}}
</span> </span>
<Button type="button" variant="outline" size="icon-sm" <Button
@click.stop="addResult(product, variant)"> type="button"
variant="outline"
size="icon-sm"
@click.stop="
addResult(product, variant)
"
>
<Plus class="size-3.5" /> <Plus class="size-3.5" />
</Button> </Button>
</div> </div>
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0" <Button
@click.stop="addResult(product, variant)"> v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
@click.stop="
addResult(product, variant)
"
>
<Plus class="size-3.5" /> <Plus class="size-3.5" />
</Button> </Button>
</div> </div>
@ -440,39 +588,61 @@ function submit() {
<FieldGroup> <FieldGroup>
<FieldSet class="grid gap-4"> <FieldSet class="grid gap-4">
<Field> <Field>
<FieldLabel for="description">Keterangan</FieldLabel> <FieldLabel for="description"
<Textarea id="description" v-model="form.description" >Keterangan</FieldLabel
placeholder="Contoh: Cutting batch pagi" rows="2" >
:maxlength="FIELD_LIMITS.description" /> <Textarea
<FieldError :errors="formErrors(form, 'description')" /> id="description"
v-model="form.description"
placeholder="Contoh: Cutting batch pagi"
rows="2"
:maxlength="FIELD_LIMITS.description"
/>
<FieldError
:errors="formErrors(form, 'description')"
/>
</Field> </Field>
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm font-medium"> <p class="text-sm font-medium">Bahan Baku</p>
Bahan Baku
</p>
<div v-if="materialCart.length === 0" <div
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"> 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. Belum ada bahan baku dipilih.
</div> </div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div v-for="(item, index) in materialCart" :key="item.raw_material_price_id" <div
class="rounded-lg border p-3"> v-for="(item, index) in materialCart"
<div class="mb-2 flex items-start justify-between gap-2"> :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"> <div class="min-w-0">
<p class="truncate text-sm font-medium"> <p
class="truncate text-sm font-medium"
>
{{ item.raw_material_name }} {{ item.raw_material_name }}
</p> </p>
<p class="truncate text-xs text-muted-foreground"> <p
{{ item.variant }} · Stok {{ item.stock_input }} {{ class="truncate text-xs text-muted-foreground"
item.unit_abbreviation }} >
{{ item.variant }} · Stok
{{ item.stock_input }}
{{ item.unit_abbreviation }}
</p> </p>
</div> </div>
<Button type="button" variant="ghost" size="icon" <Button
class="text-destructive hover:text-destructive size-7 shrink-0" type="button"
@click="removeMaterial(index)"> variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="removeMaterial(index)"
>
<Trash2 class="size-3.5" /> <Trash2 class="size-3.5" />
</Button> </Button>
</div> </div>
@ -480,17 +650,29 @@ function submit() {
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<Field> <Field>
<FieldLabel class="text-xs"> <FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }}) Pemakaian ({{
item.unit_abbreviation
}})
</FieldLabel> </FieldLabel>
<Input v-model="item.material_usage" type="number" min="0.0001" <DecimalInput
step="0.0001" class="h-8" /> v-model="
item.material_usage
"
class="h-8"
/>
</Field> </Field>
<Field> <Field>
<FieldLabel class="text-xs"> <FieldLabel class="text-xs">
Sisa ({{ item.unit_abbreviation }}) Sisa ({{
item.unit_abbreviation
}})
</FieldLabel> </FieldLabel>
<Input v-model="item.remaining_material" type="number" min="0" <DecimalInput
step="0.0001" class="h-8" /> v-model="
item.remaining_material
"
class="h-8"
/>
</Field> </Field>
</div> </div>
</div> </div>
@ -500,59 +682,108 @@ function submit() {
<Separator /> <Separator />
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm font-medium"> <p class="text-sm font-medium">Hasil Produk</p>
Hasil Produk
</p>
<div v-if="resultCart.length === 0" <div
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"> 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. Belum ada produk hasil dipilih.
</div> </div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div v-for="(item, index) in resultCart" :key="item.product_variant_id" <div
class="rounded-lg border p-3"> v-for="(item, index) in resultCart"
<div class="mb-2 flex items-start justify-between gap-2"> :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"> <div class="min-w-0">
<p class="truncate text-sm font-medium"> <p
class="truncate text-sm font-medium"
>
{{ item.product_name }} {{ item.product_name }}
</p> </p>
<p class="truncate text-xs text-muted-foreground"> <p
{{ item.variant_name }} · Stok {{ item.stock }} pcs class="truncate text-xs text-muted-foreground"
>
{{ item.variant_name }} ·
Stok {{ item.stock }} pcs
</p> </p>
</div> </div>
<Button type="button" variant="ghost" size="icon" <Button
class="text-destructive hover:text-destructive size-7 shrink-0" type="button"
@click="removeResult(index)"> variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="removeResult(index)"
>
<Trash2 class="size-3.5" /> <Trash2 class="size-3.5" />
</Button> </Button>
</div> </div>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
<Field> <Field>
<FieldLabel class="text-xs">Hasil</FieldLabel> <FieldLabel class="text-xs"
<Input v-model="item.cutting_result" type="number" min="1" step="1" >Hasil</FieldLabel
class="h-8" @change="syncResultTotals(item)" /> >
<NumberInput
v-model="
item.cutting_result
"
class="h-8"
@change="
syncResultTotals(item)
"
/>
</Field> </Field>
<Field> <Field>
<FieldLabel class="text-xs">Gudang</FieldLabel> <FieldLabel class="text-xs"
<Input v-model="item.warehouse_stock" type="number" min="0" step="1" >Gudang</FieldLabel
class="h-8" @change="syncResultTotals(item)" /> >
<NumberInput
v-model="
item.warehouse_stock
"
class="h-8"
@change="
syncResultTotals(item)
"
/>
</Field> </Field>
<Field> <Field>
<FieldLabel class="text-xs">Reject</FieldLabel> <FieldLabel class="text-xs"
<Input v-model="item.cutting_reject" type="number" min="0" step="1" >Reject</FieldLabel
class="h-8" /> >
<NumberInput
v-model="
item.cutting_reject
"
class="h-8"
/>
</Field> </Field>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<Button type="submit" class="w-full" <Button
:disabled="form.processing || materialCart.length === 0 || resultCart.length === 0"> type="submit"
class="w-full"
:disabled="
form.processing ||
materialCart.length === 0 ||
resultCart.length === 0
"
>
<Save class="size-4" /> <Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }} {{
form.processing
? 'Menyimpan...'
: submitLabel
}}
</Button> </Button>
</FieldSet> </FieldSet>
</FieldGroup> </FieldGroup>

View File

@ -21,7 +21,8 @@ import {
FieldSet, FieldSet,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; 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 { import {
Select, Select,
SelectContent, SelectContent,
@ -539,8 +540,8 @@ function submit() {
class="size-8 shrink-0" @click="adjustQuantity(index, -1)"> class="size-8 shrink-0" @click="adjustQuantity(index, -1)">
<Minus class="size-3.5" /> <Minus class="size-3.5" />
</Button> </Button>
<Input v-model="item.quantity" type="number" min="1" step="1" <NumberInput v-model="item.quantity" class="h-8 text-center"
class="h-8 text-center" @change="syncCartItemQuantity(index)" /> @change="syncCartItemQuantity(index)" />
<Button type="button" variant="outline" size="icon" <Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)"> class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" /> <Plus class="size-3.5" />

View File

@ -21,9 +21,10 @@ import {
FieldLabel, FieldLabel,
FieldSet, FieldSet,
} from '@/components/ui/field'; } 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 { 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 { import {
Select, Select,
SelectContent, SelectContent,
@ -476,8 +477,7 @@ function submit() {
class="size-8 shrink-0" @click="adjustQuantity(index, -1)"> class="size-8 shrink-0" @click="adjustQuantity(index, -1)">
<Minus class="size-3.5" /> <Minus class="size-3.5" />
</Button> </Button>
<Input v-model="item.quantity" type="number" min="0.0001" <DecimalInput v-model="item.quantity" class="h-8 text-center"
step="0.0001" class="h-8 text-center"
@change="syncCartItemQuantity(index)" /> @change="syncCartItemQuantity(index)" />
<Button type="button" variant="outline" size="icon" <Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)"> class="size-8 shrink-0" @click="adjustQuantity(index, 1)">

View File

@ -19,7 +19,7 @@ import {
FieldSet, FieldSet,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; 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 { Textarea } from '@/components/ui/textarea';
import { useFormDialog } from '@/composables/useFormDialog'; import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';

View File

@ -12,9 +12,10 @@ import {
FieldLabel, FieldLabel,
FieldSet, FieldSet,
} from '@/components/ui/field'; } 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 { 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 { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
@ -378,8 +379,7 @@ function submit() {
<FieldLabel :for="`variant_stock_${variant.client_id}`" required> <FieldLabel :for="`variant_stock_${variant.client_id}`" required>
Stok Stok
</FieldLabel> </FieldLabel>
<Input :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock" <NumberInput :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
type="number" min="0"
@update:model-value="setVariantField(variant.client_id, 'stock', String($event))" /> @update:model-value="setVariantField(variant.client_id, 'stock', String($event))" />
<FieldError <FieldError
:errors="variantError(variant.client_id, 'stock') ? [variantError(variant.client_id, 'stock')!] : []" /> :errors="variantError(variant.client_id, 'stock') ? [variantError(variant.client_id, 'stock')!] : []" />

View File

@ -12,9 +12,10 @@ import {
FieldLabel, FieldLabel,
FieldSet, FieldSet,
} from '@/components/ui/field'; } 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 { 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 { import {
Select, Select,
SelectContent, SelectContent,
@ -311,8 +312,7 @@ function submit() {
<FieldLabel :for="`stock_${price.client_id}`" required> <FieldLabel :for="`stock_${price.client_id}`" required>
Stok Stok
</FieldLabel> </FieldLabel>
<Input :id="`stock_${price.client_id}`" :model-value="price.stock" type="number" min="0" <DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
step="0.0001"
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" /> @update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
<FieldError <FieldError
:errors="priceError(price.client_id, 'stock') ? [priceError(price.client_id, 'stock')!] : []" /> :errors="priceError(price.client_id, 'stock') ? [priceError(price.client_id, 'stock')!] : []" />

View File

@ -19,7 +19,7 @@ import {
FieldSet, FieldSet,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; 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 { Textarea } from '@/components/ui/textarea';
import { useFormDialog } from '@/composables/useFormDialog'; import { useFormDialog } from '@/composables/useFormDialog';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';

View File

@ -6,7 +6,7 @@ import {
FieldLabel, FieldLabel,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RupiahInput } from '@/components/ui/rupiah-input'; import { RupiahInput } from '@/components/form/rupiah-input';
import { import {
Select, Select,
SelectContent, SelectContent,

View File

@ -5,9 +5,9 @@ import { toast } from 'vue-sonner';
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 { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field'; 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 { 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 { Textarea } from '@/components/ui/textarea';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import type { SystemSettingsData } from '@/types/setting'; import type { SystemSettingsData } from '@/types/setting';

View 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>

View File

@ -0,0 +1 @@
export { default as DecimalInput } from './DecimalInput.vue';

View 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>

View File

@ -0,0 +1 @@
export { default as NumberInput } from './NumberInput.vue';

View File

@ -13,7 +13,7 @@ import {
FieldSet, FieldSet,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; 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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import AccountLayout from '@/layouts/AccountLayout.vue'; import AccountLayout from '@/layouts/AccountLayout.vue';