a5bbd1eb7c
- 옛 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>
611 lines
22 KiB
TypeScript
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";
|