feat: [공통] 템플릿/UI 컴포넌트 보강

- IntegratedDetailTemplate 개선
- UniversalListPage 개선
- currency-input 컴포넌트 기능 확장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-27 12:29:40 +09:00
parent ea342a225c
commit d38f299c4b
3 changed files with 77 additions and 23 deletions

View File

@@ -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] = '';

View File

@@ -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]);

View File

@@ -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",