feat: add variant search functionality in CuttingCreate and CuttingEdit components

This commit is contained in:
Yoga Pangestu 2026-08-14 04:42:36 +07:00
parent dd9ce7df85
commit 45a970e2f1
2 changed files with 168 additions and 26 deletions

View File

@ -1,7 +1,7 @@
'use no memo';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
@ -76,6 +76,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
return [];
});
const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? '');
const [variantSearch, setVariantSearch] = useState('');
const [productName, setProductName] = useState(draft?.productName ?? '');
const [sample, setSample] = useState(draft?.sample ?? 0);
@ -149,13 +150,22 @@ export default function CuttingCreate({ rawMaterials }: Props) {
[rawMaterials, selectedMaterialName],
);
const addVariant = useCallback(
(priceId: number) => {
if (!selectedMaterial) {
return;
}
const groupedVariants = useMemo(() => {
if (!variantSearch) return [];
const search = variantSearch.toLowerCase();
return rawMaterials
.map((rm) => ({
...rm,
raw_material_prices: rm.raw_material_prices.filter(
(p) => p.variant.toLowerCase().includes(search),
),
}))
.filter((rm) => rm.raw_material_prices.length > 0);
}, [rawMaterials, variantSearch]);
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
const addVariant = useCallback(
(rawMaterial: (typeof rawMaterials)[number], priceId: number) => {
const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) {
return;
@ -174,14 +184,14 @@ export default function CuttingCreate({ rawMaterials }: Props) {
material_result: 0,
combination_id: null,
variant: price.variant,
material_name: selectedMaterial.name,
unit: selectedMaterial.unit,
material_name: rawMaterial.name,
unit: rawMaterial.unit,
photo_url: price.photo_url,
},
];
});
},
[selectedMaterial],
[],
);
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
@ -352,9 +362,70 @@ export default function CuttingCreate({ rawMaterials }: Props) {
</Combobox>
</div>
{selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="grid gap-2">
<Label className="text-sm font-medium">Cari Varian</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Ketik nama varian..."
value={variantSearch}
onChange={(e) => setVariantSearch(e.target.value)}
className="pl-8"
/>
</div>
</div>
{variantSearch && groupedVariants.length > 0 && (
<div className="space-y-4">
{groupedVariants.map((rm) => (
<div key={rm.id} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase">{rm.name} ({rm.unit})</p>
<div className="space-y-2">
{rm.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return (
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground">
Stok: {formatNumber(Number(price.stock))} {rm.unit} · {formatCurrency(price.price)}
{addedCount > 0 && ` · ×${addedCount}`}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(rm, price.id)}>
<Plus className="h-4 w-4" />
Tambah
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(rm.name, price.id)}>
<Layers className="h-4 w-4" />
Kombinasi
</Button>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{variantSearch && groupedVariants.length === 0 && (
<p className="text-sm text-muted-foreground">Tidak ada varian ditemukan.</p>
)}
{!variantSearch && selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label>
<div className="space-y-2">
{selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
@ -377,7 +448,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(price.id)}>
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(selectedMaterial, price.id)}>
<Plus className="h-4 w-4" />
Tambah
</Button>

View File

@ -1,7 +1,7 @@
'use no memo';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
@ -86,6 +86,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
);
const [selectedMaterialName, setSelectedMaterialName] = useState('');
const [variantSearch, setVariantSearch] = useState('');
const [productName, setProductName] = useState(cutting.product_name);
const [sample, setSample] = useState(cutting.sample);
@ -130,13 +131,22 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
[rawMaterials, selectedMaterialName],
);
const addVariant = useCallback(
(priceId: number) => {
if (!selectedMaterial) {
return;
}
const groupedVariants = useMemo(() => {
if (!variantSearch) return [];
const search = variantSearch.toLowerCase();
return rawMaterials
.map((rm) => ({
...rm,
raw_material_prices: rm.raw_material_prices.filter(
(p) => p.variant.toLowerCase().includes(search),
),
}))
.filter((rm) => rm.raw_material_prices.length > 0);
}, [rawMaterials, variantSearch]);
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
const addVariant = useCallback(
(rawMaterial: (typeof rawMaterials)[number], priceId: number) => {
const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) {
return;
@ -155,14 +165,14 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
material_result: 0,
combination_id: null,
variant: price.variant,
material_name: selectedMaterial.name,
unit: selectedMaterial.unit,
material_name: rawMaterial.name,
unit: rawMaterial.unit,
photo_url: price.photo_url,
},
];
});
},
[selectedMaterial],
[],
);
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
@ -328,9 +338,70 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
</Combobox>
</div>
{selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="grid gap-2">
<Label className="text-sm font-medium">Cari Varian</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Ketik nama varian..."
value={variantSearch}
onChange={(e) => setVariantSearch(e.target.value)}
className="pl-8"
/>
</div>
</div>
{variantSearch && groupedVariants.length > 0 && (
<div className="space-y-4">
{groupedVariants.map((rm) => (
<div key={rm.id} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase">{rm.name} ({rm.unit})</p>
<div className="space-y-2">
{rm.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return (
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground">
Stok: {formatNumber(Number(price.stock))} {rm.unit} · {formatCurrency(price.price)}
{addedCount > 0 && ` · ×${addedCount}`}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(rm, price.id)}>
<Plus className="h-4 w-4" />
Tambah
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(rm.name, price.id)}>
<Layers className="h-4 w-4" />
Kombinasi
</Button>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{variantSearch && groupedVariants.length === 0 && (
<p className="text-sm text-muted-foreground">Tidak ada varian ditemukan.</p>
)}
{!variantSearch && selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label>
<div className="space-y-2">
{selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
@ -353,7 +424,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(price.id)}>
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(selectedMaterial, price.id)}>
<Plus className="h-4 w-4" />
Tambah
</Button>