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,
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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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,

View File

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

View File

@ -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" />

View File

@ -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)">

View File

@ -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';

View File

@ -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')!] : []" />

View File

@ -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')!] : []" />

View File

@ -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';

View File

@ -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,

View File

@ -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';

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,
} 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';