ERP-node/frontend/components/unified/UnifiedInput.tsx

453 lines
13 KiB
TypeScript

"use client";
/**
* UnifiedInput
*
* 통합 입력 컴포넌트
* - text: 텍스트 입력
* - number: 숫자 입력
* - password: 비밀번호 입력
* - slider: 슬라이더 입력
* - color: 색상 선택
* - button: 버튼 (입력이 아닌 액션)
*/
import React, { forwardRef, useCallback, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { UnifiedInputProps, UnifiedInputType, UnifiedInputFormat } from "@/types/unified-components";
// 형식별 입력 마스크 및 검증 패턴
const FORMAT_PATTERNS: Record<UnifiedInputFormat, { pattern: RegExp; placeholder: string }> = {
none: { pattern: /.*/, placeholder: "" },
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" },
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" },
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" },
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" },
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" },
};
// 통화 형식 변환
function formatCurrency(value: string | number): string {
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
if (isNaN(num)) return "";
return num.toLocaleString("ko-KR");
}
// 사업자번호 형식 변환
function formatBizNo(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length <= 3) return digits;
if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 10)}`;
}
// 전화번호 형식 변환
function formatTel(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length <= 3) return digits;
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
}
/**
* 텍스트 입력 컴포넌트
*/
const TextInput = forwardRef<HTMLInputElement, {
value?: string | number;
onChange?: (value: string) => void;
format?: UnifiedInputFormat;
mask?: string;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
className?: string;
}>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
// 형식에 따른 값 포맷팅
const formatValue = useCallback((val: string): string => {
switch (format) {
case "currency":
return formatCurrency(val);
case "biz_no":
return formatBizNo(val);
case "tel":
return formatTel(val);
default:
return val;
}
}, [format]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value;
// 형식에 따른 자동 포맷팅
if (format === "currency") {
// 숫자와 쉼표만 허용
newValue = newValue.replace(/[^\d,]/g, "");
newValue = formatCurrency(newValue);
} else if (format === "biz_no") {
newValue = formatBizNo(newValue);
} else if (format === "tel") {
newValue = formatTel(newValue);
}
onChange?.(newValue);
}, [format, onChange]);
const displayValue = useMemo(() => {
if (value === undefined || value === null) return "";
return formatValue(String(value));
}, [value, formatValue]);
const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder;
return (
<Input
ref={ref}
type="text"
value={displayValue}
onChange={handleChange}
placeholder={inputPlaceholder}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full", className)}
/>
);
});
TextInput.displayName = "TextInput";
/**
* 숫자 입력 컴포넌트
*/
const NumberInput = forwardRef<HTMLInputElement, {
value?: number;
onChange?: (value: number | undefined) => void;
min?: number;
max?: number;
step?: number;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
className?: string;
}>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val === "") {
onChange?.(undefined);
return;
}
let num = parseFloat(val);
// 범위 제한
if (min !== undefined && num < min) num = min;
if (max !== undefined && num > max) num = max;
onChange?.(num);
}, [min, max, onChange]);
return (
<Input
ref={ref}
type="number"
value={value ?? ""}
onChange={handleChange}
min={min}
max={max}
step={step}
placeholder={placeholder || "숫자 입력"}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full", className)}
/>
);
});
NumberInput.displayName = "NumberInput";
/**
* 비밀번호 입력 컴포넌트
*/
const PasswordInput = forwardRef<HTMLInputElement, {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
className?: string;
}>(({ value, onChange, placeholder, readonly, disabled, className }, ref) => {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="relative">
<Input
ref={ref}
type={showPassword ? "text" : "password"}
value={value ?? ""}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder || "비밀번호 입력"}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full pr-10", className)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs"
>
{showPassword ? "숨김" : "보기"}
</button>
</div>
);
});
PasswordInput.displayName = "PasswordInput";
/**
* 슬라이더 입력 컴포넌트
*/
const SliderInput = forwardRef<HTMLDivElement, {
value?: number;
onChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
className?: string;
}>(({ value, onChange, min = 0, max = 100, step = 1, disabled, className }, ref) => {
return (
<div ref={ref} className={cn("flex items-center gap-4", className)}>
<Slider
value={[value ?? min]}
onValueChange={(values) => onChange?.(values[0])}
min={min}
max={max}
step={step}
disabled={disabled}
className="flex-1"
/>
<span className="text-sm font-medium w-12 text-right">{value ?? min}</span>
</div>
);
});
SliderInput.displayName = "SliderInput";
/**
* 색상 선택 컴포넌트
*/
const ColorInput = forwardRef<HTMLInputElement, {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}>(({ value, onChange, disabled, className }, ref) => {
return (
<div className={cn("flex items-center gap-2", className)}>
<Input
ref={ref}
type="color"
value={value || "#000000"}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
className="w-12 h-full p-1 cursor-pointer"
/>
<Input
type="text"
value={value || "#000000"}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
className="h-full flex-1 uppercase"
maxLength={7}
/>
</div>
);
});
ColorInput.displayName = "ColorInput";
/**
* 여러 줄 텍스트 입력 컴포넌트
*/
const TextareaInput = forwardRef<HTMLTextAreaElement, {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
rows?: number;
readonly?: boolean;
disabled?: boolean;
className?: string;
}>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className }, ref) => {
return (
<textarea
ref={ref}
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
rows={rows}
readOnly={readonly}
disabled={disabled}
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
/>
);
});
TextareaInput.displayName = "TextareaInput";
/**
* 메인 UnifiedInput 컴포넌트
*/
export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>(
(props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
} = props;
// config가 없으면 기본값 사용
const config: UnifiedInputConfig = configProp || { type: "text" };
// 조건부 렌더링 체크
// TODO: conditional 처리 로직 추가
// 타입별 입력 컴포넌트 렌더링
const renderInput = () => {
const inputType = config.type || "text";
switch (inputType) {
case "text":
return (
<TextInput
value={value}
onChange={(v) => onChange?.(v)}
format={config.format}
mask={config.mask}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
case "number":
return (
<NumberInput
value={typeof value === "number" ? value : undefined}
onChange={(v) => onChange?.(v ?? 0)}
min={config.min}
max={config.max}
step={config.step}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
case "password":
return (
<PasswordInput
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
case "slider":
return (
<SliderInput
value={typeof value === "number" ? value : config.min ?? 0}
onChange={(v) => onChange?.(v)}
min={config.min}
max={config.max}
step={config.step}
disabled={disabled}
/>
);
case "color":
return (
<ColorInput
value={typeof value === "string" ? value : "#000000"}
onChange={(v) => onChange?.(v)}
disabled={disabled}
/>
);
case "textarea":
return (
<TextareaInput
value={value}
onChange={(v) => onChange?.(v)}
placeholder={config.placeholder}
rows={config.rows}
readonly={readonly}
disabled={disabled}
/>
);
default:
return (
<TextInput
value={value}
onChange={(v) => onChange?.(v)}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
}
};
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
const showLabel = label && style?.labelDisplay !== false;
// size에서 우선 가져오고, 없으면 style에서 가져옴
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderInput()}
</div>
</div>
);
}
);
UnifiedInput.displayName = "UnifiedInput";
export default UnifiedInput;