feat: Implement searchable category combobox in item info page

- Added a new CategoryCombobox component for improved category selection.
- Integrated the combobox into the item info form, replacing the previous select component.
- Enhanced data handling to support multiple category selections with comma separation.
- Updated selling and standard price fields to format numbers correctly.

These changes aim to enhance user experience by providing a more intuitive and flexible category selection method.
This commit is contained in:
kjs
2026-04-06 18:24:21 +09:00
parent 5e9544f31d
commit f2ebd6ae1b

View File

@@ -33,9 +33,11 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import { exportToExcel } from "@/lib/utils/excelExport";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
Pencil, Copy, Settings2,
Pencil, Copy, Settings2, Check, ChevronsUpDown,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -44,6 +46,43 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
// 검색 가능한 카테고리 콤보박스
function CategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selected = options.find((o) => o.code === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">{selected?.label || <span className="text-muted-foreground">{placeholder}</span>}</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => { onChange(opt.code); setOpen(false); }}>
<Check className={cn("mr-2 h-3.5 w-3.5", value === opt.code ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TABLE_NAME = "item_info";
const GRID_COLUMNS = [
@@ -55,8 +94,8 @@ const GRID_COLUMNS = [
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
{ key: "selling_price", label: "판매가격", align: "right" as const },
{ key: "standard_price", label: "기준단가", align: "right" as const },
{ key: "selling_price", label: "판매가격", align: "right" as const, formatNumber: true },
{ key: "standard_price", label: "기준단가", align: "right" as const, formatNumber: true },
{ key: "weight", label: "중량", align: "right" as const },
{ key: "inventory_unit", label: "재고단위" },
{ key: "user_type01", label: "대분류" },
@@ -163,6 +202,14 @@ export default function ItemInfoPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const resolve = (col: string, code: string) => {
if (!code) return "";
// 쉼표 구분 다중값 지원
if (code.includes(",")) {
return code.split(",").map((c) => {
const trimmed = c.trim();
if (!trimmed || trimmed === "s") return "";
return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed;
}).filter(Boolean).join(", ");
}
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => {
@@ -361,6 +408,7 @@ export default function ItemInfoPage() {
key: col.key,
label: col.label,
align: col.align as "left" | "center" | "right" | undefined,
formatNumber: (col as any).formatNumber,
}))}
data={ts.groupData(items)}
loading={loading}
@@ -394,21 +442,12 @@ export default function ItemInfoPage() {
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "category" ? (
<Select
<CategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder={`${field.label} 선택`} />
</SelectTrigger>
<SelectContent>
{(categoryOptions[field.key] || []).map((opt) => (
<SelectItem key={opt.code} value={opt.code}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
@@ -416,6 +455,16 @@ export default function ItemInfoPage() {
placeholder={field.label}
rows={3}
/>
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
onChange={(e) => {
const raw = e.target.value.replace(/[^\d.-]/g, "");
setFormData((prev) => ({ ...prev, [field.key]: raw }));
}}
placeholder={field.placeholder || field.label}
className="h-9 text-right"
/>
) : (
<Input
value={formData[field.key] || ""}