feat: [공통] 템플릿/UI 컴포넌트 보강
- IntegratedDetailTemplate 개선 - UniversalListPage 개선 - currency-input 컴포넌트 기능 확장 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -94,7 +94,9 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
} else {
|
||||
const defaultData: Record<string, unknown> = {};
|
||||
config.fields.forEach((field) => {
|
||||
if (field.type === 'checkbox') {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaultData[field.key] = field.defaultValue;
|
||||
} else if (field.type === 'checkbox') {
|
||||
defaultData[field.key] = false;
|
||||
} else {
|
||||
defaultData[field.key] = '';
|
||||
@@ -135,10 +137,12 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
: (initialData as Record<string, unknown>);
|
||||
setFormData(transformed);
|
||||
} else {
|
||||
// 기본값 설정
|
||||
// 기본값 설정 (field.defaultValue 우선 적용)
|
||||
const defaultData: Record<string, unknown> = {};
|
||||
config.fields.forEach((field) => {
|
||||
if (field.type === 'checkbox') {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaultData[field.key] = field.defaultValue;
|
||||
} else if (field.type === 'checkbox') {
|
||||
defaultData[field.key] = false;
|
||||
} else {
|
||||
defaultData[field.key] = '';
|
||||
|
||||
@@ -246,8 +246,8 @@ export function UniversalListPage<T>({
|
||||
|
||||
// 삭제 등으로 현재 페이지가 빈 페이지가 되면 마지막 유효 페이지로 이동
|
||||
useEffect(() => {
|
||||
if (totalPages > 0 && currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
if (currentPage > 1 && (totalPages === 0 || currentPage > totalPages)) {
|
||||
setCurrentPage(Math.max(1, totalPages));
|
||||
}
|
||||
}, [totalPages, currentPage]);
|
||||
|
||||
@@ -328,8 +328,9 @@ export function UniversalListPage<T>({
|
||||
}, []);
|
||||
|
||||
// initialData prop 변경 감지 (부모 컴포넌트에서 데이터 로드 후 전달하는 경우)
|
||||
// 삭제 후 빈 배열도 동기화해야 빈 페이지가 올바르게 표시됨
|
||||
useEffect(() => {
|
||||
if (initialData && initialData.length > 0) {
|
||||
if (initialData) {
|
||||
setRawData(initialData);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
@@ -23,10 +23,11 @@ export interface CurrencyInputProps
|
||||
* CurrencyInput - 금액 입력 전용 컴포넌트
|
||||
*
|
||||
* 특징:
|
||||
* - 항상 천단위 콤마 표시
|
||||
* - 입력 중에도 실시간 천단위 콤마 표시
|
||||
* - 정수만 허용 (소수점 없음)
|
||||
* - 통화 기호 표시 (₩, $, ¥, €)
|
||||
* - Leading zero 자동 제거
|
||||
* - 커서 위치 자동 보정
|
||||
*
|
||||
* @example
|
||||
* // 기본 (원화)
|
||||
@@ -57,6 +58,38 @@ const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
|
||||
) => {
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const [displayValue, setDisplayValue] = React.useState<string>("");
|
||||
const innerRef = React.useRef<HTMLInputElement>(null);
|
||||
const cursorPosRef = React.useRef<number | null>(null);
|
||||
|
||||
// 외부 ref와 내부 ref 결합
|
||||
const combinedRef = React.useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
innerRef.current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||
},
|
||||
[ref]
|
||||
);
|
||||
|
||||
// 렌더 후 커서 위치 복원
|
||||
React.useLayoutEffect(() => {
|
||||
if (cursorPosRef.current !== null && innerRef.current && isFocused) {
|
||||
innerRef.current.setSelectionRange(cursorPosRef.current, cursorPosRef.current);
|
||||
cursorPosRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 숫자를 콤마 포맷 문자열로 변환
|
||||
const formatWithCommas = (sanitized: string): string => {
|
||||
if (!sanitized || sanitized === "-") return sanitized || "";
|
||||
const isNeg = sanitized.startsWith("-");
|
||||
const digits = isNeg ? sanitized.slice(1) : sanitized;
|
||||
if (!digits) return sanitized;
|
||||
const num = parseInt(digits, 10);
|
||||
if (isNaN(num)) return "";
|
||||
const formatted = formatNumber(num, { useComma: true });
|
||||
return isNeg ? `-${formatted}` : formatted;
|
||||
};
|
||||
|
||||
// 외부 value 변경 시 displayValue 동기화
|
||||
React.useEffect(() => {
|
||||
@@ -111,37 +144,53 @@ const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = e.target.value;
|
||||
// 콤마 제거
|
||||
const cursorPos = e.target.selectionStart || 0;
|
||||
|
||||
// 커서 앞 숫자 개수 계산 (콤마 무시)
|
||||
const digitsBeforeCursor = rawValue.slice(0, cursorPos).replace(/[^\d]/g, "").length;
|
||||
|
||||
// 콤마 제거 후 정제
|
||||
const withoutComma = rawValue.replace(/,/g, "");
|
||||
const sanitized = sanitizeInput(withoutComma);
|
||||
|
||||
setDisplayValue(sanitized);
|
||||
|
||||
// 입력 중 "-"만 있으면 onChange 호출 안함
|
||||
if (sanitized === "-") return;
|
||||
if (sanitized === "-") {
|
||||
setDisplayValue(sanitized);
|
||||
cursorPosRef.current = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const numValue = parseValue(sanitized);
|
||||
onChange(numValue);
|
||||
|
||||
// 실시간 콤마 포맷
|
||||
const formatted = formatWithCommas(sanitized);
|
||||
setDisplayValue(formatted);
|
||||
|
||||
// 새 커서 위치 계산: 숫자 개수 기준으로 위치 복원
|
||||
let digitCount = 0;
|
||||
let newPos = formatted.length;
|
||||
for (let i = 0; i < formatted.length; i++) {
|
||||
if (/\d/.test(formatted[i])) {
|
||||
digitCount++;
|
||||
}
|
||||
if (digitCount === digitsBeforeCursor) {
|
||||
newPos = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cursorPosRef.current = newPos;
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(true);
|
||||
|
||||
// 포커스 시 콤마 제거된 순수 숫자로 표시
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (!isNaN(numValue)) {
|
||||
setDisplayValue(String(Math.floor(numValue)));
|
||||
}
|
||||
}
|
||||
|
||||
// 포커스 시에도 콤마 유지 (실시간 포맷)
|
||||
onFocus?.(e);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(false);
|
||||
|
||||
const sanitized = sanitizeInput(displayValue);
|
||||
const sanitized = sanitizeInput(displayValue.replace(/,/g, ""));
|
||||
const numValue = parseValue(sanitized);
|
||||
|
||||
onChange(numValue);
|
||||
@@ -193,7 +242,7 @@ const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
ref={ref}
|
||||
ref={combinedRef}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
|
||||
Reference in New Issue
Block a user