Files
invyone/frontend/lib/registry/components/input/select-pickers.tsx
T
gbpark a5bbd1eb7c refactor(numbering-rule): NumberingRule → Input canonical 흡수 + 채번 관리 페이지 분리
- 옛 registry/numbering-rule, registry/v2-numbering-rule, V2NumberingRuleConfigPanel,
  NumberingRuleTemplate 폐기 — InvFieldConfigPanel + InputComponent 로 통합
- input 에 numbering-picker / select-pickers 추가, autonum 타입 흡수
- 채번 관리 전용 admin 페이지(systemMng/numberingRuleList) + CreateDialog +
  SequenceManagementPanel 신설
- backend NumberingRule controller/service/mapper 갱신 (시퀀스 관리 엔드포인트)
- input canonical 진행 노트 + 채번 관리 mockup 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:42:13 +09:00

611 lines
22 KiB
TypeScript

"use client";
/**
* Input 컴포넌트의 select 계열 picker 묶음.
*
* 단계적 이식 (V2Select.tsx 1350줄 → 핵심만 InvField canonical 모델로):
* - SingleSelectPicker → InvField choice.single.list (드롭다운, 단일)
* - MultiSelectPicker → InvField choice.multi.list (TODO Phase B.2)
* - TagPicker → InvField choice.multi.tags (TODO Phase B.3)
*
* 외형 통일: outer wrapper transparent + container border 가 box 역할.
* 모든 picker 는 className prop 으로 border/bg override 가능.
*/
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, Search, Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SingleSelectPickerProps {
value?: string;
onChange?: (value: string) => void;
options: SelectOption[];
placeholder?: string;
searchable?: boolean;
allowClear?: boolean;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const SingleSelectPicker = React.forwardRef<HTMLDivElement, SingleSelectPickerProps>(
({ value, onChange, options, placeholder = "선택", searchable, allowClear, disabled, readonly, className }, ref) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const selected = options.find((o) => o.value === value);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (!wrapperRef.current?.contains(e.target as Node)) {
setOpen(false);
setSearchQuery("");
}
};
const escHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
setSearchQuery("");
}
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", escHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", escHandler);
};
}, [open]);
const filteredOptions =
searchable && searchQuery
? options.filter((o) => o.label.toLowerCase().includes(searchQuery.toLowerCase()))
: options;
const handleSelect = (v: string) => {
onChange?.(v);
setOpen(false);
setSearchQuery("");
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange?.("");
};
return (
<div
ref={wrapperRef}
className={cn("relative h-full w-full", (disabled || readonly) && "cursor-not-allowed", className)}
>
<div
ref={ref}
className={cn(
"flex h-full w-full cursor-pointer items-center gap-1 px-2 text-sm",
(disabled || readonly) && "cursor-not-allowed",
)}
onClick={() => {
if (!disabled && !readonly) setOpen((v) => !v);
}}
>
<span className={cn("flex-1 truncate", !selected && "text-muted-foreground")}>
{selected?.label || placeholder}
</span>
{allowClear && selected && !disabled && !readonly && (
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={handleClear}
>
</button>
)}
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
{open && (
<div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 max-h-64 overflow-auto rounded-md border shadow-md">
{searchable && (
<div className="border-border bg-popover sticky top-0 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="검색"
className="border-border bg-background h-7 w-full rounded border pr-2 pl-7 text-xs outline-none"
autoFocus
/>
</div>
</div>
)}
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : (
filteredOptions.map((opt) => (
<button
key={opt.value}
type="button"
disabled={opt.disabled}
className={cn(
"hover:bg-accent block w-full px-3 py-1.5 text-left text-sm",
value === opt.value && "bg-accent font-medium",
opt.disabled && "cursor-not-allowed opacity-50",
)}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
{opt.label}
</button>
))
)}
</div>
)}
</div>
);
},
);
SingleSelectPicker.displayName = "SingleSelectPicker";
// ─────────────────────────────────────────────────────────
// MultiSelectPicker — InvField choice.multi.list (다중 선택, 라벨 join 트리거)
// ─────────────────────────────────────────────────────────
interface MultiSelectPickerProps {
value?: string[];
onChange?: (values: string[]) => void;
options: SelectOption[];
placeholder?: string;
searchable?: boolean;
allowClear?: boolean;
maxSelect?: number;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const MultiSelectPicker = React.forwardRef<HTMLDivElement, MultiSelectPickerProps>(
(
{ value, onChange, options, placeholder = "선택", searchable, allowClear, maxSelect, disabled, readonly, className },
ref,
) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
// value 정규화 — undefined/string 방어
const selectedValues: string[] = Array.isArray(value) ? value : value ? [String(value)] : [];
const selectedOptions = options.filter((o) => selectedValues.includes(o.value));
const displayLabel = selectedOptions.length === 0 ? "" : selectedOptions.map((o) => o.label).join(", ");
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (!wrapperRef.current?.contains(e.target as Node)) {
setOpen(false);
setSearchQuery("");
}
};
const escHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
setSearchQuery("");
}
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", escHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", escHandler);
};
}, [open]);
const filteredOptions =
searchable && searchQuery
? options.filter((o) => o.label.toLowerCase().includes(searchQuery.toLowerCase()))
: options;
const handleToggle = (v: string) => {
if (disabled || readonly) return;
if (selectedValues.includes(v)) {
onChange?.(selectedValues.filter((x) => x !== v));
} else {
if (maxSelect && selectedValues.length >= maxSelect) return; // 차단
onChange?.([...selectedValues, v]);
}
};
const handleClearAll = (e: React.MouseEvent) => {
e.stopPropagation();
onChange?.([]);
};
return (
<div
ref={wrapperRef}
className={cn("relative h-full w-full", (disabled || readonly) && "cursor-not-allowed", className)}
>
<div
ref={ref}
className={cn(
"flex h-full w-full cursor-pointer items-center gap-1 px-2 text-sm",
(disabled || readonly) && "cursor-not-allowed",
)}
onClick={() => {
if (!disabled && !readonly) setOpen((v) => !v);
}}
>
<span className={cn("flex-1 truncate", selectedOptions.length === 0 && "text-muted-foreground")}>
{displayLabel || placeholder}
</span>
{allowClear && selectedOptions.length > 0 && !disabled && !readonly && (
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={handleClearAll}
>
</button>
)}
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
{open && (
<div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 max-h-64 overflow-auto rounded-md border shadow-md">
{searchable && (
<div className="border-border bg-popover sticky top-0 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="검색"
className="border-border bg-background h-7 w-full rounded border pr-2 pl-7 text-xs outline-none"
autoFocus
/>
</div>
</div>
)}
{maxSelect && (
<div className="text-muted-foreground border-border border-b px-3 py-1 text-[10px]">
{selectedValues.length} / {maxSelect}
</div>
)}
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : (
filteredOptions.map((opt) => {
const checked = selectedValues.includes(opt.value);
const blocked = !checked && !!maxSelect && selectedValues.length >= maxSelect;
return (
<button
key={opt.value}
type="button"
disabled={opt.disabled || blocked}
className={cn(
"hover:bg-accent flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
checked && "bg-accent/60",
(opt.disabled || blocked) && "cursor-not-allowed opacity-50 hover:bg-transparent",
)}
onClick={() => handleToggle(opt.value)}
>
<span
className={cn(
"border-border flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border",
checked && "border-primary bg-primary text-primary-foreground",
)}
>
{checked && <Check className="h-3 w-3" />}
</span>
<span className="flex-1 truncate">{opt.label}</span>
</button>
);
})
)}
</div>
)}
</div>
);
},
);
MultiSelectPicker.displayName = "MultiSelectPicker";
// ─────────────────────────────────────────────────────────
// RadioPicker — single 의 displayMode=radio (옵션을 라디오 button list)
// ─────────────────────────────────────────────────────────
interface RadioPickerProps {
value?: string;
onChange?: (value: string) => void;
options: SelectOption[];
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const RadioPicker = React.forwardRef<HTMLDivElement, RadioPickerProps>(
({ value, onChange, options, disabled, readonly, className }, ref) => {
return (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-wrap content-start items-start gap-x-3 gap-y-1 px-2 py-1 text-sm",
(disabled || readonly) && "cursor-not-allowed",
className,
)}
>
{options.length === 0 ? (
<span className="text-muted-foreground text-xs"> </span>
) : (
options.map((opt) => {
const checked = value === opt.value;
const optDisabled = disabled || readonly || opt.disabled;
return (
<label
key={opt.value}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5",
optDisabled && "cursor-not-allowed",
)}
>
<input
type="radio"
checked={checked}
disabled={optDisabled}
onChange={() => !optDisabled && onChange?.(opt.value)}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="select-none">{opt.label}</span>
</label>
);
})
)}
</div>
);
},
);
RadioPicker.displayName = "RadioPicker";
// ─────────────────────────────────────────────────────────
// CheckboxListPicker — multi 의 displayMode=checkbox (옵션을 체크박스 list)
// ─────────────────────────────────────────────────────────
interface CheckboxListPickerProps {
value?: string[];
onChange?: (values: string[]) => void;
options: SelectOption[];
maxSelect?: number;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const CheckboxListPicker = React.forwardRef<HTMLDivElement, CheckboxListPickerProps>(
({ value, onChange, options, maxSelect, disabled, readonly, className }, ref) => {
const selectedValues: string[] = Array.isArray(value) ? value : value ? [String(value)] : [];
const handleToggle = (v: string) => {
if (disabled || readonly) return;
if (selectedValues.includes(v)) {
onChange?.(selectedValues.filter((x) => x !== v));
} else {
if (maxSelect && selectedValues.length >= maxSelect) return;
onChange?.([...selectedValues, v]);
}
};
return (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-wrap content-start items-start gap-x-3 gap-y-1 px-2 py-1 text-sm",
(disabled || readonly) && "cursor-not-allowed",
className,
)}
>
{options.length === 0 ? (
<span className="text-muted-foreground text-xs"> </span>
) : (
options.map((opt) => {
const checked = selectedValues.includes(opt.value);
const blocked = !checked && !!maxSelect && selectedValues.length >= maxSelect;
const optDisabled = disabled || readonly || opt.disabled || blocked;
return (
<label
key={opt.value}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5",
optDisabled && "cursor-not-allowed",
)}
>
<input
type="checkbox"
checked={checked}
disabled={optDisabled}
onChange={() => !optDisabled && handleToggle(opt.value)}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="select-none">{opt.label}</span>
</label>
);
})
)}
</div>
);
},
);
CheckboxListPicker.displayName = "CheckboxListPicker";
// ─────────────────────────────────────────────────────────
// TogglePicker — single.boolean 의 mode=toggle (Y/N 토글 스위치)
// value: boolean | "Y"/"N" | "true"/"false" | 1/0 모두 받음
// ─────────────────────────────────────────────────────────
interface TogglePickerProps {
value?: unknown;
onChange?: (value: boolean) => void;
trueLabel?: string;
falseLabel?: string;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
function isTruthy(v: unknown): boolean {
if (typeof v === "boolean") return v;
if (typeof v === "number") return v !== 0;
if (typeof v === "string") {
const s = v.toLowerCase();
return s === "true" || s === "y" || s === "yes" || s === "1";
}
return false;
}
export const TogglePicker = React.forwardRef<HTMLDivElement, TogglePickerProps>(
({ value, onChange, trueLabel = "예", falseLabel = "아니오", disabled, readonly, className }, ref) => {
const checked = isTruthy(value);
const lockClick = !!disabled || !!readonly;
return (
<div
ref={ref}
className={cn(
"flex h-full w-full items-center gap-2 px-2 text-sm",
lockClick && "cursor-not-allowed",
className,
)}
>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={lockClick}
onClick={() => !lockClick && onChange?.(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none",
checked ? "bg-primary" : "bg-muted",
lockClick ? "cursor-not-allowed" : "cursor-pointer",
)}
>
<span
className={cn(
"bg-background inline-block h-4 w-4 transform rounded-full shadow transition-transform",
checked ? "translate-x-[18px]" : "translate-x-[2px]",
)}
/>
</button>
<span className="text-foreground select-none">{checked ? trueLabel : falseLabel}</span>
</div>
);
},
);
TogglePicker.displayName = "TogglePicker";
// ─────────────────────────────────────────────────────────
// TagPicker — multi.tags (chip 입력. Enter / 구분자 로 추가, Backspace / ✕ 로 제거)
// ─────────────────────────────────────────────────────────
interface TagPickerProps {
value?: string[];
onChange?: (values: string[]) => void;
placeholder?: string;
maxSelect?: number;
/** 자동 분리 키. ',' 또는 ' ' 등 */
separator?: string;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const TagPicker = React.forwardRef<HTMLDivElement, TagPickerProps>(
(
{
value,
onChange,
placeholder = "태그 입력 후 Enter",
maxSelect,
separator,
disabled,
readonly,
className,
},
ref,
) => {
const [input, setInput] = useState("");
const tags: string[] = Array.isArray(value) ? value : [];
const lockEdit = !!disabled || !!readonly;
const addTag = (tag: string) => {
const trimmed = tag.trim();
if (!trimmed) return;
if (tags.includes(trimmed)) return;
if (maxSelect && tags.length >= maxSelect) return;
onChange?.([...tags, trimmed]);
};
const removeTag = (idx: number) => {
onChange?.(tags.filter((_, i) => i !== idx));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || (separator && e.key === separator)) {
e.preventDefault();
if (input.trim()) {
addTag(input);
setInput("");
}
} else if (e.key === "Backspace" && input === "" && tags.length > 0) {
removeTag(tags.length - 1);
}
};
return (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-wrap items-center gap-1 px-2 py-1 text-sm",
lockEdit && "cursor-not-allowed",
className,
)}
>
{tags.map((tag, i) => (
<span
key={i}
className="bg-accent text-accent-foreground inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs"
>
<span>{tag}</span>
{!lockEdit && (
<button
type="button"
onClick={() => removeTag(i)}
className="text-muted-foreground hover:text-foreground leading-none"
aria-label={`${tag} 제거`}
>
</button>
)}
</span>
))}
{!lockEdit && (!maxSelect || tags.length < maxSelect) && (
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={tags.length === 0 ? placeholder : ""}
className="min-w-[80px] flex-1 bg-transparent text-sm outline-none"
disabled={lockEdit}
/>
)}
</div>
);
},
);
TagPicker.displayName = "TagPicker";