- Added `updatePkgUnitItem` and `updateLoadingUnitPkg` functions to the packaging controller for updating package unit items and loading unit packages, respectively. - Implemented company code filtering to ensure data integrity based on user permissions. - Enhanced error handling for missing fields and data not found scenarios. - Updated the packaging routes to include new PUT endpoints for these update operations. (TASK: ERP-node-XXX)
195 lines
6.1 KiB
TypeScript
195 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* MultiCategorySelect
|
|
*
|
|
* 다중 선택(체크박스 팝오버) 카테고리 셀렉트 컴포넌트.
|
|
* - 외부 인터페이스: string (콤마 join). 내부에서 string ↔ array 변환 처리.
|
|
* - 옵션 5개 미만: 검색 input 숨김
|
|
* - 옵션 5개 이상: 검색 input 자동 노출
|
|
* - 선택된 항목: 트리거에 라벨 콤마 join 표시. 3개 초과 시 truncate + "+N개" 배지
|
|
*/
|
|
|
|
import React, { useState, useMemo, useEffect } from "react";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { ChevronsUpDown, Search as SearchIcon } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const SEARCH_THRESHOLD = 5;
|
|
const MAX_LABEL_DISPLAY = 3;
|
|
|
|
export interface MultiCategorySelectOption {
|
|
code: string;
|
|
label: string;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
interface MultiCategorySelectProps {
|
|
/** 콤마 join 문자열: "CODE1,CODE2" 형태 */
|
|
value: string;
|
|
onValueChange: (v: string) => void;
|
|
options: MultiCategorySelectOption[];
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
function parseValues(value: string): string[] {
|
|
if (!value) return [];
|
|
return value
|
|
.split(",")
|
|
.map((t) => t.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function joinValues(codes: string[]): string {
|
|
return codes.join(",");
|
|
}
|
|
|
|
export function MultiCategorySelect({
|
|
value,
|
|
onValueChange,
|
|
options,
|
|
placeholder = "선택",
|
|
disabled = false,
|
|
className,
|
|
}: MultiCategorySelectProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
|
|
// code가 비어있는 옵션 자동 제외
|
|
const safeOptions = useMemo(
|
|
() => options.filter((o) => o.code !== null && o.code !== undefined && o.code !== ""),
|
|
[options]
|
|
);
|
|
|
|
// 현재 선택된 코드 배열
|
|
const selectedCodes = useMemo(() => parseValues(value), [value]);
|
|
|
|
// 팝오버 닫힐 때 검색어 리셋
|
|
useEffect(() => {
|
|
if (!open) setSearch("");
|
|
}, [open]);
|
|
|
|
// 검색어 필터
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return safeOptions;
|
|
return safeOptions.filter((o) => o.label.toLowerCase().includes(q));
|
|
}, [safeOptions, search]);
|
|
|
|
// 트리거 표시 라벨
|
|
const triggerLabel = useMemo(() => {
|
|
if (selectedCodes.length === 0) return null;
|
|
const labels = selectedCodes
|
|
.map((code) => safeOptions.find((o) => o.code === code)?.label || code)
|
|
.filter(Boolean);
|
|
if (labels.length === 0) return null;
|
|
if (labels.length <= MAX_LABEL_DISPLAY) return labels.join(", ");
|
|
const shown = labels.slice(0, MAX_LABEL_DISPLAY).join(", ");
|
|
const rest = labels.length - MAX_LABEL_DISPLAY;
|
|
return (
|
|
<span className="flex items-center gap-1">
|
|
<span className="truncate">{shown}</span>
|
|
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-0 text-[10px] font-semibold text-primary">
|
|
+{rest}
|
|
</span>
|
|
</span>
|
|
);
|
|
}, [selectedCodes, safeOptions]);
|
|
|
|
const toggleCode = (code: string) => {
|
|
let next: string[];
|
|
if (selectedCodes.includes(code)) {
|
|
next = selectedCodes.filter((c) => c !== code);
|
|
} else {
|
|
next = [...selectedCodes, code];
|
|
}
|
|
onValueChange(joinValues(next));
|
|
};
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
className={cn("h-9 w-full justify-between font-normal", className)}
|
|
>
|
|
<span className="min-w-0 flex-1 overflow-hidden text-left text-sm">
|
|
{triggerLabel ?? (
|
|
<span className="text-muted-foreground">{placeholder}</span>
|
|
)}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
{safeOptions.length >= SEARCH_THRESHOLD && (
|
|
<div className="flex items-center border-b px-2">
|
|
<SearchIcon className="mr-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<Input
|
|
autoFocus
|
|
placeholder="검색..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="max-h-[280px] overflow-auto py-1">
|
|
{filtered.length === 0 ? (
|
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
검색 결과가 없습니다.
|
|
</div>
|
|
) : (
|
|
filtered.map((o) => {
|
|
const isChecked = selectedCodes.includes(o.code);
|
|
return (
|
|
<button
|
|
key={o.code}
|
|
type="button"
|
|
onClick={() => toggleCode(o.code)}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-accent",
|
|
isChecked && "bg-accent/40"
|
|
)}
|
|
>
|
|
<Checkbox
|
|
checked={isChecked}
|
|
onCheckedChange={() => toggleCode(o.code)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="pointer-events-none h-4 w-4 shrink-0"
|
|
/>
|
|
<span className="truncate">{o.label}</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
{selectedCodes.length > 0 && (
|
|
<div className="border-t px-3 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => onValueChange("")}
|
|
className="text-[11px] text-muted-foreground hover:text-destructive"
|
|
>
|
|
선택 초기화
|
|
</button>
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|